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())