diff --git a/app/hosts/[hostname]/components/ScreenshotsTab.tsx b/app/hosts/[hostname]/components/ScreenshotsTab.tsx index 34bd984..21b98c8 100644 --- a/app/hosts/[hostname]/components/ScreenshotsTab.tsx +++ b/app/hosts/[hostname]/components/ScreenshotsTab.tsx @@ -851,6 +851,26 @@ export default function ScreenshotsTab({ + +
+

来源 IP

+
+
+
+
IP 地址
+
+ {selectedRecord.ip || '未记录'} +
+
+
+
归属地
+
+ {selectedRecord.ipLocation || (selectedRecord.ip ? '未知归属地' : '未记录')} +
+
+
+
+
)} diff --git a/app/hosts/[hostname]/screenshots/route.ts b/app/hosts/[hostname]/screenshots/route.ts index c18e18b..a747a7b 100644 --- a/app/hosts/[hostname]/screenshots/route.ts +++ b/app/hosts/[hostname]/screenshots/route.ts @@ -4,6 +4,8 @@ import { storeFile } from '@/lib/fileStorage' import { push } from '@/lib/push' import { withCors } from '@/lib/middleware' import { filterIgnoredWindows } from '@/lib/windowFilters' +import { getClientIp } from '@/lib/clientIp' +import { getIpGeodataLabel } from '@/lib/geodata' import ffmpeg from 'fluent-ffmpeg' import { writeFileSync, unlinkSync, mkdtempSync, readFileSync, rmSync } from 'fs' import { tmpdir } from 'os' @@ -27,6 +29,8 @@ async function handleScreenshotUpload(req: NextRequest) { return NextResponse.json({ error: '缺少主机名' }, { status: 400 }) } + const clientIp = getClientIp(req) + const formData = await req.formData() const files: File[] = [] const rawWindowsInfo: WindowInfo[] = JSON.parse(formData.get('windows_info') as string || '[]') @@ -127,6 +131,7 @@ async function handleScreenshotUpload(req: NextRequest) { const newRecord = await prisma.record.create({ data: { hostname, + ip: clientIp, timestamp: new Date(), windows: { create: windowsInfo @@ -226,6 +231,7 @@ async function handleGetScreenshots(req: NextRequest) { select: { id: true, timestamp: true, + ip: true, isStarred: true, windows: { select: { @@ -262,6 +268,7 @@ async function handleGetScreenshots(req: NextRequest) { // Convert BigInt to string in windows.memory field const serializedRecords = records.map(record => ({ ...record, + ipLocation: getIpGeodataLabel(record.ip), windows: record.windows.map(window => ({ ...window, memory: window.memory.toString() diff --git a/app/hosts/[hostname]/starred/route.ts b/app/hosts/[hostname]/starred/route.ts index 390e7a6..9f568a6 100644 --- a/app/hosts/[hostname]/starred/route.ts +++ b/app/hosts/[hostname]/starred/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { withCors } from '@/lib/middleware' +import { getIpGeodataLabel } from '@/lib/geodata' // 获取指定主机的星标记录 async function handleGetStarredRecords(req: NextRequest) { @@ -49,6 +50,7 @@ async function handleGetStarredRecords(req: NextRequest) { }) const serializedRecords = records.map(record => ({ ...record, + ipLocation: getIpGeodataLabel(record.ip), windows: record.windows.map(window => ({ ...window, memory: window.memory.toString() diff --git a/app/hosts/[hostname]/types.ts b/app/hosts/[hostname]/types.ts index 86780fb..4939b0d 100644 --- a/app/hosts/[hostname]/types.ts +++ b/app/hosts/[hostname]/types.ts @@ -13,6 +13,8 @@ export interface Window { export interface ScreenRecord { id: string; timestamp: string; + ip: string | null; + ipLocation: string | null; isStarred: boolean; windows: Window[]; screenshots: Screenshot[]; diff --git a/bun.lock b/bun.lock index be8d077..0e78d2f 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "date-fns": "^4.1.0", "dotenv-cli": "^8.0.0", "fluent-ffmpeg": "^2.1.3", + "ip2region": "^2.3.0", "lucide-react": "^0.525.0", "minio": "^8.0.5", "multer": "^2.0.1", @@ -381,6 +382,8 @@ "internmap": ["internmap@2.0.3", "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "ip2region": ["ip2region@2.3.0", "https://registry.npmmirror.com/ip2region/-/ip2region-2.3.0.tgz", { "peerDependencies": { "@types/node": "*" } }, "sha512-zV5Xsadzrx9Ej6heoyhbXMsfGWWQ3C6bAIYStrHhw9kzLpGpVNlnAyRBxxPgxA1GNqr1Ti7oUxcWsMWNN3jZBg=="], + "ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="], "is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="], diff --git a/lib/clientIp.ts b/lib/clientIp.ts new file mode 100644 index 0000000..517535a --- /dev/null +++ b/lib/clientIp.ts @@ -0,0 +1,148 @@ +import { isIP } from 'net' +import type { NextRequest } from 'next/server' + +const DIRECT_IP_HEADERS = [ + 'cf-connecting-ip', + 'true-client-ip', + 'fly-client-ip', + 'fastly-client-ip' +] + +const FALLBACK_IP_HEADERS = [ + 'x-real-ip', + 'x-client-ip' +] + +const LIST_IP_HEADERS = [ + 'x-forwarded-for', + 'x-original-forwarded-for' +] + +export function getClientIp(req: NextRequest) { + return getClientIpFromHeaders(req.headers) +} + +export function getClientIpFromHeaders(headers: Headers) { + const candidates: string[] = [] + + for (const header of DIRECT_IP_HEADERS) { + const value = headers.get(header) + if (value) { + candidates.push(value) + } + } + + const forwarded = headers.get('forwarded') + if (forwarded) { + candidates.push(...parseForwardedHeader(forwarded)) + } + + for (const header of LIST_IP_HEADERS) { + const value = headers.get(header) + if (value) { + candidates.push(...value.split(',')) + } + } + + for (const header of FALLBACK_IP_HEADERS) { + const value = headers.get(header) + if (value) { + candidates.push(value) + } + } + + for (const candidate of candidates) { + const ip = normalizeIpCandidate(candidate) + if (ip) { + return ip + } + } + + return null +} + +export function normalizeIpCandidate(value?: string | null) { + if (!value) { + return null + } + + let candidate = value.trim() + if (!candidate || candidate.toLowerCase() === 'unknown') { + return null + } + + if (candidate.startsWith('"') && candidate.endsWith('"')) { + candidate = candidate.slice(1, -1) + } + + if (candidate.startsWith('[')) { + const closingBracketIndex = candidate.indexOf(']') + if (closingBracketIndex > 0) { + candidate = candidate.slice(1, closingBracketIndex) + } + } else { + const colonCount = (candidate.match(/:/g) || []).length + if (colonCount === 1 && candidate.includes('.')) { + candidate = candidate.slice(0, candidate.lastIndexOf(':')) + } + } + + const zoneIndex = candidate.indexOf('%') + if (zoneIndex > -1) { + candidate = candidate.slice(0, zoneIndex) + } + + candidate = candidate.trim() + + if (candidate.toLowerCase().startsWith('::ffff:')) { + const ipv4 = candidate.slice(7) + if (isIP(ipv4) === 4) { + return ipv4 + } + } + + return isIP(candidate) ? candidate : null +} + +export function isPrivateIp(ip: string) { + const normalizedIp = normalizeIpCandidate(ip) + if (!normalizedIp) { + return false + } + + if (isIP(normalizedIp) === 4) { + const [first, second] = normalizedIp.split('.').map(Number) + return first === 10 || + first === 127 || + first === 0 || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) || + (first === 169 && second === 254) || + (first === 100 && second >= 64 && second <= 127) + } + + const lowerIp = normalizedIp.toLowerCase() + return lowerIp === '::1' || + lowerIp === '::' || + lowerIp.startsWith('fc') || + lowerIp.startsWith('fd') || + lowerIp.startsWith('fe8') || + lowerIp.startsWith('fe9') || + lowerIp.startsWith('fea') || + lowerIp.startsWith('feb') +} + +function parseForwardedHeader(value: string) { + const candidates: string[] = [] + + for (const entry of value.split(',')) { + for (const segment of entry.split(';')) { + const [key, rawValue] = segment.split('=') + if (key?.trim().toLowerCase() === 'for' && rawValue) { + candidates.push(rawValue.trim()) + } + } + } + + return candidates +} \ No newline at end of file diff --git a/lib/geodata.ts b/lib/geodata.ts new file mode 100644 index 0000000..5def3aa --- /dev/null +++ b/lib/geodata.ts @@ -0,0 +1,75 @@ +import IP2Region from 'ip2region' +import { existsSync } from 'fs' +import { join } from 'path' +import { isPrivateIp, normalizeIpCandidate } from './clientIp' + +interface IpGeodata { + country: string + province: string + city: string + isp: string +} + +const ipLocationLabelCache = new Map() +let ip2Region: IP2Region | null | undefined + +export function getIpGeodataLabel(ip?: string | null) { + const normalizedIp = normalizeIpCandidate(ip) + if (!normalizedIp) { + return null + } + + const cachedLabel = ipLocationLabelCache.get(normalizedIp) + if (cachedLabel) { + return cachedLabel + } + + const label = resolveIpGeodataLabel(normalizedIp) + ipLocationLabelCache.set(normalizedIp, label) + return label +} + +function resolveIpGeodataLabel(ip: string) { + if (isPrivateIp(ip)) { + return '内网地址' + } + + try { + const geodata = getIp2Region()?.search(ip) as IpGeodata | null + const parts = uniqueNonEmptyValues([ + geodata?.country, + geodata?.province, + geodata?.city, + geodata?.isp + ]) + + return parts.length > 0 ? parts.join(' ') : '未知归属地' + } catch { + return '未知归属地' + } +} + +function getIp2Region() { + if (ip2Region !== undefined) { + return ip2Region + } + + const dataDir = join(process.cwd(), 'node_modules', 'ip2region', 'data') + const ipv4db = join(dataDir, 'ip2region.db') + const ipv6db = join(dataDir, 'ipv6wry.db') + + if (!existsSync(ipv4db) || !existsSync(ipv6db)) { + console.warn(`[geodata] ip2region database files not found in ${dataDir}`) + ip2Region = null + return ip2Region + } + + ip2Region = new IP2Region({ ipv4db, ipv6db }) + return ip2Region +} + +function uniqueNonEmptyValues(values: Array) { + return Array.from(new Set(values + .map(value => value?.trim()) + .filter((value): value is string => !!value && value !== '0'))) +} \ No newline at end of file diff --git a/package.json b/package.json index c357b66..74bbd3d 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "date-fns": "^4.1.0", "dotenv-cli": "^8.0.0", "fluent-ffmpeg": "^2.1.3", + "ip2region": "^2.3.0", "lucide-react": "^0.525.0", "minio": "^8.0.5", "multer": "^2.0.1", diff --git a/prisma/migrations/20260609000000_add_record_ip/migration.sql b/prisma/migrations/20260609000000_add_record_ip/migration.sql new file mode 100644 index 0000000..47acccf --- /dev/null +++ b/prisma/migrations/20260609000000_add_record_ip/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "records" ADD COLUMN "ip" TEXT; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4a9997c..bf5566d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -30,6 +30,7 @@ model Host { model Record { id String @id @default(cuid()) hostname String + ip String? timestamp DateTime @default(now()) isStarred Boolean @default(false) createdAt DateTime @default(now())