fix: use correct client ip header
This commit is contained in:
@@ -41,7 +41,6 @@
|
||||
"groupmq": "1.0.0-next.19",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"ramda": "^0.29.1",
|
||||
"request-ip": "^3.3.0",
|
||||
"sharp": "^0.33.5",
|
||||
"source-map-support": "^0.5.21",
|
||||
"sqlstring": "^2.3.3",
|
||||
@@ -58,7 +57,6 @@
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/ramda": "^0.30.2",
|
||||
"@types/request-ip": "^0.0.41",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"@types/sqlstring": "^2.3.2",
|
||||
"@types/uuid": "^10.0.0",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getClientIp } from '@/utils/get-client-ip';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
||||
@@ -21,7 +20,7 @@ export async function postEvent(
|
||||
request.timestamp,
|
||||
request.body,
|
||||
);
|
||||
const ip = getClientIp(request)!;
|
||||
const ip = request.clientIp;
|
||||
const ua = request.headers['user-agent']!;
|
||||
const projectId = request.client?.projectId;
|
||||
const headers = getStringHeaders(request.headers);
|
||||
|
||||
@@ -4,9 +4,12 @@ import { parseUrlMeta } from '@/utils/parseUrlMeta';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { getClientIp } from '@/utils/get-client-ip';
|
||||
import {
|
||||
DEFAULT_HEADER_ORDER,
|
||||
getClientIpFromHeaders,
|
||||
} from '@openpanel/common/server/get-client-ip';
|
||||
import { TABLE_NAMES, ch, chQuery, formatClickhouseDate } from '@openpanel/db';
|
||||
import { getGeoLocation } from '@openpanel/geo';
|
||||
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
||||
import { getCache, getRedisCache } from '@openpanel/redis';
|
||||
|
||||
interface GetFaviconParams {
|
||||
@@ -394,12 +397,35 @@ export async function stats(request: FastifyRequest, reply: FastifyReply) {
|
||||
}
|
||||
|
||||
export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
|
||||
const ip = getClientIp(request);
|
||||
const ip = getClientIpFromHeaders(request.headers);
|
||||
const others = await Promise.all(
|
||||
DEFAULT_HEADER_ORDER.map(async (header) => {
|
||||
const ip = getClientIpFromHeaders(request.headers, header);
|
||||
return {
|
||||
header,
|
||||
ip,
|
||||
geo: await getGeoLocation(ip),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (!ip) {
|
||||
return reply.status(400).send('Bad Request');
|
||||
}
|
||||
const geo = await getGeoLocation(ip);
|
||||
return reply.status(200).send(geo);
|
||||
return reply.status(200).send({
|
||||
selected: {
|
||||
geo,
|
||||
ip,
|
||||
},
|
||||
...others.reduce(
|
||||
(acc, other) => {
|
||||
acc[other.header] = other;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { ip: string; geo: GeoLocation }>,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOgImage(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getClientIp } from '@/utils/get-client-ip';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { assocPath, pathOr } from 'ramda';
|
||||
|
||||
@@ -22,7 +21,7 @@ export async function updateProfile(
|
||||
if (!projectId) {
|
||||
return reply.status(400).send('No projectId');
|
||||
}
|
||||
const ip = getClientIp(request)!;
|
||||
const ip = request.clientIp;
|
||||
const ua = request.headers['user-agent']!;
|
||||
const uaInfo = parseUserAgent(ua, properties);
|
||||
const geo = await getGeoLocation(ip);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getClientIp } from '@/utils/get-client-ip';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { path, assocPath, pathOr, pick } from 'ramda';
|
||||
|
||||
@@ -91,7 +90,7 @@ export async function handler(
|
||||
const timestamp = getTimestamp(request.timestamp, request.body.payload);
|
||||
const ip =
|
||||
path<string>(['properties', '__ip'], request.body.payload) ||
|
||||
getClientIp(request)!;
|
||||
request.clientIp;
|
||||
const ua = request.headers['user-agent']!;
|
||||
const projectId = request.client?.projectId;
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { getClientIp } from '@/utils/get-client-ip';
|
||||
import type {
|
||||
FastifyReply,
|
||||
FastifyRequest,
|
||||
HookHandlerDoneFunction,
|
||||
} from 'fastify';
|
||||
import { getClientIpFromHeaders } from '@openpanel/common/server/get-client-ip';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export async function ipHook(request: FastifyRequest) {
|
||||
const ip = getClientIp(request);
|
||||
const ip = getClientIpFromHeaders(request.headers);
|
||||
|
||||
if (ip) {
|
||||
request.clientIp = ip;
|
||||
} else {
|
||||
request.clientIp = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ process.env.TZ = 'UTC';
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
client: IServiceClientWithProject | null;
|
||||
clientIp?: string;
|
||||
clientIp: string;
|
||||
timestamp?: number;
|
||||
session: SessionValidationResult;
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import requestIp from 'request-ip';
|
||||
|
||||
const ignore = ['127.0.0.1', '::1'];
|
||||
|
||||
export function getClientIp(req: FastifyRequest) {
|
||||
return requestIp.getClientIp(req);
|
||||
}
|
||||
67
apps/public/app/api/[...op]/route.ts
Normal file
67
apps/public/app/api/[...op]/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { getClientIpFromHeaders } from '@openpanel/common/server/get-client-ip';
|
||||
// adding .js next/script import fixes an issues
|
||||
// with esm and nextjs (when using pages dir)
|
||||
import { NextResponse } from 'next/server.js';
|
||||
|
||||
type CreateNextRouteHandlerOptions = {
|
||||
apiUrl?: string;
|
||||
};
|
||||
|
||||
function createNextRouteHandler(options: CreateNextRouteHandlerOptions) {
|
||||
return async function POST(req: Request) {
|
||||
const apiUrl = options.apiUrl ?? 'https://api.openpanel.dev';
|
||||
const headers = new Headers(req.headers);
|
||||
const clientIp = getClientIpFromHeaders(headers);
|
||||
console.log('debug', {
|
||||
clientIp,
|
||||
userAgent: req.headers.get('user-agent'),
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/track`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(await req.json()),
|
||||
});
|
||||
return NextResponse.json(await res.text(), { status: res.status });
|
||||
} catch (e) {
|
||||
return NextResponse.json(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createScriptHandler() {
|
||||
return async function GET(req: Request) {
|
||||
if (!req.url.endsWith('op1.js')) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const scriptUrl = 'https://openpanel.dev/op1.js';
|
||||
try {
|
||||
const res = await fetch(scriptUrl, {
|
||||
next: { revalidate: 86400 },
|
||||
});
|
||||
const text = await res.text();
|
||||
const etag = `"${createHash('md5').update(text).digest('hex')}"`;
|
||||
return new NextResponse(text, {
|
||||
headers: {
|
||||
'Content-Type': 'text/javascript',
|
||||
'Cache-Control':
|
||||
'public, max-age=86400, stale-while-revalidate=86400',
|
||||
ETag: etag,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to fetch script',
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const POST = createNextRouteHandler({});
|
||||
export const GET = createScriptHandler();
|
||||
@@ -61,12 +61,9 @@ export default async function Layout({ children }: { children: ReactNode }) {
|
||||
<RootProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</RootProvider>
|
||||
<Script
|
||||
defer
|
||||
src="http://localhost:3000/script.js"
|
||||
data-website-id="44d65df1-e9cb-4c2c-917d-4bf1c7850948"
|
||||
/>
|
||||
<OpenPanelComponent
|
||||
apiUrl="/api/op"
|
||||
cdnUrl="/api/op/op1.js"
|
||||
clientId="301c6dc1-424c-4bc3-9886-a8beab09b615"
|
||||
trackAttributes
|
||||
trackScreenViews
|
||||
|
||||
@@ -273,19 +273,21 @@ export function GET() {
|
||||
|
||||
### Proxy events
|
||||
|
||||
With `createNextRouteHandler` you can proxy your events through your server, this will ensure all events are tracked since there is a lot of adblockers that block requests to third party domains.
|
||||
With `createNextRouteHandler` you can proxy your events through your server, this will ensure all events are tracked since there is a lot of adblockers that block requests to third party domains. You'll also need to either host our tracking script or you can use `createScriptHandler` function which proxies this as well.
|
||||
|
||||
```typescript title="/app/api/[...op]/route.ts"
|
||||
import { createNextRouteHandler } from '@openpanel/nextjs/server';
|
||||
import { createNextRouteHandler, createScriptHandler } from '@openpanel/nextjs/server';
|
||||
|
||||
export const POST = createNextRouteHandler();
|
||||
export const GET = createScriptHandler()
|
||||
```
|
||||
|
||||
Remember to change the `apiUrl` in the `OpenPanelComponent` to your own server.
|
||||
Remember to change the `apiUrl` and `cdnUrl` in the `OpenPanelComponent` to your own server.
|
||||
|
||||
```tsx
|
||||
<OpenPanelComponent
|
||||
apiUrl="/api/op" // [!code highlight]
|
||||
cdnUrl="/api/op/op1.js" // [!code highlight]
|
||||
clientId="your-client-id"
|
||||
trackScreenViews={true}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"@hyperdx/node-opentelemetry": "^0.8.1",
|
||||
"@number-flow/react": "0.3.5",
|
||||
"@openpanel/common": "workspace:*",
|
||||
"@openpanel/nextjs": "^1.0.5",
|
||||
"@openpanel/payments": "workspace:^",
|
||||
"@openpanel/sdk-info": "workspace:^",
|
||||
|
||||
@@ -9,3 +9,4 @@ export * from './src/url';
|
||||
export * from './src/id';
|
||||
export * from './src/get-previous-metric';
|
||||
export * from './src/group-by-labels';
|
||||
export * from './server/get-client-ip';
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"main": "index.ts",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./server": "./server/index.ts"
|
||||
"./server": "./server/index.ts",
|
||||
"./server/get-client-ip": "./server/get-client-ip.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
|
||||
86
packages/common/server/get-client-ip.ts
Normal file
86
packages/common/server/get-client-ip.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Get client IP from headers
|
||||
*
|
||||
* Can be configured via IP_HEADER_ORDER env variable
|
||||
* Example: IP_HEADER_ORDER="cf-connecting-ip,x-real-ip,x-forwarded-for"
|
||||
*/
|
||||
|
||||
export const DEFAULT_HEADER_ORDER = [
|
||||
'cf-connecting-ip',
|
||||
'true-client-ip',
|
||||
'x-real-ip',
|
||||
'x-client-ip',
|
||||
'fastly-client-ip',
|
||||
'x-cluster-client-ip',
|
||||
'x-appengine-user-ip',
|
||||
'do-connecting-ip',
|
||||
'x-nf-client-connection-ip',
|
||||
'x-forwarded-for',
|
||||
'x-forwarded',
|
||||
'forwarded',
|
||||
];
|
||||
|
||||
function getHeaderOrder(): string[] {
|
||||
if (typeof process !== 'undefined' && process.env?.IP_HEADER_ORDER) {
|
||||
return process.env.IP_HEADER_ORDER.split(',').map((h) => h.trim());
|
||||
}
|
||||
return DEFAULT_HEADER_ORDER;
|
||||
}
|
||||
|
||||
function isValidIp(ip: string): boolean {
|
||||
// Basic IP validation
|
||||
const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
const ipv6 = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
|
||||
return ipv4.test(ip) || ipv6.test(ip);
|
||||
}
|
||||
|
||||
export function getClientIpFromHeaders(
|
||||
headers: Record<string, string | string[] | undefined> | Headers,
|
||||
overrideHeaderName?: string,
|
||||
): string {
|
||||
let headerOrder = getHeaderOrder();
|
||||
|
||||
if (overrideHeaderName) {
|
||||
headerOrder = [overrideHeaderName];
|
||||
}
|
||||
|
||||
for (const headerName of headerOrder) {
|
||||
let value: string | null = null;
|
||||
|
||||
// Get header value
|
||||
if (headers instanceof Headers) {
|
||||
value = headers.get(headerName);
|
||||
} else {
|
||||
const headerValue = headers[headerName];
|
||||
if (Array.isArray(headerValue)) {
|
||||
value = headerValue[0] || null;
|
||||
} else {
|
||||
value = headerValue || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!value) continue;
|
||||
|
||||
// Handle x-forwarded-for (comma separated)
|
||||
if (headerName === 'x-forwarded-for') {
|
||||
const firstIp = value.split(',')[0]?.trim();
|
||||
if (firstIp && isValidIp(firstIp)) {
|
||||
return firstIp;
|
||||
}
|
||||
}
|
||||
// Handle forwarded header (RFC 7239)
|
||||
else if (headerName === 'forwarded') {
|
||||
const match = value.match(/for=(?:"?\[?([^\]"]+)\]?"?)/i);
|
||||
const ip = match?.[1];
|
||||
if (ip && isValidIp(ip)) {
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
// Regular headers
|
||||
else if (isValidIp(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getClientIpFromHeaders } from '@openpanel/common/server/get-client-ip';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { getClientIp } from 'request-ip';
|
||||
|
||||
import type { OpenPanelOptions } from '@openpanel/sdk';
|
||||
import { OpenPanel } from '@openpanel/sdk';
|
||||
@@ -22,7 +22,7 @@ export type OpenpanelOptions = OpenPanelOptions & {
|
||||
export default function createMiddleware(options: OpenpanelOptions) {
|
||||
return function middleware(req: Request, res: Response, next: NextFunction) {
|
||||
const sdk = new OpenPanel(options);
|
||||
const ip = getClientIp(req);
|
||||
const ip = getClientIpFromHeaders(req.headers);
|
||||
if (ip) {
|
||||
sdk.api.addHeader('x-client-ip', ip);
|
||||
}
|
||||
@@ -30,20 +30,15 @@ export default function createMiddleware(options: OpenpanelOptions) {
|
||||
sdk.api.addHeader('x-user-agent', req.headers['user-agent'] as string);
|
||||
}
|
||||
|
||||
if (options.getProfileId) {
|
||||
const profileId = options.getProfileId(req);
|
||||
if (profileId) {
|
||||
sdk.identify({
|
||||
profileId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (options.trackRequest?.(req.url)) {
|
||||
const profileId = options.getProfileId
|
||||
? options.getProfileId(req)
|
||||
: undefined;
|
||||
sdk.track('request', {
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
query: req.query,
|
||||
profileId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@openpanel/sdk": "workspace:1.0.0-local",
|
||||
"request-ip": "^3.3.0"
|
||||
"@openpanel/common": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": "^4.17.0 || ^5.0.0"
|
||||
|
||||
@@ -8,4 +8,6 @@ export default defineConfig({
|
||||
sourcemap: false,
|
||||
clean: true,
|
||||
minify: true,
|
||||
// Bundle @openpanel/common into the output instead of treating it as external
|
||||
noExternal: ['@openpanel/common'],
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
// adding .js next/script import fixes an issues
|
||||
// with esm and nextjs (when using pages dir)
|
||||
import { NextResponse } from 'next/server.js';
|
||||
@@ -16,9 +17,43 @@ export function createNextRouteHandler(options: CreateNextRouteHandlerOptions) {
|
||||
headers,
|
||||
body: JSON.stringify(await req.json()),
|
||||
});
|
||||
return NextResponse.json(await res.text());
|
||||
return NextResponse.json(await res.text(), { status: res.status });
|
||||
} catch (e) {
|
||||
return NextResponse.json(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createScriptHandler() {
|
||||
return async function GET(req: Request) {
|
||||
if (!req.url.endsWith('op1.js')) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const scriptUrl = 'https://openpanel.dev/op1.js';
|
||||
try {
|
||||
const res = await fetch(scriptUrl, {
|
||||
// @ts-expect-error
|
||||
next: { revalidate: 86400 },
|
||||
});
|
||||
const text = await res.text();
|
||||
const etag = `"${createHash('md5').update(text).digest('hex')}"`;
|
||||
return new NextResponse(text, {
|
||||
headers: {
|
||||
'Content-Type': 'text/javascript',
|
||||
'Cache-Control':
|
||||
'public, max-age=86400, stale-while-revalidate=86400',
|
||||
ETag: etag,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to fetch script',
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@@ -198,9 +198,6 @@ importers:
|
||||
ramda:
|
||||
specifier: ^0.29.1
|
||||
version: 0.29.1
|
||||
request-ip:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0
|
||||
sharp:
|
||||
specifier: ^0.33.5
|
||||
version: 0.33.5
|
||||
@@ -277,6 +274,9 @@ importers:
|
||||
'@number-flow/react':
|
||||
specifier: 0.3.5
|
||||
version: 0.3.5(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@openpanel/common':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/common
|
||||
'@openpanel/nextjs':
|
||||
specifier: ^1.0.5
|
||||
version: 1.0.5(next@15.0.3(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
@@ -1410,15 +1410,15 @@ importers:
|
||||
|
||||
packages/sdks/express:
|
||||
dependencies:
|
||||
'@openpanel/common':
|
||||
specifier: workspace:*
|
||||
version: link:../../common
|
||||
'@openpanel/sdk':
|
||||
specifier: workspace:1.0.0-local
|
||||
version: link:../sdk
|
||||
express:
|
||||
specifier: ^4.17.0 || ^5.0.0
|
||||
version: 4.19.2
|
||||
request-ip:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0
|
||||
devDependencies:
|
||||
'@openpanel/tsconfig':
|
||||
specifier: workspace:*
|
||||
@@ -14604,9 +14604,6 @@ packages:
|
||||
remove-trailing-slash@0.1.1:
|
||||
resolution: {integrity: sha512-o4S4Qh6L2jpnCy83ysZDau+VORNvnFw07CKSAymkd6ICNVEPisMyzlc00KlvvicsxKck94SEwhDnMNdICzO+tA==}
|
||||
|
||||
request-ip@3.3.0:
|
||||
resolution: {integrity: sha512-cA6Xh6e0fDBBBwH77SLJaJPBmD3nWVAcF9/XAcsrIHdjhFzFiB5aNQFytdjCGPezU3ROwrR11IddKAM08vohxA==}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -32008,8 +32005,6 @@ snapshots:
|
||||
|
||||
remove-trailing-slash@0.1.1: {}
|
||||
|
||||
request-ip@3.3.0: {}
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
Reference in New Issue
Block a user