feat: 添加 ip 追踪

This commit is contained in:
feie9454 2026-06-09 18:43:54 +08:00
parent ca654754ae
commit 7571316685
10 changed files with 261 additions and 0 deletions

View File

@ -851,6 +851,26 @@ export default function ScreenshotsTab({
</div> </div>
</div> </div>
</div> </div>
<div className="w-full mt-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-3"> IP</h3>
<div className="bg-gray-50 dark:bg-gray-700 rounded-md p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="p-4 bg-white dark:bg-gray-800 rounded-md shadow-sm">
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1">IP </div>
<div className="font-medium text-gray-900 dark:text-white break-all">
{selectedRecord.ip || '未记录'}
</div>
</div>
<div className="p-4 bg-white dark:bg-gray-800 rounded-md shadow-sm">
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1"></div>
<div className="font-medium text-gray-900 dark:text-white break-all">
{selectedRecord.ipLocation || (selectedRecord.ip ? '未知归属地' : '未记录')}
</div>
</div>
</div>
</div>
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -4,6 +4,8 @@ import { storeFile } from '@/lib/fileStorage'
import { push } from '@/lib/push' import { push } from '@/lib/push'
import { withCors } from '@/lib/middleware' import { withCors } from '@/lib/middleware'
import { filterIgnoredWindows } from '@/lib/windowFilters' import { filterIgnoredWindows } from '@/lib/windowFilters'
import { getClientIp } from '@/lib/clientIp'
import { getIpGeodataLabel } from '@/lib/geodata'
import ffmpeg from 'fluent-ffmpeg' import ffmpeg from 'fluent-ffmpeg'
import { writeFileSync, unlinkSync, mkdtempSync, readFileSync, rmSync } from 'fs' import { writeFileSync, unlinkSync, mkdtempSync, readFileSync, rmSync } from 'fs'
import { tmpdir } from 'os' import { tmpdir } from 'os'
@ -27,6 +29,8 @@ async function handleScreenshotUpload(req: NextRequest) {
return NextResponse.json({ error: '缺少主机名' }, { status: 400 }) return NextResponse.json({ error: '缺少主机名' }, { status: 400 })
} }
const clientIp = getClientIp(req)
const formData = await req.formData() const formData = await req.formData()
const files: File[] = [] const files: File[] = []
const rawWindowsInfo: WindowInfo[] = JSON.parse(formData.get('windows_info') as string || '[]') 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({ const newRecord = await prisma.record.create({
data: { data: {
hostname, hostname,
ip: clientIp,
timestamp: new Date(), timestamp: new Date(),
windows: { windows: {
create: windowsInfo create: windowsInfo
@ -226,6 +231,7 @@ async function handleGetScreenshots(req: NextRequest) {
select: { select: {
id: true, id: true,
timestamp: true, timestamp: true,
ip: true,
isStarred: true, isStarred: true,
windows: { windows: {
select: { select: {
@ -262,6 +268,7 @@ async function handleGetScreenshots(req: NextRequest) {
// Convert BigInt to string in windows.memory field // Convert BigInt to string in windows.memory field
const serializedRecords = records.map(record => ({ const serializedRecords = records.map(record => ({
...record, ...record,
ipLocation: getIpGeodataLabel(record.ip),
windows: record.windows.map(window => ({ windows: record.windows.map(window => ({
...window, ...window,
memory: window.memory.toString() memory: window.memory.toString()

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { withCors } from '@/lib/middleware' import { withCors } from '@/lib/middleware'
import { getIpGeodataLabel } from '@/lib/geodata'
// 获取指定主机的星标记录 // 获取指定主机的星标记录
async function handleGetStarredRecords(req: NextRequest) { async function handleGetStarredRecords(req: NextRequest) {
@ -49,6 +50,7 @@ async function handleGetStarredRecords(req: NextRequest) {
}) })
const serializedRecords = records.map(record => ({ const serializedRecords = records.map(record => ({
...record, ...record,
ipLocation: getIpGeodataLabel(record.ip),
windows: record.windows.map(window => ({ windows: record.windows.map(window => ({
...window, ...window,
memory: window.memory.toString() memory: window.memory.toString()

View File

@ -13,6 +13,8 @@ export interface Window {
export interface ScreenRecord { export interface ScreenRecord {
id: string; id: string;
timestamp: string; timestamp: string;
ip: string | null;
ipLocation: string | null;
isStarred: boolean; isStarred: boolean;
windows: Window[]; windows: Window[];
screenshots: Screenshot[]; screenshots: Screenshot[];

View File

@ -15,6 +15,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv-cli": "^8.0.0", "dotenv-cli": "^8.0.0",
"fluent-ffmpeg": "^2.1.3", "fluent-ffmpeg": "^2.1.3",
"ip2region": "^2.3.0",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"minio": "^8.0.5", "minio": "^8.0.5",
"multer": "^2.0.1", "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=="], "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=="], "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=="], "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=="],

148
lib/clientIp.ts Normal file
View File

@ -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
}

75
lib/geodata.ts Normal file
View File

@ -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<string, string>()
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<string | undefined>) {
return Array.from(new Set(values
.map(value => value?.trim())
.filter((value): value is string => !!value && value !== '0')))
}

View File

@ -26,6 +26,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv-cli": "^8.0.0", "dotenv-cli": "^8.0.0",
"fluent-ffmpeg": "^2.1.3", "fluent-ffmpeg": "^2.1.3",
"ip2region": "^2.3.0",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"minio": "^8.0.5", "minio": "^8.0.5",
"multer": "^2.0.1", "multer": "^2.0.1",

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "records" ADD COLUMN "ip" TEXT;

View File

@ -30,6 +30,7 @@ model Host {
model Record { model Record {
id String @id @default(cuid()) id String @id @default(cuid())
hostname String hostname String
ip String?
timestamp DateTime @default(now()) timestamp DateTime @default(now())
isStarred Boolean @default(false) isStarred Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())