Compare commits
No commits in common. "7571316685f3be62cafc93113e50dbf2966a3f49" and "fea99d37fe15edaa73e604c968428ef8da1fc689" have entirely different histories.
7571316685
...
fea99d37fe
@ -14,22 +14,20 @@ import { useStarToggle } from '../hooks/useStarToggle';
|
|||||||
|
|
||||||
const inFlightJsonRequests = new Map<string, Promise<unknown>>();
|
const inFlightJsonRequests = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
const fetchJson = async <T,>(url: string, signal?: AbortSignal): Promise<T> => {
|
|
||||||
const response = await fetch(url, { cache: 'no-store', signal });
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Request failed with status ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchJsonOnce = async <T,>(url: string): Promise<T> => {
|
const fetchJsonOnce = async <T,>(url: string): Promise<T> => {
|
||||||
const existingRequest = inFlightJsonRequests.get(url);
|
const existingRequest = inFlightJsonRequests.get(url);
|
||||||
if (existingRequest) {
|
if (existingRequest) {
|
||||||
return existingRequest as Promise<T>;
|
return existingRequest as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = fetchJson<T>(url)
|
const request = fetch(url, { cache: 'no-store' })
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
inFlightJsonRequests.delete(url);
|
inFlightJsonRequests.delete(url);
|
||||||
});
|
});
|
||||||
@ -38,12 +36,6 @@ const fetchJsonOnce = async <T,>(url: string): Promise<T> => {
|
|||||||
return request as Promise<T>;
|
return request as Promise<T>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAbortError = (error: unknown) => (
|
|
||||||
error instanceof DOMException && error.name === 'AbortError'
|
|
||||||
);
|
|
||||||
|
|
||||||
const HOURLY_RECORDS_REQUEST_DEBOUNCE_MS = 120;
|
|
||||||
|
|
||||||
interface ScreenshotsTabProps {
|
interface ScreenshotsTabProps {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
selectedDate: string | null;
|
selectedDate: string | null;
|
||||||
@ -61,8 +53,6 @@ export default function ScreenshotsTab({
|
|||||||
}: ScreenshotsTabProps) {
|
}: ScreenshotsTabProps) {
|
||||||
const timeDistributionRequestIdRef = useRef(0);
|
const timeDistributionRequestIdRef = useRef(0);
|
||||||
const hourlyRecordsRequestIdRef = useRef(0);
|
const hourlyRecordsRequestIdRef = useRef(0);
|
||||||
const hourlyRecordsAbortControllerRef = useRef<AbortController | null>(null);
|
|
||||||
const hourlyRecordsDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
// 状态管理
|
// 状态管理
|
||||||
const [timeDistribution, setTimeDistribution] = useState<TimeDistributionPoint[]>([]);
|
const [timeDistribution, setTimeDistribution] = useState<TimeDistributionPoint[]>([]);
|
||||||
@ -93,25 +83,6 @@ export default function ScreenshotsTab({
|
|||||||
|
|
||||||
const { updatingStars, toggleStar } = useStarToggle();
|
const { updatingStars, toggleStar } = useStarToggle();
|
||||||
|
|
||||||
const clearPendingHourlyRecordsRequest = () => {
|
|
||||||
if (hourlyRecordsDebounceTimerRef.current) {
|
|
||||||
clearTimeout(hourlyRecordsDebounceTimerRef.current);
|
|
||||||
hourlyRecordsDebounceTimerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleHourlyRecordsFetch = (
|
|
||||||
startTime: number,
|
|
||||||
endTime: number,
|
|
||||||
options?: { targetRecordId?: string, keepSelection?: boolean }
|
|
||||||
) => {
|
|
||||||
clearPendingHourlyRecordsRequest();
|
|
||||||
hourlyRecordsDebounceTimerRef.current = setTimeout(() => {
|
|
||||||
hourlyRecordsDebounceTimerRef.current = null;
|
|
||||||
fetchHourlyRecords(startTime, endTime, options);
|
|
||||||
}, HOURLY_RECORDS_REQUEST_DEBOUNCE_MS);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取时间分布数据
|
// 获取时间分布数据
|
||||||
const fetchTimeDistribution = async () => {
|
const fetchTimeDistribution = async () => {
|
||||||
const requestId = ++timeDistributionRequestIdRef.current;
|
const requestId = ++timeDistributionRequestIdRef.current;
|
||||||
@ -141,23 +112,19 @@ export default function ScreenshotsTab({
|
|||||||
|
|
||||||
// 获取小时记录
|
// 获取小时记录
|
||||||
const fetchHourlyRecords = async (startTime: number, endTime: number, options?: { targetRecordId?: string, keepSelection?: boolean }) => {
|
const fetchHourlyRecords = async (startTime: number, endTime: number, options?: { targetRecordId?: string, keepSelection?: boolean }) => {
|
||||||
clearPendingHourlyRecordsRequest();
|
|
||||||
const requestId = ++hourlyRecordsRequestIdRef.current;
|
const requestId = ++hourlyRecordsRequestIdRef.current;
|
||||||
const requestUrl = `/hosts/${hostname}/screenshots?startTime=${startTime}&endTime=${endTime}`;
|
const requestUrl = `/hosts/${hostname}/screenshots?startTime=${startTime}&endTime=${endTime}`;
|
||||||
hourlyRecordsAbortControllerRef.current?.abort();
|
|
||||||
const abortController = new AbortController();
|
|
||||||
hourlyRecordsAbortControllerRef.current = abortController;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoadingRecords(true);
|
setLoadingRecords(true);
|
||||||
setShowDetailTimeline(true);
|
setShowDetailTimeline(true);
|
||||||
|
|
||||||
const data = await fetchJson<{
|
const data = await fetchJsonOnce<{
|
||||||
lastUpdate: string | null;
|
lastUpdate: string | null;
|
||||||
records: ScreenRecord[];
|
records: ScreenRecord[];
|
||||||
}>(requestUrl, abortController.signal);
|
}>(requestUrl);
|
||||||
|
|
||||||
if (requestId !== hourlyRecordsRequestIdRef.current || abortController.signal.aborted) {
|
if (requestId !== hourlyRecordsRequestIdRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,10 +134,6 @@ export default function ScreenshotsTab({
|
|||||||
setTimeRange({ min: startTime * 1000, max: endTime * 1000 });
|
setTimeRange({ min: startTime * 1000, max: endTime * 1000 });
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (requestId !== hourlyRecordsRequestIdRef.current || abortController.signal.aborted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.targetRecordId) {
|
if (options?.targetRecordId) {
|
||||||
const found = newRecords.find((r: ScreenRecord) => r.id === options.targetRecordId);
|
const found = newRecords.find((r: ScreenRecord) => r.id === options.targetRecordId);
|
||||||
if (found) {
|
if (found) {
|
||||||
@ -188,19 +151,11 @@ export default function ScreenshotsTab({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAbortError(error)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestId === hourlyRecordsRequestIdRef.current) {
|
if (requestId === hourlyRecordsRequestIdRef.current) {
|
||||||
console.error('获取记录数据失败:', error);
|
console.error('获取记录数据失败:', error);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (hourlyRecordsAbortControllerRef.current === abortController) {
|
if (requestId === hourlyRecordsRequestIdRef.current) {
|
||||||
hourlyRecordsAbortControllerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestId === hourlyRecordsRequestIdRef.current && !abortController.signal.aborted) {
|
|
||||||
setLoadingRecords(false);
|
setLoadingRecords(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -318,8 +273,12 @@ export default function ScreenshotsTab({
|
|||||||
// 事件处理函数
|
// 事件处理函数
|
||||||
const onHourlySliderChange = (newValue: number) => {
|
const onHourlySliderChange = (newValue: number) => {
|
||||||
const selectedSec = Math.floor(newValue / 3600000) * 3600;
|
const selectedSec = Math.floor(newValue / 3600000) * 3600;
|
||||||
setHourlySliderValue(newValue);
|
|
||||||
scheduleHourlyRecordsFetch(selectedSec, selectedSec + 3600);
|
// 使用 setTimeout 避免在渲染过程中更新状态
|
||||||
|
setTimeout(() => {
|
||||||
|
setHourlySliderValue(newValue);
|
||||||
|
fetchHourlyRecords(selectedSec, selectedSec + 3600);
|
||||||
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDetailedSliderChange = (newValue: number) => {
|
const onDetailedSliderChange = (newValue: number) => {
|
||||||
@ -484,19 +443,6 @@ export default function ScreenshotsTab({
|
|||||||
}, [prevFrame, nextFrame]);
|
}, [prevFrame, nextFrame]);
|
||||||
|
|
||||||
// Effects
|
// Effects
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (hourlyRecordsDebounceTimerRef.current) {
|
|
||||||
clearTimeout(hourlyRecordsDebounceTimerRef.current);
|
|
||||||
hourlyRecordsDebounceTimerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
hourlyRecordsRequestIdRef.current += 1;
|
|
||||||
hourlyRecordsAbortControllerRef.current?.abort();
|
|
||||||
hourlyRecordsAbortControllerRef.current = null;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTimeDistribution();
|
fetchTimeDistribution();
|
||||||
}, [hostname]);
|
}, [hostname]);
|
||||||
@ -851,26 +797,6 @@ 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>
|
||||||
|
|||||||
@ -4,8 +4,6 @@ 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'
|
||||||
@ -29,8 +27,6 @@ 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 || '[]')
|
||||||
@ -131,7 +127,6 @@ 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
|
||||||
@ -231,7 +226,6 @@ 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: {
|
||||||
@ -268,7 +262,6 @@ 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()
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
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) {
|
||||||
@ -50,7 +49,6 @@ 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()
|
||||||
|
|||||||
@ -13,8 +13,6 @@ 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[];
|
||||||
|
|||||||
@ -1,86 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { Readable } from 'stream'
|
import { getFileByObjectName } from '@/lib/fileStorage'
|
||||||
import { getFileStreamByObjectName } from '@/lib/fileStorage'
|
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { withCors } from '@/lib/middleware'
|
import { withCors } from '@/lib/middleware'
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
|
||||||
|
|
||||||
interface ScreenshotFileMetadata {
|
|
||||||
objectName: string
|
|
||||||
filename: string
|
|
||||||
contentType: string
|
|
||||||
fileSize: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_SCREENSHOT_METADATA_CACHE_SIZE = 5000
|
|
||||||
const screenshotMetadataCache = new Map<string, ScreenshotFileMetadata>()
|
|
||||||
|
|
||||||
function cacheScreenshotMetadata(fileId: string, metadata: ScreenshotFileMetadata) {
|
|
||||||
if (screenshotMetadataCache.size >= MAX_SCREENSHOT_METADATA_CACHE_SIZE) {
|
|
||||||
const oldestFileId = screenshotMetadataCache.keys().next().value
|
|
||||||
if (oldestFileId) {
|
|
||||||
screenshotMetadataCache.delete(oldestFileId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
screenshotMetadataCache.set(fileId, metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findScreenshotMetadata(fileId: string): Promise<ScreenshotFileMetadata | null> {
|
|
||||||
const cached = screenshotMetadataCache.get(fileId)
|
|
||||||
if (cached) {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
const screenshot = await prisma.screenshot.findFirst({
|
|
||||||
where: { fileId },
|
|
||||||
select: {
|
|
||||||
objectName: true,
|
|
||||||
filename: true,
|
|
||||||
contentType: true,
|
|
||||||
fileSize: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (screenshot) {
|
|
||||||
cacheScreenshotMetadata(fileId, screenshot)
|
|
||||||
}
|
|
||||||
|
|
||||||
return screenshot
|
|
||||||
}
|
|
||||||
|
|
||||||
function createScreenshotHeaders(fileId: string, screenshot: ScreenshotFileMetadata) {
|
|
||||||
const headers = new Headers({
|
|
||||||
'Content-Type': resolveScreenshotContentType(screenshot),
|
|
||||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
||||||
'ETag': `"${fileId}"`,
|
|
||||||
'X-Content-Type-Options': 'nosniff'
|
|
||||||
})
|
|
||||||
|
|
||||||
if (screenshot.fileSize > 0) {
|
|
||||||
headers.set('Content-Length', String(screenshot.fileSize))
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveScreenshotContentType(screenshot: ScreenshotFileMetadata) {
|
|
||||||
const storedContentType = screenshot.contentType?.trim()
|
|
||||||
const filePath = `${screenshot.filename} ${screenshot.objectName}`.toLowerCase()
|
|
||||||
|
|
||||||
if (storedContentType && storedContentType !== 'image/webp') {
|
|
||||||
return storedContentType
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filePath.includes('.avif')) return 'image/avif'
|
|
||||||
if (filePath.includes('.webp')) return 'image/webp'
|
|
||||||
if (filePath.includes('.png')) return 'image/png'
|
|
||||||
if (filePath.includes('.jpg') || filePath.includes('.jpeg')) return 'image/jpeg'
|
|
||||||
|
|
||||||
return storedContentType || 'image/webp'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleScreenshotFile(req: NextRequest) {
|
async function handleScreenshotFile(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const pathSegments = req.nextUrl.pathname.split('/')
|
const pathSegments = req.nextUrl.pathname.split('/')
|
||||||
@ -91,29 +13,27 @@ async function handleScreenshotFile(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: '缺少文件ID' }, { status: 400 })
|
return NextResponse.json({ error: '缺少文件ID' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const screenshot = await findScreenshotMetadata(fileId)
|
// 从数据库查找 objectName
|
||||||
|
const screenshot = await prisma.screenshot.findFirst({
|
||||||
|
where: { fileId },
|
||||||
|
select: { objectName: true }
|
||||||
|
})
|
||||||
|
|
||||||
if (!screenshot) {
|
if (!screenshot) {
|
||||||
return NextResponse.json({ error: '截图不存在' }, { status: 404 })
|
return NextResponse.json({ error: '截图不存在' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = createScreenshotHeaders(fileId, screenshot)
|
const file = await getFileByObjectName(screenshot.objectName)
|
||||||
if (req.headers.get('if-none-match') === headers.get('ETag')) {
|
|
||||||
headers.delete('Content-Length')
|
|
||||||
return new NextResponse(null, { status: 304, headers })
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = await getFileStreamByObjectName(screenshot.objectName, {
|
|
||||||
contentType: resolveScreenshotContentType(screenshot),
|
|
||||||
filename: screenshot.filename,
|
|
||||||
size: screenshot.fileSize
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return NextResponse.json({ error: '文件不存在' }, { status: 404 })
|
return NextResponse.json({ error: '文件不存在' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
return new NextResponse(Readable.toWeb(file.stream) as unknown as BodyInit, { headers })
|
return new NextResponse(file.buffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': file.contentType || 'image/webp',
|
||||||
|
'Cache-Control': 'public, max-age=31536000',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取截图失败:', error)
|
console.error('获取截图失败:', error)
|
||||||
|
|||||||
3
bun.lock
3
bun.lock
@ -15,7 +15,6 @@
|
|||||||
"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",
|
||||||
@ -382,8 +381,6 @@
|
|||||||
|
|
||||||
"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
148
lib/clientIp.ts
@ -1,148 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import * as Minio from 'minio'
|
import * as Minio from 'minio'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import { Readable } from 'stream'
|
|
||||||
import { minioClient, BUCKET_NAME, initializeMinIO } from './minioClient'
|
import { minioClient, BUCKET_NAME, initializeMinIO } from './minioClient'
|
||||||
|
|
||||||
export interface StoredFile {
|
export interface StoredFile {
|
||||||
@ -21,19 +20,6 @@ export interface FileMetadata {
|
|||||||
objectName: string
|
objectName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoredFileStream {
|
|
||||||
stream: Readable
|
|
||||||
contentType: string
|
|
||||||
filename: string
|
|
||||||
size?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StoredFileStreamMetadata {
|
|
||||||
contentType?: string | null
|
|
||||||
filename?: string | null
|
|
||||||
size?: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成对象名称,使用分层结构优化性能
|
// 生成对象名称,使用分层结构优化性能
|
||||||
function generateObjectName(type: 'screenshot' | 'version' | 'other', hostname?: string): string {
|
function generateObjectName(type: 'screenshot' | 'version' | 'other', hostname?: string): string {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@ -182,27 +168,6 @@ export async function getFileByObjectName(objectName: string): Promise<{ buffer:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFileStreamByObjectName(
|
|
||||||
objectName: string,
|
|
||||||
metadata: StoredFileStreamMetadata = {}
|
|
||||||
): Promise<StoredFileStream | null> {
|
|
||||||
try {
|
|
||||||
await ensureMinIOReady()
|
|
||||||
|
|
||||||
const stream = await minioClient.getObject(BUCKET_NAME, objectName)
|
|
||||||
|
|
||||||
return {
|
|
||||||
stream,
|
|
||||||
contentType: metadata.contentType || 'application/octet-stream',
|
|
||||||
filename: metadata.filename || objectName.split('/').pop() || 'unknown',
|
|
||||||
size: metadata.size ?? undefined
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ 获取文件流失败:', error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteFile(objectName: string): Promise<boolean> {
|
export async function deleteFile(objectName: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await ensureMinIOReady()
|
await ensureMinIOReady()
|
||||||
|
|||||||
@ -1,75 +0,0 @@
|
|||||||
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,7 +26,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "screenshots_fileId_idx" ON "screenshots"("fileId");
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "records" ADD COLUMN "ip" TEXT;
|
|
||||||
@ -30,7 +30,6 @@ 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())
|
||||||
@ -76,7 +75,6 @@ model Screenshot {
|
|||||||
record Record @relation(fields: [recordId], references: [id], onDelete: Cascade)
|
record Record @relation(fields: [recordId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([recordId])
|
@@index([recordId])
|
||||||
@@index([fileId])
|
|
||||||
@@index([objectName])
|
@@index([objectName])
|
||||||
@@map("screenshots")
|
@@map("screenshots")
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user