Compare commits

...

95 Commits

Author SHA1 Message Date
yyhuni
b859fc9062 refactor(modules): 更新模块路径为新的github用户命名空间
- 修改所有import路径,从github.com/orbit/server改为github.com/yyhuni/orbit/server
- 更新go.mod模块名为github.com/yyhuni/orbit/server
- 调整内部引用路径,确保包导入一致性
- 修改.gitignore,新增AGENTS.md和WARP.md忽略规则
- 更新Scan请求中engineNames字段的绑定规则,改为必须且仅能包含一个元素
2026-01-23 18:31:54 +08:00
yyhuni
49b5fbef28 chore(docs): 删除冗余项目文档文件
- 移除了 AGENTS.md 文件,简化项目文档结构
- 移除了 WARP.md 文件,删除重复的操作指南
- 清理文档目录,减少维护负担
- 优化项目根目录内容,提升整体整洁性
2026-01-23 09:39:46 +08:00
yyhuni
11112a68f6 Remove .hypothesis, .DS_Store and log files from version control 2026-01-23 09:31:47 +08:00
yyhuni
9049b096ba Remove .venv and .kiro directories from version control 2026-01-23 09:29:51 +08:00
yyhuni
ca6c0eb082 Remove .kiro directory from version control 2026-01-23 09:28:09 +08:00
yyhuni
64bcd9a6f5 忽略 2026-01-23 09:20:32 +08:00
yyhuni
443e2172e4 忽略ai文件 2026-01-23 09:17:26 +08:00
yyhuni
c6dcfb0a5b Remove specs directory from version control 2026-01-23 09:14:46 +08:00
yyhuni
25ae325c69 Remove AI assistant directories from version
control
2026-01-23 09:12:21 +08:00
yyhuni
cab83d89cf chore(.agent,.gemini,.github): remove duplicate vercel-react-best-practices skills
- Remove vercel-react-best-practices skill directory from .agent/skills
- Remove vercel-react-best-practices skill directory from .gemini/skills
- Remove vercel-react-best-practices skill directory from .github/skills
- Eliminate redundant skill definitions across multiple agent configurations
- Consolidate skill management to reduce maintenance overhead
2026-01-22 22:46:59 +08:00
yyhuni
0f8fff2dc4 chore(.claude): reorganize Claude commands and skills structure
- Add speckit command suite (.claude/commands/) for workflow automation
- Reorganize Vercel React best practices skills with improved structure
- Add Hypothesis testing constants database
- Remove .dockerignore and .gitignore from repository
- Add .DS_Store to tracked files
- Consolidate development tooling and AI assistant configuration for improved project workflow
2026-01-22 22:46:31 +08:00
yyhuni
6e48b97dc2 chore(.specify): add project constitution and development workflow scripts
- Add constitution.md template for documenting core principles and governance
- Add check-prerequisites.sh script for unified prerequisite validation
- Add common.sh utility functions for bash scripts
- Add create-new-feature.sh script for feature scaffolding
- Add setup-plan.sh script for implementation planning
- Add update-agent-context.sh script for agent context management
- Add agent-file-template.md for standardized agent documentation
- Add checklist-template.md for task tracking
- Add plan-template.md for implementation planning
- Add spec-template.md for feature specifications
- Add tasks-template.md for task breakdown
- Update scan history components with improved data handling and UI consistency
- Update scan types and mock data for enhanced scan tracking
- Update i18n messages for scan history localization
- Establish standardized development workflow and documentation structure
2026-01-22 08:56:22 +08:00
yyhuni
ed757d6e14 feat(engineschema): add JSON schema validation and migrate subdomain discovery schema
- Add new engineschema package with schema validation utilities for engine configs
- Implement Validate() function to validate config maps against JSON schemas
- Implement ValidateYAML() function to validate YAML blobs with nested engine support
- Add schema caching with mutex synchronization for performance
- Migrate subdomain_discovery.schema.json from server/configs/engines to server/internal/engineschema
- Enhance schema with $id, x-engine, and x-engine-version metadata fields
- Add conditional validation (if/then) for bruteforce tool enabled state
- Add additionalProperties: false constraints to enforce strict schema validation
- Add jsonschema/v5 dependency to server and worker modules
- Update schema-gen tool to generate schemas in new location
- Regenerate subdomain discovery schema with enhanced validation rules
- Update documentation generation timestamp
2026-01-21 22:00:23 +08:00
yyhuni
2aa1afbabf chore(docker): add server Dockerfile and update subdomain discovery paths
- Add new server/Dockerfile for Go backend containerization with multi-stage build
- Update docker-compose.dev.yml to include server service with database and Redis dependencies
- Migrate Sublist3r tool path from /usr/local/share to /opt/orbit-tools/share for consistency
- Add legacy notice to docker/worker/Dockerfile clarifying it's for old Python executor
- Update subdomain discovery documentation with RFC3339 timestamp format
- Update template parsing test to reflect new tool path location
- Consolidate development environment configuration with all required services
2026-01-21 10:38:57 +08:00
yyhuni
35ac64db57 Merge branch 'feature/directory-sorting-demo' into feature/go-backend
Integrate frontend refactoring changes including:
- Dashboard animation optimizations
- Login flow enhancements
- Orbit rebranding updates
2026-01-20 21:24:52 +08:00
yyhuni
b4bfab92e3 fix(doc-gen): update timestamp format to RFC3339 standard
- Change timestamp format from "2006-01-02" to time.RFC3339 constant
- Ensures generated documentation includes full ISO 8601 timestamp with timezone
- Improves consistency with standard time formatting practices
2026-01-20 21:18:53 +08:00
yyhuni
72210c42d0 style(worker): format subdomain discovery constants and reorder tool definitions
- Align constant assignments with consistent spacing for improved readability
- Reorder tool name constants alphabetically for better maintainability
- Move toolSubfinder constant to end of tool definitions list
- Standardize formatting across stage and tool constant declarations
2026-01-20 21:15:08 +08:00
yyhuni
91aaf7997f feat(worker): implement workflow code generation and enhance subdomain discovery
- Add code generation tools (const-gen, doc-gen, schema-gen) to automate workflow metadata and documentation
- Implement config key mapper for dynamic template parameter mapping and validation
- Add comprehensive test coverage for command builder, template loader, and runner components
- Enhance subdomain discovery workflow with recon stage replacing passive stage for better reconnaissance
- Add subdomain result parsing and writing utilities for output handling
- Implement batch sender tests and improve server client reliability
- Add CI workflow to validate generated files are up to date before builds
- Convert YAML engine config to JSON schema for better validation and IDE support
- Add extensive test data fixtures for template validation edge cases
- Update Makefile and development scripts for improved build and test workflows
- Generate auto-documentation for subdomain discovery configuration reference
- Improve code maintainability through automated generation of constants and schemas
2026-01-20 21:09:55 +08:00
yyhuni
32e3179d58 refactor(frontend): optimize dashboard animations and extract dashboard data prefetch logic
- Remove "use client" directive from dashboard page and convert to server component
- Replace manual fade-in animation state with CSS animation class `animate-dashboard-fade-in`
- Extract dashboard data prefetch logic into reusable `prefetchDashboardData` callback in login page
- Parallelize login verification and bundle prefetch operations for faster execution
- Implement dynamic import for Monaco Editor with loading state to reduce bundle size (~2MB)
- Fix dependency array in template content effect to include full `templateContent` object
- Add `tree-node-item` class with `content-visibility: auto` for long list rendering optimization
- Simplify login flow by reusing extracted prefetch function to reduce code duplication
- Improves perceived performance by reducing animation overhead and optimizing bundle loading
2026-01-20 08:42:02 +08:00
yyhuni
487f7c84b5 fix(frontend): add null checks to PixelBlast renderer initialization
- Add renderer null check in setSize function to prevent errors during initialization
- Add renderer validation before composer.setSize call to ensure renderer exists
- Add null check in mapToPixels function to return safe default values when renderer is unavailable
- Add renderer existence check before calling renderer.render in animation loop
- Improve robustness of Three.js renderer lifecycle management to prevent runtime errors
2026-01-20 08:02:01 +08:00
yyhuni
b2cc83f569 feat(frontend): optimize login flow with dashboard data preloading and enhanced animations
- Implement dashboard data prefetching on successful login to eliminate loading delays
- Add blur transition effect to dashboard fade-in animation for smoother visual experience
- Replace login success splash screen logic with efficient data warming strategy
- Prefetch critical dashboard queries (asset statistics, scans, vulnerabilities) before navigation
- Prime auth cache to prevent full-screen loading state on dashboard entry
- Add pixel animation first-frame detection to coordinate boot splash timing
- Refactor login state management to use refs and callbacks for better control flow
- Update dashboard page transition to use will-change optimization for better performance
- Remove hardcoded login success delay constants in favor of data-driven navigation
- Improve user experience by seamlessly transitioning from login to fully-loaded dashboard
2026-01-19 23:49:16 +08:00
yyhuni
f854cf09be feat(frontend): add login success splash screen and dashboard fade-in animation
- Add "use client" directive to dashboard page for client-side state management
- Implement fade-in animation on dashboard page load using opacity transition
- Add success state tracking to login page with configurable delay timers
- Create separate boot screen output for successful authentication flow
- Add success prop to LoginBootScreen component to display auth success messages
- Define new constants for login success delay (1200ms) and fade duration (500ms)
- Update boot screen to conditionally render success or standard boot lines
- Enhance user experience with visual feedback during authentication completion
2026-01-19 22:31:16 +08:00
yyhuni
7e1c2c187a chore(skills): add Vercel React best practices guidelines for agents
- Add comprehensive Vercel React best practices skill documentation across .agent, .codex, and .gemini directories
- Include 50+ rule files covering async patterns, bundle optimization, client-side performance, and server-side rendering
- Add SKILL.md and AGENTS.md metadata files for skill configuration and agent integration
- Organize rules into categories: advanced patterns, async handling, bundle optimization, client optimization, JavaScript performance, rendering optimization, re-render prevention, and server-side caching
- Provide standardized guidelines for performance optimization and best practices across multiple AI agent platforms
2026-01-19 20:14:08 +08:00
yyhuni
4abb259ca0 feat(frontend): rebrand to Orbit and add login boot splash screen
- Replace "Star Patrol ASM Platform" branding with "Orbit ASM Platform" throughout
- Add SVG icon support and remove favicon.ico in favor of icon.svg
- Create new LoginBootScreen component for boot splash animation
- Implement boot phase state management (entering, visible, leaving, hidden)
- Add smooth transitions and animations for login page overlay
- Update metadata icons configuration to use SVG format
- Add glitch reveal animations and jitter effects to globals.css
- Enhance login page UX with minimum splash duration and fade transitions
- Update English and Chinese translations for new branding
- Improve system logs mock data structure
- Update package.json dependencies and configuration
- Ensure splash screen displays before auth check completes and redirect occurs
2026-01-19 11:10:02 +08:00
yyhuni
bbef6af000 fix(frontend): update filter examples to use correct wildcard syntax
- Replace wildcard patterns with asterisks (*) with trailing slash notation
- Update directories filter example from "/api/*" to "/api/"
- Update endpoints filter example from "/api/*" to "/api/"
- Update IP addresses filter example from "192.168.1.*" to "192.168.1."
- Update subdomains filter example from "*.test.com" to ".test.com"
- Update vulnerabilities filter example from "/api/*" to "/api/"
- Update websites filter example from "/api/*" to "/api/"
- Standardize filter syntax across all data table components for consistency
2026-01-18 21:41:30 +08:00
yyhuni
ba0864ed16 feat(target): add help tooltip for directories tab and update translations
- Import HelpCircle icon from lucide-react for help indicator
- Import Tooltip components for displaying contextual help information
- Restructure navigation layout to support help tooltip alongside tabs
- Add conditional tooltip display when directories tab is active
- Add directoriesHelp translation key to English messages
- Add directoriesHelp translation key to Chinese messages
- Improve UX by providing contextual guidance for directories functionality
2026-01-18 10:23:33 +08:00
yyhuni
f54827829a feat(dashboard): add vulnerability review status tracking and severity column
- Add review status indicator (pending/reviewed) to recent vulnerabilities table with visual badges
- Display severity column in vulnerability table for better visibility
- Import Circle and CheckCircle2 icons from lucide-react for status indicators
- Add tooltip translations for "reviewed" and "pending" status labels
- Update mock vulnerability data with isReviewed property for all entries
- Implement conditional styling for pending (blue) and reviewed (muted) status badges
- Enhance table layout to show vulnerability severity alongside review status
2026-01-18 08:58:18 +08:00
yyhuni
170021130c feat(worker): implement subdomain discovery workflow stages with wildcard detection
- Add stage_bruteforce.go with bruteforce subdomain enumeration logic
- Add stage_passive.go with passive reconnaissance stage implementation
- Add stage_merge.go with file merging and wildcard domain detection
- Add stages.go with stage orchestration and utility functions
- Update workflow.go to integrate new stages into discovery pipeline
- Implement wildcard detection with sampling and expansion ratio analysis
- Add deduplication logic during file merging to reduce redundant entries
- Implement parallel command execution for bruteforce operations
- Add wordlist management with local caching from server
- Include comprehensive logging and error handling throughout stages
2026-01-17 23:18:28 +08:00
yyhuni
b540f69152 feat(worker): implement subdomain discovery workflow and enhance validation
- Rename IsSubdomainMatchTarget to IsSubdomainOfTarget for clarity
- Add subdomain discovery workflow with template loader and helpers
- Implement workflow registry for managing scan workflows
- Add domain validator package for input validation
- Create wordlist server component for managing DNS resolver lists
- Add template loader activity for dynamic template management
- Implement worker configuration module with environment setup
- Update worker dependencies to include projectdiscovery/utils and govalidator
- Consolidate workspace directory configuration (WORKSPACE_DIR replaces RESULTS_BASE_PATH)
- Update seed generator to use standardized bulk-create API endpoint
- Update all service layer calls to use renamed validation function
2026-01-17 21:15:02 +08:00
yyhuni
d7f1e04855 chore: add server/.env to .gitignore and remove from git tracking 2026-01-17 08:25:45 +08:00
yyhuni
68ad18e6da 更名orbit 2026-01-16 09:03:20 +08:00
yyhuni
a7542d4a34 改名后端成server 2026-01-15 16:19:00 +08:00
yyhuni
6f02d9f3c5 feat(api): standardize API endpoints and update data generation logic
- Rename IP address endpoints from `/ip-addresses/` to `/host-ports` for consistency
- Update vulnerability endpoints from `/assets/vulnerabilities/` to `/vulnerabilities/`
- Remove trailing slashes from API endpoint paths for standardization
- Remove explicit `type` field from target generation in seed data
- Enhance website generation with deduplication logic and attempt limiting
- Add default admin user seed data to database initialization migration
- Improve data generator to prevent infinite loops and handle unique URL combinations
- Align frontend service calls with updated backend API structure
2026-01-15 13:02:26 +08:00
yyhuni
794846ca7a feat(backend): enhance vulnerability schema and add target validation for snapshots
- Expand vulnerability and vulnerability_snapshot table column sizes for better data handling
* Change url column from VARCHAR(2000) to TEXT for unlimited length
* Increase vuln_type from VARCHAR(100) to VARCHAR(200)
* Increase source from VARCHAR(50) to VARCHAR(100)
- Add input validation constraints to vulnerability DTOs
* Add max=200 binding constraint to VulnType field
* Add max=100 binding constraint to Source field
- Implement consistent target ID validation across snapshot handlers
* Add ErrTargetMismatch error handling in subdomain_snapshot handler
* Add ErrTargetMismatch error handling in website_snapshot handler
* Replace generic error strings with ErrTargetMismatch constant in services
- Improve error handling consistency by using defined error types instead of generic error messages
2026-01-15 12:33:19 +08:00
yyhuni
5eea7b2621 feat(backend): add input validation and default value initialization for models
- Add Content-Type validation in BindJSON to enforce application/json requests
- Implement BeforeCreate hooks for array and JSONB field initialization across models
* Endpoint and EndpointSnapshot: initialize Tech and MatchedGFPatterns arrays
* Scan: initialize EngineIDs, ContainerIDs arrays and StageProgress JSONB
* Vulnerability and VulnerabilitySnapshot: initialize RawOutput JSONB
* Website and WebsiteSnapshot: initialize Tech array
- Add ErrTargetMismatch error handling in snapshot handlers
* DirectorySnapshot, HostPortSnapshot, ScreenshotSnapshot handlers now validate targetId matches scan's target
- Enhance target validation in filter and validator packages
- Improve service layer validation for subdomain, website, and host port snapshots
- Prevent null/nil values in database by ensuring proper default initialization
2026-01-15 12:21:35 +08:00
yyhuni
069527a7f1 feat(backend): implement vulnerability and screenshot snapshot APIs with directories tab reorganization
- Add vulnerability snapshot DTO, handler, repository, and service layer with comprehensive test coverage
- Add screenshot snapshot DTO, handler, repository, and service layer for snapshot management
- Reorganize directories tab from secondary assets navigation to primary navigation in scan history and target layouts
- Update frontend navigation to include FolderSearch icon for directories tab with badge count display
- Add i18n translations for directories tab in English and Chinese messages
- Implement seed data generation tools with Python API client for testing and data population
- Add data generator, error handler, and progress tracking utilities for seed API
- Update target validator to support new snapshot-related validations
- Refactor organization and vulnerability handlers to support snapshot operations
- Add integration tests and property-based tests for vulnerability snapshot functionality
- Update Go module dependencies to support new snapshot features
2026-01-15 10:25:34 +08:00
yyhuni
e542633ad3 refactor(backend): consolidate migration files and restructure host port entities
- Remove seed data generation command (cmd/seed/main.go)
- Consolidate database migrations into single init schema file
- Rename ip_address DTO to host_port for consistency
- Add host_port_snapshot DTO and model for snapshot tracking
- Rename host_port handler and repository files for clarity
- Implement host_port_snapshot service layer with CRUD operations
- Update website_snapshot service to work with new host_port structure
- Enhance terminal login UI with focus state tracking and Tab key navigation
- Update docker-compose configuration for development environment
- Refactor directory and website snapshot DTOs for improved data structure
- Add comprehensive test coverage for model and handler changes
- Simplify database schema by consolidating related migrations into single initialization file
2026-01-14 18:04:16 +08:00
yyhuni
e8a9606d3b 优化性能 2026-01-14 16:41:35 +08:00
yyhuni
dc2e1e027d 完成endpoint website subdomain directory快照表api 2026-01-14 16:38:20 +08:00
yyhuni
b1847faa3a feat(frontend): add throttled ripple effect on mouse move to PixelBlast
- Add enableRipples prop to PixelBlast component for conditional ripple control
- Implement throttled ripple triggering on pointer move events (150ms interval)
- Remove separate pointerdown event listener and consolidate ripple logic
- Refactor onPointerMove to handle both ripple effects and liquid touch separately
- Improve performance by preventing excessive ripple calculations on rapid movements
2026-01-14 11:42:12 +08:00
yyhuni
e699842492 perf(frontend): optimize login page and animations with memoization and accessibility
- Memoize translations object in login page to prevent unnecessary re-renders
- Add support for prefers-reduced-motion media query in PixelBlast component
- Implement IntersectionObserver and Page Visibility API for intelligent animation pausing
- Limit device pixel ratio based on device type (mobile vs desktop) for better performance
- Add maxPixelRatio parameter to PixelBlast for fine-grained performance control
- Add autoPlay prop to Shuffle component for flexible animation control
- Disable autoPlay on Shuffle text animations in terminal login for better UX
- Add accessibility label to PixelBlast container when reduced motion is enabled
- Improve mobile performance by capping pixel ratio to 1.5 on mobile devices
- Respect user accessibility preferences while maintaining visual quality on desktop
2026-01-14 11:33:11 +08:00
yyhuni
08a4807bef feat(frontend): enhance terminal login UI with improved styling and i18n shortcuts
- Update PixelBlast animation with increased pixel size (6.5) and speed (0.35)
- Replace semantic color tokens with explicit zinc color palette for better visual consistency
- Add keyboard shortcuts translations to support multiple languages (en, zh)
- Implement i18n for all terminal UI labels: submit, cancel, clear, start/end actions
- Update terminal header and content styling with zinc-700 borders and zinc-100 text
- Enhance keyboard shortcuts hint display with localized action labels
- Improve text color hierarchy using zinc-400, zinc-500, and zinc-600 variants
2026-01-14 10:58:12 +08:00
yyhuni
191ff9837b feat(frontend): redesign login page with terminal UI and pixel blast animation
- Replace traditional card-based login form with immersive terminal-style interface
- Add PixelBlast animated background component for cyberpunk aesthetic
- Implement TerminalLogin component with typewriter and terminal effects
- Add new animation components: FaultyTerminal, PixelBlast, Shuffle with CSS modules
- Add gravity-stars background animation component from animate-ui
- Add terminal cursor blink animation to global styles
- Update login page translations to support terminal UI prompts and messages
- Replace Lottie animation with dynamic WebGL-based PixelBlast component
- Add dynamic imports to prevent SSR issues with WebGL rendering
- Update component registry to include @magicui and @react-bits registries
- Refactor login form state management to use async/await pattern
- Add fingerprint meta tag for search engine identification (FOFA/Shodan)
- Improve visual hierarchy with relative z-index layering for background and content
2026-01-14 10:48:41 +08:00
yyhuni
679dff9037 refactor(frontend): unify filter UI components and enhance smart filtering
- Replace DropdownMenu with Select component for severity filtering across data tables
- Add Filter icon from lucide-react to filter triggers for consistent visual design
- Update SelectTrigger width from fixed pixels to auto for responsive layout
- Integrate SmartFilterInput component into vulnerabilities data table
- Refactor severity filter options to use object structure with translated labels
- Consolidate filter UI patterns across organization targets, scan history, and vulnerabilities tables
- Register @animate-ui component registry in components.json
- Improve filter UX with consistent icon usage and flexible sizing
2026-01-14 09:51:35 +08:00
yyhuni
ce4330b628 refactor(frontend): centralize severity styling configuration
- Extract severity color and style definitions into dedicated severity-config module
- Create SEVERITY_STYLES constant with unified badge styling for all severity levels
- Create SEVERITY_COLORS constant for chart visualization consistency
- Add getSeverityStyle() helper function for dynamic severity badge generation
- Add SEVERITY_CARD_STYLES and SEVERITY_ICON_BG constants for notification styling
- Update dashboard components to use centralized severity configuration
- Update fingerprint columns to use getSeverityStyle() helper
- Update notification drawer to reference centralized severity styles
- Update search result cards to use centralized configuration
- Update vulnerability components to import from severity-config module
- Eliminate duplicate severity styling definitions across multiple components
- Improve maintainability by having single source of truth for severity styling
2026-01-14 09:05:14 +08:00
yyhuni
4ce6b148f8 feat(frontend): enhance vulnerability review status display with icons
- Add Circle and CheckCircle2 icons from lucide-react for visual status indicators
- Update reviewStatus column sizing (100px size, 90-110px range) for better layout
- Implement icon rendering: Circle for pending status, CheckCircle2 for reviewed
- Enhance Badge styling with improved hover states and ring effects
- Add gap spacing between icon and text in status badge
- Refactor status logic to use isPending variable for clearer code
- Update Chinese translations for review action labels to be more descriptive
- Improve visual feedback with conditional styling based on review status
- Maintain cursor pointer behavior only when onToggleReview callback is available
2026-01-14 08:43:47 +08:00
yyhuni
a89f775ee9 完成漏洞的review,scan的基本curd 2026-01-14 08:21:46 +08:00
yyhuni
e3003f33f9 完成漏洞的review,scan的基本curd 2026-01-14 08:21:34 +08:00
yyhuni
3760684b64 feat: add vulnerability review status feature
- Add is_reviewed and reviewed_at fields to vulnerability table
- Add PATCH /api/vulnerabilities/:id/review and /unreview endpoints
- Add POST /api/vulnerabilities/bulk-review and /bulk-unreview endpoints
- Add isReviewed filter parameter to list APIs
- Update frontend with review status indicator, filter tabs, and bulk actions
- Add i18n translations for review status
2026-01-13 19:53:12 +08:00
yyhuni
bfd7e11d09 perf(backend): optimize database seeding with batch inserts
- Replace individual Create() calls with CreateInBatches() for organizations, targets, and websites to reduce database round trips
- Build all records in memory before batch insertion instead of inserting one-by-one
- Implement chunked batch insert for organization-target links to handle large datasets efficiently
- Add ON CONFLICT DO NOTHING clause for website creation to handle duplicates gracefully
- Use strings.Join() for efficient SQL query construction in bulk insert operations
- Improve seeding performance by reducing database transactions from O(n) to O(n/batch_size)
- Add missing imports (strings, clause) required for batch operations
2026-01-13 18:57:18 +08:00
yyhuni
f758feb0d0 完善漏洞api 2026-01-13 18:46:43 +08:00
yyhuni
8798eed337 feat(backend,frontend): implement wordlist management and engine patch endpoint
- Add wordlist management system with create, list, delete, and content operations
- Implement wordlist repository, service, and handler layers
- Add wordlist DTO models for API requests and responses
- Create wordlist storage configuration with base path setting
- Add PATCH endpoint for partial engine updates alongside existing PUT endpoint
- Implement PatchEngineRequest DTO for optional field updates
- Add wordlist routes: POST/GET/DELETE for management, GET/PUT for content operations
- Remove redundant toast notifications from engine edit dialog (handled by hook)
- Configure storage settings in application config with environment variable support
- Initialize wordlist service and handler in main server setup
2026-01-13 18:03:36 +08:00
yyhuni
bd1e25cfd5 添加截图创建校验 2026-01-13 17:42:19 +08:00
yyhuni
d775055572 完成截图api 2026-01-13 17:35:57 +08:00
yyhuni
00dfad60b8 完成target资产统计count 2026-01-13 16:55:37 +08:00
yyhuni
a5c48fe4d4 feat(frontend,backend): implement IP address management and export functionality
- Add IP address DTO, handler, service, and repository layers in Go backend
- Implement IP address bulk delete endpoint at /ip-addresses/bulk-delete/
- Add IP address export endpoint with optional IP filtering by target
- Simplify IP address hosts column display using ExpandableCell component
- Update IP address export to support filtering selected IPs for download
- Add error handling and toast notifications for export operations
- Internationalize IP address column labels and tooltips in Chinese
- Update IP address service to support filtered exports with comma-separated IPs
- Add host-port mapping seeding for test data generation
- Refactor scope filter and repository queries to support IP address operations
2026-01-13 16:42:57 +08:00
yyhuni
85c880731c feat(frontend): internationalize data table and website columns
- Add Chinese translations for common column labels (name, description, status, actions, type)
- Translate vulnerability column headers (severity, source, vulnType, url, createdAt)
- Translate organization and target column headers to Chinese
- Translate subdomain and endpoint column headers with full Chinese localization
- Add comprehensive website column translations including statusCode, technologies, contentLength
- Translate directory and scheduledScan column headers to Chinese
- Update UnifiedDataTable to use i18n for "Columns" button text via tDataTable("showColumns")
- Fix websites-view to use correct translation key "website.statusCode" instead of "common.status"
- Ensure consistent terminology across all data table views for better user experience
2026-01-13 10:16:43 +08:00
yyhuni
c6b6507412 feat(frontend): internationalize tab labels in scan history and target layouts
- Replace hardcoded tab labels with i18n translation keys in scan history layout
- "Websites" → {t("tabs.websites")}
- "Subdomains" → {t("tabs.subdomains")}
- "IPs" → {t("tabs.ips")}
- "URLs" → {t("tabs.urls")}
- "Directories" → {t("tabs.directories")}
- Replace hardcoded tab labels with i18n translation keys in target layout
- Apply same translation key replacements across all tab triggers
- Add new tab translation keys to English messages (en.json)
- tabs.websites, tabs.subdomains, tabs.ips, tabs.urls, tabs.directories
- Add new tab translation keys to Chinese messages (zh.json)
- Standardize terminology: "网站" → "站点", "端点" → "URL"
- Update related dashboard and stat card translations for consistency
- Ensures consistent multilingual support across scan history and target management interfaces
2026-01-13 09:58:34 +08:00
yyhuni
af457dc44c feat(frontend,backend): implement directory, endpoint, and subdomain management APIs
- Remove words, lines, and duration fields from directory model and UI components
- Simplify directory columns by removing unnecessary metrics from table display
- Add directory, endpoint, and subdomain DTOs with proper validation and pagination
- Implement handlers for directory, endpoint, and subdomain CRUD operations
- Create repository layer for directory, endpoint, and subdomain data access
- Add service layer for directory, endpoint, and subdomain business logic
- Update API routes to use standalone endpoints (/directories, /endpoints, /subdomains)
- Fix subdomain bulk-create payload to use 'names' field instead of 'subdomains'
- Add database migration to drop unused directory_words and directory_lines tables
- Update seed data generation to support websites, endpoints, and directories per target
- Add target validator tests for improved test coverage
- Refactor subdomain service to support new API structure
2026-01-13 09:47:34 +08:00
yyhuni
9e01a6aa5e fix(frontend,backend): move bulk-delete endpoint to standalone websites route
- Move bulk-delete endpoint from `/targets/:id/websites/bulk-delete` to `/websites/bulk-delete`
- Update frontend WebsiteService to use new standalone endpoint path
- Update Go backend router configuration to register bulk-delete under standalone websites routes
- Update handler documentation to reflect correct endpoint path
- Simplifies API structure by treating bulk operations as standalone website operations rather than target-scoped
2026-01-12 22:16:34 +08:00
yyhuni
ed80772e6f feat(frontend,backend): implement website management and i18n for bulk operations
- Add website service layer with CRUD operations and filtering support
- Implement website handler with complete API endpoints
- Add website repository with database operations and query optimization
- Create website DTO for API request/response serialization
- Implement CSV export functionality for asset data
- Add scope filtering package for dynamic query building with tests
- Create database migrations for schema initialization and GIN indexes
- Migrate bulk add dialog to use i18n translations instead of hardcoded strings
- Update all frontend hooks to support pagination and filtering parameters
- Refactor organization and target services with improved error handling
- Add seed command for database initialization with sample data
- Update frontend messages (en.json, zh.json) with bulk operation translations
- Improve API client with better error handling and request formatting
- Add database migration runner to backend initialization
- Update go.mod and go.sum with new dependencies
2026-01-12 22:10:08 +08:00
yyhuni
a22af21dcb feat(frontend,backend): optimize data fetching and add database seeding
- Add database seeding utility (cmd/seed/main.go) to generate test data for organizations and targets
- Implement conditional query execution in useTargets hook with enabled option to prevent unnecessary requests
- Reduce page size from 50 to 20 in scheduled scan dialog for better performance
- Update target DTO and handler to support improved query filtering
- Enhance target repository with optimized database queries
- Replace generic "Add" button text with localized "Add Target" text in target views
- Remove redundant addButtonText prop from organization detail view
- Improve code formatting and add explanatory comments for data fetching logic
- These changes reduce unnecessary API calls on page load and provide better test data management for development
2026-01-12 18:43:16 +08:00
yyhuni
8de950a7a5 feat(organization): refactor target creation flow and fix target count queries
- Replace useBatchCreateTargets hook with direct service call in AddOrganizationDialog to avoid double toast notifications
- Simplify dialog state management by using isCreatingTargets boolean instead of mutation pending state
- Consolidate form reset and dialog close logic to execute after both organization and targets are created
- Fix target count queries in OrganizationRepository to exclude soft-deleted targets using INNER JOIN with deleted_at check
- Update FindByIDWithCount and FindAll methods to properly filter out deleted targets from count calculations
- Handle 204 No Content responses in batchCreateTargets service by returning default success response
2026-01-12 18:17:44 +08:00
yyhuni
9db84221e9 完成部分组织,目标相关后端api
前端改名项目为星巡
2026-01-12 17:59:37 +08:00
yyhuni
0728f3c01d feat(go-backend): add database auto-migration and fix Website model naming
- Add comprehensive database auto-migration in main.go with all models organized by dependency order
- Include core models (Organization, User, Target, ScanEngine, WorkerNode, etc.)
- Include scan-related models (Scan, ScanInputTarget, ScanLog, ScheduledScan)
- Include asset models (Subdomain, HostPortMapping, Website, Endpoint, Directory, Screenshot, Vulnerability)
- Include snapshot models for all asset types
- Include statistics and authentication models
- Rename WebSite struct to Website for consistency with Go naming conventions
- Update TableName method to reflect Website naming
- Add migration logging for debugging and monitoring purposes
2026-01-11 22:30:36 +08:00
yyhuni
4aa7b3d68a feat(go-backend): implement complete API layer with handlers, services, and repositories
- Add DTOs for user, organization, target, engine, pagination, and response handling
- Implement repository layer for user, organization, target, and engine entities
- Implement service layer with business logic for all core modules
- Implement HTTP handlers for user, organization, target, and engine endpoints
- Add complete CRUD API routes with soft delete support for organizations and targets
- Add environment configuration file with database, Redis, and logging settings
- Add docker-compose.dev.yml for PostgreSQL and Redis development dependencies
- Add comprehensive README.md with migration progress, API endpoints, and tech stack
- Update main.go to wire repositories, services, and handlers with dependency injection
- Update config.go to support .env file loading with environment variable priority
- Update database.go to initialize all repositories and services
2026-01-11 22:07:27 +08:00
yyhuni
3946a53337 refactor(go-backend): switch from Django pbkdf2 to bcrypt
- Simplify password.go to use bcrypt (standard Go approach)
- Remove Django password compatibility (not needed for fresh deployment)
- Update auth_handler to use VerifyPassword()
- All tests passing
2026-01-11 20:58:53 +08:00
yyhuni
c94fe1ec4b feat(go-backend): implement JWT authentication
- Add JWT token generation and validation (internal/auth/jwt.go)
- Add Django-compatible password verification (internal/auth/password.go)
- Add auth middleware for protected routes (internal/middleware/auth.go)
- Add auth handler with login, refresh, me endpoints (internal/handler/auth_handler.go)
- Add JWT config (secret, access/refresh expire times)
- Register auth routes in main.go
- All tests passing

API endpoints:
- POST /api/auth/login - User login
- POST /api/auth/refresh - Refresh access token
- GET /api/auth/me - Get current user (protected)
2026-01-11 20:55:59 +08:00
yyhuni
6dea525527 feat(go-backend): add indexes and unique constraints to all models
- Add index tags for query optimization (idx_xxx)
- Add uniqueIndex tags for unique constraints
- Add composite unique indexes (e.g., unique_subdomain_name_target)
- Update Organization/Target to many-to-many relationship
- All models now ready for GORM AutoMigrate
- All tests passing
2026-01-11 20:47:25 +08:00
yyhuni
5b0416972a feat(go-backend): complete all Go models
- Add scan-related models: ScanLog, ScanInputTarget, ScheduledScan, SubfinderProviderSettings
- Add engine models: Wordlist, NucleiTemplateRepo
- Add notification models: Notification, NotificationSettings
- Add config model: BlacklistRule
- Add statistics models: AssetStatistics, StatisticsHistory
- Add auth models: User (auth_user), Session (django_session)
- Add shopspring/decimal dependency for Vulnerability model
- Update model_test.go with all 33 model table name tests
- All tests passing
2026-01-11 20:29:11 +08:00
yyhuni
5345a34cbd 重构:去除prefect 2026-01-11 19:31:47 +08:00
github-actions[bot]
3ca56abc3e chore: bump version to v1.5.12-dev 2026-01-11 09:22:30 +00:00
yyhuni
9703add22d feat(nuclei): support configurable Nuclei templates repository with Gitee mirror
- Add NUCLEI_TEMPLATES_REPO_URL setting to allow runtime configuration of template repository URL
- Refactor install.sh mirror parameter handling to use boolean flag instead of URL string
- Replace hardcoded GitHub repository URL with Gitee mirror option for faster downloads in mainland China
- Update environment variable configuration to persist Nuclei repository URL in .env file
- Improve shell script variable quoting and conditional syntax for better reliability
- Simplify mirror detection logic by using USE_MIRROR boolean flag throughout installation process
- Add support for automatic Gitee mirror selection when --mirror flag is enabled
2026-01-11 17:19:09 +08:00
github-actions[bot]
f5a489e2d6 chore: bump version to v1.5.11-dev 2026-01-11 08:54:04 +00:00
yyhuni
d75a3f6882 fix(task_distributor): adjust high load wait parameters and improve timeout handling
- Increase high load wait interval from 60 to 120 seconds (2 minutes)
- Increase max retries from 10 to 60 to support up to 2 hours total wait time
- Improve timeout message to show actual wait duration in minutes
- Remove duplicate return statement in worker selection logic
- Update notification message to reflect new wait parameters (2 minutes check interval, 2 hours max wait)
- Clean up trailing whitespace in task_distributor.py
- Remove redundant error message from install.sh about missing/incorrect image versions
- Better handling of high load scenarios with clearer logging and user communication
2026-01-11 16:41:05 +08:00
github-actions[bot]
59e48e5b15 chore: bump version to v1.5.10-dev 2026-01-11 08:19:39 +00:00
yyhuni
2d2ec93626 perf(screenshot): optimize memory usage and add URL collection fallback logic
- Add iterator(chunk_size=50) to ScreenshotSnapshot query to prevent BinaryField data caching and reduce memory consumption
- Implement fallback logic in URL collection: WebSite → HostPortMapping → Default URL with priority handling
- Update _collect_urls_from_provider to return tuple with data source information for better logging and debugging
- Add detailed logging to track which data source was used during URL collection
- Improve code documentation with clear return type hints and fallback priority explanation
- Prevents memory spikes when processing large screenshot datasets with binary image data
2026-01-11 16:14:56 +08:00
github-actions[bot]
ced9f811f4 chore: bump version to v1.5.8-dev 2026-01-11 08:09:37 +00:00
yyhuni
aa99b26f50 fix(vuln_scan): use tool-specific parameter names for endpoint scanning
- Add conditional logic to use "input_file" parameter for nuclei tool
- Use "endpoints_file" parameter for other scanning tools
- Improve compatibility with different vulnerability scanning tools
- Ensure correct parameter naming based on tool requirements
2026-01-11 15:59:39 +08:00
yyhuni
8342f196db nuclei加入website扫描为默认 2026-01-11 12:13:27 +08:00
yyhuni
1bd2a6ed88 重构:完成provider 2026-01-11 11:15:59 +08:00
yyhuni
033ff89aee 重构:采用provider提供数据 2026-01-11 10:29:27 +08:00
yyhuni
4284a0cd9a refactor(scan): remove deprecated provider implementations and cleanup
- Delete ListTargetProvider implementation and related tests
- Delete PipelineTargetProvider implementation and related tests
- Remove target_export_service.py unused service module
- Remove test files for common properties validation
- Update engine-preset-selector component in frontend
- Remove sponsor acknowledgment section from README
- Simplify provider architecture by consolidating implementations
2026-01-10 23:53:52 +08:00
yyhuni
943a4cb960 docs(docker): remove default credentials from startup message
- Remove hardcoded default username and password display from docker startup script
- Remove warning message about changing password after first login
- Improve security by not exposing default credentials in startup output
- Simplifies startup message output for cleaner user experience
2026-01-10 11:21:14 +08:00
yyhuni
eb2d853b76 docs: remove emoji symbols from README for better accessibility
- Remove shield emoji (🛡️) from main title
- Replace emoji prefixes in navigation links with plain text anchors
- Remove emoji icons from section headers (🌐, 📚, , 📦, 🤝, 📧, 🎁, , 🙏, ⚠️, 🌟, 📄)
- Replace emoji status indicators (, ⚠️, 🔍, 💡, ) with plain text equivalents
- Remove emoji bullet points and replace with standard formatting
- Simplify documentation for improved readability and cross-platform compatibility
2026-01-10 11:17:43 +08:00
github-actions[bot]
1184c18b74 chore: bump version to v1.5.7 2026-01-10 03:10:45 +00:00
yyhuni
8a6f1b6f24 feat(engine): add --force-sub flag for selective engine config updates
- Add --force-sub command flag to init_default_engine management command
- Allow updating only sub-engines while preserving user-customized full scan config
- Update docker/scripts/init-data.sh to always update full scan engine configuration
- Change docker/server/start.sh to use --force flag for initial engine setup
- Improve update.sh with better logging functions and formatted output
- Add color-coded log functions (log_step, log_ok, log_info, log_warn, log_error)
- Enhance update.sh UI with better visual formatting and warning messages
- Refactor error messages and user prompts for improved clarity
- This enables safer upgrades by preserving custom full scan configurations while updating sub-engines
2026-01-10 11:04:42 +08:00
yyhuni
255d505aba refactor(scan): remove deprecated amass engine configurations
- Remove amass_passive engine configuration from subdomain discovery defaults
- Remove amass_active engine configuration from subdomain discovery defaults
- Simplify engine configuration by eliminating unused amass-based scanners
- Streamline the default engine template for better maintainability
2026-01-10 10:51:07 +08:00
github-actions[bot]
d06a9bab1f chore: bump version to v1.5.7-dev 2026-01-10 02:48:21 +00:00
yyhuni
6d5c776bf7 chore: improve version detection and update deployment configuration
- Update version detection to support IMAGE_TAG environment variable for Docker containers
- Add fallback mechanism to check multiple version file paths (/app/VERSION and project root)
- Add IMAGE_TAG environment variable to docker-compose.dev.yml and docker-compose.yml
- Fix frontend access URL in start.sh to include correct port (8083)
- Update upgrade warning message in update.sh to recommend fresh installation with latest code
- Improve robustness of version retrieval with better error handling for missing files
2026-01-10 10:41:36 +08:00
github-actions[bot]
bf058dd67b chore: bump version to v1.5.6-dev 2026-01-10 02:33:15 +00:00
yyhuni
0532d7c8b8 feat(notifications): add WeChat Work (WeChat Enterprise) notification support
- Add wecom notification channel configuration to mock notification settings
- Initialize wecom with disabled state and empty webhook URL by default
- Update notification settings response to include wecom configuration
- Enable WeChat Work as an alternative notification channel alongside Discord
2026-01-10 10:29:33 +08:00
yyhuni
2ee9b5ffa2 更新版本 2026-01-10 10:27:48 +08:00
yyhuni
648a1888d4 增加企业微信 2026-01-10 10:16:01 +08:00
github-actions[bot]
2508268a45 chore: bump version to v1.5.4-dev 2026-01-10 02:10:05 +00:00
796 changed files with 180601 additions and 8581 deletions

View File

@@ -0,0 +1,45 @@
name: Check Generated Files
on:
workflow_call: # 只在被其他 workflow 调用时运行
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: 1.21
- name: Generate files for all workflows
working-directory: worker
run: make generate
- name: Check for differences
run: |
if ! git diff --exit-code; then
echo "❌ Generated files are out of date!"
echo "Please run: cd worker && make generate"
echo ""
echo "Changed files:"
git status --porcelain
echo ""
echo "Diff:"
git diff
exit 1
fi
echo "✅ Generated files are up to date"
- name: Run metadata consistency tests
working-directory: worker
run: make test-metadata
- name: Run all tests
working-directory: worker
run: make test

160
.gitignore vendored
View File

@@ -1,137 +1,51 @@
# ============================
# 操作系统相关文件
# ============================
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Go
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
vendor/
go.work
# ============================
# 前端 (Next.js/Node.js) 相关
# ============================
# 依赖目录
front-back/node_modules/
front-back/.pnpm-store/
# Build artifacts
dist/
build/
bin/
# Next.js 构建产物
front-back/.next/
front-back/out/
front-back/dist/
# 环境变量文件
front-back/.env
front-back/.env.local
front-back/.env.development.local
front-back/.env.test.local
front-back/.env.production.local
# 运行时和缓存
front-back/.turbo/
front-back/.swc/
front-back/.eslintcache
front-back/.tsbuildinfo
# ============================
# 后端 (Python/Django) 相关
# ============================
# Python 虚拟环境
.venv/
venv/
env/
ENV/
# Python 编译文件
*.pyc
*.pyo
*.pyd
__pycache__/
*.py[cod]
*$py.class
# Django 相关
backend/db.sqlite3
backend/db.sqlite3-journal
backend/media/
backend/staticfiles/
backend/.env
backend/.env.local
# Python 测试和覆盖率
.pytest_cache/
.coverage
htmlcov/
*.cover
.hypothesis/
# ============================
# 后端 (Go) 相关
# ============================
# 编译产物
backend/bin/
backend/dist/
backend/*.exe
backend/*.exe~
backend/*.dll
backend/*.so
backend/*.dylib
# 测试相关
backend/*.test
backend/*.out
backend/*.prof
# Go workspace 文件
backend/go.work
backend/go.work.sum
# Go 依赖管理
backend/vendor/
# ============================
# IDE 和编辑器相关
# ============================
# IDE
.vscode/
.idea/
.cursor/
.claude/
.kiro/
.playwright-mcp/
*.swp
*.swo
*~
.DS_Store
# ============================
# Docker 相关
# ============================
docker/.env
docker/.env.local
# SSL 证书和私钥(不应提交)
docker/nginx/ssl/*.pem
docker/nginx/ssl/*.key
docker/nginx/ssl/*.crt
# ============================
# 日志文件和扫描结果
# ============================
# Environment
.env
.env.local
.env.*.local
*.log
logs/
results/
.venv/
# 开发脚本运行时文件(进程 ID 和启动日志)
backend/scripts/dev/.pids/
# Testing
coverage.txt
*.coverprofile
.hypothesis/
# ============================
# 临时文件
# ============================
# Temporary files
*.tmp
tmp/
temp/
.cache/
HGETALL
KEYS
vuln_scan/input_endpoints.txt
open-in-v0
.kiro/
.claude/
.specify/
# AI Assistant directories
codex/
openspec/
specs/
AGENTS.md
WARP.md

4
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"typescript.autoClosingTags": false,
"kiroAgent.configureMCP": "Enabled"
}

340
README.md
View File

@@ -1,340 +0,0 @@
<h1 align="center">XingRin - 星环</h1>
<p align="center">
<b>🛡️ 攻击面管理平台 (ASM) | 自动化资产发现与漏洞扫描系统</b>
</p>
<p align="center">
<a href="https://github.com/yyhuni/xingrin/stargazers"><img src="https://img.shields.io/github/stars/yyhuni/xingrin?style=flat-square&logo=github" alt="GitHub stars"></a>
<a href="https://github.com/yyhuni/xingrin/network/members"><img src="https://img.shields.io/github/forks/yyhuni/xingrin?style=flat-square&logo=github" alt="GitHub forks"></a>
<a href="https://github.com/yyhuni/xingrin/issues"><img src="https://img.shields.io/github/issues/yyhuni/xingrin?style=flat-square&logo=github" alt="GitHub issues"></a>
<a href="https://github.com/yyhuni/xingrin/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-PolyForm%20NC-blue?style=flat-square" alt="License"></a>
</p>
<p align="center">
<a href="#-功能特性">功能特性</a> •
<a href="#-全局资产搜索">资产搜索</a> •
<a href="#-快速开始">快速开始</a> •
<a href="#-文档">文档</a> •
<a href="#-反馈与贡献">反馈与贡献</a>
</p>
<p align="center">
<sub>🔍 关键词: ASM | 攻击面管理 | 漏洞扫描 | 资产发现 | 资产搜索 | Bug Bounty | 渗透测试 | Nuclei | 子域名枚举 | EASM</sub>
</p>
---
## 🌐 在线 Demo
**[https://xingrin.vercel.app/](https://xingrin.vercel.app/)**
> ⚠️ 仅用于 UI 展示,未接入后端数据库
---
<p align="center">
<b>🎨 现代化 UI </b>
</p>
<p align="center">
<img src="docs/screenshots/light.png" alt="Light Mode" width="24%">
<img src="docs/screenshots/bubblegum.png" alt="Bubblegum" width="24%">
<img src="docs/screenshots/cosmic-night.png" alt="Cosmic Night" width="24%">
<img src="docs/screenshots/quantum-rose.png" alt="Quantum Rose" width="24%">
</p>
## 📚 文档
- [📖 技术文档](./docs/README.md) - 技术文档导航(🚧 持续完善中)
- [🚀 快速开始](./docs/quick-start.md) - 一键安装和部署指南
- [🔄 版本管理](./docs/version-management.md) - Git Tag 驱动的自动化版本管理系统
- [📦 Nuclei 模板架构](./docs/nuclei-template-architecture.md) - 模板仓库的存储与同步
- [📖 字典文件架构](./docs/wordlist-architecture.md) - 字典文件的存储与同步
- [🔍 扫描流程架构](./docs/scan-flow-architecture.md) - 完整扫描流程与工具编排
---
## ✨ 功能特性
### 扫描能力
| 功能 | 状态 | 工具 | 说明 |
|------|------|------|------|
| 子域名扫描 | ✅ | Subfinder, Amass, PureDNS | 被动收集 + 主动爆破,聚合 50+ 数据源 |
| 端口扫描 | ✅ | Naabu | 自定义端口范围 |
| 站点发现 | ✅ | HTTPX | HTTP 探测,自动获取标题、状态码、技术栈 |
| 指纹识别 | ✅ | XingFinger | 2.7W+ 指纹规则,多源指纹库 |
| URL 收集 | ✅ | Waymore, Katana | 历史数据 + 主动爬取 |
| 目录扫描 | ✅ | FFUF | 高速爆破,智能字典 |
| 漏洞扫描 | ✅ | Nuclei, Dalfox | 9000+ POC 模板XSS 检测 |
| 站点截图 | ✅ | Playwright | WebP 高压缩存储 |
### 平台能力
| 功能 | 状态 | 说明 |
|------|------|------|
| 目标管理 | ✅ | 多层级组织,支持域名/IP 目标 |
| 资产快照 | ✅ | 扫描结果对比,追踪资产变化 |
| 黑名单过滤 | ✅ | 全局 + Target 级,支持通配符/CIDR |
| 定时任务 | ✅ | Cron 表达式,自动化周期扫描 |
| 分布式扫描 | ✅ | 多 Worker 节点,负载感知调度 |
| 全局搜索 | ✅ | 表达式语法,多字段组合查询 |
| 通知推送 | ✅ | 企业微信、Telegram、Discord |
| API 密钥管理 | ✅ | 可视化配置各数据源 API Key |
### 扫描流程架构
完整的扫描流程包括子域名发现、端口扫描、站点发现、指纹识别、URL 收集、目录扫描、漏洞扫描等阶段
```mermaid
flowchart LR
START["开始扫描"]
subgraph STAGE1["阶段 1: 资产发现"]
direction TB
SUB["子域名发现<br/>subfinder, amass, puredns"]
PORT["端口扫描<br/>naabu"]
SITE["站点识别<br/>httpx"]
FINGER["指纹识别<br/>xingfinger"]
SUB --> PORT --> SITE --> FINGER
end
subgraph STAGE2["阶段 2: 深度分析"]
direction TB
URL["URL 收集<br/>waymore, katana"]
DIR["目录扫描<br/>ffuf"]
SCREENSHOT["站点截图<br/>playwright"]
end
subgraph STAGE3["阶段 3: 漏洞检测"]
VULN["漏洞扫描<br/>nuclei, dalfox"]
end
FINISH["扫描完成"]
START --> STAGE1
FINGER --> STAGE2
STAGE2 --> STAGE3
STAGE3 --> FINISH
style START fill:#34495e,stroke:#2c3e50,stroke-width:2px,color:#fff
style FINISH fill:#27ae60,stroke:#229954,stroke-width:2px,color:#fff
style STAGE1 fill:#3498db,stroke:#2980b9,stroke-width:2px,color:#fff
style STAGE2 fill:#9b59b6,stroke:#8e44ad,stroke-width:2px,color:#fff
style STAGE3 fill:#e67e22,stroke:#d35400,stroke-width:2px,color:#fff
style SUB fill:#5dade2,stroke:#3498db,stroke-width:1px,color:#fff
style PORT fill:#5dade2,stroke:#3498db,stroke-width:1px,color:#fff
style SITE fill:#5dade2,stroke:#3498db,stroke-width:1px,color:#fff
style FINGER fill:#5dade2,stroke:#3498db,stroke-width:1px,color:#fff
style URL fill:#bb8fce,stroke:#9b59b6,stroke-width:1px,color:#fff
style DIR fill:#bb8fce,stroke:#9b59b6,stroke-width:1px,color:#fff
style SCREENSHOT fill:#bb8fce,stroke:#9b59b6,stroke-width:1px,color:#fff
style VULN fill:#f0b27a,stroke:#e67e22,stroke-width:1px,color:#fff
```
详细说明请查看 [扫描流程架构文档](./docs/scan-flow-architecture.md)
### 🖥️ 分布式架构
- **多节点扫描** - 支持部署多个 Worker 节点,横向扩展扫描能力
- **本地节点** - 零配置,安装即自动注册本地 Docker Worker
- **远程节点** - SSH 一键部署远程 VPS 作为扫描节点
- **负载感知调度** - 实时感知节点负载,自动分发任务到最优节点
- **节点监控** - 实时心跳检测CPU/内存/磁盘状态监控
- **断线重连** - 节点离线自动检测,恢复后自动重新接入
```mermaid
flowchart TB
subgraph MASTER["主服务器 (Master Server)"]
direction TB
REDIS["Redis 负载缓存"]
subgraph SCHEDULER["任务调度器 (Task Distributor)"]
direction TB
SUBMIT["接收扫描任务"]
SELECT["负载感知选择"]
DISPATCH["智能分发"]
SUBMIT --> SELECT
SELECT --> DISPATCH
end
REDIS -.负载数据.-> SELECT
end
subgraph WORKERS["Worker 节点集群"]
direction TB
W1["Worker 1 (本地)<br/>CPU: 45% | MEM: 60%"]
W2["Worker 2 (远程)<br/>CPU: 30% | MEM: 40%"]
W3["Worker N (远程)<br/>CPU: 90% | MEM: 85%"]
end
DISPATCH -->|任务分发| W1
DISPATCH -->|任务分发| W2
DISPATCH -->|高负载跳过| W3
W1 -.心跳上报.-> REDIS
W2 -.心跳上报.-> REDIS
W3 -.心跳上报.-> REDIS
```
### 🔎 全局资产搜索
- **多类型搜索** - 支持 Website 和 Endpoint 两种资产类型
- **表达式语法** - 支持 `=`(模糊)、`==`(精确)、`!=`(不等于)操作符
- **逻辑组合** - 支持 `&&` (AND) 和 `||` (OR) 逻辑组合
- **多字段查询** - 支持 host、url、title、tech、status、body、header 字段
- **CSV 导出** - 流式导出全部搜索结果,无数量限制
#### 搜索语法示例
```bash
# 基础搜索
host="api" # host 包含 "api"
status=="200" # 状态码精确等于 200
tech="nginx" # 技术栈包含 nginx
# 组合搜索
host="api" && status=="200" # host 包含 api 且状态码为 200
tech="vue" || tech="react" # 技术栈包含 vue 或 react
# 复杂查询
host="admin" && tech="php" && status=="200"
url="/api/v1" && status!="404"
```
### 📊 可视化界面
- **数据统计** - 资产/漏洞统计仪表盘
- **实时通知** - WebSocket 消息推送
- **通知推送** - 实时企业微信tgdiscard消息推送服务
---
## 📦 快速开始
### 环境要求
- **操作系统**: Ubuntu 20.04+ / Debian 11+
- **系统架构**: AMD64 (x86_64) / ARM64 (aarch64)
- **硬件**: 2核 4G 内存起步20GB+ 磁盘空间
### 一键安装
```bash
# 克隆项目
git clone https://github.com/yyhuni/xingrin.git
cd xingrin
# 安装并启动(生产模式)
sudo ./install.sh
# 🇨🇳 中国大陆用户推荐使用镜像加速(第三方加速服务可能会失效,不保证长期可用)
sudo ./install.sh --mirror
```
> **💡 --mirror 参数说明**
> - 自动配置 Docker 镜像加速(国内镜像源)
> - 加速 Git 仓库克隆Nuclei 模板等)
### 访问服务
- **Web 界面**: `https://ip:8083`
- **默认账号**: admin / admin首次登录后请修改密码
### 常用命令
```bash
# 启动服务
sudo ./start.sh
# 停止服务
sudo ./stop.sh
# 重启服务
sudo ./restart.sh
# 卸载
sudo ./uninstall.sh
```
## 🤝 反馈与贡献
- 💡 **发现 Bug有新想法比如UI设计功能设计等** 欢迎点击右边链接进行提交建议 [Issue](https://github.com/yyhuni/xingrin/issues) 或者公众号私信
## 📧 联系
- 微信公众号: **塔罗安全学苑**
- 微信群去公众号底下的菜单,有个交流群,点击就可以看到了,链接过期可以私信我拉你
<img src="docs/wechat-qrcode.png" alt="微信公众号" width="200">
### 🎁 关注公众号免费领取指纹库
| 指纹库 | 数量 |
|--------|------|
| ehole.json | 21,977 |
| ARL.yaml | 9,264 |
| goby.json | 7,086 |
| FingerprintHub.json | 3,147 |
> 💡 关注公众号回复「指纹」即可获取
## ☕ 赞助支持
如果这个项目对你有帮助谢谢请我能喝杯蜜雪冰城你的star和赞助是我免费更新的动力
<p>
<img src="docs/wx_pay.jpg" alt="微信支付" width="200">
<img src="docs/zfb_pay.jpg" alt="支付宝" width="200">
</p>
### 🙏 感谢以下赞助
| 昵称 | 金额 |
|------|------|
| X闭关中 | ¥88 |
## ⚠️ 免责声明
**重要:请在使用前仔细阅读**
1. 本工具仅供**授权的安全测试**和**安全研究**使用
2. 使用者必须确保已获得目标系统的**合法授权**
3. **严禁**将本工具用于未经授权的渗透测试或攻击行为
4. 未经授权扫描他人系统属于**违法行为**,可能面临法律责任
5. 开发者**不对任何滥用行为负责**
使用本工具即表示您同意:
- 仅在合法授权范围内使用
- 遵守所在地区的法律法规
- 承担因滥用产生的一切后果
## 🌟 Star History
如果这个项目对你有帮助,请给一个 ⭐ Star 支持一下!
[![Star History Chart](https://api.star-history.com/svg?repos=yyhuni/xingrin&type=Date)](https://star-history.com/#yyhuni/xingrin&Date)
## 📄 许可证
本项目采用 [GNU General Public License v3.0](LICENSE) 许可证。
### 允许的用途
- ✅ 个人学习和研究
- ✅ 商业和非商业使用
- ✅ 修改和分发
- ✅ 专利使用
- ✅ 私人使用
### 义务和限制
- 📋 **开源义务**:分发时必须提供源代码
- 📋 **相同许可**:衍生作品必须使用相同许可证
- 📋 **版权声明**:必须保留原始版权和许可证声明
-**责任免除**:不提供任何担保
- ❌ 未经授权的渗透测试
- ❌ 任何违法行为

View File

@@ -1 +0,0 @@
v1.5.3

32
agent/go.mod Normal file
View File

@@ -0,0 +1,32 @@
module github.com/yyhuni/orbit/agent
go 1.24.5
require (
github.com/docker/docker v28.5.2+incompatible
github.com/gorilla/websocket v1.5.3
github.com/shirou/gopsutil/v3 v3.24.5
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/sys v0.39.0 // indirect
)

78
agent/go.sum Normal file
View File

@@ -0,0 +1,78 @@
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,391 +0,0 @@
"""
指纹识别 Flow
负责编排指纹识别的完整流程
架构:
- Flow 负责编排多个原子 Task
- 在 site_scan 后串行执行
- 使用 xingfinger 工具识别技术栈
- 流式处理输出,批量更新数据库
"""
import logging
from datetime import datetime
from pathlib import Path
from prefect import flow
from apps.scan.handlers.scan_flow_handlers import (
on_scan_flow_completed,
on_scan_flow_failed,
on_scan_flow_running,
)
from apps.scan.tasks.fingerprint_detect import (
export_urls_for_fingerprint_task,
run_xingfinger_and_stream_update_tech_task,
)
from apps.scan.utils import build_scan_command, user_log, wait_for_system_load
from apps.scan.utils.fingerprint_helpers import get_fingerprint_paths
logger = logging.getLogger(__name__)
def calculate_fingerprint_detect_timeout(
url_count: int,
base_per_url: float = 10.0,
min_timeout: int = 300
) -> int:
"""
根据 URL 数量计算超时时间
公式:超时时间 = URL 数量 × 每 URL 基础时间
最小值300秒无上限
Args:
url_count: URL 数量
base_per_url: 每 URL 基础时间(秒),默认 10秒
min_timeout: 最小超时时间(秒),默认 300秒
Returns:
int: 计算出的超时时间(秒)
"""
return max(min_timeout, int(url_count * base_per_url))
def _export_urls(
target_id: int,
fingerprint_dir: Path,
source: str = 'website'
) -> tuple[str, int]:
"""
导出 URL 到文件
Args:
target_id: 目标 ID
fingerprint_dir: 指纹识别目录
source: 数据源类型
Returns:
tuple: (urls_file, total_count)
"""
logger.info("Step 1: 导出 URL 列表 (source=%s)", source)
urls_file = str(fingerprint_dir / 'urls.txt')
export_result = export_urls_for_fingerprint_task(
target_id=target_id,
output_file=urls_file,
source=source,
batch_size=1000
)
total_count = export_result['total_count']
logger.info(
"✓ URL 导出完成 - 文件: %s, 数量: %d",
export_result['output_file'],
total_count
)
return export_result['output_file'], total_count
def _run_fingerprint_detect(
enabled_tools: dict,
urls_file: str,
url_count: int,
fingerprint_dir: Path,
scan_id: int,
target_id: int,
source: str
) -> tuple[dict, list]:
"""
执行指纹识别任务
Args:
enabled_tools: 已启用的工具配置字典
urls_file: URL 文件路径
url_count: URL 总数
fingerprint_dir: 指纹识别目录
scan_id: 扫描任务 ID
target_id: 目标 ID
source: 数据源类型
Returns:
tuple: (tool_stats, failed_tools)
"""
tool_stats = {}
failed_tools = []
for tool_name, tool_config in enabled_tools.items():
# 1. 获取指纹库路径
lib_names = tool_config.get('fingerprint_libs', ['ehole'])
fingerprint_paths = get_fingerprint_paths(lib_names)
if not fingerprint_paths:
reason = f"没有可用的指纹库: {lib_names}"
logger.warning(reason)
failed_tools.append({'tool': tool_name, 'reason': reason})
continue
# 2. 将指纹库路径合并到 tool_config用于命令构建
tool_config_with_paths = {**tool_config, **fingerprint_paths}
# 3. 构建命令
try:
command = build_scan_command(
tool_name=tool_name,
scan_type='fingerprint_detect',
command_params={'urls_file': urls_file},
tool_config=tool_config_with_paths
)
except Exception as e:
reason = f"命令构建失败: {e}"
logger.error("构建 %s 命令失败: %s", tool_name, e)
failed_tools.append({'tool': tool_name, 'reason': reason})
continue
# 4. 计算超时时间
timeout = calculate_fingerprint_detect_timeout(url_count)
# 5. 生成日志文件路径
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
log_file = fingerprint_dir / f"{tool_name}_{timestamp}.log"
logger.info(
"开始执行 %s 指纹识别 - URL数: %d, 超时: %ds, 指纹库: %s",
tool_name, url_count, timeout, list(fingerprint_paths.keys())
)
user_log(scan_id, "fingerprint_detect", f"Running {tool_name}: {command}")
# 6. 执行扫描任务
try:
result = run_xingfinger_and_stream_update_tech_task(
cmd=command,
tool_name=tool_name,
scan_id=scan_id,
target_id=target_id,
source=source,
cwd=str(fingerprint_dir),
timeout=timeout,
log_file=str(log_file),
batch_size=100
)
tool_stats[tool_name] = {
'command': command,
'result': result,
'timeout': timeout,
'fingerprint_libs': list(fingerprint_paths.keys())
}
tool_updated = result.get('updated_count', 0)
logger.info(
"✓ 工具 %s 执行完成 - 处理记录: %d, 更新: %d, 未找到: %d",
tool_name,
result.get('processed_records', 0),
tool_updated,
result.get('not_found_count', 0)
)
user_log(
scan_id, "fingerprint_detect",
f"{tool_name} completed: identified {tool_updated} fingerprints"
)
except Exception as exc:
reason = str(exc)
failed_tools.append({'tool': tool_name, 'reason': reason})
logger.error("工具 %s 执行失败: %s", tool_name, exc, exc_info=True)
user_log(scan_id, "fingerprint_detect", f"{tool_name} failed: {reason}", "error")
if failed_tools:
logger.warning(
"以下指纹识别工具执行失败: %s",
', '.join([f['tool'] for f in failed_tools])
)
return tool_stats, failed_tools
@flow(
name="fingerprint_detect",
log_prints=True,
on_running=[on_scan_flow_running],
on_completion=[on_scan_flow_completed],
on_failure=[on_scan_flow_failed],
)
def fingerprint_detect_flow(
scan_id: int,
target_name: str,
target_id: int,
scan_workspace_dir: str,
enabled_tools: dict
) -> dict:
"""
指纹识别 Flow
主要功能:
1. 从数据库导出目标下所有 WebSite URL 到文件
2. 使用 xingfinger 进行技术栈识别
3. 解析结果并更新 WebSite.tech 字段(合并去重)
工作流程:
Step 0: 创建工作目录
Step 1: 导出 URL 列表
Step 2: 解析配置,获取启用的工具
Step 3: 执行 xingfinger 并解析结果
Args:
scan_id: 扫描任务 ID
target_name: 目标名称
target_id: 目标 ID
scan_workspace_dir: 扫描工作空间目录
enabled_tools: 启用的工具配置xingfinger
Returns:
dict: 扫描结果
"""
try:
# 负载检查:等待系统资源充足
wait_for_system_load(context="fingerprint_detect_flow")
logger.info(
"开始指纹识别 - Scan ID: %s, Target: %s, Workspace: %s",
scan_id, target_name, scan_workspace_dir
)
user_log(scan_id, "fingerprint_detect", "Starting fingerprint detection")
# 参数验证
if scan_id is None:
raise ValueError("scan_id 不能为空")
if not target_name:
raise ValueError("target_name 不能为空")
if target_id is None:
raise ValueError("target_id 不能为空")
if not scan_workspace_dir:
raise ValueError("scan_workspace_dir 不能为空")
# 数据源类型(当前只支持 website
source = 'website'
# Step 0: 创建工作目录
from apps.scan.utils import setup_scan_directory
fingerprint_dir = setup_scan_directory(scan_workspace_dir, 'fingerprint_detect')
# Step 1: 导出 URL支持懒加载
urls_file, url_count = _export_urls(target_id, fingerprint_dir, source)
if url_count == 0:
logger.warning("跳过指纹识别:没有 URL 可扫描 - Scan ID: %s", scan_id)
user_log(scan_id, "fingerprint_detect", "Skipped: no URLs to scan", "warning")
return _build_empty_result(scan_id, target_name, scan_workspace_dir, urls_file)
# Step 2: 工具配置信息
logger.info("Step 2: 工具配置信息")
logger.info("✓ 启用工具: %s", ', '.join(enabled_tools.keys()))
# Step 3: 执行指纹识别
logger.info("Step 3: 执行指纹识别")
tool_stats, failed_tools = _run_fingerprint_detect(
enabled_tools=enabled_tools,
urls_file=urls_file,
url_count=url_count,
fingerprint_dir=fingerprint_dir,
scan_id=scan_id,
target_id=target_id,
source=source
)
# 动态生成已执行的任务列表
executed_tasks = ['export_urls_for_fingerprint']
executed_tasks.extend([f'run_xingfinger ({tool})' for tool in tool_stats])
# 汇总所有工具的结果
total_processed = sum(
stats['result'].get('processed_records', 0) for stats in tool_stats.values()
)
total_updated = sum(
stats['result'].get('updated_count', 0) for stats in tool_stats.values()
)
total_created = sum(
stats['result'].get('created_count', 0) for stats in tool_stats.values()
)
total_snapshots = sum(
stats['result'].get('snapshot_count', 0) for stats in tool_stats.values()
)
# 记录 Flow 完成
logger.info("✓ 指纹识别完成 - 识别指纹: %d", total_updated)
user_log(
scan_id, "fingerprint_detect",
f"fingerprint_detect completed: identified {total_updated} fingerprints"
)
successful_tools = [
name for name in enabled_tools
if name not in [f['tool'] for f in failed_tools]
]
return {
'success': True,
'scan_id': scan_id,
'target': target_name,
'scan_workspace_dir': scan_workspace_dir,
'urls_file': urls_file,
'url_count': url_count,
'processed_records': total_processed,
'updated_count': total_updated,
'created_count': total_created,
'snapshot_count': total_snapshots,
'executed_tasks': executed_tasks,
'tool_stats': {
'total': len(enabled_tools),
'successful': len(successful_tools),
'failed': len(failed_tools),
'successful_tools': successful_tools,
'failed_tools': failed_tools,
'details': tool_stats
}
}
except ValueError as e:
logger.error("配置错误: %s", e)
raise
except RuntimeError as e:
logger.error("运行时错误: %s", e)
raise
except Exception as e:
logger.exception("指纹识别失败: %s", e)
raise
def _build_empty_result(
scan_id: int,
target_name: str,
scan_workspace_dir: str,
urls_file: str
) -> dict:
"""构建空结果(无 URL 可扫描时)"""
return {
'success': True,
'scan_id': scan_id,
'target': target_name,
'scan_workspace_dir': scan_workspace_dir,
'urls_file': urls_file,
'url_count': 0,
'processed_records': 0,
'updated_count': 0,
'created_count': 0,
'snapshot_count': 0,
'executed_tasks': ['export_urls_for_fingerprint'],
'tool_stats': {
'total': 0,
'successful': 0,
'failed': 0,
'successful_tools': [],
'failed_tools': [],
'details': {}
}
}

View File

@@ -1,284 +0,0 @@
"""
扫描初始化 Flow
负责编排扫描任务的初始化流程
职责:
- 使用 FlowOrchestrator 解析 YAML 配置
- 在 Prefect Flow 中执行子 FlowSubflow
- 按照 YAML 顺序编排工作流
- 不包含具体业务逻辑(由 Tasks 和 FlowOrchestrator 实现)
架构:
- Flow: Prefect 编排层(本文件)
- FlowOrchestrator: 配置解析和执行计划apps/scan/services/
- Tasks: 执行层apps/scan/tasks/
- Handlers: 状态管理apps/scan/handlers/
"""
# Django 环境初始化(导入即生效)
# 注意:动态扫描容器应使用 run_initiate_scan.py 启动,以便在导入前设置环境变量
from apps.common.prefect_django_setup import setup_django_for_prefect
from prefect import flow, task
from pathlib import Path
import logging
from apps.scan.handlers import (
on_initiate_scan_flow_running,
on_initiate_scan_flow_completed,
on_initiate_scan_flow_failed,
)
from prefect.futures import wait
from apps.scan.utils import setup_scan_workspace
from apps.scan.orchestrators import FlowOrchestrator
logger = logging.getLogger(__name__)
@task(name="run_subflow")
def _run_subflow_task(scan_type: str, flow_func, flow_kwargs: dict):
"""包装子 Flow 的 Task用于在并行阶段并发执行子 Flow。"""
logger.info("开始执行子 Flow: %s", scan_type)
return flow_func(**flow_kwargs)
@flow(
name='initiate_scan',
description='扫描任务初始化流程',
log_prints=True,
on_running=[on_initiate_scan_flow_running],
on_completion=[on_initiate_scan_flow_completed],
on_failure=[on_initiate_scan_flow_failed],
)
def initiate_scan_flow(
scan_id: int,
target_name: str,
target_id: int,
scan_workspace_dir: str,
engine_name: str,
scheduled_scan_name: str | None = None,
) -> dict:
"""
初始化扫描任务(动态工作流编排)
根据 YAML 配置动态编排工作流:
- 从数据库获取 engine_config (YAML)
- 检测启用的扫描类型
- 按照定义的阶段执行:
Stage 1: Discovery (顺序执行)
- subdomain_discovery
- port_scan
- site_scan
Stage 2: Analysis (并行执行)
- url_fetch
- directory_scan
Args:
scan_id: 扫描任务 ID
target_name: 目标名称
target_id: 目标 ID
scan_workspace_dir: Scan 工作空间目录路径
engine_name: 引擎名称(用于显示)
scheduled_scan_name: 定时扫描任务名称(可选,用于通知显示)
Returns:
dict: 执行结果摘要
Raises:
ValueError: 参数验证失败或配置无效
RuntimeError: 执行失败
"""
try:
# ==================== 参数验证 ====================
if not scan_id:
raise ValueError("scan_id is required")
if not scan_workspace_dir:
raise ValueError("scan_workspace_dir is required")
if not engine_name:
raise ValueError("engine_name is required")
logger.info("="*60)
logger.info("开始初始化扫描任务")
logger.info(f"Scan ID: {scan_id}")
logger.info(f"Target: {target_name}")
logger.info(f"Engine: {engine_name}")
logger.info(f"Workspace: {scan_workspace_dir}")
logger.info("="*60)
# ==================== Task 1: 创建 Scan 工作空间 ====================
scan_workspace_path = setup_scan_workspace(scan_workspace_dir)
# ==================== Task 2: 获取引擎配置 ====================
from apps.scan.models import Scan
scan = Scan.objects.get(id=scan_id)
engine_config = scan.yaml_configuration
# 使用 engine_names 进行显示
display_engine_name = ', '.join(scan.engine_names) if scan.engine_names else engine_name
# ==================== Task 3: 解析配置,生成执行计划 ====================
orchestrator = FlowOrchestrator(engine_config)
# FlowOrchestrator 已经解析了所有工具配置
enabled_tools_by_type = orchestrator.enabled_tools_by_type
logger.info("执行计划生成成功")
logger.info(f"扫描类型: {''.join(orchestrator.scan_types)}")
logger.info(f"总共 {len(orchestrator.scan_types)} 个 Flow")
# ==================== 初始化阶段进度 ====================
# 在解析完配置后立即初始化,此时已有完整的 scan_types 列表
from apps.scan.services import ScanService
scan_service = ScanService()
scan_service.init_stage_progress(scan_id, orchestrator.scan_types)
logger.info(f"✓ 初始化阶段进度 - Stages: {orchestrator.scan_types}")
# ==================== 更新 Target 最后扫描时间 ====================
# 在开始扫描时更新,表示"最后一次扫描开始时间"
from apps.targets.services import TargetService
target_service = TargetService()
target_service.update_last_scanned_at(target_id)
logger.info(f"✓ 更新 Target 最后扫描时间 - Target ID: {target_id}")
# ==================== Task 3: 执行 Flow动态阶段执行====================
# 注意:各阶段状态更新由 scan_flow_handlers.py 自动处理running/completed/failed
executed_flows = []
results = {}
# 通用执行参数
flow_kwargs = {
'scan_id': scan_id,
'target_name': target_name,
'target_id': target_id,
'scan_workspace_dir': str(scan_workspace_path)
}
def record_flow_result(scan_type, result=None, error=None):
"""
统一的结果记录函数
Args:
scan_type: 扫描类型名称
result: 执行结果(成功时)
error: 异常对象(失败时)
"""
if error:
# 失败处理:记录错误但不抛出异常,让扫描继续执行后续阶段
error_msg = f"{scan_type} 执行失败: {str(error)}"
logger.warning(error_msg)
executed_flows.append(f"{scan_type} (失败)")
results[scan_type] = {'success': False, 'error': str(error)}
# 不再抛出异常,让扫描继续
else:
# 成功处理
executed_flows.append(scan_type)
results[scan_type] = result
logger.info(f"{scan_type} 执行成功")
def get_valid_flows(flow_names):
"""
获取有效的 Flow 函数列表,并为每个 Flow 准备专属参数
Args:
flow_names: 扫描类型名称列表
Returns:
list: [(scan_type, flow_func, flow_specific_kwargs), ...] 有效的函数列表
"""
valid_flows = []
for scan_type in flow_names:
flow_func = orchestrator.get_flow_function(scan_type)
if flow_func:
# 为每个 Flow 准备专属的参数(包含对应的 enabled_tools
flow_specific_kwargs = dict(flow_kwargs)
flow_specific_kwargs['enabled_tools'] = enabled_tools_by_type.get(scan_type, {})
valid_flows.append((scan_type, flow_func, flow_specific_kwargs))
else:
logger.warning(f"跳过未实现的 Flow: {scan_type}")
return valid_flows
# ---------------------------------------------------------
# 动态阶段执行(基于 FlowOrchestrator 定义)
# ---------------------------------------------------------
for mode, enabled_flows in orchestrator.get_execution_stages():
if mode == 'sequential':
# 顺序执行
logger.info("="*60)
logger.info(f"顺序执行阶段: {', '.join(enabled_flows)}")
logger.info("="*60)
for scan_type, flow_func, flow_specific_kwargs in get_valid_flows(enabled_flows):
logger.info("="*60)
logger.info(f"执行 Flow: {scan_type}")
logger.info("="*60)
try:
result = flow_func(**flow_specific_kwargs)
record_flow_result(scan_type, result=result)
except Exception as e:
record_flow_result(scan_type, error=e)
elif mode == 'parallel':
# 并行执行阶段:通过 Task 包装子 Flow并使用 Prefect TaskRunner 并发运行
logger.info("="*60)
logger.info(f"并行执行阶段: {', '.join(enabled_flows)}")
logger.info("="*60)
futures = []
# 提交所有并行子 Flow 任务
for scan_type, flow_func, flow_specific_kwargs in get_valid_flows(enabled_flows):
logger.info("="*60)
logger.info(f"提交并行子 Flow 任务: {scan_type}")
logger.info("="*60)
future = _run_subflow_task.submit(
scan_type=scan_type,
flow_func=flow_func,
flow_kwargs=flow_specific_kwargs,
)
futures.append((scan_type, future))
# 等待所有并行子 Flow 完成
if futures:
wait([f for _, f in futures])
# 检查结果(复用统一的结果处理逻辑)
for scan_type, future in futures:
try:
result = future.result()
record_flow_result(scan_type, result=result)
except Exception as e:
record_flow_result(scan_type, error=e)
# ==================== 完成 ====================
logger.info("="*60)
logger.info("✓ 扫描任务初始化完成")
logger.info(f"执行的 Flow: {', '.join(executed_flows)}")
logger.info("="*60)
# ==================== 返回结果 ====================
return {
'success': True,
'scan_id': scan_id,
'target': target_name,
'scan_workspace_dir': str(scan_workspace_path),
'executed_flows': executed_flows,
'results': results
}
except ValueError as e:
# 参数错误
logger.error("参数错误: %s", e)
raise
except RuntimeError as e:
# 执行失败
logger.error("运行时错误: %s", e)
raise
except OSError as e:
# 文件系统错误(工作空间创建失败)
logger.error("文件系统错误: %s", e)
raise
except Exception as e:
# 其他未预期错误
logger.exception("初始化扫描任务失败: %s", e)
# 注意:失败状态更新由 Prefect State Handlers 自动处理
raise

View File

@@ -1,251 +0,0 @@
from apps.common.prefect_django_setup import setup_django_for_prefect
import logging
from datetime import datetime
from pathlib import Path
from typing import Dict
from prefect import flow
from apps.scan.handlers.scan_flow_handlers import (
on_scan_flow_running,
on_scan_flow_completed,
on_scan_flow_failed,
)
from apps.scan.utils import build_scan_command, ensure_nuclei_templates_local, user_log
from apps.scan.tasks.vuln_scan import (
export_endpoints_task,
run_vuln_tool_task,
run_and_stream_save_dalfox_vulns_task,
run_and_stream_save_nuclei_vulns_task,
)
from .utils import calculate_timeout_by_line_count
logger = logging.getLogger(__name__)
@flow(
name="endpoints_vuln_scan_flow",
log_prints=True,
)
def endpoints_vuln_scan_flow(
scan_id: int,
target_name: str,
target_id: int,
scan_workspace_dir: str,
enabled_tools: Dict[str, dict],
) -> dict:
"""基于 Endpoint 的漏洞扫描 Flow串行执行 Dalfox 等工具)。"""
try:
if scan_id is None:
raise ValueError("scan_id 不能为空")
if not target_name:
raise ValueError("target_name 不能为空")
if target_id is None:
raise ValueError("target_id 不能为空")
if not scan_workspace_dir:
raise ValueError("scan_workspace_dir 不能为空")
if not enabled_tools:
raise ValueError("enabled_tools 不能为空")
from apps.scan.utils import setup_scan_directory
vuln_scan_dir = setup_scan_directory(scan_workspace_dir, 'vuln_scan')
endpoints_file = vuln_scan_dir / "input_endpoints.txt"
# Step 1: 导出 Endpoint URL
export_result = export_endpoints_task(
target_id=target_id,
output_file=str(endpoints_file),
)
total_endpoints = export_result.get("total_count", 0)
if total_endpoints == 0 or not endpoints_file.exists() or endpoints_file.stat().st_size == 0:
logger.warning("目标下没有可用 Endpoint跳过漏洞扫描")
return {
"success": True,
"scan_id": scan_id,
"target": target_name,
"scan_workspace_dir": scan_workspace_dir,
"endpoints_file": str(endpoints_file),
"endpoint_count": 0,
"executed_tools": [],
"tool_results": {},
}
logger.info("Endpoint 导出完成,共 %d 条,开始执行漏洞扫描", total_endpoints)
tool_results: Dict[str, dict] = {}
# Step 2: 并行执行每个漏洞扫描工具(目前主要是 Dalfox
# 1先为每个工具 submit Prefect Task让 Worker 并行调度
# 2再统一收集各自的结果组装成 tool_results
tool_futures: Dict[str, dict] = {}
for tool_name, tool_config in enabled_tools.items():
# Nuclei 需要先确保本地模板存在(支持多个模板仓库)
template_args = ""
if tool_name == "nuclei":
repo_names = tool_config.get("template_repo_names")
if not repo_names or not isinstance(repo_names, (list, tuple)):
logger.error("Nuclei 配置缺少 template_repo_names数组跳过")
continue
template_paths = []
try:
for repo_name in repo_names:
path = ensure_nuclei_templates_local(repo_name)
template_paths.append(path)
logger.info("Nuclei 模板路径 [%s]: %s", repo_name, path)
except Exception as e:
logger.error("获取 Nuclei 模板失败: %s,跳过 nuclei 扫描", e)
continue
template_args = " ".join(f"-t {p}" for p in template_paths)
# 构建命令参数
command_params = {"endpoints_file": str(endpoints_file)}
if template_args:
command_params["template_args"] = template_args
command = build_scan_command(
tool_name=tool_name,
scan_type="vuln_scan",
command_params=command_params,
tool_config=tool_config,
)
raw_timeout = tool_config.get("timeout", 600)
if isinstance(raw_timeout, str) and raw_timeout == "auto":
# timeout=auto 时,根据 endpoints_file 行数自动计算超时时间
# Dalfox: 每行 100 秒Nuclei: 每行 30 秒
base_per_time = 30 if tool_name == "nuclei" else 100
timeout = calculate_timeout_by_line_count(
tool_config=tool_config,
file_path=str(endpoints_file),
base_per_time=base_per_time,
)
else:
try:
timeout = int(raw_timeout)
except (TypeError, ValueError) as e:
raise ValueError(
f"工具 {tool_name} 的 timeout 配置无效: {raw_timeout!r}"
) from e
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
log_file = vuln_scan_dir / f"{tool_name}_{timestamp}.log"
# Dalfox XSS 使用流式任务,一边解析一边保存漏洞结果
if tool_name == "dalfox_xss":
logger.info("开始执行漏洞扫描工具 %s(流式保存漏洞结果,已提交任务)", tool_name)
user_log(scan_id, "vuln_scan", f"Running {tool_name}: {command}")
future = run_and_stream_save_dalfox_vulns_task.submit(
cmd=command,
tool_name=tool_name,
scan_id=scan_id,
target_id=target_id,
cwd=str(vuln_scan_dir),
shell=True,
batch_size=1,
timeout=timeout,
log_file=str(log_file),
)
tool_futures[tool_name] = {
"future": future,
"command": command,
"timeout": timeout,
"log_file": str(log_file),
"mode": "streaming",
}
elif tool_name == "nuclei":
# Nuclei 使用流式任务
logger.info("开始执行漏洞扫描工具 %s(流式保存漏洞结果,已提交任务)", tool_name)
user_log(scan_id, "vuln_scan", f"Running {tool_name}: {command}")
future = run_and_stream_save_nuclei_vulns_task.submit(
cmd=command,
tool_name=tool_name,
scan_id=scan_id,
target_id=target_id,
cwd=str(vuln_scan_dir),
shell=True,
batch_size=1,
timeout=timeout,
log_file=str(log_file),
)
tool_futures[tool_name] = {
"future": future,
"command": command,
"timeout": timeout,
"log_file": str(log_file),
"mode": "streaming",
}
else:
# 其他工具仍使用非流式执行逻辑
logger.info("开始执行漏洞扫描工具 %s(已提交任务)", tool_name)
user_log(scan_id, "vuln_scan", f"Running {tool_name}: {command}")
future = run_vuln_tool_task.submit(
tool_name=tool_name,
command=command,
timeout=timeout,
log_file=str(log_file),
)
tool_futures[tool_name] = {
"future": future,
"command": command,
"timeout": timeout,
"log_file": str(log_file),
"mode": "normal",
}
# 统一收集所有工具的执行结果
for tool_name, meta in tool_futures.items():
future = meta["future"]
try:
result = future.result()
if meta["mode"] == "streaming":
created_vulns = result.get("created_vulns", 0)
tool_results[tool_name] = {
"command": meta["command"],
"timeout": meta["timeout"],
"processed_records": result.get("processed_records"),
"created_vulns": created_vulns,
"command_log_file": meta["log_file"],
}
logger.info("✓ 工具 %s 执行完成 - 漏洞: %d", tool_name, created_vulns)
user_log(scan_id, "vuln_scan", f"{tool_name} completed: found {created_vulns} vulnerabilities")
else:
tool_results[tool_name] = {
"command": meta["command"],
"timeout": meta["timeout"],
"duration": result.get("duration"),
"returncode": result.get("returncode"),
"command_log_file": result.get("command_log_file"),
}
logger.info("✓ 工具 %s 执行完成 - returncode=%s", tool_name, result.get("returncode"))
user_log(scan_id, "vuln_scan", f"{tool_name} completed")
except Exception as e:
reason = str(e)
logger.error("工具 %s 执行失败: %s", tool_name, e, exc_info=True)
user_log(scan_id, "vuln_scan", f"{tool_name} failed: {reason}", "error")
return {
"success": True,
"scan_id": scan_id,
"target": target_name,
"scan_workspace_dir": scan_workspace_dir,
"endpoints_file": str(endpoints_file),
"endpoint_count": total_endpoints,
"executed_tools": list(enabled_tools.keys()),
"tool_results": tool_results,
}
except Exception as e:
logger.exception("Endpoint 漏洞扫描失败: %s", e)
raise

View File

@@ -1,123 +0,0 @@
"""
漏洞扫描主 Flow
"""
import logging
from typing import Dict, Tuple
from prefect import flow
from apps.scan.handlers.scan_flow_handlers import (
on_scan_flow_running,
on_scan_flow_completed,
on_scan_flow_failed,
)
from apps.scan.configs.command_templates import get_command_template
from apps.scan.utils import user_log, wait_for_system_load
from .endpoints_vuln_scan_flow import endpoints_vuln_scan_flow
logger = logging.getLogger(__name__)
def _classify_vuln_tools(enabled_tools: Dict[str, dict]) -> Tuple[Dict[str, dict], Dict[str, dict]]:
"""根据命令模板中的 input_type 对漏洞扫描工具进行分类。
当前支持:
- endpoints_file: 以端点列表文件为输入(例如 Dalfox XSS
预留:
- 其他 input_type 将被归类到 other_tools暂不处理。
"""
endpoints_tools: Dict[str, dict] = {}
other_tools: Dict[str, dict] = {}
for tool_name, tool_config in enabled_tools.items():
template = get_command_template("vuln_scan", tool_name) or {}
input_type = template.get("input_type", "endpoints_file")
if input_type == "endpoints_file":
endpoints_tools[tool_name] = tool_config
else:
other_tools[tool_name] = tool_config
return endpoints_tools, other_tools
@flow(
name="vuln_scan",
log_prints=True,
on_running=[on_scan_flow_running],
on_completion=[on_scan_flow_completed],
on_failure=[on_scan_flow_failed],
)
def vuln_scan_flow(
scan_id: int,
target_name: str,
target_id: int,
scan_workspace_dir: str,
enabled_tools: Dict[str, dict],
) -> dict:
"""漏洞扫描主 Flow串行编排各类漏洞扫描子 Flow。
支持工具:
- dalfox_xss: XSS 漏洞扫描(流式保存)
- nuclei: 通用漏洞扫描(流式保存,支持模板 commit hash 同步)
"""
try:
# 负载检查:等待系统资源充足
wait_for_system_load(context="vuln_scan_flow")
if scan_id is None:
raise ValueError("scan_id 不能为空")
if not target_name:
raise ValueError("target_name 不能为空")
if target_id is None:
raise ValueError("target_id 不能为空")
if not scan_workspace_dir:
raise ValueError("scan_workspace_dir 不能为空")
if not enabled_tools:
raise ValueError("enabled_tools 不能为空")
logger.info("开始漏洞扫描 - Scan ID: %s, Target: %s", scan_id, target_name)
user_log(scan_id, "vuln_scan", "Starting vulnerability scan")
# Step 1: 分类工具
endpoints_tools, other_tools = _classify_vuln_tools(enabled_tools)
logger.info(
"漏洞扫描工具分类 - endpoints_file: %s, 其他: %s",
list(endpoints_tools.keys()) or "",
list(other_tools.keys()) or "",
)
if other_tools:
logger.warning(
"存在暂不支持输入类型的漏洞扫描工具,将被忽略: %s",
list(other_tools.keys()),
)
if not endpoints_tools:
raise ValueError("漏洞扫描需要至少启用一个以 endpoints_file 为输入的工具(如 dalfox_xss、nuclei")
# Step 2: 执行 Endpoint 漏洞扫描子 Flow串行
endpoint_result = endpoints_vuln_scan_flow(
scan_id=scan_id,
target_name=target_name,
target_id=target_id,
scan_workspace_dir=scan_workspace_dir,
enabled_tools=endpoints_tools,
)
# 记录 Flow 完成
total_vulns = sum(
r.get("created_vulns", 0)
for r in endpoint_result.get("tool_results", {}).values()
)
logger.info("✓ 漏洞扫描完成 - 新增漏洞: %d", total_vulns)
user_log(scan_id, "vuln_scan", f"vuln_scan completed: found {total_vulns} vulnerabilities")
# 目前只有一个子 Flow直接返回其结果
return endpoint_result
except Exception as e:
logger.exception("漏洞扫描主 Flow 失败: %s", e)
raise

View File

@@ -1,189 +0,0 @@
"""
扫描流程处理器
负责处理扫描流程(端口扫描、子域名发现等)的状态变化和通知
职责:
- 更新各阶段的进度状态running/completed/failed
- 发送扫描阶段的通知
- 记录 Flow 性能指标
"""
import logging
from prefect import Flow
from prefect.client.schemas import FlowRun, State
from apps.scan.utils.performance import FlowPerformanceTracker
from apps.scan.utils import user_log
logger = logging.getLogger(__name__)
# 存储每个 flow_run 的性能追踪器
_flow_trackers: dict[str, FlowPerformanceTracker] = {}
def _get_stage_from_flow_name(flow_name: str) -> str | None:
"""
从 Flow name 获取对应的 stage
Flow name 直接作为 stage与 engine_config 的 key 一致)
排除主 Flowinitiate_scan
"""
# 排除主 Flow它不是阶段 Flow
if flow_name == 'initiate_scan':
return None
return flow_name
def on_scan_flow_running(flow: Flow, flow_run: FlowRun, state: State) -> None:
"""
扫描流程开始运行时的回调
职责:
- 更新阶段进度为 running
- 发送扫描开始通知
- 启动性能追踪
Args:
flow: Prefect Flow 对象
flow_run: Flow 运行实例
state: Flow 当前状态
"""
logger.info("🚀 扫描流程开始运行 - Flow: %s, Run ID: %s", flow.name, flow_run.id)
# 提取流程参数
flow_params = flow_run.parameters or {}
scan_id = flow_params.get('scan_id')
target_name = flow_params.get('target_name', 'unknown')
target_id = flow_params.get('target_id')
# 启动性能追踪
if scan_id:
tracker = FlowPerformanceTracker(flow.name, scan_id)
tracker.start(target_id=target_id, target_name=target_name)
_flow_trackers[str(flow_run.id)] = tracker
# 更新阶段进度
stage = _get_stage_from_flow_name(flow.name)
if scan_id and stage:
try:
from apps.scan.services import ScanService
service = ScanService()
service.start_stage(scan_id, stage)
logger.info(f"✓ 阶段进度已更新为 running - Scan ID: {scan_id}, Stage: {stage}")
except Exception as e:
logger.error(f"更新阶段进度失败 - Scan ID: {scan_id}, Stage: {stage}: {e}")
def on_scan_flow_completed(flow: Flow, flow_run: FlowRun, state: State) -> None:
"""
扫描流程完成时的回调
职责:
- 更新阶段进度为 completed
- 发送扫描完成通知(可选)
- 记录性能指标
Args:
flow: Prefect Flow 对象
flow_run: Flow 运行实例
state: Flow 当前状态
"""
logger.info("✅ 扫描流程完成 - Flow: %s, Run ID: %s", flow.name, flow_run.id)
# 提取流程参数
flow_params = flow_run.parameters or {}
scan_id = flow_params.get('scan_id')
# 获取 flow result
result = None
try:
result = state.result() if state.result else None
except Exception:
pass
# 记录性能指标
tracker = _flow_trackers.pop(str(flow_run.id), None)
if tracker:
tracker.finish(success=True)
# 更新阶段进度
stage = _get_stage_from_flow_name(flow.name)
if scan_id and stage:
try:
from apps.scan.services import ScanService
service = ScanService()
# 从 flow result 中提取 detail如果有
detail = None
if isinstance(result, dict):
detail = result.get('detail')
service.complete_stage(scan_id, stage, detail)
logger.info(f"✓ 阶段进度已更新为 completed - Scan ID: {scan_id}, Stage: {stage}")
# 每个阶段完成后刷新缓存统计,便于前端实时看到增量
try:
service.update_cached_stats(scan_id)
logger.info("✓ 阶段完成后已刷新缓存统计 - Scan ID: %s", scan_id)
except Exception as e:
logger.error("阶段完成后刷新缓存统计失败 - Scan ID: %s, 错误: %s", scan_id, e)
except Exception as e:
logger.error(f"更新阶段进度失败 - Scan ID: {scan_id}, Stage: {stage}: {e}")
def on_scan_flow_failed(flow: Flow, flow_run: FlowRun, state: State) -> None:
"""
扫描流程失败时的回调
职责:
- 更新阶段进度为 failed
- 发送扫描失败通知
- 记录性能指标(含错误信息)
- 写入 ScanLog 供前端显示
Args:
flow: Prefect Flow 对象
flow_run: Flow 运行实例
state: Flow 当前状态
"""
logger.info("❌ 扫描流程失败 - Flow: %s, Run ID: %s", flow.name, flow_run.id)
# 提取流程参数
flow_params = flow_run.parameters or {}
scan_id = flow_params.get('scan_id')
target_name = flow_params.get('target_name', 'unknown')
# 提取错误信息
error_message = str(state.message) if state.message else "未知错误"
# 写入 ScanLog 供前端显示
stage = _get_stage_from_flow_name(flow.name)
if scan_id and stage:
user_log(scan_id, stage, f"Failed: {error_message}", "error")
# 记录性能指标(失败情况)
tracker = _flow_trackers.pop(str(flow_run.id), None)
if tracker:
tracker.finish(success=False, error_message=error_message)
# 更新阶段进度
stage = _get_stage_from_flow_name(flow.name)
if scan_id and stage:
try:
from apps.scan.services import ScanService
service = ScanService()
service.fail_stage(scan_id, stage, error_message)
logger.info(f"✓ 阶段进度已更新为 failed - Scan ID: {scan_id}, Stage: {stage}")
except Exception as e:
logger.error(f"更新阶段进度失败 - Scan ID: {scan_id}, Stage: {stage}: {e}")
# 发送通知
try:
from apps.scan.notifications import create_notification, NotificationLevel
message = f"任务:{flow.name}\n状态:执行失败\n错误:{error_message}"
create_notification(
title=target_name,
message=message,
level=NotificationLevel.HIGH
)
logger.error(f"✓ 扫描失败通知已发送 - Target: {target_name}, Flow: {flow.name}, Error: {error_message}")
except Exception as e:
logger.error(f"发送扫描失败通知失败 - Flow: {flow.name}: {e}")

View File

@@ -1,56 +0,0 @@
"""
扫描目标提供者模块
提供统一的目标获取接口,支持多种数据源:
- DatabaseTargetProvider: 从数据库查询(完整扫描)
- ListTargetProvider: 使用内存列表快速扫描阶段1
- SnapshotTargetProvider: 从快照表读取快速扫描阶段2+
- PipelineTargetProvider: 使用管道输出Phase 2
使用方式:
from apps.scan.providers import (
DatabaseTargetProvider,
ListTargetProvider,
SnapshotTargetProvider,
ProviderContext
)
# 数据库模式(完整扫描)
provider = DatabaseTargetProvider(target_id=123)
# 列表模式快速扫描阶段1
context = ProviderContext(target_id=1, scan_id=100)
provider = ListTargetProvider(
targets=["a.test.com"],
context=context
)
# 快照模式快速扫描阶段2+
context = ProviderContext(target_id=1, scan_id=100)
provider = SnapshotTargetProvider(
scan_id=100,
snapshot_type="subdomain",
context=context
)
# 使用 Provider
for host in provider.iter_hosts():
scan(host)
"""
from .base import TargetProvider, ProviderContext
from .list_provider import ListTargetProvider
from .database_provider import DatabaseTargetProvider
from .snapshot_provider import SnapshotTargetProvider, SnapshotType
from .pipeline_provider import PipelineTargetProvider, StageOutput
__all__ = [
'TargetProvider',
'ProviderContext',
'ListTargetProvider',
'DatabaseTargetProvider',
'SnapshotTargetProvider',
'SnapshotType',
'PipelineTargetProvider',
'StageOutput',
]

View File

@@ -1,115 +0,0 @@
"""
扫描目标提供者基础模块
定义 ProviderContext 数据类和 TargetProvider 抽象基类。
"""
import ipaddress
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, Iterator, Optional
if TYPE_CHECKING:
from apps.common.utils import BlacklistFilter
logger = logging.getLogger(__name__)
@dataclass
class ProviderContext:
"""
Provider 上下文,携带元数据
Attributes:
target_id: 关联的 Target ID用于结果保存None 表示临时扫描(不保存)
scan_id: 扫描任务 ID
"""
target_id: Optional[int] = None
scan_id: Optional[int] = None
class TargetProvider(ABC):
"""
扫描目标提供者抽象基类
职责:
- 提供扫描目标域名、IP、URL 等)的迭代器
- 提供黑名单过滤器
- 携带上下文信息target_id, scan_id 等)
- 自动展开 CIDR子类无需关心
使用方式:
provider = create_target_provider(target_id=123)
for host in provider.iter_hosts():
print(host)
"""
def __init__(self, context: Optional[ProviderContext] = None):
self._context = context or ProviderContext()
@property
def context(self) -> ProviderContext:
"""返回 Provider 上下文"""
return self._context
@staticmethod
def _expand_host(host: str) -> Iterator[str]:
"""
展开主机(如果是 CIDR 则展开为多个 IP否则直接返回
示例:
"192.168.1.0/30""192.168.1.1", "192.168.1.2"
"192.168.1.1""192.168.1.1"
"example.com""example.com"
"""
from apps.common.validators import detect_target_type
from apps.targets.models import Target
host = host.strip()
if not host:
return
try:
target_type = detect_target_type(host)
if target_type == Target.TargetType.CIDR:
network = ipaddress.ip_network(host, strict=False)
if network.num_addresses == 1:
yield str(network.network_address)
else:
yield from (str(ip) for ip in network.hosts())
elif target_type in (Target.TargetType.IP, Target.TargetType.DOMAIN):
yield host
except ValueError as e:
logger.warning("跳过无效的主机格式 '%s': %s", host, str(e))
def iter_hosts(self) -> Iterator[str]:
"""迭代主机列表(域名/IP自动展开 CIDR"""
for host in self._iter_raw_hosts():
yield from self._expand_host(host)
@abstractmethod
def _iter_raw_hosts(self) -> Iterator[str]:
"""迭代原始主机列表(可能包含 CIDR子类实现"""
pass
@abstractmethod
def iter_urls(self) -> Iterator[str]:
"""迭代 URL 列表"""
pass
@abstractmethod
def get_blacklist_filter(self) -> Optional['BlacklistFilter']:
"""获取黑名单过滤器,返回 None 表示不过滤"""
pass
@property
def target_id(self) -> Optional[int]:
"""返回关联的 target_id临时扫描返回 None"""
return self._context.target_id
@property
def scan_id(self) -> Optional[int]:
"""返回关联的 scan_id"""
return self._context.scan_id

View File

@@ -1,93 +0,0 @@
"""
数据库目标提供者模块
提供基于数据库查询的目标提供者实现。
"""
import logging
from typing import TYPE_CHECKING, Iterator, Optional
from .base import ProviderContext, TargetProvider
if TYPE_CHECKING:
from apps.common.utils import BlacklistFilter
logger = logging.getLogger(__name__)
class DatabaseTargetProvider(TargetProvider):
"""
数据库目标提供者 - 从 Target 表及关联资产表查询
数据来源:
- iter_hosts(): 根据 Target 类型返回域名/IP
- iter_urls(): WebSite/Endpoint 表,带回退链
使用方式:
provider = DatabaseTargetProvider(target_id=123)
for host in provider.iter_hosts():
scan(host)
"""
def __init__(self, target_id: int, context: Optional[ProviderContext] = None):
ctx = context or ProviderContext()
ctx.target_id = target_id
super().__init__(ctx)
self._blacklist_filter: Optional['BlacklistFilter'] = None
def iter_hosts(self) -> Iterator[str]:
"""从数据库查询主机列表,自动展开 CIDR 并应用黑名单过滤"""
blacklist = self.get_blacklist_filter()
for host in self._iter_raw_hosts():
for expanded_host in self._expand_host(host):
if not blacklist or blacklist.is_allowed(expanded_host):
yield expanded_host
def _iter_raw_hosts(self) -> Iterator[str]:
"""从数据库查询原始主机列表(可能包含 CIDR"""
from apps.asset.services.asset.subdomain_service import SubdomainService
from apps.targets.models import Target
from apps.targets.services import TargetService
target = TargetService().get_target(self.target_id)
if not target:
logger.warning("Target ID %d 不存在", self.target_id)
return
if target.type == Target.TargetType.DOMAIN:
yield target.name
for domain in SubdomainService().iter_subdomain_names_by_target(
target_id=self.target_id,
chunk_size=1000
):
if domain != target.name:
yield domain
elif target.type in (Target.TargetType.IP, Target.TargetType.CIDR):
yield target.name
def iter_urls(self) -> Iterator[str]:
"""从数据库查询 URL 列表使用回退链Endpoint → WebSite → Default"""
from apps.scan.services.target_export_service import (
DataSource,
_iter_urls_with_fallback,
)
blacklist = self.get_blacklist_filter()
for url, _ in _iter_urls_with_fallback(
target_id=self.target_id,
sources=[DataSource.ENDPOINT, DataSource.WEBSITE, DataSource.DEFAULT],
blacklist_filter=blacklist
):
yield url
def get_blacklist_filter(self) -> Optional['BlacklistFilter']:
"""获取黑名单过滤器(延迟加载)"""
if self._blacklist_filter is None:
from apps.common.services import BlacklistService
from apps.common.utils import BlacklistFilter
rules = BlacklistService().get_rules(self.target_id)
self._blacklist_filter = BlacklistFilter(rules)
return self._blacklist_filter

View File

@@ -1,84 +0,0 @@
"""
列表目标提供者模块
提供基于内存列表的目标提供者实现。
"""
from typing import Iterator, Optional, List
from .base import TargetProvider, ProviderContext
class ListTargetProvider(TargetProvider):
"""
列表目标提供者 - 直接使用内存中的列表
用于快速扫描、临时扫描等场景,只扫描用户指定的目标。
特点:
- 不查询数据库
- 不应用黑名单过滤(用户明确指定的目标)
- 不关联 target_id由调用方负责创建 Target
- 自动检测输入类型URL/域名/IP/CIDR
- 自动展开 CIDR
使用方式:
# 快速扫描:用户提供目标,自动识别类型
provider = ListTargetProvider(targets=[
"example.com", # 域名
"192.168.1.0/24", # CIDR自动展开
"https://api.example.com" # URL
])
for host in provider.iter_hosts():
scan(host)
"""
def __init__(
self,
targets: Optional[List[str]] = None,
context: Optional[ProviderContext] = None
):
"""
初始化列表目标提供者
Args:
targets: 目标列表自动识别类型URL/域名/IP/CIDR
context: Provider 上下文
"""
from apps.common.validators import detect_input_type
ctx = context or ProviderContext()
super().__init__(ctx)
# 自动分类目标
self._hosts = []
self._urls = []
if targets:
for target in targets:
target = target.strip()
if not target:
continue
try:
input_type = detect_input_type(target)
if input_type == 'url':
self._urls.append(target)
else:
# domain/ip/cidr 都作为 host
self._hosts.append(target)
except ValueError:
# 无法识别类型,默认作为 host
self._hosts.append(target)
def _iter_raw_hosts(self) -> Iterator[str]:
"""迭代原始主机列表(可能包含 CIDR"""
yield from self._hosts
def iter_urls(self) -> Iterator[str]:
"""迭代 URL 列表"""
yield from self._urls
def get_blacklist_filter(self) -> None:
"""列表模式不使用黑名单过滤"""
return None

View File

@@ -1,91 +0,0 @@
"""
管道目标提供者模块
提供基于管道阶段输出的目标提供者实现。
用于 Phase 2 管道模式的阶段间数据传递。
"""
from dataclasses import dataclass, field
from typing import Iterator, Optional, List, Dict, Any
from .base import TargetProvider, ProviderContext
@dataclass
class StageOutput:
"""
阶段输出数据
用于在管道阶段之间传递数据。
Attributes:
hosts: 主机列表(域名/IP
urls: URL 列表
new_targets: 新发现的目标列表
stats: 统计信息
success: 是否成功
error: 错误信息
"""
hosts: List[str] = field(default_factory=list)
urls: List[str] = field(default_factory=list)
new_targets: List[str] = field(default_factory=list)
stats: Dict[str, Any] = field(default_factory=dict)
success: bool = True
error: Optional[str] = None
class PipelineTargetProvider(TargetProvider):
"""
管道目标提供者 - 使用上一阶段的输出
用于 Phase 2 管道模式的阶段间数据传递。
特点:
- 不查询数据库
- 不应用黑名单过滤(数据已在上一阶段过滤)
- 直接使用 StageOutput 中的数据
使用方式Phase 2
stage1_output = stage1.run(input)
provider = PipelineTargetProvider(
previous_output=stage1_output,
target_id=123
)
for host in provider.iter_hosts():
stage2.scan(host)
"""
def __init__(
self,
previous_output: StageOutput,
target_id: Optional[int] = None,
context: Optional[ProviderContext] = None
):
"""
初始化管道目标提供者
Args:
previous_output: 上一阶段的输出
target_id: 可选,关联到某个 Target用于保存结果
context: Provider 上下文
"""
ctx = context or ProviderContext(target_id=target_id)
super().__init__(ctx)
self._previous_output = previous_output
def _iter_raw_hosts(self) -> Iterator[str]:
"""迭代上一阶段输出的原始主机(可能包含 CIDR"""
yield from self._previous_output.hosts
def iter_urls(self) -> Iterator[str]:
"""迭代上一阶段输出的 URL"""
yield from self._previous_output.urls
def get_blacklist_filter(self) -> None:
"""管道传递的数据已经过滤过了"""
return None
@property
def previous_output(self) -> StageOutput:
"""返回上一阶段的输出"""
return self._previous_output

View File

@@ -1,175 +0,0 @@
"""
快照目标提供者模块
提供基于快照表的目标提供者实现。
用于快速扫描的阶段间数据传递。
"""
import logging
from typing import Iterator, Optional, Literal
from .base import TargetProvider, ProviderContext
logger = logging.getLogger(__name__)
# 快照类型定义
SnapshotType = Literal["subdomain", "website", "endpoint", "host_port"]
class SnapshotTargetProvider(TargetProvider):
"""
快照目标提供者 - 从快照表读取本次扫描的数据
用于快速扫描的阶段间数据传递,解决精确扫描控制问题。
核心价值:
- 只返回本次扫描scan_id发现的资产
- 避免扫描历史数据DatabaseTargetProvider 会扫描所有历史资产)
特点:
- 通过 scan_id 过滤快照表
- 不应用黑名单过滤(数据已在上一阶段过滤)
- 支持多种快照类型subdomain/website/endpoint/host_port
使用场景:
# 快速扫描流程
用户输入: a.test.com
创建 Target: test.com (id=1)
创建 Scan: scan_id=100
# 阶段1: 子域名发现
provider = ListTargetProvider(
targets=["a.test.com"],
context=ProviderContext(target_id=1, scan_id=100)
)
# 发现: b.a.test.com, c.a.test.com
# 保存: SubdomainSnapshot(scan_id=100) + Subdomain(target_id=1)
# 阶段2: 端口扫描
provider = SnapshotTargetProvider(
scan_id=100,
snapshot_type="subdomain",
context=ProviderContext(target_id=1, scan_id=100)
)
# 只返回: b.a.test.com, c.a.test.com本次扫描发现的
# 不返回: www.test.com, api.test.com历史数据
# 阶段3: 网站扫描
provider = SnapshotTargetProvider(
scan_id=100,
snapshot_type="host_port",
context=ProviderContext(target_id=1, scan_id=100)
)
# 只返回本次扫描发现的 IP:Port
"""
def __init__(
self,
scan_id: int,
snapshot_type: SnapshotType,
context: Optional[ProviderContext] = None
):
"""
初始化快照目标提供者
Args:
scan_id: 扫描任务 ID必需
snapshot_type: 快照类型
- "subdomain": 子域名快照SubdomainSnapshot
- "website": 网站快照WebsiteSnapshot
- "endpoint": 端点快照EndpointSnapshot
- "host_port": 主机端口映射快照HostPortMappingSnapshot
context: Provider 上下文
"""
ctx = context or ProviderContext()
ctx.scan_id = scan_id
super().__init__(ctx)
self._scan_id = scan_id
self._snapshot_type = snapshot_type
def _iter_raw_hosts(self) -> Iterator[str]:
"""
从快照表迭代主机列表
根据 snapshot_type 选择不同的快照表:
- subdomain: SubdomainSnapshot.name
- host_port: HostPortMappingSnapshot.host (返回 host:port 格式,不经过验证)
"""
if self._snapshot_type == "subdomain":
from apps.asset.services.snapshot import SubdomainSnapshotsService
service = SubdomainSnapshotsService()
yield from service.iter_subdomain_names_by_scan(
scan_id=self._scan_id,
chunk_size=1000
)
elif self._snapshot_type == "host_port":
# host_port 类型不使用 _iter_raw_hosts直接在 iter_hosts 中处理
# 这里返回空,避免被基类的 iter_hosts 调用
return
else:
# 其他类型暂不支持 iter_hosts
logger.warning(
"快照类型 '%s' 不支持 iter_hosts返回空迭代器",
self._snapshot_type
)
return
def iter_hosts(self) -> Iterator[str]:
"""
迭代主机列表
对于 host_port 类型,返回 host:port 格式,不经过 CIDR 展开验证
"""
if self._snapshot_type == "host_port":
# host_port 类型直接返回 host:port不经过 _expand_host 验证
from apps.asset.services.snapshot import HostPortMappingSnapshotsService
service = HostPortMappingSnapshotsService()
queryset = service.get_by_scan(scan_id=self._scan_id)
for mapping in queryset.iterator(chunk_size=1000):
yield f"{mapping.host}:{mapping.port}"
else:
# 其他类型使用基类的 iter_hosts会调用 _iter_raw_hosts 并展开 CIDR
yield from super().iter_hosts()
def iter_urls(self) -> Iterator[str]:
"""
从快照表迭代 URL 列表
根据 snapshot_type 选择不同的快照表:
- website: WebsiteSnapshot.url
- endpoint: EndpointSnapshot.url
"""
if self._snapshot_type == "website":
from apps.asset.services.snapshot import WebsiteSnapshotsService
service = WebsiteSnapshotsService()
yield from service.iter_website_urls_by_scan(
scan_id=self._scan_id,
chunk_size=1000
)
elif self._snapshot_type == "endpoint":
from apps.asset.services.snapshot import EndpointSnapshotsService
service = EndpointSnapshotsService()
# 从快照表获取端点 URL
queryset = service.get_by_scan(scan_id=self._scan_id)
for endpoint in queryset.iterator(chunk_size=1000):
yield endpoint.url
else:
# 其他类型暂不支持 iter_urls
logger.warning(
"快照类型 '%s' 不支持 iter_urls返回空迭代器",
self._snapshot_type
)
return
def get_blacklist_filter(self) -> None:
"""快照数据已在上一阶段过滤过了"""
return None
@property
def snapshot_type(self) -> SnapshotType:
"""返回快照类型"""
return self._snapshot_type

View File

@@ -1,256 +0,0 @@
"""
通用属性测试
包含跨多个 Provider 的通用属性测试:
- Property 4: Context Propagation
- Property 5: Non-Database Provider Blacklist Filter
- Property 7: CIDR Expansion Consistency
"""
import pytest
from hypothesis import given, strategies as st, settings
from ipaddress import IPv4Network
from apps.scan.providers import (
ProviderContext,
ListTargetProvider,
DatabaseTargetProvider,
PipelineTargetProvider,
SnapshotTargetProvider
)
from apps.scan.providers.pipeline_provider import StageOutput
class TestContextPropagation:
"""
Property 4: Context Propagation
*For any* ProviderContext传入 Provider 构造函数后,
Provider 的 target_id 和 scan_id 属性应该与 context 中的值一致。
**Validates: Requirements 1.3, 1.5, 7.4, 7.5**
"""
@given(
target_id=st.integers(min_value=1, max_value=10000),
scan_id=st.integers(min_value=1, max_value=10000)
)
@settings(max_examples=100)
def test_property_4_list_provider_context_propagation(self, target_id, scan_id):
"""
Property 4: Context Propagation (ListTargetProvider)
Feature: scan-target-provider, Property 4: Context Propagation
**Validates: Requirements 1.3, 1.5, 7.4, 7.5**
"""
ctx = ProviderContext(target_id=target_id, scan_id=scan_id)
provider = ListTargetProvider(targets=["example.com"], context=ctx)
assert provider.target_id == target_id
assert provider.scan_id == scan_id
assert provider.context.target_id == target_id
assert provider.context.scan_id == scan_id
@given(
target_id=st.integers(min_value=1, max_value=10000),
scan_id=st.integers(min_value=1, max_value=10000)
)
@settings(max_examples=100)
def test_property_4_database_provider_context_propagation(self, target_id, scan_id):
"""
Property 4: Context Propagation (DatabaseTargetProvider)
Feature: scan-target-provider, Property 4: Context Propagation
**Validates: Requirements 1.3, 1.5, 7.4, 7.5**
"""
ctx = ProviderContext(target_id=999, scan_id=scan_id)
# DatabaseTargetProvider 会覆盖 context 中的 target_id
provider = DatabaseTargetProvider(target_id=target_id, context=ctx)
assert provider.target_id == target_id # 使用构造函数参数
assert provider.scan_id == scan_id # 使用 context 中的值
assert provider.context.target_id == target_id
assert provider.context.scan_id == scan_id
@given(
target_id=st.integers(min_value=1, max_value=10000),
scan_id=st.integers(min_value=1, max_value=10000)
)
@settings(max_examples=100)
def test_property_4_pipeline_provider_context_propagation(self, target_id, scan_id):
"""
Property 4: Context Propagation (PipelineTargetProvider)
Feature: scan-target-provider, Property 4: Context Propagation
**Validates: Requirements 1.3, 1.5, 7.4, 7.5**
"""
ctx = ProviderContext(target_id=target_id, scan_id=scan_id)
stage_output = StageOutput(hosts=["example.com"])
provider = PipelineTargetProvider(previous_output=stage_output, context=ctx)
assert provider.target_id == target_id
assert provider.scan_id == scan_id
assert provider.context.target_id == target_id
assert provider.context.scan_id == scan_id
@given(
target_id=st.integers(min_value=1, max_value=10000),
scan_id=st.integers(min_value=1, max_value=10000)
)
@settings(max_examples=100)
def test_property_4_snapshot_provider_context_propagation(self, target_id, scan_id):
"""
Property 4: Context Propagation (SnapshotTargetProvider)
Feature: scan-target-provider, Property 4: Context Propagation
**Validates: Requirements 1.3, 1.5, 7.4, 7.5**
"""
ctx = ProviderContext(target_id=target_id, scan_id=999)
# SnapshotTargetProvider 会覆盖 context 中的 scan_id
provider = SnapshotTargetProvider(
scan_id=scan_id,
snapshot_type="subdomain",
context=ctx
)
assert provider.target_id == target_id # 使用 context 中的值
assert provider.scan_id == scan_id # 使用构造函数参数
assert provider.context.target_id == target_id
assert provider.context.scan_id == scan_id
class TestNonDatabaseProviderBlacklistFilter:
"""
Property 5: Non-Database Provider Blacklist Filter
*For any* ListTargetProvider 或 PipelineTargetProvider 实例,
get_blacklist_filter() 方法应该返回 None。
**Validates: Requirements 3.4, 9.4, 9.5**
"""
@given(targets=st.lists(st.text(min_size=1, max_size=20), max_size=10))
@settings(max_examples=100)
def test_property_5_list_provider_no_blacklist(self, targets):
"""
Property 5: Non-Database Provider Blacklist Filter (ListTargetProvider)
Feature: scan-target-provider, Property 5: Non-Database Provider Blacklist Filter
**Validates: Requirements 3.4, 9.4, 9.5**
"""
provider = ListTargetProvider(targets=targets)
assert provider.get_blacklist_filter() is None
@given(hosts=st.lists(st.text(min_size=1, max_size=20), max_size=10))
@settings(max_examples=100)
def test_property_5_pipeline_provider_no_blacklist(self, hosts):
"""
Property 5: Non-Database Provider Blacklist Filter (PipelineTargetProvider)
Feature: scan-target-provider, Property 5: Non-Database Provider Blacklist Filter
**Validates: Requirements 3.4, 9.4, 9.5**
"""
stage_output = StageOutput(hosts=hosts)
provider = PipelineTargetProvider(previous_output=stage_output)
assert provider.get_blacklist_filter() is None
def test_property_5_snapshot_provider_no_blacklist(self):
"""
Property 5: Non-Database Provider Blacklist Filter (SnapshotTargetProvider)
Feature: scan-target-provider, Property 5: Non-Database Provider Blacklist Filter
**Validates: Requirements 3.4, 9.4, 9.5**
"""
provider = SnapshotTargetProvider(scan_id=1, snapshot_type="subdomain")
assert provider.get_blacklist_filter() is None
class TestCIDRExpansionConsistency:
"""
Property 7: CIDR Expansion Consistency
*For any* CIDR 字符串(如 "192.168.1.0/24"),所有 Provider 的 iter_hosts()
方法应该将其展开为相同的单个 IP 地址列表。
**Validates: Requirements 1.1, 3.6**
"""
@given(
# 生成小的 CIDR 范围以避免测试超时
network_prefix=st.integers(min_value=1, max_value=254),
cidr_suffix=st.integers(min_value=28, max_value=30) # /28 = 16 IPs, /30 = 4 IPs
)
@settings(max_examples=50, deadline=None)
def test_property_7_cidr_expansion_consistency(self, network_prefix, cidr_suffix):
"""
Property 7: CIDR Expansion Consistency
Feature: scan-target-provider, Property 7: CIDR Expansion Consistency
**Validates: Requirements 1.1, 3.6**
For any CIDR string, all Providers should expand it to the same IP list.
"""
cidr = f"192.168.{network_prefix}.0/{cidr_suffix}"
# 计算预期的 IP 列表
network = IPv4Network(cidr, strict=False)
# 排除网络地址和广播地址
expected_ips = [str(ip) for ip in network.hosts()]
# 如果 CIDR 太小(/31 或 /32使用所有地址
if not expected_ips:
expected_ips = [str(ip) for ip in network]
# ListTargetProvider
list_provider = ListTargetProvider(targets=[cidr])
list_result = list(list_provider.iter_hosts())
# PipelineTargetProvider
stage_output = StageOutput(hosts=[cidr])
pipeline_provider = PipelineTargetProvider(previous_output=stage_output)
pipeline_result = list(pipeline_provider.iter_hosts())
# 验证:所有 Provider 展开的结果应该一致
assert list_result == expected_ips, f"ListProvider CIDR expansion mismatch for {cidr}"
assert pipeline_result == expected_ips, f"PipelineProvider CIDR expansion mismatch for {cidr}"
assert list_result == pipeline_result, f"Providers produce different results for {cidr}"
def test_cidr_expansion_with_multiple_cidrs(self):
"""测试多个 CIDR 的展开一致性"""
cidrs = ["192.168.1.0/30", "10.0.0.0/30"]
# 计算预期结果
expected_ips = []
for cidr in cidrs:
network = IPv4Network(cidr, strict=False)
expected_ips.extend([str(ip) for ip in network.hosts()])
# ListTargetProvider
list_provider = ListTargetProvider(targets=cidrs)
list_result = list(list_provider.iter_hosts())
# PipelineTargetProvider
stage_output = StageOutput(hosts=cidrs)
pipeline_provider = PipelineTargetProvider(previous_output=stage_output)
pipeline_result = list(pipeline_provider.iter_hosts())
# 验证
assert list_result == expected_ips
assert pipeline_result == expected_ips
assert list_result == pipeline_result
def test_mixed_hosts_and_cidrs(self):
"""测试混合主机和 CIDR 的处理"""
targets = ["example.com", "192.168.1.0/30", "test.com"]
# 计算预期结果
network = IPv4Network("192.168.1.0/30", strict=False)
cidr_ips = [str(ip) for ip in network.hosts()]
expected = ["example.com"] + cidr_ips + ["test.com"]
# ListTargetProvider
list_provider = ListTargetProvider(targets=targets)
list_result = list(list_provider.iter_hosts())
# 验证
assert list_result == expected

View File

@@ -1,152 +0,0 @@
"""
ListTargetProvider 属性测试
Property 1: ListTargetProvider Round-Trip
*For any* 主机列表和 URL 列表,创建 ListTargetProvider 后迭代 iter_hosts() 和 iter_urls()
应该返回与输入相同的元素(顺序相同)。
**Validates: Requirements 3.1, 3.2**
"""
import pytest
from hypothesis import given, strategies as st, settings, assume
from apps.scan.providers.list_provider import ListTargetProvider
from apps.scan.providers.base import ProviderContext
# 生成有效域名的策略
def valid_domain_strategy():
"""生成有效的域名"""
# 生成简单的域名格式: subdomain.domain.tld
label = st.text(
alphabet=st.characters(whitelist_categories=('L',), min_codepoint=97, max_codepoint=122),
min_size=2,
max_size=10
)
return st.builds(
lambda a, b, c: f"{a}.{b}.{c}",
label, label, st.sampled_from(['com', 'net', 'org', 'io'])
)
# 生成有效 IP 地址的策略
def valid_ip_strategy():
"""生成有效的 IPv4 地址"""
octet = st.integers(min_value=1, max_value=254)
return st.builds(
lambda a, b, c, d: f"{a}.{b}.{c}.{d}",
octet, octet, octet, octet
)
# 组合策略:域名或 IP
host_strategy = st.one_of(valid_domain_strategy(), valid_ip_strategy())
# 生成有效 URL 的策略
def valid_url_strategy():
"""生成有效的 URL"""
domain = valid_domain_strategy()
return st.builds(
lambda d, path: f"https://{d}/{path}" if path else f"https://{d}",
domain,
st.one_of(
st.just(""),
st.text(
alphabet=st.characters(whitelist_categories=('L',), min_codepoint=97, max_codepoint=122),
min_size=1,
max_size=10
)
)
)
url_strategy = valid_url_strategy()
class TestListTargetProviderProperties:
"""ListTargetProvider 属性测试类"""
@given(hosts=st.lists(host_strategy, max_size=50))
@settings(max_examples=100)
def test_property_1_hosts_round_trip(self, hosts):
"""
Property 1: ListTargetProvider Round-Trip (hosts)
Feature: scan-target-provider, Property 1: ListTargetProvider Round-Trip
**Validates: Requirements 3.1, 3.2**
For any host list, creating a ListTargetProvider and iterating iter_hosts()
should return the same elements in the same order.
"""
# ListTargetProvider 使用 targets 参数,自动分类为 hosts/urls
provider = ListTargetProvider(targets=hosts)
result = list(provider.iter_hosts())
assert result == hosts
@given(urls=st.lists(url_strategy, max_size=50))
@settings(max_examples=100)
def test_property_1_urls_round_trip(self, urls):
"""
Property 1: ListTargetProvider Round-Trip (urls)
Feature: scan-target-provider, Property 1: ListTargetProvider Round-Trip
**Validates: Requirements 3.1, 3.2**
For any URL list, creating a ListTargetProvider and iterating iter_urls()
should return the same elements in the same order.
"""
# ListTargetProvider 使用 targets 参数,自动分类为 hosts/urls
provider = ListTargetProvider(targets=urls)
result = list(provider.iter_urls())
assert result == urls
@given(
hosts=st.lists(host_strategy, max_size=30),
urls=st.lists(url_strategy, max_size=30)
)
@settings(max_examples=100)
def test_property_1_combined_round_trip(self, hosts, urls):
"""
Property 1: ListTargetProvider Round-Trip (combined)
Feature: scan-target-provider, Property 1: ListTargetProvider Round-Trip
**Validates: Requirements 3.1, 3.2**
For any combination of hosts and URLs, both should round-trip correctly.
"""
# 合并 hosts 和 urlsListTargetProvider 会自动分类
combined = hosts + urls
provider = ListTargetProvider(targets=combined)
hosts_result = list(provider.iter_hosts())
urls_result = list(provider.iter_urls())
assert hosts_result == hosts
assert urls_result == urls
class TestListTargetProviderUnit:
"""ListTargetProvider 单元测试类"""
def test_empty_lists(self):
"""测试空列表返回空迭代器 - Requirements 3.5"""
provider = ListTargetProvider()
assert list(provider.iter_hosts()) == []
assert list(provider.iter_urls()) == []
def test_blacklist_filter_returns_none(self):
"""测试黑名单过滤器返回 None - Requirements 3.4"""
provider = ListTargetProvider(targets=["example.com"])
assert provider.get_blacklist_filter() is None
def test_target_id_association(self):
"""测试 target_id 关联 - Requirements 3.3"""
ctx = ProviderContext(target_id=123)
provider = ListTargetProvider(targets=["example.com"], context=ctx)
assert provider.target_id == 123
def test_context_propagation(self):
"""测试上下文传递"""
ctx = ProviderContext(target_id=456, scan_id=789)
provider = ListTargetProvider(targets=["example.com"], context=ctx)
assert provider.target_id == 456
assert provider.scan_id == 789

View File

@@ -1,180 +0,0 @@
"""
PipelineTargetProvider 属性测试
Property 3: PipelineTargetProvider Round-Trip
*For any* StageOutput 对象PipelineTargetProvider 的 iter_hosts() 和 iter_urls()
应该返回与 StageOutput 中 hosts 和 urls 列表相同的元素。
**Validates: Requirements 5.1, 5.2**
"""
import pytest
from hypothesis import given, strategies as st, settings
from apps.scan.providers.pipeline_provider import PipelineTargetProvider, StageOutput
from apps.scan.providers.base import ProviderContext
# 生成有效域名的策略
def valid_domain_strategy():
"""生成有效的域名"""
label = st.text(
alphabet=st.characters(whitelist_categories=('L',), min_codepoint=97, max_codepoint=122),
min_size=2,
max_size=10
)
return st.builds(
lambda a, b, c: f"{a}.{b}.{c}",
label, label, st.sampled_from(['com', 'net', 'org', 'io'])
)
# 生成有效 IP 地址的策略
def valid_ip_strategy():
"""生成有效的 IPv4 地址"""
octet = st.integers(min_value=1, max_value=254)
return st.builds(
lambda a, b, c, d: f"{a}.{b}.{c}.{d}",
octet, octet, octet, octet
)
# 组合策略:域名或 IP
host_strategy = st.one_of(valid_domain_strategy(), valid_ip_strategy())
# 生成有效 URL 的策略
def valid_url_strategy():
"""生成有效的 URL"""
domain = valid_domain_strategy()
return st.builds(
lambda d, path: f"https://{d}/{path}" if path else f"https://{d}",
domain,
st.one_of(
st.just(""),
st.text(
alphabet=st.characters(whitelist_categories=('L',), min_codepoint=97, max_codepoint=122),
min_size=1,
max_size=10
)
)
)
url_strategy = valid_url_strategy()
class TestPipelineTargetProviderProperties:
"""PipelineTargetProvider 属性测试类"""
@given(hosts=st.lists(host_strategy, max_size=50))
@settings(max_examples=100)
def test_property_3_hosts_round_trip(self, hosts):
"""
Property 3: PipelineTargetProvider Round-Trip (hosts)
Feature: scan-target-provider, Property 3: PipelineTargetProvider Round-Trip
**Validates: Requirements 5.1, 5.2**
For any StageOutput with hosts, PipelineTargetProvider should return
the same hosts in the same order.
"""
stage_output = StageOutput(hosts=hosts)
provider = PipelineTargetProvider(previous_output=stage_output)
result = list(provider.iter_hosts())
assert result == hosts
@given(urls=st.lists(url_strategy, max_size=50))
@settings(max_examples=100)
def test_property_3_urls_round_trip(self, urls):
"""
Property 3: PipelineTargetProvider Round-Trip (urls)
Feature: scan-target-provider, Property 3: PipelineTargetProvider Round-Trip
**Validates: Requirements 5.1, 5.2**
For any StageOutput with urls, PipelineTargetProvider should return
the same urls in the same order.
"""
stage_output = StageOutput(urls=urls)
provider = PipelineTargetProvider(previous_output=stage_output)
result = list(provider.iter_urls())
assert result == urls
@given(
hosts=st.lists(host_strategy, max_size=30),
urls=st.lists(url_strategy, max_size=30)
)
@settings(max_examples=100)
def test_property_3_combined_round_trip(self, hosts, urls):
"""
Property 3: PipelineTargetProvider Round-Trip (combined)
Feature: scan-target-provider, Property 3: PipelineTargetProvider Round-Trip
**Validates: Requirements 5.1, 5.2**
For any StageOutput with both hosts and urls, both should round-trip correctly.
"""
stage_output = StageOutput(hosts=hosts, urls=urls)
provider = PipelineTargetProvider(previous_output=stage_output)
hosts_result = list(provider.iter_hosts())
urls_result = list(provider.iter_urls())
assert hosts_result == hosts
assert urls_result == urls
class TestPipelineTargetProviderUnit:
"""PipelineTargetProvider 单元测试类"""
def test_empty_stage_output(self):
"""测试空 StageOutput 返回空迭代器 - Requirements 5.5"""
stage_output = StageOutput()
provider = PipelineTargetProvider(previous_output=stage_output)
assert list(provider.iter_hosts()) == []
assert list(provider.iter_urls()) == []
def test_blacklist_filter_returns_none(self):
"""测试黑名单过滤器返回 None - Requirements 5.3"""
stage_output = StageOutput(hosts=["example.com"])
provider = PipelineTargetProvider(previous_output=stage_output)
assert provider.get_blacklist_filter() is None
def test_target_id_association(self):
"""测试 target_id 关联 - Requirements 5.4"""
stage_output = StageOutput(hosts=["example.com"])
provider = PipelineTargetProvider(previous_output=stage_output, target_id=123)
assert provider.target_id == 123
def test_context_propagation(self):
"""测试上下文传递"""
ctx = ProviderContext(target_id=456, scan_id=789)
stage_output = StageOutput(hosts=["example.com"])
provider = PipelineTargetProvider(previous_output=stage_output, context=ctx)
assert provider.target_id == 456
assert provider.scan_id == 789
def test_previous_output_property(self):
"""测试 previous_output 属性"""
stage_output = StageOutput(hosts=["example.com"], urls=["https://example.com"])
provider = PipelineTargetProvider(previous_output=stage_output)
assert provider.previous_output is stage_output
assert provider.previous_output.hosts == ["example.com"]
assert provider.previous_output.urls == ["https://example.com"]
def test_stage_output_with_metadata(self):
"""测试带元数据的 StageOutput"""
stage_output = StageOutput(
hosts=["example.com"],
urls=["https://example.com"],
new_targets=["new.example.com"],
stats={"count": 1},
success=True,
error=None
)
provider = PipelineTargetProvider(previous_output=stage_output)
assert list(provider.iter_hosts()) == ["example.com"]
assert list(provider.iter_urls()) == ["https://example.com"]
assert provider.previous_output.new_targets == ["new.example.com"]
assert provider.previous_output.stats == {"count": 1}

View File

@@ -1,191 +0,0 @@
"""
SnapshotTargetProvider 单元测试
"""
import pytest
from unittest.mock import Mock, patch
from apps.scan.providers import SnapshotTargetProvider, ProviderContext
class TestSnapshotTargetProvider:
"""SnapshotTargetProvider 测试类"""
def test_init_with_scan_id_and_type(self):
"""测试初始化"""
provider = SnapshotTargetProvider(
scan_id=100,
snapshot_type="subdomain"
)
assert provider.scan_id == 100
assert provider.snapshot_type == "subdomain"
assert provider.target_id is None # 默认 context
def test_init_with_context(self):
"""测试带 context 初始化"""
ctx = ProviderContext(target_id=1, scan_id=100)
provider = SnapshotTargetProvider(
scan_id=100,
snapshot_type="subdomain",
context=ctx
)
assert provider.scan_id == 100
assert provider.target_id == 1
assert provider.snapshot_type == "subdomain"
@patch('apps.asset.services.snapshot.SubdomainSnapshotsService')
def test_iter_hosts_subdomain(self, mock_service_class):
"""测试从子域名快照迭代主机"""
# Mock service
mock_service = Mock()
mock_service.iter_subdomain_names_by_scan.return_value = iter([
"a.example.com",
"b.example.com"
])
mock_service_class.return_value = mock_service
# 创建 provider
provider = SnapshotTargetProvider(
scan_id=100,
snapshot_type="subdomain"
)
# 迭代主机
hosts = list(provider.iter_hosts())
assert hosts == ["a.example.com", "b.example.com"]
mock_service.iter_subdomain_names_by_scan.assert_called_once_with(
scan_id=100,
chunk_size=1000
)
@patch('apps.asset.services.snapshot.HostPortMappingSnapshotsService')
def test_iter_hosts_host_port(self, mock_service_class):
"""测试从主机端口映射快照迭代主机"""
# Mock queryset
mock_mapping1 = Mock()
mock_mapping1.host = "example.com"
mock_mapping1.port = 80
mock_mapping2 = Mock()
mock_mapping2.host = "example.com"
mock_mapping2.port = 443
mock_queryset = Mock()
mock_queryset.iterator.return_value = iter([mock_mapping1, mock_mapping2])
# Mock service
mock_service = Mock()
mock_service.get_by_scan.return_value = mock_queryset
mock_service_class.return_value = mock_service
# 创建 provider
provider = SnapshotTargetProvider(
scan_id=100,
snapshot_type="host_port"
)
# 迭代主机
hosts = list(provider.iter_hosts())
assert hosts == ["example.com:80", "example.com:443"]
mock_service.get_by_scan.assert_called_once_with(scan_id=100)
@patch('apps.asset.services.snapshot.WebsiteSnapshotsService')
def test_iter_urls_website(self, mock_service_class):
"""测试从网站快照迭代 URL"""
# Mock service
mock_service = Mock()
mock_service.iter_website_urls_by_scan.return_value = iter([
"http://example.com",
"https://example.com"
])
mock_service_class.return_value = mock_service
# 创建 provider
provider = SnapshotTargetProvider(
scan_id=100,
snapshot_type="website"
)
# 迭代 URL
urls = list(provider.iter_urls())
assert urls == ["http://example.com", "https://example.com"]
mock_service.iter_website_urls_by_scan.assert_called_once_with(
scan_id=100,
chunk_size=1000
)
@patch('apps.asset.services.snapshot.EndpointSnapshotsService')
def test_iter_urls_endpoint(self, mock_service_class):
"""测试从端点快照迭代 URL"""
# Mock queryset
mock_endpoint1 = Mock()
mock_endpoint1.url = "http://example.com/api/v1"
mock_endpoint2 = Mock()
mock_endpoint2.url = "http://example.com/api/v2"
mock_queryset = Mock()
mock_queryset.iterator.return_value = iter([mock_endpoint1, mock_endpoint2])
# Mock service
mock_service = Mock()
mock_service.get_by_scan.return_value = mock_queryset
mock_service_class.return_value = mock_service
# 创建 provider
provider = SnapshotTargetProvider(
scan_id=100,
snapshot_type="endpoint"
)
# 迭代 URL
urls = list(provider.iter_urls())
assert urls == ["http://example.com/api/v1", "http://example.com/api/v2"]
mock_service.get_by_scan.assert_called_once_with(scan_id=100)
def test_iter_hosts_unsupported_type(self):
"""测试不支持的快照类型iter_hosts"""
provider = SnapshotTargetProvider(
scan_id=100,
snapshot_type="website" # website 不支持 iter_hosts
)
hosts = list(provider.iter_hosts())
assert hosts == []
def test_iter_urls_unsupported_type(self):
"""测试不支持的快照类型iter_urls"""
provider = SnapshotTargetProvider(
scan_id=100,
snapshot_type="subdomain" # subdomain 不支持 iter_urls
)
urls = list(provider.iter_urls())
assert urls == []
def test_get_blacklist_filter(self):
"""测试黑名单过滤器(快照模式不使用黑名单)"""
provider = SnapshotTargetProvider(
scan_id=100,
snapshot_type="subdomain"
)
assert provider.get_blacklist_filter() is None
def test_context_propagation(self):
"""测试上下文传递"""
ctx = ProviderContext(target_id=456, scan_id=789)
provider = SnapshotTargetProvider(
scan_id=100, # 会被 context 覆盖
snapshot_type="subdomain",
context=ctx
)
assert provider.target_id == 456
assert provider.scan_id == 100 # scan_id 在 __init__ 中被设置

View File

@@ -1,189 +0,0 @@
#!/usr/bin/env python
"""
扫描任务启动脚本
用于动态扫描容器启动时执行。
必须在 Django 导入之前获取配置并设置环境变量。
"""
import argparse
import sys
import os
import traceback
def diagnose_prefect_environment():
"""诊断 Prefect 运行环境,输出详细信息用于排查问题"""
print("\n" + "="*60)
print("Prefect 环境诊断")
print("="*60)
# 1. 检查 Prefect 相关环境变量
print("\n[诊断] Prefect 环境变量:")
prefect_vars = [
'PREFECT_HOME',
'PREFECT_API_URL',
'PREFECT_SERVER_EPHEMERAL_ENABLED',
'PREFECT_SERVER_EPHEMERAL_STARTUP_TIMEOUT_SECONDS',
'PREFECT_SERVER_DATABASE_CONNECTION_URL',
'PREFECT_LOGGING_LEVEL',
'PREFECT_DEBUG_MODE',
]
for var in prefect_vars:
value = os.environ.get(var, 'NOT SET')
print(f" {var}={value}")
# 2. 检查 PREFECT_HOME 目录
prefect_home = os.environ.get('PREFECT_HOME', os.path.expanduser('~/.prefect'))
print(f"\n[诊断] PREFECT_HOME 目录: {prefect_home}")
if os.path.exists(prefect_home):
print(f" ✓ 目录存在")
print(f" 可写: {os.access(prefect_home, os.W_OK)}")
try:
files = os.listdir(prefect_home)
print(f" 文件列表: {files[:10]}{'...' if len(files) > 10 else ''}")
except Exception as e:
print(f" ✗ 无法列出文件: {e}")
else:
print(f" 目录不存在,尝试创建...")
try:
os.makedirs(prefect_home, exist_ok=True)
print(f" ✓ 创建成功")
except Exception as e:
print(f" ✗ 创建失败: {e}")
# 3. 检查 uvicorn 是否可用
print(f"\n[诊断] uvicorn 可用性:")
import shutil
uvicorn_path = shutil.which('uvicorn')
if uvicorn_path:
print(f" ✓ uvicorn 路径: {uvicorn_path}")
else:
print(f" ✗ uvicorn 不在 PATH 中")
print(f" PATH: {os.environ.get('PATH', 'NOT SET')}")
# 4. 检查 Prefect 版本
print(f"\n[诊断] Prefect 版本:")
try:
import prefect
print(f" ✓ prefect=={prefect.__version__}")
except Exception as e:
print(f" ✗ 无法导入 prefect: {e}")
# 5. 检查 SQLite 支持
print(f"\n[诊断] SQLite 支持:")
try:
import sqlite3
print(f" ✓ sqlite3 版本: {sqlite3.sqlite_version}")
# 测试创建数据库
test_db = os.path.join(prefect_home, 'test.db')
conn = sqlite3.connect(test_db)
conn.execute('CREATE TABLE IF NOT EXISTS test (id INTEGER)')
conn.close()
os.remove(test_db)
print(f" ✓ SQLite 读写测试通过")
except Exception as e:
print(f" ✗ SQLite 测试失败: {e}")
# 6. 检查端口绑定能力
print(f"\n[诊断] 端口绑定测试:")
try:
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('127.0.0.1', 0))
port = sock.getsockname()[1]
sock.close()
print(f" ✓ 可以绑定 127.0.0.1 端口 (测试端口: {port})")
except Exception as e:
print(f" ✗ 端口绑定失败: {e}")
# 7. 检查内存情况
print(f"\n[诊断] 系统资源:")
try:
import psutil
mem = psutil.virtual_memory()
print(f" 内存总量: {mem.total / 1024 / 1024:.0f} MB")
print(f" 可用内存: {mem.available / 1024 / 1024:.0f} MB")
print(f" 内存使用率: {mem.percent}%")
except ImportError:
print(f" psutil 未安装,跳过内存检查")
except Exception as e:
print(f" ✗ 资源检查失败: {e}")
print("\n" + "="*60)
print("诊断完成")
print("="*60 + "\n")
def main():
print("="*60)
print("run_initiate_scan.py 启动")
print(f" Python: {sys.version}")
print(f" CWD: {os.getcwd()}")
print(f" SERVER_URL: {os.environ.get('SERVER_URL', 'NOT SET')}")
print("="*60)
# 1. 从配置中心获取配置并初始化 Django必须在 Django 导入之前)
print("[1/4] 从配置中心获取配置...")
try:
from apps.common.container_bootstrap import fetch_config_and_setup_django
fetch_config_and_setup_django()
print("[1/4] ✓ 配置获取成功")
except Exception as e:
print(f"[1/4] ✗ 配置获取失败: {e}")
traceback.print_exc()
sys.exit(1)
# 2. 解析命令行参数
print("[2/4] 解析命令行参数...")
parser = argparse.ArgumentParser(description="执行扫描初始化 Flow")
parser.add_argument("--scan_id", type=int, required=True, help="扫描任务 ID")
parser.add_argument("--target_name", type=str, required=True, help="目标名称")
parser.add_argument("--target_id", type=int, required=True, help="目标 ID")
parser.add_argument("--scan_workspace_dir", type=str, required=True, help="扫描工作目录")
parser.add_argument("--engine_name", type=str, required=True, help="引擎名称")
parser.add_argument("--scheduled_scan_name", type=str, default=None, help="定时扫描任务名称(可选)")
args = parser.parse_args()
print(f"[2/4] ✓ 参数解析成功:")
print(f" scan_id: {args.scan_id}")
print(f" target_name: {args.target_name}")
print(f" target_id: {args.target_id}")
print(f" scan_workspace_dir: {args.scan_workspace_dir}")
print(f" engine_name: {args.engine_name}")
print(f" scheduled_scan_name: {args.scheduled_scan_name}")
# 2.5. 运行 Prefect 环境诊断(仅在 DEBUG 模式下)
if os.environ.get('DEBUG', '').lower() == 'true':
diagnose_prefect_environment()
# 3. 现在可以安全导入 Django 相关模块
print("[3/4] 导入 initiate_scan_flow...")
try:
from apps.scan.flows.initiate_scan_flow import initiate_scan_flow
print("[3/4] ✓ 导入成功")
except Exception as e:
print(f"[3/4] ✗ 导入失败: {e}")
traceback.print_exc()
sys.exit(1)
# 4. 执行 Flow
print("[4/4] 执行 initiate_scan_flow...")
try:
result = initiate_scan_flow(
scan_id=args.scan_id,
target_name=args.target_name,
target_id=args.target_id,
scan_workspace_dir=args.scan_workspace_dir,
engine_name=args.engine_name,
scheduled_scan_name=args.scheduled_scan_name,
)
print("[4/4] ✓ Flow 执行完成")
print(f"结果: {result}")
except Exception as e:
print(f"[4/4] ✗ Flow 执行失败: {e}")
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -1,295 +0,0 @@
"""
快速扫描服务
负责解析用户输入URL、域名、IP、CIDR并创建对应的资产数据
"""
import logging
from dataclasses import dataclass
from typing import Optional, Literal, List, Dict, Any
from urllib.parse import urlparse
from django.db import transaction
from apps.common.validators import validate_url, detect_input_type, validate_domain, validate_ip, validate_cidr, is_valid_ip
from apps.targets.services.target_service import TargetService
from apps.targets.models import Target
from apps.asset.dtos import WebSiteDTO
from apps.asset.dtos.asset import EndpointDTO
from apps.asset.repositories.asset.website_repository import DjangoWebSiteRepository
from apps.asset.repositories.asset.endpoint_repository import DjangoEndpointRepository
logger = logging.getLogger(__name__)
@dataclass
class ParsedInputDTO:
"""
解析输入 DTO
只在快速扫描流程中使用
"""
original_input: str
input_type: Literal['url', 'domain', 'ip', 'cidr']
target_name: str # host/domain/ip/cidr
target_type: Literal['domain', 'ip', 'cidr']
website_url: Optional[str] = None # 根 URLscheme://host[:port]
endpoint_url: Optional[str] = None # 完整 URL含路径
is_valid: bool = True
error: Optional[str] = None
line_number: Optional[int] = None
class QuickScanService:
"""快速扫描服务 - 解析输入并创建资产"""
def __init__(self):
self.target_service = TargetService()
self.website_repo = DjangoWebSiteRepository()
self.endpoint_repo = DjangoEndpointRepository()
def parse_inputs(self, inputs: List[str]) -> List[ParsedInputDTO]:
"""
解析多行输入
Args:
inputs: 输入字符串列表(每行一个)
Returns:
解析结果列表(跳过空行)
"""
results = []
for line_number, input_str in enumerate(inputs, start=1):
input_str = input_str.strip()
# 空行跳过
if not input_str:
continue
try:
# 检测输入类型
input_type = detect_input_type(input_str)
if input_type == 'url':
dto = self._parse_url_input(input_str, line_number)
else:
dto = self._parse_target_input(input_str, input_type, line_number)
results.append(dto)
except ValueError as e:
# 解析失败,记录错误
results.append(ParsedInputDTO(
original_input=input_str,
input_type='domain', # 默认类型
target_name=input_str,
target_type='domain',
is_valid=False,
error=str(e),
line_number=line_number
))
return results
def _parse_url_input(self, url_str: str, line_number: int) -> ParsedInputDTO:
"""
解析 URL 输入
Args:
url_str: URL 字符串
line_number: 行号
Returns:
ParsedInputDTO
"""
# 验证 URL 格式
validate_url(url_str)
# 使用标准库解析
parsed = urlparse(url_str)
host = parsed.hostname # 不含端口
has_path = parsed.path and parsed.path != '/'
# 构建 root_url: scheme://host[:port]
root_url = f"{parsed.scheme}://{parsed.netloc}"
# 检测 host 类型domain 或 ip
target_type = 'ip' if is_valid_ip(host) else 'domain'
return ParsedInputDTO(
original_input=url_str,
input_type='url',
target_name=host,
target_type=target_type,
website_url=root_url,
endpoint_url=url_str if has_path else None,
line_number=line_number
)
def _parse_target_input(
self,
input_str: str,
input_type: str,
line_number: int
) -> ParsedInputDTO:
"""
解析非 URL 输入domain/ip/cidr
Args:
input_str: 输入字符串
input_type: 输入类型
line_number: 行号
Returns:
ParsedInputDTO
"""
# 验证格式
if input_type == 'domain':
validate_domain(input_str)
target_type = 'domain'
elif input_type == 'ip':
validate_ip(input_str)
target_type = 'ip'
elif input_type == 'cidr':
validate_cidr(input_str)
target_type = 'cidr'
else:
raise ValueError(f"未知的输入类型: {input_type}")
return ParsedInputDTO(
original_input=input_str,
input_type=input_type,
target_name=input_str,
target_type=target_type,
website_url=None,
endpoint_url=None,
line_number=line_number
)
@transaction.atomic
def process_quick_scan(
self,
inputs: List[str],
engine_id: int
) -> Dict[str, Any]:
"""
处理快速扫描请求
Args:
inputs: 输入字符串列表
engine_id: 扫描引擎 ID
Returns:
处理结果字典
"""
# 1. 解析输入
parsed_inputs = self.parse_inputs(inputs)
# 分离有效和无效输入
valid_inputs = [p for p in parsed_inputs if p.is_valid]
invalid_inputs = [p for p in parsed_inputs if not p.is_valid]
if not valid_inputs:
return {
'targets': [],
'target_stats': {'created': 0, 'reused': 0, 'failed': len(invalid_inputs)},
'asset_stats': {'websites_created': 0, 'endpoints_created': 0},
'errors': [
{'line_number': p.line_number, 'input': p.original_input, 'error': p.error}
for p in invalid_inputs
]
}
# 2. 创建资产
asset_result = self.create_assets_from_parsed_inputs(valid_inputs)
# 3. 返回结果
return {
'targets': asset_result['targets'],
'target_stats': asset_result['target_stats'],
'asset_stats': asset_result['asset_stats'],
'errors': [
{'line_number': p.line_number, 'input': p.original_input, 'error': p.error}
for p in invalid_inputs
]
}
def create_assets_from_parsed_inputs(
self,
parsed_inputs: List[ParsedInputDTO]
) -> Dict[str, Any]:
"""
从解析结果创建资产
Args:
parsed_inputs: 解析结果列表(只包含有效输入)
Returns:
创建结果字典
"""
# 1. 收集所有 target 数据(内存操作,去重)
targets_data = {}
for dto in parsed_inputs:
if dto.target_name not in targets_data:
targets_data[dto.target_name] = {'name': dto.target_name, 'type': dto.target_type}
targets_list = list(targets_data.values())
# 2. 批量创建 Target复用现有方法
target_result = self.target_service.batch_create_targets(targets_list)
# 3. 查询刚创建的 Target建立 name → id 映射
target_names = [d['name'] for d in targets_list]
targets = Target.objects.filter(name__in=target_names)
target_id_map = {t.name: t.id for t in targets}
# 4. 收集 Website DTO内存操作去重
website_dtos = []
seen_websites = set()
for dto in parsed_inputs:
if dto.website_url and dto.website_url not in seen_websites:
seen_websites.add(dto.website_url)
target_id = target_id_map.get(dto.target_name)
if target_id:
website_dtos.append(WebSiteDTO(
target_id=target_id,
url=dto.website_url,
host=dto.target_name
))
# 5. 批量创建 Website存在即跳过
websites_created = 0
if website_dtos:
websites_created = self.website_repo.bulk_create_ignore_conflicts(website_dtos)
# 6. 收集 Endpoint DTO内存操作去重
endpoint_dtos = []
seen_endpoints = set()
for dto in parsed_inputs:
if dto.endpoint_url and dto.endpoint_url not in seen_endpoints:
seen_endpoints.add(dto.endpoint_url)
target_id = target_id_map.get(dto.target_name)
if target_id:
endpoint_dtos.append(EndpointDTO(
target_id=target_id,
url=dto.endpoint_url,
host=dto.target_name
))
# 7. 批量创建 Endpoint存在即跳过
endpoints_created = 0
if endpoint_dtos:
endpoints_created = self.endpoint_repo.bulk_create_ignore_conflicts(endpoint_dtos)
return {
'targets': list(targets),
'target_stats': {
'created': target_result['created_count'],
'reused': 0, # bulk_create 无法区分新建和复用
'failed': target_result['failed_count']
},
'asset_stats': {
'websites_created': websites_created,
'endpoints_created': endpoints_created
}
}

View File

@@ -1,258 +0,0 @@
"""
扫描任务服务
负责 Scan 模型的所有业务逻辑
"""
from __future__ import annotations
import logging
import uuid
from typing import Dict, List, TYPE_CHECKING
from datetime import datetime
from pathlib import Path
from django.conf import settings
from django.db import transaction
from django.db.utils import DatabaseError, IntegrityError, OperationalError
from django.core.exceptions import ValidationError, ObjectDoesNotExist
from apps.scan.models import Scan
from apps.scan.repositories import DjangoScanRepository
from apps.targets.repositories import DjangoTargetRepository, DjangoOrganizationRepository
from apps.engine.repositories import DjangoEngineRepository
from apps.targets.models import Target
from apps.engine.models import ScanEngine
from apps.common.definitions import ScanStatus
logger = logging.getLogger(__name__)
class ScanService:
"""
扫描任务服务(协调者)
职责:
- 协调各个子服务
- 提供统一的公共接口
- 保持向后兼容
注意:
- 具体业务逻辑已拆分到子服务
- 本类主要负责委托和协调
"""
# 终态集合:这些状态一旦设置,不应该被覆盖
FINAL_STATUSES = {
ScanStatus.COMPLETED,
ScanStatus.FAILED,
ScanStatus.CANCELLED
}
def __init__(self):
"""
初始化服务
"""
# 初始化子服务
from apps.scan.services.scan_creation_service import ScanCreationService
from apps.scan.services.scan_state_service import ScanStateService
from apps.scan.services.scan_control_service import ScanControlService
from apps.scan.services.scan_stats_service import ScanStatsService
self.creation_service = ScanCreationService()
self.state_service = ScanStateService()
self.control_service = ScanControlService()
self.stats_service = ScanStatsService()
# 保留 ScanRepository用于 get_scan 方法)
self.scan_repo = DjangoScanRepository()
def get_scan(self, scan_id: int, prefetch_relations: bool) -> Scan | None:
"""
获取扫描任务(包含关联对象)
自动预加载 engine 和 target避免 N+1 查询问题
Args:
scan_id: 扫描任务 ID
Returns:
Scan 对象(包含 engine 和 target或 None
"""
return self.scan_repo.get_by_id(scan_id, prefetch_relations)
def get_all_scans(self, prefetch_relations: bool = True):
return self.scan_repo.get_all(prefetch_relations=prefetch_relations)
def prepare_initiate_scan(
self,
organization_id: int | None = None,
target_id: int | None = None,
engine_id: int | None = None
) -> tuple[List[Target], ScanEngine]:
"""
为创建扫描任务做准备,返回所需的目标列表和扫描引擎
"""
return self.creation_service.prepare_initiate_scan(
organization_id, target_id, engine_id
)
def prepare_initiate_scan_multi_engine(
self,
organization_id: int | None = None,
target_id: int | None = None,
engine_ids: List[int] | None = None
) -> tuple[List[Target], str, List[str], List[int]]:
"""
为创建多引擎扫描任务做准备
Returns:
(目标列表, 合并配置, 引擎名称列表, 引擎ID列表)
"""
return self.creation_service.prepare_initiate_scan_multi_engine(
organization_id, target_id, engine_ids
)
def create_scans(
self,
targets: List[Target],
engine_ids: List[int],
engine_names: List[str],
yaml_configuration: str,
scheduled_scan_name: str | None = None
) -> List[Scan]:
"""批量创建扫描任务(委托给 ScanCreationService"""
return self.creation_service.create_scans(
targets, engine_ids, engine_names, yaml_configuration, scheduled_scan_name
)
# ==================== 状态管理方法(委托给 ScanStateService ====================
def update_status(
self,
scan_id: int,
status: ScanStatus,
error_message: str | None = None,
stopped_at: datetime | None = None
) -> bool:
"""更新 Scan 状态(委托给 ScanStateService"""
return self.state_service.update_status(
scan_id, status, error_message, stopped_at
)
def update_status_if_match(
self,
scan_id: int,
current_status: ScanStatus,
new_status: ScanStatus,
stopped_at: datetime | None = None
) -> bool:
"""条件更新 Scan 状态(委托给 ScanStateService"""
return self.state_service.update_status_if_match(
scan_id, current_status, new_status, stopped_at
)
def update_cached_stats(self, scan_id: int) -> dict | None:
"""更新缓存统计数据(委托给 ScanStateService返回统计数据字典"""
return self.state_service.update_cached_stats(scan_id)
# ==================== 进度跟踪方法(委托给 ScanStateService ====================
def init_stage_progress(self, scan_id: int, stages: list[str]) -> bool:
"""初始化阶段进度(委托给 ScanStateService"""
return self.state_service.init_stage_progress(scan_id, stages)
def start_stage(self, scan_id: int, stage: str) -> bool:
"""开始执行某个阶段(委托给 ScanStateService"""
return self.state_service.start_stage(scan_id, stage)
def complete_stage(self, scan_id: int, stage: str, detail: str | None = None) -> bool:
"""完成某个阶段(委托给 ScanStateService"""
return self.state_service.complete_stage(scan_id, stage, detail)
def fail_stage(self, scan_id: int, stage: str, error: str | None = None) -> bool:
"""标记某个阶段失败(委托给 ScanStateService"""
return self.state_service.fail_stage(scan_id, stage, error)
def cancel_running_stages(self, scan_id: int, final_status: str = "cancelled") -> bool:
"""取消所有正在运行的阶段(委托给 ScanStateService"""
return self.state_service.cancel_running_stages(scan_id, final_status)
# TODO待接入
def add_command_to_scan(self, scan_id: int, stage_name: str, tool_name: str, command: str) -> bool:
"""
增量添加命令到指定扫描阶段
Args:
scan_id: 扫描任务ID
stage_name: 阶段名称(如 'subdomain_discovery', 'port_scan'
tool_name: 工具名称
command: 执行命令
Returns:
bool: 是否成功添加
"""
try:
scan = self.get_scan(scan_id, prefetch_relations=False)
if not scan:
logger.error(f"扫描任务不存在: {scan_id}")
return False
stage_progress = scan.stage_progress or {}
# 确保指定阶段存在
if stage_name not in stage_progress:
stage_progress[stage_name] = {'status': 'running', 'commands': []}
# 确保 commands 列表存在
if 'commands' not in stage_progress[stage_name]:
stage_progress[stage_name]['commands'] = []
# 增量添加命令
command_entry = f"{tool_name}: {command}"
stage_progress[stage_name]['commands'].append(command_entry)
scan.stage_progress = stage_progress
scan.save(update_fields=['stage_progress'])
command_count = len(stage_progress[stage_name]['commands'])
logger.info(f"✓ 记录命令: {stage_name}.{tool_name} (总计: {command_count})")
return True
except Exception as e:
logger.error(f"记录命令失败: {e}")
return False
# ==================== 删除和控制方法(委托给 ScanControlService ====================
def delete_scans_two_phase(self, scan_ids: List[int]) -> dict:
"""两阶段删除扫描任务(委托给 ScanControlService"""
return self.control_service.delete_scans_two_phase(scan_ids)
def stop_scan(self, scan_id: int) -> tuple[bool, int]:
"""停止扫描任务(委托给 ScanControlService"""
return self.control_service.stop_scan(scan_id)
def hard_delete_scans(self, scan_ids: List[int]) -> tuple[int, Dict[str, int]]:
"""
硬删除扫描任务(真正删除数据)
用于 Worker 容器中执行,删除已软删除的扫描及其关联数据。
Args:
scan_ids: 扫描任务 ID 列表
Returns:
(删除数量, 详情字典)
"""
return self.scan_repo.hard_delete_by_ids(scan_ids)
# ==================== 统计方法(委托给 ScanStatsService ====================
def get_statistics(self) -> dict:
"""获取扫描统计数据(委托给 ScanStatsService"""
return self.stats_service.get_statistics()
# 导出接口
__all__ = ['ScanService']

View File

@@ -1,613 +0,0 @@
"""
目标导出服务
提供统一的目标提取和文件导出功能,支持:
- URL 导出(纯导出,不做隐式回退)
- 默认 URL 生成(独立方法)
- 带回退链的 URL 导出(用例层编排)
- 域名/IP 导出(用于端口扫描)
- 黑名单过滤集成
"""
import ipaddress
import logging
from pathlib import Path
from typing import Dict, Any, Optional, List, Iterator, Tuple
from django.db.models import QuerySet
from apps.common.utils import BlacklistFilter
logger = logging.getLogger(__name__)
class DataSource:
"""数据源类型常量"""
ENDPOINT = "endpoint"
WEBSITE = "website"
HOST_PORT = "host_port"
DEFAULT = "default"
def create_export_service(target_id: int) -> 'TargetExportService':
"""
工厂函数:创建带黑名单过滤的导出服务
Args:
target_id: 目标 ID用于加载黑名单规则
Returns:
TargetExportService: 配置好黑名单过滤器的导出服务实例
"""
from apps.common.services import BlacklistService
rules = BlacklistService().get_rules(target_id)
blacklist_filter = BlacklistFilter(rules)
return TargetExportService(blacklist_filter=blacklist_filter)
def _iter_default_urls_from_target(
target_id: int,
blacklist_filter: Optional[BlacklistFilter] = None
) -> Iterator[str]:
"""
内部生成器:从 Target 本身生成默认 URL
根据 Target 类型生成 URL
- DOMAIN: http(s)://domain
- IP: http(s)://ip
- CIDR: 展开为所有 IP 的 http(s)://ip
- URL: 直接使用目标 URL
Args:
target_id: 目标 ID
blacklist_filter: 黑名单过滤器
Yields:
str: URL
"""
from apps.targets.services import TargetService
from apps.targets.models import Target
target_service = TargetService()
target = target_service.get_target(target_id)
if not target:
logger.warning("Target ID %d 不存在,无法生成默认 URL", target_id)
return
target_name = target.name
target_type = target.type
# 根据 Target 类型生成 URL
if target_type == Target.TargetType.DOMAIN:
urls = [f"http://{target_name}", f"https://{target_name}"]
elif target_type == Target.TargetType.IP:
urls = [f"http://{target_name}", f"https://{target_name}"]
elif target_type == Target.TargetType.CIDR:
try:
network = ipaddress.ip_network(target_name, strict=False)
urls = []
for ip in network.hosts():
urls.extend([f"http://{ip}", f"https://{ip}"])
# /32 或 /128 特殊处理
if not urls:
ip = str(network.network_address)
urls = [f"http://{ip}", f"https://{ip}"]
except ValueError as e:
logger.error("CIDR 解析失败: %s - %s", target_name, e)
return
elif target_type == Target.TargetType.URL:
urls = [target_name]
else:
logger.warning("不支持的 Target 类型: %s", target_type)
return
# 过滤并产出
for url in urls:
if blacklist_filter and not blacklist_filter.is_allowed(url):
continue
yield url
def _iter_urls_with_fallback(
target_id: int,
sources: List[str],
blacklist_filter: Optional[BlacklistFilter] = None,
batch_size: int = 1000,
tried_sources: Optional[List[str]] = None
) -> Iterator[Tuple[str, str]]:
"""
内部生成器:流式产出 URL带回退链
按 sources 顺序尝试每个数据源,直到有数据返回。
回退逻辑:
- 数据源有数据且通过过滤 → 产出 URL停止回退
- 数据源有数据但全被过滤 → 不回退,停止(避免意外暴露)
- 数据源为空 → 继续尝试下一个
Args:
target_id: 目标 ID
sources: 数据源优先级列表
blacklist_filter: 黑名单过滤器
batch_size: 批次大小
tried_sources: 可选,用于记录尝试过的数据源(外部传入列表,会被修改)
Yields:
Tuple[str, str]: (url, source) - URL 和来源标识
"""
from apps.asset.models import Endpoint, WebSite
for source in sources:
if tried_sources is not None:
tried_sources.append(source)
has_output = False # 是否有输出(通过过滤的)
has_raw_data = False # 是否有原始数据(过滤前)
if source == DataSource.DEFAULT:
# 默认 URL 生成(从 Target 本身构造,复用共用生成器)
for url in _iter_default_urls_from_target(target_id, blacklist_filter):
has_raw_data = True
has_output = True
yield url, source
# 检查是否有原始数据(需要单独判断,因为生成器可能被过滤后为空)
if not has_raw_data:
# 再次检查 Target 是否存在
from apps.targets.services import TargetService
target = TargetService().get_target(target_id)
has_raw_data = target is not None
if has_raw_data:
if not has_output:
logger.info("%s 有数据但全被黑名单过滤,不回退", source)
return
continue
# 构建对应数据源的 queryset
if source == DataSource.ENDPOINT:
queryset = Endpoint.objects.filter(target_id=target_id).values_list('url', flat=True)
elif source == DataSource.WEBSITE:
queryset = WebSite.objects.filter(target_id=target_id).values_list('url', flat=True)
else:
logger.warning("未知的数据源类型: %s,跳过", source)
continue
for url in queryset.iterator(chunk_size=batch_size):
if url:
has_raw_data = True
if blacklist_filter and not blacklist_filter.is_allowed(url):
continue
has_output = True
yield url, source
# 有原始数据就停止(不管是否被过滤)
if has_raw_data:
if not has_output:
logger.info("%s 有数据但全被黑名单过滤,不回退", source)
return
logger.info("%s 为空,尝试下一个数据源", source)
def get_urls_with_fallback(
target_id: int,
sources: List[str],
batch_size: int = 1000
) -> Dict[str, Any]:
"""
带回退链的 URL 获取用例函数(返回列表)
按 sources 顺序尝试每个数据源,直到有数据返回。
Args:
target_id: 目标 ID
sources: 数据源优先级列表,如 ["website", "endpoint", "default"]
batch_size: 批次大小
Returns:
dict: {
'success': bool,
'urls': List[str],
'total_count': int,
'source': str, # 实际使用的数据源
'tried_sources': List[str], # 尝试过的数据源
}
"""
from apps.common.services import BlacklistService
rules = BlacklistService().get_rules(target_id)
blacklist_filter = BlacklistFilter(rules)
urls = []
actual_source = 'none'
tried_sources = []
for url, source in _iter_urls_with_fallback(target_id, sources, blacklist_filter, batch_size, tried_sources):
urls.append(url)
actual_source = source
if urls:
logger.info("%s 获取 %d 条 URL", actual_source, len(urls))
else:
logger.warning("所有数据源都为空,无法获取 URL")
return {
'success': True,
'urls': urls,
'total_count': len(urls),
'source': actual_source,
'tried_sources': tried_sources,
}
def export_urls_with_fallback(
target_id: int,
output_file: str,
sources: List[str],
batch_size: int = 1000
) -> Dict[str, Any]:
"""
带回退链的 URL 导出用例函数(写入文件)
按 sources 顺序尝试每个数据源,直到有数据返回。
流式写入,内存占用 O(1)。
Args:
target_id: 目标 ID
output_file: 输出文件路径
sources: 数据源优先级列表,如 ["endpoint", "website", "default"]
batch_size: 批次大小
Returns:
dict: {
'success': bool,
'output_file': str,
'total_count': int,
'source': str, # 实际使用的数据源
'tried_sources': List[str], # 尝试过的数据源
}
"""
from apps.common.services import BlacklistService
rules = BlacklistService().get_rules(target_id)
blacklist_filter = BlacklistFilter(rules)
output_path = Path(output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
total_count = 0
actual_source = 'none'
tried_sources = []
with open(output_path, 'w', encoding='utf-8', buffering=8192) as f:
for url, source in _iter_urls_with_fallback(target_id, sources, blacklist_filter, batch_size, tried_sources):
f.write(f"{url}\n")
total_count += 1
actual_source = source
if total_count % 10000 == 0:
logger.info("已导出 %d 个 URL...", total_count)
if total_count > 0:
logger.info("%s 导出 %d 条 URL 到 %s", actual_source, total_count, output_file)
else:
logger.warning("所有数据源都为空,无法导出 URL")
return {
'success': True,
'output_file': str(output_path),
'total_count': total_count,
'source': actual_source,
'tried_sources': tried_sources,
}
class TargetExportService:
"""
目标导出服务 - 提供统一的目标提取和文件导出功能
使用方式:
# 方式 1使用用例函数推荐
from apps.scan.services.target_export_service import export_urls_with_fallback, DataSource
result = export_urls_with_fallback(
target_id=1,
output_file='/path/to/output.txt',
sources=[DataSource.ENDPOINT, DataSource.WEBSITE, DataSource.DEFAULT]
)
# 方式 2直接使用 Service纯导出不带回退
export_service = create_export_service(target_id)
result = export_service.export_urls(target_id, output_path, queryset)
"""
def __init__(self, blacklist_filter: Optional[BlacklistFilter] = None):
"""
初始化导出服务
Args:
blacklist_filter: 黑名单过滤器None 表示禁用过滤
"""
self.blacklist_filter = blacklist_filter
def export_urls(
self,
target_id: int,
output_path: str,
queryset: QuerySet,
url_field: str = 'url',
batch_size: int = 1000
) -> Dict[str, Any]:
"""
纯 URL 导出函数 - 只负责将 queryset 数据写入文件
不做任何隐式回退或默认 URL 生成。
Args:
target_id: 目标 ID
output_path: 输出文件路径
queryset: 数据源 queryset由调用方构建应为 values_list flat=True
url_field: URL 字段名(用于黑名单过滤)
batch_size: 批次大小
Returns:
dict: {
'success': bool,
'output_file': str,
'total_count': int, # 实际写入数量
'queryset_count': int, # 原始数据数量(迭代计数)
'filtered_count': int, # 被黑名单过滤的数量
}
Raises:
IOError: 文件写入失败
"""
output_file = Path(output_path)
output_file.parent.mkdir(parents=True, exist_ok=True)
logger.info("开始导出 URL - target_id=%s, output=%s", target_id, output_path)
total_count = 0
filtered_count = 0
queryset_count = 0
try:
with open(output_file, 'w', encoding='utf-8', buffering=8192) as f:
for url in queryset.iterator(chunk_size=batch_size):
queryset_count += 1
if url:
# 黑名单过滤
if self.blacklist_filter and not self.blacklist_filter.is_allowed(url):
filtered_count += 1
continue
f.write(f"{url}\n")
total_count += 1
if total_count % 10000 == 0:
logger.info("已导出 %d 个 URL...", total_count)
except IOError as e:
logger.error("文件写入失败: %s - %s", output_path, e)
raise
if filtered_count > 0:
logger.info("黑名单过滤: 过滤 %d 个 URL", filtered_count)
logger.info(
"✓ URL 导出完成 - 写入: %d, 原始: %d, 过滤: %d, 文件: %s",
total_count, queryset_count, filtered_count, output_path
)
return {
'success': True,
'output_file': str(output_file),
'total_count': total_count,
'queryset_count': queryset_count,
'filtered_count': filtered_count,
}
def generate_default_urls(
self,
target_id: int,
output_path: str
) -> Dict[str, Any]:
"""
默认 URL 生成器
根据 Target 类型生成默认 URL
- DOMAIN: http(s)://domain
- IP: http(s)://ip
- CIDR: 展开为所有 IP 的 http(s)://ip
- URL: 直接使用目标 URL
Args:
target_id: 目标 ID
output_path: 输出文件路径
Returns:
dict: {
'success': bool,
'output_file': str,
'total_count': int,
}
"""
output_file = Path(output_path)
output_file.parent.mkdir(parents=True, exist_ok=True)
logger.info("生成默认 URL - target_id=%d", target_id)
total_urls = 0
with open(output_file, 'w', encoding='utf-8', buffering=8192) as f:
for url in _iter_default_urls_from_target(target_id, self.blacklist_filter):
f.write(f"{url}\n")
total_urls += 1
if total_urls % 10000 == 0:
logger.info("已生成 %d 个 URL...", total_urls)
logger.info("✓ 默认 URL 生成完成 - 数量: %d", total_urls)
return {
'success': True,
'output_file': str(output_file),
'total_count': total_urls,
}
def export_hosts(
self,
target_id: int,
output_path: str,
batch_size: int = 1000
) -> Dict[str, Any]:
"""
主机列表导出函数(用于端口扫描)
根据 Target 类型选择导出逻辑:
- DOMAIN: 从 Subdomain 表流式导出子域名
- IP: 直接写入 IP 地址
- CIDR: 展开为所有主机 IP
Args:
target_id: 目标 ID
output_path: 输出文件路径
batch_size: 批次大小
Returns:
dict: {
'success': bool,
'output_file': str,
'total_count': int,
'target_type': str
}
"""
from apps.targets.services import TargetService
from apps.targets.models import Target
output_file = Path(output_path)
output_file.parent.mkdir(parents=True, exist_ok=True)
# 获取 Target 信息
target_service = TargetService()
target = target_service.get_target(target_id)
if not target:
raise ValueError(f"Target ID {target_id} 不存在")
target_type = target.type
target_name = target.name
logger.info(
"开始导出主机列表 - Target ID: %d, Name: %s, Type: %s, 输出文件: %s",
target_id, target_name, target_type, output_path
)
total_count = 0
if target_type == Target.TargetType.DOMAIN:
total_count = self._export_domains(target_id, target_name, output_file, batch_size)
type_desc = "域名"
elif target_type == Target.TargetType.IP:
total_count = self._export_ip(target_name, output_file)
type_desc = "IP"
elif target_type == Target.TargetType.CIDR:
total_count = self._export_cidr(target_name, output_file)
type_desc = "CIDR IP"
else:
raise ValueError(f"不支持的目标类型: {target_type}")
logger.info(
"✓ 主机列表导出完成 - 类型: %s, 总数: %d, 文件: %s",
type_desc, total_count, output_path
)
return {
'success': True,
'output_file': str(output_file),
'total_count': total_count,
'target_type': target_type
}
def _export_domains(
self,
target_id: int,
target_name: str,
output_path: Path,
batch_size: int
) -> int:
"""导出域名类型目标的根域名 + 子域名"""
from apps.asset.services.asset.subdomain_service import SubdomainService
subdomain_service = SubdomainService()
domain_iterator = subdomain_service.iter_subdomain_names_by_target(
target_id=target_id,
chunk_size=batch_size
)
total_count = 0
written_domains = set() # 去重(子域名表可能已包含根域名)
with open(output_path, 'w', encoding='utf-8', buffering=8192) as f:
# 1. 先写入根域名
if self._should_write_target(target_name):
f.write(f"{target_name}\n")
written_domains.add(target_name)
total_count += 1
# 2. 再写入子域名(跳过已写入的根域名)
for domain_name in domain_iterator:
if domain_name in written_domains:
continue
if self._should_write_target(domain_name):
f.write(f"{domain_name}\n")
written_domains.add(domain_name)
total_count += 1
if total_count % 10000 == 0:
logger.info("已导出 %d 个域名...", total_count)
return total_count
def _export_ip(self, target_name: str, output_path: Path) -> int:
"""导出 IP 类型目标"""
if self._should_write_target(target_name):
with open(output_path, 'w', encoding='utf-8') as f:
f.write(f"{target_name}\n")
return 1
return 0
def _export_cidr(self, target_name: str, output_path: Path) -> int:
"""导出 CIDR 类型目标,展开为每个 IP"""
network = ipaddress.ip_network(target_name, strict=False)
total_count = 0
with open(output_path, 'w', encoding='utf-8', buffering=8192) as f:
for ip in network.hosts():
ip_str = str(ip)
if self._should_write_target(ip_str):
f.write(f"{ip_str}\n")
total_count += 1
if total_count % 10000 == 0:
logger.info("已导出 %d 个 IP...", total_count)
# /32 或 /128 特殊处理
if total_count == 0:
ip_str = str(network.network_address)
if self._should_write_target(ip_str):
with open(output_path, 'w', encoding='utf-8') as f:
f.write(f"{ip_str}\n")
total_count = 1
return total_count
def _should_write_target(self, target: str) -> bool:
"""检查目标是否应该写入(通过黑名单过滤)"""
if self.blacklist_filter:
return self.blacklist_filter.is_allowed(target)
return True

View File

@@ -1,116 +0,0 @@
"""
导出站点 URL 到 TXT 文件的 Task
支持两种模式:
1. 传统模式(向后兼容):使用 target_id 从数据库导出
2. Provider 模式:使用 TargetProvider 从任意数据源导出
数据源: WebSite.url → Default
"""
import logging
from typing import Optional
from pathlib import Path
from prefect import task
from apps.scan.services.target_export_service import (
export_urls_with_fallback,
DataSource,
)
from apps.scan.providers import TargetProvider
logger = logging.getLogger(__name__)
@task(name="export_sites")
def export_sites_task(
target_id: Optional[int] = None,
output_file: str = "",
provider: Optional[TargetProvider] = None,
batch_size: int = 1000,
) -> dict:
"""
导出目标下的所有站点 URL 到 TXT 文件
支持两种模式:
1. 传统模式(向后兼容):传入 target_id从数据库导出
2. Provider 模式:传入 provider从任意数据源导出
数据源优先级(回退链,仅传统模式):
1. WebSite 表 - 站点级别 URL
2. 默认生成 - 根据 Target 类型生成 http(s)://target_name
Args:
target_id: 目标 ID传统模式向后兼容
output_file: 输出文件路径(绝对路径)
provider: TargetProvider 实例(新模式)
batch_size: 每次读取的批次大小,默认 1000
Returns:
dict: {
'success': bool,
'output_file': str,
'total_count': int
}
Raises:
ValueError: 参数错误
IOError: 文件写入失败
"""
# 参数验证:至少提供一个
if target_id is None and provider is None:
raise ValueError("必须提供 target_id 或 provider 参数之一")
# Provider 模式:使用 TargetProvider 导出
if provider is not None:
logger.info("使用 Provider 模式 - Provider: %s", type(provider).__name__)
return _export_with_provider(output_file, provider)
# 传统模式:使用 export_urls_with_fallback
logger.info("使用传统模式 - Target ID: %d", target_id)
result = export_urls_with_fallback(
target_id=target_id,
output_file=output_file,
sources=[DataSource.WEBSITE, DataSource.DEFAULT],
batch_size=batch_size,
)
logger.info(
"站点 URL 导出完成 - source=%s, count=%d",
result['source'], result['total_count']
)
# 保持返回值格式不变(向后兼容)
return {
'success': result['success'],
'output_file': result['output_file'],
'total_count': result['total_count'],
}
def _export_with_provider(output_file: str, provider: TargetProvider) -> dict:
"""使用 Provider 导出 URL"""
output_path = Path(output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
total_count = 0
blacklist_filter = provider.get_blacklist_filter()
with open(output_path, 'w', encoding='utf-8', buffering=8192) as f:
for url in provider.iter_urls():
# 应用黑名单过滤(如果有)
if blacklist_filter and not blacklist_filter.is_allowed(url):
continue
f.write(f"{url}\n")
total_count += 1
if total_count % 1000 == 0:
logger.info("已导出 %d 个 URL...", total_count)
logger.info("✓ URL 导出完成 - 总数: %d, 文件: %s", total_count, str(output_path))
return {
'success': True,
'output_file': str(output_path),
'total_count': total_count,
}

View File

@@ -1,112 +0,0 @@
"""
导出 URL 任务
支持两种模式:
1. 传统模式(向后兼容):使用 target_id 从数据库导出
2. Provider 模式:使用 TargetProvider 从任意数据源导出
用于指纹识别前导出目标下的 URL 到文件
"""
import logging
from typing import Optional
from pathlib import Path
from prefect import task
from apps.scan.services.target_export_service import (
export_urls_with_fallback,
DataSource,
)
from apps.scan.providers import TargetProvider, DatabaseTargetProvider
logger = logging.getLogger(__name__)
@task(name="export_urls_for_fingerprint")
def export_urls_for_fingerprint_task(
target_id: Optional[int] = None,
output_file: str = "",
source: str = 'website', # 保留参数,兼容旧调用(实际值由回退链决定)
provider: Optional[TargetProvider] = None,
batch_size: int = 1000
) -> dict:
"""
导出目标下的 URL 到文件(用于指纹识别)
支持两种模式:
1. 传统模式(向后兼容):传入 target_id从数据库导出
2. Provider 模式:传入 provider从任意数据源导出
数据源优先级(回退链,仅传统模式):
1. WebSite 表 - 站点级别 URL
2. 默认生成 - 根据 Target 类型生成 http(s)://target_name
Args:
target_id: 目标 ID传统模式向后兼容
output_file: 输出文件路径
source: 数据源类型(保留参数,兼容旧调用,实际值由回退链决定)
provider: TargetProvider 实例(新模式)
batch_size: 批量读取大小
Returns:
dict: {'output_file': str, 'total_count': int, 'source': str}
"""
# 参数验证:至少提供一个
if target_id is None and provider is None:
raise ValueError("必须提供 target_id 或 provider 参数之一")
# Provider 模式:使用 TargetProvider 导出
if provider is not None:
logger.info("使用 Provider 模式 - Provider: %s", type(provider).__name__)
return _export_with_provider(output_file, provider)
# 传统模式:使用 export_urls_with_fallback
logger.info("使用传统模式 - Target ID: %d", target_id)
result = export_urls_with_fallback(
target_id=target_id,
output_file=output_file,
sources=[DataSource.WEBSITE, DataSource.DEFAULT],
batch_size=batch_size,
)
logger.info(
"指纹识别 URL 导出完成 - source=%s, count=%d",
result['source'], result['total_count']
)
# 返回实际使用的数据源(不再固定为 "website"
return {
'output_file': result['output_file'],
'total_count': result['total_count'],
'source': result['source'],
}
def _export_with_provider(output_file: str, provider: TargetProvider) -> dict:
"""使用 Provider 导出 URL"""
output_path = Path(output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
total_count = 0
blacklist_filter = provider.get_blacklist_filter()
with open(output_path, 'w', encoding='utf-8', buffering=8192) as f:
for url in provider.iter_urls():
# 应用黑名单过滤(如果有)
if blacklist_filter and not blacklist_filter.is_allowed(url):
continue
f.write(f"{url}\n")
total_count += 1
if total_count % 1000 == 0:
logger.info("已导出 %d 个 URL...", total_count)
logger.info("✓ URL 导出完成 - 总数: %d, 文件: %s", total_count, str(output_path))
return {
'output_file': str(output_path),
'total_count': total_count,
'source': 'provider',
}

View File

@@ -1,99 +0,0 @@
"""
导出主机列表到 TXT 文件的 Task
支持两种模式:
1. 传统模式(向后兼容):使用 target_id 从数据库导出
2. Provider 模式:使用 TargetProvider 从任意数据源导出
根据 Target 类型决定导出内容:
- DOMAIN: 从 Subdomain 表导出子域名
- IP: 直接写入 target.name
- CIDR: 展开 CIDR 范围内的所有 IP
"""
import logging
from pathlib import Path
from typing import Optional
from prefect import task
from apps.scan.providers import DatabaseTargetProvider, TargetProvider
logger = logging.getLogger(__name__)
@task(name="export_hosts")
def export_hosts_task(
output_file: str,
target_id: Optional[int] = None,
provider: Optional[TargetProvider] = None,
) -> dict:
"""
导出主机列表到 TXT 文件
支持两种模式:
1. 传统模式(向后兼容):传入 target_id从数据库导出
2. Provider 模式:传入 provider从任意数据源导出
根据 Target 类型自动决定导出内容:
- DOMAIN: 从 Subdomain 表导出子域名(流式处理,支持 10万+ 域名)
- IP: 直接写入 target.name单个 IP
- CIDR: 展开 CIDR 范围内的所有可用 IP
Args:
output_file: 输出文件路径(绝对路径)
target_id: 目标 ID传统模式向后兼容
provider: TargetProvider 实例(新模式)
Returns:
dict: {
'success': bool,
'output_file': str,
'total_count': int,
'target_type': str # 仅传统模式返回
}
Raises:
ValueError: 参数错误target_id 和 provider 都未提供)
IOError: 文件写入失败
"""
if target_id is None and provider is None:
raise ValueError("必须提供 target_id 或 provider 参数之一")
# 向后兼容:如果没有提供 provider使用 target_id 创建 DatabaseTargetProvider
use_legacy_mode = provider is None
if use_legacy_mode:
logger.info("使用传统模式 - Target ID: %d", target_id)
provider = DatabaseTargetProvider(target_id=target_id)
else:
logger.info("使用 Provider 模式 - Provider: %s", type(provider).__name__)
# 确保输出目录存在
output_path = Path(output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
# 使用 Provider 导出主机列表iter_hosts 内部已处理黑名单过滤)
total_count = 0
with open(output_path, 'w', encoding='utf-8', buffering=8192) as f:
for host in provider.iter_hosts():
f.write(f"{host}\n")
total_count += 1
if total_count % 1000 == 0:
logger.info("已导出 %d 个主机...", total_count)
logger.info("✓ 主机列表导出完成 - 总数: %d, 文件: %s", total_count, str(output_path))
result = {
'success': True,
'output_file': str(output_path),
'total_count': total_count,
}
# 传统模式:保持返回值格式不变(向后兼容)
if use_legacy_mode:
from apps.targets.services import TargetService
target = TargetService().get_target(target_id)
result['target_type'] = target.type if target else 'unknown'
return result

View File

@@ -1,208 +0,0 @@
"""
导出站点URL到文件的Task
支持两种模式:
1. 传统模式(向后兼容):使用 target_id 从数据库导出
2. Provider 模式:使用 TargetProvider 从任意数据源导出
特殊逻辑:
- 80 端口:只生成 HTTP URL省略端口号
- 443 端口:只生成 HTTPS URL省略端口号
- 其他端口:生成 HTTP 和 HTTPS 两个URL带端口号
"""
import logging
from typing import Optional
from pathlib import Path
from prefect import task
from apps.asset.services import HostPortMappingService
from apps.scan.services.target_export_service import create_export_service
from apps.common.services import BlacklistService
from apps.common.utils import BlacklistFilter
from apps.scan.providers import TargetProvider, DatabaseTargetProvider, ProviderContext
logger = logging.getLogger(__name__)
def _generate_urls_from_port(host: str, port: int) -> list[str]:
"""
根据端口生成 URL 列表
- 80 端口:只生成 HTTP URL省略端口号
- 443 端口:只生成 HTTPS URL省略端口号
- 其他端口:生成 HTTP 和 HTTPS 两个URL带端口号
"""
if port == 80:
return [f"http://{host}"]
elif port == 443:
return [f"https://{host}"]
else:
return [f"http://{host}:{port}", f"https://{host}:{port}"]
@task(name="export_site_urls")
def export_site_urls_task(
output_file: str,
target_id: Optional[int] = None,
provider: Optional[TargetProvider] = None,
batch_size: int = 1000
) -> dict:
"""
导出目标下的所有站点URL到文件
支持两种模式:
1. 传统模式(向后兼容):传入 target_id从 HostPortMapping 表导出
2. Provider 模式:传入 provider从任意数据源导出
传统模式特殊逻辑:
- 80 端口:只生成 HTTP URL省略端口号
- 443 端口:只生成 HTTPS URL省略端口号
- 其他端口:生成 HTTP 和 HTTPS 两个URL带端口号
回退逻辑(仅传统模式):
- 如果 HostPortMapping 为空,使用 generate_default_urls() 生成默认 URL
Args:
output_file: 输出文件路径(绝对路径)
target_id: 目标ID传统模式向后兼容
provider: TargetProvider 实例(新模式)
batch_size: 每次处理的批次大小
Returns:
dict: {
'success': bool,
'output_file': str,
'total_urls': int,
'association_count': int, # 主机端口关联数量(仅传统模式)
'source': str, # 数据来源: "host_port" | "default" | "provider"
}
Raises:
ValueError: 参数错误
IOError: 文件写入失败
"""
# 参数验证:至少提供一个
if target_id is None and provider is None:
raise ValueError("必须提供 target_id 或 provider 参数之一")
# 向后兼容:如果没有提供 provider使用传统模式
if provider is None:
logger.info("使用传统模式 - Target ID: %d, 输出文件: %s", target_id, output_file)
return _export_site_urls_legacy(target_id, output_file, batch_size)
# Provider 模式
logger.info("使用 Provider 模式 - Provider: %s, 输出文件: %s", type(provider).__name__, output_file)
# 确保输出目录存在
output_path = Path(output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
# 使用 Provider 导出 URL 列表
total_urls = 0
blacklist_filter = provider.get_blacklist_filter()
with open(output_path, 'w', encoding='utf-8', buffering=8192) as f:
for url in provider.iter_urls():
# 应用黑名单过滤(如果有)
if blacklist_filter and not blacklist_filter.is_allowed(url):
continue
f.write(f"{url}\n")
total_urls += 1
if total_urls % 1000 == 0:
logger.info("已导出 %d 个URL...", total_urls)
logger.info("✓ URL导出完成 - 总数: %d, 文件: %s", total_urls, str(output_path))
return {
'success': True,
'output_file': str(output_path),
'total_urls': total_urls,
'source': 'provider',
}
def _export_site_urls_legacy(target_id: int, output_file: str, batch_size: int) -> dict:
"""
传统模式:从 HostPortMapping 表导出 URL
保持原有逻辑不变,确保向后兼容
"""
logger.info("开始统计站点URL - Target ID: %d, 输出文件: %s", target_id, output_file)
# 确保输出目录存在
output_path = Path(output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
# 获取规则并创建过滤器
blacklist_filter = BlacklistFilter(BlacklistService().get_rules(target_id))
# 直接查询 HostPortMapping 表,按 host 排序
service = HostPortMappingService()
associations = service.iter_host_port_by_target(
target_id=target_id,
batch_size=batch_size,
)
total_urls = 0
association_count = 0
filtered_count = 0
# 流式写入文件(特殊端口逻辑)
with open(output_path, 'w', encoding='utf-8', buffering=8192) as f:
for assoc in associations:
association_count += 1
host = assoc['host']
port = assoc['port']
# 先校验 host通过了再生成 URL
if not blacklist_filter.is_allowed(host):
filtered_count += 1
continue
# 根据端口号生成URL
for url in _generate_urls_from_port(host, port):
f.write(f"{url}\n")
total_urls += 1
if association_count % 1000 == 0:
logger.info("已处理 %d 条关联,生成 %d 个URL...", association_count, total_urls)
if filtered_count > 0:
logger.info("黑名单过滤: 过滤 %d 条关联", filtered_count)
logger.info(
"✓ 站点URL导出完成 - 关联数: %d, 总URL数: %d, 文件: %s",
association_count, total_urls, str(output_path)
)
# 判断数据来源
source = "host_port"
# 数据存在但全被过滤,不回退
if association_count > 0 and total_urls == 0:
logger.info("HostPortMapping 有 %d 条数据,但全被黑名单过滤,不回退", association_count)
return {
'success': True,
'output_file': str(output_path),
'total_urls': 0,
'association_count': association_count,
'source': source,
}
# 数据源为空,回退到默认 URL 生成
if total_urls == 0:
logger.info("HostPortMapping 为空,使用默认 URL 生成")
export_service = create_export_service(target_id)
result = export_service.generate_default_urls(target_id, str(output_path))
total_urls = result['total_count']
source = "default"
return {
'success': True,
'output_file': str(output_path),
'total_urls': total_urls,
'association_count': association_count,
'source': source,
}

View File

@@ -1,120 +0,0 @@
"""
导出站点 URL 列表任务
支持两种模式:
1. 传统模式(向后兼容):使用 target_id 从数据库导出
2. Provider 模式:使用 TargetProvider 从任意数据源导出
数据源: WebSite.url → Default用于 katana 等爬虫工具)
"""
import logging
from typing import Optional
from pathlib import Path
from prefect import task
from apps.scan.services.target_export_service import (
export_urls_with_fallback,
DataSource,
)
from apps.scan.providers import TargetProvider, DatabaseTargetProvider
logger = logging.getLogger(__name__)
@task(
name='export_sites_for_url_fetch',
retries=1,
log_prints=True
)
def export_sites_task(
output_file: str,
target_id: Optional[int] = None,
scan_id: Optional[int] = None,
provider: Optional[TargetProvider] = None,
batch_size: int = 1000
) -> dict:
"""
导出站点 URL 列表到文件(用于 katana 等爬虫工具)
支持两种模式:
1. 传统模式(向后兼容):传入 target_id从数据库导出
2. Provider 模式:传入 provider从任意数据源导出
数据源优先级(回退链,仅传统模式):
1. WebSite 表 - 站点级别 URL
2. 默认生成 - 根据 Target 类型生成 http(s)://target_name
Args:
output_file: 输出文件路径
target_id: 目标 ID传统模式向后兼容
scan_id: 扫描 ID保留参数兼容旧调用
provider: TargetProvider 实例(新模式)
batch_size: 批次大小(内存优化)
Returns:
dict: {
'output_file': str, # 输出文件路径
'asset_count': int, # 资产数量
}
Raises:
ValueError: 参数错误
RuntimeError: 执行失败
"""
# 参数验证:至少提供一个
if target_id is None and provider is None:
raise ValueError("必须提供 target_id 或 provider 参数之一")
# Provider 模式:使用 TargetProvider 导出
if provider is not None:
logger.info("使用 Provider 模式 - Provider: %s", type(provider).__name__)
return _export_with_provider(output_file, provider)
# 传统模式:使用 export_urls_with_fallback
logger.info("使用传统模式 - Target ID: %d", target_id)
result = export_urls_with_fallback(
target_id=target_id,
output_file=output_file,
sources=[DataSource.WEBSITE, DataSource.DEFAULT],
batch_size=batch_size,
)
logger.info(
"站点 URL 导出完成 - source=%s, count=%d",
result['source'], result['total_count']
)
# 保持返回值格式不变(向后兼容)
return {
'output_file': result['output_file'],
'asset_count': result['total_count'],
}
def _export_with_provider(output_file: str, provider: TargetProvider) -> dict:
"""使用 Provider 导出 URL"""
output_path = Path(output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
total_count = 0
blacklist_filter = provider.get_blacklist_filter()
with open(output_path, 'w', encoding='utf-8', buffering=8192) as f:
for url in provider.iter_urls():
# 应用黑名单过滤(如果有)
if blacklist_filter and not blacklist_filter.is_allowed(url):
continue
f.write(f"{url}\n")
total_count += 1
if total_count % 1000 == 0:
logger.info("已导出 %d 个 URL...", total_count)
logger.info("✓ URL 导出完成 - 总数: %d, 文件: %s", total_count, str(output_path))
return {
'output_file': str(output_path),
'asset_count': total_count,
}

View File

@@ -1,118 +0,0 @@
"""导出 Endpoint URL 到文件的 Task
支持两种模式:
1. 传统模式(向后兼容):使用 target_id 从数据库导出
2. Provider 模式:使用 TargetProvider 从任意数据源导出
数据源优先级(回退链,仅传统模式):
1. Endpoint.url - 最精细的 URL含路径、参数等
2. WebSite.url - 站点级别 URL
3. 默认生成 - 根据 Target 类型生成 http(s)://target_name
"""
import logging
from typing import Dict, Optional
from pathlib import Path
from prefect import task
from apps.scan.services.target_export_service import (
export_urls_with_fallback,
DataSource,
)
from apps.scan.providers import TargetProvider, DatabaseTargetProvider
logger = logging.getLogger(__name__)
@task(name="export_endpoints")
def export_endpoints_task(
target_id: Optional[int] = None,
output_file: str = "",
provider: Optional[TargetProvider] = None,
batch_size: int = 1000,
) -> Dict[str, object]:
"""导出目标下的所有 Endpoint URL 到文本文件。
支持两种模式:
1. 传统模式(向后兼容):传入 target_id从数据库导出
2. Provider 模式:传入 provider从任意数据源导出
数据源优先级(回退链,仅传统模式):
1. Endpoint 表 - 最精细的 URL含路径、参数等
2. WebSite 表 - 站点级别 URL
3. 默认生成 - 根据 Target 类型生成 http(s)://target_name
Args:
target_id: 目标 ID传统模式向后兼容
output_file: 输出文件路径(绝对路径)
provider: TargetProvider 实例(新模式)
batch_size: 每次从数据库迭代的批大小
Returns:
dict: {
"success": bool,
"output_file": str,
"total_count": int,
"source": str, # 数据来源: "endpoint" | "website" | "default" | "none" | "provider"
}
"""
# 参数验证:至少提供一个
if target_id is None and provider is None:
raise ValueError("必须提供 target_id 或 provider 参数之一")
# Provider 模式:使用 TargetProvider 导出
if provider is not None:
logger.info("使用 Provider 模式 - Provider: %s", type(provider).__name__)
return _export_with_provider(output_file, provider)
# 传统模式:使用 export_urls_with_fallback
logger.info("使用传统模式 - Target ID: %d", target_id)
result = export_urls_with_fallback(
target_id=target_id,
output_file=output_file,
sources=[DataSource.ENDPOINT, DataSource.WEBSITE, DataSource.DEFAULT],
batch_size=batch_size,
)
logger.info(
"URL 导出完成 - source=%s, count=%d, tried=%s",
result['source'], result['total_count'], result['tried_sources']
)
return {
"success": result['success'],
"output_file": result['output_file'],
"total_count": result['total_count'],
"source": result['source'],
}
def _export_with_provider(output_file: str, provider: TargetProvider) -> Dict[str, object]:
"""使用 Provider 导出 URL"""
output_path = Path(output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
total_count = 0
blacklist_filter = provider.get_blacklist_filter()
with open(output_path, 'w', encoding='utf-8', buffering=8192) as f:
for url in provider.iter_urls():
# 应用黑名单过滤(如果有)
if blacklist_filter and not blacklist_filter.is_allowed(url):
continue
f.write(f"{url}\n")
total_count += 1
if total_count % 1000 == 0:
logger.info("已导出 %d 个 URL...", total_count)
logger.info("✓ URL 导出完成 - 总数: %d, 文件: %s", total_count, str(output_path))
return {
"success": True,
"output_file": str(output_path),
"total_count": total_count,
"source": "provider",
}

View File

@@ -1,497 +0,0 @@
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.exceptions import NotFound, APIException
from rest_framework.filters import SearchFilter
from django_filters.rest_framework import DjangoFilterBackend
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
from apps.scan.utils.config_merger import ConfigConflictError
logger = logging.getLogger(__name__)
from ..models import Scan, ScheduledScan
from ..serializers import (
ScanSerializer, ScanHistorySerializer, QuickScanSerializer,
InitiateScanSerializer, ScheduledScanSerializer, CreateScheduledScanSerializer,
UpdateScheduledScanSerializer, ToggleScheduledScanSerializer
)
from ..services.scan_service import ScanService
from ..services.scheduled_scan_service import ScheduledScanService
from ..repositories import ScheduledScanDTO
from apps.targets.services.target_service import TargetService
from apps.targets.services.organization_service import OrganizationService
from apps.engine.services.engine_service import EngineService
from apps.common.definitions import ScanStatus
from apps.common.pagination import BasePagination
class ScanViewSet(viewsets.ModelViewSet):
"""扫描任务视图集"""
serializer_class = ScanSerializer
pagination_class = BasePagination
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['target'] # 支持 ?target=123 过滤
search_fields = ['target__name'] # 按目标名称搜索
def get_queryset(self):
"""优化查询集提升API性能
查询优化策略:
- select_related: 预加载 target 和 engine一对一/多对一关系,使用 JOIN
- 移除 prefetch_related: 避免加载大量资产数据到内存
- order_by: 按创建时间降序排列(最新创建的任务排在最前面)
性能优化原理:
- 列表页使用缓存统计字段cached_*_count避免实时 COUNT 查询
- 序列化器:严格验证缓存字段,确保数据一致性
- 分页场景每页只显示10条记录查询高效
- 避免大数据加载:不再预加载所有关联的资产数据
"""
# 只保留必要的 select_related移除所有 prefetch_related
scan_service = ScanService()
queryset = scan_service.get_all_scans(prefetch_relations=True)
return queryset
def get_serializer_class(self):
"""根据不同的 action 返回不同的序列化器
- list action: 使用 ScanHistorySerializer包含 summary 和 progress
- retrieve action: 使用 ScanHistorySerializer包含 summary 和 progress
- 其他 action: 使用标准的 ScanSerializer
"""
if self.action in ['list', 'retrieve']:
return ScanHistorySerializer
return ScanSerializer
def destroy(self, request, *args, **kwargs):
"""
删除单个扫描任务(两阶段删除)
1. 软删除:立即对用户不可见
2. 硬删除:后台异步执行
"""
try:
scan = self.get_object()
scan_service = ScanService()
result = scan_service.delete_scans_two_phase([scan.id])
return success_response(
data={
'scanId': scan.id,
'deletedCount': result['soft_deleted_count'],
'deletedScans': result['scan_names']
}
)
except Scan.DoesNotExist:
return error_response(
code=ErrorCodes.NOT_FOUND,
status_code=status.HTTP_404_NOT_FOUND
)
except ValueError as e:
return error_response(
code=ErrorCodes.NOT_FOUND,
message=str(e),
status_code=status.HTTP_404_NOT_FOUND
)
except Exception as e:
logger.exception("删除扫描任务时发生错误")
return error_response(
code=ErrorCodes.SERVER_ERROR,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(detail=False, methods=['post'])
def quick(self, request):
"""
快速扫描接口
功能:
1. 接收目标列表和 YAML 配置
2. 自动解析输入(支持 URL、域名、IP、CIDR
3. 批量创建 Target、Website、Endpoint 资产
4. 立即发起批量扫描
请求参数:
{
"targets": [{"name": "example.com"}, {"name": "https://example.com/api"}],
"configuration": "subdomain_discovery:\n enabled: true\n ...",
"engine_ids": [1, 2], // 可选,用于记录
"engine_names": ["引擎A", "引擎B"] // 可选,用于记录
}
支持的输入格式:
- 域名: example.com
- IP: 192.168.1.1
- CIDR: 10.0.0.0/8
- URL: https://example.com/api/v1
"""
from ..services.quick_scan_service import QuickScanService
serializer = QuickScanSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
targets_data = serializer.validated_data['targets']
configuration = serializer.validated_data['configuration']
engine_ids = serializer.validated_data.get('engine_ids', [])
engine_names = serializer.validated_data.get('engine_names', [])
try:
# 提取输入字符串列表
inputs = [t['name'] for t in targets_data]
# 1. 使用 QuickScanService 解析输入并创建资产
quick_scan_service = QuickScanService()
result = quick_scan_service.process_quick_scan(inputs, engine_ids[0] if engine_ids else None)
targets = result['targets']
if not targets:
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. 直接使用前端传递的配置创建扫描
scan_service = ScanService()
created_scans = scan_service.create_scans(
targets=targets,
engine_ids=engine_ids,
engine_names=engine_names,
yaml_configuration=configuration
)
# 检查是否成功创建扫描任务
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 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 error_response(
code=ErrorCodes.VALIDATION_ERROR,
message=str(e),
status_code=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
logger.exception("快速扫描启动失败")
return error_response(
code=ErrorCodes.SERVER_ERROR,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(detail=False, methods=['post'])
def initiate(self, request):
"""
发起扫描任务
请求参数:
- organization_id: 组织ID (int, 可选)
- target_id: 目标ID (int, 可选)
- configuration: YAML 配置字符串 (str, 必填)
- engine_ids: 扫描引擎ID列表 (list[int], 必填)
- engine_names: 引擎名称列表 (list[str], 必填)
注意: organization_id 和 target_id 二选一
返回:
- 扫描任务详情(单个或多个)
"""
# 使用 serializer 验证请求数据
serializer = InitiateScanSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# 获取验证后的数据
organization_id = serializer.validated_data.get('organization_id')
target_id = serializer.validated_data.get('target_id')
configuration = serializer.validated_data['configuration']
engine_ids = serializer.validated_data['engine_ids']
engine_names = serializer.validated_data['engine_names']
try:
# 获取目标列表
scan_service = ScanService()
if organization_id:
from apps.targets.repositories import DjangoOrganizationRepository
org_repo = DjangoOrganizationRepository()
organization = org_repo.get_by_id(organization_id)
if not organization:
raise ObjectDoesNotExist(f'Organization ID {organization_id} 不存在')
targets = org_repo.get_targets(organization_id)
if not targets:
raise ValidationError(f'组织 ID {organization_id} 下没有目标')
else:
from apps.targets.repositories import DjangoTargetRepository
target_repo = DjangoTargetRepository()
target = target_repo.get_by_id(target_id)
if not target:
raise ObjectDoesNotExist(f'Target ID {target_id} 不存在')
targets = [target]
# 直接使用前端传递的配置创建扫描
created_scans = scan_service.create_scans(
targets=targets,
engine_ids=engine_ids,
engine_names=engine_names,
yaml_configuration=configuration
)
# 检查是否成功创建扫描任务
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 success_response(
data={
'count': len(created_scans),
'scans': scan_serializer.data
},
status_code=status.HTTP_201_CREATED
)
except ObjectDoesNotExist as e:
# 资源不存在错误(由 service 层抛出)
return error_response(
code=ErrorCodes.NOT_FOUND,
message=str(e),
status_code=status.HTTP_404_NOT_FOUND
)
except ValidationError as e:
# 参数验证错误(由 service 层抛出)
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message=str(e),
status_code=status.HTTP_400_BAD_REQUEST
)
except (DatabaseError, IntegrityError, OperationalError):
# 数据库错误
return error_response(
code=ErrorCodes.SERVER_ERROR,
message='Database error',
status_code=status.HTTP_503_SERVICE_UNAVAILABLE
)
# 所有快照相关的 action 和 export 已迁移到 asset/views.py 中的快照 ViewSet
# GET /api/scans/{id}/subdomains/ -> SubdomainSnapshotViewSet
# GET /api/scans/{id}/subdomains/export/ -> SubdomainSnapshotViewSet.export
# GET /api/scans/{id}/websites/ -> WebsiteSnapshotViewSet
# GET /api/scans/{id}/websites/export/ -> WebsiteSnapshotViewSet.export
# GET /api/scans/{id}/directories/ -> DirectorySnapshotViewSet
# GET /api/scans/{id}/directories/export/ -> DirectorySnapshotViewSet.export
# GET /api/scans/{id}/endpoints/ -> EndpointSnapshotViewSet
# GET /api/scans/{id}/endpoints/export/ -> EndpointSnapshotViewSet.export
# GET /api/scans/{id}/ip-addresses/ -> HostPortMappingSnapshotViewSet
# GET /api/scans/{id}/ip-addresses/export/ -> HostPortMappingSnapshotViewSet.export
# GET /api/scans/{id}/vulnerabilities/ -> VulnerabilitySnapshotViewSet
@action(detail=False, methods=['post', 'delete'], url_path='bulk-delete')
def bulk_delete(self, request):
"""
批量删除扫描记录
请求参数:
- ids: 扫描ID列表 (list[int], 必填)
示例请求:
POST /api/scans/bulk-delete/
{
"ids": [1, 2, 3]
}
返回:
- message: 成功消息
- deletedCount: 实际删除的记录数
注意:
- 使用级联删除,会同时删除关联的子域名、端点等数据
- 只删除存在的记录不存在的ID会被忽略
"""
ids = request.data.get('ids', [])
# 参数验证
if not ids:
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 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 error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='All elements in ids array must be integers',
status_code=status.HTTP_400_BAD_REQUEST
)
try:
# 使用 Service 层批量删除(两阶段删除)
scan_service = ScanService()
result = scan_service.delete_scans_two_phase(ids)
return success_response(
data={
'deletedCount': result['soft_deleted_count'],
'deletedScans': result['scan_names']
}
)
except ValueError as e:
# 未找到记录
return error_response(
code=ErrorCodes.NOT_FOUND,
message=str(e),
status_code=status.HTTP_404_NOT_FOUND
)
except Exception as e:
logger.exception("批量删除扫描任务时发生错误")
return error_response(
code=ErrorCodes.SERVER_ERROR,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(detail=False, methods=['get'])
def statistics(self, request):
"""
获取扫描统计数据
返回扫描任务的汇总统计信息,用于仪表板和扫描历史页面。
使用缓存字段聚合查询,性能优异。
返回:
- total: 总扫描次数
- running: 运行中的扫描数量
- completed: 已完成的扫描数量
- failed: 失败的扫描数量
- totalVulns: 总共发现的漏洞数量
- totalSubdomains: 总共发现的子域名数量
- totalEndpoints: 总共发现的端点数量
- totalAssets: 总资产数
"""
try:
# 使用 Service 层获取统计数据
scan_service = ScanService()
stats = scan_service.get_statistics()
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 error_response(
code=ErrorCodes.SERVER_ERROR,
message='Database error',
status_code=status.HTTP_503_SERVICE_UNAVAILABLE
)
@action(detail=True, methods=['post'])
def stop(self, request, pk=None): # pylint: disable=unused-argument
"""
停止扫描任务
URL: POST /api/scans/{id}/stop/
功能:
- 终止正在运行或初始化的扫描任务
- 更新扫描状态为 CANCELLED
状态限制:
- 只能停止 RUNNING 或 INITIATED 状态的扫描
- 已完成、失败或取消的扫描无法停止
返回:
- message: 成功消息
- revokedTaskCount: 取消的 Flow Run 数量
"""
try:
# 使用 Service 层处理停止逻辑
scan_service = ScanService()
success, revoked_count = scan_service.stop_scan(scan_id=pk)
if not success:
# 检查是否是状态不允许的问题
scan = scan_service.get_scan(scan_id=pk, prefetch_relations=False)
if scan and scan.status not in [ScanStatus.RUNNING, ScanStatus.INITIATED]:
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 error_response(
code=ErrorCodes.SERVER_ERROR,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return success_response(
data={'revokedTaskCount': revoked_count}
)
except ObjectDoesNotExist:
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 error_response(
code=ErrorCodes.SERVER_ERROR,
message='Database error',
status_code=status.HTTP_503_SERVICE_UNAVAILABLE
)

View File

@@ -11,7 +11,7 @@ import { DashboardDataTable } from "@/components/dashboard/dashboard-data-table"
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">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 animate-dashboard-fade-in">
{/* Top statistics cards */}
<DashboardStatCards />

View File

@@ -40,8 +40,11 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
title: t('title'),
description: t('description'),
keywords: t('keywords').split(',').map(k => k.trim()),
generator: "Xingrin ASM Platform",
generator: "Orbit ASM Platform",
authors: [{ name: "yyhuni" }],
icons: {
icon: [{ url: "/icon.svg", type: "image/svg+xml" }],
},
openGraph: {
title: t('ogTitle'),
description: t('ogDescription'),

View File

@@ -3,125 +3,226 @@
import React from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import Lottie from "lottie-react"
import securityAnimation from "@/public/animations/Security000-Purple.json"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent } from "@/components/ui/card"
import {
Field,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Spinner } from "@/components/ui/spinner"
import { useQueryClient } from "@tanstack/react-query"
import dynamic from "next/dynamic"
import { LoginBootScreen } from "@/components/auth/login-boot-screen"
import { TerminalLogin } from "@/components/ui/terminal-login"
import { useLogin, useAuth } from "@/hooks/use-auth"
import { vulnerabilityKeys } from "@/hooks/use-vulnerabilities"
import { useRoutePrefetch } from "@/hooks/use-route-prefetch"
import { getAssetStatistics, getStatisticsHistory } from "@/services/dashboard.service"
import { getScans } from "@/services/scan.service"
import { VulnerabilityService } from "@/services/vulnerability.service"
// Dynamic import to avoid SSR issues with WebGL
const PixelBlast = dynamic(() => import("@/components/PixelBlast"), { ssr: false })
const BOOT_SPLASH_MS = 600
const BOOT_FADE_MS = 200
type BootOverlayPhase = "entering" | "visible" | "leaving" | "hidden"
export default function LoginPage() {
// Preload all page components on login page
useRoutePrefetch()
const router = useRouter()
const queryClient = useQueryClient()
const { data: auth, isLoading: authLoading } = useAuth()
const { mutate: login, isPending } = useLogin()
const t = useTranslations("auth")
const [username, setUsername] = React.useState("")
const [password, setPassword] = React.useState("")
const { mutateAsync: login, isPending } = useLogin()
const t = useTranslations("auth.terminal")
const loginStartedRef = React.useRef(false)
const [loginReady, setLoginReady] = React.useState(false)
const [pixelFirstFrame, setPixelFirstFrame] = React.useState(false)
const handlePixelFirstFrame = React.useCallback(() => {
setPixelFirstFrame(true)
}, [])
// 提取预加载逻辑为可复用函数
const prefetchDashboardData = React.useCallback(async () => {
const scansParams = { page: 1, pageSize: 10 }
const vulnsParams = { page: 1, pageSize: 10 }
return Promise.allSettled([
queryClient.prefetchQuery({
queryKey: ["asset", "statistics"],
queryFn: getAssetStatistics,
}),
queryClient.prefetchQuery({
queryKey: ["asset", "statistics", "history", 7],
queryFn: () => getStatisticsHistory(7),
}),
queryClient.prefetchQuery({
queryKey: ["scans", scansParams],
queryFn: () => getScans(scansParams),
}),
queryClient.prefetchQuery({
queryKey: vulnerabilityKeys.list(vulnsParams),
queryFn: () => VulnerabilityService.getAllVulnerabilities(vulnsParams),
}),
])
}, [queryClient])
// Always show a short splash on entering the login page.
const [bootMinDone, setBootMinDone] = React.useState(false)
const [bootPhase, setBootPhase] = React.useState<BootOverlayPhase>("entering")
// If already logged in, redirect to dashboard
React.useEffect(() => {
if (auth?.authenticated) {
router.push("/dashboard/")
setBootMinDone(false)
setBootPhase("entering")
const bootTimer = setTimeout(() => setBootMinDone(true), BOOT_SPLASH_MS)
const raf = requestAnimationFrame(() => setBootPhase("visible"))
return () => {
clearTimeout(bootTimer)
cancelAnimationFrame(raf)
}
}, [auth, router])
}, [])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
login({ username, password })
// Start hiding the splash after the minimum time AND auth check completes.
// Note: don't schedule the fade-out timer in the same effect where we set `bootPhase`,
// otherwise the effect cleanup will cancel the timer when `bootPhase` changes.
React.useEffect(() => {
if (bootPhase !== "visible") return
if (!bootMinDone) return
if (authLoading) return
if (!pixelFirstFrame) return
setBootPhase("leaving")
}, [authLoading, bootMinDone, bootPhase, pixelFirstFrame])
React.useEffect(() => {
if (bootPhase !== "leaving") return
const timer = setTimeout(() => setBootPhase("hidden"), BOOT_FADE_MS)
return () => clearTimeout(timer)
}, [bootPhase])
// Memoize translations object to avoid recreating on every render
const translations = React.useMemo(() => ({
title: t("title"),
subtitle: t("subtitle"),
usernamePrompt: t("usernamePrompt"),
passwordPrompt: t("passwordPrompt"),
authenticating: t("authenticating"),
processing: t("processing"),
accessGranted: t("accessGranted"),
welcomeMessage: t("welcomeMessage"),
authFailed: t("authFailed"),
invalidCredentials: t("invalidCredentials"),
shortcuts: t("shortcuts"),
submit: t("submit"),
cancel: t("cancel"),
clear: t("clear"),
startEnd: t("startEnd"),
}), [t])
// If already logged in, warm up the dashboard, then redirect.
React.useEffect(() => {
if (authLoading) return
if (!auth?.authenticated) return
if (loginStartedRef.current) return
let cancelled = false
void (async () => {
await prefetchDashboardData()
if (cancelled) return
router.replace("/dashboard/")
})()
return () => {
cancelled = true
}
}, [auth?.authenticated, authLoading, prefetchDashboardData, router])
React.useEffect(() => {
if (!loginReady) return
router.replace("/dashboard/")
}, [loginReady, router])
const handleLogin = async (username: string, password: string) => {
loginStartedRef.current = true
setLoginReady(false)
// 并行执行独立操作:登录验证 + 预加载 dashboard bundle
const [loginRes] = await Promise.all([
login({ username, password }),
router.prefetch("/dashboard/"),
])
// 预加载 dashboard 数据
await prefetchDashboardData()
// Prime auth cache so AuthLayout doesn't flash a full-screen loading state.
queryClient.setQueryData(["auth", "me"], {
authenticated: true,
user: loginRes.user,
})
setLoginReady(true)
}
// 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">
<Spinner className="size-8 text-primary" />
<p className="text-muted-foreground text-sm" suppressHydrationWarning>loading...</p>
</div>
)
}
// Don't show login page if already logged in
if (auth?.authenticated) {
return null
}
const loginVisible = bootPhase === "leaving" || bootPhase === "hidden"
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>
{/* 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>
<p className="text-sm text-muted-foreground mt-1">
{t("subtitle")}
</p>
</div>
<Field>
<FieldLabel htmlFor="username">{t("username")}</FieldLabel>
<Input
id="username"
type="text"
placeholder={t("usernamePlaceholder")}
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
/>
</Field>
<Field>
<FieldLabel htmlFor="password">{t("password")}</FieldLabel>
<Input
id="password"
type="password"
placeholder={t("passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</Field>
<Field>
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? t("loggingIn") : t("login")}
</Button>
</Field>
</FieldGroup>
</form>
<div className="bg-primary/5 relative hidden md:flex md:items-center md:justify-center">
<div className="text-center p-4">
<Lottie
animationData={securityAnimation}
loop={true}
className="w-96 h-96 mx-auto"
/>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="relative flex min-h-svh flex-col bg-black">
<div className={`fixed inset-0 z-0 transition-opacity duration-300 ${loginVisible ? "opacity-100" : "opacity-0"}`}>
<PixelBlast
onFirstFrame={handlePixelFirstFrame}
className=""
style={{}}
pixelSize={6.5}
patternScale={4.5}
color="#FF10F0"
speed={0.35}
enableRipples={false}
/>
</div>
{/* Fingerprint identifier - for FOFA/Shodan and other search engines to identify */}
<meta name="generator" content="Orbit ASM Platform" />
{/* Main content area */}
<div
className={`relative z-10 flex-1 flex items-center justify-center p-6 transition-[opacity,transform] duration-300 ${
loginVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2"
}`}
>
<TerminalLogin
onLogin={handleLogin}
authDone={loginReady}
isPending={isPending}
translations={translations}
/>
</div>
{/* Version number - fixed at the bottom of the page */}
<div className="flex-shrink-0 text-center py-4">
<div
className={`relative z-10 flex-shrink-0 text-center py-4 transition-opacity duration-300 ${
loginVisible ? "opacity-100" : "opacity-0"
}`}
>
<p className="text-xs text-muted-foreground">
{process.env.NEXT_PUBLIC_VERSION || 'dev'}
{process.env.NEXT_PUBLIC_VERSION || "dev"}
</p>
</div>
{/* Full-page splash overlay */}
{bootPhase !== "hidden" && (
<div
className={`fixed inset-0 z-50 transition-opacity ease-out ${
bootPhase === "visible" ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
style={{ transitionDuration: `${BOOT_FADE_MS}ms` }}
>
<LoginBootScreen />
</div>
)}
</div>
)
}

View File

@@ -3,7 +3,7 @@
import React from "react"
import { usePathname, useParams } from "next/navigation"
import Link from "next/link"
import { Target, LayoutDashboard, Package, Image, ShieldAlert } from "lucide-react"
import { Target, LayoutDashboard, Package, FolderSearch, Image, ShieldAlert } from "lucide-react"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
@@ -23,6 +23,7 @@ export default function ScanHistoryLayout({
// Get primary navigation active tab
const getPrimaryTab = () => {
if (pathname.includes("/overview")) return "overview"
if (pathname.includes("/directories")) return "directories"
if (pathname.includes("/screenshots")) return "screenshots"
if (pathname.includes("/vulnerabilities")) return "vulnerabilities"
// All asset pages fall under "assets"
@@ -30,8 +31,7 @@ export default function ScanHistoryLayout({
pathname.includes("/websites") ||
pathname.includes("/subdomain") ||
pathname.includes("/ip-addresses") ||
pathname.includes("/endpoints") ||
pathname.includes("/directories")
pathname.includes("/endpoints")
) {
return "assets"
}
@@ -44,7 +44,6 @@ export default function ScanHistoryLayout({
if (pathname.includes("/subdomain")) return "subdomain"
if (pathname.includes("/ip-addresses")) return "ip-addresses"
if (pathname.includes("/endpoints")) return "endpoints"
if (pathname.includes("/directories")) return "directories"
return "websites"
}
@@ -55,6 +54,7 @@ export default function ScanHistoryLayout({
const primaryPaths = {
overview: `${basePath}/overview/`,
assets: `${basePath}/websites/`, // Default to websites when clicking assets
directories: `${basePath}/directories/`,
screenshots: `${basePath}/screenshots/`,
vulnerabilities: `${basePath}/vulnerabilities/`,
}
@@ -64,23 +64,22 @@ export default function ScanHistoryLayout({
subdomain: `${basePath}/subdomain/`,
"ip-addresses": `${basePath}/ip-addresses/`,
endpoints: `${basePath}/endpoints/`,
directories: `${basePath}/directories/`,
}
// Get counts for each tab from scan data
const summary = scanData?.summary as any
const stats = scanData?.cachedStats
const counts = {
subdomain: summary?.subdomains || 0,
endpoints: summary?.endpoints || 0,
websites: summary?.websites || 0,
directories: summary?.directories || 0,
screenshots: summary?.screenshots || 0,
vulnerabilities: summary?.vulnerabilities?.total || 0,
"ip-addresses": summary?.ips || 0,
subdomain: stats?.subdomainsCount || 0,
endpoints: stats?.endpointsCount || 0,
websites: stats?.websitesCount || 0,
directories: stats?.directoriesCount || 0,
screenshots: stats?.screenshotsCount || 0,
vulnerabilities: stats?.vulnsTotal || 0,
"ip-addresses": stats?.ipsCount || 0,
}
// Calculate total assets count
const totalAssets = counts.websites + counts.subdomain + counts["ip-addresses"] + counts.endpoints + counts.directories
const totalAssets = counts.websites + counts.subdomain + counts["ip-addresses"] + counts.endpoints
// Loading state
if (isLoading) {
@@ -135,6 +134,17 @@ export default function ScanHistoryLayout({
)}
</Link>
</TabsTrigger>
<TabsTrigger value="directories" asChild>
<Link href={primaryPaths.directories} className="flex items-center gap-1.5">
<FolderSearch className="h-4 w-4" />
{t("tabs.directories")}
{counts.directories > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.directories}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="screenshots" asChild>
<Link href={primaryPaths.screenshots} className="flex items-center gap-1.5">
<Image className="h-4 w-4" />
@@ -168,7 +178,7 @@ export default function ScanHistoryLayout({
<TabsList variant="underline">
<TabsTrigger value="websites" variant="underline" asChild>
<Link href={secondaryPaths.websites} className="flex items-center gap-0.5">
Websites
{t("tabs.websites")}
{counts.websites > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.websites}
@@ -178,7 +188,7 @@ export default function ScanHistoryLayout({
</TabsTrigger>
<TabsTrigger value="subdomain" variant="underline" asChild>
<Link href={secondaryPaths.subdomain} className="flex items-center gap-0.5">
Subdomains
{t("tabs.subdomains")}
{counts.subdomain > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.subdomain}
@@ -188,7 +198,7 @@ export default function ScanHistoryLayout({
</TabsTrigger>
<TabsTrigger value="ip-addresses" variant="underline" asChild>
<Link href={secondaryPaths["ip-addresses"]} className="flex items-center gap-0.5">
IPs
{t("tabs.ips")}
{counts["ip-addresses"] > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts["ip-addresses"]}
@@ -198,7 +208,7 @@ export default function ScanHistoryLayout({
</TabsTrigger>
<TabsTrigger value="endpoints" variant="underline" asChild>
<Link href={secondaryPaths.endpoints} className="flex items-center gap-0.5">
URLs
{t("tabs.urls")}
{counts.endpoints > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.endpoints}
@@ -206,16 +216,6 @@ export default function ScanHistoryLayout({
)}
</Link>
</TabsTrigger>
<TabsTrigger value="directories" variant="underline" asChild>
<Link href={secondaryPaths.directories} className="flex items-center gap-0.5">
Directories
{counts.directories > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.directories}
</Badge>
)}
</Link>
</TabsTrigger>
</TabsList>
</Tabs>
</div>

View File

@@ -29,6 +29,10 @@ export default function NotificationSettingsPage() {
enabled: z.boolean(),
webhookUrl: z.string().url(t("discord.urlInvalid")).or(z.literal('')),
}),
wecom: z.object({
enabled: z.boolean(),
webhookUrl: z.string().url(t("wecom.urlInvalid")).or(z.literal('')),
}),
categories: z.object({
scan: z.boolean(),
vulnerability: z.boolean(),
@@ -46,6 +50,15 @@ export default function NotificationSettingsPage() {
})
}
}
if (val.wecom.enabled) {
if (!val.wecom.webhookUrl || val.wecom.webhookUrl.trim() === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("wecom.requiredError"),
path: ['wecom', 'webhookUrl'],
})
}
}
})
const NOTIFICATION_CATEGORIES = [
@@ -79,6 +92,7 @@ export default function NotificationSettingsPage() {
resolver: zodResolver(schema),
values: data ?? {
discord: { enabled: false, webhookUrl: '' },
wecom: { enabled: false, webhookUrl: '' },
categories: {
scan: true,
vulnerability: true,
@@ -93,6 +107,7 @@ export default function NotificationSettingsPage() {
}
const discordEnabled = form.watch('discord.enabled')
const wecomEnabled = form.watch('wecom.enabled')
return (
<div className="p-4 md:p-6 space-y-6">
@@ -187,25 +202,59 @@ export default function NotificationSettingsPage() {
</CardHeader>
</Card>
{/* Feishu/DingTalk/WeCom - Coming soon */}
<Card className="opacity-60">
{/* 企业微信 */}
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<IconBrandSlack className="h-5 w-5 text-muted-foreground" />
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[#07C160]/10">
<IconBrandSlack className="h-5 w-5 text-[#07C160]" />
</div>
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-base">{t("enterprise.title")}</CardTitle>
<Badge variant="secondary" className="text-xs">{t("emailChannel.comingSoon")}</Badge>
</div>
<CardDescription>{t("enterprise.description")}</CardDescription>
<CardTitle className="text-base">{t("wecom.title")}</CardTitle>
<CardDescription>{t("wecom.description")}</CardDescription>
</div>
</div>
<Switch disabled />
<FormField
control={form.control}
name="wecom.enabled"
render={({ field }) => (
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={isLoading || updateMutation.isPending}
/>
</FormControl>
)}
/>
</div>
</CardHeader>
{wecomEnabled && (
<CardContent className="pt-0">
<Separator className="mb-4" />
<FormField
control={form.control}
name="wecom.webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>{t("wecom.webhookLabel")}</FormLabel>
<FormControl>
<Input
placeholder={t("wecom.webhookPlaceholder")}
{...field}
disabled={isLoading || updateMutation.isPending}
/>
</FormControl>
<FormDescription>
{t("wecom.webhookHelp")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
)}
</Card>
</TabsContent>

View File

@@ -3,10 +3,16 @@
import React from "react"
import { usePathname, useParams } from "next/navigation"
import Link from "next/link"
import { Target, LayoutDashboard, Package, Image, ShieldAlert, Settings } from "lucide-react"
import { Target, LayoutDashboard, Package, FolderSearch, Image, ShieldAlert, Settings, HelpCircle } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Badge } from "@/components/ui/badge"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useTarget } from "@/hooks/use-targets"
import { useTranslations } from "next-intl"
@@ -34,6 +40,7 @@ export default function TargetLayout({
// Get primary navigation active tab
const getPrimaryTab = () => {
if (pathname.includes("/overview")) return "overview"
if (pathname.includes("/directories")) return "directories"
if (pathname.includes("/screenshots")) return "screenshots"
if (pathname.includes("/vulnerabilities")) return "vulnerabilities"
if (pathname.includes("/settings")) return "settings"
@@ -42,8 +49,7 @@ export default function TargetLayout({
pathname.includes("/websites") ||
pathname.includes("/subdomain") ||
pathname.includes("/ip-addresses") ||
pathname.includes("/endpoints") ||
pathname.includes("/directories")
pathname.includes("/endpoints")
) {
return "assets"
}
@@ -56,7 +62,6 @@ export default function TargetLayout({
if (pathname.includes("/subdomain")) return "subdomain"
if (pathname.includes("/ip-addresses")) return "ip-addresses"
if (pathname.includes("/endpoints")) return "endpoints"
if (pathname.includes("/directories")) return "directories"
return "websites"
}
@@ -68,6 +73,7 @@ export default function TargetLayout({
const primaryPaths = {
overview: `${basePath}/overview/`,
assets: `${basePath}/websites/`, // Default to websites when clicking assets
directories: `${basePath}/directories/`,
screenshots: `${basePath}/screenshots/`,
vulnerabilities: `${basePath}/vulnerabilities/`,
settings: `${basePath}/settings/`,
@@ -78,7 +84,6 @@ export default function TargetLayout({
subdomain: `${basePath}/subdomain/`,
"ip-addresses": `${basePath}/ip-addresses/`,
endpoints: `${basePath}/endpoints/`,
directories: `${basePath}/directories/`,
}
// Get counts for each tab from target data
@@ -93,7 +98,7 @@ export default function TargetLayout({
}
// Calculate total assets count
const totalAssets = counts.websites + counts.subdomain + counts["ip-addresses"] + counts.endpoints + counts.directories
const totalAssets = counts.websites + counts.subdomain + counts["ip-addresses"] + counts.endpoints
// Loading state
if (isLoading) {
@@ -161,56 +166,82 @@ export default function TargetLayout({
</div>
{/* Primary navigation */}
<div className="px-4 lg:px-6">
<Tabs value={getPrimaryTab()}>
<TabsList>
<TabsTrigger value="overview" asChild>
<Link href={primaryPaths.overview} className="flex items-center gap-1.5">
<LayoutDashboard className="h-4 w-4" />
{t("tabs.overview")}
</Link>
</TabsTrigger>
<TabsTrigger value="assets" asChild>
<Link href={primaryPaths.assets} className="flex items-center gap-1.5">
<Package className="h-4 w-4" />
{t("tabs.assets")}
{totalAssets > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{totalAssets}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="screenshots" asChild>
<Link href={primaryPaths.screenshots} className="flex items-center gap-1.5">
<Image className="h-4 w-4" />
{t("tabs.screenshots")}
{counts.screenshots > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.screenshots}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="vulnerabilities" asChild>
<Link href={primaryPaths.vulnerabilities} className="flex items-center gap-1.5">
<ShieldAlert className="h-4 w-4" />
{t("tabs.vulnerabilities")}
{counts.vulnerabilities > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.vulnerabilities}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="settings" asChild>
<Link href={primaryPaths.settings} className="flex items-center gap-1.5">
<Settings className="h-4 w-4" />
{t("tabs.settings")}
</Link>
</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex items-center justify-between px-4 lg:px-6">
<div className="flex items-center gap-3">
<Tabs value={getPrimaryTab()}>
<TabsList>
<TabsTrigger value="overview" asChild>
<Link href={primaryPaths.overview} className="flex items-center gap-1.5">
<LayoutDashboard className="h-4 w-4" />
{t("tabs.overview")}
</Link>
</TabsTrigger>
<TabsTrigger value="assets" asChild>
<Link href={primaryPaths.assets} className="flex items-center gap-1.5">
<Package className="h-4 w-4" />
{t("tabs.assets")}
{totalAssets > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{totalAssets}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="directories" asChild>
<Link href={primaryPaths.directories} className="flex items-center gap-1.5">
<FolderSearch className="h-4 w-4" />
{t("tabs.directories")}
{counts.directories > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.directories}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="screenshots" asChild>
<Link href={primaryPaths.screenshots} className="flex items-center gap-1.5">
<Image className="h-4 w-4" />
{t("tabs.screenshots")}
{counts.screenshots > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.screenshots}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="vulnerabilities" asChild>
<Link href={primaryPaths.vulnerabilities} className="flex items-center gap-1.5">
<ShieldAlert className="h-4 w-4" />
{t("tabs.vulnerabilities")}
{counts.vulnerabilities > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.vulnerabilities}
</Badge>
)}
</Link>
</TabsTrigger>
<TabsTrigger value="settings" asChild>
<Link href={primaryPaths.settings} className="flex items-center gap-1.5">
<Settings className="h-4 w-4" />
{t("tabs.settings")}
</Link>
</TabsTrigger>
</TabsList>
</Tabs>
{getPrimaryTab() === "directories" && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-sm">
{t("directoriesHelp")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
{/* Secondary navigation (only for assets) */}
@@ -220,7 +251,7 @@ export default function TargetLayout({
<TabsList variant="underline">
<TabsTrigger value="websites" variant="underline" asChild>
<Link href={secondaryPaths.websites} className="flex items-center gap-0.5">
Websites
{t("tabs.websites")}
{counts.websites > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.websites}
@@ -230,7 +261,7 @@ export default function TargetLayout({
</TabsTrigger>
<TabsTrigger value="subdomain" variant="underline" asChild>
<Link href={secondaryPaths.subdomain} className="flex items-center gap-0.5">
Subdomains
{t("tabs.subdomains")}
{counts.subdomain > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.subdomain}
@@ -240,7 +271,7 @@ export default function TargetLayout({
</TabsTrigger>
<TabsTrigger value="ip-addresses" variant="underline" asChild>
<Link href={secondaryPaths["ip-addresses"]} className="flex items-center gap-0.5">
IPs
{t("tabs.ips")}
{counts["ip-addresses"] > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts["ip-addresses"]}
@@ -250,7 +281,7 @@ export default function TargetLayout({
</TabsTrigger>
<TabsTrigger value="endpoints" variant="underline" asChild>
<Link href={secondaryPaths.endpoints} className="flex items-center gap-0.5">
URLs
{t("tabs.urls")}
{counts.endpoints > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.endpoints}
@@ -258,16 +289,6 @@ export default function TargetLayout({
)}
</Link>
</TabsTrigger>
<TabsTrigger value="directories" variant="underline" asChild>
<Link href={secondaryPaths.directories} className="flex items-center gap-0.5">
Directories
{counts.directories > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{counts.directories}
</Badge>
)}
</Link>
</TabsTrigger>
</TabsList>
</Tabs>
</div>

View File

@@ -1,9 +1,19 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Editor from "@monaco-editor/react"
import dynamic from "next/dynamic"
import Link from "next/link"
import { useParams } from "next/navigation"
// Dynamic import Monaco Editor to reduce bundle size (~2MB)
const Editor = dynamic(() => import("@monaco-editor/react"), {
ssr: false,
loading: () => (
<div className="flex items-center justify-center h-full">
<div className="text-sm text-muted-foreground">Loading editor...</div>
</div>
),
})
import {
ChevronDown,
ChevronRight,
@@ -160,7 +170,7 @@ export default function NucleiRepoDetailPage() {
} else {
setEditorValue("")
}
}, [templateContent?.path])
}, [templateContent])
const toggleFolder = (path: string) => {
setExpandedPaths((prev) =>
@@ -248,7 +258,7 @@ export default function NucleiRepoDetailPage() {
}
}}
className={cn(
"flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
"tree-node-item flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
isFolder && "font-medium",
isActive
? "bg-primary/10 text-primary"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -245,6 +245,12 @@
/* Chrome, Safari and Opera */
}
/* 性能优化:长列表渲染优化 - content-visibility */
.tree-node-item {
content-visibility: auto;
contain-intrinsic-size: 0 36px;
}
}
/* 登录页背景 - 使用主题色适配亮暗模式 */
@@ -272,6 +278,20 @@
z-index: 1;
}
/* 终端光标闪烁动画 */
@keyframes blink {
0%, 50% {
opacity: 1;
}
51%, 100% {
opacity: 0;
}
}
.animate-blink {
animation: blink 1s step-end infinite;
}
/* 通知铃铛摇晃动画 */
@keyframes wiggle {
0%, 100% {
@@ -367,4 +387,206 @@
.animate-border-flow {
animation: border-flow 2s linear infinite;
}
}
/* Dashboard 淡入动画 - 纯 CSS 实现,避免 hydration mismatch */
@keyframes dashboard-fade-in {
from {
opacity: 0;
filter: blur(4px);
}
to {
opacity: 1;
filter: blur(0);
}
}
.animate-dashboard-fade-in {
animation: dashboard-fade-in 500ms ease-out forwards;
}
/* 登录页 - Glitch Reveal全屏开场 - 增强版赛博朋克风格 */
@keyframes orbit-splash-jitter {
0%,
100% {
transform: translate3d(0, 0, 0);
filter: none;
}
10% {
transform: translate3d(-2px, 0, 0);
}
20% {
transform: translate3d(2px, -1px, 0);
filter: hue-rotate(10deg);
}
30% {
transform: translate3d(-1px, 1px, 0);
}
45% {
transform: translate3d(1px, 0, 0);
filter: hue-rotate(-10deg);
}
60% {
transform: translate3d(0, -1px, 0);
}
75% {
transform: translate3d(1px, 1px, 0);
}
}
@keyframes orbit-splash-noise {
0% {
transform: translate3d(-2%, -2%, 0);
opacity: 0.22;
}
25% {
transform: translate3d(2%, -1%, 0);
opacity: 0.28;
}
50% {
transform: translate3d(-1%, 2%, 0);
opacity: 0.24;
}
75% {
transform: translate3d(1%, 1%, 0);
opacity: 0.30;
}
100% {
transform: translate3d(-2%, -2%, 0);
opacity: 0.22;
}
}
@keyframes orbit-splash-sweep {
0% {
transform: translate3d(0, -120%, 0);
opacity: 0;
}
18% {
opacity: 0.35;
}
100% {
transform: translate3d(0, 120%, 0);
opacity: 0;
}
}
@keyframes orbit-glitch-clip {
0% {
clip-path: inset(0 0 0 0);
transform: translate3d(0, 0, 0);
}
16% {
clip-path: inset(12% 0 72% 0);
transform: translate3d(-2px, 0, 0);
}
32% {
clip-path: inset(54% 0 18% 0);
transform: translate3d(2px, 0, 0);
}
48% {
clip-path: inset(78% 0 6% 0);
transform: translate3d(-1px, 0, 0);
}
64% {
clip-path: inset(30% 0 48% 0);
transform: translate3d(1px, 0, 0);
}
80% {
clip-path: inset(6% 0 86% 0);
transform: translate3d(0, 0, 0);
}
100% {
clip-path: inset(0 0 0 0);
transform: translate3d(0, 0, 0);
}
}
.orbit-splash-glitch {
isolation: isolate;
animation: orbit-splash-jitter 0.5s steps(2, end) infinite;
}
.orbit-splash-glitch::before {
content: "";
position: absolute;
inset: -20%;
pointer-events: none;
z-index: 20;
mix-blend-mode: screen;
background-image:
repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.08) 0px,
rgba(255, 255, 255, 0.08) 1px,
transparent 1px,
transparent 4px
),
repeating-linear-gradient(
90deg,
rgba(255, 16, 240, 0.15) 0px,
rgba(255, 16, 240, 0.15) 1px,
transparent 1px,
transparent 84px
),
repeating-linear-gradient(
45deg,
rgba(176, 38, 255, 0.08) 0px,
rgba(176, 38, 255, 0.08) 1px,
transparent 1px,
transparent 9px
);
animation: orbit-splash-noise 0.5s steps(2, end) infinite;
}
.orbit-splash-glitch::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
z-index: 20;
background: linear-gradient(
180deg,
transparent 0%,
rgba(255, 16, 240, 0.18) 50%,
transparent 100%
);
opacity: 0;
animation: orbit-splash-sweep 0.5s ease-out both;
}
.orbit-glitch-text {
position: relative;
display: inline-block;
text-shadow: 0 0 20px rgba(255, 16, 240, 0.4), 0 0 40px rgba(255, 16, 240, 0.2);
}
.orbit-glitch-text::before,
.orbit-glitch-text::after {
content: attr(data-text);
position: absolute;
inset: 0;
pointer-events: none;
}
.orbit-glitch-text::before {
color: rgba(255, 16, 240, 0.85);
transform: translate3d(-2px, 0, 0);
animation: orbit-glitch-clip 0.5s steps(2, end) infinite;
}
.orbit-glitch-text::after {
color: rgba(176, 38, 255, 0.75);
transform: translate3d(2px, 0, 0);
animation: orbit-glitch-clip 0.5s steps(2, end) infinite reverse;
}
@media (prefers-reduced-motion: reduce) {
.orbit-splash-glitch,
.orbit-splash-glitch::before,
.orbit-splash-glitch::after,
.orbit-glitch-text::before,
.orbit-glitch-text::after {
animation: none !important;
}
}

16
frontend/app/icon.svg Normal file
View File

@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="256"
height="256"
viewBox="0 0 24 24"
fill="none"
stroke="#06b6d4"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<title>Orbit</title>
<path d="M21 12h-8a1 1 0 1 0 -1 1v8a9 9 0 0 0 9 -9" />
<path d="M16 9a5 5 0 1 0 -7 7" />
<path d="M20.486 9a9 9 0 1 0 -11.482 11.495" />
</svg>

After

Width:  |  Height:  |  Size: 371 B

View File

@@ -18,5 +18,9 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
"registries": {
"@animate-ui": "https://animate-ui.com/r/{name}.json",
"@magicui": "https://magicui.design/r/{name}.json",
"@react-bits": "https://reactbits.dev/r/{name}.json"
}
}

View File

@@ -0,0 +1,6 @@
.faulty-terminal-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}

View File

@@ -0,0 +1,424 @@
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl';
import { useEffect, useRef, useMemo, useCallback } from 'react';
import './FaultyTerminal.css';
const vertexShader = `
attribute vec2 position;
attribute vec2 uv;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`;
const fragmentShader = `
precision mediump float;
varying vec2 vUv;
uniform float iTime;
uniform vec3 iResolution;
uniform float uScale;
uniform vec2 uGridMul;
uniform float uDigitSize;
uniform float uScanlineIntensity;
uniform float uGlitchAmount;
uniform float uFlickerAmount;
uniform float uNoiseAmp;
uniform float uChromaticAberration;
uniform float uDither;
uniform float uCurvature;
uniform vec3 uTint;
uniform vec2 uMouse;
uniform float uMouseStrength;
uniform float uUseMouse;
uniform float uPageLoadProgress;
uniform float uUsePageLoadAnimation;
uniform float uBrightness;
float time;
float hash21(vec2 p){
p = fract(p * 234.56);
p += dot(p, p + 34.56);
return fract(p.x * p.y);
}
float noise(vec2 p)
{
return sin(p.x * 10.0) * sin(p.y * (3.0 + sin(time * 0.090909))) + 0.2;
}
mat2 rotate(float angle)
{
float c = cos(angle);
float s = sin(angle);
return mat2(c, -s, s, c);
}
float fbm(vec2 p)
{
p *= 1.1;
float f = 0.0;
float amp = 0.5 * uNoiseAmp;
mat2 modify0 = rotate(time * 0.02);
f += amp * noise(p);
p = modify0 * p * 2.0;
amp *= 0.454545;
mat2 modify1 = rotate(time * 0.02);
f += amp * noise(p);
p = modify1 * p * 2.0;
amp *= 0.454545;
mat2 modify2 = rotate(time * 0.08);
f += amp * noise(p);
return f;
}
float pattern(vec2 p, out vec2 q, out vec2 r) {
vec2 offset1 = vec2(1.0);
vec2 offset0 = vec2(0.0);
mat2 rot01 = rotate(0.1 * time);
mat2 rot1 = rotate(0.1);
q = vec2(fbm(p + offset1), fbm(rot01 * p + offset1));
r = vec2(fbm(rot1 * q + offset0), fbm(q + offset0));
return fbm(p + r);
}
float digit(vec2 p){
vec2 grid = uGridMul * 15.0;
vec2 s = floor(p * grid) / grid;
p = p * grid;
vec2 q, r;
float intensity = pattern(s * 0.1, q, r) * 1.3 - 0.03;
if(uUseMouse > 0.5){
vec2 mouseWorld = uMouse * uScale;
float distToMouse = distance(s, mouseWorld);
float mouseInfluence = exp(-distToMouse * 8.0) * uMouseStrength * 10.0;
intensity += mouseInfluence;
float ripple = sin(distToMouse * 20.0 - iTime * 5.0) * 0.1 * mouseInfluence;
intensity += ripple;
}
if(uUsePageLoadAnimation > 0.5){
float cellRandom = fract(sin(dot(s, vec2(12.9898, 78.233))) * 43758.5453);
float cellDelay = cellRandom * 0.8;
float cellProgress = clamp((uPageLoadProgress - cellDelay) / 0.2, 0.0, 1.0);
float fadeAlpha = smoothstep(0.0, 1.0, cellProgress);
intensity *= fadeAlpha;
}
p = fract(p);
p *= uDigitSize;
float px5 = p.x * 5.0;
float py5 = (1.0 - p.y) * 5.0;
float x = fract(px5);
float y = fract(py5);
float i = floor(py5) - 2.0;
float j = floor(px5) - 2.0;
float n = i * i + j * j;
float f = n * 0.0625;
float isOn = step(0.1, intensity - f);
float brightness = isOn * (0.2 + y * 0.8) * (0.75 + x * 0.25);
return step(0.0, p.x) * step(p.x, 1.0) * step(0.0, p.y) * step(p.y, 1.0) * brightness;
}
float onOff(float a, float b, float c)
{
return step(c, sin(iTime + a * cos(iTime * b))) * uFlickerAmount;
}
float displace(vec2 look)
{
float y = look.y - mod(iTime * 0.25, 1.0);
float window = 1.0 / (1.0 + 50.0 * y * y);
return sin(look.y * 20.0 + iTime) * 0.0125 * onOff(4.0, 2.0, 0.8) * (1.0 + cos(iTime * 60.0)) * window;
}
vec3 getColor(vec2 p){
float bar = step(mod(p.y + time * 20.0, 1.0), 0.2) * 0.4 + 1.0;
bar *= uScanlineIntensity;
float displacement = displace(p);
p.x += displacement;
if (uGlitchAmount != 1.0) {
float extra = displacement * (uGlitchAmount - 1.0);
p.x += extra;
}
float middle = digit(p);
const float off = 0.002;
float sum = digit(p + vec2(-off, -off)) + digit(p + vec2(0.0, -off)) + digit(p + vec2(off, -off)) +
digit(p + vec2(-off, 0.0)) + digit(p + vec2(0.0, 0.0)) + digit(p + vec2(off, 0.0)) +
digit(p + vec2(-off, off)) + digit(p + vec2(0.0, off)) + digit(p + vec2(off, off));
vec3 baseColor = vec3(0.9) * middle + sum * 0.1 * vec3(1.0) * bar;
return baseColor;
}
vec2 barrel(vec2 uv){
vec2 c = uv * 2.0 - 1.0;
float r2 = dot(c, c);
c *= 1.0 + uCurvature * r2;
return c * 0.5 + 0.5;
}
void main() {
time = iTime * 0.333333;
vec2 uv = vUv;
if(uCurvature != 0.0){
uv = barrel(uv);
}
vec2 p = uv * uScale;
vec3 col = getColor(p);
if(uChromaticAberration != 0.0){
vec2 ca = vec2(uChromaticAberration) / iResolution.xy;
col.r = getColor(p + ca).r;
col.b = getColor(p - ca).b;
}
col *= uTint;
col *= uBrightness;
if(uDither > 0.0){
float rnd = hash21(gl_FragCoord.xy);
col += (rnd - 0.5) * (uDither * 0.003922);
}
gl_FragColor = vec4(col, 1.0);
}
`;
function hexToRgb(hex: string) {
let h = hex.replace('#', '').trim();
if (h.length === 3)
h = h
.split('')
.map(c => c + c)
.join('');
const num = parseInt(h, 16);
return [((num >> 16) & 255) / 255, ((num >> 8) & 255) / 255, (num & 255) / 255];
}
interface FaultyTerminalProps {
scale?: number;
gridMul?: [number, number];
digitSize?: number;
timeScale?: number;
pause?: boolean;
scanlineIntensity?: number;
glitchAmount?: number;
flickerAmount?: number;
noiseAmp?: number;
chromaticAberration?: number;
dither?: number;
curvature?: number;
tint?: string;
mouseReact?: boolean;
mouseStrength?: number;
dpr?: number;
pageLoadAnimation?: boolean;
brightness?: number;
className?: string;
style?: React.CSSProperties;
[key: string]: any;
}
export default function FaultyTerminal({
scale = 1,
gridMul = [2, 1],
digitSize = 1.5,
timeScale = 0.3,
pause = false,
scanlineIntensity = 0.3,
glitchAmount = 1,
flickerAmount = 1,
noiseAmp = 0,
chromaticAberration = 0,
dither = 0,
curvature = 0.2,
tint = '#ffffff',
mouseReact = true,
mouseStrength = 0.2,
dpr = Math.min(window.devicePixelRatio || 1, 2),
pageLoadAnimation = true,
brightness = 1,
className,
style,
...rest
}: FaultyTerminalProps) {
const containerRef = useRef<HTMLDivElement>(null);
const programRef = useRef<any>(null);
const rendererRef = useRef<any>(null);
const mouseRef = useRef({ x: 0.5, y: 0.5 });
const smoothMouseRef = useRef({ x: 0.5, y: 0.5 });
const frozenTimeRef = useRef(0);
const rafRef = useRef(0);
const loadAnimationStartRef = useRef(0);
const timeOffsetRef = useRef(Math.random() * 100);
const tintVec = useMemo(() => hexToRgb(tint), [tint]);
const ditherValue = useMemo(() => (typeof dither === 'boolean' ? (dither ? 1 : 0) : dither), [dither]);
const handleMouseMove = useCallback((e: MouseEvent) => {
const ctn = containerRef.current;
if (!ctn) return;
const rect = ctn.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = 1 - (e.clientY - rect.top) / rect.height;
mouseRef.current = { x, y };
}, []);
useEffect(() => {
const ctn = containerRef.current;
if (!ctn) return;
const renderer = new Renderer({ dpr });
rendererRef.current = renderer;
const gl = renderer.gl;
gl.clearColor(0, 0, 0, 1);
const geometry = new Triangle(gl);
const program = new Program(gl, {
vertex: vertexShader,
fragment: fragmentShader,
uniforms: {
iTime: { value: 0 },
iResolution: {
value: new Color(gl.canvas.width, gl.canvas.height, gl.canvas.width / gl.canvas.height)
},
uScale: { value: scale },
uGridMul: { value: new Float32Array(gridMul) },
uDigitSize: { value: digitSize },
uScanlineIntensity: { value: scanlineIntensity },
uGlitchAmount: { value: glitchAmount },
uFlickerAmount: { value: flickerAmount },
uNoiseAmp: { value: noiseAmp },
uChromaticAberration: { value: chromaticAberration },
uDither: { value: ditherValue },
uCurvature: { value: curvature },
uTint: { value: new Color(tintVec[0], tintVec[1], tintVec[2]) },
uMouse: {
value: new Float32Array([smoothMouseRef.current.x, smoothMouseRef.current.y])
},
uMouseStrength: { value: mouseStrength },
uUseMouse: { value: mouseReact ? 1 : 0 },
uPageLoadProgress: { value: pageLoadAnimation ? 0 : 1 },
uUsePageLoadAnimation: { value: pageLoadAnimation ? 1 : 0 },
uBrightness: { value: brightness }
}
});
programRef.current = program;
const mesh = new Mesh(gl, { geometry, program });
function resize() {
if (!ctn || !renderer) return;
renderer.setSize(ctn.offsetWidth, ctn.offsetHeight);
program.uniforms.iResolution.value = new Color(
gl.canvas.width,
gl.canvas.height,
gl.canvas.width / gl.canvas.height
);
}
const resizeObserver = new ResizeObserver(() => resize());
resizeObserver.observe(ctn);
resize();
const update = (t: number) => {
rafRef.current = requestAnimationFrame(update);
if (pageLoadAnimation && loadAnimationStartRef.current === 0) {
loadAnimationStartRef.current = t;
}
if (!pause) {
const elapsed = (t * 0.001 + timeOffsetRef.current) * timeScale;
program.uniforms.iTime.value = elapsed;
frozenTimeRef.current = elapsed;
} else {
program.uniforms.iTime.value = frozenTimeRef.current;
}
if (pageLoadAnimation && loadAnimationStartRef.current > 0) {
const animationDuration = 2000;
const animationElapsed = t - loadAnimationStartRef.current;
const progress = Math.min(animationElapsed / animationDuration, 1);
program.uniforms.uPageLoadProgress.value = progress;
}
if (mouseReact) {
const dampingFactor = 0.08;
const smoothMouse = smoothMouseRef.current;
const mouse = mouseRef.current;
smoothMouse.x += (mouse.x - smoothMouse.x) * dampingFactor;
smoothMouse.y += (mouse.y - smoothMouse.y) * dampingFactor;
const mouseUniform = program.uniforms.uMouse.value;
mouseUniform[0] = smoothMouse.x;
mouseUniform[1] = smoothMouse.y;
}
renderer.render({ scene: mesh });
};
rafRef.current = requestAnimationFrame(update);
ctn.appendChild(gl.canvas);
if (mouseReact) window.addEventListener('mousemove', handleMouseMove);
return () => {
cancelAnimationFrame(rafRef.current);
resizeObserver.disconnect();
if (mouseReact) window.removeEventListener('mousemove', handleMouseMove);
if (gl.canvas.parentElement === ctn) ctn.removeChild(gl.canvas);
gl.getExtension('WEBGL_lose_context')?.loseContext();
loadAnimationStartRef.current = 0;
timeOffsetRef.current = Math.random() * 100;
};
}, [
dpr,
pause,
timeScale,
scale,
gridMul,
digitSize,
scanlineIntensity,
glitchAmount,
flickerAmount,
noiseAmp,
chromaticAberration,
ditherValue,
curvature,
tintVec,
mouseReact,
mouseStrength,
pageLoadAnimation,
brightness,
handleMouseMove
]);
return <div ref={containerRef} className={`faulty-terminal-container ${className}`} style={style} {...rest} />;
}

View File

@@ -0,0 +1,6 @@
.pixel-blast-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}

View File

@@ -0,0 +1,782 @@
import { useEffect, useRef, useState, useMemo } from 'react';
import * as THREE from 'three';
import { EffectComposer, EffectPass, RenderPass, Effect } from 'postprocessing';
import './PixelBlast.css';
const createTouchTexture = () => {
const size = 64;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('2D context not available');
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const texture = new THREE.Texture(canvas);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
const trail: any[] = [];
let last: any = null;
const maxAge = 64;
let radius = 0.1 * size;
const speed = 1 / maxAge;
const clear = () => {
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
};
const drawPoint = (p: any) => {
const pos = { x: p.x * size, y: (1 - p.y) * size };
let intensity = 1;
const easeOutSine = (t: number) => Math.sin((t * Math.PI) / 2);
const easeOutQuad = (t: number) => -t * (t - 2);
if (p.age < maxAge * 0.3) intensity = easeOutSine(p.age / (maxAge * 0.3));
else intensity = easeOutQuad(1 - (p.age - maxAge * 0.3) / (maxAge * 0.7)) || 0;
intensity *= p.force;
const color = `${((p.vx + 1) / 2) * 255}, ${((p.vy + 1) / 2) * 255}, ${intensity * 255}`;
const offset = size * 5;
ctx.shadowOffsetX = offset;
ctx.shadowOffsetY = offset;
ctx.shadowBlur = radius;
ctx.shadowColor = `rgba(${color},${0.22 * intensity})`;
ctx.beginPath();
ctx.fillStyle = 'rgba(255,0,0,1)';
ctx.arc(pos.x - offset, pos.y - offset, radius, 0, Math.PI * 2);
ctx.fill();
};
const addTouch = (norm: any) => {
let force = 0;
let vx = 0;
let vy = 0;
if (last) {
const dx = norm.x - last.x;
const dy = norm.y - last.y;
if (dx === 0 && dy === 0) return;
const dd = dx * dx + dy * dy;
const d = Math.sqrt(dd);
vx = dx / (d || 1);
vy = dy / (d || 1);
force = Math.min(dd * 10000, 1);
}
last = { x: norm.x, y: norm.y };
trail.push({ x: norm.x, y: norm.y, age: 0, force, vx, vy });
};
const update = () => {
clear();
for (let i = trail.length - 1; i >= 0; i--) {
const point = trail[i];
const f = point.force * speed * (1 - point.age / maxAge);
point.x += point.vx * f;
point.y += point.vy * f;
point.age++;
if (point.age > maxAge) trail.splice(i, 1);
}
for (let i = 0; i < trail.length; i++) drawPoint(trail[i]);
texture.needsUpdate = true;
};
return {
canvas,
texture,
addTouch,
update,
set radiusScale(v) {
radius = 0.1 * size * v;
},
get radiusScale() {
return radius / (0.1 * size);
},
size
};
};
const createLiquidEffect = (texture: any, opts: any) => {
const fragment = `
uniform sampler2D uTexture;
uniform float uStrength;
uniform float uTime;
uniform float uFreq;
void mainUv(inout vec2 uv) {
vec4 tex = texture2D(uTexture, uv);
float vx = tex.r * 2.0 - 1.0;
float vy = tex.g * 2.0 - 1.0;
float intensity = tex.b;
float wave = 0.5 + 0.5 * sin(uTime * uFreq + intensity * 6.2831853);
float amt = uStrength * intensity * wave;
uv += vec2(vx, vy) * amt;
}
`;
return new Effect('LiquidEffect', fragment, {
uniforms: new Map([
['uTexture', new THREE.Uniform(texture)],
['uStrength', new THREE.Uniform(opts?.strength ?? 0.025)],
['uTime', new THREE.Uniform(0)],
['uFreq', new THREE.Uniform(opts?.freq ?? 4.5)]
])
});
};
const SHAPE_MAP = {
square: 0,
circle: 1,
triangle: 2,
diamond: 3
};
const VERTEX_SRC = `
void main() {
gl_Position = vec4(position, 1.0);
}
`;
const FRAGMENT_SRC = `
precision highp float;
uniform vec3 uColor;
uniform vec2 uResolution;
uniform float uTime;
uniform float uPixelSize;
uniform float uScale;
uniform float uDensity;
uniform float uPixelJitter;
uniform int uEnableRipples;
uniform float uRippleSpeed;
uniform float uRippleThickness;
uniform float uRippleIntensity;
uniform float uEdgeFade;
uniform int uShapeType;
const int SHAPE_SQUARE = 0;
const int SHAPE_CIRCLE = 1;
const int SHAPE_TRIANGLE = 2;
const int SHAPE_DIAMOND = 3;
const int MAX_CLICKS = 10;
uniform vec2 uClickPos [MAX_CLICKS];
uniform float uClickTimes[MAX_CLICKS];
out vec4 fragColor;
float Bayer2(vec2 a) {
a = floor(a);
return fract(a.x / 2. + a.y * a.y * .75);
}
#define Bayer4(a) (Bayer2(.5*(a))*0.25 + Bayer2(a))
#define Bayer8(a) (Bayer4(.5*(a))*0.25 + Bayer2(a))
#define FBM_OCTAVES 2
#define FBM_LACUNARITY 1.25
#define FBM_GAIN 1.0
float hash11(float n){ return fract(sin(n)*43758.5453); }
float vnoise(vec3 p){
vec3 ip = floor(p);
vec3 fp = fract(p);
float n000 = hash11(dot(ip + vec3(0.0,0.0,0.0), vec3(1.0,57.0,113.0)));
float n100 = hash11(dot(ip + vec3(1.0,0.0,0.0), vec3(1.0,57.0,113.0)));
float n010 = hash11(dot(ip + vec3(0.0,1.0,0.0), vec3(1.0,57.0,113.0)));
float n110 = hash11(dot(ip + vec3(1.0,1.0,0.0), vec3(1.0,57.0,113.0)));
float n001 = hash11(dot(ip + vec3(0.0,0.0,1.0), vec3(1.0,57.0,113.0)));
float n101 = hash11(dot(ip + vec3(1.0,0.0,1.0), vec3(1.0,57.0,113.0)));
float n011 = hash11(dot(ip + vec3(0.0,1.0,1.0), vec3(1.0,57.0,113.0)));
float n111 = hash11(dot(ip + vec3(1.0,1.0,1.0), vec3(1.0,57.0,113.0)));
vec3 w = fp*fp*fp*(fp*(fp*6.0-15.0)+10.0);
float x00 = mix(n000, n100, w.x);
float x10 = mix(n010, n110, w.x);
float x01 = mix(n001, n101, w.x);
float x11 = mix(n011, n111, w.x);
float y0 = mix(x00, x10, w.y);
float y1 = mix(x01, x11, w.y);
return mix(y0, y1, w.z) * 2.0 - 1.0;
}
float fbm2(vec2 uv, float t){
vec3 p = vec3(uv * uScale, t);
float amp = 1.0;
float freq = 1.0;
float sum = 1.0;
for (int i = 0; i < FBM_OCTAVES; ++i){
sum += amp * vnoise(p * freq);
freq *= FBM_LACUNARITY;
amp *= FBM_GAIN;
}
return sum * 0.5 + 0.5;
}
float maskCircle(vec2 p, float cov){
float r = sqrt(cov) * .25;
float d = length(p - 0.5) - r;
float aa = 0.5 * fwidth(d);
return cov * (1.0 - smoothstep(-aa, aa, d * 2.0));
}
float maskTriangle(vec2 p, vec2 id, float cov){
bool flip = mod(id.x + id.y, 2.0) > 0.5;
if (flip) p.x = 1.0 - p.x;
float r = sqrt(cov);
float d = p.y - r*(1.0 - p.x);
float aa = fwidth(d);
return cov * clamp(0.5 - d/aa, 0.0, 1.0);
}
float maskDiamond(vec2 p, float cov){
float r = sqrt(cov) * 0.564;
return step(abs(p.x - 0.49) + abs(p.y - 0.49), r);
}
void main(){
float pixelSize = uPixelSize;
vec2 fragCoord = gl_FragCoord.xy - uResolution * .5;
float aspectRatio = uResolution.x / uResolution.y;
vec2 pixelId = floor(fragCoord / pixelSize);
vec2 pixelUV = fract(fragCoord / pixelSize);
float cellPixelSize = 8.0 * pixelSize;
vec2 cellId = floor(fragCoord / cellPixelSize);
vec2 cellCoord = cellId * cellPixelSize;
vec2 uv = cellCoord / uResolution * vec2(aspectRatio, 1.0);
float base = fbm2(uv, uTime * 0.05);
base = base * 0.5 - 0.65;
float feed = base + (uDensity - 0.5) * 0.3;
float speed = uRippleSpeed;
float thickness = uRippleThickness;
const float dampT = 1.0;
const float dampR = 10.0;
if (uEnableRipples == 1) {
for (int i = 0; i < MAX_CLICKS; ++i){
vec2 pos = uClickPos[i];
if (pos.x < 0.0) continue;
float cellPixelSize = 8.0 * pixelSize;
vec2 cuv = (((pos - uResolution * .5 - cellPixelSize * .5) / (uResolution))) * vec2(aspectRatio, 1.0);
float t = max(uTime - uClickTimes[i], 0.0);
float r = distance(uv, cuv);
float waveR = speed * t;
float ring = exp(-pow((r - waveR) / thickness, 2.0));
float atten = exp(-dampT * t) * exp(-dampR * r);
feed = max(feed, ring * atten * uRippleIntensity);
}
}
float bayer = Bayer8(fragCoord / uPixelSize) - 0.5;
float bw = step(0.5, feed + bayer);
float h = fract(sin(dot(floor(fragCoord / uPixelSize), vec2(127.1, 311.7))) * 43758.5453);
float jitterScale = 1.0 + (h - 0.5) * uPixelJitter;
float coverage = bw * jitterScale;
float M;
if (uShapeType == SHAPE_CIRCLE) M = maskCircle (pixelUV, coverage);
else if (uShapeType == SHAPE_TRIANGLE) M = maskTriangle(pixelUV, pixelId, coverage);
else if (uShapeType == SHAPE_DIAMOND) M = maskDiamond(pixelUV, coverage);
else M = coverage;
if (uEdgeFade > 0.0) {
vec2 norm = gl_FragCoord.xy / uResolution;
float edge = min(min(norm.x, norm.y), min(1.0 - norm.x, 1.0 - norm.y));
float fade = smoothstep(0.0, uEdgeFade, edge);
M *= fade;
}
vec3 color = uColor;
// sRGB gamma correction - convert linear to sRGB for accurate color output
vec3 srgbColor = mix(
color * 12.92,
1.055 * pow(color, vec3(1.0 / 2.4)) - 0.055,
step(0.0031308, color)
);
fragColor = vec4(srgbColor, M);
}
`;
const MAX_CLICKS = 10;
interface PixelBlastProps {
variant?: string;
pixelSize?: number;
color?: string;
className?: string;
style?: React.CSSProperties;
antialias?: boolean;
patternScale?: number;
patternDensity?: number;
liquid?: boolean;
liquidStrength?: number;
liquidRadius?: number;
pixelSizeJitter?: number;
enableRipples?: boolean;
rippleIntensityScale?: number;
rippleThickness?: number;
rippleSpeed?: number;
liquidWobbleSpeed?: number;
autoPauseOffscreen?: boolean;
speed?: number;
transparent?: boolean;
edgeFade?: number;
noiseAmount?: number;
respectReducedMotion?: boolean;
maxPixelRatio?: number;
onFirstFrame?: () => void;
}
const PixelBlast = ({
variant = 'square',
pixelSize = 3,
color = '#B19EEF',
className,
style,
antialias = true,
patternScale = 2,
patternDensity = 1,
liquid = false,
liquidStrength = 0.1,
liquidRadius = 1,
pixelSizeJitter = 0,
enableRipples = true,
rippleIntensityScale = 1,
rippleThickness = 0.1,
rippleSpeed = 0.3,
liquidWobbleSpeed = 4.5,
autoPauseOffscreen = true,
speed = 0.5,
transparent = true,
edgeFade = 0.5,
noiseAmount = 0,
respectReducedMotion = true,
maxPixelRatio = 2,
onFirstFrame
}: PixelBlastProps) => {
const containerRef = useRef(null);
const visibilityRef = useRef({ visible: true });
const speedRef = useRef(speed);
const threeRef = useRef<any>(null);
const prevConfigRef = useRef<any>(null);
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
const onFirstFrameRef = useRef<PixelBlastProps['onFirstFrame']>(onFirstFrame);
onFirstFrameRef.current = onFirstFrame;
const firstFrameFiredRef = useRef(false);
// Limit pixel ratio for performance (lower on mobile)
const effectivePixelRatio = useMemo(() => {
if (typeof window === 'undefined') return 1;
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
const dpr = window.devicePixelRatio || 1;
if (isMobile) return Math.min(dpr, 1.5, maxPixelRatio);
return Math.min(dpr, maxPixelRatio);
}, [maxPixelRatio]);
// Check for prefers-reduced-motion
useEffect(() => {
if (!respectReducedMotion) return;
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReducedMotion(mq.matches);
const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [respectReducedMotion]);
// If WebGL rendering is disabled (e.g. reduced motion), still signal readiness so
// callers don't wait forever.
useEffect(() => {
if (!prefersReducedMotion) return;
if (firstFrameFiredRef.current) return;
firstFrameFiredRef.current = true;
onFirstFrameRef.current?.();
}, [prefersReducedMotion]);
// Pause animation when page is not visible or element is offscreen
useEffect(() => {
if (!autoPauseOffscreen || prefersReducedMotion) return;
const container = containerRef.current;
if (!container) return;
// IntersectionObserver for offscreen detection
const io = new IntersectionObserver(
([entry]) => {
visibilityRef.current.visible = entry.isIntersecting;
},
{ threshold: 0 }
);
io.observe(container);
// Page Visibility API
const handleVisibility = () => {
if (document.hidden) {
visibilityRef.current.visible = false;
}
};
document.addEventListener('visibilitychange', handleVisibility);
return () => {
io.disconnect();
document.removeEventListener('visibilitychange', handleVisibility);
};
}, [autoPauseOffscreen, prefersReducedMotion]);
// Main WebGL setup effect
useEffect(() => {
// Skip WebGL setup if user prefers reduced motion
if (prefersReducedMotion) return;
const container = containerRef.current;
if (!container) return;
speedRef.current = speed;
const needsReinitKeys = ['antialias', 'liquid', 'noiseAmount'];
const cfg = { antialias, liquid, noiseAmount };
let mustReinit = false;
if (!threeRef.current) mustReinit = true;
else if (prevConfigRef.current) {
for (const k of needsReinitKeys)
if ((prevConfigRef.current as any)[k] !== (cfg as any)[k]) {
mustReinit = true;
break;
}
}
if (mustReinit) {
if (threeRef.current) {
const t = threeRef.current;
t.resizeObserver?.disconnect();
cancelAnimationFrame(t.raf);
t.quad?.geometry.dispose();
t.material.dispose();
t.composer?.dispose();
t.renderer.dispose();
if (t.renderer.domElement.parentElement === container) (container as HTMLDivElement).removeChild(t.renderer.domElement);
threeRef.current = null;
}
let renderer: THREE.WebGLRenderer | null = null;
let canvas: HTMLCanvasElement | null = null;
try {
canvas = document.createElement('canvas');
renderer = new THREE.WebGLRenderer({
canvas,
antialias,
alpha: true,
powerPreference: 'high-performance'
});
renderer.domElement.style.width = '100%';
renderer.domElement.style.height = '100%';
renderer.setPixelRatio(effectivePixelRatio);
(container as HTMLDivElement).appendChild(renderer.domElement);
if (transparent) renderer.setClearAlpha(0);
else renderer.setClearColor(0x000000, 1);
const uniforms = {
uResolution: { value: new THREE.Vector2(0, 0) },
uTime: { value: 0 },
uColor: { value: new THREE.Color(color) },
uClickPos: {
value: Array.from({ length: MAX_CLICKS }, () => new THREE.Vector2(-1, -1))
},
uClickTimes: { value: new Float32Array(MAX_CLICKS) },
uShapeType: { value: SHAPE_MAP[variant as keyof typeof SHAPE_MAP] ?? 0 },
uPixelSize: { value: pixelSize * renderer.getPixelRatio() },
uScale: { value: patternScale },
uDensity: { value: patternDensity },
uPixelJitter: { value: pixelSizeJitter },
uEnableRipples: { value: enableRipples ? 1 : 0 },
uRippleSpeed: { value: rippleSpeed },
uRippleThickness: { value: rippleThickness },
uRippleIntensity: { value: rippleIntensityScale },
uEdgeFade: { value: edgeFade }
};
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const material = new THREE.ShaderMaterial({
vertexShader: VERTEX_SRC,
fragmentShader: FRAGMENT_SRC,
uniforms,
transparent: true,
depthTest: false,
depthWrite: false,
glslVersion: THREE.GLSL3
});
const quadGeom = new THREE.PlaneGeometry(2, 2);
const quad = new THREE.Mesh(quadGeom, material);
scene.add(quad);
const clock = new THREE.Clock();
const setSize = () => {
if (!renderer) return;
const w = (container as HTMLDivElement).clientWidth || 1;
const h = (container as HTMLDivElement).clientHeight || 1;
renderer.setSize(w, h, false);
uniforms.uResolution.value.set(renderer.domElement.width, renderer.domElement.height);
if (threeRef.current?.composer)
threeRef.current.composer.setSize(renderer.domElement.width, renderer.domElement.height);
uniforms.uPixelSize.value = pixelSize * renderer.getPixelRatio();
};
setSize();
const ro = new ResizeObserver(setSize);
ro.observe(container);
const randomFloat = () => {
if (typeof window !== 'undefined' && window.crypto?.getRandomValues) {
const u32 = new Uint32Array(1);
window.crypto.getRandomValues(u32);
return u32[0] / 0xffffffff;
}
return Math.random();
};
const timeOffset = randomFloat() * 1000;
let composer: EffectComposer | undefined;
let touch: ReturnType<typeof createTouchTexture> | undefined;
let liquidEffect: Effect | undefined;
if (liquid) {
touch = createTouchTexture();
touch.radiusScale = liquidRadius;
composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
liquidEffect = createLiquidEffect(touch.texture, {
strength: liquidStrength,
freq: liquidWobbleSpeed
});
const effectPass = new EffectPass(camera, liquidEffect);
effectPass.renderToScreen = true;
composer.addPass(renderPass);
composer.addPass(effectPass);
}
if (noiseAmount > 0) {
if (!composer) {
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
}
const noiseEffect = new Effect(
'NoiseEffect',
`uniform float uTime; uniform float uAmount; float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453);} void mainUv(inout vec2 uv){} void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor){ float n=hash(floor(uv*vec2(1920.0,1080.0))+floor(uTime*60.0)); float g=(n-0.5)*uAmount; outputColor=inputColor+vec4(vec3(g),0.0);} `,
{
uniforms: new Map([
['uTime', new THREE.Uniform(0)],
['uAmount', new THREE.Uniform(noiseAmount)]
])
}
);
const noisePass = new EffectPass(camera, noiseEffect);
noisePass.renderToScreen = true;
if (composer && composer.passes.length > 0) composer.passes.forEach(p => (p.renderToScreen = false));
composer.addPass(noisePass);
}
if (composer && renderer) composer.setSize(renderer.domElement.width, renderer.domElement.height);
const mapToPixels = (e: MouseEvent | PointerEvent) => {
if (!renderer) return { fx: 0, fy: 0, w: 0, h: 0 };
const rect = renderer.domElement.getBoundingClientRect();
const scaleX = renderer.domElement.width / rect.width;
const scaleY = renderer.domElement.height / rect.height;
const fx = (e.clientX - rect.left) * scaleX;
const fy = (rect.height - (e.clientY - rect.top)) * scaleY;
return {
fx,
fy,
w: renderer.domElement.width,
h: renderer.domElement.height
};
};
let lastRippleTime = 0;
const rippleThrottle = 150; // ms between ripples
const onPointerMove = (e: MouseEvent | PointerEvent) => {
const { fx, fy, w, h } = mapToPixels(e);
// Trigger ripple on mouse move (throttled)
const now = performance.now();
if (now - lastRippleTime > rippleThrottle) {
const ix = threeRef.current?.clickIx ?? 0;
uniforms.uClickPos.value[ix].set(fx, fy);
uniforms.uClickTimes.value[ix] = uniforms.uTime.value;
if (threeRef.current) threeRef.current.clickIx = (ix + 1) % MAX_CLICKS;
lastRippleTime = now;
}
// Liquid touch effect
if (touch) {
touch.addTouch({ x: fx / w, y: fy / h });
}
};
renderer.domElement.addEventListener('pointermove', onPointerMove, {
passive: true
});
// Store event handler for cleanup
const domElement = renderer.domElement;
let raf = 0;
let lastFrameTime = 0;
const targetDelta = 1000 / 10; // throttle to ~20fps
const animate = (now?: number) => {
const timeNow = now ?? performance.now();
if (autoPauseOffscreen && !visibilityRef.current.visible) {
raf = requestAnimationFrame(animate);
if (threeRef.current) threeRef.current.raf = raf;
return;
}
if (timeNow - lastFrameTime < targetDelta) {
raf = requestAnimationFrame(animate);
if (threeRef.current) threeRef.current.raf = raf;
return;
}
lastFrameTime = timeNow;
uniforms.uTime.value = timeOffset + clock.getElapsedTime() * speedRef.current;
if (liquidEffect) liquidEffect.uniforms.get('uTime')!.value = uniforms.uTime.value;
if (composer) {
if (touch) touch.update();
composer.passes.forEach(p => {
const effs = (p as any).effects;
if (effs)
effs.forEach((eff: Effect) => {
const u = eff.uniforms?.get('uTime');
if (u) u.value = uniforms.uTime.value;
});
});
composer.render();
} else if (renderer) renderer.render(scene, camera);
if (!firstFrameFiredRef.current) {
firstFrameFiredRef.current = true;
onFirstFrameRef.current?.();
}
raf = requestAnimationFrame(animate);
if (threeRef.current) threeRef.current.raf = raf;
};
raf = requestAnimationFrame(animate);
threeRef.current = {
renderer,
scene,
camera,
material,
clock,
clickIx: 0,
uniforms,
resizeObserver: ro,
raf,
quad,
timeOffset,
composer,
touch,
liquidEffect,
onPointerMove,
domElement
};
} catch (err) {
console.error('[PixelBlast] WebGL initialization failed', err);
if (renderer) renderer.dispose();
if (canvas && canvas.parentElement === container) {
(container as HTMLDivElement).removeChild(canvas);
}
threeRef.current = null;
if (!firstFrameFiredRef.current) {
firstFrameFiredRef.current = true;
onFirstFrameRef.current?.();
}
}
} else {
const t = threeRef.current;
t.uniforms.uShapeType.value = SHAPE_MAP[variant as keyof typeof SHAPE_MAP] ?? 0;
t.uniforms.uPixelSize.value = pixelSize * t.renderer.getPixelRatio();
t.uniforms.uColor.value.set(color);
t.uniforms.uScale.value = patternScale;
t.uniforms.uDensity.value = patternDensity;
t.uniforms.uPixelJitter.value = pixelSizeJitter;
t.uniforms.uEnableRipples.value = enableRipples ? 1 : 0;
t.uniforms.uRippleIntensity.value = rippleIntensityScale;
t.uniforms.uRippleThickness.value = rippleThickness;
t.uniforms.uRippleSpeed.value = rippleSpeed;
t.uniforms.uEdgeFade.value = edgeFade;
if (transparent) t.renderer.setClearAlpha(0);
else t.renderer.setClearColor(0x000000, 1);
if (t.liquidEffect) {
const uStrength = t.liquidEffect;
if (uStrength) uStrength.value = liquidStrength;
const uFreq = t.liquidEffect.uniforms.get('uFreq');
if (uFreq) uFreq.value = liquidWobbleSpeed;
}
if (t.touch) t.touch.radiusScale = liquidRadius;
}
prevConfigRef.current = cfg;
return () => {
if (!threeRef.current) return;
const t = threeRef.current;
// Remove event listeners
if (t.domElement && t.onPointerMove) {
t.domElement.removeEventListener('pointermove', t.onPointerMove);
}
t.resizeObserver?.disconnect();
cancelAnimationFrame(t.raf);
// Dispose Three.js resources
t.quad?.geometry.dispose();
t.material.dispose();
t.composer?.dispose();
// Dispose touch texture
if (t.touch?.texture) {
t.touch.texture.dispose();
}
t.renderer.dispose();
if (t.renderer.domElement.parentElement === container) {
(container as HTMLDivElement).removeChild(t.renderer.domElement);
}
threeRef.current = null;
};
}, [
antialias,
liquid,
noiseAmount,
pixelSize,
patternScale,
patternDensity,
enableRipples,
rippleIntensityScale,
rippleThickness,
rippleSpeed,
pixelSizeJitter,
edgeFade,
transparent,
liquidStrength,
liquidRadius,
liquidWobbleSpeed,
autoPauseOffscreen,
variant,
color,
speed,
prefersReducedMotion,
effectivePixelRatio
]);
// Render empty container if user prefers reduced motion
if (prefersReducedMotion) {
return (
<div
ref={containerRef}
className={`pixel-blast-container ${className ?? ''}`}
style={{ ...style, backgroundColor: 'transparent' }}
aria-label="PixelBlast background (disabled for reduced motion)"
/>
);
}
return (
<div
ref={containerRef}
className={`pixel-blast-container ${className ?? ''}`}
style={style}
aria-label="PixelBlast interactive background"
/>
);
};
export default PixelBlast;

View File

@@ -0,0 +1,30 @@
.shuffle-parent {
display: inline-block;
white-space: normal;
word-wrap: break-word;
will-change: transform;
line-height: 1.2;
visibility: hidden;
}
.shuffle-parent.is-ready {
visibility: visible;
}
.shuffle-char-wrapper {
display: inline-block;
overflow: hidden;
vertical-align: baseline;
position: relative;
}
.shuffle-char-wrapper > span {
display: inline-flex;
will-change: transform;
}
.shuffle-char {
line-height: 1;
display: inline-block;
text-align: center;
}

View File

@@ -0,0 +1,423 @@
import React, { useRef, useEffect, useState, useMemo } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText as GSAPSplitText } from 'gsap/SplitText';
import { useGSAP } from '@gsap/react';
import './Shuffle.css';
gsap.registerPlugin(ScrollTrigger, GSAPSplitText, useGSAP);
interface ShuffleProps {
text: string;
className?: string;
style?: React.CSSProperties;
shuffleDirection?: 'up' | 'down' | 'left' | 'right';
duration?: number;
maxDelay?: number;
ease?: string;
threshold?: number;
rootMargin?: string;
tag?: keyof React.JSX.IntrinsicElements;
textAlign?: 'left' | 'center' | 'right';
onShuffleComplete?: () => void;
shuffleTimes?: number;
animationMode?: 'evenodd' | 'random';
loop?: boolean;
loopDelay?: number;
stagger?: number;
scrambleCharset?: string;
colorFrom?: string;
colorTo?: string;
triggerOnce?: boolean;
respectReducedMotion?: boolean;
triggerOnHover?: boolean;
autoPlay?: boolean;
}
const Shuffle: React.FC<ShuffleProps> = ({
text,
className = '',
style = {},
shuffleDirection = 'right',
duration = 0.35,
maxDelay = 0,
ease = 'power3.out',
threshold = 0.1,
rootMargin = '-100px',
tag = 'p',
textAlign = 'center',
onShuffleComplete,
shuffleTimes = 1,
animationMode = 'evenodd',
loop = false,
loopDelay = 0,
stagger = 0.03,
scrambleCharset = '',
colorFrom,
colorTo,
triggerOnce = true,
respectReducedMotion = true,
triggerOnHover = true,
autoPlay = true
}) => {
const ref = useRef<HTMLElement | null>(null);
const [fontsLoaded, setFontsLoaded] = useState(false);
const [ready, setReady] = useState(false);
const splitRef = useRef<any>(null);
const wrappersRef = useRef<any[]>([]);
const tlRef = useRef<gsap.core.Timeline | null>(null);
const playingRef = useRef(false);
const hoverHandlerRef = useRef<((e: MouseEvent) => void) | null>(null);
useEffect(() => {
if ('fonts' in document) {
if (document.fonts.status === 'loaded') setFontsLoaded(true);
else document.fonts.ready.then(() => setFontsLoaded(true));
} else setFontsLoaded(true);
}, []);
const scrollTriggerStart = useMemo(() => {
const startPct = (1 - threshold) * 100;
const mm = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(rootMargin || '');
const mv = mm ? parseFloat(mm[1]) : 0;
const mu = mm ? mm[2] || 'px' : 'px';
const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;
return `top ${startPct}%${sign}`;
}, [threshold, rootMargin]);
useGSAP(
() => {
if (!ref.current || !text || !fontsLoaded) return;
if (respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setReady(true);
onShuffleComplete?.();
return;
}
const el = ref.current;
const start = scrollTriggerStart;
const removeHover = () => {
if (hoverHandlerRef.current && ref.current) {
ref.current.removeEventListener('mouseenter', hoverHandlerRef.current);
hoverHandlerRef.current = null;
}
};
const teardown = () => {
if (tlRef.current) {
tlRef.current.kill();
tlRef.current = null;
}
if (wrappersRef.current.length) {
wrappersRef.current.forEach(wrap => {
const inner = wrap.firstElementChild;
const orig = inner?.querySelector('[data-orig="1"]');
if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);
});
wrappersRef.current = [];
}
try {
splitRef.current?.revert();
} catch {
/* noop */
}
splitRef.current = null;
playingRef.current = false;
};
const build = () => {
teardown();
splitRef.current = new GSAPSplitText(el, {
type: 'chars',
charsClass: 'shuffle-char',
wordsClass: 'shuffle-word',
linesClass: 'shuffle-line',
smartWrap: true,
reduceWhiteSpace: false
});
const chars = splitRef.current.chars || [];
wrappersRef.current = [];
const rolls = Math.max(1, Math.floor(shuffleTimes));
const rand = (set: string) => set.charAt(Math.floor(Math.random() * set.length)) || '';
chars.forEach((ch: any) => {
const parent = ch.parentElement;
if (!parent) return;
const w = ch.getBoundingClientRect().width;
const h = ch.getBoundingClientRect().height;
if (!w) return;
const wrap = document.createElement('span');
Object.assign(wrap.style, {
display: 'inline-block',
overflow: 'hidden',
width: w + 'px',
height: shuffleDirection === 'up' || shuffleDirection === 'down' ? h + 'px' : 'auto',
verticalAlign: 'bottom'
});
const inner = document.createElement('span');
Object.assign(inner.style, {
display: 'inline-block',
whiteSpace: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'normal' : 'nowrap',
willChange: 'transform'
});
parent.insertBefore(wrap, ch);
wrap.appendChild(inner);
const firstOrig = ch.cloneNode(true);
Object.assign(firstOrig.style, {
display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block',
width: w + 'px',
textAlign: 'center'
});
ch.setAttribute('data-orig', '1');
Object.assign(ch.style, {
display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block',
width: w + 'px',
textAlign: 'center'
});
inner.appendChild(firstOrig);
for (let k = 0; k < rolls; k++) {
const c = ch.cloneNode(true);
if (scrambleCharset) c.textContent = rand(scrambleCharset);
Object.assign(c.style, {
display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block',
width: w + 'px',
textAlign: 'center'
});
inner.appendChild(c);
}
inner.appendChild(ch);
const steps = rolls + 1;
if (shuffleDirection === 'right' || shuffleDirection === 'down') {
const firstCopy = inner.firstElementChild;
const real = inner.lastElementChild;
if (real) inner.insertBefore(real, inner.firstChild);
if (firstCopy) inner.appendChild(firstCopy);
}
let startX = 0;
let finalX = 0;
let startY = 0;
let finalY = 0;
if (shuffleDirection === 'right') {
startX = -steps * w;
finalX = 0;
} else if (shuffleDirection === 'left') {
startX = 0;
finalX = -steps * w;
} else if (shuffleDirection === 'down') {
startY = -steps * h;
finalY = 0;
} else if (shuffleDirection === 'up') {
startY = 0;
finalY = -steps * h;
}
if (shuffleDirection === 'left' || shuffleDirection === 'right') {
gsap.set(inner, { x: startX, y: 0, force3D: true });
inner.setAttribute('data-start-x', String(startX));
inner.setAttribute('data-final-x', String(finalX));
} else {
gsap.set(inner, { x: 0, y: startY, force3D: true });
inner.setAttribute('data-start-y', String(startY));
inner.setAttribute('data-final-y', String(finalY));
}
if (colorFrom) inner.style.color = colorFrom;
wrappersRef.current.push(wrap);
});
};
const inners = () => wrappersRef.current.map(w => w.firstElementChild);
const randomizeScrambles = () => {
if (!scrambleCharset) return;
wrappersRef.current.forEach(w => {
const strip = w.firstElementChild;
if (!strip) return;
const kids = Array.from(strip.children) as Element[];
for (let i = 1; i < kids.length - 1; i++) {
kids[i].textContent = scrambleCharset.charAt(Math.floor(Math.random() * scrambleCharset.length));
}
});
};
const cleanupToStill = () => {
wrappersRef.current.forEach(w => {
const strip = w.firstElementChild;
if (!strip) return;
const real = strip.querySelector('[data-orig="1"]');
if (!real) return;
strip.replaceChildren(real);
strip.style.transform = 'none';
strip.style.willChange = 'auto';
});
};
const play = () => {
const strips = inners();
if (!strips.length) return;
playingRef.current = true;
const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down';
const tl = gsap.timeline({
smoothChildTiming: true,
repeat: loop ? -1 : 0,
repeatDelay: loop ? loopDelay : 0,
onRepeat: () => {
if (scrambleCharset) randomizeScrambles();
if (isVertical) {
gsap.set(strips, { y: (i, t) => parseFloat(t.getAttribute('data-start-y') || '0') });
} else {
gsap.set(strips, { x: (i, t) => parseFloat(t.getAttribute('data-start-x') || '0') });
}
onShuffleComplete?.();
},
onComplete: () => {
playingRef.current = false;
if (!loop) {
cleanupToStill();
if (colorTo) gsap.set(strips, { color: colorTo });
onShuffleComplete?.();
armHover();
}
}
});
const addTween = (targets: any, at: any) => {
const vars: any = {
duration,
ease,
force3D: true,
stagger: animationMode === 'evenodd' ? stagger : 0
};
if (isVertical) {
vars.y = (i: number, t: any) => parseFloat(t.getAttribute('data-final-y') || '0');
} else {
vars.x = (i: number, t: any) => parseFloat(t.getAttribute('data-final-x') || '0');
}
tl.to(targets, vars, at);
if (colorFrom && colorTo) {
tl.to(targets, { color: colorTo, duration, ease }, at);
}
};
if (animationMode === 'evenodd') {
const odd = strips.filter((_, i) => i % 2 === 1);
const even = strips.filter((_, i) => i % 2 === 0);
const oddTotal = duration + Math.max(0, odd.length - 1) * stagger;
const evenStart = odd.length ? oddTotal * 0.7 : 0;
if (odd.length) addTween(odd, 0);
if (even.length) addTween(even, evenStart);
} else {
strips.forEach(strip => {
const d = Math.random() * maxDelay;
const vars: any = {
duration,
ease,
force3D: true
};
if (isVertical) {
vars.y = parseFloat(strip.getAttribute('data-final-y') || '0');
} else {
vars.x = parseFloat(strip.getAttribute('data-final-x') || '0');
}
tl.to(strip, vars, d);
if (colorFrom && colorTo) tl.fromTo(strip, { color: colorFrom }, { color: colorTo, duration, ease }, d);
});
}
tlRef.current = tl;
};
const armHover = () => {
if (!triggerOnHover || !ref.current) return;
removeHover();
const handler = () => {
if (playingRef.current) return;
build();
if (scrambleCharset) randomizeScrambles();
play();
};
hoverHandlerRef.current = handler;
ref.current.addEventListener('mouseenter', handler);
};
const create = () => {
build();
if (scrambleCharset) randomizeScrambles();
if (autoPlay) {
play();
}
armHover();
setReady(true);
};
const st = ScrollTrigger.create({
trigger: el,
start,
once: triggerOnce,
onEnter: create
});
return () => {
st.kill();
removeHover();
teardown();
setReady(false);
};
},
{
dependencies: [
text,
duration,
maxDelay,
ease,
scrollTriggerStart,
fontsLoaded,
shuffleDirection,
shuffleTimes,
animationMode,
loop,
loopDelay,
stagger,
scrambleCharset,
colorFrom,
colorTo,
triggerOnce,
respectReducedMotion,
triggerOnHover,
onShuffleComplete,
autoPlay
],
scope: ref
}
);
const commonStyle = useMemo(() => ({ textAlign, ...style }), [textAlign, style]);
const classes = useMemo(() => `shuffle-parent ${ready ? 'is-ready' : ''} ${className}`, [ready, className]);
const Tag = tag || 'p';
return React.createElement(Tag, { ref, className: classes, style: commonStyle }, text);
};
export default Shuffle;

View File

@@ -0,0 +1,189 @@
"use client"
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { useQueryClient } from '@tanstack/react-query'
import {
IconRadar,
IconRefresh,
IconExternalLink,
IconBrandGithub,
IconMessageReport,
IconBook,
IconFileText,
IconCheck,
IconArrowUp,
} from '@tabler/icons-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Badge } from '@/components/ui/badge'
import { useVersion } from '@/hooks/use-version'
import { VersionService } from '@/services/version.service'
import type { UpdateCheckResult } from '@/types/version.types'
interface AboutDialogProps {
children: React.ReactNode
}
export function AboutDialog({ children }: AboutDialogProps) {
const t = useTranslations('about')
const { data: versionData } = useVersion()
const queryClient = useQueryClient()
const [isChecking, setIsChecking] = useState(false)
const [updateResult, setUpdateResult] = useState<UpdateCheckResult | null>(null)
const [checkError, setCheckError] = useState<string | null>(null)
const handleCheckUpdate = async () => {
setIsChecking(true)
setCheckError(null)
try {
const result = await VersionService.checkUpdate()
setUpdateResult(result)
queryClient.setQueryData(['check-update'], result)
} catch {
setCheckError(t('checkFailed'))
} finally {
setIsChecking(false)
}
}
const currentVersion = updateResult?.currentVersion || versionData?.version || '-'
const latestVersion = updateResult?.latestVersion
const hasUpdate = updateResult?.hasUpdate
return (
<Dialog>
<DialogTrigger asChild>
{children}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* Logo and name */}
<div className="flex flex-col items-center py-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 mb-3">
<IconRadar className="h-8 w-8 text-primary" />
</div>
<h2 className="text-xl font-semibold">{t('productName')}</h2>
<p className="text-sm text-muted-foreground">{t('description')}</p>
</div>
{/* Version info */}
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{t('currentVersion')}</span>
<span className="font-mono text-sm">{currentVersion}</span>
</div>
{updateResult && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{t('latestVersion')}</span>
<div className="flex items-center gap-2">
<span className="font-mono text-sm">{latestVersion}</span>
{hasUpdate ? (
<Badge variant="default" className="gap-1">
<IconArrowUp className="h-3 w-3" />
{t('updateAvailable')}
</Badge>
) : (
<Badge variant="secondary" className="gap-1">
<IconCheck className="h-3 w-3" />
{t('upToDate')}
</Badge>
)}
</div>
</div>
)}
{checkError && (
<p className="text-sm text-destructive">{checkError}</p>
)}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={handleCheckUpdate}
disabled={isChecking}
>
<IconRefresh className={`h-4 w-4 mr-2 ${isChecking ? 'animate-spin' : ''}`} />
{isChecking ? t('checking') : t('checkUpdate')}
</Button>
{hasUpdate && updateResult?.releaseUrl && (
<Button
variant="default"
size="sm"
className="flex-1"
asChild
>
<a href={updateResult.releaseUrl} target="_blank" rel="noopener noreferrer">
<IconExternalLink className="h-4 w-4 mr-2" />
{t('viewRelease')}
</a>
</Button>
)}
</div>
{hasUpdate && (
<div className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
<p>{t('updateHint')}</p>
<code className="mt-1 block rounded bg-background px-2 py-1 font-mono text-xs">
sudo ./update.sh
</code>
</div>
)}
</div>
<Separator />
{/* Links */}
<div className="grid grid-cols-2 gap-2">
<Button variant="ghost" size="sm" className="justify-start" asChild>
<a href="https://github.com/yyhuni/xingrin" target="_blank" rel="noopener noreferrer">
<IconBrandGithub className="h-4 w-4 mr-2" />
GitHub
</a>
</Button>
<Button variant="ghost" size="sm" className="justify-start" asChild>
<a href="https://github.com/yyhuni/xingrin/releases" target="_blank" rel="noopener noreferrer">
<IconFileText className="h-4 w-4 mr-2" />
{t('changelog')}
</a>
</Button>
<Button variant="ghost" size="sm" className="justify-start" asChild>
<a href="https://github.com/yyhuni/xingrin/issues" target="_blank" rel="noopener noreferrer">
<IconMessageReport className="h-4 w-4 mr-2" />
{t('feedback')}
</a>
</Button>
<Button variant="ghost" size="sm" className="justify-start" asChild>
<a href="https://github.com/yyhuni/xingrin#readme" target="_blank" rel="noopener noreferrer">
<IconBook className="h-4 w-4 mr-2" />
{t('docs')}
</a>
</Button>
</div>
{/* Footer */}
<p className="text-center text-xs text-muted-foreground">
© 2026 {t('productName')} · GPL-3.0
</p>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,360 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
type MouseGravity = 'attract' | 'repel';
type GlowAnimation = 'instant' | 'ease' | 'spring';
type StarsInteractionType = 'bounce' | 'merge';
type GravityStarsProps = {
starsCount?: number;
starsSize?: number;
starsOpacity?: number;
glowIntensity?: number;
glowAnimation?: GlowAnimation;
movementSpeed?: number;
mouseInfluence?: number;
mouseGravity?: MouseGravity;
gravityStrength?: number;
starsInteraction?: boolean;
starsInteractionType?: StarsInteractionType;
} & React.ComponentProps<'div'>;
type Particle = {
x: number;
y: number;
vx: number;
vy: number;
size: number;
opacity: number;
baseOpacity: number;
mass: number;
glowMultiplier?: number;
glowVelocity?: number;
};
function GravityStarsBackground({
starsCount = 75,
starsSize = 2,
starsOpacity = 0.75,
glowIntensity = 15,
glowAnimation = 'ease',
movementSpeed = 0.3,
mouseInfluence = 100,
mouseGravity = 'attract',
gravityStrength = 75,
starsInteraction = false,
starsInteractionType = 'bounce',
className,
...props
}: GravityStarsProps) {
const containerRef = React.useRef<HTMLDivElement | null>(null);
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const animRef = React.useRef<number | null>(null);
const starsRef = React.useRef<Particle[]>([]);
const mouseRef = React.useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const [dpr, setDpr] = React.useState(1);
const [canvasSize, setCanvasSize] = React.useState({
width: 800,
height: 600,
});
const readColor = React.useCallback(() => {
const el = containerRef.current;
if (!el) return '#ffffff';
const cs = getComputedStyle(el);
return cs.color || '#ffffff';
}, []);
const initStars = React.useCallback(
(w: number, h: number) => {
starsRef.current = Array.from({ length: starsCount }).map(() => {
const angle = Math.random() * Math.PI * 2;
const speed = movementSpeed * (0.5 + Math.random() * 0.5);
return {
x: Math.random() * w,
y: Math.random() * h,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
size: Math.random() * starsSize + 1,
opacity: starsOpacity,
baseOpacity: starsOpacity,
mass: Math.random() * 0.5 + 0.5,
glowMultiplier: 1,
glowVelocity: 0,
};
});
},
[starsCount, movementSpeed, starsOpacity, starsSize],
);
const redistributeStars = React.useCallback((w: number, h: number) => {
starsRef.current.forEach((p) => {
p.x = Math.random() * w;
p.y = Math.random() * h;
});
}, []);
const resizeCanvas = React.useCallback(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const rect = container.getBoundingClientRect();
const nextDpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2));
setDpr(nextDpr);
canvas.width = Math.max(1, Math.floor(rect.width * nextDpr));
canvas.height = Math.max(1, Math.floor(rect.height * nextDpr));
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
setCanvasSize({ width: rect.width, height: rect.height });
if (starsRef.current.length === 0) {
initStars(rect.width, rect.height);
} else {
redistributeStars(rect.width, rect.height);
}
}, [initStars, redistributeStars]);
const handlePointerMove = React.useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
let clientX = 0;
let clientY = 0;
if ('touches' in e) {
const t = e.touches[0];
if (!t) return;
clientX = t.clientX;
clientY = t.clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
mouseRef.current = { x: clientX - rect.left, y: clientY - rect.top };
},
[],
);
const updateStars = React.useCallback(() => {
const w = canvasSize.width;
const h = canvasSize.height;
const mouse = mouseRef.current;
for (let i = 0; i < starsRef.current.length; i++) {
const p = starsRef.current[i];
const dx = mouse.x - p.x;
const dy = mouse.y - p.y;
const dist = Math.hypot(dx, dy);
if (dist < mouseInfluence && dist > 0) {
const force = (mouseInfluence - dist) / mouseInfluence;
const nx = dx / dist;
const ny = dy / dist;
const g = force * (gravityStrength * 0.001);
if (mouseGravity === 'attract') {
p.vx += nx * g;
p.vy += ny * g;
} else if (mouseGravity === 'repel') {
p.vx -= nx * g;
p.vy -= ny * g;
}
p.opacity = Math.min(1, p.baseOpacity + force * 0.4);
const targetGlow = 1 + force * 2;
const currentGlow = p.glowMultiplier || 1;
if (glowAnimation === 'instant') {
p.glowMultiplier = targetGlow;
} else if (glowAnimation === 'ease') {
const ease = 0.15;
p.glowMultiplier = currentGlow + (targetGlow - currentGlow) * ease;
} else {
const spring = (targetGlow - currentGlow) * 0.2;
const damping = 0.85;
p.glowVelocity = (p.glowVelocity || 0) * damping + spring;
p.glowMultiplier = currentGlow + (p.glowVelocity || 0);
}
} else {
p.opacity = Math.max(p.baseOpacity * 0.3, p.opacity - 0.02);
const targetGlow = 1;
const currentGlow = p.glowMultiplier || 1;
if (glowAnimation === 'instant') {
p.glowMultiplier = targetGlow;
} else if (glowAnimation === 'ease') {
const ease = 0.08;
p.glowMultiplier = Math.max(
1,
currentGlow + (targetGlow - currentGlow) * ease,
);
} else {
const spring = (targetGlow - currentGlow) * 0.15;
const damping = 0.9;
p.glowVelocity = (p.glowVelocity || 0) * damping + spring;
p.glowMultiplier = Math.max(1, currentGlow + (p.glowVelocity || 0));
}
}
if (starsInteraction) {
for (let j = i + 1; j < starsRef.current.length; j++) {
const o = starsRef.current[j];
const dx2 = o.x - p.x;
const dy2 = o.y - p.y;
const d = Math.hypot(dx2, dy2);
const minD = p.size + o.size + 5;
if (d < minD && d > 0) {
if (starsInteractionType === 'bounce') {
const nx = dx2 / d;
const ny = dy2 / d;
const rvx = p.vx - o.vx;
const rvy = p.vy - o.vy;
const speed = rvx * nx + rvy * ny;
if (speed < 0) continue;
const impulse = (2 * speed) / (p.mass + o.mass);
p.vx -= impulse * o.mass * nx;
p.vy -= impulse * o.mass * ny;
o.vx += impulse * p.mass * nx;
o.vy += impulse * p.mass * ny;
const overlap = minD - d;
const sx = nx * overlap * 0.5;
const sy = ny * overlap * 0.5;
p.x -= sx;
p.y -= sy;
o.x += sx;
o.y += sy;
} else {
const mergeForce = (minD - d) / minD;
p.glowMultiplier = (p.glowMultiplier || 1) + mergeForce * 0.5;
o.glowMultiplier = (o.glowMultiplier || 1) + mergeForce * 0.5;
const af = mergeForce * 0.01;
p.vx += dx2 * af;
p.vy += dy2 * af;
o.vx -= dx2 * af;
o.vy -= dy2 * af;
}
}
}
}
p.x += p.vx;
p.y += p.vy;
p.vx += (Math.random() - 0.5) * 0.001;
p.vy += (Math.random() - 0.5) * 0.001;
p.vx *= 0.999;
p.vy *= 0.999;
if (p.x < 0) p.x = w;
if (p.x > w) p.x = 0;
if (p.y < 0) p.y = h;
if (p.y > h) p.y = 0;
}
}, [
canvasSize.width,
canvasSize.height,
mouseInfluence,
mouseGravity,
gravityStrength,
glowAnimation,
starsInteraction,
starsInteractionType,
]);
const drawStars = React.useCallback(
(ctx: CanvasRenderingContext2D) => {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
const color = readColor();
for (const p of starsRef.current) {
ctx.save();
ctx.shadowColor = color;
ctx.shadowBlur = glowIntensity * (p.glowMultiplier || 1) * 2;
ctx.globalAlpha = p.opacity;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(p.x * dpr, p.y * dpr, p.size * dpr, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
},
[dpr, glowIntensity, readColor],
);
const animate = React.useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
updateStars();
drawStars(ctx);
animRef.current = requestAnimationFrame(animate);
}, [updateStars, drawStars]);
React.useEffect(() => {
resizeCanvas();
const container = containerRef.current;
const ro =
typeof ResizeObserver !== 'undefined'
? new ResizeObserver(resizeCanvas)
: null;
if (container && ro) ro.observe(container);
const onResize = () => resizeCanvas();
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
if (ro && container) ro.disconnect();
};
}, [resizeCanvas]);
React.useEffect(() => {
if (starsRef.current.length === 0) {
initStars(canvasSize.width, canvasSize.height);
} else {
starsRef.current.forEach((p) => {
p.baseOpacity = starsOpacity;
p.opacity = starsOpacity;
const spd = Math.hypot(p.vx, p.vy);
if (spd > 0) {
const ratio = movementSpeed / spd;
p.vx *= ratio;
p.vy *= ratio;
}
});
}
}, [
starsCount,
starsOpacity,
movementSpeed,
canvasSize.width,
canvasSize.height,
initStars,
]);
React.useEffect(() => {
if (animRef.current) cancelAnimationFrame(animRef.current);
animRef.current = requestAnimationFrame(animate);
return () => {
if (animRef.current) cancelAnimationFrame(animRef.current);
animRef.current = null;
};
}, [animate]);
return (
<div
ref={containerRef}
data-slot="gravity-stars-background"
className={cn('relative size-full overflow-hidden', className)}
onMouseMove={(e) => handlePointerMove(e)}
onTouchMove={(e) => handlePointerMove(e)}
{...props}
>
<canvas ref={canvasRef} className="block w-full h-full" />
</div>
);
}
export { GravityStarsBackground, type GravityStarsProps };

View File

@@ -5,7 +5,6 @@ import type * as React from "react"
// Import various icons from Tabler Icons library
import {
IconDashboard, // Dashboard icon
IconHelp, // Help icon
IconListDetails, // List details icon
IconSettings, // Settings icon
IconUsers, // Users icon
@@ -15,10 +14,10 @@ import {
IconServer, // Server icon
IconTerminal2, // Terminal icon
IconBug, // Vulnerability icon
IconMessageReport, // Feedback icon
IconSearch, // Search icon
IconKey, // API Key icon
IconBan, // Blacklist icon
IconInfoCircle, // About icon
} from "@tabler/icons-react"
// Import internationalization hook
import { useTranslations } from 'next-intl'
@@ -27,8 +26,8 @@ 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"
import { AboutDialog } from "@/components/about-dialog"
// Import sidebar UI components
import {
Sidebar,
@@ -139,20 +138,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
},
]
// Secondary navigation menu items
const navSecondary = [
{
title: t('feedback'),
url: "https://github.com/yyhuni/xingrin/issues",
icon: IconMessageReport,
},
{
title: t('help'),
url: "https://github.com/yyhuni/xingrin",
icon: IconHelp,
},
]
// System settings related menu items
const documents = [
{
@@ -194,8 +179,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
className="data-[slot=sidebar-menu-button]:!p-1.5"
>
<Link href="/">
<IconRadar className="!size-5" />
<span className="text-base font-semibold">XingRin</span>
<IconRadar className="!size-5 text-primary" />
<span className="text-base font-semibold">{t('appName')}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -271,8 +256,21 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
{/* System settings navigation menu */}
<NavSystem items={documents} />
{/* Secondary navigation menu, using mt-auto to push to bottom */}
<NavSecondary items={navSecondary} className="mt-auto" />
{/* About system button */}
<SidebarGroup className="mt-auto">
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<AboutDialog>
<SidebarMenuButton>
<IconInfoCircle />
<span>{t('about')}</span>
</SidebarMenuButton>
</AboutDialog>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
{/* Sidebar footer */}

View File

@@ -40,8 +40,8 @@ export function ChangePasswordDialog({ open, onOpenChange }: ChangePasswordDialo
return
}
if (newPassword.length < 4) {
setError(t("passwordTooShort", { min: 4 }))
if (newPassword.length < 6) {
setError(t("passwordTooShort", { min: 6 }))
return
}

View File

@@ -0,0 +1,151 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
type BootLine = {
text: string
className?: string
}
const BOOT_LINES: BootLine[] = [
{ text: "> booting ORBIT...", className: "text-yellow-500" },
{ text: "> initializing secure terminal...", className: "text-zinc-200" },
{ text: "> loading modules: auth, i18n, ui...", className: "text-zinc-200" },
{ text: "> checking session...", className: "text-yellow-500" },
{ text: "> ready.", className: "text-green-500" },
]
const SUCCESS_LINES: BootLine[] = [
{ text: "> authentication successful", className: "text-green-500" },
{ text: "> loading user profile...", className: "text-zinc-200" },
{ text: "> initializing dashboard...", className: "text-zinc-200" },
{ text: "> preparing workspace...", className: "text-yellow-500" },
{ text: "> access granted.", className: "text-green-500" },
]
// Keep the log animation snappy so it can complete within the 0.6s splash.
const STEP_DELAYS_MS = [70, 90, 110, 130, 150]
const GLITCH_MS = 600
export function LoginBootScreen({ className, success = false }: { className?: string; success?: boolean }) {
const [visible, setVisible] = React.useState(0)
const [entered, setEntered] = React.useState(false)
const [glitchOn, setGlitchOn] = React.useState(true)
// 根据 success 状态选择显示的行
const displayLines = success ? SUCCESS_LINES : BOOT_LINES
React.useEffect(() => {
const raf = requestAnimationFrame(() => setEntered(true))
return () => cancelAnimationFrame(raf)
}, [])
React.useEffect(() => {
setGlitchOn(true)
const timer = setTimeout(() => setGlitchOn(false), GLITCH_MS)
return () => clearTimeout(timer)
}, [])
React.useEffect(() => {
setVisible(0)
const timers: Array<ReturnType<typeof setTimeout>> = []
let acc = 0
for (let i = 0; i < displayLines.length; i++) {
acc += STEP_DELAYS_MS[i] ?? 160
timers.push(
setTimeout(() => {
setVisible((prev) => Math.max(prev, i + 1))
}, acc)
)
}
return () => {
timers.forEach(clearTimeout)
}
}, [displayLines])
const progress = Math.round((Math.min(visible, displayLines.length) / displayLines.length) * 100)
return (
<div className={cn("relative flex min-h-svh flex-col bg-black", glitchOn && "orbit-splash-glitch", className)}>
{/* Main content area */}
<div className="relative z-10 flex-1 flex items-center justify-center p-6">
<div
className={cn(
"border-zinc-700 bg-zinc-900/80 backdrop-blur-sm z-0 w-full max-w-xl rounded-xl border transition-opacity duration-200 ease-out motion-reduce:transition-none",
entered ? "opacity-100" : "opacity-0"
)}
>
{/* Terminal header */}
<div className="border-zinc-700 flex items-center gap-x-2 border-b px-4 py-3">
<div className="flex flex-row gap-x-2">
<div className="h-3 w-3 rounded-full bg-red-500" />
<div className="h-3 w-3 rounded-full bg-yellow-500" />
<div className="h-3 w-3 rounded-full bg-green-500" />
</div>
<span className="ml-2 text-xs text-zinc-400 font-mono">ORBIT · boot</span>
<span className="ml-auto text-xs text-zinc-500 font-mono">{progress}%</span>
</div>
{/* Terminal body */}
<div className="p-4 font-mono text-sm min-h-[280px]">
<div className="mb-6 text-center">
<div
className={cn(
"text-3xl sm:text-4xl !font-bold tracking-wide",
"bg-gradient-to-r from-[#FF10F0] via-[#B026FF] to-[#FF10F0] bg-clip-text text-transparent",
glitchOn && "orbit-glitch-text"
)}
data-text="ORBIT"
style={{
filter: "drop-shadow(0 0 20px rgba(255, 16, 240, 0.5)) drop-shadow(0 0 40px rgba(176, 38, 255, 0.3))"
}}
>
ORBIT
</div>
<div className="mt-3 flex items-center gap-3 text-zinc-400 text-xs">
<span className="h-px flex-1 bg-gradient-to-r from-transparent via-[#B026FF] to-transparent" />
<span className="whitespace-nowrap">system bootstrap</span>
<span className="h-px flex-1 bg-gradient-to-r from-transparent via-[#B026FF] to-transparent" />
</div>
</div>
<div className="space-y-1">
{displayLines.slice(0, visible).map((line, idx) => (
<div key={idx} className={cn("whitespace-pre-wrap", line.className)}>
{line.text}
</div>
))}
{/* Cursor */}
<div className="text-green-500">
<span className="inline-block h-4 w-2 align-middle bg-green-500 animate-pulse" />
</div>
</div>
{/* Progress bar */}
<div className="mt-6">
<div className="h-1.5 w-full rounded bg-zinc-800 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-[#FF10F0] to-[#B026FF]"
style={{
width: `${progress}%`,
boxShadow: "0 0 10px rgba(255, 16, 240, 0.5), 0 0 20px rgba(176, 38, 255, 0.3)"
}}
/>
</div>
<div className="mt-2 text-xs text-zinc-500">
Checking session
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -34,36 +34,6 @@ interface BulkAddUrlsDialogProps {
onSuccess?: () => void
}
const ASSET_TYPE_LABELS: Record<AssetType, { title: string; description: string; placeholder: string }> = {
endpoint: {
title: 'Bulk Add Endpoints',
description: 'Enter endpoint URL list, one per line.',
placeholder: `Please enter endpoint URLs, one per line
Example:
https://example.com/api/v1
https://example.com/api/v2
https://example.com/login`,
},
website: {
title: 'Bulk Add Websites',
description: 'Enter website URL list, one per line.',
placeholder: `Please enter website URLs, one per line
Example:
https://example.com
https://www.example.com
https://api.example.com`,
},
directory: {
title: 'Bulk Add Directories',
description: 'Enter directory URL list, one per line.',
placeholder: `Please enter directory URLs, one per line
Example:
https://example.com/admin
https://example.com/api
https://example.com/uploads`,
},
}
/**
* Bulk add URLs dialog component
*
@@ -80,6 +50,14 @@ export function BulkAddUrlsDialog({
onSuccess,
}: BulkAddUrlsDialogProps) {
const tBulkAdd = useTranslations("bulkAdd.common")
const tUrl = useTranslations("bulkAdd.url")
// Get translated labels based on asset type
const labels = {
title: tUrl(`${assetType}.title`),
description: tUrl(`${assetType}.description`),
placeholder: tUrl(`${assetType}.placeholder`),
}
// Dialog open/close state
const [internalOpen, setInternalOpen] = useState(false)
@@ -121,7 +99,6 @@ export function BulkAddUrlsDialog({
}
const mutation = getMutation()
const labels = ASSET_TYPE_LABELS[assetType]
// Handle input changes
const handleInputChange = (value: string) => {
@@ -222,7 +199,7 @@ export function BulkAddUrlsDialog({
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4" />
Bulk Add
{tBulkAdd("bulkAdd")}
</Button>
</DialogTrigger>
)}
@@ -242,7 +219,7 @@ export function BulkAddUrlsDialog({
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="urls">
URL List <span className="text-destructive">*</span>
{tUrl("label")} <span className="text-destructive">*</span>
</Label>
<div className="flex border rounded-md overflow-hidden h-[220px]">
{/* Line number column */}
@@ -278,39 +255,43 @@ export function BulkAddUrlsDialog({
{validationResult && (
<div className="text-xs space-y-1">
<div className="text-muted-foreground">
Valid: {validationResult.validCount} items
{tUrl("valid", { count: validationResult.validCount })}
{validationResult.duplicateCount > 0 && (
<span className="text-yellow-600 ml-2">
Duplicate: {validationResult.duplicateCount} items
{tUrl("duplicate", { count: validationResult.duplicateCount })}
</span>
)}
{validationResult.invalidCount > 0 && (
<span className="text-destructive ml-2">
Invalid: {validationResult.invalidCount} items
{tUrl("invalid", { count: validationResult.invalidCount })}
</span>
)}
{validationResult.mismatchedCount > 0 && (
<span className="text-destructive ml-2">
Mismatched: {validationResult.mismatchedCount} items
{tUrl("mismatched", { count: validationResult.mismatchedCount })}
</span>
)}
</div>
{validationResult.firstError && (
<div className="text-destructive">
Line {validationResult.firstError.index + 1}: &quot;
{validationResult.firstError.url.length > 50
? validationResult.firstError.url.substring(0, 50) + '...'
: validationResult.firstError.url}&quot; -{" "}
{validationResult.firstError.error}
{tUrl("lineError", {
line: validationResult.firstError.index + 1,
value: validationResult.firstError.url.length > 50
? validationResult.firstError.url.substring(0, 50) + '...'
: validationResult.firstError.url,
error: validationResult.firstError.error,
})}
</div>
)}
{validationResult.firstMismatch && !validationResult.firstError && (
<div className="text-destructive">
Line {validationResult.firstMismatch.index + 1}: &quot;
{validationResult.firstMismatch.url.length > 50
? validationResult.firstMismatch.url.substring(0, 50) + '...'
: validationResult.firstMismatch.url}&quot; -
URL does not belong to target {targetName}, please remove before submitting
{tUrl("mismatchError", {
line: validationResult.firstMismatch.index + 1,
value: validationResult.firstMismatch.url.length > 50
? validationResult.firstMismatch.url.substring(0, 50) + '...'
: validationResult.firstMismatch.url,
target: targetName || '',
})}
</div>
)}
</div>
@@ -325,7 +306,7 @@ export function BulkAddUrlsDialog({
onClick={() => handleOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
{tBulkAdd("cancel")}
</Button>
<Button
type="submit"
@@ -334,12 +315,12 @@ export function BulkAddUrlsDialog({
{mutation.isPending ? (
<>
<LoadingSpinner />
Creating...
{tUrl("creating")}
</>
) : (
<>
<Plus className="h-4 w-4" />
Bulk Add
{tBulkAdd("bulkAdd")}
</>
)}
</Button>

View File

@@ -94,7 +94,7 @@ export function AssetTrendChart() {
} satisfies ChartConfig), [t])
// Visible series state (show all by default)
const [visibleSeries, setVisibleSeries] = useState<Set<SeriesKey>>(new Set(ALL_SERIES))
const [visibleSeries, setVisibleSeries] = useState<Set<SeriesKey>>(() => new Set(ALL_SERIES))
// Currently hovered line
const [hoveredLine, setHoveredLine] = useState<SeriesKey | null>(null)
@@ -136,10 +136,13 @@ export function AssetTrendChart() {
}
// Get latest data (use latest value from raw data)
const latest = rawData && rawData.length > 0 ? rawData[rawData.length - 1] : null
const latest = useMemo(() =>
rawData && rawData.length > 0 ? rawData[rawData.length - 1] : null,
[rawData]
)
// Display data: show hovered data when hovering, otherwise show latest data
const displayData = activeData || latest
const displayData = useMemo(() => activeData || latest, [activeData, latest])
return (
<Card>

View File

@@ -129,6 +129,8 @@ export function DashboardDataTable() {
},
tooltips: {
vulnDetails: t('tooltips.vulnDetails'),
reviewed: t('tooltips.reviewed'),
pending: t('tooltips.pending'),
},
severity: {
critical: t('severity.critical'),
@@ -230,7 +232,7 @@ export function DashboardDataTable() {
cancelled: t('common.status.cancelled'),
completed: t('common.status.completed'),
failed: t('common.status.failed'),
initiated: t('common.status.pending'),
pending: t('common.status.pending'),
running: t('common.status.running'),
},
summary: {

View File

@@ -49,7 +49,7 @@ export function DashboardScanHistory() {
cancelled: tCommon("status.cancelled"),
completed: tCommon("status.completed"),
failed: tCommon("status.failed"),
initiated: tCommon("status.pending"),
pending: tCommon("status.pending"),
running: tCommon("status.running"),
},
summary: {

View File

@@ -1,5 +1,6 @@
"use client"
import { memo } from "react"
import { useAssetStatistics } from "@/hooks/use-dashboard"
import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
@@ -8,7 +9,7 @@ import { IconTarget, IconStack2, IconBug, IconPlayerPlay, IconTrendingUp, IconTr
import { useTranslations } from "next-intl"
import { useLocale } from "next-intl"
function TrendBadge({ change }: { change: number }) {
const TrendBadge = memo(function TrendBadge({ change }: { change: number }) {
if (change === 0) return null
const isPositive = change > 0
@@ -24,9 +25,9 @@ function TrendBadge({ change }: { change: number }) {
{isPositive ? '+' : ''}{change}
</Badge>
)
}
})
function StatCard({
const StatCard = memo(function StatCard({
title,
value,
change,
@@ -66,7 +67,7 @@ function StatCard({
</CardFooter>
</Card>
)
}
})
function formatUpdateTime(dateStr: string | null, locale: string, noDataText: string) {
if (!dateStr) return noDataText

View File

@@ -23,24 +23,18 @@ import {
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { IconExternalLink } from "@tabler/icons-react"
import { Circle, CheckCircle2 } from "lucide-react"
import type { VulnerabilitySeverity } from "@/types/vulnerability.types"
import { useTranslations } from "next-intl"
import { useLocale } from "next-intl"
// Unified vulnerability severity color configuration (consistent with charts)
const severityStyles: Record<VulnerabilitySeverity, string> = {
critical: "bg-[#da3633]/10 text-[#da3633] border border-[#da3633]/20 dark:text-[#f85149]",
high: "bg-[#d29922]/10 text-[#d29922] border border-[#d29922]/20",
medium: "bg-[#d4a72c]/10 text-[#d4a72c] border border-[#d4a72c]/20",
low: "bg-[#238636]/10 text-[#238636] border border-[#238636]/20 dark:text-[#3fb950]",
info: "bg-[#848d97]/10 text-[#848d97] border border-[#848d97]/20",
}
import { SEVERITY_STYLES } from "@/lib/severity-config"
export function RecentVulnerabilities() {
const router = useRouter()
const t = useTranslations("dashboard.recentVulns")
const tSeverity = useTranslations("severity")
const tColumns = useTranslations("columns")
const tTooltips = useTranslations("tooltips")
const locale = useLocale()
const formatTime = (dateStr: string) => {
@@ -54,11 +48,11 @@ export function RecentVulnerabilities() {
}
const severityConfig = useMemo(() => ({
critical: { label: tSeverity("critical"), className: severityStyles.critical },
high: { label: tSeverity("high"), className: severityStyles.high },
medium: { label: tSeverity("medium"), className: severityStyles.medium },
low: { label: tSeverity("low"), className: severityStyles.low },
info: { label: tSeverity("info"), className: severityStyles.info },
critical: { label: tSeverity("critical"), className: SEVERITY_STYLES.critical.className },
high: { label: tSeverity("high"), className: SEVERITY_STYLES.high.className },
medium: { label: tSeverity("medium"), className: SEVERITY_STYLES.medium.className },
low: { label: tSeverity("low"), className: SEVERITY_STYLES.low.className },
info: { label: tSeverity("info"), className: SEVERITY_STYLES.info.className },
}), [tSeverity])
const { data, isLoading } = useQuery({
@@ -100,6 +94,7 @@ export function RecentVulnerabilities() {
<TableHeader>
<TableRow>
<TableHead>{tColumns("common.status")}</TableHead>
<TableHead>{tColumns("vulnerability.severity")}</TableHead>
<TableHead>{tColumns("vulnerability.source")}</TableHead>
<TableHead>{tColumns("common.type")}</TableHead>
<TableHead>{tColumns("common.url")}</TableHead>
@@ -107,31 +102,52 @@ export function RecentVulnerabilities() {
</TableRow>
</TableHeader>
<TableBody>
{vulnerabilities.map((vuln: any) => (
<TableRow
key={vuln.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => router.push(`/vulnerabilities/?id=${vuln.id}`)}
>
<TableCell>
<Badge className={severityConfig[vuln.severity as VulnerabilitySeverity]?.className}>
{severityConfig[vuln.severity as VulnerabilitySeverity]?.label ?? vuln.severity}
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline">{vuln.source}</Badge>
</TableCell>
<TableCell className="font-medium max-w-[120px] truncate">
{vuln.vulnType}
</TableCell>
<TableCell className="text-muted-foreground text-xs max-w-[200px] truncate">
{vuln.url}
</TableCell>
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
{formatTime(vuln.createdAt)}
</TableCell>
</TableRow>
))}
{vulnerabilities.map((vuln: any) => {
const isReviewed = vuln.isReviewed
const isPending = !isReviewed
return (
<TableRow
key={vuln.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => router.push(`/vulnerabilities/?id=${vuln.id}`)}
>
<TableCell>
<Badge
variant="outline"
className={`transition-all gap-1.5 cursor-default ${isPending
? "bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-400 dark:border-blue-400/30"
: "bg-muted/50 text-muted-foreground border-muted-foreground/20"
}`}
>
{isPending ? (
<Circle className="h-3 w-3" />
) : (
<CheckCircle2 className="h-3 w-3" />
)}
{isPending ? tTooltips("pending") : tTooltips("reviewed")}
</Badge>
</TableCell>
<TableCell>
<Badge className={severityConfig[vuln.severity as VulnerabilitySeverity]?.className}>
{severityConfig[vuln.severity as VulnerabilitySeverity]?.label ?? vuln.severity}
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline">{vuln.source}</Badge>
</TableCell>
<TableCell className="font-medium max-w-[120px] truncate">
{vuln.vulnType}
</TableCell>
<TableCell className="text-muted-foreground text-xs max-w-[200px] truncate">
{vuln.url}
</TableCell>
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
{formatTime(vuln.createdAt)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>

View File

@@ -18,15 +18,7 @@ import {
} from "@/components/ui/chart"
import { Skeleton } from "@/components/ui/skeleton"
import { useTranslations } from "next-intl"
// 漏洞严重程度使用固定语义化颜色
const SEVERITY_COLORS = {
critical: "#dc2626", // 红色
high: "#f97316", // 橙色
medium: "#eab308", // 黄色
low: "#3b82f6", // 蓝色
info: "#6b7280", // 灰色
}
import { SEVERITY_COLORS } from "@/lib/severity-config"
export function VulnSeverityChart() {
const { data, isLoading } = useAssetStatistics()

View File

@@ -14,10 +14,7 @@ export interface DirectoryTranslations {
url: string
status: string
length: string
words: string
lines: string
contentType: string
duration: string
createdAt: string
}
actions: {
@@ -56,15 +53,6 @@ function StatusBadge({ status }: { status: number | null }) {
)
}
/**
* Format duration (nanoseconds to milliseconds)
*/
function formatDuration(nanoseconds: number | null): string {
if (nanoseconds === null) return "-"
const milliseconds = nanoseconds / 1000000
return `${milliseconds.toFixed(2)} ms`
}
/**
* Create directory table column definitions
*/
@@ -138,34 +126,6 @@ export function createDirectoryColumns({
return <span>{length !== null ? length.toLocaleString() : "-"}</span>
},
},
{
accessorKey: "words",
size: 80,
minSize: 60,
maxSize: 120,
meta: { title: t.columns.words },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t.columns.words} />
),
cell: ({ row }) => {
const words = row.getValue("words") as number | null
return <span>{words !== null ? words.toLocaleString() : "-"}</span>
},
},
{
accessorKey: "lines",
size: 80,
minSize: 60,
maxSize: 120,
meta: { title: t.columns.lines },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t.columns.lines} />
),
cell: ({ row }) => {
const lines = row.getValue("lines") as number | null
return <span>{lines !== null ? lines.toLocaleString() : "-"}</span>
},
},
{
accessorKey: "contentType",
size: 120,
@@ -185,20 +145,6 @@ export function createDirectoryColumns({
)
},
},
{
accessorKey: "duration",
size: 100,
minSize: 80,
maxSize: 150,
meta: { title: t.columns.duration },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t.columns.duration} />
),
cell: ({ row }) => {
const duration = row.getValue("duration") as number | null
return <span className="text-muted-foreground">{formatDuration(duration)}</span>
},
},
{
accessorKey: "createdAt",
size: 150,

View File

@@ -18,7 +18,7 @@ const DIRECTORY_FILTER_FIELDS: FilterField[] = [
// Directory page filter examples
const DIRECTORY_FILTER_EXAMPLES = [
'url="/admin" && status="200"',
'url="/api/*" || url="/config/*"',
'url="/api/" || url="/config/"',
'status="200" && url!="/index.html"',
]

View File

@@ -37,6 +37,7 @@ export function DirectoriesView({
const [isSearching, setIsSearching] = useState(false)
// Internationalization
const t = useTranslations("pages.targetDetail")
const tColumns = useTranslations("columns")
const tCommon = useTranslations("common")
const tToast = useTranslations("toast")
@@ -307,7 +308,9 @@ export function DirectoriesView({
onBulkAdd={targetId ? () => setBulkAddDialogOpen(true) : undefined}
/>
{/* Bulk add dialog */}
{/* Bulk add dialog */
/* ... */
}
{targetId && (
<BulkAddUrlsDialog
targetId={targetId}

View File

@@ -20,7 +20,7 @@ const ENDPOINT_FILTER_FIELDS: FilterField[] = [
// Endpoint page filter examples
const ENDPOINT_FILTER_EXAMPLES = [
'url="/api/*" && status="200"',
'url="/api/" && status="200"',
'host="api.example.com" || host="admin.example.com"',
'title="Dashboard" && status!="404"',
'tech="php" || tech="wordpress"',

View File

@@ -9,6 +9,7 @@ import { ExpandableCell, ExpandableMonoCell } from "@/components/ui/data-table/e
import { ChevronDown, ChevronUp } from "lucide-react"
import { useTranslations } from "next-intl"
import type { FingerPrintHubFingerprint } from "@/types/fingerprint.types"
import { getSeverityStyle } from "@/lib/severity-config"
interface ColumnOptions {
formatDate: (date: string) => string
@@ -18,15 +19,7 @@ interface ColumnOptions {
* Severity badge with color coding (matching Vulnerabilities style)
*/
function SeverityBadge({ severity }: { severity: string }) {
const severityConfig: Record<string, { className: string }> = {
critical: { className: "bg-[#da3633]/10 text-[#da3633] border border-[#da3633]/20 dark:text-[#f85149]" },
high: { className: "bg-[#d29922]/10 text-[#d29922] border border-[#d29922]/20" },
medium: { className: "bg-[#d4a72c]/10 text-[#d4a72c] border border-[#d4a72c]/20" },
low: { className: "bg-[#238636]/10 text-[#238636] border border-[#238636]/20 dark:text-[#3fb950]" },
info: { className: "bg-[#848d97]/10 text-[#848d97] border border-[#848d97]/20" },
}
const config = severityConfig[severity?.toLowerCase()] || severityConfig.info
const config = getSeverityStyle(severity)
return (
<Badge className={config.className}>

View File

@@ -91,41 +91,8 @@ export function createIPAddressColumns({
),
cell: ({ getValue }) => {
const hosts = getValue<string[]>()
if (!hosts || hosts.length === 0) {
return <span className="text-muted-foreground">-</span>
}
const displayHosts = hosts.slice(0, 3)
const hasMore = hosts.length > 3
return (
<div className="flex flex-col gap-1">
{displayHosts.map((host, index) => (
<ExpandableCell key={index} value={host} maxLines={1} />
))}
{hasMore && (
<Popover>
<PopoverTrigger asChild>
<Badge variant="secondary" className="text-xs w-fit cursor-pointer hover:bg-muted">
+{hosts.length - 3} more
</Badge>
</PopoverTrigger>
<PopoverContent className="w-80 p-3">
<div className="space-y-2">
<h4 className="font-medium text-sm">{t.tooltips.allHosts} ({hosts.length})</h4>
<div className="flex flex-col gap-1 max-h-48 overflow-y-auto">
{hosts.map((host, index) => (
<span key={index} className="text-sm break-all">
{host}
</span>
))}
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
)
const value = hosts?.length ? hosts.join("\n") : null
return <ExpandableCell value={value} maxLines={3} />
},
},
{

View File

@@ -18,7 +18,7 @@ const IP_ADDRESS_FILTER_FIELDS: FilterField[] = [
// IP address page filter examples
const IP_ADDRESS_FILTER_EXAMPLES = [
'ip="192.168.1.*" && port="80"',
'ip="192.168.1." && port="80"',
'port="443" || port="8443"',
'host="api.example.com" && port!="22"',
]

View File

@@ -200,22 +200,42 @@ export function IPAddressesView({
}
// Handle download selected IP addresses
const handleDownloadSelected = () => {
const handleDownloadSelected = async () => {
if (selectedIPAddresses.length === 0) {
return
}
const csvContent = generateCSV(selectedIPAddresses)
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
const prefix = scanId ? `scan-${scanId}` : targetId ? `target-${targetId}` : "ip-addresses"
a.href = url
a.download = `${prefix}-ip-addresses-selected-${Date.now()}.csv`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
try {
// Get selected IPs and call backend export API
const ips = selectedIPAddresses.map(ip => ip.ip)
let blob: Blob | null = null
if (targetId) {
blob = await IPAddressService.exportIPAddressesByTargetId(targetId, ips)
} else if (scanId) {
// For scan, use frontend CSV generation as fallback (scan export doesn't support IP filter yet)
const csvContent = generateCSV(selectedIPAddresses)
blob = new Blob([csvContent], { type: "text/csv;charset=utf-8" })
} else {
const csvContent = generateCSV(selectedIPAddresses)
blob = new Blob([csvContent], { type: "text/csv;charset=utf-8" })
}
if (!blob) return
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
const prefix = scanId ? `scan-${scanId}` : targetId ? `target-${targetId}` : "ip-addresses"
a.href = url
a.download = `${prefix}-ip-addresses-selected-${Date.now()}.csv`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (error) {
console.error("Failed to download selected IP addresses", error)
toast.error(tToast("downloadFailed"))
}
}
// Handle bulk delete

View File

@@ -18,6 +18,7 @@ import { cn } from "@/lib/utils"
import { transformBackendNotification, useNotificationSSE } from "@/hooks/use-notification-sse"
import { useMarkAllAsRead, useNotifications } from "@/hooks/use-notifications"
import type { Notification, NotificationType, NotificationSeverity } from "@/types/notification.types"
import { SEVERITY_CARD_STYLES, SEVERITY_ICON_BG } from "@/lib/severity-config"
/**
* Notification drawer component
@@ -71,12 +72,52 @@ function getTimeGroup(dateStr?: string): 'today' | 'yesterday' | 'earlier' {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000)
if (date >= today) return 'today'
if (date >= yesterday) return 'yesterday'
return 'earlier'
}
/** Severity icon class mapping */
const SEVERITY_ICON_CLASS_MAP: Record<NotificationSeverity, string> = {
critical: "text-[#da3633] dark:text-[#f85149]",
high: "text-[#d29922]",
medium: "text-[#d4a72c]",
low: "text-[#848d97]",
}
/** Severity card class mapping */
const SEVERITY_CARD_CLASS_MAP: Record<NotificationSeverity, string> = {
critical: SEVERITY_CARD_STYLES.critical,
high: SEVERITY_CARD_STYLES.high,
medium: SEVERITY_CARD_STYLES.medium,
low: SEVERITY_CARD_STYLES.low,
}
/** Get notification icon based on type and severity */
function getNotificationIcon(type: NotificationType, severity?: NotificationSeverity) {
const severityClass = severity ? SEVERITY_ICON_CLASS_MAP[severity] : "text-gray-500"
if (type === "vulnerability") {
return <AlertTriangle className={cn("h-5 w-5", severityClass)} />
}
if (type === "scan") {
return <Activity className={cn("h-5 w-5", severityClass)} />
}
if (type === "asset") {
return <Server className={cn("h-5 w-5", severityClass)} />
}
return <Info className={cn("h-5 w-5", severityClass)} />
}
/** Get notification card classes based on severity */
function getNotificationCardClasses(severity?: NotificationSeverity) {
if (!severity) {
return "border-border bg-card hover:bg-accent/50"
}
return cn("border-border", SEVERITY_CARD_CLASS_MAP[severity] ?? "")
}
export function NotificationDrawer() {
const t = useTranslations("notificationDrawer")
const [open, setOpen] = React.useState(false)
@@ -94,20 +135,20 @@ export function NotificationDrawer() {
{ value: 'system', label: t("filters.system"), icon: <Info className="h-3 w-3" /> },
]
// Category title mapping
const categoryTitleMap: Record<NotificationType, string> = {
// Category title mapping (memoized to avoid recreation)
const categoryTitleMap = React.useMemo<Record<NotificationType, string>>(() => ({
scan: t("categories.scan"),
vulnerability: t("categories.vulnerability"),
asset: t("categories.asset"),
system: t("categories.system"),
}
}), [t])
// Time group labels
const timeGroupLabels = {
// Time group labels (memoized to avoid recreation)
const timeGroupLabels = React.useMemo(() => ({
today: t("timeGroups.today"),
yesterday: t("timeGroups.yesterday"),
earlier: t("timeGroups.earlier"),
}
}), [t])
// SSE real-time notifications
const { notifications: sseNotifications, isConnected, markNotificationsAsRead } = useNotificationSSE()
@@ -139,7 +180,7 @@ export function NotificationDrawer() {
}
}
return merged.sort((a, b) => {
return merged.toSorted((a, b) => {
const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0
const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0
return bTime - aTime
@@ -175,43 +216,6 @@ export function NotificationDrawer() {
return allNotifications.filter(n => n.type === activeFilter)
}, [allNotifications, activeFilter])
// Get notification icon
const severityIconClassMap: Record<NotificationSeverity, string> = {
critical: "text-[#da3633] dark:text-[#f85149]",
high: "text-[#d29922]",
medium: "text-[#d4a72c]",
low: "text-[#848d97]",
}
const getNotificationIcon = (type: NotificationType, severity?: NotificationSeverity) => {
const severityClass = severity ? severityIconClassMap[severity] : "text-gray-500"
if (type === "vulnerability") {
return <AlertTriangle className={cn("h-5 w-5", severityClass)} />
}
if (type === "scan") {
return <Activity className={cn("h-5 w-5", severityClass)} />
}
if (type === "asset") {
return <Server className={cn("h-5 w-5", severityClass)} />
}
return <Info className={cn("h-5 w-5", severityClass)} />
}
const severityCardClassMap: Record<NotificationSeverity, string> = {
critical: "border-[#da3633]/30 bg-[#da3633]/5 hover:bg-[#da3633]/10 dark:border-[#f85149]/30 dark:bg-[#f85149]/5 dark:hover:bg-[#f85149]/10",
high: "border-[#d29922]/30 bg-[#d29922]/5 hover:bg-[#d29922]/10 dark:border-[#d29922]/30 dark:bg-[#d29922]/5 dark:hover:bg-[#d29922]/10",
medium: "border-[#d4a72c]/30 bg-[#d4a72c]/5 hover:bg-[#d4a72c]/10 dark:border-[#d4a72c]/30 dark:bg-[#d4a72c]/5 dark:hover:bg-[#d4a72c]/10",
low: "border-[#848d97]/30 bg-[#848d97]/5 hover:bg-[#848d97]/10 dark:border-[#848d97]/30 dark:bg-[#848d97]/5 dark:hover:bg-[#848d97]/10",
}
const getNotificationCardClasses = (severity?: NotificationSeverity) => {
if (!severity) {
return "border-border bg-card hover:bg-accent/50"
}
return cn("border-border", severityCardClassMap[severity] ?? "")
}
const handleMarkAll = React.useCallback(() => {
if (allNotifications.length === 0 || isMarkingAll) return
markAllAsRead(undefined, {
@@ -240,8 +244,8 @@ export function NotificationDrawer() {
return groups
}, [filteredNotifications])
// Render single notification card
const renderNotificationCard = (notification: Notification) => (
// Render single notification card (memoized to avoid recreation)
const renderNotificationCard = React.useCallback((notification: Notification) => (
<div
key={notification.id}
className={cn(
@@ -256,10 +260,10 @@ export function NotificationDrawer() {
<div className="flex items-start gap-3">
<div className={cn(
"mt-0.5 p-1.5 rounded-full shrink-0",
notification.severity === 'critical' && "bg-[#da3633]/10 dark:bg-[#f85149]/10",
notification.severity === 'high' && "bg-[#d29922]/10",
notification.severity === 'medium' && "bg-[#d4a72c]/10",
(!notification.severity || notification.severity === 'low') && "bg-muted"
notification.severity === 'critical' && SEVERITY_ICON_BG.critical,
notification.severity === 'high' && SEVERITY_ICON_BG.high,
notification.severity === 'medium' && SEVERITY_ICON_BG.medium,
(!notification.severity || notification.severity === 'low') && SEVERITY_ICON_BG.info
)}>
{getNotificationIcon(notification.type, notification.severity)}
</div>
@@ -284,12 +288,12 @@ export function NotificationDrawer() {
</div>
</div>
</div>
)
), [categoryTitleMap])
// Render notification list (with time grouping)
const renderNotificationList = () => {
// Render notification list (with time grouping, memoized to avoid recreation)
const renderNotificationList = React.useCallback(() => {
const hasAny = filteredNotifications.length > 0
if (!hasAny) {
return (
<div className="flex flex-col items-center justify-center h-40 text-muted-foreground">
@@ -304,7 +308,7 @@ export function NotificationDrawer() {
{(['today', 'yesterday', 'earlier'] as const).map(group => {
const items = groupedNotifications[group]
if (items.length === 0) return null
return (
<div key={group}>
<h3 className="sticky top-0 z-10 text-xs font-medium text-muted-foreground mb-2 px-1 py-1 backdrop-blur bg-background/90">
@@ -318,7 +322,7 @@ export function NotificationDrawer() {
})}
</div>
)
}
}, [filteredNotifications, groupedNotifications, timeGroupLabels, renderNotificationCard, t])
return (
<Sheet open={open} onOpenChange={setOpen}>

View File

@@ -32,7 +32,7 @@ import {
} from "@/components/ui/form"
import { useCreateOrganization } from "@/hooks/use-organizations"
import { useBatchCreateTargets } from "@/hooks/use-targets"
import { batchCreateTargets } from "@/services/target.service"
import type { Organization } from "@/types/organization.types"
@@ -68,7 +68,7 @@ export function AddOrganizationDialog({
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const createOrganization = useCreateOrganization()
const batchCreateTargets = useBatchCreateTargets()
const [isCreatingTargets, setIsCreatingTargets] = useState(false)
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
@@ -105,7 +105,7 @@ export function AddOrganizationDialog({
}
}
const onSubmit = (values: FormValues) => {
const onSubmit = async (values: FormValues) => {
if (targetValidation.invalid.length > 0) return
createOrganization.mutate(
@@ -114,7 +114,7 @@ export function AddOrganizationDialog({
description: values.description?.trim() || "",
},
{
onSuccess: (newOrganization) => {
onSuccess: async (newOrganization) => {
if (values.targets && values.targets.trim()) {
const targetList = values.targets
.split("\n")
@@ -123,40 +123,32 @@ export function AddOrganizationDialog({
.map(name => ({ name }))
if (targetList.length > 0) {
batchCreateTargets.mutate(
{ targets: targetList, organizationId: newOrganization.id },
{
onSuccess: () => {
form.reset()
setOpen(false)
if (onAdd) onAdd(newOrganization)
}
}
)
} else {
form.reset()
setOpen(false)
if (onAdd) onAdd(newOrganization)
setIsCreatingTargets(true)
try {
// Call service directly to avoid double toast
await batchCreateTargets({ targets: targetList, organizationId: newOrganization.id })
} finally {
setIsCreatingTargets(false)
}
}
} else {
form.reset()
setOpen(false)
if (onAdd) onAdd(newOrganization)
}
form.reset()
setOpen(false)
if (onAdd) onAdd(newOrganization)
}
}
)
}
const handleOpenChange = (newOpen: boolean) => {
if (!createOrganization.isPending && !batchCreateTargets.isPending) {
if (!createOrganization.isPending && !isCreatingTargets) {
setOpen(newOpen)
if (!newOpen) form.reset()
}
}
const isFormValid = form.formState.isValid && targetValidation.invalid.length === 0
const isSubmitting = createOrganization.isPending || batchCreateTargets.isPending
const isSubmitting = createOrganization.isPending || isCreatingTargets
return (
<Dialog open={open} onOpenChange={handleOpenChange}>

View File

@@ -41,7 +41,7 @@ export interface OrganizationTranslations {
selectRow: string
}
tooltips: {
targetSummary: string
organizationDetails: string
initiateScan: string
}
}
@@ -240,7 +240,7 @@ export const createOrganizationColumns = ({
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">{t.tooltips.targetSummary}</p>
<p className="text-xs">{t.tooltips.organizationDetails}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

@@ -82,12 +82,20 @@ export function OrganizationDetailView({
const [searchQuery, setSearchQuery] = useState("")
const [isSearching, setIsSearching] = useState(false)
// Type filter state
const [typeFilter, setTypeFilter] = useState<string>("")
const handleSearchChange = (value: string) => {
setIsSearching(true)
setSearchQuery(value)
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
}
const handleTypeFilterChange = (value: string) => {
setTypeFilter(value)
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
}
// Use unlink targets mutation
const unlinkTargets = useUnlinkTargetsFromOrganization()
@@ -111,6 +119,7 @@ export function OrganizationDetailView({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: searchQuery || undefined,
type: typeFilter || undefined,
}
)
@@ -306,7 +315,6 @@ export function OrganizationDetailView({
searchValue={searchQuery}
onSearch={handleSearchChange}
isSearching={isSearching}
addButtonText={tCommon("actions.add")}
pagination={pagination}
setPagination={setPagination}
paginationInfo={targetsData ? {
@@ -316,6 +324,8 @@ export function OrganizationDetailView({
totalPages: targetsData.totalPages,
} : undefined}
onPaginationChange={handlePaginationChange}
typeFilter={typeFilter}
onTypeFilterChange={handleTypeFilterChange}
/>
</div>

View File

@@ -58,6 +58,7 @@ export function OrganizationList() {
const tCommon = useTranslations("common")
const tTooltips = useTranslations("tooltips")
const tConfirm = useTranslations("common.confirm")
const tOrg = useTranslations("organization")
const locale = useLocale()
// 构建翻译对象
@@ -77,7 +78,7 @@ export function OrganizationList() {
selectRow: tCommon("actions.selectRow"),
},
tooltips: {
targetSummary: tTooltips("targetSummary"),
organizationDetails: tTooltips("organizationDetails"),
initiateScan: tTooltips("initiateScan"),
},
}), [tColumns, tCommon, tTooltips])
@@ -120,7 +121,7 @@ export function OrganizationList() {
} = useOrganizations({
page: pagination.pageIndex + 1, // 转换为 1-based
pageSize: pagination.pageSize,
search: searchQuery || undefined,
filter: searchQuery || undefined,
}, { enabled: true })
useEffect(() => {
@@ -272,7 +273,7 @@ export function OrganizationList() {
onAddNew={() => setAddDialogOpen(true)}
onBulkDelete={handleBulkDelete}
onSelectionChange={setSelectedOrganizations}
searchPlaceholder={tColumns("organization.organization")}
searchPlaceholder={tOrg("name")}
searchColumn="name"
searchValue={searchQuery}
onSearch={handleSearchChange}

View File

@@ -1,11 +1,19 @@
"use client"
import * as React from "react"
import { IconSearch, IconLoader2, IconPlus } from "@tabler/icons-react"
import { IconSearch, IconLoader2 } from "@tabler/icons-react"
import { Filter } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { UnifiedDataTable } from "@/components/ui/data-table"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { ColumnDef } from "@tanstack/react-table"
import type { Target } from "@/types/target.types"
import type { PaginationInfo } from "@/types/common.types"
@@ -26,6 +34,8 @@ interface TargetsDataTableProps {
setPagination?: React.Dispatch<React.SetStateAction<{ pageIndex: number; pageSize: number }>>
paginationInfo?: PaginationInfo
onPaginationChange?: (pagination: { pageIndex: number; pageSize: number }) => void
typeFilter?: string
onTypeFilterChange?: (value: string) => void
}
/**
@@ -48,9 +58,13 @@ export function TargetsDataTable({
setPagination: setExternalPagination,
paginationInfo,
onPaginationChange,
typeFilter,
onTypeFilterChange,
}: TargetsDataTableProps) {
const t = useTranslations("common.status")
const tTarget = useTranslations("target")
const tTooltips = useTranslations("tooltips")
const tCommon = useTranslations("common")
// 本地搜索输入状态
const [localSearchValue, setLocalSearchValue] = React.useState(searchValue || "")
@@ -71,14 +85,6 @@ export function TargetsDataTable({
}
}
// 自定义添加按钮(支持 onAddHover
const addButton = onAddNew ? (
<Button onClick={onAddNew} onMouseEnter={onAddHover} size="sm">
<IconPlus className="h-4 w-4" />
{addButtonText || tTarget("createTarget")}
</Button>
) : undefined
return (
<UnifiedDataTable
data={data}
@@ -92,8 +98,14 @@ export function TargetsDataTable({
// 选择
onSelectionChange={onSelectionChange}
// 批量操作
showBulkDelete={false}
showAddButton={false}
showBulkDelete={!!onBulkDelete}
onBulkDelete={onBulkDelete}
bulkDeleteLabel={tTooltips("unlinkTarget")}
// 添加按钮(在解除关联按钮之后)
showAddButton={!!onAddNew}
onAddNew={onAddNew}
onAddHover={onAddHover}
addButtonLabel={addButtonText || tTarget("addTarget")}
// 空状态
emptyMessage={t("noData")}
// 自定义工具栏
@@ -113,9 +125,22 @@ export function TargetsDataTable({
<IconSearch className="h-4 w-4" />
)}
</Button>
{onTypeFilterChange && (
<Select value={typeFilter || "all"} onValueChange={(value) => onTypeFilterChange(value === "all" ? "" : value)}>
<SelectTrigger size="sm" className="w-auto">
<Filter className="h-4 w-4" />
<SelectValue placeholder={tCommon("actions.filter")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{tCommon("actions.all")}</SelectItem>
<SelectItem value="domain">{tTarget("types.domain")}</SelectItem>
<SelectItem value="ip">{tTarget("types.ip")}</SelectItem>
<SelectItem value="cidr">{tTarget("types.cidr")}</SelectItem>
</SelectContent>
</Select>
)}
</div>
}
toolbarRight={addButton}
/>
)
}

View File

@@ -248,7 +248,7 @@ export function OrganizationTargetsDetailView({
onBulkDelete={handleBulkDelete}
onSelectionChange={setSelectedTargets}
searchPlaceholder={tColumns("target.target")}
addButtonText={tCommon("actions.add")}
addButtonText={tTarget("addTarget")}
pagination={pagination}
setPagination={setPagination}
paginationInfo={targetsData ? {

View File

@@ -54,7 +54,7 @@ export function EnginePresetSelector({
engines.forEach(e => {
const caps = parseEngineCapabilities(e.configuration || "")
const hasRecon = caps.includes("subdomain_discovery") || caps.includes("port_scan") || caps.includes("site_scan") || caps.includes("directory_scan") || caps.includes("url_fetch")
const hasRecon = caps.includes("subdomain_discovery") || caps.includes("port_scan") || caps.includes("site_scan") || caps.includes("fingerprint_detect") || caps.includes("directory_scan") || caps.includes("url_fetch") || caps.includes("screenshot")
const hasVuln = caps.includes("vuln_scan")
if (hasRecon && hasVuln) {

View File

@@ -58,14 +58,6 @@ subdomain_discovery:
enabled: true
timeout: 600 # 10 minutes (required)
amass_passive:
enabled: true
timeout: 600 # 10 minutes (required)
amass_active:
enabled: true
timeout: 1800 # 30 minutes (required)
sublist3r:
enabled: true
timeout: 900 # 15 minutes (required)
@@ -213,16 +205,11 @@ url_fetch:
await new Promise(resolve => setTimeout(resolve, 1000))
}
toast.success(tToast("configSaveSuccess"), {
description: tToast("configSaveSuccessDesc", { name: engine.name }),
})
setHasChanges(false)
onOpenChange(false)
} catch (error) {
console.error("Failed to save YAML config:", error)
toast.error(tToast("configSaveFailed"), {
description: error instanceof Error ? error.message : tToast("unknownError"),
})
// Error toast is handled by useUpdateEngine hook
} finally {
setIsSubmitting(false)
}

View File

@@ -65,7 +65,7 @@ export interface ScanHistoryTranslations {
cancelled: string
completed: string
failed: string
initiated: string
pending: string
running: string
}
summary: {
@@ -109,7 +109,7 @@ function StatusBadge({
variant: "outline",
className: "bg-[#da3633]/10 text-[#da3633] border-[#da3633]/20 hover:bg-[#da3633]/20 dark:text-[#f85149] transition-colors",
},
initiated: {
pending: {
icon: IconClock,
variant: "outline",
className: "bg-[#d29922]/10 text-[#d29922] border-[#d29922]/20 hover:bg-[#d29922]/20 transition-colors",
@@ -126,7 +126,7 @@ function StatusBadge({
const badge = (
<Badge variant={variant} className={className}>
{(status === "running" || status === "initiated") ? (
{(status === "running" || status === "pending") ? (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-current" />
@@ -204,7 +204,8 @@ export const createScanHistoryColumns = ({
enableHiding: false,
},
{
accessorKey: "targetName",
accessorKey: "target",
accessorFn: (row) => row.target?.name,
size: 350,
minSize: 100,
meta: { title: t.columns.target },
@@ -212,8 +213,8 @@ export const createScanHistoryColumns = ({
<DataTableColumnHeader column={column} title={t.columns.target} />
),
cell: ({ row }) => {
const targetName = row.getValue("targetName") as string
const targetId = row.original.target
const targetName = row.original.target?.name
const targetId = row.original.targetId
return (
<div className="flex-1 min-w-0">
@@ -239,7 +240,8 @@ export const createScanHistoryColumns = ({
},
},
{
accessorKey: "summary",
accessorKey: "cachedStats",
accessorFn: (row) => row.cachedStats,
meta: { title: t.columns.summary },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t.columns.summary} />
@@ -247,25 +249,11 @@ export const createScanHistoryColumns = ({
size: 290,
minSize: 150,
cell: ({ row }) => {
const summary = (row.getValue("summary") as {
subdomains: number
websites: number
endpoints: number
ips: number
vulnerabilities: {
total: number
critical: number
high: number
medium: number
low: number
}
}) || {}
const subdomains = summary?.subdomains ?? 0
const websites = summary?.websites ?? 0
const endpoints = summary?.endpoints ?? 0
const ips = summary?.ips ?? 0
const vulns = summary?.vulnerabilities?.total ?? 0
const subdomains = row.original.cachedStats?.subdomainsCount ?? 0
const websites = row.original.cachedStats?.websitesCount ?? 0
const endpoints = row.original.cachedStats?.endpointsCount ?? 0
const ips = row.original.cachedStats?.ipsCount ?? 0
const vulns = row.original.cachedStats?.vulnsTotal ?? 0
const badges: React.ReactNode[] = []
@@ -368,7 +356,7 @@ export const createScanHistoryColumns = ({
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs font-medium">
{summary?.vulnerabilities?.critical ?? 0} Critical, {summary?.vulnerabilities?.high ?? 0} High, {summary?.vulnerabilities?.medium ?? 0} Medium {t.summary.vulnerabilities}
{row.original.cachedStats?.vulnsCritical ?? 0} Critical, {row.original.cachedStats?.vulnsHigh ?? 0} High, {row.original.cachedStats?.vulnsMedium ?? 0} Medium {t.summary.vulnerabilities}
</p>
</TooltipContent>
</Tooltip>
@@ -502,7 +490,7 @@ export const createScanHistoryColumns = ({
status === "failed" ? "bg-[#da3633]" :
status === "running" ? "bg-[#d29922] progress-striped" :
status === "cancelled" ? "bg-[#848d97]" :
status === "initiated" ? "bg-[#d29922] progress-striped" :
status === "pending" ? "bg-[#d29922] progress-striped" :
"bg-muted-foreground/80"
}`}
style={{ width: `${displayProgress}%` }}
@@ -524,7 +512,7 @@ export const createScanHistoryColumns = ({
enableResizing: false,
cell: ({ row }) => {
const scan = row.original
const canStop = scan.status === 'running' || scan.status === 'initiated'
const canStop = scan.status === 'running' || scan.status === 'pending'
return (
<div className="flex items-center gap-1">
@@ -578,9 +566,9 @@ export const createScanHistoryColumns = ({
},
]
// Filter out targetName column if hideTargetColumn is true
// Filter out target column if hideTargetColumn is true
if (hideTargetColumn) {
return columns.filter(col => (col as any).accessorKey !== 'targetName')
return columns.filter(col => (col as any).accessorKey !== 'target')
}
return columns

View File

@@ -4,11 +4,19 @@ import * as React from "react"
import type { ColumnDef } from "@tanstack/react-table"
import { useTranslations } from "next-intl"
import { IconSearch, IconLoader2 } from "@tabler/icons-react"
import { Filter } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { UnifiedDataTable } from "@/components/ui/data-table"
import type { ScanRecord } from "@/types/scan.types"
import type { ScanRecord, ScanStatus } from "@/types/scan.types"
import type { PaginationInfo } from "@/types/common.types"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
interface ScanHistoryDataTableProps {
data: ScanRecord[]
@@ -28,6 +36,8 @@ interface ScanHistoryDataTableProps {
hideToolbar?: boolean
hidePagination?: boolean
pageSizeOptions?: number[]
statusFilter?: ScanStatus | "all"
onStatusFilterChange?: (status: ScanStatus | "all") => void
}
/**
@@ -52,6 +62,8 @@ export function ScanHistoryDataTable({
hideToolbar = false,
hidePagination = false,
pageSizeOptions,
statusFilter = "all",
onStatusFilterChange,
}: ScanHistoryDataTableProps) {
const t = useTranslations("common.status")
const tScan = useTranslations("scan.history")
@@ -76,6 +88,16 @@ export function ScanHistoryDataTable({
}
}
// Status options
const statusOptions: { value: ScanStatus | "all"; label: string }[] = [
{ value: "all", label: tScan("allStatus") },
{ value: "running", label: t("running") },
{ value: "completed", label: t("completed") },
{ value: "failed", label: t("failed") },
{ value: "pending", label: t("pending") },
{ value: "cancelled", label: t("cancelled") },
]
return (
<UnifiedDataTable
data={data}
@@ -101,9 +123,9 @@ export function ScanHistoryDataTable({
emptyMessage={t("noData")}
// Auto column sizing
enableAutoColumnSizing
// Custom search box
// Custom search box and status filter
toolbarLeft={
<div className="flex items-center space-x-2">
<div className="flex items-center gap-2">
<Input
placeholder={searchPlaceholder || tScan("searchPlaceholder")}
value={localSearchValue}
@@ -118,6 +140,24 @@ export function ScanHistoryDataTable({
<IconSearch className="h-4 w-4" />
)}
</Button>
{onStatusFilterChange && (
<Select
value={statusFilter}
onValueChange={(value) => onStatusFilterChange(value as ScanStatus | "all")}
>
<SelectTrigger size="sm" className="w-auto">
<Filter className="h-4 w-4" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
}
/>

View File

@@ -6,7 +6,7 @@ import { useTranslations, useLocale } from "next-intl"
import { ScanHistoryDataTable } from "./scan-history-data-table"
import { createScanHistoryColumns } from "./scan-history-columns"
import { getDateLocale } from "@/lib/date-utils"
import type { ScanRecord } from "@/types/scan.types"
import type { ScanRecord, ScanStatus } from "@/types/scan.types"
import type { ColumnDef } from "@tanstack/react-table"
import { DataTableSkeleton } from "@/components/ui/data-table-skeleton"
import {
@@ -83,7 +83,7 @@ export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: custo
cancelled: tCommon("status.cancelled"),
completed: tCommon("status.completed"),
failed: tCommon("status.failed"),
initiated: tCommon("status.pending"),
pending: tCommon("status.pending"),
running: tCommon("status.running"),
},
summary: {
@@ -108,6 +108,9 @@ export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: custo
// Search state
const [searchQuery, setSearchQuery] = useState("")
const [isSearching, setIsSearching] = useState(false)
// Status filter state
const [statusFilter, setStatusFilter] = useState<ScanStatus | "all">("all")
const handleSearchChange = (value: string) => {
setIsSearching(true)
@@ -115,12 +118,18 @@ export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: custo
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
}
const handleStatusFilterChange = (status: ScanStatus | "all") => {
setStatusFilter(status)
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
}
// Get scan list data
const { data, isLoading, isFetching, error } = useScans({
page: pagination.pageIndex + 1, // API page numbers start from 1
pageSize: pagination.pageSize,
search: searchQuery || undefined,
target: targetId,
status: statusFilter === "all" ? undefined : statusFilter,
})
// Reset search state when request completes
@@ -195,7 +204,7 @@ export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: custo
try {
await deleteMutation.mutateAsync(scanToDelete.id)
toast.success(tToast("deletedScanRecord", { name: scanToDelete.targetName }))
toast.success(tToast("deletedScanRecord", { name: scanToDelete.target?.name ?? "" }))
} catch (error) {
toast.error(tToast("deleteFailed"))
console.error('Delete failed:', error)
@@ -226,7 +235,7 @@ export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: custo
try {
await stopMutation.mutateAsync(scanToStop.id)
toast.success(tToast("stoppedScan", { name: scanToStop.targetName }))
toast.success(tToast("stoppedScan", { name: scanToStop.target?.name ?? "" }))
} catch (error) {
toast.error(tToast("stopFailed"))
console.error('Stop scan failed:', error)
@@ -339,6 +348,8 @@ export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: custo
hideToolbar={hideToolbar}
pageSizeOptions={pageSizeOptions}
hidePagination={hidePagination}
statusFilter={statusFilter}
onStatusFilterChange={handleStatusFilterChange}
/>
{/* Delete confirmation dialog */}
@@ -347,7 +358,7 @@ export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: custo
<AlertDialogHeader>
<AlertDialogTitle>{tConfirm("deleteTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{tConfirm("deleteScanMessage", { name: scanToDelete?.targetName ?? "" })}
{tConfirm("deleteScanMessage", { name: scanToDelete?.target?.name ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -376,7 +387,7 @@ export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: custo
<ul className="text-sm space-y-1">
{selectedScans.map((scan) => (
<li key={scan.id} className="flex items-center justify-between">
<span className="font-medium">{scan.targetName}</span>
<span className="font-medium">{scan.target?.name}</span>
<span className="text-muted-foreground text-xs">{scan.engineNames?.join(", ") || "-"}</span>
</li>
))}
@@ -400,7 +411,7 @@ export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: custo
<AlertDialogHeader>
<AlertDialogTitle>{tConfirm("stopScanTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{tConfirm("stopScanMessage", { name: scanToStop?.targetName ?? "" })}
{tConfirm("stopScanMessage", { name: scanToStop?.target?.name ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View File

@@ -1,7 +1,8 @@
"use client"
import React, { useState } from "react"
import React from "react"
import Link from "next/link"
import dynamic from "next/dynamic"
import { useTranslations, useLocale } from "next-intl"
import {
Globe,
@@ -35,11 +36,15 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useScan } from "@/hooks/use-scans"
import { useScanLogs } from "@/hooks/use-scan-logs"
import { ScanLogList } from "@/components/scan/scan-log-list"
import { YamlEditor } from "@/components/ui/yaml-editor"
import { getDateLocale } from "@/lib/date-utils"
import { cn } from "@/lib/utils"
import type { StageStatus } from "@/types/scan.types"
// Dynamic import for YamlEditor (only loaded when config tab is active)
const YamlEditor = dynamic(() => import('@/components/ui/yaml-editor').then(m => ({ default: m.YamlEditor })), {
loading: () => <div className="flex items-center justify-center h-full text-muted-foreground text-sm">...</div>,
ssr: false
})
interface ScanOverviewProps {
scanId: number
}
@@ -93,6 +98,68 @@ const STAGE_STATUS_PRIORITY: Record<StageStatus, number> = {
cancelled: 4,
}
// Status style configuration (consistent with scan-history-columns)
const SCAN_STATUS_STYLES: Record<string, string> = {
running: "bg-[#d29922]/10 text-[#d29922] border-[#d29922]/20",
cancelled: "bg-[#848d97]/10 text-[#848d97] border-[#848d97]/20",
completed: "bg-[#238636]/10 text-[#238636] border-[#238636]/20 dark:text-[#3fb950]",
failed: "bg-[#da3633]/10 text-[#da3633] border-[#da3633]/20 dark:text-[#f85149]",
pending: "bg-[#d29922]/10 text-[#d29922] border-[#d29922]/20",
}
/**
* Format date helper function
*/
function formatDate(dateString: string | undefined, locale: string): string {
if (!dateString) return "-"
const localeStr = locale === 'zh' ? 'zh-CN' : 'en-US'
return new Date(dateString).toLocaleString(localeStr, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
/**
* Calculate duration between two dates
*/
function formatDuration(startedAt: string | undefined, completedAt: string | undefined): string {
if (!startedAt) return "-"
const start = new Date(startedAt)
const end = completedAt ? new Date(completedAt) : new Date()
const diffMs = end.getTime() - start.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMins / 60)
const remainingMins = diffMins % 60
if (diffHours > 0) {
return `${diffHours}h ${remainingMins}m`
}
return `${diffMins}m`
}
/**
* Get status icon configuration
*/
function getStatusIcon(status: string) {
switch (status) {
case "completed":
return { icon: CheckCircle2, animate: false }
case "running":
return { icon: Loader2, animate: true }
case "failed":
return { icon: XCircle, animate: false }
case "cancelled":
return { icon: XCircle, animate: false }
case "pending":
return { icon: Loader2, animate: true }
default:
return { icon: Clock, animate: false }
}
}
export function ScanOverview({ scanId }: ScanOverviewProps) {
const t = useTranslations("scan.history.overview")
const tStatus = useTranslations("scan.history.status")
@@ -100,16 +167,19 @@ export function ScanOverview({ scanId }: ScanOverviewProps) {
const locale = useLocale()
const { data: scan, isLoading, error } = useScan(scanId)
// Check if scan is running (for log polling)
const isRunning = scan?.status === 'running' || scan?.status === 'initiated'
// Memoize isRunning to avoid unnecessary recalculations
const isRunning = React.useMemo(
() => scan?.status === 'running' || scan?.status === 'pending',
[scan?.status]
)
// Auto-refresh state (default: on when running)
const [autoRefresh, setAutoRefresh] = useState(true)
const [autoRefresh, setAutoRefresh] = React.useState(true)
// Tab state for logs/config
const [activeTab, setActiveTab] = useState<'logs' | 'config'>('logs')
const [activeTab, setActiveTab] = React.useState<'logs' | 'config'>('logs')
// Logs hook
const { logs, loading: logsLoading } = useScanLogs({
scanId,
@@ -117,63 +187,6 @@ export function ScanOverview({ scanId }: ScanOverviewProps) {
pollingInterval: isRunning && autoRefresh ? 3000 : 0,
})
// Format date helper
const formatDate = (dateString: string | undefined): string => {
if (!dateString) return "-"
return new Date(dateString).toLocaleString(getDateLocale(locale), {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
// Calculate duration
const formatDuration = (startedAt: string | undefined, completedAt: string | undefined): string => {
if (!startedAt) return "-"
const start = new Date(startedAt)
const end = completedAt ? new Date(completedAt) : new Date()
const diffMs = end.getTime() - start.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMins / 60)
const remainingMins = diffMins % 60
if (diffHours > 0) {
return `${diffHours}h ${remainingMins}m`
}
return `${diffMins}m`
}
// Status style configuration (consistent with scan-history-columns)
const SCAN_STATUS_STYLES: Record<string, string> = {
running: "bg-[#d29922]/10 text-[#d29922] border-[#d29922]/20",
cancelled: "bg-[#848d97]/10 text-[#848d97] border-[#848d97]/20",
completed: "bg-[#238636]/10 text-[#238636] border-[#238636]/20 dark:text-[#3fb950]",
failed: "bg-[#da3633]/10 text-[#da3633] border-[#da3633]/20 dark:text-[#f85149]",
initiated: "bg-[#d29922]/10 text-[#d29922] border-[#d29922]/20",
pending: "bg-[#d29922]/10 text-[#d29922] border-[#d29922]/20",
}
// Get status icon
const getStatusIcon = (status: string) => {
switch (status) {
case "completed":
return { icon: CheckCircle2, animate: false }
case "running":
return { icon: Loader2, animate: true }
case "failed":
return { icon: XCircle, animate: false }
case "cancelled":
return { icon: XCircle, animate: false }
case "pending":
case "initiated":
return { icon: Loader2, animate: true }
default:
return { icon: Clock, animate: false }
}
}
if (isLoading) {
return (
<div className="space-y-6">
@@ -204,50 +217,78 @@ export function ScanOverview({ scanId }: ScanOverviewProps) {
)
}
// Use type assertion for extended properties
const scanAny = scan as any
const summary = scanAny.summary || {}
const vulnSummary = summary.vulnerabilities || { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
const statusIconConfig = getStatusIcon(scan.status)
// Memoize derived values to avoid unnecessary recalculations
const summary = React.useMemo(() => {
const scanAny = scan as any
const stats = scan.cachedStats || scanAny.summary || {}
return {
subdomains: stats.subdomainsCount ?? stats.subdomains ?? 0,
websites: stats.websitesCount ?? stats.websites ?? 0,
endpoints: stats.endpointsCount ?? stats.endpoints ?? 0,
ips: stats.ipsCount ?? stats.ips ?? 0,
directories: stats.directoriesCount ?? stats.directories ?? 0,
screenshots: stats.screenshotsCount ?? stats.screenshots ?? 0,
}
}, [scan])
const vulnSummary = React.useMemo(() => {
const scanAny = scan as any
const stats = scan.cachedStats || scanAny.summary || {}
return stats.vulnerabilities || {
total: stats.vulnsTotal ?? 0,
critical: stats.vulnsCritical ?? 0,
high: stats.vulnsHigh ?? 0,
medium: stats.vulnsMedium ?? 0,
low: stats.vulnsLow ?? 0,
}
}, [scan])
const statusIconConfig = React.useMemo(() => getStatusIcon(scan.status), [scan.status])
const StatusIcon = statusIconConfig.icon
const statusStyle = SCAN_STATUS_STYLES[scan.status] || "bg-muted text-muted-foreground"
const targetId = scanAny.target // Target ID
const targetName = scan.targetName // Target name
const startedAt = scanAny.startedAt || scan.createdAt
const completedAt = scanAny.completedAt
const targetId = scan.targetId
const targetName = scan.target?.name
const startedAt = React.useMemo(() => {
const scanAny = scan as any
return scanAny.startedAt || scan.createdAt
}, [scan])
const completedAt = React.useMemo(() => (scan as any).completedAt, [scan])
const assetCards = [
{
title: t("cards.websites"),
value: summary.websites || 0,
icon: Globe,
href: `/scan/history/${scanId}/websites/`,
},
{
title: t("cards.subdomains"),
value: summary.subdomains || 0,
icon: Network,
href: `/scan/history/${scanId}/subdomain/`,
},
{
title: t("cards.ips"),
value: summary.ips || 0,
icon: Server,
href: `/scan/history/${scanId}/ip-addresses/`,
},
{
title: t("cards.urls"),
value: summary.endpoints || 0,
icon: Link2,
href: `/scan/history/${scanId}/endpoints/`,
},
{
title: t("cards.directories"),
value: summary.directories || 0,
icon: FolderOpen,
href: `/scan/history/${scanId}/directories/`,
},
]
const assetCards = React.useMemo(
() => [
{
title: t("cards.websites"),
value: summary.websites || 0,
icon: Globe,
href: `/scan/history/${scanId}/websites/`,
},
{
title: t("cards.subdomains"),
value: summary.subdomains || 0,
icon: Network,
href: `/scan/history/${scanId}/subdomain/`,
},
{
title: t("cards.ips"),
value: summary.ips || 0,
icon: Server,
href: `/scan/history/${scanId}/ip-addresses/`,
},
{
title: t("cards.urls"),
value: summary.endpoints || 0,
icon: Link2,
href: `/scan/history/${scanId}/endpoints/`,
},
{
title: t("cards.directories"),
value: summary.directories || 0,
icon: FolderOpen,
href: `/scan/history/${scanId}/directories/`,
},
],
[summary, scanId, t]
)
return (
<div className="flex flex-col gap-6 flex-1 min-h-0">
@@ -267,7 +308,7 @@ export function ScanOverview({ scanId }: ScanOverviewProps) {
{/* Started at */}
<div className="flex items-center gap-1.5">
<Calendar className="h-4 w-4" />
<span>{t("startedAt")}: {formatDate(startedAt)}</span>
<span>{t("startedAt")}: {formatDate(startedAt, locale)}</span>
</div>
{/* Duration */}
<div className="flex items-center gap-1.5">
@@ -335,7 +376,7 @@ export function ScanOverview({ scanId }: ScanOverviewProps) {
{scan.stageProgress && Object.keys(scan.stageProgress).length > 0 ? (
<div className="space-y-1 flex-1 min-h-0 overflow-y-auto pr-1">
{Object.entries(scan.stageProgress)
.sort(([, a], [, b]) => {
.toSorted(([, a], [, b]) => {
const progressA = a as any
const progressB = b as any
const priorityA = STAGE_STATUS_PRIORITY[progressA.status as StageStatus] ?? 99

View File

@@ -40,7 +40,11 @@ interface StageDetail {
*/
export interface ScanProgressData {
id: number
targetName: string
target?: {
id: number
name: string
type: string
}
engineNames: string[]
status: string
progress: number
@@ -90,7 +94,7 @@ function ScanStatusIcon({ status }: { status: string }) {
return <IconCircleX className="h-5 w-5 text-[#848d97]" />
case "failed":
return <IconCircleX className="h-5 w-5 text-[#da3633] dark:text-[#f85149]" />
case "initiated":
case "pending":
return <PulsingDot className="text-[#d29922]" />
default:
return <PulsingDot className="text-muted-foreground" />
@@ -184,6 +188,9 @@ function StageRow({ stage, t }: { stage: StageDetail; t: (key: string) => string
)
}
/** Dialog width constant */
const DIALOG_WIDTH = 'sm:max-w-[600px] sm:min-w-[550px]'
/**
* Scan progress dialog
*/
@@ -195,9 +202,12 @@ export function ScanProgressDialog({
const t = useTranslations("scan.progress")
const locale = useLocale()
const [activeTab, setActiveTab] = useState<'stages' | 'logs'>('stages')
// 判断扫描是否正在运行(用于控制轮询)
const isRunning = data?.status === 'running' || data?.status === 'initiated'
// Memoize isRunning to avoid unnecessary recalculations
const isRunning = React.useMemo(
() => data?.status === 'running' || data?.status === 'initiated',
[data?.status]
)
// 日志轮询 Hook
const { logs, loading: logsLoading } = useScanLogs({
@@ -208,12 +218,9 @@ export function ScanProgressDialog({
if (!data) return null
// 固定宽度,切换 Tab 时不变化
const dialogWidth = 'sm:max-w-[600px] sm:min-w-[550px]'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn(dialogWidth, "transition-all duration-200")}>
<DialogContent className={cn(DIALOG_WIDTH, "transition-all duration-200")}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ScanStatusIcon status={data.status} />
@@ -225,7 +232,7 @@ export function ScanProgressDialog({
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t("target")}</span>
<span className="font-medium">{data.targetName}</span>
<span className="font-medium">{data.target?.name}</span>
</div>
<div className="flex items-start justify-between text-sm gap-4">
<span className="text-muted-foreground shrink-0">{t("engine")}</span>
@@ -322,25 +329,25 @@ function formatDateTime(isoString?: string, locale: string = "zh"): string {
}
}
/** Get stage result count from summary */
function getStageResultCount(stageName: string, summary: ScanRecord["summary"]): number | undefined {
if (!summary) return undefined
/** Get stage result count from cachedStats */
function getStageResultCount(stageName: string, stats: ScanRecord["cachedStats"]): number | undefined {
if (!stats) return undefined
switch (stageName) {
case "subdomain_discovery":
case "subdomainDiscovery":
return summary.subdomains
return stats.subdomainsCount
case "site_scan":
case "siteScan":
return summary.websites
return stats.websitesCount
case "directory_scan":
case "directoryScan":
return summary.directories
return stats.directoriesCount
case "url_fetch":
case "urlFetch":
return summary.endpoints
return stats.endpointsCount
case "vuln_scan":
case "vulnScan":
return summary.vulnerabilities?.total
return stats.vulnsTotal
default:
return undefined
}
@@ -367,7 +374,7 @@ export function buildScanProgressData(scan: ScanRecord): ScanProgressData {
if (scan.stageProgress) {
// Sort by status priority first, then by order
const sortedEntries = Object.entries(scan.stageProgress)
.sort(([, a], [, b]) => {
.toSorted(([, a], [, b]) => {
const priorityA = STATUS_PRIORITY[a.status] ?? 99
const priorityB = STATUS_PRIORITY[b.status] ?? 99
if (priorityA !== priorityB) {
@@ -378,7 +385,7 @@ export function buildScanProgressData(scan: ScanRecord): ScanProgressData {
for (const [stageName, progress] of sortedEntries) {
const resultCount = progress.status === "completed"
? getStageResultCount(stageName, scan.summary)
? getStageResultCount(stageName, scan.cachedStats)
: undefined
stages.push({
@@ -393,7 +400,7 @@ export function buildScanProgressData(scan: ScanRecord): ScanProgressData {
return {
id: scan.id,
targetName: scan.targetName,
target: scan.target,
engineNames: scan.engineNames || [],
status: scan.status,
progress: scan.progress,

View File

@@ -120,14 +120,15 @@ export function CreateScheduledScanDialog({
const handleOrgSearch = () => setOrgSearch(orgSearchInput)
const handleTargetSearch = () => setTargetSearch(targetSearchInput)
const { data: organizationsData, isFetching: isOrgFetching } = useOrganizations({
pageSize: 50,
search: orgSearch || undefined
})
const { data: targetsData, isFetching: isTargetFetching } = useTargets({
pageSize: 50,
search: targetSearch || undefined
})
// Only fetch data when dialog is open (avoid unnecessary requests on page load)
const { data: organizationsData, isFetching: isOrgFetching } = useOrganizations({
pageSize: 20,
filter: orgSearch || undefined
}, { enabled: open })
const { data: targetsData, isFetching: isTargetFetching } = useTargets({
pageSize: 20,
filter: targetSearch || undefined
}, { enabled: open })
const hasPreset = !!(presetOrganizationId || presetTargetId)
const steps = hasPreset ? PRESET_STEPS : FULL_STEPS

View File

@@ -212,7 +212,7 @@ export function ScreenshotsGallery({ targetId, scanId }: ScreenshotsGalleryProps
onKeyDown={handleKeyDown}
className="w-64"
/>
<Button variant="outline" size="sm" onClick={handleSearch}>
<Button variant="outline" size="icon" onClick={handleSearch}>
<Search className="h-4 w-4" />
</Button>
</div>

View File

@@ -57,7 +57,7 @@ const QUICK_SEARCH_TAGS = [
]
// 最近搜索本地存储 key
const RECENT_SEARCHES_KEY = 'xingrin_recent_searches'
const RECENT_SEARCHES_KEY = 'star_patrol_recent_searches'
const MAX_RECENT_SEARCHES = 5
// 获取最近搜索记录

View File

@@ -37,13 +37,15 @@ interface SearchResultCardProps {
onViewVulnerability?: (vuln: Vulnerability) => void
}
import { SEVERITY_STYLES } from "@/lib/severity-config"
// 漏洞严重程度颜色配置
const severityColors: Record<string, string> = {
critical: "bg-[#da3633]/10 text-[#da3633] border border-[#da3633]/20 dark:text-[#f85149]",
high: "bg-[#d29922]/10 text-[#d29922] border border-[#d29922]/20",
medium: "bg-[#d4a72c]/10 text-[#d4a72c] border border-[#d4a72c]/20",
low: "bg-[#238636]/10 text-[#238636] border border-[#238636]/20 dark:text-[#3fb950]",
info: "bg-[#848d97]/10 text-[#848d97] border border-[#848d97]/20",
critical: SEVERITY_STYLES.critical.className,
high: SEVERITY_STYLES.high.className,
medium: SEVERITY_STYLES.medium.className,
low: SEVERITY_STYLES.low.className,
info: SEVERITY_STYLES.info.className,
}
// 状态码 Badge variant

View File

@@ -12,7 +12,7 @@ import { useSystemLogs, useLogFiles } from "@/hooks/use-system-logs"
import { LogToolbar, type LogLevel } from "./log-toolbar"
import { AnsiLogViewer } from "./ansi-log-viewer"
const DEFAULT_FILE = "xingrin.log"
const DEFAULT_FILE = "orbit.log"
const DEFAULT_LINES = 500
export function SystemLogsView() {

View File

@@ -103,18 +103,23 @@ export function DeployTerminalDialog({
// Show connection prompt
terminal.writeln(`\x1b[90m${tTerminal("connecting")}\x1b[0m`)
// Listen for window resize
const handleResize = () => fitAddon.fit()
window.addEventListener('resize', handleResize)
// Auto-connect WebSocket
connectWs()
}, [worker])
// Manage window resize listener separately for proper cleanup
useEffect(() => {
const fitAddon = fitAddonRef.current
if (!fitAddon) return
const handleResize = () => fitAddon.fit()
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [worker])
}, [fitAddonRef.current])
// Connect WebSocket
const connectWs = useCallback(() => {

View File

@@ -17,7 +17,7 @@ const SUBDOMAIN_FILTER_FIELDS: FilterField[] = [
// Subdomain page filter examples
const SUBDOMAIN_FILTER_EXAMPLES = [
'name="api.example.com"',
'name="*.test.com"',
'name=".test.com"',
]
// Component props type definition

View File

@@ -347,6 +347,7 @@ export function AddTargetDialog({
{t("linkOrganization")}
</Label>
<Button
type="button"
variant="outline"
role="combobox"
className="w-full justify-between"
@@ -447,6 +448,7 @@ export function AddTargetDialog({
</div>
<div className="flex items-center space-x-2">
<Button
type="button"
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => setOrgPage(1)}
@@ -456,6 +458,7 @@ export function AddTargetDialog({
<IconChevronsLeft />
</Button>
<Button
type="button"
variant="outline"
className="h-8 w-8 p-0"
onClick={() => setOrgPage(prev => Math.max(1, prev - 1))}
@@ -465,6 +468,7 @@ export function AddTargetDialog({
<IconChevronLeft />
</Button>
<Button
type="button"
variant="outline"
className="h-8 w-8 p-0"
onClick={() => setOrgPage(prev => Math.min(organizationsData.pagination.totalPages, prev + 1))}
@@ -474,6 +478,7 @@ export function AddTargetDialog({
<IconChevronRight />
</Button>
<Button
type="button"
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => setOrgPage(organizationsData.pagination.totalPages)}

View File

@@ -35,6 +35,7 @@ export function AllTargetsDetailView() {
const tTooltips = useTranslations("tooltips")
const tCommon = useTranslations("common")
const tConfirm = useTranslations("common.confirm")
const tTarget = useTranslations("target")
// Build translation object
const translations: AllTargetsTranslations = {
@@ -60,6 +61,7 @@ export function AllTargetsDetailView() {
}
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 })
const [searchQuery, setSearchQuery] = useState("")
const [typeFilter, setTypeFilter] = useState("")
const [selectedTargets, setSelectedTargets] = useState<Target[]>([])
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
@@ -84,8 +86,13 @@ export function AllTargetsDetailView() {
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
}
const handleTypeFilterChange = (value: string) => {
setTypeFilter(value)
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
}
// Use API hooks
const { data, isLoading, isFetching, error } = useTargets(pagination.pageIndex + 1, pagination.pageSize, undefined, searchQuery || undefined)
const { data, isLoading, isFetching, error } = useTargets(pagination.pageIndex + 1, pagination.pageSize, typeFilter || undefined, searchQuery || undefined)
const deleteTargetMutation = useDeleteTarget()
const batchDeleteMutation = useBatchDeleteTargets()
@@ -114,7 +121,7 @@ export function AllTargetsDetailView() {
if (!targetToDelete) return
try {
await deleteTargetMutation.mutateAsync(targetToDelete.id)
await deleteTargetMutation.mutateAsync({ id: targetToDelete.id, name: targetToDelete.name })
setDeleteDialogOpen(false)
setTargetToDelete(null)
} catch (error) {
@@ -199,16 +206,19 @@ export function AllTargetsDetailView() {
onAddHover={() => setShouldPrefetchOrgs(true)}
onBulkDelete={handleBatchDelete}
onSelectionChange={setSelectedTargets}
searchPlaceholder={tColumns("target.target")}
searchPlaceholder={tTarget("name")}
searchValue={searchQuery}
onSearch={handleSearchChange}
isSearching={isSearching}
addButtonText={tCommon("actions.add")}
addButtonText={tTarget("addTarget")}
// 分页相关属性
pagination={pagination}
onPaginationChange={handlePaginationChange}
totalCount={totalCount}
manualPagination={true}
// 类型筛选
typeFilter={typeFilter}
onTypeFilterChange={handleTypeFilterChange}
/>
{/* Add target dialog */}

View File

@@ -2,6 +2,7 @@
import React, { useState } from "react"
import Link from "next/link"
import dynamic from "next/dynamic"
import { useTranslations, useLocale } from "next-intl"
import {
Globe,
@@ -25,13 +26,71 @@ import { Button } from "@/components/ui/button"
import { useTarget } from "@/hooks/use-targets"
import { useScheduledScans } from "@/hooks/use-scheduled-scans"
import { ScanHistoryList } from "@/components/scan/history/scan-history-list"
import { InitiateScanDialog } from "@/components/scan/initiate-scan-dialog"
import { getDateLocale } from "@/lib/date-utils"
// Dynamic import for InitiateScanDialog (only loaded when dialog is opened)
const InitiateScanDialog = dynamic(() => import('@/components/scan/initiate-scan-dialog').then(m => ({ default: m.InitiateScanDialog })), {
ssr: false
})
interface TargetOverviewProps {
targetId: number
}
/**
* Format date helper function
*/
function formatDate(dateString: string | undefined, locale: string): string {
if (!dateString) return "-"
return new Date(dateString).toLocaleString(locale === 'zh' ? 'zh-CN' : 'en-US', {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
/**
* Format short date for scheduled scans
*/
function formatShortDate(
dateString: string | undefined,
locale: string,
todayText: string,
tomorrowText: string
): string {
if (!dateString) return "-"
const date = new Date(dateString)
const now = new Date()
const tomorrow = new Date(now)
tomorrow.setDate(tomorrow.getDate() + 1)
const localeStr = locale === 'zh' ? 'zh-CN' : 'en-US'
// Check if it's today
if (date.toDateString() === now.toDateString()) {
return todayText + " " + date.toLocaleTimeString(localeStr, {
hour: "2-digit",
minute: "2-digit",
})
}
// Check if it's tomorrow
if (date.toDateString() === tomorrow.toDateString()) {
return tomorrowText + " " + date.toLocaleTimeString(localeStr, {
hour: "2-digit",
minute: "2-digit",
})
}
// Otherwise show date
return date.toLocaleString(localeStr, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
/**
* Target overview component
* Displays statistics cards for the target
@@ -43,70 +102,26 @@ export function TargetOverview({ targetId }: TargetOverviewProps) {
const [scanDialogOpen, setScanDialogOpen] = useState(false)
const { data: target, isLoading, error } = useTarget(targetId)
const { data: scheduledScansData, isLoading: isLoadingScans } = useScheduledScans({
targetId,
pageSize: 5
const { data: scheduledScansData, isLoading: isLoadingScans } = useScheduledScans({
targetId,
pageSize: 5
})
const scheduledScans = scheduledScansData?.results || []
const totalScheduledScans = scheduledScansData?.total || 0
const enabledScans = scheduledScans.filter(s => s.isEnabled)
// Memoize derived values to avoid unnecessary recalculations
const scheduledScans = React.useMemo(() => scheduledScansData?.results || [], [scheduledScansData?.results])
const totalScheduledScans = React.useMemo(() => scheduledScansData?.total || 0, [scheduledScansData?.total])
const enabledScans = React.useMemo(() => scheduledScans.filter(s => s.isEnabled), [scheduledScans])
// Format date helper
const formatDate = (dateString: string | undefined): string => {
if (!dateString) return "-"
return new Date(dateString).toLocaleString(getDateLocale(locale), {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
// Format short date for scheduled scans
const formatShortDate = (dateString: string | undefined): string => {
if (!dateString) return "-"
const date = new Date(dateString)
const now = new Date()
const tomorrow = new Date(now)
tomorrow.setDate(tomorrow.getDate() + 1)
// Check if it's today
if (date.toDateString() === now.toDateString()) {
return t("scheduledScans.today") + " " + date.toLocaleTimeString(getDateLocale(locale), {
hour: "2-digit",
minute: "2-digit",
})
}
// Check if it's tomorrow
if (date.toDateString() === tomorrow.toDateString()) {
return t("scheduledScans.tomorrow") + " " + date.toLocaleTimeString(getDateLocale(locale), {
hour: "2-digit",
minute: "2-digit",
})
}
// Otherwise show date
return date.toLocaleString(getDateLocale(locale), {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
// Get next execution time from enabled scans
const getNextExecution = () => {
// Get next execution time from enabled scans (memoized)
const nextExecution = React.useMemo(() => {
const enabledWithNextRun = enabledScans.filter(s => s.nextRunTime)
if (enabledWithNextRun.length === 0) return null
const sorted = enabledWithNextRun.sort((a, b) =>
const sorted = enabledWithNextRun.toSorted((a, b) =>
new Date(a.nextRunTime!).getTime() - new Date(b.nextRunTime!).getTime()
)
return sorted[0]
}
const nextExecution = getNextExecution()
}, [enabledScans])
if (isLoading) {
return (
@@ -138,41 +153,49 @@ export function TargetOverview({ targetId }: TargetOverviewProps) {
)
}
const summary = (target as any).summary || {}
const vulnSummary = summary.vulnerabilities || { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
// Memoize summary and vulnerability data to avoid unnecessary recalculations
const summary = React.useMemo(() => (target as any).summary || {}, [target])
const vulnSummary = React.useMemo(
() => summary.vulnerabilities || { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
[summary]
)
const assetCards = [
{
title: t("cards.websites"),
value: summary.websites || 0,
icon: Globe,
href: `/target/${targetId}/websites/`,
},
{
title: t("cards.subdomains"),
value: summary.subdomains || 0,
icon: Network,
href: `/target/${targetId}/subdomain/`,
},
{
title: t("cards.ips"),
value: summary.ips || 0,
icon: Server,
href: `/target/${targetId}/ip-addresses/`,
},
{
title: t("cards.urls"),
value: summary.endpoints || 0,
icon: Link2,
href: `/target/${targetId}/endpoints/`,
},
{
title: t("cards.directories"),
value: summary.directories || 0,
icon: FolderOpen,
href: `/target/${targetId}/directories/`,
},
]
// Memoize asset cards array to avoid recreation on every render
const assetCards = React.useMemo(
() => [
{
title: t("cards.websites"),
value: summary.websites || 0,
icon: Globe,
href: `/target/${targetId}/websites/`,
},
{
title: t("cards.subdomains"),
value: summary.subdomains || 0,
icon: Network,
href: `/target/${targetId}/subdomain/`,
},
{
title: t("cards.ips"),
value: summary.ips || 0,
icon: Server,
href: `/target/${targetId}/ip-addresses/`,
},
{
title: t("cards.urls"),
value: summary.endpoints || 0,
icon: Link2,
href: `/target/${targetId}/endpoints/`,
},
{
title: t("cards.directories"),
value: summary.directories || 0,
icon: FolderOpen,
href: `/target/${targetId}/directories/`,
},
],
[summary, targetId, t]
)
return (
<div className="space-y-6">
@@ -181,11 +204,11 @@ export function TargetOverview({ targetId }: TargetOverviewProps) {
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
<Calendar className="h-4 w-4" />
<span>{t("createdAt")}: {formatDate(target.createdAt)}</span>
<span>{t("createdAt")}: {formatDate(target.createdAt, locale)}</span>
</div>
<div className="flex items-center gap-1.5">
<Clock className="h-4 w-4" />
<span>{t("lastScanned")}: {formatDate(target.lastScannedAt)}</span>
<span>{t("lastScanned")}: {formatDate(target.lastScannedAt, locale)}</span>
</div>
</div>
<Button onClick={() => setScanDialogOpen(true)}>
@@ -264,7 +287,14 @@ export function TargetOverview({ targetId }: TargetOverviewProps) {
{nextExecution && (
<div className="text-sm">
<span className="text-muted-foreground">{t("scheduledScans.nextRun")}: </span>
<span className="font-medium">{formatShortDate(nextExecution.nextRunTime)}</span>
<span className="font-medium">
{formatShortDate(
nextExecution.nextRunTime,
locale,
t("scheduledScans.today"),
t("scheduledScans.tomorrow")
)}
</span>
</div>
)}

View File

@@ -2,10 +2,18 @@
import * as React from "react"
import { IconSearch, IconLoader2 } from "@tabler/icons-react"
import { Filter } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { UnifiedDataTable } from "@/components/ui/data-table"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { ColumnDef } from "@tanstack/react-table"
import type { Target } from "@/types/target.types"
import type { PaginationInfo } from "@/types/common.types"
@@ -27,6 +35,9 @@ interface TargetsDataTableProps {
onPaginationChange?: (pagination: { pageIndex: number, pageSize: number }) => void
totalCount?: number
manualPagination?: boolean
// Type filter
typeFilter?: string
onTypeFilterChange?: (value: string) => void
}
/**
@@ -49,6 +60,8 @@ export function TargetsDataTable({
onPaginationChange,
totalCount,
manualPagination = false,
typeFilter,
onTypeFilterChange,
}: TargetsDataTableProps) {
const t = useTranslations("common.status")
const tActions = useTranslations("common.actions")
@@ -137,6 +150,20 @@ export function TargetsDataTable({
<IconSearch className="h-4 w-4" />
)}
</Button>
{onTypeFilterChange && (
<Select value={typeFilter || "all"} onValueChange={(value) => onTypeFilterChange(value === "all" ? "" : value)}>
<SelectTrigger size="sm" className="w-auto">
<Filter className="h-4 w-4" />
<SelectValue placeholder={tActions("filter")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{tActions("all")}</SelectItem>
<SelectItem value="domain">{tTarget("types.domain")}</SelectItem>
<SelectItem value="ip">{tTarget("types.ip")}</SelectItem>
<SelectItem value="cidr">{tTarget("types.cidr")}</SelectItem>
</SelectContent>
</Select>
)}
</div>
}
/>

View File

@@ -151,6 +151,7 @@ export function UnifiedDataTable<TData>({
enableAutoColumnSizing = false,
}: UnifiedDataTableProps<TData>) {
const tActions = useTranslations("common.actions")
const tDataTable = useTranslations("dataTable")
const locale = useLocale()
// Internal state
@@ -438,7 +439,7 @@ export function UnifiedDataTable<TData>({
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns className="h-4 w-4" />
Columns
{tDataTable("showColumns")}
<IconChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>

View File

@@ -0,0 +1,512 @@
"use client"
import * as React from "react"
import dynamic from "next/dynamic"
import { cn } from "@/lib/utils"
// Dynamic import to avoid SSR issues with GSAP
const Shuffle = dynamic(() => import("@/components/Shuffle"), { ssr: false })
type BootLine = {
text: string
className?: string
}
// Boot log animation timing (similar to LoginBootScreen)
const AUTH_STEP_DELAYS_MS = [120, 160, 200, 240]
const GLITCH_MS = 600
function AuthBootLog({
authenticatingLabel,
processingLabel,
done = false,
className,
}: {
authenticatingLabel: string
processingLabel: string
done?: boolean
className?: string
}) {
const [visible, setVisible] = React.useState(0)
const [glitchOn, setGlitchOn] = React.useState(true)
const authLines = React.useMemo<BootLine[]>(
() => [
{ text: `> ${authenticatingLabel}`, className: "text-yellow-500" },
{ text: "> initializing secure channel...", className: "text-zinc-200" },
{ text: "> validating credentials...", className: "text-zinc-200" },
{ text: "> checking session...", className: "text-yellow-500" },
],
[authenticatingLabel]
)
React.useEffect(() => {
setGlitchOn(true)
const timer = setTimeout(() => setGlitchOn(false), GLITCH_MS)
return () => clearTimeout(timer)
}, [])
React.useEffect(() => {
setVisible(0)
const timers: Array<ReturnType<typeof setTimeout>> = []
let acc = 0
for (let i = 0; i < authLines.length; i++) {
acc += AUTH_STEP_DELAYS_MS[i] ?? 220
timers.push(
setTimeout(() => {
setVisible((prev) => Math.max(prev, i + 1))
}, acc)
)
}
return () => {
timers.forEach(clearTimeout)
}
}, [authLines])
// When the login flow completes, force the log to finish and jump progress to 100%.
React.useEffect(() => {
if (!done) return
setVisible(authLines.length)
}, [authLines.length, done])
const rawProgress = Math.round((Math.min(visible, authLines.length) / authLines.length) * 100)
const progress = done ? 100 : Math.min(rawProgress, 99)
return (
<div className={cn(glitchOn && "orbit-splash-glitch", className)}>
<div className="space-y-1">
{authLines.slice(0, visible).map((line, idx) => (
<div key={idx} className={cn("whitespace-pre-wrap", line.className)}>
{line.text}
</div>
))}
{/* Cursor */}
<div className="text-green-500">
<span className="inline-block h-4 w-2 align-middle bg-green-500 animate-pulse" />
</div>
</div>
{/* Progress bar */}
<div className="mt-6">
<div className="h-1.5 w-full rounded bg-zinc-800 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-[#FF10F0] to-[#B026FF]"
style={{
width: `${progress}%`,
boxShadow: "0 0 10px rgba(255, 16, 240, 0.5), 0 0 20px rgba(176, 38, 255, 0.3)",
}}
/>
</div>
<div className="mt-2 text-xs text-zinc-500">{processingLabel}</div>
</div>
</div>
)
}
type LoginStep = "username" | "password" | "authenticating" | "success" | "error"
interface TerminalLoginTranslations {
title: string
subtitle: string
usernamePrompt: string
passwordPrompt: string
authenticating: string
processing: string
accessGranted: string
welcomeMessage: string
authFailed: string
invalidCredentials: string
shortcuts: string
submit: string
cancel: string
clear: string
startEnd: string
}
interface TerminalLine {
text: string
type: "prompt" | "input" | "info" | "success" | "error" | "warning"
}
interface TerminalLoginProps {
onLogin: (username: string, password: string) => Promise<void>
authDone?: boolean
isPending?: boolean
className?: string
translations: TerminalLoginTranslations
}
export function TerminalLogin({
onLogin,
authDone = false,
isPending = false,
className,
translations: t,
}: TerminalLoginProps) {
const [step, setStep] = React.useState<LoginStep>("username")
const [username, setUsername] = React.useState("")
const [password, setPassword] = React.useState("")
const [lines, setLines] = React.useState<TerminalLine[]>([])
const [cursorPosition, setCursorPosition] = React.useState(0)
const [isFocused, setIsFocused] = React.useState(false)
const inputRef = React.useRef<HTMLInputElement>(null)
const containerRef = React.useRef<HTMLDivElement>(null)
// Focus input on mount and when step changes
React.useEffect(() => {
inputRef.current?.focus()
}, [step])
// Click anywhere to focus input
const handleContainerClick = () => {
inputRef.current?.focus()
}
const addLine = (line: TerminalLine) => {
setLines((prev) => [...prev, line])
}
const getCurrentValue = () => {
if (step === "username") return username
if (step === "password") return password
return ""
}
const setCurrentValue = (value: string) => {
if (step === "username") {
setUsername(value)
setCursorPosition(value.length)
} else if (step === "password") {
setPassword(value)
setCursorPosition(value.length)
}
}
const handleKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
const value = getCurrentValue()
// Ctrl+C - Cancel/Clear current input
if (e.ctrlKey && e.key === "c") {
e.preventDefault()
if (step === "username" || step === "password") {
addLine({ text: `^C`, type: "warning" })
setCurrentValue("")
setCursorPosition(0)
}
return
}
// Ctrl+U - Clear line (delete from cursor to start)
if (e.ctrlKey && e.key === "u") {
e.preventDefault()
setCurrentValue("")
setCursorPosition(0)
return
}
// Ctrl+A - Move cursor to start
if (e.ctrlKey && e.key === "a") {
e.preventDefault()
setCursorPosition(0)
if (inputRef.current) {
inputRef.current.setSelectionRange(0, 0)
}
return
}
// Ctrl+E - Move cursor to end
if (e.ctrlKey && e.key === "e") {
e.preventDefault()
setCursorPosition(value.length)
if (inputRef.current) {
inputRef.current.setSelectionRange(value.length, value.length)
}
return
}
// Ctrl+W - Delete word before cursor
if (e.ctrlKey && e.key === "w") {
e.preventDefault()
const beforeCursor = value.slice(0, cursorPosition)
const afterCursor = value.slice(cursorPosition)
const lastSpace = beforeCursor.trimEnd().lastIndexOf(" ")
const newBefore = lastSpace === -1 ? "" : beforeCursor.slice(0, lastSpace + 1)
setCurrentValue(newBefore + afterCursor)
setCursorPosition(newBefore.length)
return
}
// Tab - Move to next field (username -> password)
if (e.key === "Tab" && step === "username") {
e.preventDefault()
if (!username.trim()) return
addLine({ text: `> ${t.usernamePrompt}: `, type: "prompt" })
addLine({ text: username, type: "input" })
setStep("password")
setCursorPosition(0)
return
}
// Enter - Submit
if (e.key === "Enter") {
if (step === "username") {
if (!username.trim()) return
addLine({ text: `> ${t.usernamePrompt}: `, type: "prompt" })
addLine({ text: username, type: "input" })
setStep("password")
setCursorPosition(0)
} else if (step === "password") {
if (!password.trim()) return
addLine({ text: `> ${t.passwordPrompt}: `, type: "prompt" })
addLine({ text: "*".repeat(password.length), type: "input" })
addLine({ text: "", type: "info" })
setStep("authenticating")
try {
await onLogin(username, password)
addLine({ text: `> ${t.accessGranted}`, type: "success" })
addLine({ text: `> ${t.welcomeMessage}`, type: "success" })
// Keep showing the authenticating progress bar until navigation happens.
} catch {
addLine({ text: `> ${t.authFailed}`, type: "error" })
addLine({ text: `> ${t.invalidCredentials}`, type: "error" })
addLine({ text: "", type: "info" })
setStep("error")
setTimeout(() => {
setUsername("")
setPassword("")
setLines([])
setCursorPosition(0)
setStep("username")
}, 2000)
}
}
return
}
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setCurrentValue(value)
setCursorPosition(e.target.selectionStart || value.length)
}
const handleSelect = (e: React.SyntheticEvent<HTMLInputElement>) => {
const target = e.target as HTMLInputElement
setCursorPosition(target.selectionStart || 0)
}
const isInputDisabled = step === "authenticating" || step === "success" || isPending
const getCurrentPrompt = () => {
if (step === "username") return `> ${t.usernamePrompt}: `
if (step === "password") return `> ${t.passwordPrompt}: `
return "> "
}
const getDisplayValue = () => {
if (step === "username") return username
if (step === "password") return "*".repeat(password.length)
return ""
}
// Render cursor at position
const renderInputWithCursor = () => {
const displayValue = getDisplayValue()
const before = displayValue.slice(0, cursorPosition)
const after = displayValue.slice(cursorPosition)
const cursorChar = after[0] || ""
if (!isFocused) {
return <span className="text-zinc-100">{displayValue}</span>
}
return (
<>
<span className="text-zinc-100">{before}</span>
<span className="animate-blink inline-block min-w-[0.6em] bg-green-500 text-black">
{cursorChar || "\u00A0"}
</span>
<span className="text-zinc-100">{after.slice(1)}</span>
</>
)
}
return (
<div
ref={containerRef}
onClick={handleContainerClick}
className={cn(
"border-zinc-700 bg-zinc-900/80 backdrop-blur-sm z-0 w-full max-w-xl rounded-xl border cursor-text",
className
)}
>
{/* Terminal header */}
<div className="border-zinc-700 flex items-center gap-x-2 border-b px-4 py-3">
<div className="flex flex-row gap-x-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<div className="h-3 w-3 rounded-full bg-yellow-500"></div>
<div className="h-3 w-3 rounded-full bg-green-500"></div>
</div>
<span className="ml-2 text-xs text-zinc-400 font-mono">{t.title}</span>
</div>
{/* Terminal content */}
<div className="p-4 font-mono text-sm min-h-[280px]">
{/* Shuffle Title Banner */}
<div className="mb-6 text-center">
<Shuffle
text="ORBIT"
className="!text-4xl sm:!text-5xl md:!text-6xl !font-bold text-cyan-500"
shuffleDirection="up"
duration={0.5}
stagger={0.04}
shuffleTimes={2}
triggerOnHover={true}
triggerOnce={false}
autoPlay={false}
/>
<div className="mt-3 flex items-center gap-3 text-zinc-400 text-sm">
<span className="h-px flex-1 bg-zinc-700" />
<span className="whitespace-nowrap">{t.subtitle}</span>
<span className="h-px flex-1 bg-zinc-700" />
</div>
</div>
{/* ========== Mobile Form ========== */}
<div className="sm:hidden">
{(step === "username" || step === "password" || step === "error") && (
<form
onSubmit={async (e) => {
e.preventDefault()
if (!username.trim() || !password.trim()) return
setStep("authenticating")
try {
await onLogin(username, password)
// Keep showing the authenticating progress bar until navigation happens.
} catch {
setStep("error")
setTimeout(() => {
setUsername("")
setPassword("")
setStep("username")
}, 2000)
}
}}
className="space-y-4"
>
<div>
<label className="text-green-500 text-xs mb-1 block">{t.usernamePrompt}</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isInputDisabled}
className="w-full bg-zinc-800 border border-zinc-600 rounded px-3 py-2 text-zinc-100 outline-none focus:border-green-500 font-mono text-sm"
autoComplete="username"
/>
</div>
<div>
<label className="text-green-500 text-xs mb-1 block">{t.passwordPrompt}</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isInputDisabled}
className="w-full bg-zinc-800 border border-zinc-600 rounded px-3 py-2 text-zinc-100 outline-none focus:border-green-500 font-mono text-sm"
autoComplete="current-password"
/>
</div>
{step === "error" && (
<p className="text-red-500 text-sm">{t.invalidCredentials}</p>
)}
<button
type="submit"
disabled={isInputDisabled}
className="w-full py-2 px-4 bg-green-600 hover:bg-green-500 disabled:opacity-50 text-black font-mono text-sm rounded transition-colors"
>
{t.submit}
</button>
</form>
)}
{step === "authenticating" && (
<div className="py-4">
<AuthBootLog authenticatingLabel={t.authenticating} processingLabel={t.processing} done={authDone} />
</div>
)}
{step === "success" && (
<div className="text-green-500 text-center py-4">
{t.accessGranted}
</div>
)}
</div>
{/* ========== Desktop Terminal ========== */}
<div className="hidden sm:block">
{/* Previous lines */}
{lines.map((line, index) => (
<span
key={index}
className={cn(
"whitespace-pre-wrap",
line.type === "prompt" && "text-green-500",
line.type === "input" && "text-zinc-100",
line.type === "info" && "text-zinc-500",
line.type === "success" && "text-green-500",
line.type === "error" && "text-red-500",
line.type === "warning" && "text-yellow-500"
)}
>
{line.text}
{(line.type === "prompt" || line.text === "") ? "" : "\n"}
</span>
))}
{/* Current input line */}
{(step === "username" || step === "password") && (
<div className="flex items-center">
<span className="text-green-500">{getCurrentPrompt()}</span>
{renderInputWithCursor()}
<input
ref={inputRef}
type={step === "password" ? "password" : "text"}
value={getCurrentValue()}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onSelect={handleSelect}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={isInputDisabled}
className="absolute opacity-0 pointer-events-none"
autoComplete={step === "username" ? "username" : "current-password"}
autoFocus
/>
</div>
)}
{/* Loading indicator */}
{step === "authenticating" && (
<div className="mt-2">
<AuthBootLog authenticatingLabel={t.authenticating} processingLabel={t.processing} done={authDone} />
</div>
)}
{/* Keyboard shortcuts hint */}
{(step === "username" || step === "password") && (
<div className="mt-6 text-xs text-zinc-600">
<span className="text-zinc-500">{t.shortcuts}:</span>{" "}
<span className="text-cyan-600">Enter</span> {t.submit}{" "}
<span className="text-cyan-600">Ctrl+C</span> {t.cancel}{" "}
<span className="text-cyan-600">Ctrl+U</span> {t.clear}{" "}
<span className="text-cyan-600">Ctrl+A/E</span> {t.startEnd}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,255 @@
"use client"
import {
Children,
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { motion, MotionProps, useInView } from "motion/react"
import { cn } from "@/lib/utils"
interface SequenceContextValue {
completeItem: (index: number) => void
activeIndex: number
sequenceStarted: boolean
}
const SequenceContext = createContext<SequenceContextValue | null>(null)
const useSequence = () => useContext(SequenceContext)
const ItemIndexContext = createContext<number | null>(null)
const useItemIndex = () => useContext(ItemIndexContext)
interface AnimatedSpanProps extends MotionProps {
children: React.ReactNode
delay?: number
className?: string
startOnView?: boolean
}
export const AnimatedSpan = ({
children,
delay = 0,
className,
startOnView = false,
...props
}: AnimatedSpanProps) => {
const elementRef = useRef<HTMLDivElement | null>(null)
const isInView = useInView(elementRef as React.RefObject<Element>, {
amount: 0.3,
once: true,
})
const sequence = useSequence()
const itemIndex = useItemIndex()
const [hasStarted, setHasStarted] = useState(false)
useEffect(() => {
if (!sequence || itemIndex === null) return
if (!sequence.sequenceStarted) return
if (hasStarted) return
if (sequence.activeIndex === itemIndex) {
setHasStarted(true)
}
}, [sequence?.activeIndex, sequence?.sequenceStarted, hasStarted, itemIndex])
const shouldAnimate = sequence ? hasStarted : startOnView ? isInView : true
return (
<motion.div
ref={elementRef}
initial={{ opacity: 0, y: -5 }}
animate={shouldAnimate ? { opacity: 1, y: 0 } : { opacity: 0, y: -5 }}
transition={{ duration: 0.3, delay: sequence ? 0 : delay / 1000 }}
className={cn("grid text-sm font-normal tracking-tight", className)}
onAnimationComplete={() => {
if (!sequence) return
if (itemIndex === null) return
sequence.completeItem(itemIndex)
}}
{...props}
>
{children}
</motion.div>
)
}
interface TypingAnimationProps extends MotionProps {
children: string
className?: string
duration?: number
delay?: number
as?: React.ElementType
startOnView?: boolean
}
export const TypingAnimation = ({
children,
className,
duration = 60,
delay = 0,
as: Component = "span",
startOnView = true,
...props
}: TypingAnimationProps) => {
if (typeof children !== "string") {
throw new Error("TypingAnimation: children must be a string. Received:")
}
const MotionComponent = useMemo(
() =>
motion.create(Component, {
forwardMotionProps: true,
}),
[Component]
)
const [displayedText, setDisplayedText] = useState<string>("")
const [started, setStarted] = useState(false)
const elementRef = useRef<HTMLElement | null>(null)
const isInView = useInView(elementRef as React.RefObject<Element>, {
amount: 0.3,
once: true,
})
const sequence = useSequence()
const itemIndex = useItemIndex()
useEffect(() => {
if (sequence && itemIndex !== null) {
if (!sequence.sequenceStarted) return
if (started) return
if (sequence.activeIndex === itemIndex) {
setStarted(true)
}
return
}
if (!startOnView) {
const startTimeout = setTimeout(() => setStarted(true), delay)
return () => clearTimeout(startTimeout)
}
if (!isInView) return
const startTimeout = setTimeout(() => setStarted(true), delay)
return () => clearTimeout(startTimeout)
}, [
delay,
startOnView,
isInView,
started,
sequence?.activeIndex,
sequence?.sequenceStarted,
itemIndex,
])
useEffect(() => {
if (!started) return
let i = 0
const typingEffect = setInterval(() => {
if (i < children.length) {
setDisplayedText(children.substring(0, i + 1))
i++
} else {
clearInterval(typingEffect)
if (sequence && itemIndex !== null) {
sequence.completeItem(itemIndex)
}
}
}, duration)
return () => {
clearInterval(typingEffect)
}
}, [children, duration, started])
return (
<MotionComponent
ref={elementRef}
className={cn("text-sm font-normal tracking-tight", className)}
{...props}
>
{displayedText}
</MotionComponent>
)
}
interface TerminalProps {
children: React.ReactNode
className?: string
sequence?: boolean
startOnView?: boolean
}
export const Terminal = ({
children,
className,
sequence = true,
startOnView = true,
}: TerminalProps) => {
const containerRef = useRef<HTMLDivElement | null>(null)
const isInView = useInView(containerRef as React.RefObject<Element>, {
amount: 0.3,
once: true,
})
const [activeIndex, setActiveIndex] = useState(0)
const sequenceHasStarted = sequence ? !startOnView || isInView : false
const contextValue = useMemo<SequenceContextValue | null>(() => {
if (!sequence) return null
return {
completeItem: (index: number) => {
setActiveIndex((current) => (index === current ? current + 1 : current))
},
activeIndex,
sequenceStarted: sequenceHasStarted,
}
}, [sequence, activeIndex, sequenceHasStarted])
const wrappedChildren = useMemo(() => {
if (!sequence) return children
const array = Children.toArray(children)
return array.map((child, index) => (
<ItemIndexContext.Provider key={index} value={index}>
{child as React.ReactNode}
</ItemIndexContext.Provider>
))
}, [children, sequence])
const content = (
<div
ref={containerRef}
className={cn(
"border-border bg-background z-0 h-full max-h-[400px] w-full max-w-lg rounded-xl border",
className
)}
>
<div className="border-border flex flex-col gap-y-2 border-b p-4">
<div className="flex flex-row gap-x-2">
<div className="h-2 w-2 rounded-full bg-red-500"></div>
<div className="h-2 w-2 rounded-full bg-yellow-500"></div>
<div className="h-2 w-2 rounded-full bg-green-500"></div>
</div>
</div>
<pre className="p-4">
<code className="grid gap-y-1 overflow-auto">{wrappedChildren}</code>
</pre>
</div>
)
if (!sequence) return content
return (
<SequenceContext.Provider value={contextValue}>
{content}
</SequenceContext.Provider>
)
}

View File

@@ -1,19 +1,21 @@
"use client"
import { ColumnDef } from "@tanstack/react-table"
import { Eye } from "lucide-react"
import { Eye, Circle, CheckCircle2 } from "lucide-react"
import { Checkbox } from "@/components/ui/checkbox"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { ExpandableUrlCell } from "@/components/ui/data-table/expandable-cell"
import { DataTableColumnHeader } from "@/components/ui/data-table/column-header"
import { SEVERITY_STYLES } from "@/lib/severity-config"
import type { Vulnerability, VulnerabilitySeverity } from "@/types/vulnerability.types"
// Translation type definitions
export interface VulnerabilityTranslations {
columns: {
status?: string
severity: string
source: string
vulnType: string
@@ -27,6 +29,8 @@ export interface VulnerabilityTranslations {
}
tooltips: {
vulnDetails: string
reviewed: string
pending: string
}
severity: {
critical: string
@@ -40,21 +44,24 @@ export interface VulnerabilityTranslations {
interface ColumnActions {
formatDate: (date: string) => string
handleViewDetail: (vulnerability: Vulnerability) => void
onToggleReview?: (vulnerability: Vulnerability) => void
t: VulnerabilityTranslations
}
export function createVulnerabilityColumns({
formatDate,
handleViewDetail,
onToggleReview,
t,
}: ColumnActions): ColumnDef<Vulnerability>[] {
// Unified vulnerability severity color configuration
// Color progression: cool (info) → warm (low/medium) → hot (high/critical)
const severityConfig: Record<VulnerabilitySeverity, { className: string }> = {
critical: { className: "bg-[#da3633]/10 text-[#da3633] border border-[#da3633]/20 dark:text-[#f85149]" },
high: { className: "bg-[#d29922]/10 text-[#d29922] border border-[#d29922]/20" },
medium: { className: "bg-[#d4a72c]/10 text-[#d4a72c] border border-[#d4a72c]/20" },
low: { className: "bg-[#238636]/10 text-[#238636] border border-[#238636]/20 dark:text-[#3fb950]" },
info: { className: "bg-[#848d97]/10 text-[#848d97] border border-[#848d97]/20" },
critical: { className: SEVERITY_STYLES.critical.className },
high: { className: SEVERITY_STYLES.high.className },
medium: { className: SEVERITY_STYLES.medium.className },
low: { className: SEVERITY_STYLES.low.className },
info: { className: SEVERITY_STYLES.info.className },
}
return [
@@ -84,6 +91,39 @@ export function createVulnerabilityColumns({
enableSorting: false,
enableHiding: false,
},
{
id: "reviewStatus",
meta: { title: t.columns.status || "状态" },
size: 100,
minSize: 90,
maxSize: 110,
enableResizing: false,
header: t.columns.status || "状态",
cell: ({ row }) => {
const isReviewed = row.original.isReviewed
const isPending = !isReviewed
return (
<Badge
variant="outline"
className={`transition-all gap-1.5 ${onToggleReview ? "cursor-pointer hover:ring-2 hover:ring-offset-1" : "cursor-default"} ${isPending
? "bg-blue-500/10 text-blue-600 border-blue-500/30 hover:ring-blue-500/30 dark:text-blue-400 dark:border-blue-400/30"
: "bg-muted/50 text-muted-foreground border-muted-foreground/20 hover:ring-muted-foreground/30"
}`}
onClick={() => onToggleReview?.(row.original)}
>
{isPending ? (
<Circle className="h-3 w-3" />
) : (
<CheckCircle2 className="h-3 w-3" />
)}
{isPending ? t.tooltips.pending : t.tooltips.reviewed}
</Badge>
)
},
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "severity",
meta: { title: t.columns.severity },

View File

@@ -3,12 +3,29 @@
import * as React from "react"
import type { ColumnDef } from "@tanstack/react-table"
import { useTranslations } from "next-intl"
import { CheckCircle, Circle, X, Filter } from "lucide-react"
import { UnifiedDataTable } from "@/components/ui/data-table"
import { PREDEFINED_FIELDS, type FilterField } from "@/components/common/smart-filter-input"
import type { Vulnerability } from "@/types/vulnerability.types"
import { SmartFilterInput, PREDEFINED_FIELDS, type FilterField, type ParsedFilter } from "@/components/common/smart-filter-input"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { Vulnerability, VulnerabilitySeverity } from "@/types/vulnerability.types"
import type { PaginationInfo } from "@/types/common.types"
import type { DownloadOption } from "@/types/data-table.types"
// Review filter type
export type ReviewFilter = "all" | "pending" | "reviewed"
// Severity filter type
export type SeverityFilter = VulnerabilitySeverity | "all"
// Vulnerability page filter fields
const VULNERABILITY_FILTER_FIELDS: FilterField[] = [
{ key: "type", label: "Type", description: "Vulnerability type" },
@@ -22,9 +39,10 @@ const VULNERABILITY_FILTER_EXAMPLES = [
'type="xss" || type="sqli"',
'severity="critical" || severity="high"',
'source="nuclei" && severity="high"',
'type="xss" && url="/api/*"',
'type="xss" && url="/api/"',
]
interface VulnerabilitiesDataTableProps {
data: Vulnerability[]
columns: ColumnDef<Vulnerability>[]
@@ -39,6 +57,21 @@ interface VulnerabilitiesDataTableProps {
onDownloadAll?: () => void
onDownloadSelected?: () => void
hideToolbar?: boolean
// Review status props
reviewFilter?: ReviewFilter
onReviewFilterChange?: (filter: ReviewFilter) => void
pendingCount?: number
reviewedCount?: number
selectedRows?: Vulnerability[]
onBulkMarkAsReviewed?: () => void
onBulkMarkAsPending?: () => void
// New: severity filter
severityFilter?: SeverityFilter
onSeverityFilterChange?: (filter: SeverityFilter) => void
// New: source filter
sourceFilter?: string
onSourceFilterChange?: (source: string) => void
availableSources?: string[]
}
export function VulnerabilitiesDataTable({
@@ -55,13 +88,27 @@ export function VulnerabilitiesDataTable({
onDownloadAll,
onDownloadSelected,
hideToolbar = false,
reviewFilter = "all",
onReviewFilterChange,
pendingCount = 0,
reviewedCount = 0,
selectedRows = [],
onBulkMarkAsReviewed,
onBulkMarkAsPending,
severityFilter = "all",
onSeverityFilterChange,
sourceFilter = "all",
onSourceFilterChange,
availableSources = [],
}: VulnerabilitiesDataTableProps) {
const t = useTranslations("common.status")
const tDownload = useTranslations("common.download")
const tActions = useTranslations("common.actions")
const tVuln = useTranslations("vulnerabilities")
const tSeverity = useTranslations("severity")
// Handle smart filter search
const handleFilterSearch = (rawQuery: string) => {
const handleSmartSearch = (_filters: ParsedFilter[], rawQuery: string) => {
onFilterChange?.(rawQuery)
}
@@ -83,34 +130,152 @@ export function VulnerabilitiesDataTable({
})
}
// Severity options for Select
const severityOptions: { value: SeverityFilter; label: string }[] = [
{ value: "all", label: tVuln("reviewStatus.all") },
{ value: "critical", label: tSeverity("critical") },
{ value: "high", label: tSeverity("high") },
{ value: "medium", label: tSeverity("medium") },
{ value: "low", label: tSeverity("low") },
{ value: "info", label: tSeverity("info") },
]
// Left toolbar content - smart filter + severity select
const leftToolbarContent = (
<div className="flex items-center gap-2 flex-1">
<SmartFilterInput
fields={VULNERABILITY_FILTER_FIELDS}
examples={VULNERABILITY_FILTER_EXAMPLES}
placeholder={tActions("search")}
value={filterValue}
onSearch={handleSmartSearch}
className="flex-1 max-w-md"
/>
{onSeverityFilterChange && (
<Select
value={severityFilter}
onValueChange={(value) => onSeverityFilterChange(value as SeverityFilter)}
>
<SelectTrigger size="sm" className="w-auto">
<Filter className="h-4 w-4" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{severityOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)
// Right toolbar content - review tabs
const rightToolbarContent = (
<>
{/* Review filter tabs */}
{onReviewFilterChange && (
<Tabs value={reviewFilter} onValueChange={(v) => onReviewFilterChange(v as ReviewFilter)}>
<TabsList>
<TabsTrigger value="all">
{tVuln("reviewStatus.all")}
</TabsTrigger>
<TabsTrigger value="pending">
{tVuln("reviewStatus.pending")}
{pendingCount > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{pendingCount}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="reviewed">
{tVuln("reviewStatus.reviewed")}
{reviewedCount > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
{reviewedCount}
</Badge>
)}
</TabsTrigger>
</TabsList>
</Tabs>
)}
</>
)
// Floating action bar for bulk operations
const floatingActionBar = selectedRows.length > 0 && (onBulkMarkAsReviewed || onBulkMarkAsPending) && (
<div className="fixed bottom-6 left-[calc(50vw+var(--sidebar-width,14rem)/2)] -translate-x-1/2 z-50 animate-in slide-in-from-bottom-4 fade-in duration-200">
<div className="flex items-center gap-3 bg-background border rounded-lg shadow-lg px-4 py-2.5">
<span className="text-sm text-muted-foreground">
{tVuln("selected", { count: selectedRows.length })}
</span>
<div className="h-4 w-px bg-border" />
{onBulkMarkAsReviewed && (
<Button
variant="outline"
size="sm"
onClick={onBulkMarkAsReviewed}
className="h-8"
>
<CheckCircle className="h-4 w-4 mr-1.5" />
{tVuln("markAsReviewed")}
</Button>
)}
{onBulkMarkAsPending && (
<Button
variant="outline"
size="sm"
onClick={onBulkMarkAsPending}
className="h-8"
>
<Circle className="h-4 w-4 mr-1.5" />
{tVuln("markAsPending")}
</Button>
)}
{onSelectionChange && (
<Button
variant="ghost"
size="icon"
onClick={() => onSelectionChange([])}
className="h-8 w-8 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
)
return (
<UnifiedDataTable
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={handleFilterSearch}
filterFields={VULNERABILITY_FILTER_FIELDS}
filterExamples={VULNERABILITY_FILTER_EXAMPLES}
// Selection
onSelectionChange={onSelectionChange}
// Bulk operations
onBulkDelete={onBulkDelete}
bulkDeleteLabel={tActions("delete")}
showAddButton={false}
// Download
downloadOptions={downloadOptions.length > 0 ? downloadOptions : undefined}
// Toolbar
hideToolbar={hideToolbar}
// Empty state
emptyMessage={t("noData")}
/>
<>
<UnifiedDataTable
data={data}
columns={columns}
getRowId={(row) => String(row.id)}
// Pagination
pagination={pagination}
setPagination={setPagination}
paginationInfo={paginationInfo}
onPaginationChange={onPaginationChange}
// Toolbar
toolbarLeft={leftToolbarContent}
// Selection
onSelectionChange={onSelectionChange}
// Bulk operations
onBulkDelete={onBulkDelete}
bulkDeleteLabel={tActions("delete")}
showAddButton={false}
// Download
downloadOptions={downloadOptions.length > 0 ? downloadOptions : undefined}
// Toolbar
hideToolbar={hideToolbar}
toolbarRight={rightToolbarContent}
// Empty state
emptyMessage={t("noData")}
/>
{floatingActionBar}
</>
)
}

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