Compare commits

..

44 Commits

Author SHA1 Message Date
yyhuni
4bab76f233 fix:组织删除问题 2025-12-31 17:50:37 +08:00
yyhuni
09416b4615 fix:redis端口 2025-12-31 17:45:25 +08:00
github-actions[bot]
bc1c5f6b0e chore: bump version to v1.2.7-dev 2025-12-31 06:16:42 +00:00
github-actions[bot]
2f2742e6fe chore: bump version to v1.2.6-dev 2025-12-31 05:29:36 +00:00
yyhuni
be3c346a74 增加搜索字段 2025-12-31 12:40:21 +08:00
yyhuni
0c7a6fff12 增加tech字段的搜索 2025-12-31 12:37:02 +08:00
yyhuni
3b4f0e3147 fix:指纹识别 2025-12-31 12:30:31 +08:00
yyhuni
51212a2a0c fix:指纹识别 2025-12-31 12:17:23 +08:00
yyhuni
58533bbaf6 fix:docker api 2025-12-31 12:03:08 +08:00
github-actions[bot]
6ccca1602d chore: bump version to v1.2.5-dev 2025-12-31 03:48:32 +00:00
yyhuni
6389b0f672 feat(fingerprints): Add type annotation to getAcceptConfig function
- Add explicit return type annotation `Record<string, string[]>` to getAcceptConfig function
- Improve type safety and IDE autocomplete for file type configuration
- Enhance code clarity for accepted file types mapping in import dialog
2025-12-31 10:17:25 +08:00
yyhuni
d7599b8599 feat(fingerprints): Add database indexes and expand test data generation
- Add database indexes on 'link' field in FingersFingerprint model for improved query performance
- Add database index on 'author' field in FingerPrintHubFingerprint model for filtering optimization
- Expand test data generation to include Fingers, FingerPrintHub, and ARL fingerprint types
- Add comprehensive fingerprint data generation methods with realistic templates and patterns
- Update test data cleanup to include all fingerprint table types
- Add i18n translations for fingerprint-related UI components and labels
- Optimize route prefetching hook for better performance
- Improve fingerprint data table columns and vulnerability columns display consistencyzxc
2025-12-31 10:04:15 +08:00
yyhuni
8eff298293 更新镜像加速逻辑 2025-12-31 08:56:55 +08:00
yyhuni
3634101c5b 添加灯塔等指纹 2025-12-31 08:55:37 +08:00
yyhuni
163973a7df feat(i18n): Add internationalization support to dropzone component
- Add useTranslations hook to DropzoneContent component for multi-language support
- Add useTranslations hook to DropzoneEmptyState component for multi-language support
- Replace hardcoded English strings with i18n translation keys in dropzone UI
- Add comprehensive translation keys for dropzone messages in en.json:
* uploadFile, uploadFiles, dragOrClick, dragOrClickReplace
* moreFiles, supports, minimum, maximum, sizeBetween
- Add corresponding Chinese translations in zh.json for all dropzone messages
- Support dynamic content in translations using parameterized keys (files count, size ranges)
- Ensure consistent user experience across English and Chinese interfaces
2025-12-30 21:19:37 +08:00
yyhuni
80ffecba3e feat(i18n): Add UI component i18n provider and standardize translation keys
- Add UiI18nProvider component to wrap UI library translations globally
- Integrate UiI18nProvider into root layout for consistent i18n support
- Standardize download action translation keys (allEndpoints → all, selectedEndpoints → selected)
- Update ExpandableTagList component prop from maxVisible to maxLines for better layout control
- Fix color scheme in dashboard stop scan button (chart-2 → primary)
- Add DOCKER_API_VERSION configuration to backend settings for Docker client compatibility
- Update task distributor to use configurable Docker API version (default 1.40)
- Add environment variable support for Docker API version in task execution commands
- Update i18n configuration and message files with standardized keys
- Ensure UI components respect application locale settings across all data tables and dialogs
2025-12-30 21:19:28 +08:00
yyhuni
3c21ac940c 恢复ssh docker 2025-12-30 20:35:51 +08:00
yyhuni
5c9f484d70 fix(frontend): Fix i18n translation key references and add missing labels
- Change "nav" translation namespace to "navigation" in scan engine and wordlists pages
- Replace parameterized translation calls with raw translation strings for cron schedule options in scheduled scan page and dashboard component
- Cast raw translation results to string type for proper TypeScript typing
- Add missing "name" and "type" labels to fingerprint section in English and Chinese message files
- Ensure consistent translation key usage across components for better maintainability
2025-12-30 18:21:16 +08:00
yyhuni
7567f6c25b 更新文字描述 2025-12-30 18:08:39 +08:00
yyhuni
0599a0b298 ansi-to-html加入 2025-12-30 18:01:29 +08:00
yyhuni
f7557fe90c ansi-to-html替代log显示 2025-12-30 18:01:22 +08:00
yyhuni
13571b9772 fix(frontend): Fix xterm SSR initialization error
- Add 100ms delay for terminal initialization to ensure DOM is mounted
- Use requestAnimationFrame for fit() to avoid dimensions error
- Add try-catch for all xterm operations
- Proper cleanup on unmount

Fixes: Cannot read properties of undefined (reading 'dimensions')
2025-12-30 17:41:38 +08:00
yyhuni
8ee76eef69 feat(frontend): Add ANSI color support for system logs
- Create AnsiLogViewer component using xterm.js
- Replace Monaco Editor with xterm for log viewing
- Native ANSI escape code rendering (colors, bold, etc.)
- Auto-scroll to bottom, clickable URLs support

Benefits:
- Colorized logs for better readability
- No more escape codes like [32m[0m in UI
- Professional terminal-like experience
2025-12-30 17:39:12 +08:00
yyhuni
2a31e29aa2 fix: Add shell quoting for command arguments
- Use shlex.quote() to escape special characters in argument values
- Fixes: 'unrecognized arguments' error when values contain spaces
- Example: target_name='example.com scan' now correctly quoted
2025-12-30 17:32:09 +08:00
yyhuni
81abc59961 Refactor: Migrate TaskDistributor to Docker SDK
- Replace CLI subprocess with Python Docker SDK
- Add DockerClientManager for unified container management
- Remove 300+ lines of shell command building code
- Enable future features: container status monitoring, log streaming

Breaking changes: None (backward compatible with existing scans)
Rollback: git reset --hard v1.0-before-docker-sdk
2025-12-30 17:23:18 +08:00
yyhuni
ffbfec6dd5 feat(stage2): Refactor TaskDistributor to use Docker SDK
- Replace CLI subprocess calls with DockerClientManager.run_container()
- Add helper methods: _build_container_command, _build_container_environment, _build_container_volumes
- Refactor execute_scan_flow() and execute_cleanup_on_all_workers() to use SDK
- Remove old CLI methods: _build_docker_command, _execute_docker_command, _execute_local_docker, _execute_ssh_docker
- Remove paramiko import (no longer needed for local workers)

Benefits:
- 300+ lines removed (CLI string building complexity)
- Type-safe container configuration (no more shlex.quote errors)
- Structured error handling (ImageNotFound, APIError)
- Ready for container status monitoring and log streaming
2025-12-30 17:20:26 +08:00
yyhuni
a0091636a8 feat(stage1): Add DockerClientManager
- Create docker_client_manager.py with local Docker client support
- Add container lifecycle management (run, status, logs, stop, remove)
- Implement structured error handling (ImageNotFound, APIError)
- Add client connection caching and reuse
- Set Docker API version to 1.40 (compatible with Docker 19.03+)
- Add dependencies: docker>=6.0.0, packaging>=21.0

TODO: Remote worker support (Docker Context or SSH tunnel)
2025-12-30 17:17:17 +08:00
yyhuni
69490ab396 feat: Add DockerClientManager for unified Docker client management
- Create docker_client_manager.py with local Docker client support
- Add container lifecycle management (run, status, logs, stop, remove)
- Implement structured error handling (ImageNotFound, APIError)
- Add client connection caching and reuse
- Set Docker API version to 1.40 (compatible with Docker 19.03+)
- Add docker>=6.0.0 and packaging>=21.0 dependencies

TODO: Remote worker support (Docker Context or SSH tunnel)
2025-12-30 17:15:29 +08:00
yyhuni
7306964abf 更新readme 2025-12-30 16:44:08 +08:00
yyhuni
cb6b0259e3 fix:响应不匹配 2025-12-30 16:40:17 +08:00
yyhuni
e1b4618e58 refactor(worker): isolate scan tools to dedicated directory
- Move scan tools base path from `/usr/local/bin` to `/opt/xingrin-tools/bin` to avoid conflicts with system tools and Python packages
- Create dedicated `/opt/xingrin-tools/bin` directory in worker Dockerfile following FHS standards
- Update PATH environment variable to prioritize project-specific tools directory
- Add `SCAN_TOOLS_PATH` environment variable to `.env.example` with documentation
- Update settings.py to use new default path with explanatory comments
- Fix TypeScript type annotation in system-logs-view.tsx for better maintainability
- Remove frontend package-lock.json to reduce repository size
- Update task distributor comment to reflect new tool location
This change improves tool isolation and prevents naming conflicts while maintaining FHS compliance.
2025-12-30 11:42:09 +08:00
yyhuni
556dcf5f62 重构日志ui功能 2025-12-30 11:13:38 +08:00
yyhuni
0628eef025 重构响应为标准响应格式 2025-12-30 10:56:26 +08:00
yyhuni
38ed8bc642 fix(scan): improve config parser validation and enable subdomain resolve timeout
- Uncomment timeout: auto setting in subdomain discovery config example
- Add validation to reject None or non-dict configuration values
- Raise ValueError with descriptive message when config is None
- Raise ValueError when config is not a dictionary type
- Update docstring to document Raises section for error conditions
- Prevent silent failures from malformed YAML configurations
2025-12-30 08:54:02 +08:00
yyhuni
2f4d6a2168 统一工具挂载为/usr/local/bin 2025-12-30 08:45:36 +08:00
yyhuni
c25cb9e06b fix:工具挂载 2025-12-30 08:39:17 +08:00
yyhuni
b14ab71c7f fix:auth frontend 2025-12-30 08:12:04 +08:00
github-actions[bot]
8b5060e2d3 chore: bump version to v1.2.2-dev 2025-12-29 17:08:05 +00:00
yyhuni
3c9335febf refactor: determine target branch by tag location instead of naming
- Check which branch contains the tag (main or dev)
- Update VERSION file on the source branch
- Only tags from main branch update 'latest' Docker tag
- More flexible and follows standard Git workflow
2025-12-29 23:34:05 +08:00
yyhuni
1b95e4f2c3 feat: update VERSION file for dev tags on dev branch
- Dev tags (v*-dev) now update VERSION file on dev branch
- Release tags (v* without suffix) update VERSION file on main branch
- Keeps main and dev branches independent
2025-12-29 23:30:17 +08:00
yyhuni
d20a600afc refactor: use settings.GIT_MIRROR instead of os.getenv in worker_views 2025-12-29 23:13:35 +08:00
yyhuni
c29b11fd37 feat: add GIT_MIRROR to worker config center
- Add gitMirror field to worker configuration API
- Container bootstrap reads gitMirror and sets GIT_MIRROR env var
- Remove redundant GIT_MIRROR injection from task_distributor
- All environment variables are managed through config center
2025-12-29 23:11:31 +08:00
yyhuni
6caf707072 refactor: replace Chinese comments with English in frontend components
- Replace all Chinese inline comments with English equivalents across 24 frontend component files
- Update JSDoc comments to use English for better code documentation
- Improve code readability and maintainability for international development team
- Standardize comment style across directories, endpoints, ip-addresses, subdomains, and websites components
- Ensure consistency with previous frontend refactoring efforts
2025-12-29 23:01:16 +08:00
yyhuni
2627b1fc40 refactor: replace Chinese comments with English across frontend components
- Replace Chinese comments with English in fingerprint components (ehole, goby, wappalyzer)
- Update comments in scan engine, history, and scheduled scan modules
- Translate comments in worker deployment and configuration dialogs
- Update comments in subdomain management and target components
- Translate comments in tools configuration and command modules
- Replace Chinese comments in vulnerability components
- Improve code maintainability and consistency with English documentation standards
- Update Docker build workflow cache configuration with image-specific scopes for better cache isolation
2025-12-29 22:14:12 +08:00
232 changed files with 29465 additions and 11968 deletions

View File

@@ -106,34 +106,65 @@ jobs:
${{ steps.version.outputs.IS_RELEASE == 'true' && format('{0}/{1}:latest', env.IMAGE_PREFIX, matrix.image) || '' }}
build-args: |
IMAGE_TAG=${{ steps.version.outputs.VERSION }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha,scope=${{ matrix.image }}
cache-to: type=gha,mode=max,scope=${{ matrix.image }}
provenance: false
sbom: false
# 所有镜像构建成功后,更新 VERSION 文件
# 只有正式版本(不含 -dev, -alpha, -beta, -rc 等后缀)才更新
# 根据 tag 所在的分支更新对应分支的 VERSION 文件
update-version:
runs-on: ubuntu-latest
needs: build
if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-')
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0 # 获取完整历史,用于判断 tag 所在分支
token: ${{ secrets.GITHUB_TOKEN }}
- name: Determine source branch and version
id: branch
run: |
VERSION="${GITHUB_REF#refs/tags/}"
# 查找包含此 tag 的分支
BRANCHES=$(git branch -r --contains ${{ github.ref_name }})
echo "Branches containing tag: $BRANCHES"
# 判断 tag 来自哪个分支
if echo "$BRANCHES" | grep -q "origin/main"; then
TARGET_BRANCH="main"
UPDATE_LATEST="true"
elif echo "$BRANCHES" | grep -q "origin/dev"; then
TARGET_BRANCH="dev"
UPDATE_LATEST="false"
else
echo "Warning: Tag not found in main or dev branch, defaulting to main"
TARGET_BRANCH="main"
UPDATE_LATEST="false"
fi
echo "BRANCH=$TARGET_BRANCH" >> $GITHUB_OUTPUT
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "UPDATE_LATEST=$UPDATE_LATEST" >> $GITHUB_OUTPUT
echo "Will update VERSION on branch: $TARGET_BRANCH"
- name: Checkout target branch
run: |
git checkout ${{ steps.branch.outputs.BRANCH }}
- name: Update VERSION file
run: |
VERSION="${GITHUB_REF#refs/tags/}"
VERSION="${{ steps.branch.outputs.VERSION }}"
echo "$VERSION" > VERSION
echo "Updated VERSION to $VERSION"
echo "Updated VERSION to $VERSION on branch ${{ steps.branch.outputs.BRANCH }}"
- name: Commit and push
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add VERSION
git diff --staged --quiet || git commit -m "chore: bump version to ${GITHUB_REF#refs/tags/}"
git push
git diff --staged --quiet || git commit -m "chore: bump version to ${{ steps.branch.outputs.VERSION }}"
git push origin ${{ steps.branch.outputs.BRANCH }}

View File

@@ -178,7 +178,7 @@ cd xingrin
# 安装并启动(生产模式)
sudo ./install.sh
# 🇨🇳 中国大陆用户推荐使用镜像加速
# 🇨🇳 中国大陆用户推荐使用镜像加速(第三方加速服务可能会失效,不保证长期可用)
sudo ./install.sh --mirror
```
@@ -211,7 +211,6 @@ sudo ./uninstall.sh
- 🐛 **如果发现 Bug** 可以点击右边链接进行提交 [Issue](https://github.com/yyhuni/xingrin/issues)
- 💡 **有新想法比如UI设计功能设计等** 欢迎点击右边链接进行提交建议 [Issue](https://github.com/yyhuni/xingrin/issues)
- 🔧 **想参与开发?** 关注我公众号与我个人联系
## 📧 联系
- 目前版本就我个人使用,可能会有很多边界问题

View File

@@ -1 +1 @@
v1.1.14
v1.2.7-dev

View File

@@ -28,6 +28,7 @@ class EndpointService:
'host': 'host',
'title': 'title',
'status': 'status_code',
'tech': 'tech',
}
def __init__(self):
@@ -115,7 +116,7 @@ class EndpointService:
"""获取目标下的所有端点"""
queryset = self.repo.get_by_target(target_id)
if filter_query:
queryset = apply_filters(queryset, filter_query, self.FILTER_FIELD_MAPPING)
queryset = apply_filters(queryset, filter_query, self.FILTER_FIELD_MAPPING, json_array_fields=['tech'])
return queryset
def count_endpoints_by_target(self, target_id: int) -> int:
@@ -134,7 +135,7 @@ class EndpointService:
"""获取所有端点(全局查询)"""
queryset = self.repo.get_all()
if filter_query:
queryset = apply_filters(queryset, filter_query, self.FILTER_FIELD_MAPPING)
queryset = apply_filters(queryset, filter_query, self.FILTER_FIELD_MAPPING, json_array_fields=['tech'])
return queryset
def iter_endpoint_urls_by_target(self, target_id: int, chunk_size: int = 1000) -> Iterator[str]:

View File

@@ -20,6 +20,7 @@ class WebSiteService:
'host': 'host',
'title': 'title',
'status': 'status_code',
'tech': 'tech',
}
def __init__(self, repository=None):
@@ -107,14 +108,14 @@ class WebSiteService:
"""获取目标下的所有网站"""
queryset = self.repo.get_by_target(target_id)
if filter_query:
queryset = apply_filters(queryset, filter_query, self.FILTER_FIELD_MAPPING)
queryset = apply_filters(queryset, filter_query, self.FILTER_FIELD_MAPPING, json_array_fields=['tech'])
return queryset
def get_all(self, filter_query: Optional[str] = None):
"""获取所有网站"""
queryset = self.repo.get_all()
if filter_query:
queryset = apply_filters(queryset, filter_query, self.FILTER_FIELD_MAPPING)
queryset = apply_filters(queryset, filter_query, self.FILTER_FIELD_MAPPING, json_array_fields=['tech'])
return queryset
def get_by_url(self, url: str, target_id: int) -> int:

View File

@@ -2,6 +2,8 @@ import logging
from rest_framework import viewsets, status, filters
from rest_framework.decorators import action
from rest_framework.response import Response
from apps.common.response_helpers import success_response, error_response
from apps.common.error_codes import ErrorCodes
from rest_framework.request import Request
from rest_framework.exceptions import NotFound, ValidationError as DRFValidationError
from django.core.exceptions import ValidationError, ObjectDoesNotExist
@@ -57,7 +59,7 @@ class AssetStatisticsViewSet(viewsets.ViewSet):
"""
try:
stats = self.service.get_statistics()
return Response({
return success_response(data={
'totalTargets': stats['total_targets'],
'totalSubdomains': stats['total_subdomains'],
'totalIps': stats['total_ips'],
@@ -80,9 +82,10 @@ class AssetStatisticsViewSet(viewsets.ViewSet):
})
except (DatabaseError, OperationalError) as e:
logger.exception("获取资产统计数据失败")
return Response(
{'error': '获取统计数据失败'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Failed to get statistics',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(detail=False, methods=['get'], url_path='history')
@@ -107,12 +110,13 @@ class AssetStatisticsViewSet(viewsets.ViewSet):
days = min(max(days, 1), 90) # 限制在 1-90 天
history = self.service.get_statistics_history(days=days)
return Response(history)
return success_response(data=history)
except (DatabaseError, OperationalError) as e:
logger.exception("获取统计历史数据失败")
return Response(
{'error': '获取历史数据失败'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Failed to get history data',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@@ -164,45 +168,50 @@ class SubdomainViewSet(viewsets.ModelViewSet):
响应:
{
"message": "批量创建完成",
"createdCount": 10,
"skippedCount": 2,
"invalidCount": 1,
"mismatchedCount": 1,
"totalReceived": 14
"data": {
"createdCount": 10,
"skippedCount": 2,
"invalidCount": 1,
"mismatchedCount": 1,
"totalReceived": 14
}
}
"""
from apps.targets.models import Target
target_pk = self.kwargs.get('target_pk')
if not target_pk:
return Response(
{'error': '必须在目标下批量创建子域名'},
status=status.HTTP_400_BAD_REQUEST
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Must create subdomains under a target',
status_code=status.HTTP_400_BAD_REQUEST
)
# 获取目标
try:
target = Target.objects.get(pk=target_pk)
except Target.DoesNotExist:
return Response(
{'error': '目标不存在'},
status=status.HTTP_404_NOT_FOUND
return error_response(
code=ErrorCodes.NOT_FOUND,
message='Target not found',
status_code=status.HTTP_404_NOT_FOUND
)
# 验证目标类型必须为域名
if target.type != Target.TargetType.DOMAIN:
return Response(
{'error': '只有域名类型的目标支持导入子域名'},
status=status.HTTP_400_BAD_REQUEST
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Only domain type targets support subdomain import',
status_code=status.HTTP_400_BAD_REQUEST
)
# 获取请求体中的子域名列表
subdomains = request.data.get('subdomains', [])
if not subdomains or not isinstance(subdomains, list):
return Response(
{'error': '请求体不能为空或格式错误'},
status=status.HTTP_400_BAD_REQUEST
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Request body cannot be empty or invalid format',
status_code=status.HTTP_400_BAD_REQUEST
)
# 调用 service 层处理
@@ -214,19 +223,19 @@ class SubdomainViewSet(viewsets.ModelViewSet):
)
except Exception as e:
logger.exception("批量创建子域名失败")
return Response(
{'error': '服务器内部错误'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Server internal error',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({
'message': '批量创建完成',
return success_response(data={
'createdCount': result.created_count,
'skippedCount': result.skipped_count,
'invalidCount': result.invalid_count,
'mismatchedCount': result.mismatched_count,
'totalReceived': result.total_received,
}, status=status.HTTP_200_OK)
})
@action(detail=False, methods=['get'], url_path='export')
def export(self, request, **kwargs):
@@ -265,6 +274,7 @@ class WebSiteViewSet(viewsets.ModelViewSet):
- host="example" 主机名模糊匹配
- title="login" 标题模糊匹配
- status="200,301" 状态码多值匹配
- tech="nginx" 技术栈匹配(数组字段)
- 多条件空格分隔 AND 关系
"""
@@ -299,35 +309,38 @@ class WebSiteViewSet(viewsets.ModelViewSet):
响应:
{
"message": "批量创建完成",
"createdCount": 10,
"mismatchedCount": 2
"data": {
"createdCount": 10
}
}
"""
from apps.targets.models import Target
target_pk = self.kwargs.get('target_pk')
if not target_pk:
return Response(
{'error': '必须在目标下批量创建网站'},
status=status.HTTP_400_BAD_REQUEST
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Must create websites under a target',
status_code=status.HTTP_400_BAD_REQUEST
)
# 获取目标
try:
target = Target.objects.get(pk=target_pk)
except Target.DoesNotExist:
return Response(
{'error': '目标不存在'},
status=status.HTTP_404_NOT_FOUND
return error_response(
code=ErrorCodes.NOT_FOUND,
message='Target not found',
status_code=status.HTTP_404_NOT_FOUND
)
# 获取请求体中的 URL 列表
urls = request.data.get('urls', [])
if not urls or not isinstance(urls, list):
return Response(
{'error': '请求体不能为空或格式错误'},
status=status.HTTP_400_BAD_REQUEST
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Request body cannot be empty or invalid format',
status_code=status.HTTP_400_BAD_REQUEST
)
# 调用 service 层处理
@@ -340,15 +353,15 @@ class WebSiteViewSet(viewsets.ModelViewSet):
)
except Exception as e:
logger.exception("批量创建网站失败")
return Response(
{'error': '服务器内部错误'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Server internal error',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({
'message': '批量创建完成',
return success_response(data={
'createdCount': created_count,
}, status=status.HTTP_200_OK)
})
@action(detail=False, methods=['get'], url_path='export')
def export(self, request, **kwargs):
@@ -426,35 +439,38 @@ class DirectoryViewSet(viewsets.ModelViewSet):
响应:
{
"message": "批量创建完成",
"createdCount": 10,
"mismatchedCount": 2
"data": {
"createdCount": 10
}
}
"""
from apps.targets.models import Target
target_pk = self.kwargs.get('target_pk')
if not target_pk:
return Response(
{'error': '必须在目标下批量创建目录'},
status=status.HTTP_400_BAD_REQUEST
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Must create directories under a target',
status_code=status.HTTP_400_BAD_REQUEST
)
# 获取目标
try:
target = Target.objects.get(pk=target_pk)
except Target.DoesNotExist:
return Response(
{'error': '目标不存在'},
status=status.HTTP_404_NOT_FOUND
return error_response(
code=ErrorCodes.NOT_FOUND,
message='Target not found',
status_code=status.HTTP_404_NOT_FOUND
)
# 获取请求体中的 URL 列表
urls = request.data.get('urls', [])
if not urls or not isinstance(urls, list):
return Response(
{'error': '请求体不能为空或格式错误'},
status=status.HTTP_400_BAD_REQUEST
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Request body cannot be empty or invalid format',
status_code=status.HTTP_400_BAD_REQUEST
)
# 调用 service 层处理
@@ -467,15 +483,15 @@ class DirectoryViewSet(viewsets.ModelViewSet):
)
except Exception as e:
logger.exception("批量创建目录失败")
return Response(
{'error': '服务器内部错误'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Server internal error',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({
'message': '批量创建完成',
return success_response(data={
'createdCount': created_count,
}, status=status.HTTP_200_OK)
})
@action(detail=False, methods=['get'], url_path='export')
def export(self, request, **kwargs):
@@ -519,6 +535,7 @@ class EndpointViewSet(viewsets.ModelViewSet):
- host="example" 主机名模糊匹配
- title="login" 标题模糊匹配
- status="200,301" 状态码多值匹配
- tech="nginx" 技术栈匹配(数组字段)
- 多条件空格分隔 AND 关系
"""
@@ -553,35 +570,38 @@ class EndpointViewSet(viewsets.ModelViewSet):
响应:
{
"message": "批量创建完成",
"createdCount": 10,
"mismatchedCount": 2
"data": {
"createdCount": 10
}
}
"""
from apps.targets.models import Target
target_pk = self.kwargs.get('target_pk')
if not target_pk:
return Response(
{'error': '必须在目标下批量创建端点'},
status=status.HTTP_400_BAD_REQUEST
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Must create endpoints under a target',
status_code=status.HTTP_400_BAD_REQUEST
)
# 获取目标
try:
target = Target.objects.get(pk=target_pk)
except Target.DoesNotExist:
return Response(
{'error': '目标不存在'},
status=status.HTTP_404_NOT_FOUND
return error_response(
code=ErrorCodes.NOT_FOUND,
message='Target not found',
status_code=status.HTTP_404_NOT_FOUND
)
# 获取请求体中的 URL 列表
urls = request.data.get('urls', [])
if not urls or not isinstance(urls, list):
return Response(
{'error': '请求体不能为空或格式错误'},
status=status.HTTP_400_BAD_REQUEST
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Request body cannot be empty or invalid format',
status_code=status.HTTP_400_BAD_REQUEST
)
# 调用 service 层处理
@@ -594,15 +614,15 @@ class EndpointViewSet(viewsets.ModelViewSet):
)
except Exception as e:
logger.exception("批量创建端点失败")
return Response(
{'error': '服务器内部错误'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Server internal error',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({
'message': '批量创建完成',
return success_response(data={
'createdCount': created_count,
}, status=status.HTTP_200_OK)
})
@action(detail=False, methods=['get'], url_path='export')
def export(self, request, **kwargs):

View File

@@ -57,28 +57,17 @@ def fetch_config_and_setup_django():
os.environ.setdefault("DB_USER", db_user)
os.environ.setdefault("DB_PASSWORD", config['db']['password'])
# Redis 配置
os.environ.setdefault("REDIS_URL", config['redisUrl'])
# 日志配置
os.environ.setdefault("LOG_DIR", config['paths']['logs'])
os.environ.setdefault("LOG_LEVEL", config['logging']['level'])
os.environ.setdefault("ENABLE_COMMAND_LOGGING", str(config['logging']['enableCommandLogging']).lower())
os.environ.setdefault("DEBUG", str(config['debug']))
# Xget 加速配置(用于 Git clone 加速)
xget_mirror = config.get('xgetMirror', '')
if xget_mirror:
os.environ.setdefault("XGET_MIRROR", xget_mirror)
print(f"[CONFIG] ✓ 配置获取成功")
print(f"[CONFIG] DB_HOST: {db_host}")
print(f"[CONFIG] DB_PORT: {db_port}")
print(f"[CONFIG] DB_NAME: {db_name}")
print(f"[CONFIG] DB_USER: {db_user}")
print(f"[CONFIG] REDIS_URL: {config['redisUrl']}")
if xget_mirror:
print(f"[CONFIG] XGET_MIRROR: {xget_mirror}")
except Exception as e:
print(f"[ERROR] 获取配置失败: {config_url} - {e}", file=sys.stderr)

View File

@@ -0,0 +1,31 @@
"""
标准化错误码定义
采用简化方案(参考 Stripe、GitHub 等大厂做法):
- 只定义 5-10 个通用错误码
- 未知错误使用通用错误码
- 错误码格式:大写字母和下划线组成
"""
class ErrorCodes:
"""标准化错误码
只定义通用错误码,其他错误使用通用消息。
这是 Stripe、GitHub 等大厂的标准做法。
错误码格式规范:
- 使用大写字母和下划线
- 简洁明了,易于理解
- 前端通过错误码映射到 i18n 键
"""
# 通用错误码8 个)
VALIDATION_ERROR = 'VALIDATION_ERROR' # 输入验证失败
NOT_FOUND = 'NOT_FOUND' # 资源未找到
PERMISSION_DENIED = 'PERMISSION_DENIED' # 权限不足
SERVER_ERROR = 'SERVER_ERROR' # 服务器内部错误
BAD_REQUEST = 'BAD_REQUEST' # 请求格式错误
CONFLICT = 'CONFLICT' # 资源冲突(如重复创建)
UNAUTHORIZED = 'UNAUTHORIZED' # 未认证
RATE_LIMITED = 'RATE_LIMITED' # 请求过于频繁

View File

@@ -0,0 +1,88 @@
"""
标准化 API 响应辅助函数
遵循行业标准RFC 9457 Problem Details和大厂实践Google、Stripe、GitHub
- 成功响应只包含数据,不包含 message 字段
- 错误响应使用机器可读的错误码,前端映射到 i18n 消息
"""
from typing import Any, Dict, List, Optional, Union
from rest_framework import status
from rest_framework.response import Response
def success_response(
data: Optional[Union[Dict[str, Any], List[Any]]] = None,
status_code: int = status.HTTP_200_OK
) -> Response:
"""
标准化成功响应
直接返回数据,不做包装,符合 Stripe/GitHub 等大厂标准。
Args:
data: 响应数据dict 或 list
status_code: HTTP 状态码,默认 200
Returns:
Response: DRF Response 对象
Examples:
# 单个资源
>>> success_response(data={'id': 1, 'name': 'Test'})
{'id': 1, 'name': 'Test'}
# 操作结果
>>> success_response(data={'count': 3, 'scans': [...]})
{'count': 3, 'scans': [...]}
# 创建资源
>>> success_response(data={'id': 1}, status_code=201)
"""
# 注意:不能使用 data or {},因为空列表 [] 会被转换为 {}
if data is None:
data = {}
return Response(data, status=status_code)
def error_response(
code: str,
message: Optional[str] = None,
details: Optional[List[Dict[str, Any]]] = None,
status_code: int = status.HTTP_400_BAD_REQUEST
) -> Response:
"""
标准化错误响应
Args:
code: 错误码(如 'VALIDATION_ERROR', 'NOT_FOUND'
格式:大写字母和下划线组成
message: 开发者调试信息(非用户显示)
details: 详细错误信息(如字段级验证错误)
status_code: HTTP 状态码,默认 400
Returns:
Response: DRF Response 对象
Examples:
# 简单错误
>>> error_response(code='NOT_FOUND', status_code=404)
{'error': {'code': 'NOT_FOUND'}}
# 带调试信息
>>> error_response(
... code='VALIDATION_ERROR',
... message='Invalid input data',
... details=[{'field': 'name', 'message': 'Required'}]
... )
{'error': {'code': 'VALIDATION_ERROR', 'message': '...', 'details': [...]}}
"""
error_body: Dict[str, Any] = {'code': code}
if message:
error_body['message'] = message
if details:
error_body['details'] = details
return Response({'error': error_body}, status=status_code)

View File

@@ -4,15 +4,28 @@
提供系统日志的读取功能,支持:
- 从日志目录读取日志文件
- 限制返回行数,防止内存溢出
- 列出可用的日志文件
"""
import fnmatch
import logging
import os
import subprocess
from datetime import datetime, timezone
from typing import TypedDict
logger = logging.getLogger(__name__)
class LogFileInfo(TypedDict):
"""日志文件信息"""
filename: str
category: str # 'system' | 'error' | 'performance' | 'container'
size: int
modifiedAt: str # ISO 8601 格式
class SystemLogService:
"""
系统日志服务类
@@ -20,23 +33,131 @@ class SystemLogService:
负责读取系统日志文件,支持从容器内路径或宿主机挂载路径读取日志。
"""
# 日志文件分类规则
CATEGORY_RULES = [
('xingrin.log', 'system'),
('xingrin_error.log', 'error'),
('performance.log', 'performance'),
('container_*.log', 'container'),
]
def __init__(self):
# 日志文件路径(统一使用 /opt/xingrin/logs
self.log_file = "/opt/xingrin/logs/xingrin.log"
self.default_lines = 200 # 默认返回行数
self.max_lines = 10000 # 最大返回行数限制
self.timeout_seconds = 3 # tail 命令超时时间
# 日志目录路径
self.log_dir = "/opt/xingrin/logs"
self.default_file = "xingrin.log" # 默认日志文件
self.default_lines = 200 # 默认返回行数
self.max_lines = 10000 # 最大返回行数限制
self.timeout_seconds = 3 # tail 命令超时时间
def get_logs_content(self, lines: int | None = None) -> str:
def _categorize_file(self, filename: str) -> str | None:
"""
根据文件名判断日志分类
Returns:
分类名称,如果不是日志文件则返回 None
"""
for pattern, category in self.CATEGORY_RULES:
if fnmatch.fnmatch(filename, pattern):
return category
return None
def _validate_filename(self, filename: str) -> bool:
"""
验证文件名是否合法(防止路径遍历攻击)
Args:
filename: 要验证的文件名
Returns:
bool: 文件名是否合法
"""
# 不允许包含路径分隔符
if '/' in filename or '\\' in filename:
return False
# 不允许 .. 路径遍历
if '..' in filename:
return False
# 必须是已知的日志文件类型
return self._categorize_file(filename) is not None
def get_log_files(self) -> list[LogFileInfo]:
"""
获取所有可用的日志文件列表
Returns:
日志文件信息列表,按分类和文件名排序
"""
files: list[LogFileInfo] = []
if not os.path.isdir(self.log_dir):
logger.warning("日志目录不存在: %s", self.log_dir)
return files
for filename in os.listdir(self.log_dir):
filepath = os.path.join(self.log_dir, filename)
# 只处理文件,跳过目录
if not os.path.isfile(filepath):
continue
# 判断分类
category = self._categorize_file(filename)
if category is None:
continue
# 获取文件信息
try:
stat = os.stat(filepath)
modified_at = datetime.fromtimestamp(
stat.st_mtime, tz=timezone.utc
).isoformat()
files.append({
'filename': filename,
'category': category,
'size': stat.st_size,
'modifiedAt': modified_at,
})
except OSError as e:
logger.warning("获取文件信息失败 %s: %s", filepath, e)
continue
# 排序按分类优先级system > error > performance > container然后按文件名
category_order = {'system': 0, 'error': 1, 'performance': 2, 'container': 3}
files.sort(key=lambda f: (category_order.get(f['category'], 99), f['filename']))
return files
def get_logs_content(self, filename: str | None = None, lines: int | None = None) -> str:
"""
获取系统日志内容
Args:
filename: 日志文件名,默认为 xingrin.log
lines: 返回的日志行数,默认 200 行,最大 10000 行
Returns:
str: 日志内容,每行以换行符分隔,保持原始顺序
Raises:
ValueError: 文件名不合法
FileNotFoundError: 日志文件不存在
"""
# 文件名处理
if filename is None:
filename = self.default_file
# 验证文件名
if not self._validate_filename(filename):
raise ValueError(f"无效的文件名: {filename}")
# 构建完整路径
log_file = os.path.join(self.log_dir, filename)
# 检查文件是否存在
if not os.path.isfile(log_file):
raise FileNotFoundError(f"日志文件不存在: {filename}")
# 参数校验和默认值处理
if lines is None:
lines = self.default_lines
@@ -48,7 +169,7 @@ class SystemLogService:
lines = self.max_lines
# 使用 tail 命令读取日志文件末尾内容
cmd = ["tail", "-n", str(lines), self.log_file]
cmd = ["tail", "-n", str(lines), log_file]
result = subprocess.run(
cmd,

View File

@@ -7,7 +7,7 @@
"""
from django.urls import path
from .views import LoginView, LogoutView, MeView, ChangePasswordView, SystemLogsView
from .views import LoginView, LogoutView, MeView, ChangePasswordView, SystemLogsView, SystemLogFilesView
urlpatterns = [
# 认证相关
@@ -18,4 +18,5 @@ urlpatterns = [
# 系统管理
path('system/logs/', SystemLogsView.as_view(), name='system-logs'),
path('system/logs/files/', SystemLogFilesView.as_view(), name='system-log-files'),
]

View File

@@ -13,7 +13,6 @@ from .csv_utils import (
format_datetime,
UTF8_BOM,
)
from .xget_proxy import get_xget_proxy_url
__all__ = [
'deduplicate_for_bulk',
@@ -26,5 +25,4 @@ __all__ = [
'format_list_field',
'format_datetime',
'UTF8_BOM',
'get_xget_proxy_url',
]

View File

@@ -1,41 +0,0 @@
"""Xget proxy utilities for Git URL acceleration."""
import os
from urllib.parse import urlparse
def get_xget_proxy_url(original_url: str) -> str:
"""
Convert Git repository URL to Xget proxy format.
Args:
original_url: Original repository URL, e.g., https://github.com/user/repo.git
Returns:
Converted URL, e.g., https://xget.xi-xu.me/gh/https://github.com/user/repo.git
If XGET_MIRROR is not set, returns the original URL unchanged.
"""
xget_mirror = os.getenv("XGET_MIRROR", "").strip()
if not xget_mirror:
return original_url
# Remove trailing slash from mirror URL if present
xget_mirror = xget_mirror.rstrip("/")
parsed = urlparse(original_url)
host = parsed.netloc.lower()
# Map domains to proxy prefixes
prefix_map = {
"github.com": "gh",
"gitlab.com": "gl",
"gitea.com": "gitea",
"codeberg.org": "codeberg",
}
for domain, prefix in prefix_map.items():
if domain in host:
return f"{xget_mirror}/{prefix}/{original_url}"
# Unknown domain, return original URL
return original_url

View File

@@ -7,6 +7,6 @@
"""
from .auth_views import LoginView, LogoutView, MeView, ChangePasswordView
from .system_log_views import SystemLogsView
from .system_log_views import SystemLogsView, SystemLogFilesView
__all__ = ['LoginView', 'LogoutView', 'MeView', 'ChangePasswordView', 'SystemLogsView']
__all__ = ['LoginView', 'LogoutView', 'MeView', 'ChangePasswordView', 'SystemLogsView', 'SystemLogFilesView']

View File

@@ -11,6 +11,9 @@ from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAuthenticated
from apps.common.response_helpers import success_response, error_response
from apps.common.error_codes import ErrorCodes
logger = logging.getLogger(__name__)
@@ -28,9 +31,10 @@ class LoginView(APIView):
password = request.data.get('password')
if not username or not password:
return Response(
{'error': '请提供用户名和密码'},
status=status.HTTP_400_BAD_REQUEST
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Username and password are required',
status_code=status.HTTP_400_BAD_REQUEST
)
user = authenticate(request, username=username, password=password)
@@ -38,20 +42,22 @@ class LoginView(APIView):
if user is not None:
login(request, user)
logger.info(f"用户 {username} 登录成功")
return Response({
'message': '登录成功',
'user': {
'id': user.id,
'username': user.username,
'isStaff': user.is_staff,
'isSuperuser': user.is_superuser,
return success_response(
data={
'user': {
'id': user.id,
'username': user.username,
'isStaff': user.is_staff,
'isSuperuser': user.is_superuser,
}
}
})
)
else:
logger.warning(f"用户 {username} 登录失败:用户名或密码错误")
return Response(
{'error': '用户名或密码错误'},
status=status.HTTP_401_UNAUTHORIZED
return error_response(
code=ErrorCodes.UNAUTHORIZED,
message='Invalid username or password',
status_code=status.HTTP_401_UNAUTHORIZED
)
@@ -79,7 +85,7 @@ class LogoutView(APIView):
logout(request)
else:
logout(request)
return Response({'message': '已登出'})
return success_response()
@method_decorator(csrf_exempt, name='dispatch')
@@ -100,22 +106,26 @@ class MeView(APIView):
if user_id:
try:
user = User.objects.get(pk=user_id)
return Response({
'authenticated': True,
'user': {
'id': user.id,
'username': user.username,
'isStaff': user.is_staff,
'isSuperuser': user.is_superuser,
return success_response(
data={
'authenticated': True,
'user': {
'id': user.id,
'username': user.username,
'isStaff': user.is_staff,
'isSuperuser': user.is_superuser,
}
}
})
)
except User.DoesNotExist:
pass
return Response({
'authenticated': False,
'user': None
})
return success_response(
data={
'authenticated': False,
'user': None
}
)
@method_decorator(csrf_exempt, name='dispatch')
@@ -134,17 +144,19 @@ class ChangePasswordView(APIView):
user_id = request.session.get('_auth_user_id')
if not user_id:
return Response(
{'error': '请先登录'},
status=status.HTTP_401_UNAUTHORIZED
return error_response(
code=ErrorCodes.UNAUTHORIZED,
message='Please login first',
status_code=status.HTTP_401_UNAUTHORIZED
)
try:
user = User.objects.get(pk=user_id)
except User.DoesNotExist:
return Response(
{'error': '用户不存在'},
status=status.HTTP_401_UNAUTHORIZED
return error_response(
code=ErrorCodes.UNAUTHORIZED,
message='User does not exist',
status_code=status.HTTP_401_UNAUTHORIZED
)
# CamelCaseParser 将 oldPassword -> old_password
@@ -152,15 +164,17 @@ class ChangePasswordView(APIView):
new_password = request.data.get('new_password')
if not old_password or not new_password:
return Response(
{'error': '请提供旧密码和新密码'},
status=status.HTTP_400_BAD_REQUEST
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Old password and new password are required',
status_code=status.HTTP_400_BAD_REQUEST
)
if not user.check_password(old_password):
return Response(
{'error': '旧密码错误'},
status=status.HTTP_400_BAD_REQUEST
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Old password is incorrect',
status_code=status.HTTP_400_BAD_REQUEST
)
user.set_password(new_password)
@@ -170,4 +184,4 @@ class ChangePasswordView(APIView):
update_session_auth_hash(request, user)
logger.info(f"用户 {user.username} 已修改密码")
return Response({'message': '密码修改成功'})
return success_response()

View File

@@ -13,12 +13,57 @@ from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.common.response_helpers import success_response, error_response
from apps.common.error_codes import ErrorCodes
from apps.common.services.system_log_service import SystemLogService
logger = logging.getLogger(__name__)
@method_decorator(csrf_exempt, name="dispatch")
class SystemLogFilesView(APIView):
"""
日志文件列表 API 视图
GET /api/system/logs/files/
获取所有可用的日志文件列表
Response:
{
"files": [
{
"filename": "xingrin.log",
"category": "system",
"size": 1048576,
"modifiedAt": "2025-01-15T10:30:00+00:00"
},
...
]
}
"""
authentication_classes = []
permission_classes = [AllowAny]
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.service = SystemLogService()
def get(self, request):
"""获取日志文件列表"""
try:
files = self.service.get_log_files()
return success_response(data={"files": files})
except Exception:
logger.exception("获取日志文件列表失败")
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Failed to get log files',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@method_decorator(csrf_exempt, name="dispatch")
class SystemLogsView(APIView):
"""
@@ -28,6 +73,7 @@ class SystemLogsView(APIView):
获取系统日志内容
Query Parameters:
file (str, optional): 日志文件名,默认 xingrin.log
lines (int, optional): 返回的日志行数,默认 200最大 10000
Response:
@@ -52,18 +98,33 @@ class SystemLogsView(APIView):
"""
获取系统日志
支持通过 lines 参数控制返回行数,用于前端分页或实时刷新场景
支持通过 file 和 lines 参数控制返回内容
"""
try:
# 解析 lines 参数
# 解析参数
filename = request.query_params.get("file")
lines_raw = request.query_params.get("lines")
lines = int(lines_raw) if lines_raw is not None else None
# 调用服务获取日志内容
content = self.service.get_logs_content(lines=lines)
return Response({"content": content})
except ValueError:
return Response({"error": "lines 参数必须是整数"}, status=status.HTTP_400_BAD_REQUEST)
content = self.service.get_logs_content(filename=filename, lines=lines)
return success_response(data={"content": content})
except ValueError as e:
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message=str(e) if 'file' in str(e).lower() else 'lines must be an integer',
status_code=status.HTTP_400_BAD_REQUEST
)
except FileNotFoundError as e:
return error_response(
code=ErrorCodes.NOT_FOUND,
message=str(e),
status_code=status.HTTP_404_NOT_FOUND
)
except Exception:
logger.exception("获取系统日志失败")
return Response({"error": "获取系统日志失败"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Failed to get system logs',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)

View File

@@ -90,6 +90,7 @@ class Command(BaseCommand):
single_config,
sort_keys=False,
allow_unicode=True,
default_flow_style=None,
)
except yaml.YAMLError as e:
self.stdout.write(self.style.ERROR(f'生成子引擎 {scan_type} 配置失败: {e}'))

View File

@@ -3,6 +3,9 @@
- EHole 指纹: ehole.json -> 导入到数据库
- Goby 指纹: goby.json -> 导入到数据库
- Wappalyzer 指纹: wappalyzer.json -> 导入到数据库
- Fingers 指纹: fingers_http.json -> 导入到数据库
- FingerPrintHub 指纹: fingerprinthub_web.json -> 导入到数据库
- ARL 指纹: ARL.yaml -> 导入到数据库
可重复执行:如果数据库已有数据则跳过,只在空库时导入。
"""
@@ -11,14 +14,25 @@ import json
import logging
from pathlib import Path
import yaml
from django.conf import settings
from django.core.management.base import BaseCommand
from apps.engine.models import EholeFingerprint, GobyFingerprint, WappalyzerFingerprint
from apps.engine.models import (
EholeFingerprint,
GobyFingerprint,
WappalyzerFingerprint,
FingersFingerprint,
FingerPrintHubFingerprint,
ARLFingerprint,
)
from apps.engine.services.fingerprints import (
EholeFingerprintService,
GobyFingerprintService,
WappalyzerFingerprintService,
FingersFingerprintService,
FingerPrintHubFingerprintService,
ARLFingerprintService,
)
@@ -33,6 +47,7 @@ DEFAULT_FINGERPRINTS = [
"model": EholeFingerprint,
"service": EholeFingerprintService,
"data_key": "fingerprint", # JSON 中指纹数组的 key
"file_format": "json",
},
{
"type": "goby",
@@ -40,6 +55,7 @@ DEFAULT_FINGERPRINTS = [
"model": GobyFingerprint,
"service": GobyFingerprintService,
"data_key": None, # Goby 是数组格式,直接使用整个 JSON
"file_format": "json",
},
{
"type": "wappalyzer",
@@ -47,6 +63,31 @@ DEFAULT_FINGERPRINTS = [
"model": WappalyzerFingerprint,
"service": WappalyzerFingerprintService,
"data_key": "apps", # Wappalyzer 使用 apps 对象
"file_format": "json",
},
{
"type": "fingers",
"filename": "fingers_http.json",
"model": FingersFingerprint,
"service": FingersFingerprintService,
"data_key": None, # Fingers 是数组格式
"file_format": "json",
},
{
"type": "fingerprinthub",
"filename": "fingerprinthub_web.json",
"model": FingerPrintHubFingerprint,
"service": FingerPrintHubFingerprintService,
"data_key": None, # FingerPrintHub 是数组格式
"file_format": "json",
},
{
"type": "arl",
"filename": "ARL.yaml",
"model": ARLFingerprint,
"service": ARLFingerprintService,
"data_key": None, # ARL 是 YAML 数组格式
"file_format": "yaml",
},
]
@@ -68,6 +109,7 @@ class Command(BaseCommand):
model = item["model"]
service_class = item["service"]
data_key = item["data_key"]
file_format = item.get("file_format", "json")
# 检查数据库是否已有数据
existing_count = model.objects.count()
@@ -87,11 +129,14 @@ class Command(BaseCommand):
failed += 1
continue
# 读取并解析 JSON
# 读取并解析文件(支持 JSON 和 YAML
try:
with open(src_path, "r", encoding="utf-8") as f:
json_data = json.load(f)
except (json.JSONDecodeError, OSError) as exc:
if file_format == "yaml":
file_data = yaml.safe_load(f)
else:
file_data = json.load(f)
except (json.JSONDecodeError, yaml.YAMLError, OSError) as exc:
self.stdout.write(self.style.ERROR(
f"[{fp_type}] 读取指纹文件失败: {exc}"
))
@@ -99,7 +144,7 @@ class Command(BaseCommand):
continue
# 提取指纹数据(根据不同格式处理)
fingerprints = self._extract_fingerprints(json_data, data_key, fp_type)
fingerprints = self._extract_fingerprints(file_data, data_key, fp_type)
if not fingerprints:
self.stdout.write(self.style.WARNING(
f"[{fp_type}] 指纹文件中没有有效数据,跳过"

View File

@@ -4,7 +4,14 @@
"""
from .engine import WorkerNode, ScanEngine, Wordlist, NucleiTemplateRepo
from .fingerprints import EholeFingerprint, GobyFingerprint, WappalyzerFingerprint
from .fingerprints import (
EholeFingerprint,
GobyFingerprint,
WappalyzerFingerprint,
FingersFingerprint,
FingerPrintHubFingerprint,
ARLFingerprint,
)
__all__ = [
# 核心 Models
@@ -16,4 +23,7 @@ __all__ = [
"EholeFingerprint",
"GobyFingerprint",
"WappalyzerFingerprint",
"FingersFingerprint",
"FingerPrintHubFingerprint",
"ARLFingerprint",
]

View File

@@ -106,3 +106,90 @@ class WappalyzerFingerprint(models.Model):
def __str__(self) -> str:
return f"{self.name}"
class FingersFingerprint(models.Model):
"""Fingers 格式指纹规则 (fingers_http.json)
使用正则表达式和标签进行匹配,支持 favicon hash、header、body 等多种检测方式
"""
name = models.CharField(max_length=300, unique=True, help_text='指纹名称')
link = models.URLField(max_length=500, blank=True, default='', help_text='相关链接')
rule = models.JSONField(default=list, help_text='匹配规则数组')
tag = models.JSONField(default=list, help_text='标签数组')
focus = models.BooleanField(default=False, help_text='是否重点关注')
default_port = models.JSONField(default=list, blank=True, help_text='默认端口数组')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'fingers_fingerprint'
verbose_name = 'Fingers 指纹'
verbose_name_plural = 'Fingers 指纹'
ordering = ['-created_at']
indexes = [
models.Index(fields=['name']),
models.Index(fields=['link']),
models.Index(fields=['focus']),
models.Index(fields=['-created_at']),
]
def __str__(self) -> str:
return f"{self.name}"
class FingerPrintHubFingerprint(models.Model):
"""FingerPrintHub 格式指纹规则 (fingerprinthub_web.json)
基于 nuclei 模板格式,使用 HTTP 请求和响应特征进行匹配
"""
fp_id = models.CharField(max_length=200, unique=True, help_text='指纹ID')
name = models.CharField(max_length=300, help_text='指纹名称')
author = models.CharField(max_length=200, blank=True, default='', help_text='作者')
tags = models.CharField(max_length=500, blank=True, default='', help_text='标签')
severity = models.CharField(max_length=50, blank=True, default='info', help_text='严重程度')
metadata = models.JSONField(default=dict, blank=True, help_text='元数据')
http = models.JSONField(default=list, help_text='HTTP 匹配规则')
source_file = models.CharField(max_length=500, blank=True, default='', help_text='来源文件')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'fingerprinthub_fingerprint'
verbose_name = 'FingerPrintHub 指纹'
verbose_name_plural = 'FingerPrintHub 指纹'
ordering = ['-created_at']
indexes = [
models.Index(fields=['fp_id']),
models.Index(fields=['name']),
models.Index(fields=['author']),
models.Index(fields=['severity']),
models.Index(fields=['-created_at']),
]
def __str__(self) -> str:
return f"{self.name} ({self.fp_id})"
class ARLFingerprint(models.Model):
"""ARL 格式指纹规则 (ARL.yaml)
使用简单的 name + rule 表达式格式
"""
name = models.CharField(max_length=300, unique=True, help_text='指纹名称')
rule = models.TextField(help_text='匹配规则表达式')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'arl_fingerprint'
verbose_name = 'ARL 指纹'
verbose_name_plural = 'ARL 指纹'
ordering = ['-created_at']
indexes = [
models.Index(fields=['name']),
models.Index(fields=['-created_at']),
]
def __str__(self) -> str:
return f"{self.name}"

View File

@@ -6,9 +6,15 @@
from .ehole import EholeFingerprintSerializer
from .goby import GobyFingerprintSerializer
from .wappalyzer import WappalyzerFingerprintSerializer
from .fingers import FingersFingerprintSerializer
from .fingerprinthub import FingerPrintHubFingerprintSerializer
from .arl import ARLFingerprintSerializer
__all__ = [
"EholeFingerprintSerializer",
"GobyFingerprintSerializer",
"WappalyzerFingerprintSerializer",
"FingersFingerprintSerializer",
"FingerPrintHubFingerprintSerializer",
"ARLFingerprintSerializer",
]

View File

@@ -0,0 +1,31 @@
"""ARL 指纹 Serializer"""
from rest_framework import serializers
from apps.engine.models import ARLFingerprint
class ARLFingerprintSerializer(serializers.ModelSerializer):
"""ARL 指纹序列化器
字段映射:
- name: 指纹名称 (必填, 唯一)
- rule: 匹配规则表达式 (必填)
"""
class Meta:
model = ARLFingerprint
fields = ['id', 'name', 'rule', 'created_at']
read_only_fields = ['id', 'created_at']
def validate_name(self, value):
"""校验 name 字段"""
if not value or not value.strip():
raise serializers.ValidationError("name 字段不能为空")
return value.strip()
def validate_rule(self, value):
"""校验 rule 字段"""
if not value or not value.strip():
raise serializers.ValidationError("rule 字段不能为空")
return value.strip()

View File

@@ -0,0 +1,50 @@
"""FingerPrintHub 指纹 Serializer"""
from rest_framework import serializers
from apps.engine.models import FingerPrintHubFingerprint
class FingerPrintHubFingerprintSerializer(serializers.ModelSerializer):
"""FingerPrintHub 指纹序列化器
字段映射:
- fp_id: 指纹ID (必填, 唯一)
- name: 指纹名称 (必填)
- author: 作者 (可选)
- tags: 标签字符串 (可选)
- severity: 严重程度 (可选, 默认 'info')
- metadata: 元数据 JSON (可选)
- http: HTTP 匹配规则数组 (必填)
- source_file: 来源文件 (可选)
"""
class Meta:
model = FingerPrintHubFingerprint
fields = ['id', 'fp_id', 'name', 'author', 'tags', 'severity',
'metadata', 'http', 'source_file', 'created_at']
read_only_fields = ['id', 'created_at']
def validate_fp_id(self, value):
"""校验 fp_id 字段"""
if not value or not value.strip():
raise serializers.ValidationError("fp_id 字段不能为空")
return value.strip()
def validate_name(self, value):
"""校验 name 字段"""
if not value or not value.strip():
raise serializers.ValidationError("name 字段不能为空")
return value.strip()
def validate_http(self, value):
"""校验 http 字段"""
if not isinstance(value, list):
raise serializers.ValidationError("http 必须是数组")
return value
def validate_metadata(self, value):
"""校验 metadata 字段"""
if not isinstance(value, dict):
raise serializers.ValidationError("metadata 必须是对象")
return value

View File

@@ -0,0 +1,48 @@
"""Fingers 指纹 Serializer"""
from rest_framework import serializers
from apps.engine.models import FingersFingerprint
class FingersFingerprintSerializer(serializers.ModelSerializer):
"""Fingers 指纹序列化器
字段映射:
- name: 指纹名称 (必填, 唯一)
- link: 相关链接 (可选)
- rule: 匹配规则数组 (必填)
- tag: 标签数组 (可选)
- focus: 是否重点关注 (可选, 默认 False)
- default_port: 默认端口数组 (可选)
"""
class Meta:
model = FingersFingerprint
fields = ['id', 'name', 'link', 'rule', 'tag', 'focus',
'default_port', 'created_at']
read_only_fields = ['id', 'created_at']
def validate_name(self, value):
"""校验 name 字段"""
if not value or not value.strip():
raise serializers.ValidationError("name 字段不能为空")
return value.strip()
def validate_rule(self, value):
"""校验 rule 字段"""
if not isinstance(value, list):
raise serializers.ValidationError("rule 必须是数组")
return value
def validate_tag(self, value):
"""校验 tag 字段"""
if not isinstance(value, list):
raise serializers.ValidationError("tag 必须是数组")
return value
def validate_default_port(self, value):
"""校验 default_port 字段"""
if not isinstance(value, list):
raise serializers.ValidationError("default_port 必须是数组")
return value

View File

@@ -7,10 +7,16 @@ from .base import BaseFingerprintService
from .ehole import EholeFingerprintService
from .goby import GobyFingerprintService
from .wappalyzer import WappalyzerFingerprintService
from .fingers_service import FingersFingerprintService
from .fingerprinthub_service import FingerPrintHubFingerprintService
from .arl_service import ARLFingerprintService
__all__ = [
"BaseFingerprintService",
"EholeFingerprintService",
"GobyFingerprintService",
"WappalyzerFingerprintService",
"FingersFingerprintService",
"FingerPrintHubFingerprintService",
"ARLFingerprintService",
]

View File

@@ -0,0 +1,110 @@
"""ARL 指纹管理 Service
实现 ARL 格式指纹的校验、转换和导出逻辑
支持 YAML 格式的导入导出
"""
import logging
import yaml
from apps.engine.models import ARLFingerprint
from .base import BaseFingerprintService
logger = logging.getLogger(__name__)
class ARLFingerprintService(BaseFingerprintService):
"""ARL 指纹管理服务(继承基类,实现 ARL 特定逻辑)"""
model = ARLFingerprint
def validate_fingerprint(self, item: dict) -> bool:
"""
校验单条 ARL 指纹
校验规则:
- name 字段必须存在且非空
- rule 字段必须存在且非空
Args:
item: 单条指纹数据
Returns:
bool: 是否有效
"""
name = item.get('name', '')
rule = item.get('rule', '')
return bool(name and str(name).strip()) and bool(rule and str(rule).strip())
def to_model_data(self, item: dict) -> dict:
"""
转换 ARL YAML 格式为 Model 字段
Args:
item: 原始 ARL YAML 数据
Returns:
dict: Model 字段数据
"""
return {
'name': str(item.get('name', '')).strip(),
'rule': str(item.get('rule', '')).strip(),
}
def get_export_data(self) -> list:
"""
获取导出数据ARL 格式 - 数组,用于 YAML 导出)
Returns:
list: ARL 格式的数据(数组格式)
[
{"name": "...", "rule": "..."},
...
]
"""
fingerprints = self.model.objects.all()
return [
{
'name': fp.name,
'rule': fp.rule,
}
for fp in fingerprints
]
def export_to_yaml(self, output_path: str) -> int:
"""
导出所有指纹到 YAML 文件
Args:
output_path: 输出文件路径
Returns:
int: 导出的指纹数量
"""
data = self.get_export_data()
with open(output_path, 'w', encoding='utf-8') as f:
yaml.dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
count = len(data)
logger.info("导出 ARL 指纹文件: %s, 数量: %d", output_path, count)
return count
def parse_yaml_import(self, yaml_content: str) -> list:
"""
解析 YAML 格式的导入内容
Args:
yaml_content: YAML 格式的字符串内容
Returns:
list: 解析后的指纹数据列表
Raises:
ValueError: 当 YAML 格式无效时
"""
try:
data = yaml.safe_load(yaml_content)
if not isinstance(data, list):
raise ValueError("ARL YAML 文件必须是数组格式")
return data
except yaml.YAMLError as e:
raise ValueError(f"无效的 YAML 格式: {e}")

View File

@@ -0,0 +1,110 @@
"""FingerPrintHub 指纹管理 Service
实现 FingerPrintHub 格式指纹的校验、转换和导出逻辑
"""
from apps.engine.models import FingerPrintHubFingerprint
from .base import BaseFingerprintService
class FingerPrintHubFingerprintService(BaseFingerprintService):
"""FingerPrintHub 指纹管理服务(继承基类,实现 FingerPrintHub 特定逻辑)"""
model = FingerPrintHubFingerprint
def validate_fingerprint(self, item: dict) -> bool:
"""
校验单条 FingerPrintHub 指纹
校验规则:
- id 字段必须存在且非空
- info 字段必须存在且包含 name
- http 字段必须是数组
Args:
item: 单条指纹数据
Returns:
bool: 是否有效
"""
fp_id = item.get('id', '')
info = item.get('info', {})
http = item.get('http')
if not fp_id or not str(fp_id).strip():
return False
if not isinstance(info, dict) or not info.get('name'):
return False
if not isinstance(http, list):
return False
return True
def to_model_data(self, item: dict) -> dict:
"""
转换 FingerPrintHub JSON 格式为 Model 字段
字段映射(嵌套结构转扁平):
- id (JSON) → fp_id (Model)
- info.name (JSON) → name (Model)
- info.author (JSON) → author (Model)
- info.tags (JSON) → tags (Model)
- info.severity (JSON) → severity (Model)
- info.metadata (JSON) → metadata (Model)
- http (JSON) → http (Model)
- _source_file (JSON) → source_file (Model)
Args:
item: 原始 FingerPrintHub JSON 数据
Returns:
dict: Model 字段数据
"""
info = item.get('info', {})
return {
'fp_id': str(item.get('id', '')).strip(),
'name': str(info.get('name', '')).strip(),
'author': info.get('author', ''),
'tags': info.get('tags', ''),
'severity': info.get('severity', 'info'),
'metadata': info.get('metadata', {}),
'http': item.get('http', []),
'source_file': item.get('_source_file', ''),
}
def get_export_data(self) -> list:
"""
获取导出数据FingerPrintHub JSON 格式 - 数组)
Returns:
list: FingerPrintHub 格式的 JSON 数据(数组格式)
[
{
"id": "...",
"info": {"name": "...", "author": "...", "tags": "...",
"severity": "...", "metadata": {...}},
"http": [...],
"_source_file": "..."
},
...
]
"""
fingerprints = self.model.objects.all()
data = []
for fp in fingerprints:
item = {
'id': fp.fp_id,
'info': {
'name': fp.name,
'author': fp.author,
'tags': fp.tags,
'severity': fp.severity,
'metadata': fp.metadata,
},
'http': fp.http,
}
# 只有当 source_file 非空时才添加该字段
if fp.source_file:
item['_source_file'] = fp.source_file
data.append(item)
return data

View File

@@ -0,0 +1,83 @@
"""Fingers 指纹管理 Service
实现 Fingers 格式指纹的校验、转换和导出逻辑
"""
from apps.engine.models import FingersFingerprint
from .base import BaseFingerprintService
class FingersFingerprintService(BaseFingerprintService):
"""Fingers 指纹管理服务(继承基类,实现 Fingers 特定逻辑)"""
model = FingersFingerprint
def validate_fingerprint(self, item: dict) -> bool:
"""
校验单条 Fingers 指纹
校验规则:
- name 字段必须存在且非空
- rule 字段必须是数组
Args:
item: 单条指纹数据
Returns:
bool: 是否有效
"""
name = item.get('name', '')
rule = item.get('rule')
return bool(name and str(name).strip()) and isinstance(rule, list)
def to_model_data(self, item: dict) -> dict:
"""
转换 Fingers JSON 格式为 Model 字段
字段映射:
- default_port (JSON) → default_port (Model)
Args:
item: 原始 Fingers JSON 数据
Returns:
dict: Model 字段数据
"""
return {
'name': str(item.get('name', '')).strip(),
'link': item.get('link', ''),
'rule': item.get('rule', []),
'tag': item.get('tag', []),
'focus': item.get('focus', False),
'default_port': item.get('default_port', []),
}
def get_export_data(self) -> list:
"""
获取导出数据Fingers JSON 格式 - 数组)
Returns:
list: Fingers 格式的 JSON 数据(数组格式)
[
{"name": "...", "link": "...", "rule": [...], "tag": [...],
"focus": false, "default_port": [...]},
...
]
"""
fingerprints = self.model.objects.all()
data = []
for fp in fingerprints:
item = {
'name': fp.name,
'link': fp.link,
'rule': fp.rule,
'tag': fp.tag,
}
# 只有当 focus 为 True 时才添加该字段(保持与原始格式一致)
if fp.focus:
item['focus'] = fp.focus
# 只有当 default_port 非空时才添加该字段
if fp.default_port:
item['default_port'] = fp.default_port
data.append(item)
return data

View File

@@ -186,7 +186,6 @@ class NucleiTemplateRepoService:
RuntimeError: Git 命令执行失败
"""
import subprocess
from apps.common.utils.xget_proxy import get_xget_proxy_url
obj = self._get_repo_obj(repo_id)
@@ -197,14 +196,12 @@ class NucleiTemplateRepoService:
cmd: List[str]
action: str
# 获取代理后的 URL如果启用了 xget 加速)
proxied_url = get_xget_proxy_url(obj.repo_url)
if proxied_url != obj.repo_url:
logger.info("使用 Xget 加速: %s -> %s", obj.repo_url, proxied_url)
# 直接使用原始 URL不再使用 Git 加速)
repo_url = obj.repo_url
# 判断是 clone 还是 pull
if git_dir.is_dir():
# 检查远程地址是否变化(比较原始 URL不是代理 URL
# 检查远程地址是否变化
current_remote = subprocess.run(
["git", "-C", str(local_path), "remote", "get-url", "origin"],
check=False,
@@ -214,13 +211,13 @@ class NucleiTemplateRepoService:
)
current_url = current_remote.stdout.strip() if current_remote.returncode == 0 else ""
# 检查是否需要重新 clone(原始 URL 或代理 URL 变化都需要)
if current_url not in [obj.repo_url, proxied_url]:
# 检查是否需要重新 clone
if current_url != repo_url:
# 远程地址变化,删除旧目录重新 clone
logger.info("nuclei 模板仓库 %s 远程地址变化,重新 clone: %s -> %s", obj.id, current_url, obj.repo_url)
logger.info("nuclei 模板仓库 %s 远程地址变化,重新 clone: %s -> %s", obj.id, current_url, repo_url)
shutil.rmtree(local_path)
local_path.mkdir(parents=True, exist_ok=True)
cmd = ["git", "clone", "--depth", "1", proxied_url, str(local_path)]
cmd = ["git", "clone", "--depth", "1", repo_url, str(local_path)]
action = "clone"
else:
# 已有仓库且地址未变,执行 pull
@@ -231,7 +228,7 @@ class NucleiTemplateRepoService:
if local_path.exists() and not local_path.is_dir():
raise RuntimeError(f"本地路径已存在且不是目录: {local_path}")
# --depth 1 浅克隆,只获取最新提交,节省空间和时间
cmd = ["git", "clone", "--depth", "1", proxied_url, str(local_path)]
cmd = ["git", "clone", "--depth", "1", repo_url, str(local_path)]
action = "clone"
# 执行 Git 命令

View File

@@ -274,7 +274,7 @@ class TaskDistributor:
network_arg = ""
server_url = f"https://{settings.PUBLIC_HOST}:{settings.PUBLIC_PORT}"
# 挂载路径(统一挂载 /opt/xingrin
# 挂载路径(统一挂载 /opt/xingrin,扫描工具在 /opt/xingrin-tools/bin 不受影响
host_xingrin_dir = "/opt/xingrin"
# 环境变量SERVER_URL + IS_LOCAL其他配置容器启动时从配置中心获取
@@ -311,11 +311,10 @@ class TaskDistributor:
# - 本地 Workerinstall.sh 已预拉取镜像,直接使用本地版本
# - 远程 Workerdeploy 时已预拉取镜像,直接使用本地版本
# - 避免每次任务都检查 Docker Hub提升性能和稳定性
# 使用双引号包裹 sh -c 命令,内部 shlex.quote 生成的单引号参数可正确解析
cmd = f'''docker run --rm -d --pull=missing {network_arg} \
{' '.join(env_vars)} \
{' '.join(volumes)} \
{self.docker_image} \
cmd = f'''docker run --rm -d --pull=missing {network_arg} \\
{' '.join(env_vars)} \\
{' '.join(volumes)} \\
{self.docker_image} \\
sh -c "{inner_cmd}"'''
return cmd

View File

@@ -11,6 +11,9 @@ from .views.fingerprints import (
EholeFingerprintViewSet,
GobyFingerprintViewSet,
WappalyzerFingerprintViewSet,
FingersFingerprintViewSet,
FingerPrintHubFingerprintViewSet,
ARLFingerprintViewSet,
)
@@ -24,6 +27,9 @@ router.register(r"nuclei/repos", NucleiTemplateRepoViewSet, basename="nuclei-rep
router.register(r"fingerprints/ehole", EholeFingerprintViewSet, basename="ehole-fingerprint")
router.register(r"fingerprints/goby", GobyFingerprintViewSet, basename="goby-fingerprint")
router.register(r"fingerprints/wappalyzer", WappalyzerFingerprintViewSet, basename="wappalyzer-fingerprint")
router.register(r"fingerprints/fingers", FingersFingerprintViewSet, basename="fingers-fingerprint")
router.register(r"fingerprints/fingerprinthub", FingerPrintHubFingerprintViewSet, basename="fingerprinthub-fingerprint")
router.register(r"fingerprints/arl", ARLFingerprintViewSet, basename="arl-fingerprint")
urlpatterns = [
path("", include(router.urls)),

View File

@@ -7,10 +7,16 @@ from .base import BaseFingerprintViewSet
from .ehole import EholeFingerprintViewSet
from .goby import GobyFingerprintViewSet
from .wappalyzer import WappalyzerFingerprintViewSet
from .fingers import FingersFingerprintViewSet
from .fingerprinthub import FingerPrintHubFingerprintViewSet
from .arl import ARLFingerprintViewSet
__all__ = [
"BaseFingerprintViewSet",
"EholeFingerprintViewSet",
"GobyFingerprintViewSet",
"WappalyzerFingerprintViewSet",
"FingersFingerprintViewSet",
"FingerPrintHubFingerprintViewSet",
"ARLFingerprintViewSet",
]

View File

@@ -0,0 +1,122 @@
"""ARL 指纹管理 ViewSet"""
import yaml
from django.http import HttpResponse
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from apps.common.pagination import BasePagination
from apps.common.response_helpers import success_response
from apps.engine.models import ARLFingerprint
from apps.engine.serializers.fingerprints import ARLFingerprintSerializer
from apps.engine.services.fingerprints import ARLFingerprintService
from .base import BaseFingerprintViewSet
class ARLFingerprintViewSet(BaseFingerprintViewSet):
"""ARL 指纹管理 ViewSet
继承自 BaseFingerprintViewSet提供以下 API
标准 CRUDModelViewSet
- GET / 列表查询(分页)
- POST / 创建单条
- GET /{id}/ 获取详情
- PUT /{id}/ 更新
- DELETE /{id}/ 删除
批量操作(继承自基类):
- POST /batch_create/ 批量创建JSON body
- POST /import_file/ 文件导入multipart/form-data支持 YAML
- POST /bulk-delete/ 批量删除
- POST /delete-all/ 删除所有
- GET /export/ 导出下载YAML 格式)
智能过滤语法filter 参数):
- name="word" 模糊匹配 name 字段
- name=="WordPress" 精确匹配
- rule="body=" 按规则内容筛选
"""
queryset = ARLFingerprint.objects.all()
serializer_class = ARLFingerprintSerializer
pagination_class = BasePagination
service_class = ARLFingerprintService
# 排序配置
ordering_fields = ['created_at', 'name']
ordering = ['-created_at']
# ARL 过滤字段映射
FILTER_FIELD_MAPPING = {
'name': 'name',
'rule': 'rule',
}
def parse_import_data(self, json_data) -> list:
"""
解析 ARL 格式的导入数据JSON 格式)
输入格式:[{...}, {...}] 数组格式
返回:指纹列表
"""
if isinstance(json_data, list):
return json_data
return []
def get_export_filename(self) -> str:
"""导出文件名"""
return 'ARL.yaml'
@action(detail=False, methods=['post'])
def import_file(self, request):
"""
文件导入(支持 YAML 和 JSON 格式)
POST /api/engine/fingerprints/arl/import_file/
请求格式multipart/form-data
- file: YAML 或 JSON 文件
返回:同 batch_create
"""
file = request.FILES.get('file')
if not file:
raise ValidationError('缺少文件')
filename = file.name.lower()
content = file.read().decode('utf-8')
try:
if filename.endswith('.yaml') or filename.endswith('.yml'):
# YAML 格式
fingerprints = yaml.safe_load(content)
else:
# JSON 格式
import json
fingerprints = json.loads(content)
except (yaml.YAMLError, json.JSONDecodeError) as e:
raise ValidationError(f'无效的文件格式: {e}')
if not isinstance(fingerprints, list):
raise ValidationError('文件内容必须是数组格式')
if not fingerprints:
raise ValidationError('文件中没有有效的指纹数据')
result = self.get_service().batch_create_fingerprints(fingerprints)
return success_response(data=result)
@action(detail=False, methods=['get'])
def export(self, request):
"""
导出指纹YAML 格式)
GET /api/engine/fingerprints/arl/export/
返回YAML 文件下载
"""
data = self.get_service().get_export_data()
content = yaml.dump(data, allow_unicode=True, default_flow_style=False, sort_keys=False)
response = HttpResponse(content, content_type='application/x-yaml')
response['Content-Disposition'] = f'attachment; filename="{self.get_export_filename()}"'
return response

View File

@@ -13,6 +13,7 @@ from rest_framework.response import Response
from rest_framework.exceptions import ValidationError
from apps.common.pagination import BasePagination
from apps.common.response_helpers import success_response
from apps.common.utils.filter_utils import apply_filters
logger = logging.getLogger(__name__)
@@ -129,7 +130,7 @@ class BaseFingerprintViewSet(viewsets.ModelViewSet):
raise ValidationError('fingerprints 必须是数组')
result = self.get_service().batch_create_fingerprints(fingerprints)
return Response(result, status=status.HTTP_201_CREATED)
return success_response(data=result, status_code=status.HTTP_201_CREATED)
@action(detail=False, methods=['post'])
def import_file(self, request):
@@ -156,7 +157,7 @@ class BaseFingerprintViewSet(viewsets.ModelViewSet):
raise ValidationError('文件中没有有效的指纹数据')
result = self.get_service().batch_create_fingerprints(fingerprints)
return Response(result, status=status.HTTP_201_CREATED)
return success_response(data=result, status_code=status.HTTP_201_CREATED)
@action(detail=False, methods=['post'], url_path='bulk-delete')
def bulk_delete(self, request):
@@ -174,7 +175,7 @@ class BaseFingerprintViewSet(viewsets.ModelViewSet):
raise ValidationError('ids 必须是数组')
deleted_count = self.queryset.model.objects.filter(id__in=ids).delete()[0]
return Response({'deleted': deleted_count})
return success_response(data={'deleted': deleted_count})
@action(detail=False, methods=['post'], url_path='delete-all')
def delete_all(self, request):
@@ -185,7 +186,7 @@ class BaseFingerprintViewSet(viewsets.ModelViewSet):
返回:{"deleted": 1000}
"""
deleted_count = self.queryset.model.objects.all().delete()[0]
return Response({'deleted': deleted_count})
return success_response(data={'deleted': deleted_count})
@action(detail=False, methods=['get'])
def export(self, request):

View File

@@ -0,0 +1,73 @@
"""FingerPrintHub 指纹管理 ViewSet"""
from apps.common.pagination import BasePagination
from apps.engine.models import FingerPrintHubFingerprint
from apps.engine.serializers.fingerprints import FingerPrintHubFingerprintSerializer
from apps.engine.services.fingerprints import FingerPrintHubFingerprintService
from .base import BaseFingerprintViewSet
class FingerPrintHubFingerprintViewSet(BaseFingerprintViewSet):
"""FingerPrintHub 指纹管理 ViewSet
继承自 BaseFingerprintViewSet提供以下 API
标准 CRUDModelViewSet
- GET / 列表查询(分页)
- POST / 创建单条
- GET /{id}/ 获取详情
- PUT /{id}/ 更新
- DELETE /{id}/ 删除
批量操作(继承自基类):
- POST /batch_create/ 批量创建JSON body
- POST /import_file/ 文件导入multipart/form-data
- POST /bulk-delete/ 批量删除
- POST /delete-all/ 删除所有
- GET /export/ 导出下载
智能过滤语法filter 参数):
- name="word" 模糊匹配 name 字段
- fp_id=="xxx" 精确匹配指纹ID
- author="xxx" 按作者筛选
- severity="info" 按严重程度筛选
- tags="cms" 按标签筛选
"""
queryset = FingerPrintHubFingerprint.objects.all()
serializer_class = FingerPrintHubFingerprintSerializer
pagination_class = BasePagination
service_class = FingerPrintHubFingerprintService
# 排序配置
ordering_fields = ['created_at', 'name', 'severity']
ordering = ['-created_at']
# FingerPrintHub 过滤字段映射
FILTER_FIELD_MAPPING = {
'fp_id': 'fp_id',
'name': 'name',
'author': 'author',
'tags': 'tags',
'severity': 'severity',
'source_file': 'source_file',
}
# JSON 数组字段(使用 __contains 查询)
JSON_ARRAY_FIELDS = ['http']
def parse_import_data(self, json_data) -> list:
"""
解析 FingerPrintHub JSON 格式的导入数据
输入格式:[{...}, {...}] 数组格式
返回:指纹列表
"""
if isinstance(json_data, list):
return json_data
return []
def get_export_filename(self) -> str:
"""导出文件名"""
return 'fingerprinthub_web.json'

View File

@@ -0,0 +1,69 @@
"""Fingers 指纹管理 ViewSet"""
from apps.common.pagination import BasePagination
from apps.engine.models import FingersFingerprint
from apps.engine.serializers.fingerprints import FingersFingerprintSerializer
from apps.engine.services.fingerprints import FingersFingerprintService
from .base import BaseFingerprintViewSet
class FingersFingerprintViewSet(BaseFingerprintViewSet):
"""Fingers 指纹管理 ViewSet
继承自 BaseFingerprintViewSet提供以下 API
标准 CRUDModelViewSet
- GET / 列表查询(分页)
- POST / 创建单条
- GET /{id}/ 获取详情
- PUT /{id}/ 更新
- DELETE /{id}/ 删除
批量操作(继承自基类):
- POST /batch_create/ 批量创建JSON body
- POST /import_file/ 文件导入multipart/form-data
- POST /bulk-delete/ 批量删除
- POST /delete-all/ 删除所有
- GET /export/ 导出下载
智能过滤语法filter 参数):
- name="word" 模糊匹配 name 字段
- name=="WordPress" 精确匹配
- tag="cms" 按标签筛选
- focus="true" 按重点关注筛选
"""
queryset = FingersFingerprint.objects.all()
serializer_class = FingersFingerprintSerializer
pagination_class = BasePagination
service_class = FingersFingerprintService
# 排序配置
ordering_fields = ['created_at', 'name']
ordering = ['-created_at']
# Fingers 过滤字段映射
FILTER_FIELD_MAPPING = {
'name': 'name',
'link': 'link',
'focus': 'focus',
}
# JSON 数组字段(使用 __contains 查询)
JSON_ARRAY_FIELDS = ['tag', 'rule', 'default_port']
def parse_import_data(self, json_data) -> list:
"""
解析 Fingers JSON 格式的导入数据
输入格式:[{...}, {...}] 数组格式
返回:指纹列表
"""
if isinstance(json_data, list):
return json_data
return []
def get_export_filename(self) -> str:
"""导出文件名"""
return 'fingers_http.json'

View File

@@ -31,6 +31,8 @@ from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from apps.common.response_helpers import success_response, error_response
from apps.common.error_codes import ErrorCodes
from apps.engine.models import NucleiTemplateRepo
from apps.engine.serializers import NucleiTemplateRepoSerializer
from apps.engine.services import NucleiTemplateRepoService
@@ -107,18 +109,30 @@ class NucleiTemplateRepoViewSet(viewsets.ModelViewSet):
try:
repo_id = int(pk) if pk is not None else None
except (TypeError, ValueError):
return Response({"message": "无效的仓库 ID"}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Invalid repository ID',
status_code=status.HTTP_400_BAD_REQUEST
)
# 调用 Service 层
try:
result = self.service.refresh_repo(repo_id)
except ValidationError as exc:
return Response({"message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message=str(exc),
status_code=status.HTTP_400_BAD_REQUEST
)
except Exception as exc: # noqa: BLE001
logger.error("刷新 Nuclei 模板仓库失败: %s", exc, exc_info=True)
return Response({"message": f"刷新仓库失败: {exc}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return error_response(
code=ErrorCodes.SERVER_ERROR,
message=f'Refresh failed: {exc}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({"message": "刷新成功", "result": result}, status=status.HTTP_200_OK)
return success_response(data={'result': result})
# ==================== 自定义 Action: 模板只读浏览 ====================
@@ -142,18 +156,30 @@ class NucleiTemplateRepoViewSet(viewsets.ModelViewSet):
try:
repo_id = int(pk) if pk is not None else None
except (TypeError, ValueError):
return Response({"message": "无效的仓库 ID"}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Invalid repository ID',
status_code=status.HTTP_400_BAD_REQUEST
)
# 调用 Service 层,仅从当前本地目录读取目录树
try:
roots = self.service.get_template_tree(repo_id)
except ValidationError as exc:
return Response({"message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message=str(exc),
status_code=status.HTTP_400_BAD_REQUEST
)
except Exception as exc: # noqa: BLE001
logger.error("获取 Nuclei 模板目录树失败: %s", exc, exc_info=True)
return Response({"message": "获取模板目录树失败"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Failed to get template tree',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({"roots": roots})
return success_response(data={'roots': roots})
@action(detail=True, methods=["get"], url_path="templates/content")
def templates_content(self, request: Request, pk: str | None = None) -> Response:
@@ -174,23 +200,43 @@ class NucleiTemplateRepoViewSet(viewsets.ModelViewSet):
try:
repo_id = int(pk) if pk is not None else None
except (TypeError, ValueError):
return Response({"message": "无效的仓库 ID"}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Invalid repository ID',
status_code=status.HTTP_400_BAD_REQUEST
)
# 解析 path 参数
rel_path = (request.query_params.get("path", "") or "").strip()
if not rel_path:
return Response({"message": "缺少 path 参数"}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Missing path parameter',
status_code=status.HTTP_400_BAD_REQUEST
)
# 调用 Service 层
try:
result = self.service.get_template_content(repo_id, rel_path)
except ValidationError as exc:
return Response({"message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message=str(exc),
status_code=status.HTTP_400_BAD_REQUEST
)
except Exception as exc: # noqa: BLE001
logger.error("获取 Nuclei 模板内容失败: %s", exc, exc_info=True)
return Response({"message": "获取模板内容失败"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Failed to get template content',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
# 文件不存在
if result is None:
return Response({"message": "模板不存在或无法读取"}, status=status.HTTP_404_NOT_FOUND)
return Response(result)
return error_response(
code=ErrorCodes.NOT_FOUND,
message='Template not found or unreadable',
status_code=status.HTTP_404_NOT_FOUND
)
return success_response(data=result)

View File

@@ -9,6 +9,8 @@ from rest_framework.decorators import action
from rest_framework.response import Response
from apps.common.pagination import BasePagination
from apps.common.response_helpers import success_response, error_response
from apps.common.error_codes import ErrorCodes
from apps.engine.serializers.wordlist_serializer import WordlistSerializer
from apps.engine.services.wordlist_service import WordlistService
@@ -46,7 +48,11 @@ class WordlistViewSet(viewsets.ViewSet):
uploaded_file = request.FILES.get("file")
if not uploaded_file:
return Response({"error": "缺少字典文件"}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Missing wordlist file',
status_code=status.HTTP_400_BAD_REQUEST
)
try:
wordlist = self.service.create_wordlist(
@@ -55,21 +61,32 @@ class WordlistViewSet(viewsets.ViewSet):
uploaded_file=uploaded_file,
)
except ValidationError as exc:
return Response({"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message=str(exc),
status_code=status.HTTP_400_BAD_REQUEST
)
serializer = WordlistSerializer(wordlist)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return success_response(data=serializer.data, status_code=status.HTTP_201_CREATED)
def destroy(self, request, pk=None):
"""删除字典记录"""
try:
wordlist_id = int(pk)
except (TypeError, ValueError):
return Response({"error": "无效的 ID"}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Invalid ID',
status_code=status.HTTP_400_BAD_REQUEST
)
success = self.service.delete_wordlist(wordlist_id)
if not success:
return Response({"error": "字典不存在"}, status=status.HTTP_404_NOT_FOUND)
return error_response(
code=ErrorCodes.NOT_FOUND,
status_code=status.HTTP_404_NOT_FOUND
)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -82,15 +99,27 @@ class WordlistViewSet(viewsets.ViewSet):
"""
name = (request.query_params.get("wordlist", "") or "").strip()
if not name:
return Response({"error": "缺少参数 wordlist"}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Missing parameter: wordlist',
status_code=status.HTTP_400_BAD_REQUEST
)
wordlist = self.service.get_wordlist_by_name(name)
if not wordlist:
return Response({"error": "字典不存在"}, status=status.HTTP_404_NOT_FOUND)
return error_response(
code=ErrorCodes.NOT_FOUND,
message='Wordlist not found',
status_code=status.HTTP_404_NOT_FOUND
)
file_path = wordlist.file_path
if not file_path or not os.path.exists(file_path):
return Response({"error": "字典文件不存在"}, status=status.HTTP_404_NOT_FOUND)
return error_response(
code=ErrorCodes.NOT_FOUND,
message='Wordlist file not found',
status_code=status.HTTP_404_NOT_FOUND
)
filename = os.path.basename(file_path)
response = FileResponse(open(file_path, "rb"), as_attachment=True, filename=filename)
@@ -106,22 +135,38 @@ class WordlistViewSet(viewsets.ViewSet):
try:
wordlist_id = int(pk)
except (TypeError, ValueError):
return Response({"error": "无效的 ID"}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Invalid ID',
status_code=status.HTTP_400_BAD_REQUEST
)
if request.method == "GET":
content = self.service.get_wordlist_content(wordlist_id)
if content is None:
return Response({"error": "字典不存在或文件无法读取"}, status=status.HTTP_404_NOT_FOUND)
return Response({"content": content})
return error_response(
code=ErrorCodes.NOT_FOUND,
message='Wordlist not found or file unreadable',
status_code=status.HTTP_404_NOT_FOUND
)
return success_response(data={"content": content})
elif request.method == "PUT":
content = request.data.get("content")
if content is None:
return Response({"error": "缺少 content 参数"}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Missing content parameter',
status_code=status.HTTP_400_BAD_REQUEST
)
wordlist = self.service.update_wordlist_content(wordlist_id, content)
if not wordlist:
return Response({"error": "字典不存在或更新失败"}, status=status.HTTP_404_NOT_FOUND)
return error_response(
code=ErrorCodes.NOT_FOUND,
message='Wordlist not found or update failed',
status_code=status.HTTP_404_NOT_FOUND
)
serializer = WordlistSerializer(wordlist)
return Response(serializer.data)
return success_response(data=serializer.data)

View File

@@ -9,6 +9,8 @@ from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from apps.common.response_helpers import success_response, error_response
from apps.common.error_codes import ErrorCodes
from apps.engine.serializers import WorkerNodeSerializer
from apps.engine.services import WorkerService
from apps.common.signals import worker_delete_failed
@@ -111,9 +113,8 @@ class WorkerNodeViewSet(viewsets.ModelViewSet):
threading.Thread(target=_async_remote_uninstall, daemon=True).start()
# 3. 立即返回成功
return Response(
{"message": f"节点 {worker_name} 已删除"},
status=status.HTTP_200_OK
return success_response(
data={'name': worker_name}
)
@action(detail=True, methods=['post'])
@@ -190,11 +191,13 @@ class WorkerNodeViewSet(viewsets.ModelViewSet):
worker.status = 'online'
worker.save(update_fields=['status'])
return Response({
'status': 'ok',
'need_update': need_update,
'server_version': server_version
})
return success_response(
data={
'status': 'ok',
'needUpdate': need_update,
'serverVersion': server_version
}
)
def _trigger_remote_agent_update(self, worker, target_version: str):
"""
@@ -304,9 +307,10 @@ class WorkerNodeViewSet(viewsets.ModelViewSet):
is_local = request.data.get('is_local', True)
if not name:
return Response(
{'error': '缺少 name 参数'},
status=status.HTTP_400_BAD_REQUEST
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Missing name parameter',
status_code=status.HTTP_400_BAD_REQUEST
)
worker, created = self.worker_service.register_worker(
@@ -314,11 +318,13 @@ class WorkerNodeViewSet(viewsets.ModelViewSet):
is_local=is_local
)
return Response({
'worker_id': worker.id,
'name': worker.name,
'created': created
})
return success_response(
data={
'workerId': worker.id,
'name': worker.name,
'created': created
}
)
@action(detail=False, methods=['get'])
def config(self, request):
@@ -334,13 +340,12 @@ class WorkerNodeViewSet(viewsets.ModelViewSet):
返回:
{
"db": {"host": "...", "port": "...", ...},
"redisUrl": "...",
"paths": {"results": "...", "logs": "..."}
}
配置逻辑:
- 本地 Worker (is_local=true): db_host=postgres, redis=redis:6379
- 远程 Worker (is_local=false): db_host=PUBLIC_HOST, redis=PUBLIC_HOST:6379
- 本地 Worker (is_local=true): db_host=postgres
- 远程 Worker (is_local=false): db_host=PUBLIC_HOST
"""
from django.conf import settings
import logging
@@ -365,39 +370,35 @@ class WorkerNodeViewSet(viewsets.ModelViewSet):
if is_local_worker:
# 本地 Worker直接用 Docker 内部服务名
worker_db_host = 'postgres'
worker_redis_url = 'redis://redis:6379/0'
else:
# 远程 Worker通过公网 IP 访问
public_host = settings.PUBLIC_HOST
if public_host in ('server', 'localhost', '127.0.0.1'):
logger.warning("远程 Worker 请求配置,但 PUBLIC_HOST=%s 不是有效的公网地址", public_host)
worker_db_host = public_host
worker_redis_url = f'redis://{public_host}:6379/0'
else:
# 远程数据库场景:所有 Worker 都用 DB_HOST
worker_db_host = db_host
worker_redis_url = getattr(settings, 'WORKER_REDIS_URL', 'redis://redis:6379/0')
logger.info("返回 Worker 配置 - db_host: %s, redis_url: %s", worker_db_host, worker_redis_url)
logger.info("返回 Worker 配置 - db_host: %s", worker_db_host)
return Response({
'db': {
'host': worker_db_host,
'port': str(settings.DATABASES['default']['PORT']),
'name': settings.DATABASES['default']['NAME'],
'user': settings.DATABASES['default']['USER'],
'password': settings.DATABASES['default']['PASSWORD'],
},
'redisUrl': worker_redis_url,
'paths': {
'results': getattr(settings, 'CONTAINER_RESULTS_MOUNT', '/opt/xingrin/results'),
'logs': getattr(settings, 'CONTAINER_LOGS_MOUNT', '/opt/xingrin/logs'),
},
'logging': {
'level': os.getenv('LOG_LEVEL', 'INFO'),
'enableCommandLogging': os.getenv('ENABLE_COMMAND_LOGGING', 'true').lower() == 'true',
},
'debug': settings.DEBUG,
# Xget 加速配置(用于 Git clone 加速,如 Nuclei 模板仓库)
'xgetMirror': os.getenv('XGET_MIRROR', ''),
})
return success_response(
data={
'db': {
'host': worker_db_host,
'port': str(settings.DATABASES['default']['PORT']),
'name': settings.DATABASES['default']['NAME'],
'user': settings.DATABASES['default']['USER'],
'password': settings.DATABASES['default']['PASSWORD'],
},
'paths': {
'results': getattr(settings, 'CONTAINER_RESULTS_MOUNT', '/opt/xingrin/results'),
'logs': getattr(settings, 'CONTAINER_LOGS_MOUNT', '/opt/xingrin/logs'),
},
'logging': {
'level': os.getenv('LOG_LEVEL', 'INFO'),
'enableCommandLogging': os.getenv('ENABLE_COMMAND_LOGGING', 'true').lower() == 'true',
},
'debug': settings.DEBUG,
}
)

View File

@@ -7,7 +7,7 @@
from django.conf import settings
# ==================== 路径配置 ====================
SCAN_TOOLS_BASE_PATH = getattr(settings, 'SCAN_TOOLS_BASE_PATH', '/opt/xingrin/tools')
SCAN_TOOLS_BASE_PATH = getattr(settings, 'SCAN_TOOLS_BASE_PATH', '/usr/local/bin')
# ==================== 子域名发现 ====================
@@ -35,7 +35,7 @@ SUBDOMAIN_DISCOVERY_COMMANDS = {
},
'sublist3r': {
'base': "python3 '{scan_tools_base}/Sublist3r/sublist3r.py' -d {domain} -o '{output_file}'",
'base': "python3 '/usr/local/share/Sublist3r/sublist3r.py' -d {domain} -o '{output_file}'",
'optional': {
'threads': '-t {threads}'
}
@@ -115,7 +115,7 @@ SITE_SCAN_COMMANDS = {
DIRECTORY_SCAN_COMMANDS = {
'ffuf': {
'base': "ffuf -u '{url}FUZZ' -se -ac -sf -json -w '{wordlist}'",
'base': "'{scan_tools_base}/ffuf' -u '{url}FUZZ' -se -ac -sf -json -w '{wordlist}'",
'optional': {
'delay': '-p {delay}',
'threads': '-t {threads}',
@@ -239,6 +239,9 @@ FINGERPRINT_DETECT_COMMANDS = {
'ehole': '--ehole {ehole}',
'goby': '--goby {goby}',
'wappalyzer': '--wappalyzer {wappalyzer}',
'fingers': '--fingers {fingers}',
'fingerprinthub': '--fingerprint {fingerprinthub}',
'arl': '--arl {arl}',
}
},
}

View File

@@ -53,7 +53,7 @@ subdomain_discovery:
resolve:
enabled: true
subdomain_resolve:
# timeout: auto # 自动根据候选子域数量计算
timeout: auto # 自动根据候选子域数量计算
# ==================== 端口扫描 ====================
port_scan:
@@ -87,7 +87,7 @@ fingerprint_detect:
tools:
xingfinger:
enabled: true
fingerprint-libs: [ehole, goby, wappalyzer] # 启用的指纹库ehole, goby, wappalyzer, fingers, fingerprinthub
fingerprint-libs: [ehole, goby, wappalyzer, fingers, fingerprinthub, arl] # 全部指纹库
# ==================== 目录扫描 ====================
directory_scan:

View File

@@ -14,6 +14,8 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from apps.common.pagination import BasePagination
from apps.common.response_helpers import success_response, error_response
from apps.common.error_codes import ErrorCodes
from .models import Notification
from .serializers import NotificationSerializer
from .types import NotificationLevel
@@ -60,34 +62,7 @@ def notifications_test(request):
}, status=500)
def build_api_response(
data: Any = None,
*,
message: str = '操作成功',
code: str = '200',
state: str = 'success',
status_code: int = status.HTTP_200_OK
) -> Response:
"""构建统一的 API 响应格式
Args:
data: 响应数据体(可选)
message: 响应消息
code: 响应代码
state: 响应状态success/error
status_code: HTTP 状态码
Returns:
DRF Response 对象
"""
payload = {
'code': code,
'state': state,
'message': message,
}
if data is not None:
payload['data'] = data
return Response(payload, status=status_code)
# build_api_response 已废弃,请使用 success_response/error_response
def _parse_bool(value: str | None) -> bool | None:
@@ -172,7 +147,7 @@ class NotificationUnreadCountView(APIView):
"""获取未读通知数量"""
service = NotificationService()
count = service.get_unread_count()
return build_api_response({'count': count}, message='获取未读数量成功')
return success_response(data={'count': count})
class NotificationMarkAllAsReadView(APIView):
@@ -192,7 +167,7 @@ class NotificationMarkAllAsReadView(APIView):
"""标记全部通知为已读"""
service = NotificationService()
updated = service.mark_all_as_read()
return build_api_response({'updated': updated}, message='全部标记已读成功')
return success_response(data={'updated': updated})
class NotificationSettingsView(APIView):
@@ -209,13 +184,13 @@ class NotificationSettingsView(APIView):
"""获取通知设置"""
service = NotificationSettingsService()
settings = service.get_settings()
return Response(settings)
return success_response(data=settings)
def put(self, request: Request) -> Response:
"""更新通知设置"""
service = NotificationSettingsService()
settings = service.update_settings(request.data)
return Response({'message': '已保存通知设置', **settings})
return success_response(data=settings)
# ============================================
@@ -247,22 +222,24 @@ def notification_callback(request):
required_fields = ['id', 'category', 'title', 'message', 'level', 'created_at']
for field in required_fields:
if field not in data:
return Response(
{'error': f'缺少字段: {field}'},
status=status.HTTP_400_BAD_REQUEST
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message=f'Missing field: {field}',
status_code=status.HTTP_400_BAD_REQUEST
)
# 推送到 WebSocket
_push_notification_to_websocket(data)
logger.debug(f"回调通知推送成功 - ID: {data['id']}, Title: {data['title']}")
return Response({'status': 'ok'})
return success_response(data={'status': 'ok'})
except Exception as e:
logger.error(f"回调通知处理失败: {e}", exc_info=True)
return Response(
{'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
return error_response(
code=ErrorCodes.SERVER_ERROR,
message=str(e),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)

View File

@@ -27,9 +27,6 @@ class BlacklistService:
DEFAULT_PATTERNS = [
r'\.gov$', # .gov 结尾
r'\.gov\.[a-z]{2}$', # .gov.cn, .gov.uk 等
r'\.edu$', # .edu 结尾
r'\.edu\.[a-z]{2}$', # .edu.cn 等
r'\.mil$', # .mil 结尾
]
def __init__(self, patterns: Optional[List[str]] = None):

View File

@@ -113,9 +113,10 @@ def bulk_merge_tech_field(
host = parsed.hostname or ''
# 插入新记录(带冲突处理)
# 显式传入所有 NOT NULL 字段的默认值
insert_sql = f"""
INSERT INTO {table_name} (target_id, url, host, tech, created_at)
VALUES (%s, %s, %s, %s::varchar[], NOW())
INSERT INTO {table_name} (target_id, url, host, location, title, webserver, body_preview, content_type, tech, created_at)
VALUES (%s, %s, %s, '', '', '', '', '', %s::varchar[], NOW())
ON CONFLICT (target_id, url) DO UPDATE SET
tech = (
SELECT ARRAY(SELECT DISTINCT unnest(

View File

@@ -36,7 +36,14 @@ def _normalize_config_keys(config: Dict[str, Any]) -> Dict[str, Any]:
Returns:
key 已转换的新字典
Raises:
ValueError: 配置为 None 或非字典类型时抛出
"""
if config is None:
raise ValueError("配置不能为空None请检查 YAML 格式,确保冒号后有配置内容或使用 {} 表示空配置")
if not isinstance(config, dict):
raise ValueError(f"配置格式错误:期望 dict实际 {type(config).__name__}")
return {
k.replace('-', '_') if isinstance(k, str) else k: v
for k, v in config.items()

View File

@@ -18,6 +18,9 @@ FINGERPRINT_LIB_MAP = {
'ehole': 'ensure_ehole_fingerprint_local',
'goby': 'ensure_goby_fingerprint_local',
'wappalyzer': 'ensure_wappalyzer_fingerprint_local',
'fingers': 'ensure_fingers_fingerprint_local',
'fingerprinthub': 'ensure_fingerprinthub_fingerprint_local',
'arl': 'ensure_arl_fingerprint_local',
}
@@ -221,10 +224,170 @@ def get_fingerprint_paths(lib_names: list) -> dict:
return paths
def ensure_fingers_fingerprint_local() -> str:
"""
确保本地存在最新的 Fingers 指纹文件(带缓存)
Returns:
str: 本地指纹文件路径
"""
from apps.engine.services.fingerprints import FingersFingerprintService
service = FingersFingerprintService()
current_version = service.get_fingerprint_version()
# 缓存目录和文件
base_dir = getattr(settings, 'FINGERPRINTS_BASE_PATH', '/opt/xingrin/fingerprints')
os.makedirs(base_dir, exist_ok=True)
cache_file = os.path.join(base_dir, 'fingers.json')
version_file = os.path.join(base_dir, 'fingers.version')
# 检查缓存版本
cached_version = None
if os.path.exists(version_file):
try:
with open(version_file, 'r') as f:
cached_version = f.read().strip()
except OSError as e:
logger.warning("读取 Fingers 版本文件失败: %s", e)
# 版本匹配,直接返回缓存
if cached_version == current_version and os.path.exists(cache_file):
logger.info("Fingers 指纹文件缓存有效(版本匹配): %s", cache_file)
return cache_file
# 版本不匹配,重新导出
logger.info(
"Fingers 指纹文件需要更新: cached=%s, current=%s",
cached_version, current_version
)
data = service.get_export_data()
with open(cache_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False)
# 写入版本文件
try:
with open(version_file, 'w') as f:
f.write(current_version)
except OSError as e:
logger.warning("写入 Fingers 版本文件失败: %s", e)
logger.info("Fingers 指纹文件已更新: %s", cache_file)
return cache_file
def ensure_fingerprinthub_fingerprint_local() -> str:
"""
确保本地存在最新的 FingerPrintHub 指纹文件(带缓存)
Returns:
str: 本地指纹文件路径
"""
from apps.engine.services.fingerprints import FingerPrintHubFingerprintService
service = FingerPrintHubFingerprintService()
current_version = service.get_fingerprint_version()
# 缓存目录和文件
base_dir = getattr(settings, 'FINGERPRINTS_BASE_PATH', '/opt/xingrin/fingerprints')
os.makedirs(base_dir, exist_ok=True)
cache_file = os.path.join(base_dir, 'fingerprinthub.json')
version_file = os.path.join(base_dir, 'fingerprinthub.version')
# 检查缓存版本
cached_version = None
if os.path.exists(version_file):
try:
with open(version_file, 'r') as f:
cached_version = f.read().strip()
except OSError as e:
logger.warning("读取 FingerPrintHub 版本文件失败: %s", e)
# 版本匹配,直接返回缓存
if cached_version == current_version and os.path.exists(cache_file):
logger.info("FingerPrintHub 指纹文件缓存有效(版本匹配): %s", cache_file)
return cache_file
# 版本不匹配,重新导出
logger.info(
"FingerPrintHub 指纹文件需要更新: cached=%s, current=%s",
cached_version, current_version
)
data = service.get_export_data()
with open(cache_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False)
# 写入版本文件
try:
with open(version_file, 'w') as f:
f.write(current_version)
except OSError as e:
logger.warning("写入 FingerPrintHub 版本文件失败: %s", e)
logger.info("FingerPrintHub 指纹文件已更新: %s", cache_file)
return cache_file
def ensure_arl_fingerprint_local() -> str:
"""
确保本地存在最新的 ARL 指纹文件(带缓存)
Returns:
str: 本地指纹文件路径YAML 格式)
"""
import yaml
from apps.engine.services.fingerprints import ARLFingerprintService
service = ARLFingerprintService()
current_version = service.get_fingerprint_version()
# 缓存目录和文件
base_dir = getattr(settings, 'FINGERPRINTS_BASE_PATH', '/opt/xingrin/fingerprints')
os.makedirs(base_dir, exist_ok=True)
cache_file = os.path.join(base_dir, 'arl.yaml')
version_file = os.path.join(base_dir, 'arl.version')
# 检查缓存版本
cached_version = None
if os.path.exists(version_file):
try:
with open(version_file, 'r') as f:
cached_version = f.read().strip()
except OSError as e:
logger.warning("读取 ARL 版本文件失败: %s", e)
# 版本匹配,直接返回缓存
if cached_version == current_version and os.path.exists(cache_file):
logger.info("ARL 指纹文件缓存有效(版本匹配): %s", cache_file)
return cache_file
# 版本不匹配,重新导出
logger.info(
"ARL 指纹文件需要更新: cached=%s, current=%s",
cached_version, current_version
)
data = service.get_export_data()
with open(cache_file, 'w', encoding='utf-8') as f:
yaml.dump(data, f, allow_unicode=True, default_flow_style=False)
# 写入版本文件
try:
with open(version_file, 'w') as f:
f.write(current_version)
except OSError as e:
logger.warning("写入 ARL 版本文件失败: %s", e)
logger.info("ARL 指纹文件已更新: %s", cache_file)
return cache_file
__all__ = [
"ensure_ehole_fingerprint_local",
"ensure_goby_fingerprint_local",
"ensure_wappalyzer_fingerprint_local",
"ensure_fingers_fingerprint_local",
"ensure_fingerprinthub_fingerprint_local",
"ensure_arl_fingerprint_local",
"get_fingerprint_paths",
"FINGERPRINT_LIB_MAP",
]

View File

@@ -19,7 +19,6 @@ from typing import Optional
from django.conf import settings
from apps.common.utils.xget_proxy import get_xget_proxy_url
from apps.engine.models import NucleiTemplateRepo
logger = logging.getLogger(__name__)
@@ -50,7 +49,7 @@ def get_local_commit_hash(local_path: Path) -> Optional[str]:
def git_clone(repo_url: str, local_path: Path) -> bool:
"""Git clone 仓库(支持 Xget 加速)
"""Git clone 仓库
Args:
repo_url: 仓库 URL
@@ -59,15 +58,9 @@ def git_clone(repo_url: str, local_path: Path) -> bool:
Returns:
是否成功
"""
# Transform URL for Xget acceleration if enabled
proxied_url = get_xget_proxy_url(repo_url)
if proxied_url != repo_url:
logger.info("Using Xget acceleration: %s -> %s", repo_url, proxied_url)
logger.info("正在 clone 模板仓库: %s -> %s", repo_url, local_path)
result = subprocess.run(
["git", "clone", "--depth", "1", proxied_url, str(local_path)],
["git", "clone", "--depth", "1", repo_url, str(local_path)],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,

View File

@@ -7,6 +7,9 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db.utils import DatabaseError, IntegrityError, OperationalError
import logging
from apps.common.response_helpers import success_response, error_response
from apps.common.error_codes import ErrorCodes
logger = logging.getLogger(__name__)
from ..models import Scan, ScheduledScan
@@ -75,20 +78,31 @@ class ScanViewSet(viewsets.ModelViewSet):
scan_service = ScanService()
result = scan_service.delete_scans_two_phase([scan.id])
return Response({
'message': f'已删除扫描任务: Scan #{scan.id}',
'scanId': scan.id,
'deletedCount': result['soft_deleted_count'],
'deletedScans': result['scan_names']
}, status=status.HTTP_200_OK)
return success_response(
data={
'scanId': scan.id,
'deletedCount': result['soft_deleted_count'],
'deletedScans': result['scan_names']
}
)
except Scan.DoesNotExist:
raise NotFound('扫描任务不存在')
return error_response(
code=ErrorCodes.NOT_FOUND,
status_code=status.HTTP_404_NOT_FOUND
)
except ValueError as e:
raise NotFound(str(e))
return error_response(
code=ErrorCodes.NOT_FOUND,
message=str(e),
status_code=status.HTTP_404_NOT_FOUND
)
except Exception as e:
logger.exception("删除扫描任务时发生错误")
raise APIException('服务器错误,请稍后重试')
return error_response(
code=ErrorCodes.SERVER_ERROR,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(detail=False, methods=['post'])
def quick(self, request):
@@ -132,10 +146,12 @@ class ScanViewSet(viewsets.ModelViewSet):
targets = result['targets']
if not targets:
return Response({
'error': '没有有效的目标可供扫描',
'errors': result.get('errors', [])
}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='No valid targets for scanning',
details=result.get('errors', []),
status_code=status.HTTP_400_BAD_REQUEST
)
# 2. 获取扫描引擎
engine_service = EngineService()
@@ -150,24 +166,44 @@ class ScanViewSet(viewsets.ModelViewSet):
engine=engine
)
# 检查是否成功创建扫描任务
if not created_scans:
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='No scan tasks were created. All targets may already have active scans.',
details={
'targetStats': result['target_stats'],
'assetStats': result['asset_stats'],
'errors': result.get('errors', [])
},
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY
)
# 序列化返回结果
scan_serializer = ScanSerializer(created_scans, many=True)
return Response({
'message': f'快速扫描已启动:{len(created_scans)} 个任务',
'target_stats': result['target_stats'],
'asset_stats': result['asset_stats'],
'errors': result.get('errors', []),
'scans': scan_serializer.data
}, status=status.HTTP_201_CREATED)
return success_response(
data={
'count': len(created_scans),
'targetStats': result['target_stats'],
'assetStats': result['asset_stats'],
'errors': result.get('errors', []),
'scans': scan_serializer.data
},
status_code=status.HTTP_201_CREATED
)
except ValidationError as e:
return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message=str(e),
status_code=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
logger.exception("快速扫描启动失败")
return Response(
{'error': '服务器内部错误,请稍后重试'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
return error_response(
code=ErrorCodes.SERVER_ERROR,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(detail=False, methods=['post'])
@@ -205,38 +241,47 @@ class ScanViewSet(viewsets.ModelViewSet):
engine=engine
)
# 检查是否成功创建扫描任务
if not created_scans:
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='No scan tasks were created. All targets may already have active scans.',
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY
)
# 序列化返回结果
scan_serializer = ScanSerializer(created_scans, many=True)
return Response(
{
'message': f'已成功发起 {len(created_scans)} 个扫描任务',
return success_response(
data={
'count': len(created_scans),
'scans': scan_serializer.data
},
status=status.HTTP_201_CREATED
status_code=status.HTTP_201_CREATED
)
except ObjectDoesNotExist as e:
# 资源不存在错误(由 service 层抛出)
error_msg = str(e)
return Response(
{'error': error_msg},
status=status.HTTP_404_NOT_FOUND
return error_response(
code=ErrorCodes.NOT_FOUND,
message=str(e),
status_code=status.HTTP_404_NOT_FOUND
)
except ValidationError as e:
# 参数验证错误(由 service 层抛出)
return Response(
{'error': str(e)},
status=status.HTTP_400_BAD_REQUEST
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message=str(e),
status_code=status.HTTP_400_BAD_REQUEST
)
except (DatabaseError, IntegrityError, OperationalError):
# 数据库错误
return Response(
{'error': '数据库错误,请稍后重试'},
status=status.HTTP_503_SERVICE_UNAVAILABLE
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Database error',
status_code=status.HTTP_503_SERVICE_UNAVAILABLE
)
# 所有快照相关的 action 和 export 已迁移到 asset/views.py 中的快照 ViewSet
@@ -278,21 +323,24 @@ class ScanViewSet(viewsets.ModelViewSet):
# 参数验证
if not ids:
return Response(
{'error': '缺少必填参数: ids'},
status=status.HTTP_400_BAD_REQUEST
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Missing required parameter: ids',
status_code=status.HTTP_400_BAD_REQUEST
)
if not isinstance(ids, list):
return Response(
{'error': 'ids 必须是数组'},
status=status.HTTP_400_BAD_REQUEST
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='ids must be an array',
status_code=status.HTTP_400_BAD_REQUEST
)
if not all(isinstance(i, int) for i in ids):
return Response(
{'error': 'ids 数组中的所有元素必须是整数'},
status=status.HTTP_400_BAD_REQUEST
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='All elements in ids array must be integers',
status_code=status.HTTP_400_BAD_REQUEST
)
try:
@@ -300,19 +348,27 @@ class ScanViewSet(viewsets.ModelViewSet):
scan_service = ScanService()
result = scan_service.delete_scans_two_phase(ids)
return Response({
'message': f"已删除 {result['soft_deleted_count']} 个扫描任务",
'deletedCount': result['soft_deleted_count'],
'deletedScans': result['scan_names']
}, status=status.HTTP_200_OK)
return success_response(
data={
'deletedCount': result['soft_deleted_count'],
'deletedScans': result['scan_names']
}
)
except ValueError as e:
# 未找到记录
raise NotFound(str(e))
return error_response(
code=ErrorCodes.NOT_FOUND,
message=str(e),
status_code=status.HTTP_404_NOT_FOUND
)
except Exception as e:
logger.exception("批量删除扫描任务时发生错误")
raise APIException('服务器错误,请稍后重试')
return error_response(
code=ErrorCodes.SERVER_ERROR,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(detail=False, methods=['get'])
def statistics(self, request):
@@ -337,22 +393,25 @@ class ScanViewSet(viewsets.ModelViewSet):
scan_service = ScanService()
stats = scan_service.get_statistics()
return Response({
'total': stats['total'],
'running': stats['running'],
'completed': stats['completed'],
'failed': stats['failed'],
'totalVulns': stats['total_vulns'],
'totalSubdomains': stats['total_subdomains'],
'totalEndpoints': stats['total_endpoints'],
'totalWebsites': stats['total_websites'],
'totalAssets': stats['total_assets'],
})
return success_response(
data={
'total': stats['total'],
'running': stats['running'],
'completed': stats['completed'],
'failed': stats['failed'],
'totalVulns': stats['total_vulns'],
'totalSubdomains': stats['total_subdomains'],
'totalEndpoints': stats['total_endpoints'],
'totalWebsites': stats['total_websites'],
'totalAssets': stats['total_assets'],
}
)
except (DatabaseError, OperationalError):
return Response(
{'error': '数据库错误,请稍后重试'},
status=status.HTTP_503_SERVICE_UNAVAILABLE
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Database error',
status_code=status.HTTP_503_SERVICE_UNAVAILABLE
)
@action(detail=True, methods=['post'])
@@ -383,35 +442,31 @@ class ScanViewSet(viewsets.ModelViewSet):
# 检查是否是状态不允许的问题
scan = scan_service.get_scan(scan_id=pk, prefetch_relations=False)
if scan and scan.status not in [ScanStatus.RUNNING, ScanStatus.INITIATED]:
return Response(
{
'error': f'无法停止扫描:当前状态为 {ScanStatus(scan.status).label}',
'detail': '只能停止运行中或初始化状态的扫描'
},
status=status.HTTP_400_BAD_REQUEST
return error_response(
code=ErrorCodes.BAD_REQUEST,
message=f'Cannot stop scan: current status is {ScanStatus(scan.status).label}',
status_code=status.HTTP_400_BAD_REQUEST
)
# 其他失败原因
return Response(
{'error': '停止扫描失败'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
return error_response(
code=ErrorCodes.SERVER_ERROR,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response(
{
'message': f'扫描已停止,已撤销 {revoked_count} 个任务',
'revokedTaskCount': revoked_count
},
status=status.HTTP_200_OK
return success_response(
data={'revokedTaskCount': revoked_count}
)
except ObjectDoesNotExist:
return Response(
{'error': f'扫描 ID {pk} 不存在'},
status=status.HTTP_404_NOT_FOUND
return error_response(
code=ErrorCodes.NOT_FOUND,
message=f'Scan ID {pk} not found',
status_code=status.HTTP_404_NOT_FOUND
)
except (DatabaseError, IntegrityError, OperationalError):
return Response(
{'error': '数据库错误,请稍后重试'},
status=status.HTTP_503_SERVICE_UNAVAILABLE
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Database error',
status_code=status.HTTP_503_SERVICE_UNAVAILABLE
)

View File

@@ -18,6 +18,8 @@ from ..serializers import (
from ..services.scheduled_scan_service import ScheduledScanService
from ..repositories import ScheduledScanDTO
from apps.common.pagination import BasePagination
from apps.common.response_helpers import success_response, error_response
from apps.common.error_codes import ErrorCodes
logger = logging.getLogger(__name__)
@@ -75,15 +77,16 @@ class ScheduledScanViewSet(viewsets.ModelViewSet):
scheduled_scan = self.service.create(dto)
response_serializer = ScheduledScanSerializer(scheduled_scan)
return Response(
{
'message': f'创建定时扫描任务成功: {scheduled_scan.name}',
'scheduled_scan': response_serializer.data
},
status=status.HTTP_201_CREATED
return success_response(
data=response_serializer.data,
status_code=status.HTTP_201_CREATED
)
except ValidationError as e:
return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message=str(e),
status_code=status.HTTP_400_BAD_REQUEST
)
def update(self, request, *args, **kwargs):
"""更新定时扫描任务"""
@@ -105,24 +108,27 @@ class ScheduledScanViewSet(viewsets.ModelViewSet):
scheduled_scan = self.service.update(instance.id, dto)
response_serializer = ScheduledScanSerializer(scheduled_scan)
return Response({
'message': f'更新定时扫描任务成功: {scheduled_scan.name}',
'scheduled_scan': response_serializer.data
})
return success_response(data=response_serializer.data)
except ValidationError as e:
return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message=str(e),
status_code=status.HTTP_400_BAD_REQUEST
)
def destroy(self, request, *args, **kwargs):
"""删除定时扫描任务"""
instance = self.get_object()
scan_id = instance.id
name = instance.name
if self.service.delete(instance.id):
return Response({
'message': f'删除定时扫描任务成功: {name}',
'id': instance.id
})
return Response({'error': '删除失败'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
if self.service.delete(scan_id):
return success_response(data={'id': scan_id, 'name': name})
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Failed to delete scheduled scan',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(detail=True, methods=['post'])
def toggle(self, request, pk=None):
@@ -136,14 +142,11 @@ class ScheduledScanViewSet(viewsets.ModelViewSet):
scheduled_scan = self.get_object()
response_serializer = ScheduledScanSerializer(scheduled_scan)
status_text = '启用' if is_enabled else '禁用'
return Response({
'message': f'{status_text}定时扫描任务',
'scheduled_scan': response_serializer.data
})
return success_response(data=response_serializer.data)
return Response(
{'error': f'定时扫描任务 ID {pk} 不存在或操作失败'},
status=status.HTTP_404_NOT_FOUND
return error_response(
code=ErrorCodes.NOT_FOUND,
message=f'Scheduled scan with ID {pk} not found or operation failed',
status_code=status.HTTP_404_NOT_FOUND
)

View File

@@ -10,6 +10,7 @@ from .serializers import OrganizationSerializer, TargetSerializer, TargetDetailS
from .services.target_service import TargetService
from .services.organization_service import OrganizationService
from apps.common.pagination import BasePagination
from apps.common.response_helpers import success_response
logger = logging.getLogger(__name__)
@@ -94,9 +95,8 @@ class OrganizationViewSet(viewsets.ModelViewSet):
# 批量解除关联(直接使用 ID避免查询对象
organization.targets.remove(*existing_target_ids)
return Response({
'unlinked_count': existing_count,
'message': f'成功解除 {existing_count} 个目标的关联'
return success_response(data={
'unlinkedCount': existing_count
})
def destroy(self, request, *args, **kwargs):
@@ -124,13 +124,12 @@ class OrganizationViewSet(viewsets.ModelViewSet):
# 直接调用 Service 层的业务方法(软删除 + 分发硬删除任务)
result = self.org_service.delete_organizations_two_phase([organization.id])
return Response({
'message': f'已删除组织: {organization.name}',
return success_response(data={
'organizationId': organization.id,
'organizationName': organization.name,
'deletedCount': result['soft_deleted_count'],
'deletedOrganizations': result['organization_names']
}, status=200)
})
except Organization.DoesNotExist:
raise NotFound('组织不存在')
@@ -181,11 +180,10 @@ class OrganizationViewSet(viewsets.ModelViewSet):
# 调用 Service 层的业务方法(软删除 + 分发硬删除任务)
result = self.org_service.delete_organizations_two_phase(ids)
return Response({
'message': f"已删除 {result['soft_deleted_count']} 个组织",
return success_response(data={
'deletedCount': result['soft_deleted_count'],
'deletedOrganizations': result['organization_names']
}, status=200)
})
except ValueError as e:
raise NotFound(str(e))
@@ -271,12 +269,11 @@ class TargetViewSet(viewsets.ModelViewSet):
# 直接调用 Service 层的业务方法(软删除 + 分发硬删除任务)
result = self.target_service.delete_targets_two_phase([target.id])
return Response({
'message': f'已删除目标: {target.name}',
return success_response(data={
'targetId': target.id,
'targetName': target.name,
'deletedCount': result['soft_deleted_count']
}, status=200)
})
except Target.DoesNotExist:
raise NotFound('目标不存在')
@@ -330,11 +327,10 @@ class TargetViewSet(viewsets.ModelViewSet):
# 调用 Service 层的业务方法(软删除 + 分发硬删除任务)
result = self.target_service.delete_targets_two_phase(ids)
return Response({
'message': f"已删除 {result['soft_deleted_count']} 个目标",
return success_response(data={
'deletedCount': result['soft_deleted_count'],
'deletedTargets': result['target_names']
}, status=200)
})
except ValueError as e:
raise NotFound(str(e))
@@ -389,7 +385,7 @@ class TargetViewSet(viewsets.ModelViewSet):
raise ValidationError(str(e))
# 3. 返回响应
return Response(result, status=status.HTTP_201_CREATED)
return success_response(data=result, status_code=status.HTTP_201_CREATED)
# subdomains action 已迁移到 SubdomainViewSet 嵌套路由
# GET /api/targets/{id}/subdomains/ -> SubdomainViewSet

View File

@@ -278,8 +278,9 @@ ENABLE_COMMAND_LOGGING = get_bool_env('ENABLE_COMMAND_LOGGING', True)
# ==================== 数据目录配置(统一使用 /opt/xingrin ====================
# 所有数据目录统一挂载到 /opt/xingrin便于管理和备份
# 扫描工具基础路径
SCAN_TOOLS_BASE_PATH = os.getenv('SCAN_TOOLS_PATH', '/opt/xingrin/tools')
# 扫描工具基础路径worker 容器内,符合 FHS 标准)
# 使用 /opt/xingrin-tools/bin 隔离项目专用扫描工具,避免与系统工具或 Python 包冲突
SCAN_TOOLS_BASE_PATH = os.getenv('SCAN_TOOLS_PATH', '/opt/xingrin-tools/bin')
# 字典文件基础路径
WORDLISTS_BASE_PATH = os.getenv('WORDLISTS_PATH', '/opt/xingrin/wordlists')
@@ -354,25 +355,16 @@ HOST_WORDLISTS_DIR = '/opt/xingrin/wordlists'
# ============================================
# Worker 配置中心(任务容器从 /api/workers/config/ 获取)
# ============================================
# Worker 数据库/Redis 地址由 worker_views.py 的 config API 动态返回
# Worker 数据库地址由 worker_views.py 的 config API 动态返回
# 根据请求来源(本地/远程)返回不同的配置:
# - 本地 WorkerDocker 网络内):使用内部服务名postgres, redis
# - 本地 WorkerDocker 网络内):使用内部服务名 postgres
# - 远程 Worker公网访问使用 PUBLIC_HOST
#
# 以下变量仅作为备用/兼容配置,实际配置由 API 动态生成
# 注意Redis 仅在 Server 容器内使用Worker 不需要直接连接 Redis
_db_host = DATABASES['default']['HOST']
_is_internal_db = _db_host in ('postgres', 'localhost', '127.0.0.1')
WORKER_DB_HOST = os.getenv('WORKER_DB_HOST', _db_host)
# 远程 Worker 访问 Redis 的地址(自动推导)
# - 如果 PUBLIC_HOST 是外部 IP → 使用 PUBLIC_HOST
# - 如果 PUBLIC_HOST 是 Docker 内部名 → 使用 redis本地部署
_is_internal_public = PUBLIC_HOST in ('server', 'localhost', '127.0.0.1')
WORKER_REDIS_URL = os.getenv(
'WORKER_REDIS_URL',
'redis://redis:6379/0' if _is_internal_public else f'redis://{PUBLIC_HOST}:6379/0'
)
# 容器内挂载目标路径(统一使用 /opt/xingrin
CONTAINER_RESULTS_MOUNT = '/opt/xingrin/results'
CONTAINER_LOGS_MOUNT = '/opt/xingrin/logs'

19519
backend/fingerprints/ARL.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,10 @@ uvicorn[standard]==0.30.1
# SSH & 远程部署
paramiko>=3.0.0
# Docker 管理
docker>=6.0.0 # Python Docker SDK
packaging>=21.0 # 版本比较
# 测试框架
pytest==8.0.0
pytest-django==4.7.0

View File

@@ -222,6 +222,9 @@ class TestDataGenerator:
self.create_ehole_fingerprints()
self.create_goby_fingerprints()
self.create_wappalyzer_fingerprints()
self.create_fingers_fingerprints()
self.create_fingerprinthub_fingerprints()
self.create_arl_fingerprints()
self.conn.commit()
print("\n✅ 测试数据生成完成!")
@@ -238,6 +241,7 @@ class TestDataGenerator:
tables = [
# 指纹表
'ehole_fingerprint', 'goby_fingerprint', 'wappalyzer_fingerprint',
'fingers_fingerprint', 'fingerprinthub_fingerprint', 'arl_fingerprint',
# 快照表(先删除,因为有外键依赖 scan)
'vulnerability_snapshot', 'host_port_mapping_snapshot', 'directory_snapshot',
'endpoint_snapshot', 'website_snapshot', 'subdomain_snapshot',
@@ -1990,6 +1994,371 @@ class TestDataGenerator:
print(f" ✓ 创建了 {count} 个 Wappalyzer 指纹\n")
def create_fingers_fingerprints(self):
"""创建 Fingers 指纹数据"""
print("🔍 创建 Fingers 指纹...")
cur = self.conn.cursor()
# 应用名称模板(长名称)
name_templates = [
'Apache-HTTP-Server-Web-Application-Platform-Open-Source-Software',
'Nginx-High-Performance-Web-Server-Reverse-Proxy-Load-Balancer',
'Microsoft-IIS-Internet-Information-Services-Windows-Web-Server',
'Tomcat-Java-Servlet-Container-Apache-Application-Server-Platform',
'WordPress-Content-Management-System-Blogging-Platform-PHP-MySQL',
'Drupal-CMS-Content-Management-Framework-PHP-Community-Platform',
'Joomla-Open-Source-CMS-Web-Content-Management-System-Framework',
'Laravel-PHP-Framework-Web-Application-Development-MVC-Pattern',
'Django-Python-Web-Framework-High-Level-MTV-Architecture-Pattern',
'Ruby-on-Rails-Web-Application-Framework-MVC-Convention-Configuration',
'Express-JS-Node-JS-Web-Application-Framework-Minimal-Flexible',
'Spring-Boot-Java-Framework-Microservices-Enterprise-Application',
'ASP-NET-Core-Cross-Platform-Web-Framework-Microsoft-Open-Source',
'React-JavaScript-Library-Building-User-Interfaces-Facebook-Meta',
'Vue-JS-Progressive-JavaScript-Framework-Web-Application-Development',
'Angular-TypeScript-Platform-Framework-Web-Applications-Google',
'jQuery-JavaScript-Library-DOM-Manipulation-Ajax-Event-Handling',
'Bootstrap-CSS-Framework-Responsive-Mobile-First-Web-Development',
'Tailwind-CSS-Utility-First-Framework-Rapid-UI-Development-Tool',
'Docker-Container-Platform-Application-Deployment-Virtualization',
'Kubernetes-Container-Orchestration-Platform-Cloud-Native-Apps',
'Redis-In-Memory-Data-Structure-Store-Database-Cache-Broker',
'MongoDB-Document-NoSQL-Database-Scalable-High-Performance',
'PostgreSQL-Relational-Database-Management-System-Open-Source',
'MySQL-Database-Management-System-Relational-Database-Oracle',
'Elasticsearch-Search-Analytics-Engine-Distributed-RESTful-API',
'RabbitMQ-Message-Broker-Advanced-Message-Queuing-Protocol',
'Jenkins-Automation-Server-Continuous-Integration-Deployment',
'GitLab-DevOps-Platform-Git-Repository-CI-CD-Pipeline-Management',
'Grafana-Observability-Platform-Metrics-Visualization-Dashboard',
]
# 标签模板
tag_options = [
['web-server', 'http', 'apache', 'linux'],
['web-server', 'reverse-proxy', 'nginx', 'high-performance'],
['web-server', 'windows', 'microsoft', 'iis'],
['cms', 'php', 'wordpress', 'blog', 'mysql'],
['cms', 'php', 'drupal', 'content-management'],
['framework', 'php', 'laravel', 'mvc', 'modern'],
['framework', 'python', 'django', 'full-stack'],
['framework', 'ruby', 'rails', 'mvc', 'convention'],
['framework', 'javascript', 'nodejs', 'express', 'backend'],
['framework', 'java', 'spring', 'enterprise', 'microservices'],
['framework', 'dotnet', 'aspnet', 'microsoft', 'cross-platform'],
['library', 'javascript', 'react', 'frontend', 'ui'],
['framework', 'javascript', 'vue', 'progressive', 'reactive'],
['framework', 'typescript', 'angular', 'google', 'spa'],
['database', 'nosql', 'mongodb', 'document', 'json'],
['database', 'relational', 'postgresql', 'sql', 'open-source'],
['database', 'relational', 'mysql', 'sql', 'oracle'],
['cache', 'database', 'redis', 'in-memory', 'key-value'],
['search', 'analytics', 'elasticsearch', 'distributed', 'restful'],
['container', 'docker', 'virtualization', 'deployment'],
]
# 规则模板
rule_templates = [
# favicon hash 规则
[{'method': 'faviconhash', 'favicon': f'-{random.randint(1000000000, 9999999999)}'}],
# keyword 规则
[{'method': 'keyword', 'keyword': ['X-Powered-By', 'Server', 'X-Generator']}],
# 混合规则
[
{'method': 'keyword', 'keyword': ['content="WordPress', 'wp-content/', 'wp-includes/']},
{'method': 'faviconhash', 'favicon': f'-{random.randint(1000000000, 9999999999)}'}
],
# header 规则
[{'method': 'keyword', 'keyword': ['Server: nginx', 'X-Powered-By: PHP']}],
# body 规则
[{'method': 'keyword', 'keyword': ['<meta name="generator"', 'Powered by', 'Built with']}],
]
# 端口模板
port_options = [
[80, 443],
[80, 443, 8080, 8443],
[80, 443, 8000, 8080, 8443],
[3000, 3001, 5000],
[8080, 8081, 8888, 9000],
[443, 8443, 9443],
[], # 空数组
]
count = 0
batch_data = []
for i in range(200): # 生成 200 条 Fingers 指纹
name = f'{random.choice(name_templates)}-{random.randint(1000, 9999)}'
link = f'https://www.example-{random.randint(1000, 9999)}.com'
rule = random.choice(rule_templates)
tag = random.choice(tag_options)
focus = random.choice([True, False])
default_port = random.choice(port_options)
batch_data.append((
name, link, json.dumps(rule), json.dumps(tag), focus, json.dumps(default_port)
))
count += 1
if batch_data:
execute_values(cur, """
INSERT INTO fingers_fingerprint (name, link, rule, tag, focus, default_port, created_at)
VALUES %s
ON CONFLICT (name) DO NOTHING
""", batch_data, template="(%s, %s, %s, %s, %s, %s, NOW())")
print(f" ✓ 创建了 {count} 个 Fingers 指纹\n")
def create_fingerprinthub_fingerprints(self):
"""创建 FingerPrintHub 指纹数据"""
print("🔍 创建 FingerPrintHub 指纹...")
cur = self.conn.cursor()
# FP ID 前缀
fp_id_prefixes = [
'web', 'cms', 'framework', 'server', 'database', 'cache', 'cdn',
'waf', 'load-balancer', 'proxy', 'api', 'admin', 'monitoring'
]
# 应用名称模板
name_templates = [
'Apache-HTTP-Server-Detection-Web-Platform-Fingerprint',
'Nginx-Web-Server-Identification-Reverse-Proxy-Detection',
'WordPress-CMS-Detection-Content-Management-System-Fingerprint',
'Drupal-CMS-Identification-Web-Content-Platform-Detection',
'Joomla-CMS-Detection-Web-Content-Management-Framework',
'Laravel-Framework-Detection-PHP-Web-Application-Platform',
'Django-Framework-Identification-Python-Web-Framework-Detection',
'Spring-Boot-Framework-Detection-Java-Enterprise-Application',
'React-Library-Detection-JavaScript-UI-Framework-Fingerprint',
'Vue-JS-Framework-Detection-Progressive-JavaScript-Platform',
'Angular-Framework-Identification-TypeScript-Web-Platform',
'Docker-Container-Detection-Virtualization-Platform-Fingerprint',
'Kubernetes-Orchestration-Detection-Container-Management-Platform',
'Redis-Cache-Detection-In-Memory-Database-Fingerprint',
'MongoDB-Database-Detection-NoSQL-Document-Store-Platform',
'PostgreSQL-Database-Detection-Relational-Database-System',
'MySQL-Database-Detection-Relational-Database-Management',
'Elasticsearch-Search-Detection-Analytics-Engine-Platform',
'Jenkins-CI-CD-Detection-Automation-Server-Platform',
'GitLab-DevOps-Detection-Version-Control-Platform-System',
'Grafana-Monitoring-Detection-Observability-Platform-Dashboard',
'Prometheus-Monitoring-Detection-Time-Series-Database-System',
'Kibana-Visualization-Detection-Data-Dashboard-Platform',
'Cloudflare-CDN-Detection-Web-Application-Firewall-Platform',
'Akamai-CDN-Detection-Content-Delivery-Network-Platform',
'AWS-CloudFront-CDN-Detection-Amazon-Web-Services-Platform',
'Microsoft-IIS-Detection-Internet-Information-Services-Server',
'Tomcat-Server-Detection-Java-Servlet-Container-Platform',
'JBoss-Server-Detection-Enterprise-Application-Platform',
'WebLogic-Server-Detection-Oracle-Application-Server-Platform',
]
# 作者模板
authors = [
'security-research-team', 'fingerprint-detection-group', 'web-security-lab',
'cyber-threat-intelligence', 'vulnerability-research-team', 'security-automation-team',
'open-source-security', 'community-contributors', 'detection-engineering-team'
]
# 严重程度
severities = ['info', 'low', 'medium', 'high', 'critical']
# metadata 模板
metadata_templates = [
{
'vendor': 'Apache Software Foundation',
'product': 'Apache HTTP Server',
'verified': True,
'max-request': 1,
'shodan-query': 'http.server:"Apache"'
},
{
'vendor': 'Nginx Inc',
'product': 'Nginx Web Server',
'verified': True,
'max-request': 1,
'shodan-query': 'http.server:"nginx"'
},
{
'vendor': 'WordPress',
'product': 'WordPress CMS',
'verified': True,
'max-request': 2,
'fofa-query': 'body="wp-content"'
},
{
'vendor': 'Various',
'product': 'Web Framework',
'verified': False,
'max-request': 1
},
]
# HTTP 规则模板
http_templates = [
[{
'method': 'GET',
'path': ['{{BaseURL}}'],
'matchers': [{
'type': 'word',
'words': ['Server: nginx', 'X-Powered-By'],
'condition': 'or'
}]
}],
[{
'method': 'GET',
'path': ['{{BaseURL}}/admin'],
'matchers': [{
'type': 'status',
'status': [200, 401, 403]
}]
}],
[{
'method': 'GET',
'path': ['{{BaseURL}}'],
'matchers': [{
'type': 'word',
'words': ['wp-content', 'wordpress'],
'part': 'body',
'condition': 'and'
}]
}],
]
# source_file 模板
source_files = [
'fingerprints/web-servers/apache.yaml',
'fingerprints/web-servers/nginx.yaml',
'fingerprints/cms/wordpress.yaml',
'fingerprints/cms/drupal.yaml',
'fingerprints/frameworks/laravel.yaml',
'fingerprints/frameworks/django.yaml',
'fingerprints/frameworks/spring.yaml',
'fingerprints/databases/mongodb.yaml',
'fingerprints/databases/postgresql.yaml',
'fingerprints/cache/redis.yaml',
]
count = 0
batch_data = []
for i in range(200): # 生成 200 条 FingerPrintHub 指纹
fp_id = f'{random.choice(fp_id_prefixes)}-detection-{random.randint(10000, 99999)}'
name = f'{random.choice(name_templates)}-{random.randint(1000, 9999)}'
author = random.choice(authors)
tags = ','.join(random.sample(['web', 'cms', 'framework', 'server', 'detection', 'fingerprint'], random.randint(2, 4)))
severity = random.choice(severities)
metadata = random.choice(metadata_templates).copy()
http = random.choice(http_templates)
source_file = random.choice(source_files)
batch_data.append((
fp_id, name, author, tags, severity,
json.dumps(metadata), json.dumps(http), source_file
))
count += 1
if batch_data:
execute_values(cur, """
INSERT INTO fingerprinthub_fingerprint (
fp_id, name, author, tags, severity, metadata, http, source_file, created_at
)
VALUES %s
ON CONFLICT (fp_id) DO NOTHING
""", batch_data, template="(%s, %s, %s, %s, %s, %s, %s, %s, NOW())")
print(f" ✓ 创建了 {count} 个 FingerPrintHub 指纹\n")
def create_arl_fingerprints(self):
"""创建 ARL 指纹数据"""
print("🔍 创建 ARL 指纹...")
cur = self.conn.cursor()
# 应用名称模板
name_templates = [
'Apache-HTTP-Server-Web-Platform-Application-Server',
'Nginx-High-Performance-Web-Server-Reverse-Proxy',
'Microsoft-IIS-Internet-Information-Services-Server',
'WordPress-Content-Management-System-Blogging-Platform',
'Drupal-Open-Source-CMS-Content-Management-Framework',
'Joomla-Web-Content-Management-System-Framework',
'Laravel-PHP-Web-Application-Framework-MVC-Pattern',
'Django-Python-Web-Framework-MTV-Architecture',
'Spring-Boot-Java-Enterprise-Application-Framework',
'Express-Node-JS-Web-Application-Framework-Minimal',
'React-JavaScript-Library-User-Interface-Components',
'Vue-JS-Progressive-JavaScript-Framework-Reactive',
'Angular-TypeScript-Web-Application-Framework-Google',
'Docker-Container-Platform-Application-Deployment',
'Kubernetes-Container-Orchestration-Cloud-Native',
'Redis-In-Memory-Database-Cache-Message-Broker',
'MongoDB-Document-NoSQL-Database-Scalable-Platform',
'PostgreSQL-Relational-Database-Management-System',
'MySQL-Database-Management-Relational-Database-Oracle',
'Elasticsearch-Search-Analytics-Engine-Distributed',
'Jenkins-Automation-Server-Continuous-Integration',
'GitLab-DevOps-Platform-Git-Repository-CI-CD-Pipeline',
'Grafana-Observability-Metrics-Visualization-Dashboard',
'Prometheus-Monitoring-Time-Series-Database-Alerting',
'RabbitMQ-Message-Broker-AMQP-Protocol-Queue-System',
'Tomcat-Java-Servlet-Container-Application-Server',
'JBoss-Enterprise-Application-Platform-Java-EE-Server',
'WebLogic-Oracle-Application-Server-Java-Enterprise',
'Cloudflare-CDN-DDoS-Protection-Web-Firewall-Platform',
'Amazon-CloudFront-CDN-Content-Delivery-Network-AWS',
]
# 规则表达式模板
rule_templates = [
# 简单规则
'header="Server" && header="nginx"',
'body="WordPress" && body="wp-content"',
'title="Admin Panel" || title="Dashboard"',
'header="X-Powered-By" && header="PHP"',
'body="Powered by" && body="Laravel"',
# 复杂规则
'(header="Server" && header="Apache") || (body="Apache" && title="Apache")',
'(body="wp-content" && body="wp-includes") || (header="X-Powered-By" && header="WordPress")',
'(title="Jenkins" && body="Jenkins") || (header="X-Jenkins" && status="200")',
'(body="Spring" && body="Whitelabel Error Page") || header="X-Application-Context"',
'(body="React" && body="react-dom") || (body="__REACT" && body="reactRoot")',
# 带状态码规则
'status="200" && body="nginx" && title="Welcome to nginx"',
'status="403" && body="Apache" && header="Server"',
'status="401" && header="WWW-Authenticate" && body="Unauthorized"',
# 多条件规则
'header="Server" && (body="PHP" || body="Laravel" || body="Symfony")',
'body="Django" && (header="X-Frame-Options" || body="csrfmiddlewaretoken")',
'(title="GitLab" && body="gitlab") || (header="X-GitLab-Feature-Category")',
# JSON API 规则
'body="{\\"version\\"" && body="api" && header="Content-Type"',
'status="200" && body="swagger" && body="openapi"',
# 错误页面规则
'status="404" && body="Not Found" && body="nginx"',
'status="500" && body="Internal Server Error" && body="Apache"',
]
count = 0
batch_data = []
for i in range(200): # 生成 200 条 ARL 指纹
name = f'{random.choice(name_templates)}-{random.randint(1000, 9999)}'
rule = random.choice(rule_templates)
batch_data.append((name, rule))
count += 1
if batch_data:
execute_values(cur, """
INSERT INTO arl_fingerprint (name, rule, created_at)
VALUES %s
ON CONFLICT (name) DO NOTHING
""", batch_data, template="(%s, %s, NOW())")
print(f" ✓ 创建了 {count} 个 ARL 指纹\n")
class MillionDataGenerator:
"""

View File

@@ -9,9 +9,8 @@ DB_USER=postgres
DB_PASSWORD=123.com
# ==================== Redis 配置 ====================
# 在 Docker 网络中Redis 服务名称为 redis
# Redis 仅在 Docker 内部网络使用,不暴露公网端口
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_DB=0
# ==================== 服务端口配置 ====================
@@ -41,6 +40,9 @@ SCAN_RESULTS_DIR=/opt/xingrin/results
# Django 日志目录
# 注意:如果留空或删除此变量,日志将只输出到 Docker 控制台(标准输出),不写入文件
LOG_DIR=/opt/xingrin/logs
# 扫描工具路径(容器内路径,符合 FHS 标准,已隔离避免命名冲突)
# 默认值已在 settings.py 中设置,无需修改,除非需要回退到旧路径
SCAN_TOOLS_PATH=/opt/xingrin-tools/bin
# ==================== 日志级别配置 ====================
# 应用日志级别DEBUG / INFO / WARNING / ERROR

View File

@@ -24,8 +24,6 @@ services:
redis:
image: redis:7-alpine
restart: always
ports:
- "${REDIS_PORT}:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s

View File

@@ -30,8 +30,6 @@ services:
redis:
image: redis:7-alpine
restart: always
ports:
- "${REDIS_PORT}:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s

View File

@@ -121,7 +121,7 @@ init_fingerprints() {
# 初始化 Nuclei 模板仓库
init_nuclei_templates() {
log_step "初始化 Nuclei 模板仓库..."
# 只创建数据库记录git clone 由 install.sh 在容器外完成(支持 xget 加速)
# 只创建数据库记录git clone 由 install.sh 在容器外完成(支持 Git 加速)
docker compose exec -T server python backend/manage.py init_nuclei_templates
log_info "Nuclei 模板仓库初始化完成"
}
@@ -165,7 +165,7 @@ main() {
init_engine_config
init_wordlists
# init_fingerprints
init_fingerprints
init_nuclei_templates
init_admin_user

View File

@@ -23,7 +23,7 @@ echo " ✓ 默认目录字典已就绪"
echo " [1.4/3] 初始化默认指纹库..."
# python manage.py init_fingerprints
python manage.py init_fingerprints
echo " ✓ 默认指纹库已就绪"
# 2. 启动 Django uvicorn 服务 (ASGI)

View File

@@ -71,20 +71,26 @@ RUN pipx install uro && \
pipx install waymore && \
pipx install dnsgen
# 3. 安装 Sublist3r统一放在 /opt/xingrin/tools 下
RUN git clone https://github.com/aboul3la/Sublist3r.git /opt/xingrin/tools/Sublist3r && \
pip3 install --no-cache-dir -r /opt/xingrin/tools/Sublist3r/requirements.txt --break-system-packages
# 3. 安装 Sublist3rPython 脚本工具,放在 /usr/local/share 标准目录
RUN git clone https://github.com/aboul3la/Sublist3r.git /usr/local/share/Sublist3r && \
pip3 install --no-cache-dir -r /usr/local/share/Sublist3r/requirements.txt --break-system-packages
# 4. 从 go-builder 阶段复制 Go 环境和编译好的工具
# 扫描工具统一放在 /opt/xingrin/tools/,避免与 Python 包冲突(如 httpx
# 创建项目专用工具目录(符合 FHS 标准,/opt 用于独立软件包
# 避免与系统工具或 Python 包冲突,避免被 /opt/xingrin 挂载覆盖
RUN mkdir -p /opt/xingrin-tools/bin
ENV GOPATH=/root/go
ENV PATH=/usr/local/go/bin:/opt/xingrin/tools:$PATH:$GOPATH/bin
ENV GOPROXY=https://goproxy.cn,direct
RUN mkdir -p /opt/xingrin/tools
COPY --from=go-builder /usr/local/go /usr/local/go
COPY --from=go-builder /go/bin/* /opt/xingrin/tools/
COPY --from=go-builder /usr/local/bin/massdns /opt/xingrin/tools/massdns
# 从 go-builder 复制扫描工具到专用目录(避免与系统工具或 Python 包冲突)
COPY --from=go-builder /go/bin/* /opt/xingrin-tools/bin/
COPY --from=go-builder /usr/local/bin/massdns /opt/xingrin-tools/bin/massdns
# 将专用工具目录添加到 PATH优先级高于 /usr/local/bin避免冲突
ENV PATH=/opt/xingrin-tools/bin:/usr/local/go/bin:/usr/local/bin:$PATH:$GOPATH/bin
# 5. 安装 uv Python 包管理器)并安装 Python 依赖
COPY backend/requirements.txt .

View File

@@ -13,9 +13,9 @@
- **权限**: sudo 管理员权限
- **端口要求**: 需要开放以下端口
- `8083` - HTTPS 访问(主要访问端口)
- `5432` - PostgreSQL 数据库(如使用本地数据库)
- `6379` - Redis 缓存服务
- `5432` - PostgreSQL 数据库(如使用本地数据库且有远程 Worker
- 后端 API 仅容器内监听 8888由 nginx 反代到 8083对公网无需放行 8888
- Redis 仅在 Docker 内部网络使用,无需对外开放
## 一键安装
@@ -60,8 +60,7 @@ sudo ./install.sh --no-frontend
#### 必须放行的端口
```
8083 - HTTPS 访问(主要访问端口)
5432 - PostgreSQL如使用本地数据库
6379 - Redis 缓存
5432 - PostgreSQL如使用本地数据库且有远程 Worker
```
#### 推荐方案

View File

@@ -4,27 +4,27 @@ import { VulnSeverityChart } from "@/components/dashboard/vuln-severity-chart"
import { DashboardDataTable } from "@/components/dashboard/dashboard-data-table"
/**
* 仪表板页面组件
* 这是应用的主要仪表板页面,包含卡片、图表和数据表格
* 布局结构已移至根布局组件中
* Dashboard page component
* This is the main dashboard page of the application, containing cards, charts and data tables
* Layout structure has been moved to the root layout component
*/
export default function Page() {
return (
// 内容区域,包含卡片、图表和数据表格
// Content area containing cards, charts and data tables
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
{/* 顶部统计卡片 */}
{/* Top statistics cards */}
<DashboardStatCards />
{/* 图表区域 - 趋势图 + 漏洞分布 */}
{/* Chart area - Trend chart + Vulnerability distribution */}
<div className="grid gap-4 px-4 lg:px-6 @xl/main:grid-cols-2">
{/* 资产趋势折线图 */}
{/* Asset trend line chart */}
<AssetTrendChart />
{/* 漏洞严重程度分布 */}
{/* Vulnerability severity distribution */}
<VulnSeverityChart />
</div>
{/* 漏洞 / 扫描历史 Tab */}
{/* Vulnerabilities / Scan history tab */}
<div className="px-4 lg:px-6">
<DashboardDataTable />
</div>

View File

@@ -5,13 +5,13 @@ import { getMessages, setRequestLocale, getTranslations } from 'next-intl/server
import { notFound } from 'next/navigation'
import { locales, localeHtmlLang, type Locale } from '@/i18n/config'
// 导入全局样式文件
// Import global style files
import "../globals.css"
// 导入思源黑体(Noto Sans SC)本地字体
// Import Noto Sans SC local font
import "@fontsource/noto-sans-sc/400.css"
import "@fontsource/noto-sans-sc/500.css"
import "@fontsource/noto-sans-sc/700.css"
// 导入颜色主题
// Import color themes
import "@/styles/themes/bubblegum.css"
import "@/styles/themes/quantum-rose.css"
import "@/styles/themes/clean-slate.css"
@@ -24,13 +24,14 @@ import { Suspense } from "react"
import Script from "next/script"
import { QueryProvider } from "@/components/providers/query-provider"
import { ThemeProvider } from "@/components/providers/theme-provider"
import { UiI18nProvider } from "@/components/providers/ui-i18n-provider"
// 导入公共布局组件
// Import common layout components
import { RoutePrefetch } from "@/components/route-prefetch"
import { RouteProgress } from "@/components/route-progress"
import { AuthLayout } from "@/components/auth/auth-layout"
// 动态生成元数据
// Dynamically generate metadata
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: 'metadata' })
@@ -54,7 +55,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
}
}
// 使用思源黑体 + 系统字体回退,完全本地加载
// Use Noto Sans SC + system font fallback, fully loaded locally
const fontConfig = {
className: "font-sans",
style: {
@@ -62,7 +63,7 @@ const fontConfig = {
}
}
// 生成静态参数,支持所有语言
// Generate static parameters, support all languages
export function generateStaticParams() {
return locales.map((locale) => ({ locale }))
}
@@ -73,8 +74,8 @@ interface Props {
}
/**
* 语言布局组件
* 包装所有页面,提供国际化上下文
* Language layout component
* Wraps all pages, provides internationalization context
*/
export default async function LocaleLayout({
children,
@@ -82,47 +83,50 @@ export default async function LocaleLayout({
}: Props) {
const { locale } = await params
// 验证 locale 有效性
// Validate locale validity
if (!locales.includes(locale as Locale)) {
notFound()
}
// 启用静态渲染
// Enable static rendering
setRequestLocale(locale)
// 加载翻译消息
// Load translation messages
const messages = await getMessages()
return (
<html lang={localeHtmlLang[locale as Locale]} suppressHydrationWarning>
<body className={fontConfig.className} style={fontConfig.style}>
{/* 加载外部脚本 */}
{/* Load external scripts */}
<Script
src="https://tweakcn.com/live-preview.min.js"
strategy="beforeInteractive"
crossOrigin="anonymous"
/>
{/* 路由加载进度条 */}
{/* Route loading progress bar */}
<Suspense fallback={null}>
<RouteProgress />
</Suspense>
{/* ThemeProvider 提供主题切换功能 */}
{/* ThemeProvider provides theme switching functionality */}
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
{/* NextIntlClientProvider 提供国际化上下文 */}
{/* NextIntlClientProvider provides internationalization context */}
<NextIntlClientProvider messages={messages}>
{/* QueryProvider 提供 React Query 功能 */}
{/* QueryProvider provides React Query functionality */}
<QueryProvider>
{/* 路由预加载 */}
<RoutePrefetch />
{/* AuthLayout 处理认证和侧边栏显示 */}
<AuthLayout>
{children}
</AuthLayout>
{/* UiI18nProvider provides UI component translations */}
<UiI18nProvider>
{/* Route prefetch */}
<RoutePrefetch />
{/* AuthLayout handles authentication and sidebar display */}
<AuthLayout>
{children}
</AuthLayout>
</UiI18nProvider>
</QueryProvider>
</NextIntlClientProvider>
</ThemeProvider>

View File

@@ -16,8 +16,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
}
/**
* 登录页面布局
* 不包含侧边栏和头部
* Login page layout
* Does not include sidebar and header
*/
export default function LoginLayout({
children,

View File

@@ -18,7 +18,7 @@ import { useLogin, useAuth } from "@/hooks/use-auth"
import { useRoutePrefetch } from "@/hooks/use-route-prefetch"
export default function LoginPage() {
// 在登录页面预加载所有页面组件
// Preload all page components on login page
useRoutePrefetch()
const router = useRouter()
const { data: auth, isLoading: authLoading } = useAuth()
@@ -28,7 +28,7 @@ export default function LoginPage() {
const [username, setUsername] = React.useState("")
const [password, setPassword] = React.useState("")
// 如果已登录,跳转到 dashboard
// If already logged in, redirect to dashboard
React.useEffect(() => {
if (auth?.authenticated) {
router.push("/dashboard/")
@@ -40,7 +40,7 @@ export default function LoginPage() {
login({ username, password })
}
// 加载中显示 spinner
// Show spinner while loading
if (authLoading) {
return (
<div className="flex min-h-svh w-full flex-col items-center justify-center gap-4 bg-background">
@@ -50,21 +50,21 @@ export default function LoginPage() {
)
}
// 已登录不显示登录页
// Don't show login page if already logged in
if (auth?.authenticated) {
return null
}
return (
<div className="login-bg flex min-h-svh flex-col p-6 md:p-10">
{/* 主要内容区域 */}
{/* Main content area */}
<div className="flex-1 flex items-center justify-center">
<div className="w-full max-w-sm md:max-w-4xl">
<Card className="overflow-hidden p-0">
<CardContent className="grid p-0 md:grid-cols-2">
<form className="p-6 md:p-8" onSubmit={handleSubmit}>
<FieldGroup>
{/* 指纹标识 - 用于 FOFA/Shodan 等搜索引擎识别 */}
{/* Fingerprint identifier - for FOFA/Shodan and other search engines to identify */}
<meta name="generator" content="Xingrin ASM Platform" />
<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-2xl font-bold">{t("title")}</h1>
@@ -116,7 +116,7 @@ export default function LoginPage() {
</div>
</div>
{/* 版本号 - 固定在页面底部 */}
{/* Version number - fixed at the bottom of the page */}
<div className="flex-shrink-0 text-center py-4">
<p className="text-xs text-muted-foreground">
{process.env.NEXT_PUBLIC_VERSION || 'dev'}

View File

@@ -4,8 +4,8 @@ import React from "react"
import { OrganizationDetailView } from "@/components/organization/organization-detail-view"
/**
* 组织详情页面
* 显示组织的统计信息和资产列表
* Organization detail page
* Displays organization statistics and asset list
*/
export default function OrganizationDetailPage({
params,

View File

@@ -1,22 +1,22 @@
"use client"
// 导入组织管理组件
// Import organization management component
import { OrganizationList } from "@/components/organization/organization-list"
// 导入图标
// Import icons
import { Building2 } from "lucide-react"
import { useTranslations } from "next-intl"
/**
* 组织管理页面
* 资产管理下的组织管理子页面,显示组织列表和相关操作
* Organization management page
* Sub-page under asset management that displays organization list and related operations
*/
export default function OrganizationPage() {
const t = useTranslations("pages.organization")
return (
// 内容区域,包含组织管理功能
// Content area containing organization management features
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
{/* 页面头部 */}
{/* Page header */}
<div className="flex items-center justify-between px-4 lg:px-6">
<div>
<h2 className="text-2xl font-bold tracking-tight flex items-center gap-2">
@@ -29,7 +29,7 @@ export default function OrganizationPage() {
</div>
</div>
{/* 组织列表组件 */}
{/* Organization list component */}
<div className="px-4 lg:px-6">
<OrganizationList />
</div>

View File

@@ -2,6 +2,6 @@ import { redirect } from 'next/navigation';
import { defaultLocale } from '@/i18n/config';
export default function Home() {
// 直接重定向到仪表板页面(带语言前缀)
// Redirect directly to dashboard page (with language prefix)
redirect(`/${defaultLocale}/dashboard/`);
}

View File

@@ -27,7 +27,7 @@ import { cn } from "@/lib/utils"
import type { ScanEngine } from "@/types/engine.types"
import { MasterDetailSkeleton } from "@/components/ui/master-detail-skeleton"
/** 功能配置项定义 - 与 YAML 配置结构对应 */
/** Feature configuration item definition - corresponds to YAML configuration structure */
const FEATURE_LIST = [
{ key: "subdomain_discovery" },
{ key: "port_scan" },
@@ -40,7 +40,7 @@ const FEATURE_LIST = [
type FeatureKey = typeof FEATURE_LIST[number]["key"]
/** 解析引擎配置获取启用的功能 */
/** Parse engine configuration to get enabled features */
function parseEngineFeatures(engine: ScanEngine): Record<FeatureKey, boolean> {
const defaultFeatures: Record<FeatureKey, boolean> = {
subdomain_discovery: false,
@@ -72,14 +72,14 @@ function parseEngineFeatures(engine: ScanEngine): Record<FeatureKey, boolean> {
}
}
/** 计算启用的功能数量 */
/** Calculate the number of enabled features */
function countEnabledFeatures(engine: ScanEngine) {
const features = parseEngineFeatures(engine)
return Object.values(features).filter(Boolean).length
}
/**
* 扫描引擎页面
* Scan engine page
*/
export default function ScanEnginePage() {
const [selectedId, setSelectedId] = useState<number | null>(null)
@@ -92,10 +92,10 @@ export default function ScanEnginePage() {
const { currentTheme } = useColorTheme()
// 国际化
// Internationalization
const tCommon = useTranslations("common")
const tConfirm = useTranslations("common.confirm")
const tNav = useTranslations("nav")
const tNav = useTranslations("navigation")
const tEngine = useTranslations("scan.engine")
// API Hooks
@@ -104,20 +104,20 @@ export default function ScanEnginePage() {
const updateEngineMutation = useUpdateEngine()
const deleteEngineMutation = useDeleteEngine()
// 过滤引擎列表
// Filter engine list
const filteredEngines = useMemo(() => {
if (!searchQuery.trim()) return engines
const query = searchQuery.toLowerCase()
return engines.filter((e) => e.name.toLowerCase().includes(query))
}, [engines, searchQuery])
// 选中的引擎
// Selected engine
const selectedEngine = useMemo(() => {
if (!selectedId) return null
return engines.find((e) => e.id === selectedId) || null
}, [selectedId, engines])
// 选中引擎的功能状态
// Selected engine's feature status
const selectedFeatures = useMemo(() => {
if (!selectedEngine) return null
return parseEngineFeatures(selectedEngine)
@@ -160,14 +160,14 @@ export default function ScanEnginePage() {
})
}
// 加载状态
// Loading state
if (isLoading) {
return <MasterDetailSkeleton title={tNav("scanEngine")} listItemCount={4} />
}
return (
<div className="flex flex-col h-full">
{/* 顶部:标题 + 搜索 + 新建按钮 */}
{/* Top: Title + Search + Create button */}
<div className="flex items-center justify-between gap-4 px-4 py-4 lg:px-6">
<h1 className="text-2xl font-bold shrink-0">{tNav("scanEngine")}</h1>
<div className="flex items-center gap-2 flex-1 max-w-md">
@@ -189,9 +189,9 @@ export default function ScanEnginePage() {
<Separator />
{/* 主体:左侧列表 + 右侧详情 */}
{/* Main: Left list + Right details */}
<div className="flex flex-1 min-h-0">
{/* 左侧:引擎列表 */}
{/* Left: Engine list */}
<div className="w-72 lg:w-80 border-r flex flex-col">
<div className="px-4 py-3 border-b">
<h2 className="text-sm font-medium text-muted-foreground">
@@ -231,11 +231,11 @@ export default function ScanEnginePage() {
</ScrollArea>
</div>
{/* 右侧:引擎详情 */}
{/* Right: Engine details */}
<div className="flex-1 flex flex-col min-w-0">
{selectedEngine && selectedFeatures ? (
<>
{/* 详情头部 */}
{/* Details header */}
<div className="px-6 py-4 border-b">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 shrink-0">
@@ -255,9 +255,9 @@ export default function ScanEnginePage() {
</div>
</div>
{/* 详情内容 */}
{/* Details content */}
<div className="flex-1 flex flex-col min-h-0 p-6 gap-6">
{/* 功能状态 */}
{/* Feature status */}
<div className="shrink-0">
<h3 className="text-sm font-medium mb-3">{tEngine("enabledFeatures")}</h3>
<div className="rounded-lg border">
@@ -285,7 +285,7 @@ export default function ScanEnginePage() {
</div>
</div>
{/* 配置预览 */}
{/* Configuration preview */}
{selectedEngine.configuration && (
<div className="flex-1 flex flex-col min-h-0">
<h3 className="text-sm font-medium mb-3 shrink-0">{tEngine("configPreview")}</h3>
@@ -312,7 +312,7 @@ export default function ScanEnginePage() {
)}
</div>
{/* 操作按钮 */}
{/* Action buttons */}
<div className="px-6 py-4 border-t flex items-center gap-2">
<Button
variant="outline"
@@ -336,7 +336,7 @@ export default function ScanEnginePage() {
</div>
</>
) : (
// 未选中状态
// Unselected state
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<Settings className="h-12 w-12 mx-auto mb-3 opacity-50" />
@@ -347,7 +347,7 @@ export default function ScanEnginePage() {
</div>
</div>
{/* 编辑引擎弹窗 */}
{/* Edit engine dialog */}
<EngineEditDialog
engine={editingEngine}
open={isEditDialogOpen}
@@ -355,14 +355,14 @@ export default function ScanEnginePage() {
onSave={handleSaveYaml}
/>
{/* 新建引擎弹窗 */}
{/* Create engine dialog */}
<EngineCreateDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
onSave={handleCreateEngine}
/>
{/* 删除确认弹窗 */}
{/* Delete confirmation dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>

View File

@@ -39,7 +39,7 @@ export default function ScanHistoryLayout({
"ip-addresses": `${basePath}/ip-addresses/`,
}
// 从扫描数据中获取各个tab的数量
// Get counts for each tab from scan data
const counts = {
subdomain: scanData?.summary?.subdomains || 0,
endpoints: scanData?.summary?.endpoints || 0,

View File

@@ -6,15 +6,15 @@ import { ScanHistoryList } from "@/components/scan/history/scan-history-list"
import { ScanHistoryStatCards } from "@/components/scan/history/scan-history-stat-cards"
/**
* 扫描历史页面
* 显示所有扫描任务的历史记录
* Scan history page
* Displays historical records of all scan tasks
*/
export default function ScanHistoryPage() {
const t = useTranslations("scan.history")
return (
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
{/* 页面标题 */}
{/* Page title */}
<div className="flex items-center gap-3 px-4 lg:px-6">
<IconRadar className="size-8 text-primary" />
<div>
@@ -23,12 +23,12 @@ export default function ScanHistoryPage() {
</div>
</div>
{/* 统计卡片 */}
{/* Statistics cards */}
<div className="px-4 lg:px-6">
<ScanHistoryStatCards />
</div>
{/* 扫描历史列表 */}
{/* Scan history list */}
<div className="px-4 lg:px-6">
<ScanHistoryList />
</div>

View File

@@ -25,8 +25,8 @@ import type { ScheduledScan } from "@/types/scheduled-scan.types"
import { DataTableSkeleton } from "@/components/ui/data-table-skeleton"
/**
* 定时扫描页面
* 管理定时扫描任务配置
* Scheduled scan page
* Manage scheduled scan task configuration
*/
export default function ScheduledScanPage() {
const [createDialogOpen, setCreateDialogOpen] = React.useState(false)
@@ -35,13 +35,13 @@ export default function ScheduledScanPage() {
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
const [deletingScheduledScan, setDeletingScheduledScan] = React.useState<ScheduledScan | null>(null)
// 国际化
// Internationalization
const tColumns = useTranslations("columns")
const tCommon = useTranslations("common")
const tScan = useTranslations("scan")
const tConfirm = useTranslations("common.confirm")
// 构建翻译对象
// Build translation object
const translations = React.useMemo(() => ({
columns: {
taskName: tColumns("scheduledScan.taskName"),
@@ -64,21 +64,21 @@ export default function ScheduledScanPage() {
},
cron: {
everyMinute: tScan("cron.everyMinute"),
everyNMinutes: tScan("cron.everyNMinutes"),
everyHour: tScan("cron.everyHour"),
everyNHours: tScan("cron.everyNHours"),
everyDay: tScan("cron.everyDay"),
everyWeek: tScan("cron.everyWeek"),
everyMonth: tScan("cron.everyMonth"),
everyNMinutes: tScan.raw("cron.everyNMinutes") as string,
everyHour: tScan.raw("cron.everyHour") as string,
everyNHours: tScan.raw("cron.everyNHours") as string,
everyDay: tScan.raw("cron.everyDay") as string,
everyWeek: tScan.raw("cron.everyWeek") as string,
everyMonth: tScan.raw("cron.everyMonth") as string,
weekdays: tScan.raw("cron.weekdays") as string[],
},
}), [tColumns, tCommon, tScan])
// 分页状态
// Pagination state
const [page, setPage] = React.useState(1)
const [pageSize, setPageSize] = React.useState(10)
// 搜索状态
// Search state
const [searchQuery, setSearchQuery] = React.useState("")
const [isSearching, setIsSearching] = React.useState(false)
@@ -88,10 +88,10 @@ export default function ScheduledScanPage() {
setPage(1)
}
// 使用实际 API
// Use actual API
const { data, isLoading, isFetching, refetch } = useScheduledScans({ page, pageSize, search: searchQuery || undefined })
// 当请求完成时重置搜索状态
// Reset search state when request completes
React.useEffect(() => {
if (!isFetching && isSearching) {
setIsSearching(false)
@@ -104,7 +104,7 @@ export default function ScheduledScanPage() {
const total = data?.total || 0
const totalPages = data?.totalPages || 1
// 格式化日期
// Format date
const formatDate = React.useCallback((dateString: string) => {
const date = new Date(dateString)
return date.toLocaleString("zh-CN", {
@@ -116,19 +116,19 @@ export default function ScheduledScanPage() {
})
}, [])
// 编辑任务
// Edit task
const handleEdit = React.useCallback((scan: ScheduledScan) => {
setEditingScheduledScan(scan)
setEditDialogOpen(true)
}, [])
// 删除任务(打开确认弹窗)
// Delete task (open confirmation dialog)
const handleDelete = React.useCallback((scan: ScheduledScan) => {
setDeletingScheduledScan(scan)
setDeleteDialogOpen(true)
}, [])
// 确认删除任务
// Confirm delete task
const confirmDelete = React.useCallback(() => {
if (deletingScheduledScan) {
deleteScheduledScan(deletingScheduledScan.id)
@@ -137,28 +137,28 @@ export default function ScheduledScanPage() {
}
}, [deletingScheduledScan, deleteScheduledScan])
// 切换任务启用状态
// Toggle task enabled status
const handleToggleStatus = React.useCallback((scan: ScheduledScan, enabled: boolean) => {
toggleScheduledScan({ id: scan.id, isEnabled: enabled })
}, [toggleScheduledScan])
// 页码变化处理
// Page change handler
const handlePageChange = React.useCallback((newPage: number) => {
setPage(newPage)
}, [])
// 每页数量变化处理
// Page size change handler
const handlePageSizeChange = React.useCallback((newPageSize: number) => {
setPageSize(newPageSize)
setPage(1) // 重置到第一页
setPage(1) // Reset to first page
}, [])
// 添加新任务
// Add new task
const handleAddNew = React.useCallback(() => {
setCreateDialogOpen(true)
}, [])
// 创建列定义
// Create column definition
const columns = React.useMemo(
() =>
createScheduledScanColumns({
@@ -191,7 +191,7 @@ export default function ScheduledScanPage() {
return (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
{/* 页面标题 */}
{/* Page title */}
<div className="px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">{tScan("scheduled.title")}</h1>
@@ -199,7 +199,7 @@ export default function ScheduledScanPage() {
</div>
</div>
{/* 数据表格 */}
{/* Data table */}
<div className="px-4 lg:px-6">
<ScheduledScanDataTable
data={scheduledScans}
@@ -219,14 +219,14 @@ export default function ScheduledScanPage() {
/>
</div>
{/* 新建定时扫描对话框 */}
{/* Create scheduled scan dialog */}
<CreateScheduledScanDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={() => refetch()}
/>
{/* 编辑定时扫描对话框 */}
{/* Edit scheduled scan dialog */}
<EditScheduledScanDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
@@ -234,7 +234,7 @@ export default function ScheduledScanPage() {
onSuccess={() => refetch()}
/>
{/* 删除确认弹窗 */}
{/* Delete confirmation dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>

View File

@@ -109,9 +109,9 @@ export default function NotificationSettingsPage() {
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* 推送渠道 Tab */}
{/* Push channels tab */}
<TabsContent value="channels" className="space-y-4 mt-4">
{/* Discord 卡片 */}
{/* Discord card */}
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
@@ -166,7 +166,7 @@ export default function NotificationSettingsPage() {
)}
</Card>
{/* 邮件 - 即将支持 */}
{/* Email - Coming soon */}
<Card className="opacity-60">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
@@ -187,7 +187,7 @@ export default function NotificationSettingsPage() {
</CardHeader>
</Card>
{/* 飞书/钉钉/企微 - 即将支持 */}
{/* Feishu/DingTalk/WeCom - Coming soon */}
<Card className="opacity-60">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
@@ -209,7 +209,7 @@ export default function NotificationSettingsPage() {
</Card>
</TabsContent>
{/* 通知偏好 Tab */}
{/* Notification preferences tab */}
<TabsContent value="preferences" className="mt-4">
<Card>
<CardHeader>
@@ -252,7 +252,7 @@ export default function NotificationSettingsPage() {
</Card>
</TabsContent>
{/* 保存按钮 */}
{/* Save button */}
<div className="flex justify-end mt-6">
<Button type="submit" disabled={updateMutation.isPending || isLoading}>
{t("saveSettings")}

View File

@@ -4,15 +4,15 @@ import { useParams, useRouter } from "next/navigation"
import { useEffect } from "react"
/**
* 目标详情页面(兼容旧路由)
* 自动重定向到域名页面
* Target detail page (compatible with old routes)
* Automatically redirects to subdomain page
*/
export default function TargetDetailsPage() {
const { id } = useParams<{ id: string }>()
const router = useRouter()
useEffect(() => {
// 重定向到子域名页面
// Redirect to subdomain page
router.replace(`/target/${id}/subdomain/`)
}, [id, router])

View File

@@ -5,8 +5,8 @@ import { useParams } from "next/navigation"
import { EndpointsDetailView } from "@/components/endpoints"
/**
* 目标端点页面
* 显示目标下的端点详情
* Target endpoints page
* Displays endpoint details under the target
*/
export default function TargetEndpointsPage() {
const { id } = useParams<{ id: string }>()

View File

@@ -11,8 +11,8 @@ import { useTarget } from "@/hooks/use-targets"
import { useTranslations } from "next-intl"
/**
* 目标详情布局
* 为所有子页面提供共享的目标信息和导航
* Target detail layout
* Provides shared target information and navigation for all sub-pages
*/
export default function TargetLayout({
children,
@@ -23,14 +23,14 @@ export default function TargetLayout({
const pathname = usePathname()
const t = useTranslations("pages.targetDetail")
// 使用 React Query 获取目标数据
// Use React Query to get target data
const {
data: target,
isLoading,
error
} = useTarget(Number(id))
// 获取当前激活的 Tab
// Get currently active tab
const getActiveTab = () => {
if (pathname.includes("/subdomain")) return "subdomain"
if (pathname.includes("/endpoints")) return "endpoints"
@@ -41,7 +41,7 @@ export default function TargetLayout({
return ""
}
// Tab 路径映射
// Tab path mapping
const basePath = `/target/${id}`
const tabPaths = {
subdomain: `${basePath}/subdomain/`,
@@ -52,7 +52,7 @@ export default function TargetLayout({
"ip-addresses": `${basePath}/ip-addresses/`,
}
// 从目标数据中获取各个tab的数量
// Get counts for each tab from target data
const counts = {
subdomain: (target as any)?.summary?.subdomains || 0,
endpoints: (target as any)?.summary?.endpoints || 0,
@@ -62,11 +62,11 @@ export default function TargetLayout({
"ip-addresses": (target as any)?.summary?.ips || 0,
}
// 加载状态
// Loading state
if (isLoading) {
return (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
{/* 页面头部骨架 */}
{/* Page header skeleton */}
<div className="flex items-center justify-between px-4 lg:px-6">
<div className="w-full max-w-xl space-y-2">
<div className="flex items-center gap-2">
@@ -77,7 +77,7 @@ export default function TargetLayout({
</div>
</div>
{/* Tabs 导航骨架 */}
{/* Tabs navigation skeleton */}
<div className="flex items-center justify-between px-4 lg:px-6">
<div className="flex gap-2">
<Skeleton className="h-9 w-20" />
@@ -88,7 +88,7 @@ export default function TargetLayout({
)
}
// 错误状态
// Error state
if (error) {
return (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
@@ -123,7 +123,7 @@ export default function TargetLayout({
return (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
{/* 页面头部 */}
{/* Page header */}
<div className="flex items-center justify-between px-4 lg:px-6">
<div>
<h2 className="text-2xl font-bold tracking-tight flex items-center gap-2">
@@ -134,7 +134,7 @@ export default function TargetLayout({
</div>
</div>
{/* Tabs 导航 - 使用 Link 确保触发进度条 */}
{/* Tabs navigation - Use Link to ensure progress bar is triggered */}
<div className="flex items-center justify-between px-4 lg:px-6">
<Tabs value={getActiveTab()} className="w-full">
<TabsList>
@@ -202,7 +202,7 @@ export default function TargetLayout({
</Tabs>
</div>
{/* 子页面内容 */}
{/* Sub-page content */}
{children}
</div>
)

View File

@@ -4,15 +4,15 @@ import { useParams, useRouter } from "next/navigation"
import { useEffect } from "react"
/**
* 目标详情默认页面
* 自动重定向到域名页面
* Target detail default page
* Automatically redirects to subdomain page
*/
export default function TargetDetailPage() {
const { id } = useParams<{ id: string }>()
const router = useRouter()
useEffect(() => {
// 重定向到子域名页面
// Redirect to subdomain page
router.replace(`/target/${id}/subdomain/`)
}, [id, router])

View File

@@ -5,8 +5,8 @@ import { useParams } from "next/navigation"
import { VulnerabilitiesDetailView } from "@/components/vulnerabilities"
/**
* 目标漏洞页面
* 显示目标下的漏洞详情
* Target vulnerabilities page
* Displays vulnerability details under the target
*/
export default function TargetVulnerabilitiesPage() {
const { id } = useParams<{ id: string }>()

View File

@@ -9,7 +9,7 @@ export default function AllTargetsPage() {
return (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
{/* 页面头部 */}
{/* Page header */}
<div className="flex items-center justify-between px-4 lg:px-6">
<div>
<h2 className="text-2xl font-bold tracking-tight flex items-center gap-2">
@@ -22,7 +22,7 @@ export default function AllTargetsPage() {
</div>
</div>
{/* 内容区域 */}
{/* Content area */}
<div className="px-4 lg:px-6">
<AllTargetsDetailView />
</div>

View File

@@ -1,8 +1,8 @@
/**
* 自定义工具页面
* 展示和管理自定义扫描脚本和工具
* Custom tools page
* Display and manage custom scanning scripts and tools
*/
export default function CustomToolsPage() {
// 工具配置功能已下线,此页面保留占位避免历史链接报错
// Tool configuration feature has been deprecated, this page is kept as placeholder to avoid broken historical links
return null
}

View File

@@ -1,8 +1,8 @@
/**
* 开源工具页面
* 展示和管理开源扫描工具
* Open source tools page
* Display and manage open source scanning tools
*/
export default function OpensourceToolsPage() {
// 工具配置功能已下线,此页面保留占位避免历史链接报错
// Tool configuration feature has been deprecated, this page is kept as placeholder to avoid broken historical links
return null
}

View File

@@ -1,10 +1,10 @@
"use client"
/**
* 工具配置页面
* 展示和管理扫描工具集(开源工具和自定义工具)
* Tool configuration page
* Display and manage scanning tool sets (open source tools and custom tools)
*/
export default function ToolConfigPage() {
// 工具配置功能已下线,此页面保留占位避免历史链接报错
// Tool configuration feature has been deprecated, this page is kept as placeholder to avoid broken historical links
return null
}

View File

@@ -0,0 +1,12 @@
"use client"
import React from "react"
import { ARLFingerprintView } from "@/components/fingerprints"
export default function ARLFingerprintPage() {
return (
<div className="px-4 lg:px-6">
<ARLFingerprintView />
</div>
)
}

View File

@@ -0,0 +1,12 @@
"use client"
import React from "react"
import { FingerPrintHubFingerprintView } from "@/components/fingerprints"
export default function FingerPrintHubFingerprintPage() {
return (
<div className="px-4 lg:px-6">
<FingerPrintHubFingerprintView />
</div>
)
}

View File

@@ -0,0 +1,12 @@
"use client"
import React from "react"
import { FingersFingerprintView } from "@/components/fingerprints"
export default function FingersFingerprintPage() {
return (
<div className="px-4 lg:px-6">
<FingersFingerprintView />
</div>
)
}

View File

@@ -16,16 +16,9 @@ import {
import { useFingerprintStats } from "@/hooks/use-fingerprints"
import { useTranslations } from "next-intl"
// 指纹库说明
const FINGERPRINT_HELP = `
• EHole: 红队重点资产识别工具支持关键词、favicon hash 等方式识别
• Goby: 攻击面测绘工具,包含大量 Web 应用和设备指纹
• Wappalyzer: 浏览器扩展,可识别网站使用的技术栈
`.trim()
/**
* 指纹管理布局
* 提供 Tab 导航切换不同指纹库
* Fingerprint management layout
* Provides tab navigation to switch between different fingerprint libraries
*/
export default function FingerprintsLayout({
children,
@@ -36,27 +29,36 @@ export default function FingerprintsLayout({
const { data: stats, isLoading } = useFingerprintStats()
const t = useTranslations("tools.fingerprints")
// 获取当前激活的 Tab
// Get currently active tab
const getActiveTab = () => {
if (pathname.includes("/ehole")) return "ehole"
if (pathname.includes("/goby")) return "goby"
if (pathname.includes("/wappalyzer")) return "wappalyzer"
if (pathname.includes("/fingers")) return "fingers"
if (pathname.includes("/fingerprinthub")) return "fingerprinthub"
if (pathname.includes("/arl")) return "arl"
return "ehole"
}
// Tab 路径映射
// Tab path mapping
const basePath = "/tools/fingerprints"
const tabPaths = {
ehole: `${basePath}/ehole/`,
goby: `${basePath}/goby/`,
wappalyzer: `${basePath}/wappalyzer/`,
fingers: `${basePath}/fingers/`,
fingerprinthub: `${basePath}/fingerprinthub/`,
arl: `${basePath}/arl/`,
}
// 各指纹库数量
// Fingerprint library counts
const counts = {
ehole: stats?.ehole || 0,
goby: stats?.goby || 0,
wappalyzer: stats?.wappalyzer || 0,
fingers: stats?.fingers || 0,
fingerprinthub: stats?.fingerprinthub || 0,
arl: stats?.arl || 0,
}
if (isLoading) {
@@ -77,7 +79,7 @@ export default function FingerprintsLayout({
return (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
{/* 页面头部 */}
{/* Page header */}
<div className="flex items-center justify-between px-4 lg:px-6">
<div>
<h2 className="text-2xl font-bold tracking-tight flex items-center gap-2">
@@ -88,7 +90,7 @@ export default function FingerprintsLayout({
</div>
</div>
{/* Tabs 导航 */}
{/* Tabs navigation */}
<div className="flex items-center justify-between px-4 lg:px-6">
<div className="flex items-center gap-3">
<Tabs value={getActiveTab()} className="w-full">
@@ -123,6 +125,36 @@ export default function FingerprintsLayout({
)}
</Link>
</TabsTrigger>
<TabsTrigger value="fingers" asChild>
<Link href={tabPaths.fingers} className="flex items-center gap-0.5">
Fingers
{counts.fingers > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.fingers}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="fingerprinthub" asChild>
<Link href={tabPaths.fingerprinthub} className="flex items-center gap-0.5">
FingerPrintHub
{counts.fingerprinthub > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.fingerprinthub}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="arl" asChild>
<Link href={tabPaths.arl} className="flex items-center gap-0.5">
ARL
{counts.arl > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.arl}
</Badge>
)}
</Link>
</TabsTrigger>
</TabsList>
</Tabs>
@@ -132,14 +164,14 @@ export default function FingerprintsLayout({
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-sm whitespace-pre-line">
{FINGERPRINT_HELP}
{t("helpText")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* 子页面内容 */}
{/* Sub-page content */}
{children}
</div>
)

View File

@@ -3,7 +3,7 @@
import { redirect } from "next/navigation"
/**
* 指纹管理首页 - 重定向到 EHole
* Fingerprint management homepage - Redirect to EHole
*/
export default function FingerprintsPage() {
redirect("/tools/fingerprints/ehole/")

View File

@@ -36,7 +36,7 @@ interface FlattenedNode extends NucleiTemplateTreeNode {
level: number
}
/** 解析 YAML 内容提取模板信息 */
/** Parse YAML content to extract template information */
function parseTemplateInfo(content: string) {
const info: {
id?: string
@@ -46,7 +46,7 @@ function parseTemplateInfo(content: string) {
author?: string
} = {}
// 简单正则提取,不用完整 YAML 解析
// Simple regex extraction, no full YAML parsing
const idMatch = content.match(/^id:\s*(.+)$/m)
if (idMatch) info.id = idMatch[1].trim()
@@ -65,7 +65,7 @@ function parseTemplateInfo(content: string) {
return info
}
/** 严重程度对应的颜色 */
/** Severity level corresponding colors */
function getSeverityColor(severity?: string) {
switch (severity) {
case "critical":
@@ -103,7 +103,7 @@ export default function NucleiRepoDetailPage() {
const { data: repoDetail } = useNucleiRepo(numericRepoId)
const refreshMutation = useRefreshNucleiRepo()
// 展开的节点和过滤后的节点
// Expanded nodes and filtered nodes
const nodes: FlattenedNode[] = useMemo(() => {
const result: FlattenedNode[] = []
const expandedSet = new Set(expandedPaths)
@@ -121,7 +121,7 @@ export default function NucleiRepoDetailPage() {
continue
}
// 搜索过滤
// Search filter
if (query && isFile && !item.name.toLowerCase().includes(query)) {
continue
}
@@ -129,7 +129,7 @@ export default function NucleiRepoDetailPage() {
result.push({ ...item, level })
if (isFolder && item.children && item.children.length > 0) {
// 搜索时展开所有文件夹,否则按 expandedPaths
// Expand all folders when searching, otherwise follow expandedPaths
if (query || expandedSet.has(item.path)) {
visit(item.children, level + 1)
}
@@ -170,7 +170,7 @@ export default function NucleiRepoDetailPage() {
const repoDisplayName = repoDetail?.name || t("repoName", { id: repoId })
// 解析当前模板信息
// Parse current template information
const templateInfo = useMemo(() => {
if (!templateContent?.content) return null
return parseTemplateInfo(templateContent.content)
@@ -178,7 +178,7 @@ export default function NucleiRepoDetailPage() {
return (
<div className="flex flex-col h-full">
{/* 顶部:返回 + 标题 + 搜索 + 同步 */}
{/* Top: Back + Title + Search + Sync */}
<div className="flex items-center gap-4 px-4 py-4 lg:px-6">
<Link href="/tools/nuclei/">
<Button variant="ghost" size="sm" className="gap-1.5">
@@ -211,9 +211,9 @@ export default function NucleiRepoDetailPage() {
<Separator />
{/* 主体:左侧目录 + 右侧内容 */}
{/* Main: Left directory + Right content */}
<div className="flex flex-1 min-h-0">
{/* 左侧:模板目录 */}
{/* Left: Template directory */}
<div className="w-72 lg:w-80 border-r flex flex-col">
<div className="px-4 py-3 border-b">
<h2 className="text-sm font-medium text-muted-foreground">
@@ -280,11 +280,11 @@ export default function NucleiRepoDetailPage() {
</ScrollArea>
</div>
{/* 右侧:模板内容 */}
{/* Right: Template content */}
<div className="flex-1 flex flex-col min-w-0">
{selectedPath && templateContent ? (
<>
{/* 模板头部 */}
{/* Template header */}
<div className="px-6 py-4 border-b">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 shrink-0">
@@ -309,7 +309,7 @@ export default function NucleiRepoDetailPage() {
</div>
</div>
{/* 代码编辑器 */}
{/* Code editor */}
<div className="flex-1 min-h-0">
<Editor
height="100%"
@@ -328,7 +328,7 @@ export default function NucleiRepoDetailPage() {
/>
</div>
{/* 模板信息 */}
{/* Template information */}
{templateInfo && (templateInfo.tags || templateInfo.author) && (
<div className="px-6 py-3 border-t flex items-center gap-4 text-sm">
{templateInfo.tags && templateInfo.tags.length > 0 && (
@@ -358,7 +358,7 @@ export default function NucleiRepoDetailPage() {
)}
</>
) : (
// 未选中状态
// Unselected state
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-3 opacity-50" />

View File

@@ -33,7 +33,7 @@ import { cn } from "@/lib/utils"
import { MasterDetailSkeleton } from "@/components/ui/master-detail-skeleton"
import { getDateLocale } from "@/lib/date-utils"
/** 格式化时间显示 */
/** Format time display */
function formatDateTime(isoString: string | null, locale: string) {
if (!isoString) return "-"
try {
@@ -56,7 +56,7 @@ export default function NucleiReposPage() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [repoToDelete, setRepoToDelete] = useState<NucleiRepo | null>(null)
// 国际化
// Internationalization
const tCommon = useTranslations("common")
const tConfirm = useTranslations("common.confirm")
const t = useTranslations("pages.nuclei")
@@ -69,7 +69,7 @@ export default function NucleiReposPage() {
const refreshMutation = useRefreshNucleiRepo()
const updateMutation = useUpdateNucleiRepo()
// 过滤仓库列表
// Filter repository list
const filteredRepos = useMemo(() => {
if (!repos) return []
if (!searchQuery.trim()) return repos
@@ -81,7 +81,7 @@ export default function NucleiReposPage() {
)
}, [repos, searchQuery])
// 选中的仓库
// Selected repository
const selectedRepo = useMemo(() => {
if (!selectedId || !repos) return null
return repos.find((r) => r.id === selectedId) || null
@@ -159,14 +159,14 @@ export default function NucleiReposPage() {
)
}
// 加载状态
// Loading state
if (isLoading) {
return <MasterDetailSkeleton title={t("title")} listItemCount={3} />
}
return (
<div className="flex flex-col h-full">
{/* 顶部:标题 + 搜索 + 新增按钮 */}
{/* Top: Title + Search + Add button */}
<div className="flex items-center justify-between gap-4 px-4 py-4 lg:px-6">
<h1 className="text-2xl font-bold shrink-0">{t("title")}</h1>
<div className="flex items-center gap-2 flex-1 max-w-md">
@@ -188,9 +188,9 @@ export default function NucleiReposPage() {
<Separator />
{/* 主体:左侧列表 + 右侧详情 */}
{/* Main: Left list + Right details */}
<div className="flex flex-1 min-h-0">
{/* 左侧:仓库列表 */}
{/* Left: Repository list */}
<div className="w-72 lg:w-80 border-r flex flex-col">
<div className="px-4 py-3 border-b">
<h2 className="text-sm font-medium text-muted-foreground">
@@ -245,11 +245,11 @@ export default function NucleiReposPage() {
</ScrollArea>
</div>
{/* 右侧:仓库详情 */}
{/* Right: Repository details */}
<div className="flex-1 flex flex-col min-w-0">
{selectedRepo ? (
<>
{/* 详情头部 */}
{/* Details header */}
<div className="px-6 py-4 border-b">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 shrink-0">
@@ -272,10 +272,10 @@ export default function NucleiReposPage() {
</div>
</div>
{/* 详情内容 */}
{/* Details content */}
<ScrollArea className="flex-1">
<div className="p-6 space-y-6">
{/* 统计信息 */}
{/* Statistics information */}
<div className="rounded-lg border">
<div className="grid grid-cols-2 divide-x">
<div className="p-4">
@@ -322,7 +322,7 @@ export default function NucleiReposPage() {
</div>
</ScrollArea>
{/* 操作按钮 */}
{/* Action buttons */}
<div className="px-6 py-4 border-t flex items-center gap-2">
<Button
variant="outline"
@@ -361,7 +361,7 @@ export default function NucleiReposPage() {
</div>
</>
) : (
// 未选中状态
// Unselected state
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<GitBranch className="h-12 w-12 mx-auto mb-3 opacity-50" />
@@ -405,7 +405,7 @@ export default function NucleiReposPage() {
/>
</div>
{/* 目前只支持公开仓库,这里不再提供认证方式和凭据配置 */}
{/* Currently only public repositories are supported, no authentication method and credential configuration provided here */}
<DialogFooter>
<Button
@@ -457,7 +457,7 @@ export default function NucleiReposPage() {
/>
</div>
{/* 编辑时也不再支持配置认证方式/凭据,仅允许修改 Git 地址 */}
{/* Editing also no longer supports configuring authentication method/credentials, only allows modifying Git address */}
<DialogFooter>
<Button
@@ -479,7 +479,7 @@ export default function NucleiReposPage() {
</DialogContent>
</Dialog>
{/* 删除确认弹窗 */}
{/* Delete confirmation dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>

View File

@@ -7,14 +7,14 @@ import Link from "next/link"
import { useTranslations } from "next-intl"
/**
* 工具概览页面
* 显示开源工具和自定义工具的入口
* Tools overview page
* Displays entry points for open source tools and custom tools
*/
export default function ToolsPage() {
const t = useTranslations("pages.tools")
const tCommon = useTranslations("common")
// 功能模块
// Feature modules
const modules = [
{
title: t("wordlists.title"),
@@ -42,7 +42,7 @@ export default function ToolsPage() {
return (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
{/* 页面头部 */}
{/* Page header */}
<div className="flex items-center justify-between px-4 lg:px-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t("title")}</h2>
@@ -52,7 +52,7 @@ export default function ToolsPage() {
</div>
</div>
{/* 统计卡片 */}
{/* Statistics cards */}
<div className="px-4 lg:px-6">
<div className="grid gap-4 md:grid-cols-2">
{modules.map((module) => (
@@ -73,7 +73,7 @@ export default function ToolsPage() {
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* 统计信息 */}
{/* Statistics information */}
<div className="flex items-center gap-6 text-sm">
<div>
<span className="text-muted-foreground">{t("stats.total")}</span>
@@ -85,7 +85,7 @@ export default function ToolsPage() {
</div>
</div>
{/* 操作按钮 */}
{/* Action buttons */}
{module.status === "available" ? (
<Link href={module.href}>
<Button className="w-full">
@@ -105,7 +105,7 @@ export default function ToolsPage() {
</div>
</div>
{/* 快速操作 */}
{/* Quick actions */}
<div className="px-4 lg:px-6">
<Card>
<CardHeader>

View File

@@ -34,17 +34,17 @@ export default function WordlistsPage() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [wordlistToDelete, setWordlistToDelete] = useState<Wordlist | null>(null)
// 国际化
// Internationalization
const tCommon = useTranslations("common")
const tConfirm = useTranslations("common.confirm")
const tNav = useTranslations("nav")
const tNav = useTranslations("navigation")
const t = useTranslations("pages.wordlists")
const locale = useLocale()
const { data, isLoading } = useWordlists({ page: 1, pageSize: 1000 })
const deleteMutation = useDeleteWordlist()
// 过滤字典列表
// Filter wordlist list
const filteredWordlists = useMemo(() => {
if (!data?.results) return []
if (!searchQuery.trim()) return data.results
@@ -56,7 +56,7 @@ export default function WordlistsPage() {
)
}, [data?.results, searchQuery])
// 选中的字典
// Selected wordlist
const selectedWordlist = useMemo(() => {
if (!selectedId || !data?.results) return null
return data.results.find((w) => w.id === selectedId) || null
@@ -97,14 +97,14 @@ export default function WordlistsPage() {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
// 加载状态
// Loading state
if (isLoading) {
return <MasterDetailSkeleton title={tNav("wordlists")} listItemCount={5} />
}
return (
<div className="flex flex-col h-full">
{/* 顶部:标题 + 搜索 + 上传按钮 */}
{/* Top: Title + Search + Upload button */}
<div className="flex items-center justify-between gap-4 px-4 py-4 lg:px-6">
<h1 className="text-2xl font-bold shrink-0">{t("title")}</h1>
<div className="flex items-center gap-2 flex-1 max-w-md">
@@ -123,9 +123,9 @@ export default function WordlistsPage() {
<Separator />
{/* 主体:左侧列表 + 右侧详情 */}
{/* Main: Left list + Right details */}
<div className="flex flex-1 min-h-0">
{/* 左侧:字典列表 */}
{/* Left: Wordlist list */}
<div className="w-72 lg:w-80 border-r flex flex-col">
<div className="px-4 py-3 border-b">
<h2 className="text-sm font-medium text-muted-foreground">
@@ -165,11 +165,11 @@ export default function WordlistsPage() {
</ScrollArea>
</div>
{/* 右侧:字典详情 */}
{/* Right: Wordlist details */}
<div className="flex-1 flex flex-col min-w-0">
{selectedWordlist ? (
<>
{/* 详情头部 */}
{/* Details header */}
<div className="px-6 py-4 border-b">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 shrink-0">
@@ -188,10 +188,10 @@ export default function WordlistsPage() {
</div>
</div>
{/* 详情内容 */}
{/* Details content */}
<ScrollArea className="flex-1">
<div className="p-6 space-y-6">
{/* 基本信息 */}
{/* Basic information */}
<div className="rounded-lg border">
<div className="grid grid-cols-2 divide-x">
<div className="p-4">
@@ -232,7 +232,7 @@ export default function WordlistsPage() {
</div>
</ScrollArea>
{/* 操作按钮 */}
{/* Action buttons */}
<div className="px-6 py-4 border-t flex items-center gap-2">
<Button
variant="outline"
@@ -256,7 +256,7 @@ export default function WordlistsPage() {
</div>
</>
) : (
// 未选中状态
// Unselected state
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-3 opacity-50" />
@@ -267,14 +267,14 @@ export default function WordlistsPage() {
</div>
</div>
{/* 编辑弹窗 */}
{/* Edit dialog */}
<WordlistEditDialog
wordlist={editingWordlist}
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
/>
{/* 删除确认弹窗 */}
{/* Delete confirmation dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>

View File

@@ -5,15 +5,15 @@ import { useTranslations } from "next-intl"
import { VulnerabilitiesDetailView } from "@/components/vulnerabilities"
/**
* 全部漏洞页面
* 显示系统中所有漏洞
* All vulnerabilities page
* Displays all vulnerabilities in the system
*/
export default function VulnerabilitiesPage() {
const t = useTranslations("vulnerabilities")
return (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
{/* 页面头部 */}
{/* Page header */}
<div className="flex items-center justify-between px-4 lg:px-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t("title")}</h2>
@@ -21,7 +21,7 @@ export default function VulnerabilitiesPage() {
</div>
</div>
{/* 漏洞列表 */}
{/* Vulnerability list */}
<div className="px-4 lg:px-6">
<VulnerabilitiesDetailView />
</div>

View File

@@ -1,8 +1,8 @@
import type React from "react"
/**
* 根布局组件
* 这是最外层的布局,实际内容由 [locale]/layout.tsx 处理
* Root layout component
* This is the outermost layout, actual content is handled by [locale]/layout.tsx
*/
export default function RootLayout({
children,

View File

@@ -1,31 +1,31 @@
"use client" // 标记为客户端组件,可以使用浏览器 API 和交互功能
"use client" // Mark as client component, can use browser APIs and interactive features
// 导入 React
// Import React library
import type * as React from "react"
// 导入 Tabler Icons 图标库中的各种图标
// Import various icons from Tabler Icons library
import {
IconDashboard, // 仪表板图标
IconHelp, // 帮助图标
IconListDetails, // 列表详情图标
IconSettings, // 设置图标
IconUsers, // 用户图标
IconChevronRight, // 右箭头图标
IconRadar, // 雷达扫描图标
IconTool, // 工具图标
IconServer, // 服务器图标
IconTerminal2, // 终端图标
IconBug, // 漏洞图标
IconDashboard, // Dashboard icon
IconHelp, // Help icon
IconListDetails, // List details icon
IconSettings, // Settings icon
IconUsers, // Users icon
IconChevronRight, // Right arrow icon
IconRadar, // Radar scan icon
IconTool, // Tool icon
IconServer, // Server icon
IconTerminal2, // Terminal icon
IconBug, // Vulnerability icon
} from "@tabler/icons-react"
// 导入国际化 hook
// Import internationalization hook
import { useTranslations } from 'next-intl'
// 导入国际化导航组件
// Import internationalization navigation components
import { Link, usePathname } from '@/i18n/navigation'
// 导入自定义导航组件
// Import custom navigation components
import { NavSystem } from "@/components/nav-system"
import { NavSecondary } from "@/components/nav-secondary"
import { NavUser } from "@/components/nav-user"
// 导入侧边栏 UI 组件
// Import sidebar UI components
import {
Sidebar,
SidebarContent,
@@ -42,7 +42,7 @@ import {
SidebarGroupLabel,
SidebarRail,
} from "@/components/ui/sidebar"
// 导入折叠组件
// Import collapsible component
import {
Collapsible,
CollapsibleContent,
@@ -50,10 +50,10 @@ import {
} from "@/components/ui/collapsible"
/**
* 应用侧边栏组件
* 显示应用的主要导航菜单,包括用户信息、主菜单、文档和次要菜单
* 支持子菜单的展开和折叠功能
* @param props - Sidebar 组件的所有属性
* Application sidebar component
* Displays the main navigation menu of the application, including user info, main menu, documents and secondary menu
* Supports expand and collapse functionality for submenus
* @param props - All properties of the Sidebar component
*/
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const t = useTranslations('navigation')
@@ -61,14 +61,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const normalize = (p: string) => (p !== "/" && p.endsWith("/") ? p.slice(0, -1) : p)
const current = normalize(pathname)
// 用户信息
// User information
const user = {
name: "admin",
email: "admin@admin.com",
avatar: "",
}
// 主导航菜单项 - 使用翻译
// Main navigation menu items - using translations
const navMain = [
{
title: t('dashboard'),
@@ -130,7 +130,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
},
]
// 次要导航菜单项
// Secondary navigation menu items
const navSecondary = [
{
title: t('help'),
@@ -139,7 +139,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
},
]
// 系统设置相关菜单项
// System settings related menu items
const documents = [
{
name: t('workers'),
@@ -159,9 +159,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
]
return (
// collapsible="icon" 表示侧边栏可以折叠为仅图标模式
// collapsible="icon" means the sidebar can be collapsed to icon-only mode
<Sidebar collapsible="icon" {...props}>
{/* 侧边栏头部 */}
{/* Sidebar header */}
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
@@ -178,9 +178,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarMenu>
</SidebarHeader>
{/* 侧边栏主要内容区域 */}
{/* Sidebar main content area */}
<SidebarContent>
{/* 主导航菜单 */}
{/* Main navigation menu */}
<SidebarGroup>
<SidebarGroupLabel>{t('mainFeatures')}</SidebarGroupLabel>
<SidebarGroupContent>
@@ -245,13 +245,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarGroupContent>
</SidebarGroup>
{/* 系统设置导航菜单 */}
{/* System settings navigation menu */}
<NavSystem items={documents} />
{/* 次要导航菜单,使用 mt-auto 推到底部 */}
{/* Secondary navigation menu, using mt-auto to push to bottom */}
<NavSecondary items={navSecondary} className="mt-auto" />
</SidebarContent>
{/* 侧边栏底部 */}
{/* Sidebar footer */}
<SidebarFooter>
<NavUser user={user} />
</SidebarFooter>

View File

@@ -12,13 +12,25 @@ import { Suspense } from "react"
import { useAuth } from "@/hooks/use-auth"
import { useRouter } from "next/navigation"
// Public routes that don't require authentication
// Public routes that don't require authentication (without locale prefix)
const PUBLIC_ROUTES = ["/login"]
interface AuthLayoutProps {
children: React.ReactNode
}
/**
* Check if the current path is a public route
* Handles internationalized paths like /en/login, /zh/login
*/
function isPublicPath(pathname: string): boolean {
// Remove locale prefix (e.g., /en/login -> /login, /zh/login -> /login)
const pathWithoutLocale = pathname.replace(/^\/[a-z]{2}(?=\/|$)/, '')
return PUBLIC_ROUTES.some((route) =>
pathWithoutLocale === route || pathWithoutLocale.startsWith(`${route}/`)
)
}
/**
* Authentication layout component
* Decides whether to show sidebar based on login status and route
@@ -30,9 +42,7 @@ export function AuthLayout({ children }: AuthLayoutProps) {
const tCommon = useTranslations("common")
// Check if it's a public route (login page)
const isPublicRoute = PUBLIC_ROUTES.some((route) =>
pathname.startsWith(route)
)
const isPublicRoute = isPublicPath(pathname)
// Redirect to login page if not authenticated (useEffect must be before all conditional returns)
React.useEffect(() => {

View File

@@ -12,7 +12,7 @@ import { IconPalette, IconCheck } from "@tabler/icons-react"
import { useTranslations } from "next-intl"
/**
* 颜色主题切换器
* Color theme switcher
*/
export function ColorThemeSwitcher() {
const { theme, setTheme, mounted } = useColorTheme()
@@ -39,12 +39,12 @@ export function ColorThemeSwitcher() {
<DropdownMenuItem
key={t.id}
onClick={() => {
console.log('切换主题到:', t.id)
console.log('Switching theme to:', t.id)
setTheme(t.id as ColorThemeId)
}}
className="flex items-center gap-2"
>
{/* 颜色预览色块 */}
{/* Color preview blocks */}
<div className="flex items-center gap-1">
{t.colors.map((c, i) => (
<span

View File

@@ -309,7 +309,7 @@ export function DashboardDataTable() {
<AlertDialogCancel>{t('common.actions.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={confirmStop}
className="bg-chart-2 text-white hover:bg-chart-2/90"
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{t('scan.stopScan')}
</AlertDialogAction>

View File

@@ -44,12 +44,12 @@ export function DashboardScheduledScans() {
},
cron: {
everyMinute: tScan("cron.everyMinute"),
everyNMinutes: tScan("cron.everyNMinutes"),
everyHour: tScan("cron.everyHour"),
everyNHours: tScan("cron.everyNHours"),
everyDay: tScan("cron.everyDay"),
everyWeek: tScan("cron.everyWeek"),
everyMonth: tScan("cron.everyMonth"),
everyNMinutes: tScan.raw("cron.everyNMinutes") as string,
everyHour: tScan.raw("cron.everyHour") as string,
everyNHours: tScan.raw("cron.everyNHours") as string,
everyDay: tScan.raw("cron.everyDay") as string,
everyWeek: tScan.raw("cron.everyWeek") as string,
everyMonth: tScan.raw("cron.everyMonth") as string,
weekdays: tScan.raw("cron.weekdays") as string[],
},
}), [tColumns, tCommon, tScan])

View File

@@ -8,7 +8,7 @@ import { DataTableColumnHeader } from "@/components/ui/data-table/column-header"
import { ExpandableCell } from "@/components/ui/data-table/expandable-cell"
import type { Directory } from "@/types/directory.types"
// 翻译类型定义
// Translation type definitions
export interface DirectoryTranslations {
columns: {
url: string
@@ -32,7 +32,7 @@ interface CreateColumnsProps {
}
/**
* HTTP 状态码徽章组件
* HTTP status code badge component
*/
function StatusBadge({ status }: { status: number | null }) {
if (!status) return <span className="text-muted-foreground">-</span>
@@ -57,7 +57,7 @@ function StatusBadge({ status }: { status: number | null }) {
}
/**
* 格式化持续时间(纳秒转毫秒)
* Format duration (nanoseconds to milliseconds)
*/
function formatDuration(nanoseconds: number | null): string {
if (nanoseconds === null) return "-"
@@ -66,7 +66,7 @@ function formatDuration(nanoseconds: number | null): string {
}
/**
* 创建目录表格列定义
* Create directory table column definitions
*/
export function createDirectoryColumns({
formatDate,

View File

@@ -9,13 +9,13 @@ import type { Directory } from "@/types/directory.types"
import type { PaginationInfo } from "@/types/common.types"
import type { DownloadOption } from "@/types/data-table.types"
// 目录页面的过滤字段配置
// Directory page filter field configuration
const DIRECTORY_FILTER_FIELDS: FilterField[] = [
{ key: "url", label: "URL", description: "Directory URL" },
{ key: "status", label: "Status", description: "HTTP status code" },
]
// 目录页面的示例
// Directory page filter examples
const DIRECTORY_FILTER_EXAMPLES = [
'url="/admin" && status="200"',
'url="/api/*" || url="/config/*"',
@@ -25,7 +25,7 @@ const DIRECTORY_FILTER_EXAMPLES = [
interface DirectoriesDataTableProps {
data: Directory[]
columns: ColumnDef<Directory>[]
// 智能过滤
// Smart filter
filterValue?: string
onFilterChange?: (value: string) => void
isSearching?: boolean
@@ -35,7 +35,7 @@ interface DirectoriesDataTableProps {
onPaginationChange?: (pagination: { pageIndex: number; pageSize: number }) => void
onBulkDelete?: () => void
onSelectionChange?: (selectedRows: Directory[]) => void
// 下载回调函数
// Download callback functions
onDownloadAll?: () => void
onDownloadSelected?: () => void
onBulkAdd?: () => void
@@ -62,32 +62,32 @@ export function DirectoriesDataTable({
const tDownload = useTranslations("common.download")
const [selectedRows, setSelectedRows] = React.useState<Directory[]>([])
// 处理智能过滤搜索
// Handle smart filter search
const handleSmartSearch = (rawQuery: string) => {
if (onFilterChange) {
onFilterChange(rawQuery)
}
}
// 处理选中行变化
// Handle selection change
const handleSelectionChange = (rows: Directory[]) => {
setSelectedRows(rows)
onSelectionChange?.(rows)
}
// 下载选项
// Download options
const downloadOptions: DownloadOption[] = []
if (onDownloadAll) {
downloadOptions.push({
key: "all",
label: tDownload("allDirectories"),
label: tDownload("all"),
onClick: onDownloadAll,
})
}
if (onDownloadSelected) {
downloadOptions.push({
key: "selected",
label: tDownload("selectedDirectories"),
label: tDownload("selected"),
onClick: onDownloadSelected,
disabled: (count) => count === 0,
})
@@ -98,30 +98,30 @@ export function DirectoriesDataTable({
data={data}
columns={columns}
getRowId={(row) => String(row.id)}
// 分页
// Pagination
pagination={pagination}
setPagination={setPagination}
paginationInfo={paginationInfo}
onPaginationChange={onPaginationChange}
// 智能过滤
// Smart filter
searchMode="smart"
searchValue={filterValue}
onSearch={handleSmartSearch}
isSearching={isSearching}
filterFields={DIRECTORY_FILTER_FIELDS}
filterExamples={DIRECTORY_FILTER_EXAMPLES}
// 选择
// Selection
onSelectionChange={handleSelectionChange}
// 批量操作
// Bulk operations
onBulkDelete={onBulkDelete}
bulkDeleteLabel="Delete"
showAddButton={false}
// 批量添加按钮
// Bulk add button
onBulkAdd={onBulkAdd}
bulkAddLabel={tActions("add")}
// 下载
// Download
downloadOptions={downloadOptions.length > 0 ? downloadOptions : undefined}
// 空状态
// Empty state
emptyMessage={t("noData")}
/>
)

Some files were not shown because too many files have changed in this diff Show More