diff --git a/.claude-flow/.gitignore b/.claude-flow/.gitignore new file mode 100644 index 0000000..51f4f63 --- /dev/null +++ b/.claude-flow/.gitignore @@ -0,0 +1,7 @@ +# Claude Flow runtime files +data/ +logs/ +sessions/ +neural/ +*.log +*.tmp diff --git a/.claude-flow/CAPABILITIES.md b/.claude-flow/CAPABILITIES.md new file mode 100644 index 0000000..0254d35 --- /dev/null +++ b/.claude-flow/CAPABILITIES.md @@ -0,0 +1,403 @@ +# RuFlo V3 - Complete Capabilities Reference +> Generated: 2026-03-06T01:38:48.235Z +> Full documentation: https://github.com/ruvnet/claude-flow + +## 📋 Table of Contents + +1. [Overview](#overview) +2. [Swarm Orchestration](#swarm-orchestration) +3. [Available Agents (60+)](#available-agents) +4. [CLI Commands (26 Commands, 140+ Subcommands)](#cli-commands) +5. [Hooks System (27 Hooks + 12 Workers)](#hooks-system) +6. [Memory & Intelligence (RuVector)](#memory--intelligence) +7. [Hive-Mind Consensus](#hive-mind-consensus) +8. [Performance Targets](#performance-targets) +9. [Integration Ecosystem](#integration-ecosystem) + +--- + +## Overview + +RuFlo V3 is a domain-driven design architecture for multi-agent AI coordination with: + +- **15-Agent Swarm Coordination** with hierarchical and mesh topologies +- **HNSW Vector Search** - 150x-12,500x faster pattern retrieval +- **SONA Neural Learning** - Self-optimizing with <0.05ms adaptation +- **Byzantine Fault Tolerance** - Queen-led consensus mechanisms +- **MCP Server Integration** - Model Context Protocol support + +### Current Configuration +| Setting | Value | +|---------|-------| +| Topology | hierarchical-mesh | +| Max Agents | 15 | +| Memory Backend | hybrid | +| HNSW Indexing | Enabled | +| Neural Learning | Enabled | +| LearningBridge | Enabled (SONA + ReasoningBank) | +| Knowledge Graph | Enabled (PageRank + Communities) | +| Agent Scopes | Enabled (project/local/user) | + +--- + +## Swarm Orchestration + +### Topologies +| Topology | Description | Best For | +|----------|-------------|----------| +| `hierarchical` | Queen controls workers directly | Anti-drift, tight control | +| `mesh` | Fully connected peer network | Distributed tasks | +| `hierarchical-mesh` | V3 hybrid (recommended) | 10+ agents | +| `ring` | Circular communication | Sequential workflows | +| `star` | Central coordinator | Simple coordination | +| `adaptive` | Dynamic based on load | Variable workloads | + +### Strategies +- `balanced` - Even distribution across agents +- `specialized` - Clear roles, no overlap (anti-drift) +- `adaptive` - Dynamic task routing + +### Quick Commands +```bash +# Initialize swarm +npx @claude-flow/cli@latest swarm init --topology hierarchical --max-agents 8 --strategy specialized + +# Check status +npx @claude-flow/cli@latest swarm status + +# Monitor activity +npx @claude-flow/cli@latest swarm monitor +``` + +--- + +## Available Agents + +### Core Development (5) +`coder`, `reviewer`, `tester`, `planner`, `researcher` + +### V3 Specialized (4) +`security-architect`, `security-auditor`, `memory-specialist`, `performance-engineer` + +### Swarm Coordination (5) +`hierarchical-coordinator`, `mesh-coordinator`, `adaptive-coordinator`, `collective-intelligence-coordinator`, `swarm-memory-manager` + +### Consensus & Distributed (7) +`byzantine-coordinator`, `raft-manager`, `gossip-coordinator`, `consensus-builder`, `crdt-synchronizer`, `quorum-manager`, `security-manager` + +### Performance & Optimization (5) +`perf-analyzer`, `performance-benchmarker`, `task-orchestrator`, `memory-coordinator`, `smart-agent` + +### GitHub & Repository (9) +`github-modes`, `pr-manager`, `code-review-swarm`, `issue-tracker`, `release-manager`, `workflow-automation`, `project-board-sync`, `repo-architect`, `multi-repo-swarm` + +### SPARC Methodology (6) +`sparc-coord`, `sparc-coder`, `specification`, `pseudocode`, `architecture`, `refinement` + +### Specialized Development (8) +`backend-dev`, `mobile-dev`, `ml-developer`, `cicd-engineer`, `api-docs`, `system-architect`, `code-analyzer`, `base-template-generator` + +### Testing & Validation (2) +`tdd-london-swarm`, `production-validator` + +### Agent Routing by Task +| Task Type | Recommended Agents | Topology | +|-----------|-------------------|----------| +| Bug Fix | researcher, coder, tester | mesh | +| New Feature | coordinator, architect, coder, tester, reviewer | hierarchical | +| Refactoring | architect, coder, reviewer | mesh | +| Performance | researcher, perf-engineer, coder | hierarchical | +| Security | security-architect, auditor, reviewer | hierarchical | +| Docs | researcher, api-docs | mesh | + +--- + +## CLI Commands + +### Core Commands (12) +| Command | Subcommands | Description | +|---------|-------------|-------------| +| `init` | 4 | Project initialization | +| `agent` | 8 | Agent lifecycle management | +| `swarm` | 6 | Multi-agent coordination | +| `memory` | 11 | AgentDB with HNSW search | +| `mcp` | 9 | MCP server management | +| `task` | 6 | Task assignment | +| `session` | 7 | Session persistence | +| `config` | 7 | Configuration | +| `status` | 3 | System monitoring | +| `workflow` | 6 | Workflow templates | +| `hooks` | 17 | Self-learning hooks | +| `hive-mind` | 6 | Consensus coordination | + +### Advanced Commands (14) +| Command | Subcommands | Description | +|---------|-------------|-------------| +| `daemon` | 5 | Background workers | +| `neural` | 5 | Pattern training | +| `security` | 6 | Security scanning | +| `performance` | 5 | Profiling & benchmarks | +| `providers` | 5 | AI provider config | +| `plugins` | 5 | Plugin management | +| `deployment` | 5 | Deploy management | +| `embeddings` | 4 | Vector embeddings | +| `claims` | 4 | Authorization | +| `migrate` | 5 | V2→V3 migration | +| `process` | 4 | Process management | +| `doctor` | 1 | Health diagnostics | +| `completions` | 4 | Shell completions | + +### Example Commands +```bash +# Initialize +npx @claude-flow/cli@latest init --wizard + +# Spawn agent +npx @claude-flow/cli@latest agent spawn -t coder --name my-coder + +# Memory operations +npx @claude-flow/cli@latest memory store --key "pattern" --value "data" --namespace patterns +npx @claude-flow/cli@latest memory search --query "authentication" + +# Diagnostics +npx @claude-flow/cli@latest doctor --fix +``` + +--- + +## Hooks System + +### 27 Available Hooks + +#### Core Hooks (6) +| Hook | Description | +|------|-------------| +| `pre-edit` | Context before file edits | +| `post-edit` | Record edit outcomes | +| `pre-command` | Risk assessment | +| `post-command` | Command metrics | +| `pre-task` | Task start + agent suggestions | +| `post-task` | Task completion learning | + +#### Session Hooks (4) +| Hook | Description | +|------|-------------| +| `session-start` | Start/restore session | +| `session-end` | Persist state | +| `session-restore` | Restore previous | +| `notify` | Cross-agent notifications | + +#### Intelligence Hooks (5) +| Hook | Description | +|------|-------------| +| `route` | Optimal agent routing | +| `explain` | Routing decisions | +| `pretrain` | Bootstrap intelligence | +| `build-agents` | Generate configs | +| `transfer` | Pattern transfer | + +#### Coverage Hooks (3) +| Hook | Description | +|------|-------------| +| `coverage-route` | Coverage-based routing | +| `coverage-suggest` | Improvement suggestions | +| `coverage-gaps` | Gap analysis | + +### 12 Background Workers +| Worker | Priority | Purpose | +|--------|----------|---------| +| `ultralearn` | normal | Deep knowledge | +| `optimize` | high | Performance | +| `consolidate` | low | Memory consolidation | +| `predict` | normal | Predictive preload | +| `audit` | critical | Security | +| `map` | normal | Codebase mapping | +| `preload` | low | Resource preload | +| `deepdive` | normal | Deep analysis | +| `document` | normal | Auto-docs | +| `refactor` | normal | Suggestions | +| `benchmark` | normal | Benchmarking | +| `testgaps` | normal | Coverage gaps | + +--- + +## Memory & Intelligence + +### RuVector Intelligence System +- **SONA**: Self-Optimizing Neural Architecture (<0.05ms) +- **MoE**: Mixture of Experts routing +- **HNSW**: 150x-12,500x faster search +- **EWC++**: Prevents catastrophic forgetting +- **Flash Attention**: 2.49x-7.47x speedup +- **Int8 Quantization**: 3.92x memory reduction + +### 4-Step Intelligence Pipeline +1. **RETRIEVE** - HNSW pattern search +2. **JUDGE** - Success/failure verdicts +3. **DISTILL** - LoRA learning extraction +4. **CONSOLIDATE** - EWC++ preservation + +### Self-Learning Memory (ADR-049) + +| Component | Status | Description | +|-----------|--------|-------------| +| **LearningBridge** | ✅ Enabled | Connects insights to SONA/ReasoningBank neural pipeline | +| **MemoryGraph** | ✅ Enabled | PageRank knowledge graph + community detection | +| **AgentMemoryScope** | ✅ Enabled | 3-scope agent memory (project/local/user) | + +**LearningBridge** - Insights trigger learning trajectories. Confidence evolves: +0.03 on access, -0.005/hour decay. Consolidation runs the JUDGE/DISTILL/CONSOLIDATE pipeline. + +**MemoryGraph** - Builds a knowledge graph from entry references. PageRank identifies influential insights. Communities group related knowledge. Graph-aware ranking blends vector + structural scores. + +**AgentMemoryScope** - Maps Claude Code 3-scope directories: +- `project`: `/.claude/agent-memory//` +- `local`: `/.claude/agent-memory-local//` +- `user`: `~/.claude/agent-memory//` + +High-confidence insights (>0.8) can transfer between agents. + +### Memory Commands +```bash +# Store pattern +npx @claude-flow/cli@latest memory store --key "name" --value "data" --namespace patterns + +# Semantic search +npx @claude-flow/cli@latest memory search --query "authentication" + +# List entries +npx @claude-flow/cli@latest memory list --namespace patterns + +# Initialize database +npx @claude-flow/cli@latest memory init --force +``` + +--- + +## Hive-Mind Consensus + +### Queen Types +| Type | Role | +|------|------| +| Strategic Queen | Long-term planning | +| Tactical Queen | Execution coordination | +| Adaptive Queen | Dynamic optimization | + +### Worker Types (8) +`researcher`, `coder`, `analyst`, `tester`, `architect`, `reviewer`, `optimizer`, `documenter` + +### Consensus Mechanisms +| Mechanism | Fault Tolerance | Use Case | +|-----------|-----------------|----------| +| `byzantine` | f < n/3 faulty | Adversarial | +| `raft` | f < n/2 failed | Leader-based | +| `gossip` | Eventually consistent | Large scale | +| `crdt` | Conflict-free | Distributed | +| `quorum` | Configurable | Flexible | + +### Hive-Mind Commands +```bash +# Initialize +npx @claude-flow/cli@latest hive-mind init --queen-type strategic + +# Status +npx @claude-flow/cli@latest hive-mind status + +# Spawn workers +npx @claude-flow/cli@latest hive-mind spawn --count 5 --type worker + +# Consensus +npx @claude-flow/cli@latest hive-mind consensus --propose "task" +``` + +--- + +## Performance Targets + +| Metric | Target | Status | +|--------|--------|--------| +| HNSW Search | 150x-12,500x faster | ✅ Implemented | +| Memory Reduction | 50-75% | ✅ Implemented (3.92x) | +| SONA Integration | Pattern learning | ✅ Implemented | +| Flash Attention | 2.49x-7.47x | 🔄 In Progress | +| MCP Response | <100ms | ✅ Achieved | +| CLI Startup | <500ms | ✅ Achieved | +| SONA Adaptation | <0.05ms | 🔄 In Progress | +| Graph Build (1k) | <200ms | ✅ 2.78ms (71.9x headroom) | +| PageRank (1k) | <100ms | ✅ 12.21ms (8.2x headroom) | +| Insight Recording | <5ms/each | ✅ 0.12ms (41x headroom) | +| Consolidation | <500ms | ✅ 0.26ms (1,955x headroom) | +| Knowledge Transfer | <100ms | ✅ 1.25ms (80x headroom) | + +--- + +## Integration Ecosystem + +### Integrated Packages +| Package | Version | Purpose | +|---------|---------|---------| +| agentic-flow | 3.0.0-alpha.1 | Core coordination + ReasoningBank + Router | +| agentdb | 3.0.0-alpha.10 | Vector database + 8 controllers | +| @ruvector/attention | 0.1.3 | Flash attention | +| @ruvector/sona | 0.1.5 | Neural learning | + +### Optional Integrations +| Package | Command | +|---------|---------| +| ruv-swarm | `npx ruv-swarm mcp start` | +| flow-nexus | `npx flow-nexus@latest mcp start` | +| agentic-jujutsu | `npx agentic-jujutsu@latest` | + +### MCP Server Setup +```bash +# Add Claude Flow MCP +claude mcp add claude-flow -- npx -y @claude-flow/cli@latest + +# Optional servers +claude mcp add ruv-swarm -- npx -y ruv-swarm mcp start +claude mcp add flow-nexus -- npx -y flow-nexus@latest mcp start +``` + +--- + +## Quick Reference + +### Essential Commands +```bash +# Setup +npx @claude-flow/cli@latest init --wizard +npx @claude-flow/cli@latest daemon start +npx @claude-flow/cli@latest doctor --fix + +# Swarm +npx @claude-flow/cli@latest swarm init --topology hierarchical --max-agents 8 +npx @claude-flow/cli@latest swarm status + +# Agents +npx @claude-flow/cli@latest agent spawn -t coder +npx @claude-flow/cli@latest agent list + +# Memory +npx @claude-flow/cli@latest memory search --query "patterns" + +# Hooks +npx @claude-flow/cli@latest hooks pre-task --description "task" +npx @claude-flow/cli@latest hooks worker dispatch --trigger optimize +``` + +### File Structure +``` +.claude-flow/ +├── config.yaml # Runtime configuration +├── CAPABILITIES.md # This file +├── data/ # Memory storage +├── logs/ # Operation logs +├── sessions/ # Session state +├── hooks/ # Custom hooks +├── agents/ # Agent configs +└── workflows/ # Workflow templates +``` + +--- + +**Full Documentation**: https://github.com/ruvnet/claude-flow +**Issues**: https://github.com/ruvnet/claude-flow/issues diff --git a/.claude-flow/config.yaml b/.claude-flow/config.yaml new file mode 100644 index 0000000..09c1ce5 --- /dev/null +++ b/.claude-flow/config.yaml @@ -0,0 +1,43 @@ +# RuFlo V3 Runtime Configuration +# Generated: 2026-03-06T01:38:48.235Z + +version: "3.0.0" + +swarm: + topology: hierarchical-mesh + maxAgents: 15 + autoScale: true + coordinationStrategy: consensus + +memory: + backend: hybrid + enableHNSW: true + persistPath: .claude-flow/data + cacheSize: 100 + # ADR-049: Self-Learning Memory + learningBridge: + enabled: true + sonaMode: balanced + confidenceDecayRate: 0.005 + accessBoostAmount: 0.03 + consolidationThreshold: 10 + memoryGraph: + enabled: true + pageRankDamping: 0.85 + maxNodes: 5000 + similarityThreshold: 0.8 + agentScopes: + enabled: true + defaultScope: project + +neural: + enabled: true + modelPath: .claude-flow/neural + +hooks: + enabled: true + autoExecute: true + +mcp: + autoStart: false + port: 3000 diff --git a/.claude-flow/metrics/learning.json b/.claude-flow/metrics/learning.json new file mode 100644 index 0000000..70c938c --- /dev/null +++ b/.claude-flow/metrics/learning.json @@ -0,0 +1,17 @@ +{ + "initialized": "2026-03-06T01:38:48.235Z", + "routing": { + "accuracy": 0, + "decisions": 0 + }, + "patterns": { + "shortTerm": 0, + "longTerm": 0, + "quality": 0 + }, + "sessions": { + "total": 0, + "current": null + }, + "_note": "Intelligence grows as you use Claude Flow" +} \ No newline at end of file diff --git a/.claude-flow/metrics/swarm-activity.json b/.claude-flow/metrics/swarm-activity.json new file mode 100644 index 0000000..a0f6b8c --- /dev/null +++ b/.claude-flow/metrics/swarm-activity.json @@ -0,0 +1,18 @@ +{ + "timestamp": "2026-03-06T01:38:48.235Z", + "processes": { + "agentic_flow": 0, + "mcp_server": 0, + "estimated_agents": 0 + }, + "swarm": { + "active": false, + "agent_count": 0, + "coordination_active": false + }, + "integration": { + "agentic_flow_active": false, + "mcp_active": false + }, + "_initialized": true +} \ No newline at end of file diff --git a/.claude-flow/metrics/v3-progress.json b/.claude-flow/metrics/v3-progress.json new file mode 100644 index 0000000..ce81596 --- /dev/null +++ b/.claude-flow/metrics/v3-progress.json @@ -0,0 +1,26 @@ +{ + "version": "3.0.0", + "initialized": "2026-03-06T01:38:48.235Z", + "domains": { + "completed": 0, + "total": 5, + "status": "INITIALIZING" + }, + "ddd": { + "progress": 0, + "modules": 0, + "totalFiles": 0, + "totalLines": 0 + }, + "swarm": { + "activeAgents": 0, + "maxAgents": 15, + "topology": "hierarchical-mesh" + }, + "learning": { + "status": "READY", + "patternsLearned": 0, + "sessionsCompleted": 0 + }, + "_note": "Metrics will update as you use Claude Flow. Run: npx @claude-flow/cli@latest daemon start" +} \ No newline at end of file diff --git a/.claude-flow/security/audit-status.json b/.claude-flow/security/audit-status.json new file mode 100644 index 0000000..ed706ea --- /dev/null +++ b/.claude-flow/security/audit-status.json @@ -0,0 +1,8 @@ +{ + "initialized": "2026-03-06T01:38:48.236Z", + "status": "PENDING", + "cvesFixed": 0, + "totalCves": 3, + "lastScan": null, + "_note": "Run: npx @claude-flow/cli@latest security scan" +} \ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..1f54617 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,22 @@ +{ + "mcpServers": { + "claude-flow": { + "command": "npx", + "args": [ + "-y", + "@claude-flow/cli@latest", + "mcp", + "start" + ], + "env": { + "npm_config_update_notifier": "false", + "CLAUDE_FLOW_MODE": "v3", + "CLAUDE_FLOW_HOOKS_ENABLED": "true", + "CLAUDE_FLOW_TOPOLOGY": "hierarchical-mesh", + "CLAUDE_FLOW_MAX_AGENTS": "15", + "CLAUDE_FLOW_MEMORY_BACKEND": "hybrid" + }, + "autoStart": false + } + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b2b3ee2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,188 @@ +# Claude Code Configuration - RuFlo V3 + +## Behavioral Rules (Always Enforced) + +- Do what has been asked; nothing more, nothing less +- NEVER create files unless they're absolutely necessary for achieving your goal +- ALWAYS prefer editing an existing file to creating a new one +- NEVER proactively create documentation files (*.md) or README files unless explicitly requested +- NEVER save working files, text/mds, or tests to the root folder +- Never continuously check status after spawning a swarm — wait for results +- ALWAYS read a file before editing it +- NEVER commit secrets, credentials, or .env files + +## File Organization + +- NEVER save to root folder — use the directories below +- Use `/src` for source code files +- Use `/tests` for test files +- Use `/docs` for documentation and markdown files +- Use `/config` for configuration files +- Use `/scripts` for utility scripts +- Use `/examples` for example code + +## Project Architecture + +- Follow Domain-Driven Design with bounded contexts +- Keep files under 500 lines +- Use typed interfaces for all public APIs +- Prefer TDD London School (mock-first) for new code +- Use event sourcing for state changes +- Ensure input validation at system boundaries + +### Project Config + +- **Topology**: hierarchical-mesh +- **Max Agents**: 15 +- **Memory**: hybrid +- **HNSW**: Enabled +- **Neural**: Enabled + +## Build & Test + +```bash +# Build +npm run build + +# Test +npm test + +# Lint +npm run lint +``` + +- ALWAYS run tests after making code changes +- ALWAYS verify build succeeds before committing + +## Security Rules + +- NEVER hardcode API keys, secrets, or credentials in source files +- NEVER commit .env files or any file containing secrets +- Always validate user input at system boundaries +- Always sanitize file paths to prevent directory traversal +- Run `npx @claude-flow/cli@latest security scan` after security-related changes + +## Concurrency: 1 MESSAGE = ALL RELATED OPERATIONS + +- All operations MUST be concurrent/parallel in a single message +- Use Claude Code's Task tool for spawning agents, not just MCP +- ALWAYS batch ALL todos in ONE TodoWrite call (5-10+ minimum) +- ALWAYS spawn ALL agents in ONE message with full instructions via Task tool +- ALWAYS batch ALL file reads/writes/edits in ONE message +- ALWAYS batch ALL Bash commands in ONE message + +## Swarm Orchestration + +- MUST initialize the swarm using CLI tools when starting complex tasks +- MUST spawn concurrent agents using Claude Code's Task tool +- Never use CLI tools alone for execution — Task tool agents do the actual work +- MUST call CLI tools AND Task tool in ONE message for complex work + +### 3-Tier Model Routing (ADR-026) + +| Tier | Handler | Latency | Cost | Use Cases | +|------|---------|---------|------|-----------| +| **1** | Agent Booster (WASM) | <1ms | $0 | Simple transforms (var→const, add types) — Skip LLM | +| **2** | Haiku | ~500ms | $0.0002 | Simple tasks, low complexity (<30%) | +| **3** | Sonnet/Opus | 2-5s | $0.003-0.015 | Complex reasoning, architecture, security (>30%) | + +- Always check for `[AGENT_BOOSTER_AVAILABLE]` or `[TASK_MODEL_RECOMMENDATION]` before spawning agents +- Use Edit tool directly when `[AGENT_BOOSTER_AVAILABLE]` + +## Swarm Configuration & Anti-Drift + +- ALWAYS use hierarchical topology for coding swarms +- Keep maxAgents at 6-8 for tight coordination +- Use specialized strategy for clear role boundaries +- Use `raft` consensus for hive-mind (leader maintains authoritative state) +- Run frequent checkpoints via `post-task` hooks +- Keep shared memory namespace for all agents + +```bash +npx @claude-flow/cli@latest swarm init --topology hierarchical --max-agents 8 --strategy specialized +``` + +## Swarm Execution Rules + +- ALWAYS use `run_in_background: true` for all agent Task calls +- ALWAYS put ALL agent Task calls in ONE message for parallel execution +- After spawning, STOP — do NOT add more tool calls or check status +- Never poll TaskOutput or check swarm status — trust agents to return +- When agent results arrive, review ALL results before proceeding + +## V3 CLI Commands + +### Core Commands + +| Command | Subcommands | Description | +|---------|-------------|-------------| +| `init` | 4 | Project initialization | +| `agent` | 8 | Agent lifecycle management | +| `swarm` | 6 | Multi-agent swarm coordination | +| `memory` | 11 | AgentDB memory with HNSW search | +| `task` | 6 | Task creation and lifecycle | +| `session` | 7 | Session state management | +| `hooks` | 17 | Self-learning hooks + 12 workers | +| `hive-mind` | 6 | Byzantine fault-tolerant consensus | + +### Quick CLI Examples + +```bash +npx @claude-flow/cli@latest init --wizard +npx @claude-flow/cli@latest agent spawn -t coder --name my-coder +npx @claude-flow/cli@latest swarm init --v3-mode +npx @claude-flow/cli@latest memory search --query "authentication patterns" +npx @claude-flow/cli@latest doctor --fix +``` + +## Available Agents (60+ Types) + +### Core Development +`coder`, `reviewer`, `tester`, `planner`, `researcher` + +### Specialized +`security-architect`, `security-auditor`, `memory-specialist`, `performance-engineer` + +### Swarm Coordination +`hierarchical-coordinator`, `mesh-coordinator`, `adaptive-coordinator` + +### GitHub & Repository +`pr-manager`, `code-review-swarm`, `issue-tracker`, `release-manager` + +### SPARC Methodology +`sparc-coord`, `sparc-coder`, `specification`, `pseudocode`, `architecture` + +## Memory Commands Reference + +```bash +# Store (REQUIRED: --key, --value; OPTIONAL: --namespace, --ttl, --tags) +npx @claude-flow/cli@latest memory store --key "pattern-auth" --value "JWT with refresh" --namespace patterns + +# Search (REQUIRED: --query; OPTIONAL: --namespace, --limit, --threshold) +npx @claude-flow/cli@latest memory search --query "authentication patterns" + +# List (OPTIONAL: --namespace, --limit) +npx @claude-flow/cli@latest memory list --namespace patterns --limit 10 + +# Retrieve (REQUIRED: --key; OPTIONAL: --namespace) +npx @claude-flow/cli@latest memory retrieve --key "pattern-auth" --namespace patterns +``` + +## Quick Setup + +```bash +claude mcp add claude-flow -- npx -y @claude-flow/cli@latest +npx @claude-flow/cli@latest daemon start +npx @claude-flow/cli@latest doctor --fix +``` + +## Claude Code vs CLI Tools + +- Claude Code's Task tool handles ALL execution: agents, file ops, code generation, git +- CLI tools handle coordination via Bash: swarm init, memory, hooks, routing +- NEVER use CLI tools as a substitute for Task tool agents + +## Support + +- Documentation: https://github.com/ruvnet/claude-flow +- Issues: https://github.com/ruvnet/claude-flow/issues diff --git a/apis/admin/redemption.api b/apis/admin/redemption.api index ca5632c..ff2240b 100644 --- a/apis/admin/redemption.api +++ b/apis/admin/redemption.api @@ -27,8 +27,8 @@ type ( Status int64 `json:"status,omitempty" validate:"omitempty,oneof=0 1"` } ToggleRedemptionCodeStatusRequest { - Id int64 `json:"id" validate:"required"` - Status int64 `json:"status" validate:"oneof=0 1"` + Id int64 `json:"id" validate:"required"` + Status int64 `json:"status" validate:"oneof=0 1"` } DeleteRedemptionCodeRequest { Id int64 `json:"id" validate:"required"` @@ -93,3 +93,4 @@ service ppanel { @handler GetRedemptionRecordList get /record/list (GetRedemptionRecordListRequest) returns (GetRedemptionRecordListResponse) } + diff --git a/apis/admin/system.api b/apis/admin/system.api index d82d3bd..04134a4 100644 --- a/apis/admin/system.api +++ b/apis/admin/system.api @@ -127,6 +127,14 @@ service ppanel { @handler UpdateVerifyCodeConfig put /verify_code_config (VerifyCodeConfig) + @doc "Get Signature Config" + @handler GetSignatureConfig + get /signature_config returns (SignatureConfig) + + @doc "Update Signature Config" + @handler UpdateSignatureConfig + put /signature_config (SignatureConfig) + @doc "PreView Node Multiplier" @handler PreViewNodeMultiplier get /node_multiplier/preview returns (PreViewNodeMultiplierResponse) diff --git a/apis/admin/user.api b/apis/admin/user.api index 54ebbdf..b2822c6 100644 --- a/apis/admin/user.api +++ b/apis/admin/user.api @@ -15,18 +15,18 @@ import ( type ( // GetUserListRequest GetUserListRequest { - Page int `form:"page"` - Size int `form:"size"` - Search string `form:"search,omitempty"` - UserId *int64 `form:"user_id,omitempty"` - Unscoped bool `form:"unscoped,omitempty"` - SubscribeId *int64 `form:"subscribe_id,omitempty"` - UserSubscribeId *int64 `form:"user_subscribe_id,omitempty"` - ShortCode string `form:"short_code,omitempty"` - FamilyJoined *bool `form:"family_joined,omitempty"` - FamilyStatus string `form:"family_status,omitempty"` + Page int `form:"page"` + Size int `form:"size"` + Search string `form:"search,omitempty"` + UserId *int64 `form:"user_id,omitempty"` + Unscoped bool `form:"unscoped,omitempty"` + SubscribeId *int64 `form:"subscribe_id,omitempty"` + UserSubscribeId *int64 `form:"user_subscribe_id,omitempty"` + ShortCode string `form:"short_code,omitempty"` + FamilyJoined *bool `form:"family_joined,omitempty"` + FamilyStatus string `form:"family_status,omitempty"` FamilyOwnerUserId *int64 `form:"family_owner_user_id,omitempty"` - FamilyId *int64 `form:"family_id,omitempty"` + FamilyId *int64 `form:"family_id,omitempty"` } // GetUserListResponse GetUserListResponse { @@ -366,3 +366,4 @@ service ppanel { @handler DissolveFamily put /family/dissolve (DissolveFamilyRequest) } + diff --git a/apis/common.api b/apis/common.api index db935f4..e6d9414 100644 --- a/apis/common.api +++ b/apis/common.api @@ -24,6 +24,7 @@ type ( Invite InviteConfig `json:"invite"` Currency Currency `json:"currency"` Subscribe SubscribeConfig `json:"subscribe"` + Signature SignatureConfig `json:"signature"` VerifyCode PubilcVerifyCodeConfig `json:"verify_code"` OAuthMethods []string `json:"oauth_methods"` WebAd bool `json:"web_ad"` diff --git a/apis/public/redemption.api b/apis/public/redemption.api index 8bbb3ae..787fa35 100644 --- a/apis/public/redemption.api +++ b/apis/public/redemption.api @@ -30,3 +30,4 @@ service ppanel { @handler RedeemCode post / (RedeemCodeRequest) returns (RedeemCodeResponse) } + diff --git a/apis/public/subscribe.api b/apis/public/subscribe.api index 6c51122..445379b 100644 --- a/apis/public/subscribe.api +++ b/apis/public/subscribe.api @@ -14,11 +14,9 @@ type ( QuerySubscribeListRequest { Language string `form:"language"` } - QueryUserSubscribeNodeListResponse { List []UserSubscribeInfo `json:"list"` } - UserSubscribeInfo { Id int64 `json:"id"` UserId int64 `json:"user_id"` @@ -41,7 +39,6 @@ type ( IsTryOut bool `json:"is_try_out"` Nodes []*UserSubscribeNodeInfo `json:"nodes"` } - UserSubscribeNodeInfo { Id int64 `json:"id"` Name string `json:"name"` @@ -75,3 +72,4 @@ service ppanel { @handler QueryUserSubscribeNodeList get /node/list returns (QueryUserSubscribeNodeListResponse) } + diff --git a/apis/public/user.api b/apis/public/user.api index 820a160..a6eb50f 100644 --- a/apis/public/user.api +++ b/apis/public/user.api @@ -66,7 +66,6 @@ type ( UnbindOAuthRequest { Method string `json:"method"` } - GetLoginLogRequest { Page int `form:"page"` Size int `form:"size"` @@ -95,21 +94,17 @@ type ( Email string `json:"email" validate:"required"` Code string `json:"code" validate:"required"` } - - GetDeviceListResponse { - List []UserDevice `json:"list"` - Total int64 `json:"total"` - } - - UnbindDeviceRequest { - Id int64 `json:"id" validate:"required"` - } - + GetDeviceListResponse { + List []UserDevice `json:"list"` + Total int64 `json:"total"` + } + UnbindDeviceRequest { + Id int64 `json:"id" validate:"required"` + } UpdateUserSubscribeNoteRequest { UserSubscribeId int64 `json:"user_subscribe_id" validate:"required"` Note string `json:"note" validate:"max=500"` } - UpdateUserRulesRequest { Rules []string `json:"rules" validate:"required"` } @@ -135,23 +130,20 @@ type ( List []WithdrawalLog `json:"list"` Total int64 `json:"total"` } - - - GetDeviceOnlineStatsResponse { - WeeklyStats []WeeklyStat `json:"weekly_stats"` - ConnectionRecords ConnectionRecords `json:"connection_records"` - } - - WeeklyStat { - Day int `json:"day"` - DayName string `json:"day_name"` - Hours float64 `json:"hours"` - } - ConnectionRecords { - CurrentContinuousDays int64 `json:"current_continuous_days"` - HistoryContinuousDays int64 `json:"history_continuous_days"` - LongestSingleConnection int64 `json:"longest_single_connection"` - } + GetDeviceOnlineStatsResponse { + WeeklyStats []WeeklyStat `json:"weekly_stats"` + ConnectionRecords ConnectionRecords `json:"connection_records"` + } + WeeklyStat { + Day int `json:"day"` + DayName string `json:"day_name"` + Hours float64 `json:"hours"` + } + ConnectionRecords { + CurrentContinuousDays int64 `json:"current_continuous_days"` + HistoryContinuousDays int64 `json:"history_continuous_days"` + LongestSingleConnection int64 `json:"longest_single_connection"` + } ) @server ( @@ -248,9 +240,9 @@ service ppanel { @handler UpdateBindEmail put /bind_email (UpdateBindEmailRequest) - @doc "Get Device List" - @handler GetDeviceList - get /devices returns (GetDeviceListResponse) + @doc "Get Device List" + @handler GetDeviceList + get /devices returns (GetDeviceListResponse) @doc "Unbind Device" @handler UnbindDevice @@ -272,23 +264,23 @@ service ppanel { @handler QueryWithdrawalLog get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse) - @doc "Device Online Statistics" - @handler DeviceOnlineStatistics - get /device_online_statistics returns (GetDeviceOnlineStatsResponse) - - @doc "Delete Current User Account" - @handler DeleteCurrentUserAccount - delete /current_user_account + @doc "Device Online Statistics" + @handler DeviceOnlineStatistics + get /device_online_statistics returns (GetDeviceOnlineStatsResponse) + @doc "Delete Current User Account" + @handler DeleteCurrentUserAccount + delete /current_user_account } -@server( - prefix: v1/public/user - group: public/user/ws - middleware: AuthMiddleware + +@server ( + prefix: v1/public/user + group: public/user/ws + middleware: AuthMiddleware ) - service ppanel { - @doc "Webosocket Device Connect" - @handler DeviceWsConnect - get /device_ws_connect + @doc "Webosocket Device Connect" + @handler DeviceWsConnect + get /device_ws_connect } + diff --git a/apis/types.api b/apis/types.api index 8f60427..b58c62f 100644 --- a/apis/types.api +++ b/apis/types.api @@ -84,6 +84,9 @@ type ( VerifyCodeLimit int64 `json:"verify_code_limit"` VerifyCodeInterval int64 `json:"verify_code_interval"` } + SignatureConfig { + EnableSignature bool `json:"enable_signature"` + } PubilcVerifyCodeConfig { VerifyCodeInterval int64 `json:"verify_code_interval"` } @@ -217,7 +220,7 @@ type ( CurrencySymbol string `json:"currency_symbol"` } SubscribeDiscount { - Quantity int64 `json:"quantity"` + Quantity int64 `json:"quantity"` Discount float64 `json:"discount"` } Subscribe { @@ -493,26 +496,26 @@ type ( UpdatedAt int64 `json:"updated_at"` } UserSubscribe { - Id int64 `json:"id"` - UserId int64 `json:"user_id"` - OrderId int64 `json:"order_id"` - SubscribeId int64 `json:"subscribe_id"` - Subscribe Subscribe `json:"subscribe"` - StartTime int64 `json:"start_time"` - ExpireTime int64 `json:"expire_time"` - FinishedAt int64 `json:"finished_at"` - ResetTime int64 `json:"reset_time"` - Traffic int64 `json:"traffic"` - Download int64 `json:"download"` - Upload int64 `json:"upload"` - Token string `json:"token"` - Status uint8 `json:"status"` + Id int64 `json:"id"` + UserId int64 `json:"user_id"` + OrderId int64 `json:"order_id"` + SubscribeId int64 `json:"subscribe_id"` + Subscribe Subscribe `json:"subscribe"` + StartTime int64 `json:"start_time"` + ExpireTime int64 `json:"expire_time"` + FinishedAt int64 `json:"finished_at"` + ResetTime int64 `json:"reset_time"` + Traffic int64 `json:"traffic"` + Download int64 `json:"download"` + Upload int64 `json:"upload"` + Token string `json:"token"` + Status uint8 `json:"status"` EntitlementSource string `json:"entitlement_source"` EntitlementOwnerUserId int64 `json:"entitlement_owner_user_id"` ReadOnly bool `json:"read_only"` - Short string `json:"short"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` + Short string `json:"short"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` } UserAffiliate { Avatar string `json:"avatar"` @@ -555,15 +558,15 @@ type ( UpdatedAt int64 `json:"updated_at"` } FamilyMemberItem { - UserId int64 `json:"user_id"` - Identifier string `json:"identifier"` - Role uint8 `json:"role"` - RoleName string `json:"role_name"` - Status uint8 `json:"status"` - StatusName string `json:"status_name"` - JoinSource string `json:"join_source"` - JoinedAt int64 `json:"joined_at"` - LeftAt int64 `json:"left_at,omitempty"` + UserId int64 `json:"user_id"` + Identifier string `json:"identifier"` + Role uint8 `json:"role"` + RoleName string `json:"role_name"` + Status uint8 `json:"status"` + StatusName string `json:"status_name"` + JoinSource string `json:"join_source"` + JoinedAt int64 `json:"joined_at"` + LeftAt int64 `json:"left_at,omitempty"` } FamilyDetail { Summary FamilySummary `json:"summary"` @@ -737,7 +740,6 @@ type ( List []SubscribeGroup `json:"list"` Total int64 `json:"total"` } - GetUserSubscribeTrafficLogsRequest { Page int `form:"page"` Size int `form:"size"` @@ -915,3 +917,4 @@ type ( UserSubscribeId int64 `json:"user_subscribe_id"` } ) + diff --git a/etc/ppanel.yaml b/etc/ppanel.yaml index f2b0b08..c91d70e 100644 --- a/etc/ppanel.yaml +++ b/etc/ppanel.yaml @@ -40,6 +40,21 @@ Redis: ReadTimeout: 3 # 读操作超时时间(秒),建议2-3秒 WriteTimeout: 3 # 写操作超时时间(秒),建议2-3秒 +AppSignature: + AppSecrets: + android-client: uB4G,XxL2{7b # Android 客户端签名密钥 + ios-client: uB4G,XxL2{7b # iOS 客户端签名密钥 + web-client: uB4G,XxL2{7b # Web 客户端签名密钥 + ValidWindowSeconds: 300 # 签名时间窗口(秒) + SkipPrefixes: + - /v1/notify/ # 支付回调不验签 + - /v1/iap/notifications # Apple IAP 回调不验签 + - /v1/telegram/webhook # Telegram 回调不验签 + - /v1/subscribe/config # 订阅导出不验签 + +Signature: + EnableSignature: false # 系统签名开关(实际运行会以数据库 system.signature.EnableSignature 为准) + Administrator: Email: admin@ppanel.dev # 后台登录邮箱,请修改 Password: CHANGE_ME_TO_STRONG_PASSWORD # 后台登录密码,请修改为强密码 diff --git a/initialize/init.go b/initialize/init.go index 2333b5e..3e43c31 100644 --- a/initialize/init.go +++ b/initialize/init.go @@ -13,6 +13,7 @@ func StartInitSystemConfig(svc *svc.ServiceContext) { Invite(svc) Verify(svc) Subscribe(svc) + Signature(svc) Register(svc) Mobile(svc) Currency(svc) diff --git a/initialize/migrate/database/02138_signature_config.down.sql b/initialize/migrate/database/02138_signature_config.down.sql new file mode 100644 index 0000000..a891dcd --- /dev/null +++ b/initialize/migrate/database/02138_signature_config.down.sql @@ -0,0 +1,4 @@ +DELETE +FROM `system` +WHERE `category` = 'signature' + AND `key` = 'EnableSignature'; diff --git a/initialize/migrate/database/02138_signature_config.up.sql b/initialize/migrate/database/02138_signature_config.up.sql new file mode 100644 index 0000000..6316fee --- /dev/null +++ b/initialize/migrate/database/02138_signature_config.up.sql @@ -0,0 +1,14 @@ +INSERT INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`) +SELECT 'signature', + 'EnableSignature', + 'false', + 'bool', + 'Enable signature verification for public APIs', + NOW(3), + NOW(3) +WHERE NOT EXISTS ( + SELECT 1 + FROM `system` + WHERE `category` = 'signature' + AND `key` = 'EnableSignature' +); diff --git a/initialize/signature.go b/initialize/signature.go new file mode 100644 index 0000000..6edddbb --- /dev/null +++ b/initialize/signature.go @@ -0,0 +1,22 @@ +package initialize + +import ( + "context" + + "github.com/perfect-panel/server/internal/config" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/tool" +) + +func Signature(svc *svc.ServiceContext) { + logger.Debug("Signature config initialization") + configs, err := svc.SystemModel.GetSignatureConfig(context.Background()) + if err != nil { + logger.Error("[Init Signature Config] Get Signature Config Error: ", logger.Field("error", err.Error())) + return + } + var signatureConfig config.Signature + tool.SystemConfigSliceReflectToStruct(configs, &signatureConfig) + svc.Config.Signature = signatureConfig +} diff --git a/internal/config/cacheKey.go b/internal/config/cacheKey.go index 0e500e1..ea53d5c 100644 --- a/internal/config/cacheKey.go +++ b/internal/config/cacheKey.go @@ -36,6 +36,9 @@ const TosConfigKey = "system:tos_config" // VerifyCodeConfigKey Verify Code Config Key const VerifyCodeConfigKey = "system:verify_code_config" +// SignatureConfigKey Signature Config Key +const SignatureConfigKey = "system:signature_config" + // SessionIdKey cache session key const SessionIdKey = "auth:session_id" diff --git a/internal/config/config.go b/internal/config/config.go index eb7cc06..9f41edb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,36 +5,39 @@ import ( "github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/orm" + "github.com/perfect-panel/server/pkg/signature" "github.com/perfect-panel/server/pkg/trace" ) type Config struct { - Model string `yaml:"Model" default:"prod"` - Host string `yaml:"Host" default:"0.0.0.0"` - Port int `yaml:"Port" default:"8080"` - Debug bool `yaml:"Debug" default:"false"` - TLS TLS `yaml:"TLS"` - JwtAuth JwtAuth `yaml:"JwtAuth"` - Logger logger.LogConf `yaml:"Logger"` - MySQL orm.Config `yaml:"MySQL"` - Redis RedisConfig `yaml:"Redis"` - Site SiteConfig `yaml:"Site"` - Node NodeConfig `yaml:"Node"` - Mobile MobileConfig `yaml:"Mobile"` - Email EmailConfig `yaml:"Email"` - Device DeviceConfig `yaml:"device"` - Verify Verify `yaml:"Verify"` - VerifyCode VerifyCode `yaml:"VerifyCode"` - Register RegisterConfig `yaml:"Register"` - Subscribe SubscribeConfig `yaml:"Subscribe"` - Invite InviteConfig `yaml:"Invite"` - Kutt KuttConfig `yaml:"Kutt"` - OpenInstall OpenInstallConfig `yaml:"OpenInstall"` - Loki LokiConfig `yaml:"Loki"` - Telegram Telegram `yaml:"Telegram"` - Log Log `yaml:"Log"` - Currency Currency `yaml:"Currency"` - Trace trace.Config `yaml:"Trace"` + Model string `yaml:"Model" default:"prod"` + Host string `yaml:"Host" default:"0.0.0.0"` + Port int `yaml:"Port" default:"8080"` + Debug bool `yaml:"Debug" default:"false"` + TLS TLS `yaml:"TLS"` + JwtAuth JwtAuth `yaml:"JwtAuth"` + Logger logger.LogConf `yaml:"Logger"` + MySQL orm.Config `yaml:"MySQL"` + Redis RedisConfig `yaml:"Redis"` + Site SiteConfig `yaml:"Site"` + Node NodeConfig `yaml:"Node"` + Mobile MobileConfig `yaml:"Mobile"` + Email EmailConfig `yaml:"Email"` + Device DeviceConfig `yaml:"device"` + AppSignature signature.SignatureConf `yaml:"AppSignature"` + Signature Signature `yaml:"Signature"` + Verify Verify `yaml:"Verify"` + VerifyCode VerifyCode `yaml:"VerifyCode"` + Register RegisterConfig `yaml:"Register"` + Subscribe SubscribeConfig `yaml:"Subscribe"` + Invite InviteConfig `yaml:"Invite"` + Kutt KuttConfig `yaml:"Kutt"` + OpenInstall OpenInstallConfig `yaml:"OpenInstall"` + Loki LokiConfig `yaml:"Loki"` + Telegram Telegram `yaml:"Telegram"` + Log Log `yaml:"Log"` + Currency Currency `yaml:"Currency"` + Trace trace.Config `yaml:"Trace"` Administrator struct { Email string `yaml:"Email" default:"admin@ppanel.dev"` Password string `yaml:"Password" default:"password"` @@ -122,6 +125,10 @@ type DeviceConfig struct { SecuritySecret string `yaml:"security_secret"` } +type Signature struct { + EnableSignature bool `yaml:"EnableSignature" default:"false"` +} + type SiteConfig struct { Host string `yaml:"Host" default:""` SiteName string `yaml:"SiteName" default:""` diff --git a/internal/handler/admin/system/getSignatureConfigHandler.go b/internal/handler/admin/system/getSignatureConfigHandler.go new file mode 100644 index 0000000..88f90fb --- /dev/null +++ b/internal/handler/admin/system/getSignatureConfigHandler.go @@ -0,0 +1,18 @@ +package system + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/system" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/result" +) + +// Get Signature Config +func GetSignatureConfigHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + + l := system.NewGetSignatureConfigLogic(c.Request.Context(), svcCtx) + resp, err := l.GetSignatureConfig() + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/admin/system/updateSignatureConfigHandler.go b/internal/handler/admin/system/updateSignatureConfigHandler.go new file mode 100644 index 0000000..496c195 --- /dev/null +++ b/internal/handler/admin/system/updateSignatureConfigHandler.go @@ -0,0 +1,26 @@ +package system + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/system" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Update Signature Config +func UpdateSignatureConfigHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.SignatureConfig + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := system.NewUpdateSignatureConfigLogic(c.Request.Context(), svcCtx) + err := l.UpdateSignatureConfig(&req) + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 27b7248..47858a2 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -28,7 +28,6 @@ import ( common "github.com/perfect-panel/server/internal/handler/common" publicAnnouncement "github.com/perfect-panel/server/internal/handler/public/announcement" publicDocument "github.com/perfect-panel/server/internal/handler/public/document" - publicIapApple "github.com/perfect-panel/server/internal/handler/public/iap/apple" publicOrder "github.com/perfect-panel/server/internal/handler/public/order" publicPayment "github.com/perfect-panel/server/internal/handler/public/payment" publicPortal "github.com/perfect-panel/server/internal/handler/public/portal" @@ -237,12 +236,6 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Filter traffic log details adminLogGroupRouter.GET("/traffic/details", adminLog.FilterTrafficLogDetailsHandler(serverCtx)) - - // Error message log list - adminLogGroupRouter.GET("/message/error/list", adminLog.GetErrorLogMessageListHandler(serverCtx)) - - // Error message log detail - adminLogGroupRouter.GET("/message/error/detail", adminLog.GetErrorLogMessageDetailHandler(serverCtx)) } adminMarketingGroupRouter := router.Group("/v1/admin/marketing") @@ -471,6 +464,12 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // setting telegram bot adminSystemGroupRouter.POST("/setting_telegram_bot", adminSystem.SettingTelegramBotHandler(serverCtx)) + // Get Signature Config + adminSystemGroupRouter.GET("/signature_config", adminSystem.GetSignatureConfigHandler(serverCtx)) + + // Update Signature Config + adminSystemGroupRouter.PUT("/signature_config", adminSystem.UpdateSignatureConfigHandler(serverCtx)) + // Get site config adminSystemGroupRouter.GET("/site_config", adminSystem.GetSiteConfigHandler(serverCtx)) @@ -579,6 +578,21 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // kick offline user device adminUserGroupRouter.PUT("/device/kick_offline", adminUser.KickOfflineByUserDeviceHandler(serverCtx)) + // Get family detail + adminUserGroupRouter.GET("/family/detail", adminUser.GetFamilyDetailHandler(serverCtx)) + + // Dissolve family + adminUserGroupRouter.PUT("/family/dissolve", adminUser.DissolveFamilyHandler(serverCtx)) + + // Get family list + adminUserGroupRouter.GET("/family/list", adminUser.GetFamilyListHandler(serverCtx)) + + // Update family max members + adminUserGroupRouter.PUT("/family/max_members", adminUser.UpdateFamilyMaxMembersHandler(serverCtx)) + + // Remove family member + adminUserGroupRouter.PUT("/family/member/remove", adminUser.RemoveFamilyMemberHandler(serverCtx)) + // Get user list adminUserGroupRouter.GET("/list", adminUser.GetUserListHandler(serverCtx)) @@ -623,21 +637,6 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Get user subcribe traffic logs adminUserGroupRouter.GET("/subscribe/traffic_logs", adminUser.GetUserSubscribeTrafficLogsHandler(serverCtx)) - - // Get family list - adminUserGroupRouter.GET("/family/list", adminUser.GetFamilyListHandler(serverCtx)) - - // Get family detail - adminUserGroupRouter.GET("/family/detail", adminUser.GetFamilyDetailHandler(serverCtx)) - - // Update family max members - adminUserGroupRouter.PUT("/family/max_members", adminUser.UpdateFamilyMaxMembersHandler(serverCtx)) - - // Remove family member - adminUserGroupRouter.PUT("/family/member/remove", adminUser.RemoveFamilyMemberHandler(serverCtx)) - - // Dissolve family - adminUserGroupRouter.PUT("/family/dissolve", adminUser.DissolveFamilyHandler(serverCtx)) } authGroupRouter := router.Group("/v1/auth") @@ -650,18 +649,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Check user telephone is exist authGroupRouter.GET("/check/telephone", auth.CheckUserTelephoneHandler(serverCtx)) - // Check legacy verification code - authGroupRouter.POST("/check-code", middleware.ApiVersionSwitchHandler( - auth.CheckCodeLegacyV1Handler(serverCtx), - auth.CheckCodeLegacyV2Handler(serverCtx), - )) - // User login authGroupRouter.POST("/login", auth.UserLoginHandler(serverCtx)) - // Email login - authGroupRouter.POST("/login/email", auth.EmailLoginHandler(serverCtx)) - // Device Login authGroupRouter.POST("/login/device", auth.DeviceLoginHandler(serverCtx)) @@ -702,17 +692,11 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { commonGroupRouter.GET("/ads", common.GetAdsHandler(serverCtx)) // Check verification code - commonGroupRouter.POST("/check_verification_code", middleware.ApiVersionSwitchHandler( - common.CheckVerificationCodeV1Handler(serverCtx), - common.CheckVerificationCodeV2Handler(serverCtx), - )) + commonGroupRouter.POST("/check_verification_code", common.CheckVerificationCodeHandler(serverCtx)) // Get Client commonGroupRouter.GET("/client", common.GetClientHandler(serverCtx)) - // Get Download Link - commonGroupRouter.GET("/client/download", common.GetDownloadLinkHandler(serverCtx)) - // Heartbeat commonGroupRouter.GET("/heartbeat", common.HeartbeatHandler(serverCtx)) @@ -733,12 +717,6 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Get Tos Content commonGroupRouter.GET("/site/tos", common.GetTosHandler(serverCtx)) - - // Submit contact info - commonGroupRouter.POST("/contact", common.SubmitContactHandler(serverCtx)) - - // Report client error log - commonGroupRouter.POST("/log/message/report", common.ReportLogMessageHandler(serverCtx)) } publicAnnouncementGroupRouter := router.Group("/v1/public/announcement") @@ -828,15 +806,6 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { publicRedemptionGroupRouter.POST("/", publicRedemption.RedeemCodeHandler(serverCtx)) } - iapAppleGroupRouter := router.Group("/v1/public/iap/apple") - iapAppleGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx)) - { - iapAppleGroupRouter.GET("/status", publicIapApple.GetAppleStatusHandler(serverCtx)) - iapAppleGroupRouter.POST("/transactions/attach", publicIapApple.AttachAppleTransactionHandler(serverCtx)) - iapAppleGroupRouter.POST("/transactions/attach_by_id", publicIapApple.AttachAppleTransactionByIdHandler(serverCtx)) - iapAppleGroupRouter.POST("/restore", publicIapApple.RestoreAppleTransactionsHandler(serverCtx)) - } - publicSubscribeGroupRouter := router.Group("/v1/public/subscribe") publicSubscribeGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx)) @@ -961,30 +930,6 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Query Withdrawal Log publicUserGroupRouter.GET("/withdrawal_log", publicUser.QueryWithdrawalLogHandler(serverCtx)) - - // Bind Email With Verification - publicUserGroupRouter.POST("/bind_email_with_verification", publicUser.BindEmailWithVerificationHandler(serverCtx)) - - // Bind Invite Code - publicUserGroupRouter.POST("/bind_invite_code", publicUser.BindInviteCodeHandler(serverCtx)) - - // Get Subscribe Status - publicUserGroupRouter.POST("/subscribe_status", publicUser.GetSubscribeStatusHandler(serverCtx)) - - // Delete Account - publicUserGroupRouter.POST("/delete_account", publicUser.DeleteAccountHandler(serverCtx)) - - // Get agent realtime data - publicUserGroupRouter.GET("/agent/realtime", publicUser.GetAgentRealtimeHandler(serverCtx)) - - // Get agent downloads data - publicUserGroupRouter.GET("/agent/downloads", publicUser.GetAgentDownloadsHandler(serverCtx)) - - // Get user invite statistics - publicUserGroupRouter.GET("/invite/stats", publicUser.GetUserInviteStatsHandler(serverCtx)) - - // Get invite sales data - publicUserGroupRouter.GET("/invite/sales", publicUser.GetInviteSalesHandler(serverCtx)) } publicUserWsGroupRouter := router.Group("/v1/public/user") @@ -1015,10 +960,10 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { serverGroupRouter.GET("/user", server.GetServerUserListHandler(serverCtx)) } - serverV2GroupRouter := router.Group("/v2/server") + serverGroupRouter := router.Group("/v2/server") { // Get Server Protocol Config - serverV2GroupRouter.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx)) + serverGroupRouter.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx)) } } diff --git a/internal/logic/admin/system/getSignatureConfigLogic.go b/internal/logic/admin/system/getSignatureConfigLogic.go new file mode 100644 index 0000000..e7ae15e --- /dev/null +++ b/internal/logic/admin/system/getSignatureConfigLogic.go @@ -0,0 +1,38 @@ +package system + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/tool" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type GetSignatureConfigLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Get Signature Config +func NewGetSignatureConfigLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetSignatureConfigLogic { + return &GetSignatureConfigLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetSignatureConfigLogic) GetSignatureConfig() (resp *types.SignatureConfig, err error) { + resp = &types.SignatureConfig{} + configs, err := l.svcCtx.SystemModel.GetSignatureConfig(l.ctx) + if err != nil { + l.Errorw("[GetSignatureConfig] Database query error", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get signature config error: %v", err.Error()) + } + tool.SystemConfigSliceReflectToStruct(configs, resp) + return resp, nil +} diff --git a/internal/logic/admin/system/updateSignatureConfigLogic.go b/internal/logic/admin/system/updateSignatureConfigLogic.go new file mode 100644 index 0000000..e53ef6f --- /dev/null +++ b/internal/logic/admin/system/updateSignatureConfigLogic.go @@ -0,0 +1,53 @@ +package system + +import ( + "context" + "reflect" + + "github.com/perfect-panel/server/initialize" + "github.com/perfect-panel/server/internal/config" + "github.com/perfect-panel/server/internal/model/system" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/tool" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +type UpdateSignatureConfigLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Update Signature Config +func NewUpdateSignatureConfigLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateSignatureConfigLogic { + return &UpdateSignatureConfigLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *UpdateSignatureConfigLogic) UpdateSignatureConfig(req *types.SignatureConfig) error { + v := reflect.ValueOf(*req) + t := v.Type() + err := l.svcCtx.SystemModel.Transaction(l.ctx, func(db *gorm.DB) error { + for i := 0; i < v.NumField(); i++ { + fieldName := t.Field(i).Name + fieldValue := tool.ConvertValueToString(v.Field(i)) + if err := db.Model(&system.System{}).Where("`category` = 'signature' and `key` = ?", fieldName).Update("value", fieldValue).Error; err != nil { + return err + } + } + return l.svcCtx.Redis.Del(l.ctx, config.SignatureConfigKey, config.GlobalConfigKey).Err() + }) + if err != nil { + l.Errorw("[UpdateSignatureConfig] update signature config error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update signature config error: %v", err.Error()) + } + initialize.Signature(l.svcCtx) + return nil +} diff --git a/internal/logic/common/getGlobalConfigLogic.go b/internal/logic/common/getGlobalConfigLogic.go index 393371b..502e098 100644 --- a/internal/logic/common/getGlobalConfigLogic.go +++ b/internal/logic/common/getGlobalConfigLogic.go @@ -47,6 +47,7 @@ func (l *GetGlobalConfigLogic) GetGlobalConfig() (resp *types.GetGlobalConfigRes tool.DeepCopy(&resp.Auth.Email, l.svcCtx.Config.Email) tool.DeepCopy(&resp.Auth.Mobile, l.svcCtx.Config.Mobile) tool.DeepCopy(&resp.Auth.Register, l.svcCtx.Config.Register) + tool.DeepCopy(&resp.Signature, l.svcCtx.Config.Signature) tool.DeepCopy(&resp.Verify, l.svcCtx.Config.Verify) tool.DeepCopy(&resp.Invite, l.svcCtx.Config.Invite) tool.SystemConfigSliceReflectToStruct(currencyCfg, &resp.Currency) diff --git a/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go b/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go index af88aa0..2530ae7 100644 --- a/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go +++ b/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go @@ -113,16 +113,17 @@ func (l *QueryUserSubscribeNodeListLogic) getServers(userSub *user.Subscribe) (u return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find subscribe details error: %v", err.Error()) } nodeIds := tool.StringToInt64Slice(subDetails.Nodes) - tags := strings.Split(subDetails.NodeTags, ",") + tags := normalizeSubscribeNodeTags(subDetails.NodeTags) l.Debugf("[Generate Subscribe]nodes: %v, NodeTags: %v", nodeIds, tags) enable := true _, nodes, err := l.svcCtx.NodeModel.FilterNodeList(l.ctx, &node.FilterNodeParams{ - Page: 0, + Page: 1, Size: 1000, NodeId: nodeIds, + Tag: tags, Enabled: &enable, // Only get enabled nodes }) @@ -213,3 +214,21 @@ func (l *QueryUserSubscribeNodeListLogic) getUserSubscribe(token string) (*user. return userSub, nil } + +func normalizeSubscribeNodeTags(raw string) []string { + if raw == "" { + return nil + } + + parts := strings.Split(raw, ",") + cleaned := make([]string, 0, len(parts)) + for _, tag := range parts { + trimmed := strings.TrimSpace(tag) + if trimmed == "" { + continue + } + cleaned = append(cleaned, trimmed) + } + + return tool.RemoveDuplicateElements(cleaned...) +} diff --git a/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic_test.go b/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic_test.go index b2cf915..776fcf7 100644 --- a/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic_test.go +++ b/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic_test.go @@ -23,3 +23,11 @@ func TestFillUserSubscribeInfoEntitlementFields(t *testing.T) { require.Equal(t, int64(3001), sub.EntitlementOwnerUserId) require.True(t, sub.ReadOnly) } + +func TestNormalizeSubscribeNodeTags(t *testing.T) { + tags := normalizeSubscribeNodeTags("美国, 日本, , 美国, ,日本") + require.Equal(t, []string{"美国", "日本"}, tags) + + empty := normalizeSubscribeNodeTags("") + require.Nil(t, empty) +} diff --git a/internal/middleware/corsMiddleware.go b/internal/middleware/corsMiddleware.go index 51c92b0..56648ce 100644 --- a/internal/middleware/corsMiddleware.go +++ b/internal/middleware/corsMiddleware.go @@ -15,7 +15,10 @@ func CorsMiddleware(c *gin.Context) { } // c.Writer.Header().Set("Access-Control-Allow-Origin", c.Request.Host) c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE, UPDATE") - c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Origin, X-CSRF-Token, Authorization, AccessToken, Token, Range, api-header") + c.Writer.Header().Set( + "Access-Control-Allow-Headers", + "Content-Type, Origin, X-CSRF-Token, Authorization, AccessToken, Token, Range, api-header, X-Signature-Enabled, X-App-Id, X-Timestamp, X-Nonce, X-Signature", + ) c.Writer.Header().Set("Access-Control-Expose-Headers", "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers") c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") c.Writer.Header().Set("Access-Control-Max-Age", "172800") diff --git a/internal/middleware/signatureMiddleware.go b/internal/middleware/signatureMiddleware.go new file mode 100644 index 0000000..36afff5 --- /dev/null +++ b/internal/middleware/signatureMiddleware.go @@ -0,0 +1,137 @@ +package middleware + +import ( + "bytes" + "io" + "strings" + + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/result" + "github.com/perfect-panel/server/pkg/signature" + "github.com/perfect-panel/server/pkg/xerr" +) + +var ( + publicSignaturePrefixes = []string{ + "/v1/auth", + "/v1/auth/oauth", + "/v1/common", + "/v1/public", + } + defaultSignatureSkipPrefixes = []string{ + "/v1/notify/", + "/v1/iap/notifications", + "/v1/telegram/webhook", + "/v1/subscribe/config", + } +) + +func SignatureMiddleware(srvCtx *svc.ServiceContext) func(c *gin.Context) { + skipPrefixes := collectSignatureSkipPrefixes(srvCtx) + return func(c *gin.Context) { + path := c.Request.URL.Path + if !isPublicSignaturePath(path) { + c.Next() + return + } + + for _, prefix := range skipPrefixes { + if strings.HasPrefix(path, prefix) { + c.Next() + return + } + } + if !srvCtx.Config.Signature.EnableSignature { + c.Next() + return + } + if c.GetHeader("X-Signature-Enabled") != "1" { + c.Next() + return + } + + appId := c.GetHeader("X-App-Id") + if appId == "" { + result.HttpResult(c, nil, xerr.NewErrCode(xerr.InvalidAccess)) + c.Abort() + return + } + + timestamp := c.GetHeader("X-Timestamp") + nonce := c.GetHeader("X-Nonce") + sig := c.GetHeader("X-Signature") + if timestamp == "" || nonce == "" || sig == "" { + result.HttpResult(c, nil, xerr.NewErrCode(xerr.SignatureMissing)) + c.Abort() + return + } + + var bodyBytes []byte + if c.Request.Body != nil { + bodyBytes, _ = io.ReadAll(c.Request.Body) + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + + sts := signature.BuildStringToSign( + c.Request.Method, + path, + c.Request.URL.RawQuery, + bodyBytes, + appId, + timestamp, + nonce, + ) + if err := srvCtx.SignatureValidator.Validate(c.Request.Context(), appId, timestamp, nonce, sig, sts); err != nil { + result.HttpResult(c, nil, xerr.NewErrCode(mapSignatureErr(err))) + c.Abort() + return + } + + c.Next() + } +} + +func isPublicSignaturePath(path string) bool { + for _, prefix := range publicSignaturePrefixes { + if strings.HasPrefix(path, prefix) { + return true + } + } + return false +} + +func collectSignatureSkipPrefixes(srvCtx *svc.ServiceContext) []string { + prefixSet := make(map[string]struct{}, len(defaultSignatureSkipPrefixes)+len(srvCtx.Config.AppSignature.SkipPrefixes)+1) + for _, prefix := range defaultSignatureSkipPrefixes { + prefixSet[prefix] = struct{}{} + } + for _, prefix := range srvCtx.Config.AppSignature.SkipPrefixes { + if strings.TrimSpace(prefix) == "" { + continue + } + prefixSet[prefix] = struct{}{} + } + if path := strings.TrimSpace(srvCtx.Config.Subscribe.SubscribePath); path != "" { + prefixSet[path] = struct{}{} + } + + prefixes := make([]string, 0, len(prefixSet)) + for prefix := range prefixSet { + prefixes = append(prefixes, prefix) + } + return prefixes +} + +func mapSignatureErr(err error) uint32 { + switch err { + case signature.ErrSignatureMissing: + return xerr.SignatureMissing + case signature.ErrSignatureExpired: + return xerr.SignatureExpired + case signature.ErrSignatureReplay: + return xerr.SignatureReplay + default: + return xerr.SignatureInvalid + } +} diff --git a/internal/middleware/signatureMiddleware_test.go b/internal/middleware/signatureMiddleware_test.go new file mode 100644 index 0000000..8ea1919 --- /dev/null +++ b/internal/middleware/signatureMiddleware_test.go @@ -0,0 +1,239 @@ +package middleware + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/config" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/signature" + "github.com/perfect-panel/server/pkg/xerr" +) + +type testNonceStore struct { + seen map[string]bool +} + +func newTestNonceStore() *testNonceStore { + return &testNonceStore{seen: map[string]bool{}} +} + +func (s *testNonceStore) SetIfNotExists(_ context.Context, appId, nonce string, _ int64) (bool, error) { + key := appId + ":" + nonce + if s.seen[key] { + return true, nil + } + s.seen[key] = true + return false, nil +} + +func makeTestSignature(secret, sts string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(sts)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func newTestServiceContext() *svc.ServiceContext { + conf := config.Config{} + conf.Signature.EnableSignature = true + conf.AppSignature = signature.SignatureConf{ + AppSecrets: map[string]string{ + "web-client": "test-secret", + }, + ValidWindowSeconds: 300, + SkipPrefixes: []string{ + "/v1/public/health", + }, + } + return &svc.ServiceContext{ + Config: conf, + SignatureValidator: signature.NewValidator(conf.AppSignature, newTestNonceStore()), + } +} + +func newTestServiceContextWithSwitch(enabled bool) *svc.ServiceContext { + svcCtx := newTestServiceContext() + svcCtx.Config.Signature.EnableSignature = enabled + return svcCtx +} + +func decodeCode(t *testing.T, body []byte) uint32 { + t.Helper() + var resp struct { + Code uint32 `json:"code"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("unmarshal response failed: %v", err) + } + return resp.Code +} + +func TestSignatureMiddlewareMissingAppID(t *testing.T) { + gin.SetMode(gin.TestMode) + svcCtx := newTestServiceContext() + r := gin.New() + r.Use(SignatureMiddleware(svcCtx)) + r.GET("/v1/public/ping", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + req := httptest.NewRequest(http.MethodGet, "/v1/public/ping", nil) + req.Header.Set("X-Signature-Enabled", "1") + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + + if code := decodeCode(t, resp.Body.Bytes()); code != xerr.InvalidAccess { + t.Fatalf("expected InvalidAccess(%d), got %d", xerr.InvalidAccess, code) + } +} + +func TestSignatureMiddlewareMissingSignatureHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + svcCtx := newTestServiceContext() + r := gin.New() + r.Use(SignatureMiddleware(svcCtx)) + r.GET("/v1/public/ping", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + req := httptest.NewRequest(http.MethodGet, "/v1/public/ping", nil) + req.Header.Set("X-Signature-Enabled", "1") + req.Header.Set("X-App-Id", "web-client") + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + + if code := decodeCode(t, resp.Body.Bytes()); code != xerr.SignatureMissing { + t.Fatalf("expected SignatureMissing(%d), got %d", xerr.SignatureMissing, code) + } +} + +func TestSignatureMiddlewarePassesWhenSignatureHeaderMissing(t *testing.T) { + gin.SetMode(gin.TestMode) + svcCtx := newTestServiceContext() + r := gin.New() + r.Use(SignatureMiddleware(svcCtx)) + r.GET("/v1/public/ping", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + req := httptest.NewRequest(http.MethodGet, "/v1/public/ping", nil) + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK || resp.Body.String() != "ok" { + t.Fatalf("expected pass-through without X-Signature-Enabled, got code=%d body=%s", resp.Code, resp.Body.String()) + } +} + +func TestSignatureMiddlewarePassesWhenSignatureHeaderIsZero(t *testing.T) { + gin.SetMode(gin.TestMode) + svcCtx := newTestServiceContext() + r := gin.New() + r.Use(SignatureMiddleware(svcCtx)) + r.GET("/v1/public/ping", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + req := httptest.NewRequest(http.MethodGet, "/v1/public/ping", nil) + req.Header.Set("X-Signature-Enabled", "0") + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK || resp.Body.String() != "ok" { + t.Fatalf("expected pass-through when X-Signature-Enabled=0, got code=%d body=%s", resp.Code, resp.Body.String()) + } +} + +func TestSignatureMiddlewarePassesWhenSystemSwitchDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + svcCtx := newTestServiceContextWithSwitch(false) + r := gin.New() + r.Use(SignatureMiddleware(svcCtx)) + r.GET("/v1/public/ping", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + req := httptest.NewRequest(http.MethodGet, "/v1/public/ping", nil) + req.Header.Set("X-Signature-Enabled", "1") + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK || resp.Body.String() != "ok" { + t.Fatalf("expected pass-through when system switch is disabled, got code=%d body=%s", resp.Code, resp.Body.String()) + } +} + +func TestSignatureMiddlewareSkipsNonPublicPath(t *testing.T) { + gin.SetMode(gin.TestMode) + svcCtx := newTestServiceContext() + r := gin.New() + r.Use(SignatureMiddleware(svcCtx)) + r.GET("/v1/admin/ping", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + req := httptest.NewRequest(http.MethodGet, "/v1/admin/ping", nil) + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK || resp.Body.String() != "ok" { + t.Fatalf("expected pass-through for non-public path, got code=%d body=%s", resp.Code, resp.Body.String()) + } +} + +func TestSignatureMiddlewareHonorsSkipPrefix(t *testing.T) { + gin.SetMode(gin.TestMode) + svcCtx := newTestServiceContext() + r := gin.New() + r.Use(SignatureMiddleware(svcCtx)) + r.GET("/v1/public/healthz", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + req := httptest.NewRequest(http.MethodGet, "/v1/public/healthz", nil) + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK || resp.Body.String() != "ok" { + t.Fatalf("expected skip-prefix pass-through, got code=%d body=%s", resp.Code, resp.Body.String()) + } +} + +func TestSignatureMiddlewareRestoresBodyAfterVerify(t *testing.T) { + gin.SetMode(gin.TestMode) + svcCtx := newTestServiceContext() + r := gin.New() + r.Use(SignatureMiddleware(svcCtx)) + r.POST("/v1/public/body", func(c *gin.Context) { + body, _ := io.ReadAll(c.Request.Body) + c.String(http.StatusOK, string(body)) + }) + + body := `{"hello":"world"}` + req := httptest.NewRequest(http.MethodPost, "/v1/public/body?a=1&b=2", strings.NewReader(body)) + ts := strconv.FormatInt(time.Now().Unix(), 10) + nonce := "nonce-body-1" + sts := signature.BuildStringToSign(http.MethodPost, "/v1/public/body", "a=1&b=2", []byte(body), "web-client", ts, nonce) + req.Header.Set("X-Signature-Enabled", "1") + req.Header.Set("X-App-Id", "web-client") + req.Header.Set("X-Timestamp", ts) + req.Header.Set("X-Nonce", nonce) + req.Header.Set("X-Signature", makeTestSignature("test-secret", sts)) + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK || resp.Body.String() != body { + t.Fatalf("expected restored body, got code=%d body=%s", resp.Code, resp.Body.String()) + } +} diff --git a/internal/model/node/model.go b/internal/model/node/model.go index 4647ddb..bede293 100644 --- a/internal/model/node/model.go +++ b/internal/model/node/model.go @@ -202,27 +202,36 @@ func (m *customServerModel) ClearServerAllCache(ctx context.Context) error { // CountNodesByIdsAndTags 根据节点ID和标签计算启用的节点数量 func (m *customServerModel) CountNodesByIdsAndTags(ctx context.Context, nodeIds []int64, tags []string) (int64, error) { + tags = normalizeNodeTags(tags) + if len(nodeIds) == 0 && len(tags) == 0 { + return 0, nil + } + var count int64 query := m.WithContext(ctx).Model(&Node{}).Where("enabled = ?", true) - - if len(nodeIds) > 0 || len(tags) > 0 { - subQuery := m.WithContext(ctx).Model(&Node{}).Where("enabled = ?", true) - - if len(nodeIds) > 0 && len(tags) > 0 { - subQuery = subQuery.Where("id IN ? OR ?", nodeIds, InSet("tags", tags)) - } else if len(nodeIds) > 0 { - subQuery = subQuery.Where("id IN ?", nodeIds) - } else { - subQuery = subQuery.Scopes(InSet("tags", tags)) - } - - query = subQuery + if len(nodeIds) > 0 { + query = query.Where("id IN ?", nodeIds) + } + if len(tags) > 0 { + query = query.Scopes(InSet("tags", tags)) } err := query.Count(&count).Error return count, err } +func normalizeNodeTags(tags []string) []string { + cleaned := make([]string, 0, len(tags)) + for _, tag := range tags { + trimmed := strings.TrimSpace(tag) + if trimmed == "" { + continue + } + cleaned = append(cleaned, trimmed) + } + return tool.RemoveDuplicateElements(cleaned...) +} + // InSet 支持多值 OR 查询 func InSet(field string, values []string) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { diff --git a/internal/model/node/model_test.go b/internal/model/node/model_test.go new file mode 100644 index 0000000..7318b3b --- /dev/null +++ b/internal/model/node/model_test.go @@ -0,0 +1,12 @@ +package node + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeNodeTags(t *testing.T) { + tags := normalizeNodeTags([]string{"美国", " 日本 ", "", "美国", " ", "日本"}) + require.Equal(t, []string{"美国", "日本"}, tags) +} diff --git a/internal/model/subscribe/default.go b/internal/model/subscribe/default.go index 29e748c..a8d63bc 100644 --- a/internal/model/subscribe/default.go +++ b/internal/model/subscribe/default.go @@ -15,7 +15,8 @@ import ( var _ Model = (*customSubscribeModel)(nil) var ( - cacheSubscribeIdPrefix = "cache:subscribe:id:" + cacheSubscribeIdPrefix = "cache:subscribe:id:" + cacheUserSubscribeUserPrefix = "cache:user:subscribe:user:" ) type ( @@ -119,13 +120,31 @@ func (m *defaultSubscribeModel) Update(ctx context.Context, data *Subscribe, tx if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } + + var userIds []int64 + err = m.QueryNoCacheCtx(ctx, &userIds, func(conn *gorm.DB, v interface{}) error { + return conn.Table("user_subscribe"). + Where("subscribe_id = ? AND status IN (0, 1)", data.Id). + Distinct("user_id"). + Pluck("user_id", &userIds).Error + }) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + userSubscribeCacheKeys := make([]string, 0, len(userIds)) + for _, userId := range userIds { + userSubscribeCacheKeys = append(userSubscribeCacheKeys, fmt.Sprintf("%s%d", cacheUserSubscribeUserPrefix, userId)) + } + allCacheKeys := append(m.getCacheKeys(old), userSubscribeCacheKeys...) + err = m.ExecCtx(ctx, func(conn *gorm.DB) error { db := conn if len(tx) > 0 { db = tx[0] } return db.Save(data).Error - }, m.getCacheKeys(old)...) + }, allCacheKeys...) return err } @@ -137,13 +156,31 @@ func (m *defaultSubscribeModel) Delete(ctx context.Context, id int64, tx ...*gor } return err } + + var userIds []int64 + err = m.QueryNoCacheCtx(ctx, &userIds, func(conn *gorm.DB, v interface{}) error { + return conn.Table("user_subscribe"). + Where("subscribe_id = ? AND status IN (0, 1)", id). + Distinct("user_id"). + Pluck("user_id", &userIds).Error + }) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + userSubscribeCacheKeys := make([]string, 0, len(userIds)) + for _, userId := range userIds { + userSubscribeCacheKeys = append(userSubscribeCacheKeys, fmt.Sprintf("%s%d", cacheUserSubscribeUserPrefix, userId)) + } + allCacheKeys := append(m.getCacheKeys(data), userSubscribeCacheKeys...) + err = m.ExecCtx(ctx, func(conn *gorm.DB) error { db := conn if len(tx) > 0 { db = tx[0] } return db.Delete(&Subscribe{}, id).Error - }, m.getCacheKeys(data)...) + }, allCacheKeys...) return err } diff --git a/internal/model/system/model.go b/internal/model/system/model.go index a7c28e1..e392ebb 100644 --- a/internal/model/system/model.go +++ b/internal/model/system/model.go @@ -19,6 +19,7 @@ type customSystemLogicModel interface { GetTosConfig(ctx context.Context) ([]*System, error) GetCurrencyConfig(ctx context.Context) ([]*System, error) GetVerifyCodeConfig(ctx context.Context) ([]*System, error) + GetSignatureConfig(ctx context.Context) ([]*System, error) GetLogConfig(ctx context.Context) ([]*System, error) UpdateNodeMultiplierConfig(ctx context.Context, config string) error FindNodeMultiplierConfig(ctx context.Context) (*System, error) @@ -154,6 +155,15 @@ func (m *customSystemModel) GetVerifyCodeConfig(ctx context.Context) ([]*System, return configs, err } +// GetSignatureConfig returns the signature config. +func (m *customSystemModel) GetSignatureConfig(ctx context.Context) ([]*System, error) { + var configs []*System + err := m.QueryCtx(ctx, &configs, config.SignatureConfigKey, func(conn *gorm.DB, v interface{}) error { + return conn.Where("`category` = ?", "signature").Find(v).Error + }) + return configs, err +} + // GetLogConfig returns the log config. func (m *customSystemModel) GetLogConfig(ctx context.Context) ([]*System, error) { var configs []*System diff --git a/internal/server.go b/internal/server.go index 98db20a..36e5542 100644 --- a/internal/server.go +++ b/internal/server.go @@ -50,7 +50,14 @@ func initServer(svc *svc.ServiceContext) *gin.Engine { } r.Use(sessions.Sessions("ppanel", sessionStore)) // use cors middleware - r.Use(middleware.TraceMiddleware(svc), middleware.ApiVersionMiddleware(svc), middleware.LoggerMiddleware(svc), middleware.CorsMiddleware, gin.Recovery()) + r.Use( + middleware.TraceMiddleware(svc), + middleware.ApiVersionMiddleware(svc), + middleware.LoggerMiddleware(svc), + middleware.SignatureMiddleware(svc), + middleware.CorsMiddleware, + gin.Recovery(), + ) // register handlers handler.RegisterHandlers(r, svc) diff --git a/internal/svc/serviceContext.go b/internal/svc/serviceContext.go index df4356b..b358f31 100644 --- a/internal/svc/serviceContext.go +++ b/internal/svc/serviceContext.go @@ -28,6 +28,7 @@ import ( "github.com/perfect-panel/server/pkg/limit" "github.com/perfect-panel/server/pkg/nodeMultiplier" "github.com/perfect-panel/server/pkg/orm" + "github.com/perfect-panel/server/pkg/signature" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "github.com/hibiken/asynq" @@ -36,33 +37,34 @@ import ( ) type ServiceContext struct { - DB *gorm.DB - Redis *redis.Client - Config config.Config - Queue *asynq.Client - ExchangeRate float64 - GeoIP *IPLocation + DB *gorm.DB + Redis *redis.Client + Config config.Config + Queue *asynq.Client + ExchangeRate float64 + GeoIP *IPLocation + SignatureValidator *signature.Validator //NodeCache *cache.NodeCacheClient - AuthModel auth.Model - AdsModel ads.Model - LogModel log.Model + AuthModel auth.Model + AdsModel ads.Model + LogModel log.Model LogMessageModel logmessage.Model - NodeModel node.Model - UserModel user.Model - OrderModel order.Model - ClientModel client.Model - TicketModel ticket.Model + NodeModel node.Model + UserModel user.Model + OrderModel order.Model + ClientModel client.Model + TicketModel ticket.Model //ServerModel server.Model - SystemModel system.Model - CouponModel coupon.Model - RedemptionCodeModel redemption.RedemptionCodeModel - RedemptionRecordModel redemption.RedemptionRecordModel - PaymentModel payment.Model - DocumentModel document.Model - SubscribeModel subscribe.Model - TrafficLogModel traffic.Model - AnnouncementModel announcement.Model + SystemModel system.Model + CouponModel coupon.Model + RedemptionCodeModel redemption.RedemptionCodeModel + RedemptionRecordModel redemption.RedemptionRecordModel + PaymentModel payment.Model + DocumentModel document.Model + SubscribeModel subscribe.Model + TrafficLogModel traffic.Model + AnnouncementModel announcement.Model IAPAppleTransactionModel iapapple.Model Restart func() error @@ -109,24 +111,27 @@ func NewServiceContext(c config.Config) *ServiceContext { _ = rds.FlushAll(context.Background()).Err() } authLimiter := limit.NewPeriodLimit(86400, 15, rds, config.SendCountLimitKeyPrefix, limit.Align()) + nonceStore := signature.NewRedisNonceStore(rds) + srv := &ServiceContext{ - DB: db, - Redis: rds, - Config: c, - Queue: NewAsynqClient(c), - ExchangeRate: 0, - GeoIP: geoIP, + DB: db, + Redis: rds, + Config: c, + Queue: NewAsynqClient(c), + ExchangeRate: 0, + GeoIP: geoIP, + SignatureValidator: signature.NewValidator(c.AppSignature, nonceStore), //NodeCache: cache.NewNodeCacheClient(rds), - AuthLimiter: authLimiter, - AdsModel: ads.NewModel(db, rds), - LogModel: log.NewModel(db), + AuthLimiter: authLimiter, + AdsModel: ads.NewModel(db, rds), + LogModel: log.NewModel(db), LogMessageModel: logmessage.NewModel(db), - NodeModel: node.NewModel(db, rds), - AuthModel: auth.NewModel(db, rds), - UserModel: user.NewModel(db, rds), - OrderModel: order.NewModel(db, rds), - ClientModel: client.NewSubscribeApplicationModel(db), - TicketModel: ticket.NewModel(db, rds), + NodeModel: node.NewModel(db, rds), + AuthModel: auth.NewModel(db, rds), + UserModel: user.NewModel(db, rds), + OrderModel: order.NewModel(db, rds), + ClientModel: client.NewSubscribeApplicationModel(db), + TicketModel: ticket.NewModel(db, rds), //ServerModel: server.NewModel(db, rds), SystemModel: system.NewModel(db, rds), CouponModel: coupon.NewModel(db, rds), diff --git a/internal/types/compat_types.go b/internal/types/compat_types.go new file mode 100644 index 0000000..dd5b5d4 --- /dev/null +++ b/internal/types/compat_types.go @@ -0,0 +1,61 @@ +package types + +type ContactRequest struct { + Name string `json:"name" validate:"required,max=100"` + Email string `json:"email" validate:"required,email"` + OtherContact string `json:"other_contact" validate:"max=200"` + Notes string `json:"notes" validate:"max=2000"` +} + +type GetDownloadLinkRequest struct { + InviteCode string `form:"invite_code,optional"` + Platform string `form:"platform" validate:"required,oneof=windows mac ios android"` +} + +type GetDownloadLinkResponse struct { + Url string `json:"url"` +} + +type ReportLogMessageRequest struct { + Platform string `json:"platform" validate:"required,max=32"` + AppVersion string `json:"app_version" validate:"required,max=64"` + OsName string `json:"os_name" validate:"max=64"` + OsVersion string `json:"os_version" validate:"max=64"` + DeviceId string `json:"device_id" validate:"required,max=255"` + UserId int64 `json:"user_id"` + SessionId string `json:"session_id" validate:"max=255"` + Level uint8 `json:"level"` + ErrorCode string `json:"error_code" validate:"max=128"` + Message string `json:"message" validate:"required,max=65535"` + Stack string `json:"stack" validate:"max=1048576"` + Context interface{} `json:"context"` + OccurredAt int64 `json:"occurred_at"` +} + +type ReportLogMessageResponse struct { + Id int64 `json:"id"` +} + +type LegacyCheckVerificationCodeRequest struct { + Method string `json:"method" form:"method" validate:"omitempty,oneof=email mobile"` + Account string `json:"account" form:"account"` + Email string `json:"email" form:"email"` + Code string `json:"code" form:"code" validate:"required"` + Type uint8 `json:"type" form:"type" validate:"required"` +} + +type LegacyCheckVerificationCodeResponse struct { + Status bool `json:"status"` + Exist bool `json:"exist"` +} + +type EmailLoginRequest struct { + Identifier string `json:"identifier" form:"identifier"` + Email string `json:"email" form:"email" validate:"required,email"` + Code string `json:"code" form:"code" validate:"required"` + Invite string `json:"invite" form:"invite"` + IP string `header:"X-Original-Forwarded-For"` + UserAgent string `header:"User-Agent"` + LoginType string `header:"Login-Type"` + CfToken string `json:"cf_token,optional" form:"cf_token"` +} diff --git a/internal/types/types.go b/internal/types/types.go index 3b06cd6..3f2e7c8 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -221,19 +221,6 @@ type CheckVerificationCodeRespone struct { Exist bool `json:"exist,omitempty"` } -type LegacyCheckVerificationCodeRequest struct { - Method string `json:"method" form:"method"` - Account string `json:"account" form:"account"` - Email string `json:"email" form:"email"` - Code string `json:"code" form:"code" validate:"required"` - Type uint8 `json:"type" form:"type" validate:"required"` -} - -type LegacyCheckVerificationCodeResponse struct { - Status bool `json:"status"` - Exist bool `json:"exist"` -} - type CheckoutOrderRequest struct { OrderNo string `json:"orderNo"` ReturnUrl string `json:"returnUrl,omitempty"` @@ -576,6 +563,11 @@ type DeviceLoginRequest struct { ShortCode string `json:"short_code,optional"` } +type DissolveFamilyRequest struct { + FamilyId int64 `json:"family_id" validate:"required,gt=0"` + Reason string `json:"reason,omitempty"` +} + type Document struct { Id int64 `json:"id"` Title string `json:"title"` @@ -615,6 +607,34 @@ type EmailAuthticateConfig struct { DomainSuffixList string `json:"domain_suffix_list"` } +type FamilyDetail struct { + Summary FamilySummary `json:"summary"` + Members []FamilyMemberItem `json:"members"` +} + +type FamilyMemberItem struct { + UserId int64 `json:"user_id"` + Identifier string `json:"identifier"` + Role uint8 `json:"role"` + RoleName string `json:"role_name"` + Status uint8 `json:"status"` + StatusName string `json:"status_name"` + JoinSource string `json:"join_source"` + JoinedAt int64 `json:"joined_at"` + LeftAt int64 `json:"left_at,omitempty"` +} + +type FamilySummary struct { + FamilyId int64 `json:"family_id"` + OwnerUserId int64 `json:"owner_user_id"` + OwnerIdentifier string `json:"owner_identifier"` + Status string `json:"status"` + ActiveMemberCount int64 `json:"active_member_count"` + MaxMembers int64 `json:"max_members"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + type FilterBalanceLogRequest struct { FilterLogParams UserId int64 `form:"user_id,optional"` @@ -887,6 +907,25 @@ type GetDocumentListResponse struct { List []Document `json:"list"` } +type GetFamilyDetailRequest struct { + Id int64 `form:"id" validate:"required"` +} + +type GetFamilyListRequest struct { + Page int `form:"page"` + Size int `form:"size"` + Keyword string `form:"keyword,omitempty"` + Status string `form:"status,omitempty"` + OwnerUserId *int64 `form:"owner_user_id,omitempty"` + FamilyId *int64 `form:"family_id,omitempty"` + UserId *int64 `form:"user_id,omitempty"` +} + +type GetFamilyListResponse struct { + List []FamilySummary `json:"list"` + Total int64 `json:"total"` +} + type GetGlobalConfigResponse struct { Site SiteConfig `json:"site"` Verify VeifyConfig `json:"verify"` @@ -894,6 +933,7 @@ type GetGlobalConfigResponse struct { Invite InviteConfig `json:"invite"` Currency Currency `json:"currency"` Subscribe SubscribeConfig `json:"subscribe"` + Signature SignatureConfig `json:"signature"` VerifyCode PubilcVerifyCodeConfig `json:"verify_code"` OAuthMethods []string `json:"oauth_methods"` WebAd bool `json:"web_ad"` @@ -1188,41 +1228,6 @@ type GetUserSubscribeResetTrafficLogsResponse struct { Total int64 `json:"total"` } -type GetFamilyListRequest struct { - Page int `form:"page"` - Size int `form:"size"` - Keyword string `form:"keyword,omitempty"` - Status string `form:"status,omitempty"` - OwnerUserId *int64 `form:"owner_user_id,omitempty"` - FamilyId *int64 `form:"family_id,omitempty"` - UserId *int64 `form:"user_id,omitempty"` -} - -type GetFamilyListResponse struct { - List []FamilySummary `json:"list"` - Total int64 `json:"total"` -} - -type GetFamilyDetailRequest struct { - Id int64 `form:"id" validate:"required"` -} - -type UpdateFamilyMaxMembersRequest struct { - FamilyId int64 `json:"family_id" validate:"required,gt=0"` - MaxMembers int64 `json:"max_members" validate:"required,gt=0"` -} - -type RemoveFamilyMemberRequest struct { - FamilyId int64 `json:"family_id" validate:"required,gt=0"` - UserId int64 `json:"user_id" validate:"required,gt=0"` - Reason string `json:"reason,omitempty"` -} - -type DissolveFamilyRequest struct { - FamilyId int64 `json:"family_id" validate:"required,gt=0"` - Reason string `json:"reason,omitempty"` -} - type GetUserSubscribeTrafficLogsRequest struct { Page int `form:"page"` Size int `form:"size"` @@ -1706,10 +1711,8 @@ type QueryOrderDetailRequest struct { } type QueryOrderListRequest struct { - Page int `form:"page" validate:"required"` - Size int `form:"size" validate:"required"` - Status int `form:"status"` - Search string `form:"search"` + Page int `form:"page" validate:"required"` + Size int `form:"size" validate:"required"` } type QueryOrderListResponse struct { @@ -1932,6 +1935,12 @@ type RegisterLog struct { Timestamp int64 `json:"timestamp"` } +type RemoveFamilyMemberRequest struct { + FamilyId int64 `json:"family_id" validate:"required,gt=0"` + UserId int64 `json:"user_id" validate:"required,gt=0"` + Reason string `json:"reason,omitempty"` +} + type RenewalOrderRequest struct { UserSubscribeID int64 `json:"user_subscribe_id"` Quantity int64 `json:"quantity" validate:"lte=1000"` @@ -2163,6 +2172,10 @@ type ShadowsocksProtocol struct { Method string `json:"method"` } +type SignatureConfig struct { + EnableSignature bool `json:"enable_signature"` +} + type SiteConfig struct { Host string `json:"host"` SiteName string `json:"site_name"` @@ -2523,6 +2536,11 @@ type UpdateDocumentRequest struct { Show *bool `json:"show"` } +type UpdateFamilyMaxMembersRequest struct { + FamilyId int64 `json:"family_id" validate:"required,gt=0"` + MaxMembers int64 `json:"max_members" validate:"required,gt=0"` +} + type UpdateNodeRequest struct { Id int64 `json:"id"` Name string `json:"name"` @@ -2699,7 +2717,6 @@ type User struct { GiftAmount int64 `json:"gift_amount"` Telegram int64 `json:"telegram"` ReferCode string `json:"refer_code"` - ShareLink string `json:"share_link,omitempty"` RefererId int64 `json:"referer_id"` Enable bool `json:"enable"` IsAdmin bool `json:"is_admin,omitempty"` @@ -2716,8 +2733,6 @@ type User struct { IsDel bool `json:"is_del,omitempty"` Remark string `json:"remark,omitempty"` PurchasedPackage string `json:"purchased_package,omitempty"` - LastLoginTime int64 `json:"last_login_time"` - MemberStatus string `json:"member_status"` FamilyJoined bool `json:"family_joined,omitempty"` FamilyId int64 `json:"family_id,omitempty"` FamilyRole uint8 `json:"family_role,omitempty"` @@ -2761,34 +2776,6 @@ type UserLoginLog struct { Timestamp int64 `json:"timestamp"` } -type FamilySummary struct { - FamilyId int64 `json:"family_id"` - OwnerUserId int64 `json:"owner_user_id"` - OwnerIdentifier string `json:"owner_identifier"` - Status string `json:"status"` - ActiveMemberCount int64 `json:"active_member_count"` - MaxMembers int64 `json:"max_members"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` -} - -type FamilyMemberItem struct { - UserId int64 `json:"user_id"` - Identifier string `json:"identifier"` - Role uint8 `json:"role"` - RoleName string `json:"role_name"` - Status uint8 `json:"status"` - StatusName string `json:"status_name"` - JoinSource string `json:"join_source"` - JoinedAt int64 `json:"joined_at"` - LeftAt int64 `json:"left_at,omitempty"` -} - -type FamilyDetail struct { - Summary FamilySummary `json:"summary"` - Members []FamilyMemberItem `json:"members"` -} - type UserLoginRequest struct { Identifier string `json:"identifier"` Email string `json:"email" validate:"required"` @@ -2844,7 +2831,6 @@ type UserSubscribe struct { EntitlementOwnerUserId int64 `json:"entitlement_owner_user_id"` ReadOnly bool `json:"read_only"` Short string `json:"short"` - IsGift bool `json:"is_gift"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } @@ -3023,221 +3009,3 @@ type WithdrawalLog struct { CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } - -type EmailLoginRequest struct { - Identifier string `json:"identifier"` - Email string `json:"email" validate:"required"` - Code string `json:"code" validate:"required"` - Invite string `json:"invite,optional"` - IP string `header:"X-Original-Forwarded-For"` - UserAgent string `header:"User-Agent"` - LoginType string `header:"Login-Type"` - CfToken string `json:"cf_token,optional"` -} - -type BindEmailWithVerificationRequest struct { - Email string `json:"email" validate:"required"` - Code string `json:"code" validate:"required"` -} - -type BindEmailWithVerificationResponse struct { - Success bool `json:"success"` - Message string `json:"message,omitempty"` - Token string `json:"token,omitempty"` - UserId int64 `json:"user_id,omitempty"` - FamilyJoined bool `json:"family_joined,omitempty"` - FamilyId int64 `json:"family_id,omitempty"` - OwnerUserId int64 `json:"owner_user_id,omitempty"` -} - -type BindInviteCodeRequest struct { - InviteCode string `json:"invite_code" validate:"required"` -} - -type GetSubscribeStatusResponse struct { - DeviceStatus bool `json:"device_status"` - EmailStatus bool `json:"email_status"` -} - -type GetSubscribeStatusRequest struct { - Email string `json:"email" validate:"required,email"` -} - -type DeleteAccountResponse struct { - Success bool `json:"success"` - Message string `json:"message,omitempty"` - UserId int64 `json:"user_id"` - Code int `json:"code"` -} - -type GetDownloadLinkRequest struct { - InviteCode string `form:"invite_code,optional"` - Platform string `form:"platform" validate:"required,oneof=windows mac ios android"` -} - -type GetDownloadLinkResponse struct { - Url string `json:"url"` -} - -type ContactRequest struct { - Name string `json:"name" validate:"required"` - Email string `json:"email" validate:"required,email"` - OtherContact string `json:"other_contact,omitempty"` - Notes string `json:"notes,omitempty"` -} - -type ReportLogMessageRequest struct { - Platform string `json:"platform" validate:"required"` - AppVersion string `json:"appVersion"` - OsName string `json:"osName"` - OsVersion string `json:"osVersion"` - DeviceId string `json:"deviceId"` - UserId int64 `json:"userId"` - SessionId string `json:"sessionId"` - Level uint8 `json:"level"` - ErrorCode string `json:"errorCode"` - Message string `json:"message" validate:"required"` - Stack string `json:"stack"` - Context map[string]interface{} `json:"context"` - OccurredAt int64 `json:"occurredAt"` -} - -type ReportLogMessageResponse struct { - Id int64 `json:"id"` -} - -type GetErrorLogMessageListRequest struct { - Page int `form:"page"` - Size int `form:"size"` - Platform string `form:"platform"` - Level uint8 `form:"level"` - UserId int64 `form:"user_id"` - DeviceId string `form:"device_id"` - ErrorCode string `form:"error_code"` - Keyword string `form:"keyword"` - Start int64 `form:"start"` - End int64 `form:"end"` -} - -type ErrorLogMessage struct { - Id int64 `json:"id"` - Platform string `json:"platform"` - AppVersion string `json:"app_version"` - OsName string `json:"os_name"` - OsVersion string `json:"os_version"` - DeviceId string `json:"device_id"` - UserId int64 `json:"user_id"` - SessionId string `json:"session_id"` - Level uint8 `json:"level"` - ErrorCode string `json:"error_code"` - Message string `json:"message"` - CreatedAt int64 `json:"created_at"` -} - -type GetErrorLogMessageListResponse struct { - Total int64 `json:"total"` - List []ErrorLogMessage `json:"list"` -} - -type GetErrorLogMessageDetailResponse struct { - Id int64 `json:"id"` - Platform string `json:"platform"` - AppVersion string `json:"app_version"` - OsName string `json:"os_name"` - OsVersion string `json:"os_version"` - DeviceId string `json:"device_id"` - UserId int64 `json:"user_id"` - SessionId string `json:"session_id"` - Level uint8 `json:"level"` - ErrorCode string `json:"error_code"` - Message string `json:"message"` - Stack string `json:"stack"` - Context map[string]interface{} `json:"context"` - ClientIP string `json:"client_ip"` - UserAgent string `json:"user_agent"` - Locale string `json:"locale"` - OccurredAt int64 `json:"occurred_at"` - CreatedAt int64 `json:"created_at"` -} - -type AttachAppleTransactionRequest struct { - SignedTransactionJWS string `json:"signed_transaction_jws" validate:"required"` - DurationDays int64 `json:"duration_days,omitempty"` - Tier string `json:"tier,omitempty"` - SubscribeId int64 `json:"subscribe_id,omitempty"` - OrderNo string `json:"order_no" validate:"required"` -} - -type AttachAppleTransactionResponse struct { - ExpiresAt int64 `json:"expires_at"` - Tier string `json:"tier"` -} - -type AttachAppleTransactionByIdRequest struct { - TransactionId string `json:"transaction_id" validate:"required"` - OrderNo string `json:"order_no" validate:"required"` - Sandbox *bool `json:"sandbox,omitempty"` -} - -type RestoreAppleTransactionsRequest struct { - Transactions []string `json:"transactions" validate:"required"` -} - -type GetAppleStatusResponse struct { - Active bool `json:"active"` - ExpiresAt int64 `json:"expires_at"` - Tier string `json:"tier"` -} - -type GetAgentRealtimeRequest struct{} - -type GetAgentRealtimeResponse struct { - Total int64 `json:"total"` - Clicks int64 `json:"clicks"` - Views int64 `json:"views"` - Installs int64 `json:"installs"` - PaidCount int64 `json:"paid_count"` - GrowthRate string `json:"growth_rate"` - PaidGrowthRate string `json:"paid_growth_rate"` -} - -type GetAgentDownloadsRequest struct{} - -type GetAgentDownloadsResponse struct { - Total int64 `json:"total"` - Platforms *PlatformDownloads `json:"platforms"` - ComparisonRate *string `json:"comparison_rate,omitempty"` -} - -type PlatformDownloads struct { - IOS int64 `json:"ios"` - Android int64 `json:"android"` - Windows int64 `json:"windows"` - Mac int64 `json:"mac"` -} - -type GetUserInviteStatsRequest struct{} - -type GetUserInviteStatsResponse struct { - FriendlyCount int64 `json:"friendly_count"` - HistoryCount int64 `json:"history_count"` -} - -type GetInviteSalesRequest struct { - Page int `form:"page" validate:"required"` - Size int `form:"size" validate:"required"` - StartTime int64 `form:"start_time,optional"` - EndTime int64 `form:"end_time,optional"` -} - -type GetInviteSalesResponse struct { - Total int64 `json:"total"` - List []InvitedUserSale `json:"list"` -} - -type InvitedUserSale struct { - Amount float64 `json:"amount"` - UpdatedAt int64 `json:"update_at"` - UserHash string `json:"user_hash"` - ProductName string `json:"product_name"` -} diff --git a/pkg/signature/canonical.go b/pkg/signature/canonical.go new file mode 100644 index 0000000..c674a05 --- /dev/null +++ b/pkg/signature/canonical.go @@ -0,0 +1,50 @@ +package signature + +import ( + "crypto/sha256" + "fmt" + "net/url" + "sort" + "strings" +) + +// BuildStringToSign StringToSign = METHOD\nPATH\nCANONICAL_QUERY\nBODY_SHA256\nX-App-Id\nX-Timestamp\nX-Nonce +func BuildStringToSign(method, path, rawQuery string, body []byte, appId, timestamp, nonce string) string { + canonical := canonicalQuery(rawQuery) + bodyHash := sha256Hex(body) + parts := []string{ + strings.ToUpper(method), + path, + canonical, + bodyHash, + appId, + timestamp, + nonce, + } + return strings.Join(parts, "\n") +} + +func canonicalQuery(rawQuery string) string { + if rawQuery == "" { + return "" + } + values, err := url.ParseQuery(rawQuery) + if err != nil { + return rawQuery + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, fmt.Sprintf("%s=%s", key, values.Get(key))) + } + return strings.Join(parts, "&") +} + +func sha256Hex(data []byte) string { + h := sha256.Sum256(data) + return fmt.Sprintf("%x", h) +} diff --git a/pkg/signature/config.go b/pkg/signature/config.go new file mode 100644 index 0000000..e1c4697 --- /dev/null +++ b/pkg/signature/config.go @@ -0,0 +1,7 @@ +package signature + +type SignatureConf struct { + AppSecrets map[string]string `yaml:"AppSecrets"` + ValidWindowSeconds int64 `yaml:"ValidWindowSeconds"` + SkipPrefixes []string `yaml:"SkipPrefixes"` +} diff --git a/pkg/signature/errors.go b/pkg/signature/errors.go new file mode 100644 index 0000000..793fad4 --- /dev/null +++ b/pkg/signature/errors.go @@ -0,0 +1,10 @@ +package signature + +import "errors" + +var ( + ErrSignatureMissing = errors.New("signature missing") + ErrSignatureExpired = errors.New("signature expired") + ErrSignatureInvalid = errors.New("signature invalid") + ErrSignatureReplay = errors.New("signature replay") +) diff --git a/pkg/signature/nonce_store.go b/pkg/signature/nonce_store.go new file mode 100644 index 0000000..f1ece52 --- /dev/null +++ b/pkg/signature/nonce_store.go @@ -0,0 +1,30 @@ +package signature + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +type NonceStore interface { + SetIfNotExists(ctx context.Context, appId, nonce string, ttlSeconds int64) (bool, error) +} + +type RedisNonceStore struct { + client *redis.Client +} + +func NewRedisNonceStore(client *redis.Client) *RedisNonceStore { + return &RedisNonceStore{client: client} +} + +func (s *RedisNonceStore) SetIfNotExists(ctx context.Context, appId, nonce string, ttlSeconds int64) (bool, error) { + key := fmt.Sprintf("sig:nonce:%s:%s", appId, nonce) + ok, err := s.client.SetNX(ctx, key, "1", time.Duration(ttlSeconds)*time.Second).Result() + if err != nil { + return false, err + } + return !ok, nil +} diff --git a/pkg/signature/signature_test.go b/pkg/signature/signature_test.go new file mode 100644 index 0000000..658c2a4 --- /dev/null +++ b/pkg/signature/signature_test.go @@ -0,0 +1,120 @@ +package signature + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "strconv" + "testing" + "time" +) + +type mockNonceStore struct { + seen map[string]bool +} + +func newMockNonceStore() *mockNonceStore { + return &mockNonceStore{seen: map[string]bool{}} +} + +func (m *mockNonceStore) SetIfNotExists(_ context.Context, appId, nonce string, _ int64) (bool, error) { + key := appId + ":" + nonce + if m.seen[key] { + return true, nil + } + m.seen[key] = true + return false, nil +} + +func makeSignature(secret, stringToSign string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(stringToSign)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func TestValidateSuccess(t *testing.T) { + conf := SignatureConf{ + AppSecrets: map[string]string{"web-client": "uB4G,XxL2{7b"}, + ValidWindowSeconds: 300, + } + v := NewValidator(conf, newMockNonceStore()) + + ts := strconv.FormatInt(time.Now().Unix(), 10) + nonce := fmt.Sprintf("%x", time.Now().UnixNano()) + sts := BuildStringToSign("POST", "/v1/public/order/create", "", []byte(`{"plan_id":1}`), "web-client", ts, nonce) + sig := makeSignature("uB4G,XxL2{7b", sts) + + if err := v.Validate(context.Background(), "web-client", ts, nonce, sig, sts); err != nil { + t.Fatalf("expected success, got %v", err) + } +} + +func TestValidateExpired(t *testing.T) { + conf := SignatureConf{ + AppSecrets: map[string]string{"web-client": "uB4G,XxL2{7b"}, + ValidWindowSeconds: 300, + } + v := NewValidator(conf, newMockNonceStore()) + + ts := strconv.FormatInt(time.Now().Unix()-400, 10) + nonce := "abc" + sts := BuildStringToSign("GET", "/v1/public/user/info", "", nil, "web-client", ts, nonce) + sig := makeSignature("uB4G,XxL2{7b", sts) + + if err := v.Validate(context.Background(), "web-client", ts, nonce, sig, sts); err != ErrSignatureExpired { + t.Fatalf("expected ErrSignatureExpired, got %v", err) + } +} + +func TestValidateReplay(t *testing.T) { + conf := SignatureConf{ + AppSecrets: map[string]string{"web-client": "uB4G,XxL2{7b"}, + ValidWindowSeconds: 300, + } + v := NewValidator(conf, newMockNonceStore()) + + ts := strconv.FormatInt(time.Now().Unix(), 10) + nonce := "same-nonce-replay" + sts := BuildStringToSign("GET", "/v1/public/user/info", "", nil, "web-client", ts, nonce) + sig := makeSignature("uB4G,XxL2{7b", sts) + + _ = v.Validate(context.Background(), "web-client", ts, nonce, sig, sts) + if err := v.Validate(context.Background(), "web-client", ts, nonce, sig, sts); err != ErrSignatureReplay { + t.Fatalf("expected ErrSignatureReplay, got %v", err) + } +} + +func TestValidateInvalidSignature(t *testing.T) { + conf := SignatureConf{ + AppSecrets: map[string]string{"web-client": "uB4G,XxL2{7b"}, + ValidWindowSeconds: 300, + } + v := NewValidator(conf, newMockNonceStore()) + + ts := strconv.FormatInt(time.Now().Unix(), 10) + nonce := "nonce-invalid-sig" + sts := BuildStringToSign("POST", "/v1/public/order/create", "", []byte(`{"plan_id":1}`), "web-client", ts, nonce) + + if err := v.Validate(context.Background(), "web-client", ts, nonce, "badsignature", sts); err != ErrSignatureInvalid { + t.Fatalf("expected ErrSignatureInvalid, got %v", err) + } +} + +func TestBuildStringToSignCanonicalQuery(t *testing.T) { + got := BuildStringToSign( + "get", + "/v1/public/order/list", + "b=2&a=1&a=3&c=", + nil, + "web-client", + "1700000000", + "nonce-1", + ) + + want := "GET\n/v1/public/order/list\na=1&b=2&c=\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\nweb-client\n1700000000\nnonce-1" + if got != want { + t.Fatalf("unexpected stringToSign\nwant: %s\ngot: %s", want, got) + } +} diff --git a/pkg/signature/validator.go b/pkg/signature/validator.go new file mode 100644 index 0000000..34c111a --- /dev/null +++ b/pkg/signature/validator.go @@ -0,0 +1,61 @@ +package signature + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "strconv" + "time" +) + +type Validator struct { + conf SignatureConf + nonceStore NonceStore +} + +func NewValidator(conf SignatureConf, store NonceStore) *Validator { + return &Validator{ + conf: conf, + nonceStore: store, + } +} + +func (v *Validator) windowSeconds() int64 { + if v.conf.ValidWindowSeconds <= 0 { + return 300 + } + return v.conf.ValidWindowSeconds +} + +func (v *Validator) Validate(ctx context.Context, appId, timestamp, nonce, signature, stringToSign string) error { + secret, ok := v.conf.AppSecrets[appId] + if !ok { + return ErrSignatureMissing + } + + ts, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + return ErrSignatureExpired + } + diff := time.Now().Unix() - ts + if diff < 0 { + diff = -diff + } + if diff > v.windowSeconds() { + return ErrSignatureExpired + } + + replayed, err := v.nonceStore.SetIfNotExists(ctx, appId, nonce, v.windowSeconds()+60) + if err == nil && replayed { + return ErrSignatureReplay + } + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(stringToSign)) + expected := hex.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(expected), []byte(signature)) { + return ErrSignatureInvalid + } + return nil +} diff --git a/pkg/xerr/errCode.go b/pkg/xerr/errCode.go index 927b46f..6d4fbc1 100644 --- a/pkg/xerr/errCode.go +++ b/pkg/xerr/errCode.go @@ -58,6 +58,10 @@ const ( InvalidAccess uint32 = 40005 InvalidCiphertext uint32 = 40006 SecretIsEmpty uint32 = 40007 + SignatureMissing uint32 = 40008 + SignatureExpired uint32 = 40009 + SignatureInvalid uint32 = 40010 + SignatureReplay uint32 = 40011 ) //coupon error diff --git a/pkg/xerr/errMsg.go b/pkg/xerr/errMsg.go index cc475bf..7b43cc9 100644 --- a/pkg/xerr/errMsg.go +++ b/pkg/xerr/errMsg.go @@ -17,6 +17,10 @@ func init() { SecretIsEmpty: "Secret is empty", InvalidAccess: "Invalid access", InvalidCiphertext: "Invalid ciphertext", + SignatureMissing: "Signature headers are missing", + SignatureExpired: "Signature is expired", + SignatureInvalid: "Signature is invalid", + SignatureReplay: "Signature nonce replay detected", // Database error DatabaseQueryError: "Database query error", DatabaseUpdateError: "Database update error",