mirror of
https://github.com/yyhuni/xingrin.git
synced 2026-01-24 08:13:10 +08:00
feat(search): add result export functionality and pagination limit support
- Add optional limit parameter to AssetSearchService.search() method for controlling result set size - Implement AssetSearchExportView for exporting search results as CSV files with UTF-8 BOM encoding - Add CSV export endpoint at GET /api/assets/search/export/ with configurable MAX_EXPORT_ROWS limit (10000) - Support both website and endpoint asset types with type-specific column mappings in CSV export - Format array fields (tech, matched_gf_patterns) and dates appropriately in exported CSV - Update URL routing to include new search export endpoint - Update views __init__.py to export AssetSearchExportView - Add CSV generation with streaming response for efficient memory usage on large exports - Update frontend search service to support export functionality - Add internationalization strings for export feature in en.json and zh.json - Update smart-filter-input and search-results-table components to support export UI - Update installation and Docker startup scripts for deployment compatibility
This commit is contained in:
@@ -323,7 +323,8 @@ class AssetSearchService:
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
asset_type: AssetType = 'website'
|
||||
asset_type: AssetType = 'website',
|
||||
limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
搜索资产
|
||||
@@ -331,6 +332,7 @@ class AssetSearchService:
|
||||
Args:
|
||||
query: 搜索查询字符串
|
||||
asset_type: 资产类型 ('website' 或 'endpoint')
|
||||
limit: 最大返回数量(可选)
|
||||
|
||||
Returns:
|
||||
List[Dict]: 搜索结果列表
|
||||
@@ -348,6 +350,10 @@ class AssetSearchService:
|
||||
ORDER BY created_at DESC
|
||||
"""
|
||||
|
||||
# 添加 LIMIT
|
||||
if limit is not None and limit > 0:
|
||||
sql += f" LIMIT {int(limit)}"
|
||||
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(sql, params)
|
||||
|
||||
@@ -11,6 +11,7 @@ from .views import (
|
||||
VulnerabilityViewSet,
|
||||
AssetStatisticsViewSet,
|
||||
AssetSearchView,
|
||||
AssetSearchExportView,
|
||||
)
|
||||
|
||||
# 创建 DRF 路由器
|
||||
@@ -27,4 +28,5 @@ router.register(r'statistics', AssetStatisticsViewSet, basename='asset-statistic
|
||||
urlpatterns = [
|
||||
path('assets/', include(router.urls)),
|
||||
path('assets/search/', AssetSearchView.as_view(), name='asset-search'),
|
||||
path('assets/search/export/', AssetSearchExportView.as_view(), name='asset-search-export'),
|
||||
]
|
||||
|
||||
@@ -19,7 +19,7 @@ from .asset_views import (
|
||||
HostPortMappingSnapshotViewSet,
|
||||
VulnerabilitySnapshotViewSet,
|
||||
)
|
||||
from .search_views import AssetSearchView
|
||||
from .search_views import AssetSearchView, AssetSearchExportView
|
||||
|
||||
__all__ = [
|
||||
'AssetStatisticsViewSet',
|
||||
@@ -36,4 +36,5 @@ __all__ = [
|
||||
'HostPortMappingSnapshotViewSet',
|
||||
'VulnerabilitySnapshotViewSet',
|
||||
'AssetSearchView',
|
||||
'AssetSearchExportView',
|
||||
]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
提供资产搜索的 REST API 接口:
|
||||
- GET /api/assets/search/ - 搜索资产
|
||||
- GET /api/assets/search/export/ - 导出搜索结果为 CSV
|
||||
|
||||
搜索语法:
|
||||
- field="value" 模糊匹配(ILIKE %value%)
|
||||
@@ -27,10 +28,14 @@
|
||||
|
||||
import logging
|
||||
import json
|
||||
import csv
|
||||
from io import StringIO
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
from rest_framework import status
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.db import connection
|
||||
|
||||
from apps.common.response_helpers import success_response, error_response
|
||||
@@ -269,3 +274,127 @@ class AssetSearchView(APIView):
|
||||
'totalPages': total_pages,
|
||||
'assetType': asset_type,
|
||||
})
|
||||
|
||||
|
||||
class AssetSearchExportView(APIView):
|
||||
"""
|
||||
资产搜索导出 API
|
||||
|
||||
GET /api/assets/search/export/
|
||||
|
||||
Query Parameters:
|
||||
q: 搜索查询表达式
|
||||
asset_type: 资产类型 ('website' 或 'endpoint',默认 'website')
|
||||
|
||||
Response:
|
||||
CSV 文件流
|
||||
"""
|
||||
|
||||
# 导出数量限制
|
||||
MAX_EXPORT_ROWS = 10000
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.service = AssetSearchService()
|
||||
|
||||
def _parse_headers(self, headers_data) -> str:
|
||||
"""解析响应头为字符串"""
|
||||
if not headers_data:
|
||||
return ''
|
||||
try:
|
||||
headers = json.loads(headers_data)
|
||||
return '; '.join(f'{k}: {v}' for k, v in headers.items())
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return str(headers_data)
|
||||
|
||||
def _generate_csv(self, results: list, asset_type: str):
|
||||
"""生成 CSV 内容的生成器"""
|
||||
# 定义列
|
||||
if asset_type == 'website':
|
||||
columns = ['url', 'host', 'title', 'status_code', 'content_type', 'content_length',
|
||||
'webserver', 'location', 'tech', 'vhost', 'created_at']
|
||||
headers = ['URL', 'Host', 'Title', 'Status', 'Content-Type', 'Content-Length',
|
||||
'Webserver', 'Location', 'Technologies', 'VHost', 'Created At']
|
||||
else:
|
||||
columns = ['url', 'host', 'title', 'status_code', 'content_type', 'content_length',
|
||||
'webserver', 'location', 'tech', 'matched_gf_patterns', 'vhost', 'created_at']
|
||||
headers = ['URL', 'Host', 'Title', 'Status', 'Content-Type', 'Content-Length',
|
||||
'Webserver', 'Location', 'Technologies', 'GF Patterns', 'VHost', 'Created At']
|
||||
|
||||
# 写入 BOM 和表头
|
||||
output = StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# UTF-8 BOM
|
||||
yield '\ufeff'
|
||||
|
||||
# 表头
|
||||
writer.writerow(headers)
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
|
||||
# 数据行
|
||||
for result in results:
|
||||
row = []
|
||||
for col in columns:
|
||||
value = result.get(col)
|
||||
if col == 'tech' or col == 'matched_gf_patterns':
|
||||
# 数组转字符串
|
||||
row.append('; '.join(value) if value else '')
|
||||
elif col == 'created_at':
|
||||
# 日期格式化
|
||||
row.append(value.strftime('%Y-%m-%d %H:%M:%S') if value else '')
|
||||
elif col == 'vhost':
|
||||
row.append('true' if value else 'false' if value is False else '')
|
||||
else:
|
||||
row.append(str(value) if value is not None else '')
|
||||
|
||||
writer.writerow(row)
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
|
||||
def get(self, request: Request):
|
||||
"""导出搜索结果为 CSV"""
|
||||
# 获取搜索查询
|
||||
query = request.query_params.get('q', '').strip()
|
||||
|
||||
if not query:
|
||||
return error_response(
|
||||
code=ErrorCodes.VALIDATION_ERROR,
|
||||
message='Search query (q) is required',
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# 获取并验证资产类型
|
||||
asset_type = request.query_params.get('asset_type', 'website').strip().lower()
|
||||
if asset_type not in VALID_ASSET_TYPES:
|
||||
return error_response(
|
||||
code=ErrorCodes.VALIDATION_ERROR,
|
||||
message=f'Invalid asset_type. Must be one of: {", ".join(VALID_ASSET_TYPES)}',
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# 获取搜索结果(限制数量)
|
||||
results = self.service.search(query, asset_type, limit=self.MAX_EXPORT_ROWS)
|
||||
|
||||
if not results:
|
||||
return error_response(
|
||||
code=ErrorCodes.NOT_FOUND,
|
||||
message='No results to export',
|
||||
status_code=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# 生成文件名
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'search_{asset_type}_{timestamp}.csv'
|
||||
|
||||
# 返回流式响应
|
||||
response = StreamingHttpResponse(
|
||||
self._generate_csv(results, asset_type),
|
||||
content_type='text/csv; charset=utf-8'
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
|
||||
return response
|
||||
|
||||
@@ -15,10 +15,12 @@ NC='\033[0m'
|
||||
# 解析参数
|
||||
WITH_FRONTEND=true
|
||||
DEV_MODE=false
|
||||
QUIET_MODE=false
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--no-frontend) WITH_FRONTEND=false ;;
|
||||
--dev) DEV_MODE=true ;;
|
||||
--quiet) QUIET_MODE=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -155,6 +157,11 @@ echo -e "${GREEN}[OK]${NC} 服务已启动"
|
||||
# 数据初始化
|
||||
./scripts/init-data.sh
|
||||
|
||||
# 静默模式下不显示结果(由调用方显示)
|
||||
if [ "$QUIET_MODE" = true ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 获取访问地址
|
||||
PUBLIC_HOST=$(grep "^PUBLIC_HOST=" .env 2>/dev/null | cut -d= -f2)
|
||||
if [ -n "$PUBLIC_HOST" ] && [ "$PUBLIC_HOST" != "server" ]; then
|
||||
|
||||
@@ -381,9 +381,9 @@ export function SmartFilterInput({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Popover open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverAnchor asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverAnchor asChild>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
@@ -420,11 +420,7 @@ export function SmartFilterInput({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleSearch}>
|
||||
<IconSearch className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
@@ -523,6 +519,10 @@ export function SmartFilterInput({
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button variant="outline" size="sm" onClick={handleSearch}>
|
||||
<IconSearch className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { Search, AlertCircle, Globe, Link2, ShieldAlert, History, X } from "lucide-react"
|
||||
import { Search, AlertCircle, History, X, Download } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { SmartFilterInput, type FilterField } from "@/components/common/smart-filter-input"
|
||||
import { SearchPagination } from "./search-pagination"
|
||||
import { useAssetSearch } from "@/hooks/use-search"
|
||||
import { VulnerabilityDetailDialog } from "@/components/vulnerabilities/vulnerability-detail-dialog"
|
||||
import { VulnerabilityService } from "@/services/vulnerability.service"
|
||||
import { SearchService } from "@/services/search.service"
|
||||
import type { SearchParams, SearchState, Vulnerability as SearchVuln, AssetType } from "@/types/search.types"
|
||||
import type { Vulnerability } from "@/types/vulnerability.types"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { SearchResultsTable } from "./search-results-table"
|
||||
import { SearchResultCard } from "./search-result-card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { getAssetStatistics } from "@/services/dashboard.service"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Website 搜索示例
|
||||
@@ -98,6 +99,7 @@ function removeRecentSearch(query: string) {
|
||||
|
||||
export function SearchPage() {
|
||||
const t = useTranslations('search')
|
||||
const urlSearchParams = useSearchParams()
|
||||
const [searchState, setSearchState] = useState<SearchState>("initial")
|
||||
const [query, setQuery] = useState("")
|
||||
const [assetType, setAssetType] = useState<AssetType>("website")
|
||||
@@ -108,19 +110,28 @@ export function SearchPage() {
|
||||
const [vulnDialogOpen, setVulnDialogOpen] = useState(false)
|
||||
const [, setLoadingVuln] = useState(false)
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>([])
|
||||
|
||||
// 获取资产统计数据
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['assetStatistics'],
|
||||
queryFn: getAssetStatistics,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
})
|
||||
const [initialQueryProcessed, setInitialQueryProcessed] = useState(false)
|
||||
|
||||
// 加载最近搜索记录
|
||||
useEffect(() => {
|
||||
setRecentSearches(getRecentSearches())
|
||||
}, [])
|
||||
|
||||
// 处理 URL 参数中的搜索查询
|
||||
useEffect(() => {
|
||||
if (initialQueryProcessed) return
|
||||
|
||||
const q = urlSearchParams.get('q')
|
||||
if (q) {
|
||||
setQuery(q)
|
||||
setSearchParams({ q, asset_type: assetType })
|
||||
setSearchState("searching")
|
||||
saveRecentSearch(q)
|
||||
setRecentSearches(getRecentSearches())
|
||||
}
|
||||
setInitialQueryProcessed(true)
|
||||
}, [urlSearchParams, assetType, initialQueryProcessed])
|
||||
|
||||
// 根据资产类型选择搜索示例
|
||||
const searchExamples = useMemo(() => {
|
||||
return assetType === 'endpoint' ? ENDPOINT_SEARCH_EXAMPLES : WEBSITE_SEARCH_EXAMPLES
|
||||
@@ -178,6 +189,25 @@ export function SearchPage() {
|
||||
setRecentSearches(getRecentSearches())
|
||||
}, [])
|
||||
|
||||
// 导出状态
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
|
||||
// 导出 CSV(调用后端 API 导出全部结果)
|
||||
const handleExportCSV = useCallback(async () => {
|
||||
if (!searchParams.q) return
|
||||
|
||||
setIsExporting(true)
|
||||
try {
|
||||
await SearchService.exportCSV(searchParams.q, assetType)
|
||||
toast.success(t('exportSuccess'))
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error)
|
||||
toast.error(t('exportFailed'))
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}, [searchParams.q, assetType, t])
|
||||
|
||||
// 当数据加载完成时更新状态
|
||||
if (searchState === "searching" && data && !isLoading) {
|
||||
setSearchState("results")
|
||||
@@ -289,38 +319,6 @@ export function SearchPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 资产统计 */}
|
||||
{stats && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="grid grid-cols-3 gap-6 mt-4"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1 p-4 rounded-xl bg-muted/50">
|
||||
<Globe className="h-5 w-5 text-muted-foreground mb-1" />
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
{stats.totalWebsites.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{t('assetTypes.website')}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 p-4 rounded-xl bg-muted/50">
|
||||
<Link2 className="h-5 w-5 text-muted-foreground mb-1" />
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
{stats.totalEndpoints.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{t('assetTypes.endpoint')}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 p-4 rounded-xl bg-muted/50">
|
||||
<ShieldAlert className="h-5 w-5 text-muted-foreground mb-1" />
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
{stats.totalVulns.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{t('stats.vulnerabilities')}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 最近搜索 */}
|
||||
{recentSearches.length > 0 && (
|
||||
<motion.div
|
||||
@@ -404,6 +402,15 @@ export function SearchPage() {
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{isFetching ? t('loading') : t('resultsCount', { count: data?.total ?? 0 })}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExportCSV}
|
||||
disabled={!data?.results || data.results.length === 0 || isExporting}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1.5" />
|
||||
{isExporting ? t('exporting') : t('export')}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useTranslations, useFormatter } from "next-intl"
|
||||
import { useFormatter } from "next-intl"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { DataTableColumnHeader, UnifiedDataTable } from "@/components/ui/data-table"
|
||||
@@ -15,7 +15,6 @@ interface SearchResultsTableProps {
|
||||
}
|
||||
|
||||
export function SearchResultsTable({ results, assetType }: SearchResultsTableProps) {
|
||||
const t = useTranslations('search.table')
|
||||
const format = useFormatter()
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
@@ -33,9 +32,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
|
||||
{
|
||||
id: "url",
|
||||
accessorKey: "url",
|
||||
meta: { title: t('url') },
|
||||
meta: { title: "URL" },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('url')} />
|
||||
<DataTableColumnHeader column={column} title="URL" />
|
||||
),
|
||||
size: 350,
|
||||
minSize: 200,
|
||||
@@ -47,9 +46,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
|
||||
{
|
||||
id: "host",
|
||||
accessorKey: "host",
|
||||
meta: { title: t('host') },
|
||||
meta: { title: "Host" },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('host')} />
|
||||
<DataTableColumnHeader column={column} title="Host" />
|
||||
),
|
||||
size: 180,
|
||||
minSize: 100,
|
||||
@@ -61,9 +60,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
|
||||
{
|
||||
id: "title",
|
||||
accessorKey: "title",
|
||||
meta: { title: t('title') },
|
||||
meta: { title: "Title" },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('title')} />
|
||||
<DataTableColumnHeader column={column} title="Title" />
|
||||
),
|
||||
size: 150,
|
||||
minSize: 100,
|
||||
@@ -75,9 +74,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
|
||||
{
|
||||
id: "statusCode",
|
||||
accessorKey: "statusCode",
|
||||
meta: { title: t('status') },
|
||||
meta: { title: "Status" },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('status')} />
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
size: 80,
|
||||
minSize: 60,
|
||||
@@ -103,9 +102,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
|
||||
{
|
||||
id: "technologies",
|
||||
accessorKey: "technologies",
|
||||
meta: { title: t('technologies') },
|
||||
meta: { title: "Tech" },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('technologies')} />
|
||||
<DataTableColumnHeader column={column} title="Tech" />
|
||||
),
|
||||
size: 180,
|
||||
minSize: 120,
|
||||
@@ -118,9 +117,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
|
||||
{
|
||||
id: "contentLength",
|
||||
accessorKey: "contentLength",
|
||||
meta: { title: t('contentLength') },
|
||||
meta: { title: "Length" },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('contentLength')} />
|
||||
<DataTableColumnHeader column={column} title="Length" />
|
||||
),
|
||||
size: 100,
|
||||
minSize: 80,
|
||||
@@ -134,9 +133,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
|
||||
{
|
||||
id: "location",
|
||||
accessorKey: "location",
|
||||
meta: { title: t('location') },
|
||||
meta: { title: "Location" },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('location')} />
|
||||
<DataTableColumnHeader column={column} title="Location" />
|
||||
),
|
||||
size: 150,
|
||||
minSize: 100,
|
||||
@@ -148,9 +147,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
|
||||
{
|
||||
id: "webserver",
|
||||
accessorKey: "webserver",
|
||||
meta: { title: t('webserver') },
|
||||
meta: { title: "Server" },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('webserver')} />
|
||||
<DataTableColumnHeader column={column} title="Server" />
|
||||
),
|
||||
size: 120,
|
||||
minSize: 80,
|
||||
@@ -162,9 +161,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
|
||||
{
|
||||
id: "contentType",
|
||||
accessorKey: "contentType",
|
||||
meta: { title: t('contentType') },
|
||||
meta: { title: "Type" },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('contentType')} />
|
||||
<DataTableColumnHeader column={column} title="Type" />
|
||||
),
|
||||
size: 120,
|
||||
minSize: 80,
|
||||
@@ -176,9 +175,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
|
||||
{
|
||||
id: "responseBody",
|
||||
accessorKey: "responseBody",
|
||||
meta: { title: t('responseBody') },
|
||||
meta: { title: "Body" },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('responseBody')} />
|
||||
<DataTableColumnHeader column={column} title="Body" />
|
||||
),
|
||||
size: 300,
|
||||
minSize: 200,
|
||||
@@ -189,9 +188,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
|
||||
{
|
||||
id: "responseHeaders",
|
||||
accessorKey: "responseHeaders",
|
||||
meta: { title: t('responseHeaders') },
|
||||
meta: { title: "Headers" },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('responseHeaders')} />
|
||||
<DataTableColumnHeader column={column} title="Headers" />
|
||||
),
|
||||
size: 250,
|
||||
minSize: 150,
|
||||
@@ -210,9 +209,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
|
||||
{
|
||||
id: "vhost",
|
||||
accessorKey: "vhost",
|
||||
meta: { title: t('vhost') },
|
||||
meta: { title: "VHost" },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('vhost')} />
|
||||
<DataTableColumnHeader column={column} title="VHost" />
|
||||
),
|
||||
size: 80,
|
||||
minSize: 60,
|
||||
@@ -226,9 +225,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
|
||||
{
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
meta: { title: t('createdAt') },
|
||||
meta: { title: "Created" },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('createdAt')} />
|
||||
<DataTableColumnHeader column={column} title="Created" />
|
||||
),
|
||||
size: 150,
|
||||
minSize: 120,
|
||||
@@ -239,16 +238,16 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
|
||||
return <span className="text-sm">{formatDate(createdAt)}</span>
|
||||
},
|
||||
},
|
||||
], [t, formatDate])
|
||||
], [formatDate])
|
||||
|
||||
// Endpoint 特有列
|
||||
const endpointColumns: ColumnDef<SearchResult, unknown>[] = useMemo(() => [
|
||||
{
|
||||
id: "matchedGfPatterns",
|
||||
accessorKey: "matchedGfPatterns",
|
||||
meta: { title: t('gfPatterns') },
|
||||
meta: { title: "GF Patterns" },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('gfPatterns')} />
|
||||
<DataTableColumnHeader column={column} title="GF Patterns" />
|
||||
),
|
||||
size: 150,
|
||||
minSize: 100,
|
||||
@@ -259,7 +258,7 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
|
||||
return <ExpandableTagList items={patterns} maxLines={2} variant="secondary" />
|
||||
},
|
||||
},
|
||||
], [t])
|
||||
], [])
|
||||
|
||||
// 根据资产类型组合列
|
||||
const columns = useMemo(() => {
|
||||
|
||||
@@ -326,6 +326,10 @@
|
||||
"noResultsHint": "Try adjusting your search criteria",
|
||||
"vulnLoadError": "Failed to load vulnerability details",
|
||||
"recentSearches": "Recent Searches",
|
||||
"export": "Export",
|
||||
"exporting": "Exporting...",
|
||||
"exportSuccess": "Export successful",
|
||||
"exportFailed": "Export failed",
|
||||
"stats": {
|
||||
"vulnerabilities": "Vulnerabilities"
|
||||
},
|
||||
@@ -1971,6 +1975,16 @@
|
||||
"formatInvalid": "Invalid format"
|
||||
}
|
||||
},
|
||||
"globalSearch": {
|
||||
"search": "Search",
|
||||
"placeholder": "Search assets... (host=\"api\" && tech=\"nginx\")",
|
||||
"noResults": "No results found",
|
||||
"searchFor": "Search for",
|
||||
"recent": "Recent Searches",
|
||||
"quickSearch": "Quick Search",
|
||||
"hint": "Supports FOFA-style syntax",
|
||||
"toSearch": "to search"
|
||||
},
|
||||
"errors": {
|
||||
"unknown": "Operation failed, please try again later",
|
||||
"validation": "Invalid input data",
|
||||
|
||||
@@ -326,6 +326,10 @@
|
||||
"noResultsHint": "请尝试调整搜索条件",
|
||||
"vulnLoadError": "加载漏洞详情失败",
|
||||
"recentSearches": "最近搜索",
|
||||
"export": "导出",
|
||||
"exporting": "导出中...",
|
||||
"exportSuccess": "导出成功",
|
||||
"exportFailed": "导出失败",
|
||||
"stats": {
|
||||
"vulnerabilities": "漏洞"
|
||||
},
|
||||
@@ -1971,6 +1975,16 @@
|
||||
"formatInvalid": "格式无效"
|
||||
}
|
||||
},
|
||||
"globalSearch": {
|
||||
"search": "搜索",
|
||||
"placeholder": "搜索资产... (host=\"api\" && tech=\"nginx\")",
|
||||
"noResults": "未找到结果",
|
||||
"searchFor": "搜索",
|
||||
"recent": "最近搜索",
|
||||
"quickSearch": "快捷搜索",
|
||||
"hint": "支持 FOFA 风格语法",
|
||||
"toSearch": "搜索"
|
||||
},
|
||||
"errors": {
|
||||
"unknown": "操作失败,请稍后重试",
|
||||
"validation": "输入数据无效",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api } from "@/lib/api-client"
|
||||
import type { SearchParams, SearchResponse } from "@/types/search.types"
|
||||
import type { SearchParams, SearchResponse, AssetType } from "@/types/search.types"
|
||||
|
||||
/**
|
||||
* 资产搜索 API 服务
|
||||
@@ -38,4 +38,38 @@ export class SearchService {
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出搜索结果为 CSV
|
||||
* GET /api/assets/search/export/
|
||||
*/
|
||||
static async exportCSV(query: string, assetType: AssetType): Promise<void> {
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.append('q', query)
|
||||
queryParams.append('asset_type', assetType)
|
||||
|
||||
const response = await api.get(
|
||||
`/assets/search/export/?${queryParams.toString()}`,
|
||||
{ responseType: 'blob' }
|
||||
)
|
||||
|
||||
// 从响应头获取文件名
|
||||
const contentDisposition = response.headers?.['content-disposition']
|
||||
let filename = `search_${assetType}_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename="?([^"]+)"?/)
|
||||
if (match) filename = match[1]
|
||||
}
|
||||
|
||||
// 创建下载链接
|
||||
const blob = new Blob([response.data as BlobPart], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
11
install.sh
11
install.sh
@@ -94,7 +94,7 @@ show_banner() {
|
||||
echo -e "${MAGENTA} ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝${RESET}"
|
||||
echo -e ""
|
||||
echo -e "${DIM} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
|
||||
echo -e "${BOLD} 🔒 分布式安全扫描平台 │ 一键部署安装程序${RESET}"
|
||||
echo -e "${BOLD} 🔒 分布式安全扫描平台 │ 一键部署 (Ubuntu)${RESET}"
|
||||
echo -e "${DIM} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
|
||||
echo -e ""
|
||||
}
|
||||
@@ -132,7 +132,6 @@ fi
|
||||
|
||||
# 显示标题
|
||||
show_banner
|
||||
header "XingRin 一键安装脚本 (Ubuntu)"
|
||||
info "当前用户: ${BOLD}$REAL_USER${RESET}"
|
||||
info "项目路径: ${BOLD}$ROOT_DIR${RESET}"
|
||||
info "安装版本: ${BOLD}$APP_VERSION${RESET}"
|
||||
@@ -324,7 +323,7 @@ check_pg_ivm() {
|
||||
# 显示安装总结信息
|
||||
show_summary() {
|
||||
echo
|
||||
if [ "$1" == "success" ]; then
|
||||
if [ "$1" = "success" ]; then
|
||||
# 成功 Banner
|
||||
echo -e ""
|
||||
echo -e "${GREEN}${BOLD} ╔═══════════════════════════════════════════════════╗${RESET}"
|
||||
@@ -378,7 +377,9 @@ show_summary() {
|
||||
echo -e " ${YELLOW} ⚠ 请首次登录后修改密码!${RESET}"
|
||||
echo
|
||||
|
||||
if [ "$1" != "success" ]; then
|
||||
if [ "$1" = "success" ]; then
|
||||
: # 成功模式,不显示后续命令
|
||||
else
|
||||
echo -e "${DIM} ──────────────────────────────────────────────────────${RESET}"
|
||||
echo -e " ${BLUE}🚀 后续命令${RESET}"
|
||||
echo -e " ${DIM}├─${RESET} ./start.sh ${DIM}# 启动所有服务${RESET}"
|
||||
@@ -743,7 +744,7 @@ fi
|
||||
# 启动服务
|
||||
# ==============================================================================
|
||||
step "正在启动服务..."
|
||||
"$ROOT_DIR/start.sh" $START_ARGS
|
||||
"$ROOT_DIR/start.sh" ${START_ARGS} --quiet
|
||||
|
||||
# ==============================================================================
|
||||
# 完成总结
|
||||
|
||||
Reference in New Issue
Block a user