feat: 添加 ip 追踪
This commit is contained in:
parent
ca654754ae
commit
7571316685
@ -851,6 +851,26 @@ export default function ScreenshotsTab({
|
||||
</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>
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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[];
|
||||
|
||||
3
bun.lock
3
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=="],
|
||||
|
||||
148
lib/clientIp.ts
Normal file
148
lib/clientIp.ts
Normal 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
75
lib/geodata.ts
Normal 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')))
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "records" ADD COLUMN "ip" TEXT;
|
||||
@ -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())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user