mirror of
https://github.com/yyhuni/xingrin.git
synced 2026-01-31 19:53:11 +08:00
Compare commits
10 Commits
v1.4.0-dev
...
v1.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6d5338acb | ||
|
|
c521bdb511 | ||
|
|
abf2d95f6f | ||
|
|
ab58cf0d85 | ||
|
|
fb0111adf2 | ||
|
|
161ee9a2b1 | ||
|
|
0cf75585d5 | ||
|
|
1d8d5f51d9 | ||
|
|
3f8de07c8c | ||
|
|
6ff86e14ec |
@@ -69,6 +69,12 @@
|
||||
- **自定义流程** - YAML 配置扫描流程,灵活编排
|
||||
- **定时扫描** - Cron 表达式配置,自动化周期扫描
|
||||
|
||||
### 🚫 黑名单过滤
|
||||
- **两层黑名单** - 全局黑名单 + Target 级黑名单,灵活控制扫描范围
|
||||
- **智能规则识别** - 自动识别域名通配符(`*.gov`)、IP、CIDR 网段
|
||||
- **敏感目标保护** - 过滤政府、军事、教育等敏感域名,防止误扫
|
||||
- **内网过滤** - 支持 `10.0.0.0/8`、`172.16.0.0/12`、`192.168.0.0/16` 等私有网段
|
||||
|
||||
### 🔖 指纹识别
|
||||
- **多源指纹库** - 内置 EHole、Goby、Wappalyzer、Fingers、FingerPrintHub、ARL 等 2.7W+ 指纹规则
|
||||
- **自动识别** - 扫描流程自动执行,识别 Web 应用技术栈
|
||||
|
||||
@@ -12,16 +12,19 @@ from .views import (
|
||||
AssetStatisticsViewSet,
|
||||
AssetSearchView,
|
||||
AssetSearchExportView,
|
||||
EndpointViewSet,
|
||||
HostPortMappingViewSet,
|
||||
)
|
||||
|
||||
# 创建 DRF 路由器
|
||||
router = DefaultRouter()
|
||||
|
||||
# 注册 ViewSet
|
||||
# 注意:IPAddress 模型已被重构为 HostPortMapping,相关路由已移除
|
||||
router.register(r'subdomains', SubdomainViewSet, basename='subdomain')
|
||||
router.register(r'websites', WebSiteViewSet, basename='website')
|
||||
router.register(r'directories', DirectoryViewSet, basename='directory')
|
||||
router.register(r'endpoints', EndpointViewSet, basename='endpoint')
|
||||
router.register(r'ip-addresses', HostPortMappingViewSet, basename='ip-address')
|
||||
router.register(r'vulnerabilities', VulnerabilityViewSet, basename='vulnerability')
|
||||
router.register(r'statistics', AssetStatisticsViewSet, basename='asset-statistics')
|
||||
|
||||
|
||||
@@ -260,6 +260,35 @@ class SubdomainViewSet(viewsets.ModelViewSet):
|
||||
field_formatters=formatters
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='bulk-delete')
|
||||
def bulk_delete(self, request, **kwargs):
|
||||
"""批量删除子域名
|
||||
|
||||
POST /api/assets/subdomains/bulk-delete/
|
||||
|
||||
请求体: {"ids": [1, 2, 3]}
|
||||
响应: {"deletedCount": 3}
|
||||
"""
|
||||
ids = request.data.get('ids', [])
|
||||
if not ids or not isinstance(ids, list):
|
||||
return error_response(
|
||||
code=ErrorCodes.VALIDATION_ERROR,
|
||||
message='ids is required and must be a list',
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
from ..models import Subdomain
|
||||
deleted_count, _ = Subdomain.objects.filter(id__in=ids).delete()
|
||||
return success_response(data={'deletedCount': deleted_count})
|
||||
except Exception as e:
|
||||
logger.exception("批量删除子域名失败")
|
||||
return error_response(
|
||||
code=ErrorCodes.SERVER_ERROR,
|
||||
message='Failed to delete subdomains',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
class WebSiteViewSet(viewsets.ModelViewSet):
|
||||
"""站点管理 ViewSet
|
||||
@@ -393,6 +422,35 @@ class WebSiteViewSet(viewsets.ModelViewSet):
|
||||
field_formatters=formatters
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='bulk-delete')
|
||||
def bulk_delete(self, request, **kwargs):
|
||||
"""批量删除网站
|
||||
|
||||
POST /api/assets/websites/bulk-delete/
|
||||
|
||||
请求体: {"ids": [1, 2, 3]}
|
||||
响应: {"deletedCount": 3}
|
||||
"""
|
||||
ids = request.data.get('ids', [])
|
||||
if not ids or not isinstance(ids, list):
|
||||
return error_response(
|
||||
code=ErrorCodes.VALIDATION_ERROR,
|
||||
message='ids is required and must be a list',
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
from ..models import WebSite
|
||||
deleted_count, _ = WebSite.objects.filter(id__in=ids).delete()
|
||||
return success_response(data={'deletedCount': deleted_count})
|
||||
except Exception as e:
|
||||
logger.exception("批量删除网站失败")
|
||||
return error_response(
|
||||
code=ErrorCodes.SERVER_ERROR,
|
||||
message='Failed to delete websites',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
class DirectoryViewSet(viewsets.ModelViewSet):
|
||||
"""目录管理 ViewSet
|
||||
@@ -521,6 +579,35 @@ class DirectoryViewSet(viewsets.ModelViewSet):
|
||||
field_formatters=formatters
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='bulk-delete')
|
||||
def bulk_delete(self, request, **kwargs):
|
||||
"""批量删除目录
|
||||
|
||||
POST /api/assets/directories/bulk-delete/
|
||||
|
||||
请求体: {"ids": [1, 2, 3]}
|
||||
响应: {"deletedCount": 3}
|
||||
"""
|
||||
ids = request.data.get('ids', [])
|
||||
if not ids or not isinstance(ids, list):
|
||||
return error_response(
|
||||
code=ErrorCodes.VALIDATION_ERROR,
|
||||
message='ids is required and must be a list',
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
from ..models import Directory
|
||||
deleted_count, _ = Directory.objects.filter(id__in=ids).delete()
|
||||
return success_response(data={'deletedCount': deleted_count})
|
||||
except Exception as e:
|
||||
logger.exception("批量删除目录失败")
|
||||
return error_response(
|
||||
code=ErrorCodes.SERVER_ERROR,
|
||||
message='Failed to delete directories',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
class EndpointViewSet(viewsets.ModelViewSet):
|
||||
"""端点管理 ViewSet
|
||||
@@ -655,6 +742,35 @@ class EndpointViewSet(viewsets.ModelViewSet):
|
||||
field_formatters=formatters
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='bulk-delete')
|
||||
def bulk_delete(self, request, **kwargs):
|
||||
"""批量删除端点
|
||||
|
||||
POST /api/assets/endpoints/bulk-delete/
|
||||
|
||||
请求体: {"ids": [1, 2, 3]}
|
||||
响应: {"deletedCount": 3}
|
||||
"""
|
||||
ids = request.data.get('ids', [])
|
||||
if not ids or not isinstance(ids, list):
|
||||
return error_response(
|
||||
code=ErrorCodes.VALIDATION_ERROR,
|
||||
message='ids is required and must be a list',
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
from ..models import Endpoint
|
||||
deleted_count, _ = Endpoint.objects.filter(id__in=ids).delete()
|
||||
return success_response(data={'deletedCount': deleted_count})
|
||||
except Exception as e:
|
||||
logger.exception("批量删除端点失败")
|
||||
return error_response(
|
||||
code=ErrorCodes.SERVER_ERROR,
|
||||
message='Failed to delete endpoints',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
class HostPortMappingViewSet(viewsets.ModelViewSet):
|
||||
"""主机端口映射管理 ViewSet(IP 地址聚合视图)
|
||||
@@ -728,6 +844,38 @@ class HostPortMappingViewSet(viewsets.ModelViewSet):
|
||||
field_formatters=formatters
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='bulk-delete')
|
||||
def bulk_delete(self, request, **kwargs):
|
||||
"""批量删除 IP 地址映射
|
||||
|
||||
POST /api/assets/ip-addresses/bulk-delete/
|
||||
|
||||
请求体: {"ips": ["192.168.1.1", "10.0.0.1"]}
|
||||
响应: {"deletedCount": 3}
|
||||
|
||||
注意:由于 IP 地址是聚合显示的,删除时传入 IP 列表,
|
||||
会删除该 IP 下的所有 host:port 映射记录
|
||||
"""
|
||||
ips = request.data.get('ips', [])
|
||||
if not ips or not isinstance(ips, list):
|
||||
return error_response(
|
||||
code=ErrorCodes.VALIDATION_ERROR,
|
||||
message='ips is required and must be a list',
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
from ..models import HostPortMapping
|
||||
deleted_count, _ = HostPortMapping.objects.filter(ip__in=ips).delete()
|
||||
return success_response(data={'deletedCount': deleted_count})
|
||||
except Exception as e:
|
||||
logger.exception("批量删除 IP 地址映射失败")
|
||||
return error_response(
|
||||
code=ErrorCodes.SERVER_ERROR,
|
||||
message='Failed to delete ip addresses',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
class VulnerabilityViewSet(viewsets.ModelViewSet):
|
||||
"""漏洞资产管理 ViewSet(只读)
|
||||
|
||||
@@ -17,7 +17,12 @@ from .scan_state_service import ScanStateService
|
||||
from .scan_control_service import ScanControlService
|
||||
from .scan_stats_service import ScanStatsService
|
||||
from .scheduled_scan_service import ScheduledScanService
|
||||
from .target_export_service import TargetExportService
|
||||
from .target_export_service import (
|
||||
TargetExportService,
|
||||
create_export_service,
|
||||
export_urls_with_fallback,
|
||||
DataSource,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'ScanService', # 主入口(向后兼容)
|
||||
@@ -27,5 +32,8 @@ __all__ = [
|
||||
'ScanStatsService',
|
||||
'ScheduledScanService',
|
||||
'TargetExportService', # 目标导出服务
|
||||
'create_export_service',
|
||||
'export_urls_with_fallback',
|
||||
'DataSource',
|
||||
]
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
目标导出服务
|
||||
|
||||
提供统一的目标提取和文件导出功能,支持:
|
||||
- URL 导出(流式写入 + 默认值回退)
|
||||
- URL 导出(纯导出,不做隐式回退)
|
||||
- 默认 URL 生成(独立方法)
|
||||
- 带回退链的 URL 导出(用例层编排)
|
||||
- 域名/IP 导出(用于端口扫描)
|
||||
- 黑名单过滤集成
|
||||
"""
|
||||
@@ -10,7 +12,7 @@
|
||||
import ipaddress
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List
|
||||
from typing import Dict, Any, Optional, List, Callable
|
||||
|
||||
from django.db.models import QuerySet
|
||||
|
||||
@@ -19,6 +21,14 @@ from apps.common.utils import BlacklistFilter
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DataSource:
|
||||
"""数据源类型常量"""
|
||||
ENDPOINT = "endpoint"
|
||||
WEBSITE = "website"
|
||||
HOST_PORT = "host_port"
|
||||
DEFAULT = "default"
|
||||
|
||||
|
||||
def create_export_service(target_id: int) -> 'TargetExportService':
|
||||
"""
|
||||
工厂函数:创建带黑名单过滤的导出服务
|
||||
@@ -36,21 +46,129 @@ def create_export_service(target_id: int) -> 'TargetExportService':
|
||||
return TargetExportService(blacklist_filter=blacklist_filter)
|
||||
|
||||
|
||||
def export_urls_with_fallback(
|
||||
target_id: int,
|
||||
output_file: str,
|
||||
sources: List[str],
|
||||
batch_size: int = 1000
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
带回退链的 URL 导出用例函数
|
||||
|
||||
按 sources 顺序尝试每个数据源,直到有数据返回。
|
||||
|
||||
回退逻辑:
|
||||
1. 遍历 sources 列表
|
||||
2. 对每个 source 构建 queryset 并调用 export_urls()
|
||||
3. 如果 total_count > 0,返回
|
||||
4. 如果 queryset_count > 0 但 total_count == 0(全被黑名单过滤),不回退
|
||||
5. 如果 source == "default",调用 generate_default_urls()
|
||||
|
||||
Args:
|
||||
target_id: 目标 ID
|
||||
output_file: 输出文件路径
|
||||
sources: 数据源优先级列表,如 ["endpoint", "website", "default"]
|
||||
batch_size: 批次大小
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'success': bool,
|
||||
'output_file': str,
|
||||
'total_count': int,
|
||||
'source': str, # 实际使用的数据源
|
||||
'tried_sources': List[str], # 尝试过的数据源
|
||||
}
|
||||
"""
|
||||
from apps.asset.models import Endpoint, WebSite
|
||||
|
||||
export_service = create_export_service(target_id)
|
||||
tried_sources = []
|
||||
|
||||
for source in sources:
|
||||
tried_sources.append(source)
|
||||
|
||||
if source == DataSource.DEFAULT:
|
||||
# 默认 URL 生成
|
||||
result = export_service.generate_default_urls(target_id, output_file)
|
||||
return {
|
||||
'success': result['success'],
|
||||
'output_file': result['output_file'],
|
||||
'total_count': result['total_count'],
|
||||
'source': DataSource.DEFAULT,
|
||||
'tried_sources': tried_sources,
|
||||
}
|
||||
|
||||
# 构建对应数据源的 queryset
|
||||
if source == DataSource.ENDPOINT:
|
||||
queryset = Endpoint.objects.filter(target_id=target_id).values_list('url', flat=True)
|
||||
elif source == DataSource.WEBSITE:
|
||||
queryset = WebSite.objects.filter(target_id=target_id).values_list('url', flat=True)
|
||||
else:
|
||||
logger.warning("未知的数据源类型: %s,跳过", source)
|
||||
continue
|
||||
|
||||
result = export_service.export_urls(
|
||||
target_id=target_id,
|
||||
output_path=output_file,
|
||||
queryset=queryset,
|
||||
batch_size=batch_size
|
||||
)
|
||||
|
||||
# 有数据写入,返回
|
||||
if result['total_count'] > 0:
|
||||
logger.info("从 %s 导出 %d 条 URL", source, result['total_count'])
|
||||
return {
|
||||
'success': result['success'],
|
||||
'output_file': result['output_file'],
|
||||
'total_count': result['total_count'],
|
||||
'source': source,
|
||||
'tried_sources': tried_sources,
|
||||
}
|
||||
|
||||
# 数据存在但全被黑名单过滤,不回退
|
||||
if result['queryset_count'] > 0:
|
||||
logger.info(
|
||||
"%s 有 %d 条数据,但全被黑名单过滤(filtered=%d),不回退",
|
||||
source, result['queryset_count'], result['filtered_count']
|
||||
)
|
||||
return {
|
||||
'success': result['success'],
|
||||
'output_file': result['output_file'],
|
||||
'total_count': 0,
|
||||
'source': source,
|
||||
'tried_sources': tried_sources,
|
||||
}
|
||||
|
||||
# 数据源为空,继续尝试下一个
|
||||
logger.info("%s 为空,尝试下一个数据源", source)
|
||||
|
||||
# 所有数据源都为空
|
||||
logger.warning("所有数据源都为空,无法导出 URL")
|
||||
return {
|
||||
'success': True,
|
||||
'output_file': output_file,
|
||||
'total_count': 0,
|
||||
'source': 'none',
|
||||
'tried_sources': tried_sources,
|
||||
}
|
||||
|
||||
|
||||
class TargetExportService:
|
||||
"""
|
||||
目标导出服务 - 提供统一的目标提取和文件导出功能
|
||||
|
||||
使用方式:
|
||||
from apps.common.services import BlacklistService
|
||||
from apps.common.utils import BlacklistFilter
|
||||
# 方式 1:使用用例函数(推荐)
|
||||
from apps.scan.services.target_export_service import export_urls_with_fallback, DataSource
|
||||
|
||||
# 获取规则并创建过滤器
|
||||
blacklist_service = BlacklistService()
|
||||
rules = blacklist_service.get_rules(target_id)
|
||||
blacklist_filter = BlacklistFilter(rules)
|
||||
result = export_urls_with_fallback(
|
||||
target_id=1,
|
||||
output_file='/path/to/output.txt',
|
||||
sources=[DataSource.ENDPOINT, DataSource.WEBSITE, DataSource.DEFAULT]
|
||||
)
|
||||
|
||||
# 使用导出服务
|
||||
export_service = TargetExportService(blacklist_filter=blacklist_filter)
|
||||
# 方式 2:直接使用 Service(纯导出,不带回退)
|
||||
export_service = create_export_service(target_id)
|
||||
result = export_service.export_urls(target_id, output_path, queryset)
|
||||
"""
|
||||
|
||||
@@ -72,16 +190,14 @@ class TargetExportService:
|
||||
batch_size: int = 1000
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
统一 URL 导出函数
|
||||
纯 URL 导出函数 - 只负责将 queryset 数据写入文件
|
||||
|
||||
自动判断数据库有无数据:
|
||||
- 有数据:流式写入数据库数据到文件
|
||||
- 无数据:调用默认值生成器生成 URL
|
||||
不做任何隐式回退或默认 URL 生成。
|
||||
|
||||
Args:
|
||||
target_id: 目标 ID
|
||||
output_path: 输出文件路径
|
||||
queryset: 数据源 queryset(由 Task 层构建,应为 values_list flat=True)
|
||||
queryset: 数据源 queryset(由调用方构建,应为 values_list flat=True)
|
||||
url_field: URL 字段名(用于黑名单过滤)
|
||||
batch_size: 批次大小
|
||||
|
||||
@@ -89,7 +205,9 @@ class TargetExportService:
|
||||
dict: {
|
||||
'success': bool,
|
||||
'output_file': str,
|
||||
'total_count': int
|
||||
'total_count': int, # 实际写入数量
|
||||
'queryset_count': int, # 原始数据数量(迭代计数)
|
||||
'filtered_count': int, # 被黑名单过滤的数量
|
||||
}
|
||||
|
||||
Raises:
|
||||
@@ -102,9 +220,12 @@ class TargetExportService:
|
||||
|
||||
total_count = 0
|
||||
filtered_count = 0
|
||||
queryset_count = 0
|
||||
|
||||
try:
|
||||
with open(output_file, 'w', encoding='utf-8', buffering=8192) as f:
|
||||
for url in queryset.iterator(chunk_size=batch_size):
|
||||
queryset_count += 1
|
||||
if url:
|
||||
# 黑名单过滤
|
||||
if self.blacklist_filter and not self.blacklist_filter.is_allowed(url):
|
||||
@@ -122,25 +243,26 @@ class TargetExportService:
|
||||
if filtered_count > 0:
|
||||
logger.info("黑名单过滤: 过滤 %d 个 URL", filtered_count)
|
||||
|
||||
# 默认值回退模式
|
||||
if total_count == 0:
|
||||
total_count = self._generate_default_urls(target_id, output_file)
|
||||
|
||||
logger.info("✓ URL 导出完成 - 数量: %d, 文件: %s", total_count, output_path)
|
||||
logger.info(
|
||||
"✓ URL 导出完成 - 写入: %d, 原始: %d, 过滤: %d, 文件: %s",
|
||||
total_count, queryset_count, filtered_count, output_path
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'output_file': str(output_file),
|
||||
'total_count': total_count
|
||||
'total_count': total_count,
|
||||
'queryset_count': queryset_count,
|
||||
'filtered_count': filtered_count,
|
||||
}
|
||||
|
||||
def _generate_default_urls(
|
||||
def generate_default_urls(
|
||||
self,
|
||||
target_id: int,
|
||||
output_path: Path
|
||||
) -> int:
|
||||
output_path: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
默认值生成器(内部函数)
|
||||
默认 URL 生成器
|
||||
|
||||
根据 Target 类型生成默认 URL:
|
||||
- DOMAIN: http(s)://domain
|
||||
@@ -153,26 +275,37 @@ class TargetExportService:
|
||||
output_path: 输出文件路径
|
||||
|
||||
Returns:
|
||||
int: 写入的 URL 总数
|
||||
dict: {
|
||||
'success': bool,
|
||||
'output_file': str,
|
||||
'total_count': int,
|
||||
}
|
||||
"""
|
||||
from apps.targets.services import TargetService
|
||||
from apps.targets.models import Target
|
||||
|
||||
output_file = Path(output_path)
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
target_service = TargetService()
|
||||
target = target_service.get_target(target_id)
|
||||
|
||||
if not target:
|
||||
logger.warning("Target ID %d 不存在,无法生成默认 URL", target_id)
|
||||
return 0
|
||||
return {
|
||||
'success': True,
|
||||
'output_file': str(output_file),
|
||||
'total_count': 0,
|
||||
}
|
||||
|
||||
target_name = target.name
|
||||
target_type = target.type
|
||||
|
||||
logger.info("懒加载模式:Target 类型=%s, 名称=%s", target_type, target_name)
|
||||
logger.info("生成默认 URL:Target 类型=%s, 名称=%s", target_type, target_name)
|
||||
|
||||
total_urls = 0
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8', buffering=8192) as f:
|
||||
with open(output_file, 'w', encoding='utf-8', buffering=8192) as f:
|
||||
if target_type == Target.TargetType.DOMAIN:
|
||||
urls = [f"http://{target_name}", f"https://{target_name}"]
|
||||
for url in urls:
|
||||
@@ -221,8 +354,13 @@ class TargetExportService:
|
||||
else:
|
||||
logger.warning("不支持的 Target 类型: %s", target_type)
|
||||
|
||||
logger.info("✓ 懒加载生成默认 URL - 数量: %d", total_urls)
|
||||
return total_urls
|
||||
logger.info("✓ 默认 URL 生成完成 - 数量: %d", total_urls)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'output_file': str(output_file),
|
||||
'total_count': total_urls,
|
||||
}
|
||||
|
||||
def _should_write_url(self, url: str) -> bool:
|
||||
"""检查 URL 是否应该写入(通过黑名单过滤)"""
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"""
|
||||
导出站点 URL 到 TXT 文件的 Task
|
||||
|
||||
使用 TargetExportService 统一处理导出逻辑和默认值回退
|
||||
数据源: WebSite.url
|
||||
使用 export_urls_with_fallback 用例函数处理回退链逻辑
|
||||
数据源: WebSite.url → Default
|
||||
"""
|
||||
import logging
|
||||
from prefect import task
|
||||
|
||||
from apps.asset.models import WebSite
|
||||
from apps.scan.services import TargetExportService
|
||||
from apps.scan.services.target_export_service import create_export_service
|
||||
from apps.scan.services.target_export_service import (
|
||||
export_urls_with_fallback,
|
||||
DataSource,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,13 +24,9 @@ def export_sites_task(
|
||||
"""
|
||||
导出目标下的所有站点 URL 到 TXT 文件
|
||||
|
||||
数据源: WebSite.url
|
||||
|
||||
懒加载模式:
|
||||
- 如果数据库为空,根据 Target 类型生成默认 URL
|
||||
- DOMAIN: http(s)://domain
|
||||
- IP: http(s)://ip
|
||||
- CIDR: 展开为所有 IP 的 URL
|
||||
数据源优先级(回退链):
|
||||
1. WebSite 表 - 站点级别 URL
|
||||
2. 默认生成 - 根据 Target 类型生成 http(s)://target_name
|
||||
|
||||
Args:
|
||||
target_id: 目标 ID
|
||||
@@ -47,25 +44,21 @@ def export_sites_task(
|
||||
ValueError: 参数错误
|
||||
IOError: 文件写入失败
|
||||
"""
|
||||
# 构建数据源 queryset(Task 层决定数据源)
|
||||
queryset = WebSite.objects.filter(target_id=target_id).values_list('url', flat=True)
|
||||
|
||||
# 使用工厂函数创建导出服务
|
||||
export_service = create_export_service(target_id)
|
||||
|
||||
result = export_service.export_urls(
|
||||
result = export_urls_with_fallback(
|
||||
target_id=target_id,
|
||||
output_path=output_file,
|
||||
queryset=queryset,
|
||||
batch_size=batch_size
|
||||
output_file=output_file,
|
||||
sources=[DataSource.WEBSITE, DataSource.DEFAULT],
|
||||
batch_size=batch_size,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"站点 URL 导出完成 - source=%s, count=%d",
|
||||
result['source'], result['total_count']
|
||||
)
|
||||
|
||||
# 保持返回值格式不变(向后兼容)
|
||||
return {
|
||||
'success': result['success'],
|
||||
'output_file': result['output_file'],
|
||||
'total_count': result['total_count']
|
||||
'total_count': result['total_count'],
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
导出 URL 任务
|
||||
|
||||
用于指纹识别前导出目标下的 URL 到文件
|
||||
使用 TargetExportService 统一处理导出逻辑和默认值回退
|
||||
使用 export_urls_with_fallback 用例函数处理回退链逻辑
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from prefect import task
|
||||
|
||||
from apps.asset.models import WebSite
|
||||
from apps.scan.services.target_export_service import create_export_service
|
||||
from apps.scan.services.target_export_service import (
|
||||
export_urls_with_fallback,
|
||||
DataSource,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,46 +21,40 @@ logger = logging.getLogger(__name__)
|
||||
def export_urls_for_fingerprint_task(
|
||||
target_id: int,
|
||||
output_file: str,
|
||||
source: str = 'website',
|
||||
source: str = 'website', # 保留参数,兼容旧调用(实际值由回退链决定)
|
||||
batch_size: int = 1000
|
||||
) -> dict:
|
||||
"""
|
||||
导出目标下的 URL 到文件(用于指纹识别)
|
||||
|
||||
数据源: WebSite.url
|
||||
|
||||
懒加载模式:
|
||||
- 如果数据库为空,根据 Target 类型生成默认 URL
|
||||
- DOMAIN: http(s)://domain
|
||||
- IP: http(s)://ip
|
||||
- CIDR: 展开为所有 IP 的 URL
|
||||
- URL: 直接使用目标 URL
|
||||
数据源优先级(回退链):
|
||||
1. WebSite 表 - 站点级别 URL
|
||||
2. 默认生成 - 根据 Target 类型生成 http(s)://target_name
|
||||
|
||||
Args:
|
||||
target_id: 目标 ID
|
||||
output_file: 输出文件路径
|
||||
source: 数据源类型(保留参数,兼容旧调用)
|
||||
source: 数据源类型(保留参数,兼容旧调用,实际值由回退链决定)
|
||||
batch_size: 批量读取大小
|
||||
|
||||
Returns:
|
||||
dict: {'output_file': str, 'total_count': int, 'source': str}
|
||||
"""
|
||||
# 构建数据源 queryset(Task 层决定数据源)
|
||||
queryset = WebSite.objects.filter(target_id=target_id).values_list('url', flat=True)
|
||||
|
||||
# 使用工厂函数创建导出服务
|
||||
export_service = create_export_service(target_id)
|
||||
|
||||
result = export_service.export_urls(
|
||||
result = export_urls_with_fallback(
|
||||
target_id=target_id,
|
||||
output_path=output_file,
|
||||
queryset=queryset,
|
||||
batch_size=batch_size
|
||||
output_file=output_file,
|
||||
sources=[DataSource.WEBSITE, DataSource.DEFAULT],
|
||||
batch_size=batch_size,
|
||||
)
|
||||
|
||||
# 保持返回值格式不变(向后兼容)
|
||||
logger.info(
|
||||
"指纹识别 URL 导出完成 - source=%s, count=%d",
|
||||
result['source'], result['total_count']
|
||||
)
|
||||
|
||||
# 返回实际使用的数据源(不再固定为 "website")
|
||||
return {
|
||||
'output_file': result['output_file'],
|
||||
'total_count': result['total_count'],
|
||||
'source': source
|
||||
'source': result['source'],
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
导出站点URL到文件的Task
|
||||
|
||||
直接使用 HostPortMapping 表查询 host+port 组合,拼接成URL格式写入文件
|
||||
使用 TargetExportService 处理默认值回退逻辑
|
||||
使用 TargetExportService.generate_default_urls() 处理默认值回退逻辑
|
||||
|
||||
特殊逻辑:
|
||||
- 80 端口:只生成 HTTP URL(省略端口号)
|
||||
@@ -46,18 +46,15 @@ def export_site_urls_task(
|
||||
"""
|
||||
导出目标下的所有站点URL到文件(基于 HostPortMapping 表)
|
||||
|
||||
数据源: HostPortMapping (host + port)
|
||||
数据源: HostPortMapping (host + port) → Default
|
||||
|
||||
特殊逻辑:
|
||||
- 80 端口:只生成 HTTP URL(省略端口号)
|
||||
- 443 端口:只生成 HTTPS URL(省略端口号)
|
||||
- 其他端口:生成 HTTP 和 HTTPS 两个URL(带端口号)
|
||||
|
||||
懒加载模式:
|
||||
- 如果数据库为空,根据 Target 类型生成默认 URL
|
||||
- DOMAIN: http(s)://domain
|
||||
- IP: http(s)://ip
|
||||
- CIDR: 展开为所有 IP 的 URL
|
||||
回退逻辑:
|
||||
- 如果 HostPortMapping 为空,使用 generate_default_urls() 生成默认 URL
|
||||
|
||||
Args:
|
||||
target_id: 目标ID
|
||||
@@ -69,7 +66,8 @@ def export_site_urls_task(
|
||||
'success': bool,
|
||||
'output_file': str,
|
||||
'total_urls': int,
|
||||
'association_count': int # 主机端口关联数量
|
||||
'association_count': int, # 主机端口关联数量
|
||||
'source': str, # 数据来源: "host_port" | "default"
|
||||
}
|
||||
|
||||
Raises:
|
||||
@@ -94,6 +92,7 @@ def export_site_urls_task(
|
||||
|
||||
total_urls = 0
|
||||
association_count = 0
|
||||
filtered_count = 0
|
||||
|
||||
# 流式写入文件(特殊端口逻辑)
|
||||
with open(output_path, 'w', encoding='utf-8', buffering=8192) as f:
|
||||
@@ -104,6 +103,7 @@ def export_site_urls_task(
|
||||
|
||||
# 先校验 host,通过了再生成 URL
|
||||
if not blacklist_filter.is_allowed(host):
|
||||
filtered_count += 1
|
||||
continue
|
||||
|
||||
# 根据端口号生成URL
|
||||
@@ -114,19 +114,40 @@ def export_site_urls_task(
|
||||
if association_count % 1000 == 0:
|
||||
logger.info("已处理 %d 条关联,生成 %d 个URL...", association_count, total_urls)
|
||||
|
||||
if filtered_count > 0:
|
||||
logger.info("黑名单过滤: 过滤 %d 条关联", filtered_count)
|
||||
|
||||
logger.info(
|
||||
"✓ 站点URL导出完成 - 关联数: %d, 总URL数: %d, 文件: %s",
|
||||
association_count, total_urls, str(output_path)
|
||||
)
|
||||
|
||||
# 默认值回退模式:使用工厂函数创建导出服务
|
||||
# 判断数据来源
|
||||
source = "host_port"
|
||||
|
||||
# 数据存在但全被过滤,不回退
|
||||
if association_count > 0 and total_urls == 0:
|
||||
logger.info("HostPortMapping 有 %d 条数据,但全被黑名单过滤,不回退", association_count)
|
||||
return {
|
||||
'success': True,
|
||||
'output_file': str(output_path),
|
||||
'total_urls': 0,
|
||||
'association_count': association_count,
|
||||
'source': source,
|
||||
}
|
||||
|
||||
# 数据源为空,回退到默认 URL 生成
|
||||
if total_urls == 0:
|
||||
logger.info("HostPortMapping 为空,使用默认 URL 生成")
|
||||
export_service = create_export_service(target_id)
|
||||
total_urls = export_service._generate_default_urls(target_id, output_path)
|
||||
result = export_service.generate_default_urls(target_id, str(output_path))
|
||||
total_urls = result['total_count']
|
||||
source = "default"
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'output_file': str(output_path),
|
||||
'total_urls': total_urls,
|
||||
'association_count': association_count
|
||||
'association_count': association_count,
|
||||
'source': source,
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"""
|
||||
导出站点 URL 列表任务
|
||||
|
||||
使用 TargetExportService 统一处理导出逻辑和默认值回退
|
||||
数据源: WebSite.url(用于 katana 等爬虫工具)
|
||||
使用 export_urls_with_fallback 用例函数处理回退链逻辑
|
||||
数据源: WebSite.url → Default(用于 katana 等爬虫工具)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from prefect import task
|
||||
from typing import Optional
|
||||
|
||||
from apps.asset.models import WebSite
|
||||
from apps.scan.services.target_export_service import create_export_service
|
||||
from apps.scan.services.target_export_service import (
|
||||
export_urls_with_fallback,
|
||||
DataSource,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,13 +30,9 @@ def export_sites_task(
|
||||
"""
|
||||
导出站点 URL 列表到文件(用于 katana 等爬虫工具)
|
||||
|
||||
数据源: WebSite.url
|
||||
|
||||
懒加载模式:
|
||||
- 如果数据库为空,根据 Target 类型生成默认 URL
|
||||
- DOMAIN: http(s)://domain
|
||||
- IP: http(s)://ip
|
||||
- CIDR: 展开为所有 IP 的 URL
|
||||
数据源优先级(回退链):
|
||||
1. WebSite 表 - 站点级别 URL
|
||||
2. 默认生成 - 根据 Target 类型生成 http(s)://target_name
|
||||
|
||||
Args:
|
||||
output_file: 输出文件路径
|
||||
@@ -53,17 +50,16 @@ def export_sites_task(
|
||||
ValueError: 参数错误
|
||||
RuntimeError: 执行失败
|
||||
"""
|
||||
# 构建数据源 queryset(Task 层决定数据源)
|
||||
queryset = WebSite.objects.filter(target_id=target_id).values_list('url', flat=True)
|
||||
|
||||
# 使用工厂函数创建导出服务
|
||||
export_service = create_export_service(target_id)
|
||||
|
||||
result = export_service.export_urls(
|
||||
result = export_urls_with_fallback(
|
||||
target_id=target_id,
|
||||
output_path=output_file,
|
||||
queryset=queryset,
|
||||
batch_size=batch_size
|
||||
output_file=output_file,
|
||||
sources=[DataSource.WEBSITE, DataSource.DEFAULT],
|
||||
batch_size=batch_size,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"站点 URL 导出完成 - source=%s, count=%d",
|
||||
result['source'], result['total_count']
|
||||
)
|
||||
|
||||
# 保持返回值格式不变(向后兼容)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""导出 Endpoint URL 到文件的 Task
|
||||
|
||||
使用 TargetExportService 统一处理导出逻辑和默认值回退
|
||||
使用 export_urls_with_fallback 用例函数处理回退链逻辑
|
||||
|
||||
数据源优先级(回退链):
|
||||
1. Endpoint.url - 最精细的 URL(含路径、参数等)
|
||||
@@ -9,13 +9,14 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from prefect import task
|
||||
|
||||
from apps.asset.models import Endpoint, WebSite
|
||||
from apps.scan.services.target_export_service import create_export_service
|
||||
from apps.scan.services.target_export_service import (
|
||||
export_urls_with_fallback,
|
||||
DataSource,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,55 +44,24 @@ def export_endpoints_task(
|
||||
"success": bool,
|
||||
"output_file": str,
|
||||
"total_count": int,
|
||||
"source": str, # 数据来源: "endpoint" | "website" | "default"
|
||||
"source": str, # 数据来源: "endpoint" | "website" | "default" | "none"
|
||||
}
|
||||
"""
|
||||
export_service = create_export_service(target_id)
|
||||
output_path = Path(output_file)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 1. 优先从 Endpoint 表导出
|
||||
endpoint_queryset = Endpoint.objects.filter(target_id=target_id).values_list('url', flat=True)
|
||||
result = export_service.export_urls(
|
||||
result = export_urls_with_fallback(
|
||||
target_id=target_id,
|
||||
output_path=output_file,
|
||||
queryset=endpoint_queryset,
|
||||
batch_size=batch_size
|
||||
output_file=output_file,
|
||||
sources=[DataSource.ENDPOINT, DataSource.WEBSITE, DataSource.DEFAULT],
|
||||
batch_size=batch_size,
|
||||
)
|
||||
|
||||
if result['total_count'] > 0:
|
||||
logger.info("从 Endpoint 表导出 %d 条 URL", result['total_count'])
|
||||
return {
|
||||
"success": True,
|
||||
"output_file": result['output_file'],
|
||||
"total_count": result['total_count'],
|
||||
"source": "endpoint",
|
||||
}
|
||||
|
||||
# 2. Endpoint 为空,回退到 WebSite 表
|
||||
logger.info("Endpoint 表为空,回退到 WebSite 表")
|
||||
website_queryset = WebSite.objects.filter(target_id=target_id).values_list('url', flat=True)
|
||||
result = export_service.export_urls(
|
||||
target_id=target_id,
|
||||
output_path=output_file,
|
||||
queryset=website_queryset,
|
||||
batch_size=batch_size
|
||||
logger.info(
|
||||
"URL 导出完成 - source=%s, count=%d, tried=%s",
|
||||
result['source'], result['total_count'], result['tried_sources']
|
||||
)
|
||||
|
||||
if result['total_count'] > 0:
|
||||
logger.info("从 WebSite 表导出 %d 条 URL", result['total_count'])
|
||||
return {
|
||||
"success": True,
|
||||
"output_file": result['output_file'],
|
||||
"total_count": result['total_count'],
|
||||
"source": "website",
|
||||
}
|
||||
|
||||
# 3. WebSite 也为空,生成默认 URL(export_urls 内部已处理)
|
||||
logger.info("WebSite 表也为空,使用默认 URL 生成")
|
||||
return {
|
||||
"success": True,
|
||||
"success": result['success'],
|
||||
"output_file": result['output_file'],
|
||||
"total_count": result['total_count'],
|
||||
"source": "default",
|
||||
"source": result['source'],
|
||||
}
|
||||
|
||||
@@ -182,12 +182,12 @@ class BatchCreateTargetSerializer(serializers.Serializer):
|
||||
批量创建目标的序列化器
|
||||
|
||||
安全限制:
|
||||
- 最多支持 1000 个目标的批量创建
|
||||
- 最多支持 5000 个目标的批量创建
|
||||
- 防止恶意用户提交大量数据导致服务器过载
|
||||
"""
|
||||
|
||||
# 批量创建的最大数量限制
|
||||
MAX_BATCH_SIZE = 1000
|
||||
MAX_BATCH_SIZE = 5000
|
||||
|
||||
# 目标列表
|
||||
targets = serializers.ListField(
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useTargetDirectories, useScanDirectories } from "@/hooks/use-directorie
|
||||
import { useTarget } from "@/hooks/use-targets"
|
||||
import { DirectoryService } from "@/services/directory.service"
|
||||
import { BulkAddUrlsDialog } from "@/components/common/bulk-add-urls-dialog"
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
|
||||
import { getDateLocale } from "@/lib/date-utils"
|
||||
import type { TargetType } from "@/lib/url-validator"
|
||||
import type { Directory } from "@/types/directory.types"
|
||||
@@ -29,6 +30,8 @@ export function DirectoriesView({
|
||||
})
|
||||
const [selectedDirectories, setSelectedDirectories] = useState<Directory[]>([])
|
||||
const [bulkAddDialogOpen, setBulkAddDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const [filterQuery, setFilterQuery] = useState("")
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
@@ -240,6 +243,26 @@ export function DirectoriesView({
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// Handle bulk delete
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedDirectories.length === 0) return
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const ids = selectedDirectories.map(d => d.id)
|
||||
const result = await DirectoryService.bulkDelete(ids)
|
||||
toast.success(tToast("deleteSuccess", { count: result.deletedCount }))
|
||||
setSelectedDirectories([])
|
||||
setDeleteDialogOpen(false)
|
||||
refetch()
|
||||
} catch (error) {
|
||||
console.error("Failed to delete directories", error)
|
||||
toast.error(tToast("deleteFailed"))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
@@ -280,6 +303,7 @@ export function DirectoriesView({
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
onDownloadSelected={handleDownloadSelected}
|
||||
onBulkDelete={targetId ? () => setDeleteDialogOpen(true) : undefined}
|
||||
onBulkAdd={targetId ? () => setBulkAddDialogOpen(true) : undefined}
|
||||
/>
|
||||
|
||||
@@ -295,6 +319,17 @@ export function DirectoriesView({
|
||||
onSuccess={() => refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title={tCommon("actions.confirmDelete")}
|
||||
description={tCommon("actions.deleteConfirmMessage", { count: selectedDirectories.length })}
|
||||
onConfirm={handleBulkDelete}
|
||||
loading={isDeleting}
|
||||
variant="destructive"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ interface EndpointsDataTableProps<TData extends { id: number | string }, TValue>
|
||||
onAddNew?: () => void
|
||||
addButtonText?: string
|
||||
onSelectionChange?: (selectedRows: TData[]) => void
|
||||
onBulkDelete?: () => void
|
||||
pagination?: { pageIndex: number; pageSize: number }
|
||||
onPaginationChange?: (pagination: { pageIndex: number; pageSize: number }) => void
|
||||
totalCount?: number
|
||||
@@ -54,6 +55,7 @@ export function EndpointsDataTable<TData extends { id: number | string }, TValue
|
||||
onAddNew,
|
||||
addButtonText = "Add",
|
||||
onSelectionChange,
|
||||
onBulkDelete,
|
||||
pagination: externalPagination,
|
||||
onPaginationChange,
|
||||
totalCount,
|
||||
@@ -135,7 +137,8 @@ export function EndpointsDataTable<TData extends { id: number | string }, TValue
|
||||
// Selection
|
||||
onSelectionChange={onSelectionChange}
|
||||
// Bulk operations
|
||||
showBulkDelete={false}
|
||||
onBulkDelete={onBulkDelete}
|
||||
bulkDeleteLabel="Delete"
|
||||
onAddNew={onAddNew}
|
||||
addButtonLabel={addButtonText}
|
||||
// Bulk add button
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createEndpointColumns } from "./endpoints-columns"
|
||||
import { LoadingSpinner } from "@/components/loading-spinner"
|
||||
import { DataTableSkeleton } from "@/components/ui/data-table-skeleton"
|
||||
import { BulkAddUrlsDialog } from "@/components/common/bulk-add-urls-dialog"
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
|
||||
import { getDateLocale } from "@/lib/date-utils"
|
||||
import type { TargetType } from "@/lib/url-validator"
|
||||
import {
|
||||
@@ -41,6 +42,8 @@ export function EndpointsDetailView({
|
||||
const [endpointToDelete, setEndpointToDelete] = useState<Endpoint | null>(null)
|
||||
const [selectedEndpoints, setSelectedEndpoints] = useState<Endpoint[]>([])
|
||||
const [bulkAddDialogOpen, setBulkAddDialogOpen] = useState(false)
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
// Pagination state management
|
||||
const [pagination, setPagination] = useState({
|
||||
@@ -280,6 +283,26 @@ export function EndpointsDetailView({
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// Handle bulk delete
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedEndpoints.length === 0) return
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const ids = selectedEndpoints.map(e => e.id)
|
||||
const result = await EndpointService.bulkDelete(ids)
|
||||
toast.success(tToast("deleteSuccess", { count: result.deletedCount }))
|
||||
setSelectedEndpoints([])
|
||||
setBulkDeleteDialogOpen(false)
|
||||
refetch()
|
||||
} catch (error) {
|
||||
console.error("Failed to delete endpoints", error)
|
||||
toast.error(tToast("deleteFailed"))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
@@ -327,6 +350,7 @@ export function EndpointsDetailView({
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
onDownloadSelected={handleDownloadSelected}
|
||||
onBulkDelete={targetId ? () => setBulkDeleteDialogOpen(true) : undefined}
|
||||
onBulkAdd={targetId ? () => setBulkAddDialogOpen(true) : undefined}
|
||||
/>
|
||||
|
||||
@@ -343,7 +367,18 @@ export function EndpointsDetailView({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
{/* Bulk delete confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
open={bulkDeleteDialogOpen}
|
||||
onOpenChange={setBulkDeleteDialogOpen}
|
||||
title={tConfirm("deleteTitle")}
|
||||
description={tCommon("actions.deleteConfirmMessage", { count: selectedEndpoints.length })}
|
||||
onConfirm={handleBulkDelete}
|
||||
loading={isDeleting}
|
||||
variant="destructive"
|
||||
/>
|
||||
|
||||
{/* Single delete confirmation dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createIPAddressColumns } from "./ip-addresses-columns"
|
||||
import { DataTableSkeleton } from "@/components/ui/data-table-skeleton"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useTargetIPAddresses, useScanIPAddresses } from "@/hooks/use-ip-addresses"
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
|
||||
import { getDateLocale } from "@/lib/date-utils"
|
||||
import type { IPAddress } from "@/types/ip-address.types"
|
||||
import { IPAddressService } from "@/services/ip-address.service"
|
||||
@@ -26,6 +27,8 @@ export function IPAddressesView({
|
||||
})
|
||||
const [selectedIPAddresses, setSelectedIPAddresses] = useState<IPAddress[]>([])
|
||||
const [filterQuery, setFilterQuery] = useState("")
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
// Internationalization
|
||||
const tColumns = useTranslations("columns")
|
||||
@@ -215,6 +218,27 @@ export function IPAddressesView({
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// Handle bulk delete
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedIPAddresses.length === 0) return
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
// IP addresses are aggregated, pass IP strings instead of IDs
|
||||
const ips = selectedIPAddresses.map(ip => ip.ip)
|
||||
const result = await IPAddressService.bulkDelete(ips)
|
||||
toast.success(tToast("deleteSuccess", { count: result.deletedCount }))
|
||||
setSelectedIPAddresses([])
|
||||
setDeleteDialogOpen(false)
|
||||
refetch()
|
||||
} catch (error) {
|
||||
console.error("Failed to delete IP addresses", error)
|
||||
toast.error(tToast("deleteFailed"))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
@@ -253,6 +277,18 @@ export function IPAddressesView({
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
onDownloadSelected={handleDownloadSelected}
|
||||
onBulkDelete={targetId ? () => setDeleteDialogOpen(true) : undefined}
|
||||
/>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title={tCommon("actions.confirmDelete")}
|
||||
description={tCommon("actions.deleteConfirmMessage", { count: selectedIPAddresses.length })}
|
||||
onConfirm={handleBulkDelete}
|
||||
loading={isDeleting}
|
||||
variant="destructive"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -14,8 +14,10 @@ import { createSubdomainColumns } from "./subdomains-columns"
|
||||
import { DataTableSkeleton } from "@/components/ui/data-table-skeleton"
|
||||
import { SubdomainService } from "@/services/subdomain.service"
|
||||
import { BulkAddSubdomainsDialog } from "./bulk-add-subdomains-dialog"
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
|
||||
import { getDateLocale } from "@/lib/date-utils"
|
||||
import type { Subdomain } from "@/types/subdomain.types"
|
||||
import { toast } from "sonner"
|
||||
|
||||
/**
|
||||
* Subdomain detail view component
|
||||
@@ -31,11 +33,14 @@ export function SubdomainsDetailView({
|
||||
scanId?: number
|
||||
}) {
|
||||
const [selectedSubdomains, setSelectedSubdomains] = useState<Subdomain[]>([])
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
// Internationalization
|
||||
const tColumns = useTranslations("columns")
|
||||
const tCommon = useTranslations("common")
|
||||
const tSubdomains = useTranslations("subdomains")
|
||||
const tToast = useTranslations("toast")
|
||||
const locale = useLocale()
|
||||
|
||||
// Build translation object
|
||||
@@ -215,6 +220,26 @@ export function SubdomainsDetailView({
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// Handle bulk delete
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedSubdomains.length === 0) return
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const ids = selectedSubdomains.map(s => s.id)
|
||||
const result = await SubdomainService.bulkDeleteSubdomains(ids)
|
||||
toast.success(tToast("deleteSuccess", { count: result.deletedCount }))
|
||||
setSelectedSubdomains([])
|
||||
setDeleteDialogOpen(false)
|
||||
refetch()
|
||||
} catch (error) {
|
||||
console.error("Failed to delete subdomains", error)
|
||||
toast.error(tToast("deleteFailed"))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Create column definitions
|
||||
const subdomainColumns = useMemo(
|
||||
() =>
|
||||
@@ -279,6 +304,7 @@ export function SubdomainsDetailView({
|
||||
isSearching={isSearching}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
onDownloadSelected={handleDownloadSelected}
|
||||
onBulkDelete={targetId ? () => setDeleteDialogOpen(true) : undefined}
|
||||
pagination={pagination}
|
||||
setPagination={setPagination}
|
||||
paginationInfo={{
|
||||
@@ -301,6 +327,17 @@ export function SubdomainsDetailView({
|
||||
onSuccess={() => refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title={tCommon("actions.confirmDelete")}
|
||||
description={tCommon("actions.deleteConfirmMessage", { count: selectedSubdomains.length })}
|
||||
onConfirm={handleBulkDelete}
|
||||
loading={isDeleting}
|
||||
variant="destructive"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
66
frontend/components/ui/confirm-dialog.tsx
Normal file
66
frontend/components/ui/confirm-dialog.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description: string
|
||||
onConfirm: () => void | Promise<void>
|
||||
loading?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
onConfirm,
|
||||
loading = false,
|
||||
variant = "default",
|
||||
confirmText,
|
||||
cancelText,
|
||||
}: ConfirmDialogProps) {
|
||||
const t = useTranslations("common.actions")
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
{cancelText || t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={variant}
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? t("processing") : (confirmText || t("confirm"))}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useTargetWebSites, useScanWebSites } from "@/hooks/use-websites"
|
||||
import { useTarget } from "@/hooks/use-targets"
|
||||
import { WebsiteService } from "@/services/website.service"
|
||||
import { BulkAddUrlsDialog } from "@/components/common/bulk-add-urls-dialog"
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
|
||||
import { getDateLocale } from "@/lib/date-utils"
|
||||
import type { TargetType } from "@/lib/url-validator"
|
||||
import type { WebSite } from "@/types/website.types"
|
||||
@@ -29,6 +30,8 @@ export function WebSitesView({
|
||||
})
|
||||
const [selectedWebSites, setSelectedWebSites] = useState<WebSite[]>([])
|
||||
const [bulkAddDialogOpen, setBulkAddDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const [filterQuery, setFilterQuery] = useState("")
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
@@ -251,6 +254,26 @@ export function WebSitesView({
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// Handle bulk delete
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedWebSites.length === 0) return
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const ids = selectedWebSites.map(w => w.id)
|
||||
const result = await WebsiteService.bulkDelete(ids)
|
||||
toast.success(tToast("deleteSuccess", { count: result.deletedCount }))
|
||||
setSelectedWebSites([])
|
||||
setDeleteDialogOpen(false)
|
||||
refetch()
|
||||
} catch (error) {
|
||||
console.error("Failed to delete websites", error)
|
||||
toast.error(tToast("deleteFailed"))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
@@ -291,6 +314,7 @@ export function WebSitesView({
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
onDownloadSelected={handleDownloadSelected}
|
||||
onBulkDelete={targetId ? () => setDeleteDialogOpen(true) : undefined}
|
||||
onBulkAdd={targetId ? () => setBulkAddDialogOpen(true) : undefined}
|
||||
/>
|
||||
|
||||
@@ -306,6 +330,17 @@ export function WebSitesView({
|
||||
onSuccess={() => refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title={tCommon("actions.confirmDelete")}
|
||||
description={tCommon("actions.deleteConfirmMessage", { count: selectedWebSites.length })}
|
||||
onConfirm={handleBulkDelete}
|
||||
loading={isDeleting}
|
||||
variant="destructive"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -173,7 +173,10 @@
|
||||
"selectAll": "Select all",
|
||||
"selectRow": "Select row",
|
||||
"website": "Website",
|
||||
"description": "Description"
|
||||
"description": "Description",
|
||||
"processing": "Processing...",
|
||||
"confirmDelete": "Confirm Delete",
|
||||
"deleteConfirmMessage": "Are you sure you want to delete {count} selected items? This action cannot be undone."
|
||||
},
|
||||
"yamlEditor": {
|
||||
"syntaxError": "Syntax Error",
|
||||
@@ -1456,8 +1459,9 @@
|
||||
"copied": "Copied",
|
||||
"copyFailed": "Copy failed",
|
||||
"downloadFailed": "Download failed",
|
||||
"deletedScanRecord": "Deleted scan record: {name}",
|
||||
"deleteSuccess": "Deleted successfully: {count} items",
|
||||
"deleteFailed": "Delete failed, please retry",
|
||||
"deletedScanRecord": "Deleted scan record: {name}",
|
||||
"stoppedScan": "Stopped scan task: {name}",
|
||||
"stopFailed": "Stop failed, please retry",
|
||||
"bulkDeleteSuccess": "Deleted {count} scan records",
|
||||
|
||||
@@ -173,7 +173,10 @@
|
||||
"selectAll": "全选",
|
||||
"selectRow": "选择行",
|
||||
"website": "官网",
|
||||
"description": "描述"
|
||||
"description": "描述",
|
||||
"processing": "处理中...",
|
||||
"confirmDelete": "确认删除",
|
||||
"deleteConfirmMessage": "确定要删除选中的 {count} 条记录吗?此操作不可撤销。"
|
||||
},
|
||||
"yamlEditor": {
|
||||
"syntaxError": "语法错误",
|
||||
@@ -1456,8 +1459,9 @@
|
||||
"copied": "已复制",
|
||||
"copyFailed": "复制失败",
|
||||
"downloadFailed": "下载失败",
|
||||
"deletedScanRecord": "已删除扫描记录: {name}",
|
||||
"deleteSuccess": "删除成功:{count} 条",
|
||||
"deleteFailed": "删除失败,请重试",
|
||||
"deletedScanRecord": "已删除扫描记录: {name}",
|
||||
"stoppedScan": "已停止扫描任务: {name}",
|
||||
"stopFailed": "停止失败,请重试",
|
||||
"bulkDeleteSuccess": "已删除 {count} 个扫描记录",
|
||||
|
||||
69
frontend/mock/data/blacklist.ts
Normal file
69
frontend/mock/data/blacklist.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Blacklist Mock Data
|
||||
*
|
||||
* 黑名单规则 mock 数据
|
||||
* - 全局黑名单:适用于所有 Target
|
||||
* - Target 黑名单:仅适用于特定 Target
|
||||
*/
|
||||
|
||||
export interface BlacklistResponse {
|
||||
patterns: string[]
|
||||
}
|
||||
|
||||
export interface UpdateBlacklistRequest {
|
||||
patterns: string[]
|
||||
}
|
||||
|
||||
// 全局黑名单 mock 数据
|
||||
let mockGlobalBlacklistPatterns: string[] = [
|
||||
'*.gov',
|
||||
'*.edu',
|
||||
'*.mil',
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
]
|
||||
|
||||
// Target 黑名单 mock 数据(按 targetId 存储)
|
||||
const mockTargetBlacklistPatterns: Record<number, string[]> = {
|
||||
1: ['*.internal.example.com', '192.168.1.0/24'],
|
||||
2: ['cdn.example.com', '*.cdn.*'],
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全局黑名单
|
||||
*/
|
||||
export function getMockGlobalBlacklist(): BlacklistResponse {
|
||||
return {
|
||||
patterns: [...mockGlobalBlacklistPatterns],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新全局黑名单(全量替换)
|
||||
*/
|
||||
export function updateMockGlobalBlacklist(data: UpdateBlacklistRequest): BlacklistResponse {
|
||||
mockGlobalBlacklistPatterns = [...data.patterns]
|
||||
return {
|
||||
patterns: mockGlobalBlacklistPatterns,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Target 黑名单
|
||||
*/
|
||||
export function getMockTargetBlacklist(targetId: number): BlacklistResponse {
|
||||
return {
|
||||
patterns: mockTargetBlacklistPatterns[targetId] ? [...mockTargetBlacklistPatterns[targetId]] : [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Target 黑名单(全量替换)
|
||||
*/
|
||||
export function updateMockTargetBlacklist(targetId: number, data: UpdateBlacklistRequest): BlacklistResponse {
|
||||
mockTargetBlacklistPatterns[targetId] = [...data.patterns]
|
||||
return {
|
||||
patterns: mockTargetBlacklistPatterns[targetId],
|
||||
}
|
||||
}
|
||||
@@ -182,3 +182,11 @@ export {
|
||||
getMockNotificationSettings,
|
||||
updateMockNotificationSettings,
|
||||
} from './data/notification-settings'
|
||||
|
||||
// Blacklist
|
||||
export {
|
||||
getMockGlobalBlacklist,
|
||||
updateMockGlobalBlacklist,
|
||||
getMockTargetBlacklist,
|
||||
updateMockTargetBlacklist,
|
||||
} from './data/blacklist'
|
||||
|
||||
@@ -6,8 +6,25 @@ export interface BulkCreateDirectoriesResponse {
|
||||
createdCount: number
|
||||
}
|
||||
|
||||
// Bulk delete response type
|
||||
export interface BulkDeleteResponse {
|
||||
deletedCount: number
|
||||
}
|
||||
|
||||
/** Directory related API service */
|
||||
export class DirectoryService {
|
||||
/**
|
||||
* Bulk delete directories
|
||||
* POST /api/assets/directories/bulk-delete/
|
||||
*/
|
||||
static async bulkDelete(ids: number[]): Promise<BulkDeleteResponse> {
|
||||
const response = await api.post<BulkDeleteResponse>(
|
||||
`/assets/directories/bulk-delete/`,
|
||||
{ ids }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk create directories (bind to target)
|
||||
* POST /api/targets/{target_id}/directories/bulk-create/
|
||||
|
||||
@@ -16,8 +16,25 @@ export interface BulkCreateEndpointsResponse {
|
||||
createdCount: number
|
||||
}
|
||||
|
||||
// Bulk delete response type
|
||||
export interface BulkDeleteResponse {
|
||||
deletedCount: number
|
||||
}
|
||||
|
||||
export class EndpointService {
|
||||
|
||||
/**
|
||||
* Bulk delete endpoints
|
||||
* POST /api/assets/endpoints/bulk-delete/
|
||||
*/
|
||||
static async bulkDelete(ids: number[]): Promise<BulkDeleteResponse> {
|
||||
const response = await api.post<BulkDeleteResponse>(
|
||||
`/assets/endpoints/bulk-delete/`,
|
||||
{ ids }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk create endpoints (bind to target)
|
||||
* POST /api/targets/{target_id}/endpoints/bulk-create/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api } from '@/lib/api-client'
|
||||
import { USE_MOCK, mockDelay, getMockGlobalBlacklist, updateMockGlobalBlacklist } from '@/mock'
|
||||
|
||||
export interface GlobalBlacklistResponse {
|
||||
patterns: string[]
|
||||
@@ -12,6 +13,10 @@ export interface UpdateGlobalBlacklistRequest {
|
||||
* Get global blacklist rules
|
||||
*/
|
||||
export async function getGlobalBlacklist(): Promise<GlobalBlacklistResponse> {
|
||||
if (USE_MOCK) {
|
||||
await mockDelay()
|
||||
return getMockGlobalBlacklist()
|
||||
}
|
||||
const res = await api.get<GlobalBlacklistResponse>('/blacklist/rules/')
|
||||
return res.data
|
||||
}
|
||||
@@ -20,6 +25,10 @@ export async function getGlobalBlacklist(): Promise<GlobalBlacklistResponse> {
|
||||
* Update global blacklist rules (full replace)
|
||||
*/
|
||||
export async function updateGlobalBlacklist(data: UpdateGlobalBlacklistRequest): Promise<GlobalBlacklistResponse> {
|
||||
if (USE_MOCK) {
|
||||
await mockDelay()
|
||||
return updateMockGlobalBlacklist(data)
|
||||
}
|
||||
const res = await api.put<GlobalBlacklistResponse>('/blacklist/rules/', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import { api } from "@/lib/api-client"
|
||||
import type { GetIPAddressesParams, GetIPAddressesResponse } from "@/types/ip-address.types"
|
||||
|
||||
// Bulk delete response type
|
||||
export interface BulkDeleteResponse {
|
||||
deletedCount: number
|
||||
}
|
||||
|
||||
export class IPAddressService {
|
||||
/**
|
||||
* Bulk delete IP addresses
|
||||
* POST /api/assets/ip-addresses/bulk-delete/
|
||||
* Note: IP addresses are aggregated, so we pass IP strings instead of IDs
|
||||
*/
|
||||
static async bulkDelete(ips: string[]): Promise<BulkDeleteResponse> {
|
||||
const response = await api.post<BulkDeleteResponse>(
|
||||
`/assets/ip-addresses/bulk-delete/`,
|
||||
{ ips }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
static async getTargetIPAddresses(
|
||||
targetId: number,
|
||||
params?: GetIPAddressesParams
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
BatchCreateTargetsRequest,
|
||||
BatchCreateTargetsResponse,
|
||||
} from '@/types/target.types'
|
||||
import { USE_MOCK, mockDelay, getMockTargets, getMockTargetById } from '@/mock'
|
||||
import { USE_MOCK, mockDelay, getMockTargets, getMockTargetById, getMockTargetBlacklist, updateMockTargetBlacklist } from '@/mock'
|
||||
|
||||
/**
|
||||
* Get all targets list (paginated)
|
||||
@@ -163,6 +163,10 @@ export async function getTargetEndpoints(
|
||||
* Get target's blacklist rules
|
||||
*/
|
||||
export async function getTargetBlacklist(id: number): Promise<{ patterns: string[] }> {
|
||||
if (USE_MOCK) {
|
||||
await mockDelay()
|
||||
return getMockTargetBlacklist(id)
|
||||
}
|
||||
const response = await api.get<{ patterns: string[] }>(`/targets/${id}/blacklist/`)
|
||||
return response.data
|
||||
}
|
||||
@@ -174,6 +178,11 @@ export async function updateTargetBlacklist(
|
||||
id: number,
|
||||
patterns: string[]
|
||||
): Promise<{ count: number }> {
|
||||
if (USE_MOCK) {
|
||||
await mockDelay()
|
||||
const result = updateMockTargetBlacklist(id, { patterns })
|
||||
return { count: result.patterns.length }
|
||||
}
|
||||
const response = await api.put<{ count: number }>(`/targets/${id}/blacklist/`, { patterns })
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -6,11 +6,27 @@ export interface BulkCreateWebsitesResponse {
|
||||
createdCount: number
|
||||
}
|
||||
|
||||
// Bulk delete response type
|
||||
export interface BulkDeleteResponse {
|
||||
deletedCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Website related API service
|
||||
* All frontend website interface calls should be centralized here
|
||||
*/
|
||||
export class WebsiteService {
|
||||
/**
|
||||
* Bulk delete websites
|
||||
* POST /api/assets/websites/bulk-delete/
|
||||
*/
|
||||
static async bulkDelete(ids: number[]): Promise<BulkDeleteResponse> {
|
||||
const response = await api.post<BulkDeleteResponse>(
|
||||
`/assets/websites/bulk-delete/`,
|
||||
{ ids }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
/**
|
||||
* Bulk create websites (bind to target)
|
||||
* POST /api/targets/{target_id}/websites/bulk-create/
|
||||
|
||||
Reference in New Issue
Block a user