From 279a10d3174098bdcf1a20b6f2a8ff40d49f897b Mon Sep 17 00:00:00 2001 From: senke Date: Sun, 22 Feb 2026 21:13:00 +0100 Subject: [PATCH] chore(cleanup): remove veza-chat-server directory and all operational references Chat functionality is now fully handled by the Go backend (since v0.502). Remove the deprecated Rust chat server and all its references from: - CI/CD workflows (ci.yml, cd.yml, rust-ci.yml, chat-ci.yml) - Monitoring & proxy config (prometheus, caddy, haproxy) - Incus deployment scripts and documentation - Monorepo config (package.json, dependabot, GH templates) --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .github/dependabot.yml | 6 - .github/pull_request_template.md | 3 +- .github/workflows/cd.yml | 20 +- .github/workflows/chat-ci.yml | 41 - .github/workflows/ci.yml | 13 +- .github/workflows/rust-ci.yml | 13 - config/caddy/Caddyfile.staging | 1 - config/docker/README.md | 1 - config/haproxy/haproxy.cfg | 14 - config/incus/DEBIAN13_MIGRATION.md | 5 +- config/incus/DEPLOYMENT_FEEDBACK.md | 8 +- config/incus/DEPLOYMENT_STATUS.md | 28 +- config/incus/README.md | 7 +- config/incus/build-native.sh | 49 +- config/incus/check-deployment.sh | 25 +- config/incus/deploy-service-native.sh | 45 +- config/incus/deploy-service.sh | 5 +- config/incus/env/env.example | 12 +- config/incus/fix-network-now.sh | 1 - config/incus/haproxy.cfg | 19 +- config/incus/setup-basic-incus.sh | 2 +- config/incus/systemd/veza-chat-server.service | 27 - config/incus/verify-deployment.sh | 17 +- config/prometheus.yml | 5 - package.json | 2 +- veza-chat-server/.clippy.toml | 14 - veza-chat-server/.dockerignore | 68 - veza-chat-server/.env.lab.example | 18 - veza-chat-server/.gitignore | 42 - veza-chat-server/AUDIT_CHAT_SERVER_RUST.md | 1019 ----------- .../AUDIT_EXHAUSTIF_CHAT_SERVER.md | 636 ------- veza-chat-server/Cargo.toml | 252 --- veza-chat-server/Dockerfile | 65 - veza-chat-server/Dockerfile.production | 66 - veza-chat-server/Makefile | 221 --- veza-chat-server/build.rs | 41 - veza-chat-server/check_output.txt | 16 - veza-chat-server/check_output_2.txt | 94 - .../grafana/dashboards/veza-dashboard.json | 26 - .../config/grafana/datasources/prometheus.yml | 8 - veza-chat-server/config/prometheus.yml | 27 - veza-chat-server/deploy-simple.sh | 112 -- veza-chat-server/docker-compose.local.yml | 120 -- veza-chat-server/docker-compose.yml | 181 -- .../docs/AUDIT_DELIVERED_TYPING.md | 167 -- .../docs/AUDIT_HISTORY_SEARCH_SYNC.md | 212 --- veza-chat-server/docs/CHAT_DB_STRATEGY.md | 35 - .../docs/CHAT_DELIVERED_AND_TYPING.md | 412 ----- .../docs/CHAT_HISTORY_SEARCH_SYNC.md | 593 ------- .../docs/CHAT_MESSAGE_EDIT_DELETE.md | 444 ----- veza-chat-server/docs/CHAT_PANIC_CLEANUP.md | 241 --- veza-chat-server/docs/CHAT_PERMISSIONS.md | 328 ---- veza-chat-server/docs/CHAT_READ_RECEIPTS.md | 352 ---- veza-chat-server/env.example | 132 -- .../migrations/001_create_clean_database.sql | 116 -- .../migrations/002_advanced_features.sql | 223 --- .../migrations/003_read_receipts.sql | 58 - .../migrations/004_delivered_status.sql | 58 - .../migrations/005_message_edit_delete.sql | 22 - .../migrations/006_history_search_sync.sql | 59 - .../migrations/1000_dm_enriched.sql | 69 - .../migrations/1001_post_migration_fixes.sql | 87 - .../migrations/1002_add_missing_uuids.sql | 58 - .../999_cleanup_production_ready_fixed.sql | 402 ----- .../archive/003_enhanced_schema.sql | 270 --- .../archive/003_enhanced_schema_fixed.sql | 130 -- .../archive/003_enhanced_schema_simple.sql | 121 -- .../migrations/archive/004_corrective_fix.sql | 69 - .../archive/004_corrective_migration.sql | 337 ---- .../archive/999_cleanup_production_ready.sql | 386 ----- veza-chat-server/package.json | 9 - veza-chat-server/proto/chat/chat.proto | 320 ---- veza-chat-server/proto/common/auth.proto | 89 - veza-chat-server/scripts/reset_lab_db.sh | 42 - veza-chat-server/scripts/start_lab.sh | 62 - veza-chat-server/scripts/test_lab.sh | 93 - veza-chat-server/sqlx-data.json | 1 - veza-chat-server/src/advanced_moderation.rs | 864 ---------- veza-chat-server/src/auth.rs | 331 ---- veza-chat-server/src/authentication.rs | 259 --- veza-chat-server/src/cache.rs | 297 ---- veza-chat-server/src/chat_management.rs | 794 --------- veza-chat-server/src/client.rs | 79 - veza-chat-server/src/config.rs | 670 -------- .../src/core/advanced_rate_limiter.rs | 829 --------- veza-chat-server/src/core/channels.rs | 495 ------ veza-chat-server/src/core/connection.rs | 263 --- veza-chat-server/src/core/encryption.rs | 504 ------ veza-chat-server/src/core/message.rs | 339 ---- veza-chat-server/src/core/mod.rs | 19 - .../src/core/moderation_integration.rs | 295 ---- veza-chat-server/src/core/rich_messages.rs | 643 ------- veza-chat-server/src/core/room.rs | 239 --- veza-chat-server/src/core/user.rs | 251 --- veza-chat-server/src/database/mod.rs | 5 - veza-chat-server/src/database/pool.rs | 88 - veza-chat-server/src/delivered_status.rs | 315 ---- veza-chat-server/src/env.rs | 148 -- veza-chat-server/src/error.rs | 744 -------- veza-chat-server/src/event_bus.rs | 209 --- veza-chat-server/src/generated/veza.chat.rs | 1509 ----------------- .../src/generated/veza.common.auth.rs | 465 ----- veza-chat-server/src/grpc_client.rs | 219 --- veza-chat-server/src/grpc_server.rs | 253 --- veza-chat-server/src/hub/audit.rs | 681 -------- veza-chat-server/src/hub/channel_websocket.rs | 627 ------- veza-chat-server/src/hub/channels.rs | 780 --------- veza-chat-server/src/hub/common.rs | 283 ---- veza-chat-server/src/hub/direct_messages.rs | 861 ---------- .../src/hub/direct_messages_websocket.rs | 678 -------- veza-chat-server/src/hub/mod.rs | 93 - veza-chat-server/src/hub/reactions.rs | 488 ------ veza-chat-server/src/hub/room_enhanced.rs | 89 - veza-chat-server/src/jwt_manager.rs | 683 -------- veza-chat-server/src/jwt_revocation_store.rs | 129 -- veza-chat-server/src/lib.rs | 52 - veza-chat-server/src/main.rs | 566 ------- veza-chat-server/src/message_handler.rs | 328 ---- veza-chat-server/src/message_store.rs | 288 ---- veza-chat-server/src/message_store_simple.rs | 329 ---- veza-chat-server/src/messages.rs | 74 - veza-chat-server/src/middleware/mod.rs | 6 - veza-chat-server/src/middleware/request_id.rs | 79 - veza-chat-server/src/models/message.rs | 50 - veza-chat-server/src/models/mod.rs | 115 -- veza-chat-server/src/moderation.rs | 407 ----- veza-chat-server/src/monitoring.rs | 431 ----- veza-chat-server/src/optimized_persistence.rs | 928 ---------- veza-chat-server/src/permissions.rs | 251 --- veza-chat-server/src/presence.rs | 265 --- veza-chat-server/src/prometheus_metrics.rs | 367 ---- veza-chat-server/src/rate_limiter.rs | 453 ----- veza-chat-server/src/reactions.rs | 223 --- veza-chat-server/src/read_receipts.rs | 495 ------ .../src/repository/message_repository.rs | 677 -------- veza-chat-server/src/repository/mod.rs | 8 - .../src/repository/room_repository.rs | 224 --- veza-chat-server/src/repository/tests.rs | 75 - veza-chat-server/src/security/csrf.rs | 265 --- veza-chat-server/src/security/mod.rs | 155 -- veza-chat-server/src/security/permission.rs | 584 ------- veza-chat-server/src/security/rate_limiter.rs | 320 ---- .../src/services/message_edit_service.rs | 257 --- veza-chat-server/src/services/mod.rs | 10 - veza-chat-server/src/services/room_service.rs | 365 ---- veza-chat-server/src/simple_message_store.rs | 155 -- veza-chat-server/src/structured_logging.rs | 534 ------ veza-chat-server/src/test_simple_store.rs | 79 - veza-chat-server/src/typing_indicator.rs | 179 -- veza-chat-server/src/utils.rs | 105 -- veza-chat-server/src/validation.rs | 54 - veza-chat-server/src/websocket/broadcast.rs | 302 ---- veza-chat-server/src/websocket/handler.rs | 1231 -------------- veza-chat-server/src/websocket/mod.rs | 363 ---- 155 files changed, 37 insertions(+), 37524 deletions(-) delete mode 100644 .github/workflows/chat-ci.yml delete mode 100644 config/incus/systemd/veza-chat-server.service delete mode 100644 veza-chat-server/.clippy.toml delete mode 100644 veza-chat-server/.dockerignore delete mode 100644 veza-chat-server/.env.lab.example delete mode 100644 veza-chat-server/.gitignore delete mode 100644 veza-chat-server/AUDIT_CHAT_SERVER_RUST.md delete mode 100644 veza-chat-server/AUDIT_EXHAUSTIF_CHAT_SERVER.md delete mode 100644 veza-chat-server/Cargo.toml delete mode 100644 veza-chat-server/Dockerfile delete mode 100644 veza-chat-server/Dockerfile.production delete mode 100644 veza-chat-server/Makefile delete mode 100644 veza-chat-server/build.rs delete mode 100644 veza-chat-server/check_output.txt delete mode 100644 veza-chat-server/check_output_2.txt delete mode 100644 veza-chat-server/config/grafana/dashboards/veza-dashboard.json delete mode 100644 veza-chat-server/config/grafana/datasources/prometheus.yml delete mode 100644 veza-chat-server/config/prometheus.yml delete mode 100644 veza-chat-server/deploy-simple.sh delete mode 100644 veza-chat-server/docker-compose.local.yml delete mode 100644 veza-chat-server/docker-compose.yml delete mode 100644 veza-chat-server/docs/AUDIT_DELIVERED_TYPING.md delete mode 100644 veza-chat-server/docs/AUDIT_HISTORY_SEARCH_SYNC.md delete mode 100644 veza-chat-server/docs/CHAT_DB_STRATEGY.md delete mode 100644 veza-chat-server/docs/CHAT_DELIVERED_AND_TYPING.md delete mode 100644 veza-chat-server/docs/CHAT_HISTORY_SEARCH_SYNC.md delete mode 100644 veza-chat-server/docs/CHAT_MESSAGE_EDIT_DELETE.md delete mode 100644 veza-chat-server/docs/CHAT_PANIC_CLEANUP.md delete mode 100644 veza-chat-server/docs/CHAT_PERMISSIONS.md delete mode 100644 veza-chat-server/docs/CHAT_READ_RECEIPTS.md delete mode 100644 veza-chat-server/env.example delete mode 100644 veza-chat-server/migrations/001_create_clean_database.sql delete mode 100644 veza-chat-server/migrations/002_advanced_features.sql delete mode 100644 veza-chat-server/migrations/003_read_receipts.sql delete mode 100644 veza-chat-server/migrations/004_delivered_status.sql delete mode 100644 veza-chat-server/migrations/005_message_edit_delete.sql delete mode 100644 veza-chat-server/migrations/006_history_search_sync.sql delete mode 100644 veza-chat-server/migrations/1000_dm_enriched.sql delete mode 100644 veza-chat-server/migrations/1001_post_migration_fixes.sql delete mode 100644 veza-chat-server/migrations/1002_add_missing_uuids.sql delete mode 100644 veza-chat-server/migrations/999_cleanup_production_ready_fixed.sql delete mode 100644 veza-chat-server/migrations/archive/003_enhanced_schema.sql delete mode 100644 veza-chat-server/migrations/archive/003_enhanced_schema_fixed.sql delete mode 100644 veza-chat-server/migrations/archive/003_enhanced_schema_simple.sql delete mode 100644 veza-chat-server/migrations/archive/004_corrective_fix.sql delete mode 100644 veza-chat-server/migrations/archive/004_corrective_migration.sql delete mode 100644 veza-chat-server/migrations/archive/999_cleanup_production_ready.sql delete mode 100644 veza-chat-server/package.json delete mode 100644 veza-chat-server/proto/chat/chat.proto delete mode 100644 veza-chat-server/proto/common/auth.proto delete mode 100755 veza-chat-server/scripts/reset_lab_db.sh delete mode 100755 veza-chat-server/scripts/start_lab.sh delete mode 100755 veza-chat-server/scripts/test_lab.sh delete mode 100644 veza-chat-server/sqlx-data.json delete mode 100644 veza-chat-server/src/advanced_moderation.rs delete mode 100644 veza-chat-server/src/auth.rs delete mode 100644 veza-chat-server/src/authentication.rs delete mode 100644 veza-chat-server/src/cache.rs delete mode 100644 veza-chat-server/src/chat_management.rs delete mode 100644 veza-chat-server/src/client.rs delete mode 100644 veza-chat-server/src/config.rs delete mode 100644 veza-chat-server/src/core/advanced_rate_limiter.rs delete mode 100644 veza-chat-server/src/core/channels.rs delete mode 100644 veza-chat-server/src/core/connection.rs delete mode 100644 veza-chat-server/src/core/encryption.rs delete mode 100644 veza-chat-server/src/core/message.rs delete mode 100644 veza-chat-server/src/core/mod.rs delete mode 100644 veza-chat-server/src/core/moderation_integration.rs delete mode 100644 veza-chat-server/src/core/rich_messages.rs delete mode 100644 veza-chat-server/src/core/room.rs delete mode 100644 veza-chat-server/src/core/user.rs delete mode 100644 veza-chat-server/src/database/mod.rs delete mode 100644 veza-chat-server/src/database/pool.rs delete mode 100644 veza-chat-server/src/delivered_status.rs delete mode 100644 veza-chat-server/src/env.rs delete mode 100644 veza-chat-server/src/error.rs delete mode 100644 veza-chat-server/src/event_bus.rs delete mode 100644 veza-chat-server/src/generated/veza.chat.rs delete mode 100644 veza-chat-server/src/generated/veza.common.auth.rs delete mode 100644 veza-chat-server/src/grpc_client.rs delete mode 100644 veza-chat-server/src/grpc_server.rs delete mode 100644 veza-chat-server/src/hub/audit.rs delete mode 100644 veza-chat-server/src/hub/channel_websocket.rs delete mode 100644 veza-chat-server/src/hub/channels.rs delete mode 100644 veza-chat-server/src/hub/common.rs delete mode 100644 veza-chat-server/src/hub/direct_messages.rs delete mode 100644 veza-chat-server/src/hub/direct_messages_websocket.rs delete mode 100644 veza-chat-server/src/hub/mod.rs delete mode 100644 veza-chat-server/src/hub/reactions.rs delete mode 100644 veza-chat-server/src/hub/room_enhanced.rs delete mode 100644 veza-chat-server/src/jwt_manager.rs delete mode 100644 veza-chat-server/src/jwt_revocation_store.rs delete mode 100644 veza-chat-server/src/lib.rs delete mode 100644 veza-chat-server/src/main.rs delete mode 100644 veza-chat-server/src/message_handler.rs delete mode 100644 veza-chat-server/src/message_store.rs delete mode 100644 veza-chat-server/src/message_store_simple.rs delete mode 100644 veza-chat-server/src/messages.rs delete mode 100644 veza-chat-server/src/middleware/mod.rs delete mode 100644 veza-chat-server/src/middleware/request_id.rs delete mode 100644 veza-chat-server/src/models/message.rs delete mode 100644 veza-chat-server/src/models/mod.rs delete mode 100644 veza-chat-server/src/moderation.rs delete mode 100644 veza-chat-server/src/monitoring.rs delete mode 100644 veza-chat-server/src/optimized_persistence.rs delete mode 100644 veza-chat-server/src/permissions.rs delete mode 100644 veza-chat-server/src/presence.rs delete mode 100644 veza-chat-server/src/prometheus_metrics.rs delete mode 100644 veza-chat-server/src/rate_limiter.rs delete mode 100644 veza-chat-server/src/reactions.rs delete mode 100644 veza-chat-server/src/read_receipts.rs delete mode 100644 veza-chat-server/src/repository/message_repository.rs delete mode 100644 veza-chat-server/src/repository/mod.rs delete mode 100644 veza-chat-server/src/repository/room_repository.rs delete mode 100644 veza-chat-server/src/repository/tests.rs delete mode 100644 veza-chat-server/src/security/csrf.rs delete mode 100644 veza-chat-server/src/security/mod.rs delete mode 100644 veza-chat-server/src/security/permission.rs delete mode 100644 veza-chat-server/src/security/rate_limiter.rs delete mode 100644 veza-chat-server/src/services/message_edit_service.rs delete mode 100644 veza-chat-server/src/services/mod.rs delete mode 100644 veza-chat-server/src/services/room_service.rs delete mode 100644 veza-chat-server/src/simple_message_store.rs delete mode 100644 veza-chat-server/src/structured_logging.rs delete mode 100644 veza-chat-server/src/test_simple_store.rs delete mode 100644 veza-chat-server/src/typing_indicator.rs delete mode 100644 veza-chat-server/src/utils.rs delete mode 100644 veza-chat-server/src/validation.rs delete mode 100644 veza-chat-server/src/websocket/broadcast.rs delete mode 100644 veza-chat-server/src/websocket/handler.rs delete mode 100644 veza-chat-server/src/websocket/mod.rs diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 66bb17d42..bab52fed6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -27,7 +27,7 @@ Ce qui aurait dû se passer. ## 💻 Contexte -- Service impacté : (backend-api / chat-server / stream-server / web-frontend / infra) +- Service impacté : (backend-api / stream-server / web-frontend / infra) - Branch : (main / develop / autre) - Environnement : (local / dev / staging / prod) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7739b9560..f2cbc76dd 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,12 +6,6 @@ updates: interval: "weekly" labels: ["dependencies", "go"] - - package-ecosystem: "cargo" - directory: "/veza-chat-server" - schedule: - interval: "weekly" - labels: ["dependencies", "rust"] - - package-ecosystem: "cargo" directory: "/veza-stream-server" schedule: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 8d89fad8e..9737e8886 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,7 +1,7 @@ # 🧩 Résumé - **Type de changement** : (feat / fix / refactor / chore / docs) -- **Scope** : (backend-api / chat-server / stream-server / web-frontend / infra / docs) +- **Scope** : (backend-api / stream-server / web-frontend / infra / docs) --- @@ -48,7 +48,6 @@ Si oui, préciser : Coche ce qui a été lancé : - [ ] `go test ./...` (backend-api) -- [ ] `cargo test` (chat-server) - [ ] `cargo test` (stream-server) - [ ] `pnpm test` (web-frontend) - [ ] Tests manuels locaux (décrire rapidement) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 61bfe1c39..da047605b 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -36,9 +36,8 @@ jobs: run: | docker build -t veza-frontend:${{ github.sha }} -f apps/web/Dockerfile.production apps/web/ - - name: Build Rust Services Docker Images + - name: Build Stream Server Docker Image run: | - docker build -t veza-chat-server:${{ github.sha }} -f veza-chat-server/Dockerfile.production veza-chat-server/ docker build -t veza-stream-server:${{ github.sha }} -f veza-stream-server/Dockerfile.production veza-stream-server/ - name: Trivy vulnerability scan @@ -57,14 +56,6 @@ jobs: exit-code: '1' severity: 'CRITICAL,HIGH' - - name: Trivy scan chat server - uses: aquasecurity/trivy-action@0.28.0 - with: - image-ref: 'veza-chat-server:${{ github.sha }}' - format: 'table' - exit-code: '1' - severity: 'CRITICAL,HIGH' - - name: Trivy scan stream server uses: aquasecurity/trivy-action@0.28.0 with: @@ -76,7 +67,7 @@ jobs: - name: Generate SBOM run: | mkdir -p sbom - for svc in veza-backend-api veza-frontend veza-chat-server veza-stream-server; do + for svc in veza-backend-api veza-frontend veza-stream-server; do trivy image --format cyclonedx --output "sbom/${svc}-${{ github.sha }}.json" "${svc}:${{ github.sha }}" done - name: Upload SBOM artifacts @@ -89,7 +80,7 @@ jobs: if: vars.DOCKER_REGISTRY != '' run: | echo "${{ secrets.DOCKER_REGISTRY_PASSWORD }}" | docker login "${{ vars.DOCKER_REGISTRY }}" -u "${{ secrets.DOCKER_REGISTRY_USERNAME }}" --password-stdin - for svc in veza-backend-api veza-frontend veza-chat-server veza-stream-server; do + for svc in veza-backend-api veza-frontend veza-stream-server; do docker tag "${svc}:${{ github.sha }}" "${{ vars.DOCKER_REGISTRY }}/${svc}:${{ github.sha }}" docker tag "${svc}:${{ github.sha }}" "${{ vars.DOCKER_REGISTRY }}/${svc}:latest" docker push "${{ vars.DOCKER_REGISTRY }}/${svc}:${{ github.sha }}" @@ -107,7 +98,7 @@ jobs: COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} run: | - for svc in veza-backend-api veza-frontend veza-chat-server veza-stream-server; do + for svc in veza-backend-api veza-frontend veza-stream-server; do cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${{ vars.DOCKER_REGISTRY }}/${svc}:${{ github.sha }}" cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${{ vars.DOCKER_REGISTRY }}/${svc}:latest" done @@ -117,7 +108,6 @@ jobs: echo "## Build Summary" >> $GITHUB_STEP_SUMMARY echo "- Backend: veza-backend-api:${{ github.sha }}" >> $GITHUB_STEP_SUMMARY echo "- Frontend: veza-frontend:${{ github.sha }}" >> $GITHUB_STEP_SUMMARY - echo "- Chat Server: veza-chat-server:${{ github.sha }}" >> $GITHUB_STEP_SUMMARY echo "- Stream Server: veza-stream-server:${{ github.sha }}" >> $GITHUB_STEP_SUMMARY deploy: @@ -134,7 +124,7 @@ jobs: echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > "$KUBECONFIG" chmod 600 "$KUBECONFIG" export KUBECONFIG - for svc in veza-backend-api veza-chat-server veza-stream-server; do + for svc in veza-backend-api veza-stream-server; do kubectl set image "deployment/${svc}" "${svc}=${{ vars.DOCKER_REGISTRY }}/${svc}:${{ github.sha }}" \ -n veza --record || echo "Skipping ${svc} (deployment not found)" done diff --git a/.github/workflows/chat-ci.yml b/.github/workflows/chat-ci.yml deleted file mode 100644 index 8ca2673e9..000000000 --- a/.github/workflows/chat-ci.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Chat Server CI - -on: - push: - paths: - - "veza-chat-server/**" - - "veza-common/**" - - ".github/workflows/chat-ci.yml" - pull_request: - paths: - - "veza-chat-server/**" - - "veza-common/**" - - ".github/workflows/chat-ci.yml" - -jobs: - test: - runs-on: ubuntu-latest - - defaults: - run: - working-directory: veza-chat-server - - steps: - - uses: actions/checkout@v4 - - - name: Set up Rust - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Lint with clippy - run: cargo clippy --all-targets -- -D warnings - - - name: Audit dependencies - uses: actions-rust-lang/audit@v1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Run tests - run: cargo test --all - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e98038f7..1b4c8bfaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: run: npx turbo run build --filter=veza-backend-api rust-services: - name: Rust Services (Chat & Stream) + name: Rust Services (Stream) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -81,24 +81,19 @@ jobs: - name: Install cargo-audit run: cargo install cargo-audit - - name: Auditing Chat Server - run: | - cd veza-chat-server - cargo audit - - name: Auditing Stream Server run: | cd veza-stream-server cargo audit - name: Lint - run: npx turbo run lint --filter=veza-chat-server --filter=veza-stream-server + run: npx turbo run lint --filter=veza-stream-server - name: Build - run: npx turbo run build --filter=veza-chat-server --filter=veza-stream-server + run: npx turbo run build --filter=veza-stream-server - name: Test - run: npx turbo run test --filter=veza-chat-server --filter=veza-stream-server + run: npx turbo run test --filter=veza-stream-server frontend: name: Frontend (Web) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index f8acd2b9b..68fd75107 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -3,26 +3,13 @@ on: push: branches: [main] paths: - - 'veza-chat-server/**' - 'veza-stream-server/**' pull_request: branches: [main] paths: - - 'veza-chat-server/**' - 'veza-stream-server/**' jobs: - clippy-chat: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - name: Clippy lint - run: cargo clippy -- -D warnings - working-directory: veza-chat-server - clippy-stream: runs-on: ubuntu-latest steps: diff --git a/config/caddy/Caddyfile.staging b/config/caddy/Caddyfile.staging index 2e34a1c15..ee72f47f5 100644 --- a/config/caddy/Caddyfile.staging +++ b/config/caddy/Caddyfile.staging @@ -1,6 +1,5 @@ {$STAGING_DOMAIN:staging.veza.app} { reverse_proxy /api/* backend:8080 - reverse_proxy /ws chat-server:8081 reverse_proxy /stream stream-server:3001 reverse_proxy /hls/* stream-server:3001 reverse_proxy /* frontend:5173 diff --git a/config/docker/README.md b/config/docker/README.md index 1fc058574..eccc2ab0c 100644 --- a/config/docker/README.md +++ b/config/docker/README.md @@ -15,7 +15,6 @@ This project uses multiple `docker-compose` files for different environments. Af | File | Purpose | Usage | |------|---------|-------| -| `veza-chat-server/docker-compose.yml` | Standalone chat server dev | `cd veza-chat-server && docker compose up` | | `veza-stream-server/docker-compose.yml` | Standalone stream server dev | `cd veza-stream-server && docker compose up` | ## Infrastructure monitoring diff --git a/config/haproxy/haproxy.cfg b/config/haproxy/haproxy.cfg index 318171072..6dd2cb039 100644 --- a/config/haproxy/haproxy.cfg +++ b/config/haproxy/haproxy.cfg @@ -38,14 +38,12 @@ frontend http_frontend # ACLs for routing acl is_api path_beg /api/v1 - acl is_ws path_beg /ws acl is_stream path_beg /stream acl is_hls path_beg /hls acl is_web path_beg / # Route to appropriate backend use_backend backend_api if is_api - use_backend chat_ws if is_ws use_backend stream_ws if is_stream use_backend stream_ws if is_hls use_backend web_frontend if is_web @@ -59,13 +57,11 @@ frontend https_frontend mode http # ACLs for routing acl is_api path_beg /api/v1 - acl is_ws path_beg /ws acl is_stream path_beg /stream acl is_hls path_beg /hls acl is_web path_beg / # Route to appropriate backend use_backend backend_api if is_api - use_backend chat_ws if is_ws use_backend stream_ws if is_stream use_backend stream_ws if is_hls use_backend web_frontend if is_web @@ -84,16 +80,6 @@ backend backend_api # Add more servers for load balancing: # server backend2 backend-api-2:8080 check inter 5s fall 3 rise 2 -# Chat WebSocket (Rust) -backend chat_ws - mode http - balance roundrobin - option httpchk GET /health - http-check expect status 200 - server chat1 chat-server:3000 check inter 5s fall 3 rise 2 - # WebSocket specific options - timeout tunnel 3600s - # Stream WebSocket (Rust) backend stream_ws mode http diff --git a/config/incus/DEBIAN13_MIGRATION.md b/config/incus/DEBIAN13_MIGRATION.md index 53764aad0..7503429bc 100644 --- a/config/incus/DEBIAN13_MIGRATION.md +++ b/config/incus/DEBIAN13_MIGRATION.md @@ -28,8 +28,7 @@ Tous les scripts de déploiement ont été mis à jour pour utiliser **Debian 13 - `veza-web` (10.10.10.5) - Debian 13 ✅ - `veza-haproxy` (10.10.10.6) - Debian 13 ✅ -❌ **2 conteneurs manquants** : -- `veza-chat-server` (10.10.10.3) - Binaire non compilé (erreurs Rust) +❌ **1 conteneur manquant** : - `veza-stream-server` (10.10.10.4) - Binaire non compilé (erreurs Rust) ### Vérification OS @@ -52,7 +51,7 @@ VERSION_CODENAME=trixie - Apache (Web) - HAProxy -3. **Corriger les erreurs de compilation Rust** pour déployer chat-server et stream-server +3. **Corriger les erreurs de compilation Rust** pour déployer stream-server ## Commandes Utiles diff --git a/config/incus/DEPLOYMENT_FEEDBACK.md b/config/incus/DEPLOYMENT_FEEDBACK.md index 88fb3a309..e3414f8d5 100644 --- a/config/incus/DEPLOYMENT_FEEDBACK.md +++ b/config/incus/DEPLOYMENT_FEEDBACK.md @@ -11,7 +11,6 @@ Le déploiement Incus sans Docker est maintenant **complètement implémenté et **Fichier**: `config/incus/build-native.sh` - ✅ Build Go backend (binaire statique Linux) -- ✅ Build Rust chat-server (release mode) - ✅ Build Rust stream-server (release mode) - ✅ Build frontend React (production build) - ✅ Gestion des erreurs et vérifications @@ -35,12 +34,11 @@ Le déploiement Incus sans Docker est maintenant **complètement implémenté et - ✅ Copie des binaires compilés - ✅ Configuration systemd - ✅ Configuration des variables d'environnement -- ✅ Support pour tous les services (backend, chat, stream, web, haproxy, infra) +- ✅ Support pour tous les services (backend, stream, web, haproxy, infra) **Services déployés**: - `veza-infra` (10.10.10.10) - PostgreSQL + Redis - `veza-backend-api` (10.10.10.2) - Backend Go -- `veza-chat-server` (10.10.10.3) - Chat Server Rust - `veza-stream-server` (10.10.10.4) - Stream Server Rust - `veza-web` (10.10.10.5) - Frontend React (Apache) - `veza-haproxy` (10.10.10.6) - Reverse Proxy @@ -50,7 +48,6 @@ Le déploiement Incus sans Docker est maintenant **complètement implémenté et **Dossier**: `config/incus/systemd/` - ✅ `veza-backend-api.service` - Service Backend API -- ✅ `veza-chat-server.service` - Service Chat Server - ✅ `veza-stream-server.service` - Service Stream Server **Caractéristiques**: @@ -64,7 +61,6 @@ Le déploiement Incus sans Docker est maintenant **complètement implémenté et **Dossier**: `config/incus/env/` - ✅ `backend-api.env` - Variables Backend API -- ✅ `chat-server.env` - Variables Chat Server - ✅ `stream-server.env` - Variables Stream Server **Configuration réseau**: @@ -198,7 +194,6 @@ Le container `veza-infra` doit être démarré avant les services applicatifs po Les ports suivants sont utilisés : - Backend API: 8080 -- Chat Server: 8081 - Stream Server: 3002 - Web (Apache): 80 - HAProxy: 80 @@ -239,7 +234,6 @@ Internet HAProxy (10.10.10.6:80) | +---> Backend API (10.10.10.2:8080) - +---> Chat Server (10.10.10.3:8081) +---> Stream Server (10.10.10.4:3002) +---> Web Frontend (10.10.10.5:80) | diff --git a/config/incus/DEPLOYMENT_STATUS.md b/config/incus/DEPLOYMENT_STATUS.md index 423a665d6..3ec8ffb00 100644 --- a/config/incus/DEPLOYMENT_STATUS.md +++ b/config/incus/DEPLOYMENT_STATUS.md @@ -17,8 +17,7 @@ Le déploiement Incus de Veza est **partiellement fonctionnel** mais nécessite - Binaires compilés pour backend-api et web ### ❌ Ce qui ne fonctionne pas -- **4 conteneurs manquants** : - - `veza-chat-server` (10.10.10.3) +- **3 conteneurs manquants** : - `veza-stream-server` (10.10.10.4) - `veza-web` (10.10.10.5) - `veza-haproxy` (10.10.10.6) @@ -65,18 +64,7 @@ dial tcp 10.10.10.10:6379: connect: connection refused 1. Installer et démarrer Redis dans veza-infra 2. Redémarrer veza-backend-api -### 3. Chat Server (veza-chat-server) - -**État**: ❌ Conteneur non déployé - -**Raison**: Binaire non compilé (erreurs de compilation Rust) - -**Action requise**: -1. Corriger les erreurs de compilation Rust -2. Compiler le binaire: `./config/incus/build-native.sh chat-server` -3. Déployer: `./config/incus/deploy-service-native.sh chat-server` - -### 4. Stream Server (veza-stream-server) +### 3. Stream Server (veza-stream-server) **État**: ❌ Conteneur non déployé @@ -87,7 +75,7 @@ dial tcp 10.10.10.10:6379: connect: connection refused 2. Compiler le binaire: `./config/incus/build-native.sh stream-server` 3. Déployer: `./config/incus/deploy-service-native.sh stream-server` -### 5. Web Frontend (veza-web) +### 4. Web Frontend (veza-web) **État**: ❌ Conteneur non déployé @@ -98,7 +86,7 @@ dial tcp 10.10.10.10:6379: connect: connection refused ./config/incus/deploy-service-native.sh web ``` -### 6. HAProxy (veza-haproxy) +### 5. HAProxy (veza-haproxy) **État**: ❌ Conteneur non déployé @@ -134,7 +122,7 @@ incus exec veza-backend-api -- systemctl status veza-backend-api # Déployer haproxy ./config/incus/deploy-service-native.sh haproxy -# Pour chat-server et stream-server, corriger d'abord les erreurs de compilation +# Pour stream-server, corriger d'abord les erreurs de compilation ``` ### Étape 4: Vérification Complète @@ -179,9 +167,9 @@ make incus-start-all ## Problèmes Connus -1. **Compilation Rust échoue** pour chat-server et stream-server +1. **Compilation Rust échoue** pour stream-server - Nécessite correction des erreurs de compilation - - Voir les logs dans `/tmp/chat-build.log` et `/tmp/stream-build.log` + - Voir les logs dans `/tmp/stream-build.log` 2. **Déploiement prend beaucoup de temps** - L'installation des packages peut prendre 5-10 minutes par conteneur @@ -196,7 +184,7 @@ make incus-start-all 1. ✅ **Priorité 1**: Corriger l'infrastructure (PostgreSQL + Redis) 2. ✅ **Priorité 2**: Faire démarrer le Backend API 3. ✅ **Priorité 3**: Déployer Web et HAProxy -4. ⚠️ **Priorité 4**: Corriger les erreurs de compilation Rust pour chat/stream +4. ⚠️ **Priorité 4**: Corriger les erreurs de compilation Rust pour stream-server ## Support diff --git a/config/incus/README.md b/config/incus/README.md index 937e6abdb..2cabdafed 100644 --- a/config/incus/README.md +++ b/config/incus/README.md @@ -23,7 +23,6 @@ Chaque service est déployé dans un container Incus séparé avec des binaires |---------|-----------|-----|------|-------------| | Infrastructure | `veza-infra` | 10.10.10.10 | 5432, 6379 | PostgreSQL + Redis | | Backend API | `veza-backend-api` | 10.10.10.2 | 8080 | Backend Go (binaire natif) | -| Chat Server | `veza-chat-server` | 10.10.10.3 | 8081 | Serveur Chat Rust (binaire natif) | | Stream Server | `veza-stream-server` | 10.10.10.4 | 3002 | Serveur Stream Rust (binaire natif) | | Web Frontend | `veza-web` | 10.10.10.5 | 80 | Frontend React (Apache - fichiers statiques uniquement) | | HAProxy | `veza-haproxy` | 10.10.10.6 | 80 | Reverse Proxy | @@ -127,7 +126,6 @@ incus exec veza-backend-api -- systemctl status veza-backend-api Les fichiers de configuration sont dans `config/incus/env/` : - `backend-api.env` - Configuration Backend API -- `chat-server.env` - Configuration Chat Server - `stream-server.env` - Configuration Stream Server **Important** : Ces fichiers `.env` ne sont pas versionnés (ils contiennent des secrets). @@ -137,7 +135,7 @@ Créez-les localement à partir de `env/env.example` : cd config/incus/env # Créer les fichiers à partir du template et remplir les valeurs cp env.example backend-api.env # puis éditer -# Idem pour chat-server.env et stream-server.env +# Idem pour stream-server.env ``` Modifiez ces fichiers avant le déploiement ou éditez-les dans les containers : @@ -151,7 +149,6 @@ incus exec veza-backend-api -- systemctl restart veza-backend-api Les fichiers systemd sont dans `config/incus/systemd/` : - `veza-backend-api.service` -- `veza-chat-server.service` - `veza-stream-server.service` ## Accès aux services @@ -160,7 +157,6 @@ Une fois déployé, les services sont accessibles via : - **HAProxy (point d'entrée)** : http://10.10.10.6:80 - **Backend API** : http://10.10.10.2:8080 -- **Chat Server** : ws://10.10.10.3:8081/ws - **Stream Server** : ws://10.10.10.4:3002/stream - **Web Frontend** : http://10.10.10.5:80 @@ -213,7 +209,6 @@ make deploy-incus **HAProxy** est le point d'entrée principal et gère tout le routing : - `/api/v1/*` → Backend API (10.10.10.2:8080) -- `/ws/*` → Chat Server (10.10.10.3:8081) - `/stream/*` → Stream Server (10.10.10.4:3002) - `/*` → Web Frontend (10.10.10.5:80) diff --git a/config/incus/build-native.sh b/config/incus/build-native.sh index 06f1f6bd4..4b237f7dc 100755 --- a/config/incus/build-native.sh +++ b/config/incus/build-native.sh @@ -45,49 +45,6 @@ build_backend_api() { echo -e "${GREEN}✅ backend-api built${NC}" } -build_chat_server() { - echo -e "${BLUE}Building chat-server (Rust)...${NC}" - cd "${PROJECT_ROOT}/veza-chat-server" - - # Try cross-compilation first, fallback to native - BINARY_PATH="" - if command -v rustup >/dev/null 2>&1 && rustup target list --installed | grep -q "x86_64-unknown-linux-gnu"; then - echo -e "${YELLOW}Attempting cross-compilation...${NC}" - if cargo build --release --target x86_64-unknown-linux-gnu 2>&1 | tee /tmp/chat-build.log; then - BINARY_PATH="target/x86_64-unknown-linux-gnu/release/chat-server" - fi - fi - - # Fallback to native build - if [ -z "${BINARY_PATH}" ] || [ ! -f "${BINARY_PATH}" ]; then - echo -e "${YELLOW}Using native build...${NC}" - if cargo build --release 2>&1 | tee /tmp/chat-build.log; then - BINARY_PATH="target/release/chat-server" - else - echo -e "${RED}❌ Failed to build chat-server${NC}" - echo -e "${YELLOW}Build log saved to /tmp/chat-build.log${NC}" - return 1 - fi - fi - - # Copy binary - if [ -f "${BINARY_PATH}" ]; then - cp "${BINARY_PATH}" "${BUILD_DIR}/veza-chat-server" - chmod +x "${BUILD_DIR}/veza-chat-server" - else - echo -e "${RED}❌ Failed to build chat-server: binary not found${NC}" - return 1 - fi - - if [ ! -f "${BUILD_DIR}/veza-chat-server" ]; then - echo -e "${RED}❌ Failed to copy chat-server binary${NC}" - return 1 - fi - - echo -e "${GREEN}✅ chat-server built${NC}" - return 0 -} - build_stream_server() { echo -e "${BLUE}Building stream-server (Rust)...${NC}" cd "${PROJECT_ROOT}/veza-stream-server" @@ -178,9 +135,6 @@ case "${SERVICE}" in backend-api) build_backend_api ;; - chat-server) - build_chat_server - ;; stream-server) build_stream_server ;; @@ -190,7 +144,6 @@ case "${SERVICE}" in all) FAILED=0 build_backend_api || FAILED=$((FAILED + 1)) - build_chat_server || FAILED=$((FAILED + 1)) build_stream_server || FAILED=$((FAILED + 1)) build_web || FAILED=$((FAILED + 1)) @@ -206,7 +159,7 @@ case "${SERVICE}" in ;; *) echo -e "${YELLOW}Unknown service: ${SERVICE}${NC}" - echo "Available services: backend-api, chat-server, stream-server, web, all" + echo "Available services: backend-api, stream-server, web, all" exit 1 ;; esac diff --git a/config/incus/check-deployment.sh b/config/incus/check-deployment.sh index 186e7d7fe..c77890ac7 100755 --- a/config/incus/check-deployment.sh +++ b/config/incus/check-deployment.sh @@ -15,7 +15,7 @@ echo "" # 1. Vérifier les conteneurs echo -e "${BLUE}1. Conteneurs Incus:${NC}" -EXPECTED_CONTAINERS=("veza-infra" "veza-backend-api" "veza-chat-server" "veza-stream-server" "veza-web" "veza-haproxy") +EXPECTED_CONTAINERS=("veza-infra" "veza-backend-api" "veza-stream-server" "veza-web" "veza-haproxy") ALL_CONTAINERS_OK=true for container in "${EXPECTED_CONTAINERS[@]}"; do @@ -49,16 +49,6 @@ if incus list -c n --format csv 2>/dev/null | grep -q "^veza-backend-api$"; then fi fi -# Chat Server -if incus list -c n --format csv 2>/dev/null | grep -q "^veza-chat-server$"; then - if incus exec veza-chat-server -- systemctl is-active --quiet veza-chat-server 2>/dev/null; then - echo -e " ${GREEN}✅ veza-chat-server - ACTIVE${NC}" - else - echo -e " ${YELLOW}⚠️ veza-chat-server - INACTIVE${NC}" - ALL_SERVICES_OK=false - fi -fi - # Stream Server if incus list -c n --format csv 2>/dev/null | grep -q "^veza-stream-server$"; then if incus exec veza-stream-server -- systemctl is-active --quiet veza-stream-server 2>/dev/null; then @@ -146,19 +136,6 @@ if incus list -c n --format csv 2>/dev/null | grep -q "^veza-backend-api$"; then fi fi -# Chat Server -if incus list -c n --format csv 2>/dev/null | grep -q "^veza-chat-server$"; then - if incus exec veza-chat-server -- systemctl is-active --quiet veza-chat-server 2>/dev/null; then - if incus exec veza-chat-server -- timeout 3 curl -s -f http://localhost:8081/health >/dev/null 2>&1; then - echo -e " ${GREEN}✅ Chat Server (http://10.10.10.3:8081) - OK${NC}" - else - echo -e " ${YELLOW}⚠️ Chat Server - Service running but endpoint not responding${NC}" - fi - else - echo -e " ${RED}❌ Chat Server - Service not running${NC}" - fi -fi - # Stream Server if incus list -c n --format csv 2>/dev/null | grep -q "^veza-stream-server$"; then if incus exec veza-stream-server -- systemctl is-active --quiet veza-stream-server 2>/dev/null; then diff --git a/config/incus/deploy-service-native.sh b/config/incus/deploy-service-native.sh index 6a01aaa7e..1ffc98eb7 100755 --- a/config/incus/deploy-service-native.sh +++ b/config/incus/deploy-service-native.sh @@ -13,7 +13,7 @@ BUILD_DIR="${PROJECT_ROOT}/.build/incus" if [ -z "$SERVICE" ]; then echo "Usage: $0 " - echo "Services: backend-api, chat-server, stream-server, web, haproxy, infra" + echo "Services: backend-api, stream-server, web, haproxy, infra" exit 1 fi @@ -109,9 +109,6 @@ case ${SERVICE} in backend-api) STATIC_IP="10.10.10.2" ;; - chat-server) - STATIC_IP="10.10.10.3" - ;; stream-server) STATIC_IP="10.10.10.4" ;; @@ -419,44 +416,6 @@ case ${SERVICE} in incus exec ${CONTAINER_NAME} -- systemctl start veza-backend-api || echo "Warning: Service start failed, check logs" ;; - chat-server) - echo "Installing Rust runtime dependencies..." - incus exec ${CONTAINER_NAME} -- bash -c " - export DEBIAN_FRONTEND=noninteractive - apt-get install -y -qq ca-certificates libc6 libssl3 - " - - # Copy binary - echo "Copying chat-server binary..." - if [ ! -f "${BUILD_DIR}/veza-chat-server" ]; then - echo "ERROR: Binary not found at ${BUILD_DIR}/veza-chat-server" - echo "Please run: make build-all-native or ./config/incus/build-native.sh chat-server" - exit 1 - fi - incus file push "${BUILD_DIR}/veza-chat-server" ${CONTAINER_NAME}/usr/local/bin/veza-chat-server - incus exec ${CONTAINER_NAME} -- chmod +x /usr/local/bin/veza-chat-server - - # Create directories - incus exec ${CONTAINER_NAME} -- bash -c " - mkdir -p /opt/veza/chat-server - mkdir -p /var/log/veza - mkdir -p /etc/veza - " - - # Copy systemd service - incus file push "${PROJECT_ROOT}/config/incus/systemd/veza-chat-server.service" \ - ${CONTAINER_NAME}/etc/systemd/system/veza-chat-server.service - - # Copy environment file template - incus file push "${PROJECT_ROOT}/config/incus/env/chat-server.env" \ - ${CONTAINER_NAME}/etc/veza/chat-server.env 2>/dev/null || true - - # Enable and start service - incus exec ${CONTAINER_NAME} -- systemctl daemon-reload - incus exec ${CONTAINER_NAME} -- systemctl enable veza-chat-server - incus exec ${CONTAINER_NAME} -- systemctl start veza-chat-server || echo "Warning: Service start failed, check logs" - ;; - stream-server) echo "Installing Rust runtime dependencies..." incus exec ${CONTAINER_NAME} -- bash -c " @@ -850,7 +809,7 @@ if incus list ${CONTAINER_NAME} --format csv | grep -q "${CONTAINER_NAME}"; then # Check service status if applicable case ${SERVICE} in - backend-api|chat-server|stream-server) + backend-api|stream-server) SERVICE_NAME="veza-${SERVICE}" if incus exec ${CONTAINER_NAME} -- systemctl is-active ${SERVICE_NAME} >/dev/null 2>&1; then echo "✅ Service ${SERVICE_NAME} is running" diff --git a/config/incus/deploy-service.sh b/config/incus/deploy-service.sh index d10afe5d6..319cc022d 100755 --- a/config/incus/deploy-service.sh +++ b/config/incus/deploy-service.sh @@ -11,7 +11,7 @@ PROFILE="veza-profile" if [ -z "$SERVICE" ]; then echo "Usage: $0 " - echo "Services: backend-api, chat-server, stream-server, web, haproxy" + echo "Services: backend-api, stream-server, web, haproxy" exit 1 fi @@ -59,9 +59,6 @@ case ${SERVICE} in backend-api) incus file push -r ../../veza-backend-api ${CONTAINER_NAME}/opt/veza/ ;; - chat-server) - incus file push -r ../../veza-chat-server ${CONTAINER_NAME}/opt/veza/ - ;; stream-server) incus file push -r ../../veza-stream-server ${CONTAINER_NAME}/opt/veza/ ;; diff --git a/config/incus/env/env.example b/config/incus/env/env.example index f6af901fd..e4852cc38 100644 --- a/config/incus/env/env.example +++ b/config/incus/env/env.example @@ -1,5 +1,5 @@ # Incus Environment Templates -# Copy the relevant section to backend-api.env, chat-server.env, stream-server.env +# Copy the relevant section to backend-api.env, stream-server.env # NEVER commit real .env files — they contain secrets. # Create these files locally: cp env.example backend-api.env && edit backend-api.env @@ -16,19 +16,9 @@ # JWT_SECRET=${JWT_SECRET} # CORS_ALLOWED_ORIGINS=https://veza.fr,https://app.veza.fr # STREAM_SERVER_URL=http://10.10.10.4:3002 -# CHAT_SERVER_URL=http://10.10.10.3:8081 # ENABLE_CLAMAV=false # CLAMAV_REQUIRED=false -# === chat-server.env === -# RUST_ENV=production -# RUST_LOG=info -# DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:5432/veza?sslmode=disable -# JWT_SECRET=${JWT_SECRET} -# SERVER_BIND_ADDR=0.0.0.0:8081 -# REDIS_URL=redis://${REDIS_HOST}:6379 -# RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@${RABBITMQ_HOST}:5672/%2f - # === stream-server.env === # RUST_ENV=production # RUST_LOG=info diff --git a/config/incus/fix-network-now.sh b/config/incus/fix-network-now.sh index 5a1a1bc2e..fbb34db1c 100755 --- a/config/incus/fix-network-now.sh +++ b/config/incus/fix-network-now.sh @@ -145,7 +145,6 @@ for CONTAINER in ${CONTAINERS}; do case ${CONTAINER} in *infra*) CONTAINER_IP="10.10.10.10" ;; *backend-api*) CONTAINER_IP="10.10.10.2" ;; - *chat-server*) CONTAINER_IP="10.10.10.3" ;; *stream-server*) CONTAINER_IP="10.10.10.4" ;; *web*) CONTAINER_IP="10.10.10.5" ;; *haproxy*) CONTAINER_IP="10.10.10.6" ;; diff --git a/config/incus/haproxy.cfg b/config/incus/haproxy.cfg index 8eef94d49..dcf41f41b 100644 --- a/config/incus/haproxy.cfg +++ b/config/incus/haproxy.cfg @@ -47,11 +47,11 @@ frontend http_frontend acl is_stream path_beg /stream acl is_web path_beg / - # Return 503 for WebSocket endpoints (chat/stream not available) - # Note: chat-server and stream-server are disabled (Rust services not deployed) + # Return 503 for WebSocket endpoints (stream not available) + # Note: stream-server is disabled (Rust service not deployed) # Must be before redirect to avoid processing order issues - http-request return status 503 content-type "text/plain" string "Service temporarily unavailable: chat-server and stream-server are not deployed" if is_ws - http-request return status 503 content-type "text/plain" string "Service temporarily unavailable: chat-server and stream-server are not deployed" if is_stream + http-request return status 503 content-type "text/plain" string "Service temporarily unavailable: stream-server is not deployed" if is_ws + http-request return status 503 content-type "text/plain" string "Service temporarily unavailable: stream-server is not deployed" if is_stream # Redirect HTTP to HTTPS (after WebSocket checks) redirect scheme https code 301 if !{ ssl_fc } @@ -72,17 +72,6 @@ backend backend_api http-check expect status 200 server backend1 10.10.10.2:8080 check inter 5s fall 3 rise 2 -# Chat WebSocket (Rust) - veza-chat-server container -# DISABLED: chat-server is not deployed (Rust compilation issues) -# backend chat_ws -# mode http -# balance roundrobin -# option httpchk GET /health -# http-check expect status 200 -# server chat1 10.10.10.3:8081 check inter 5s fall 3 rise 2 -# # WebSocket specific options -# timeout tunnel 3600s - # Stream WebSocket (Rust) - veza-stream-server container # DISABLED: stream-server is not deployed (Rust compilation issues) # backend stream_ws diff --git a/config/incus/setup-basic-incus.sh b/config/incus/setup-basic-incus.sh index 2cf085497..531a50370 100755 --- a/config/incus/setup-basic-incus.sh +++ b/config/incus/setup-basic-incus.sh @@ -142,4 +142,4 @@ echo "" echo "You can now deploy services with:" echo " ./deploy-service-native.sh " echo "" -echo "Available services: infra, backend-api, chat-server, stream-server, web, haproxy" +echo "Available services: infra, backend-api, stream-server, web, haproxy" diff --git a/config/incus/systemd/veza-chat-server.service b/config/incus/systemd/veza-chat-server.service deleted file mode 100644 index 93a38cf5d..000000000 --- a/config/incus/systemd/veza-chat-server.service +++ /dev/null @@ -1,27 +0,0 @@ -[Unit] -Description=Veza Chat Server Service -After=network.target - -[Service] -Type=simple -User=root -WorkingDirectory=/opt/veza/chat-server -ExecStart=/usr/local/bin/veza-chat-server -Restart=always -RestartSec=5 -StandardOutput=journal -StandardError=journal -SyslogIdentifier=veza-chat-server - -# Environment variables -EnvironmentFile=/etc/veza/chat-server.env - -# Security -NoNewPrivileges=true -PrivateTmp=true - -# Resource limits -LimitNOFILE=65536 - -[Install] -WantedBy=multi-user.target diff --git a/config/incus/verify-deployment.sh b/config/incus/verify-deployment.sh index c94853af1..e71e4e145 100755 --- a/config/incus/verify-deployment.sh +++ b/config/incus/verify-deployment.sh @@ -15,7 +15,7 @@ echo "" # Check if containers exist echo -e "${BLUE}Checking containers...${NC}" -CONTAINERS=("veza-infra" "veza-backend-api" "veza-chat-server" "veza-stream-server" "veza-web" "veza-haproxy") +CONTAINERS=("veza-infra" "veza-backend-api" "veza-stream-server" "veza-web" "veza-haproxy") ALL_EXIST=true for container in "${CONTAINERS[@]}"; do @@ -39,7 +39,6 @@ echo "" echo -e "${BLUE}Checking IP addresses...${NC}" EXPECTED_IPS=( "veza-backend-api:10.10.10.2" - "veza-chat-server:10.10.10.3" "veza-stream-server:10.10.10.4" "veza-web:10.10.10.5" "veza-haproxy:10.10.10.6" @@ -64,7 +63,7 @@ echo "" # Check services echo -e "${BLUE}Checking systemd services...${NC}" -SERVICES=("veza-backend-api" "veza-chat-server" "veza-stream-server") +SERVICES=("veza-backend-api" "veza-stream-server") for service in "${SERVICES[@]}"; do container="veza-$(echo $service | sed 's/veza-//')" @@ -143,18 +142,6 @@ if incus list -c n --format csv | grep -q "^veza-backend-api$"; then fi fi -if incus list -c n --format csv | grep -q "^veza-chat-server$"; then - if incus exec veza-chat-server -- systemctl is-active --quiet veza-chat-server 2>/dev/null; then - if incus exec veza-chat-server -- timeout 2 curl -s -f http://localhost:8081/health >/dev/null 2>&1; then - echo -e " ${GREEN}✅ Chat Server health check - OK${NC}" - else - echo -e " ${YELLOW}⚠️ Chat Server health check - Service running but endpoint not responding${NC}" - fi - else - echo -e " ${YELLOW}⚠️ Chat Server - Service not running${NC}" - fi -fi - if incus list -c n --format csv | grep -q "^veza-stream-server$"; then if incus exec veza-stream-server -- systemctl is-active --quiet veza-stream-server 2>/dev/null; then if incus exec veza-stream-server -- timeout 2 curl -s -f http://localhost:3002/health >/dev/null 2>&1; then diff --git a/config/prometheus.yml b/config/prometheus.yml index db94ffa41..f3a7cc898 100644 --- a/config/prometheus.yml +++ b/config/prometheus.yml @@ -15,11 +15,6 @@ scrape_configs: - targets: ['backend-api:8080'] # Use container name in same network metrics_path: '/metrics' - - job_name: 'veza-chat' - static_configs: - - targets: ['chat-server:8081'] # Use container name - metrics_path: '/metrics' - - job_name: 'veza-stream' static_configs: - targets: ['stream-server:8082'] # Use container name diff --git a/package.json b/package.json index 25889af09..9dac9407b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "veza-monorepo", "private": true, "packageManager": "npm@10.9.2", - "workspaces": ["apps/web", "packages/*", "veza-backend-api", "veza-chat-server", "veza-stream-server"], + "workspaces": ["apps/web", "packages/*", "veza-backend-api", "veza-stream-server"], "overrides": { "axios": ">=1.13.5" }, diff --git a/veza-chat-server/.clippy.toml b/veza-chat-server/.clippy.toml deleted file mode 100644 index b99414326..000000000 --- a/veza-chat-server/.clippy.toml +++ /dev/null @@ -1,14 +0,0 @@ -# Configuration Clippy pour le chat server -# Ignorer les warnings non critiques pour se concentrer sur les erreurs importantes - -# Ignorer les warnings de formatage -allow-mixed-uninlined-format-args = true - -# Ignorer les warnings de variables inutilisées (code en développement) -allow-unwrap-in-tests = true -allow-expect-in-tests = true -allow-dbg-in-tests = true - -# Ignorer les warnings de style -allow-comparison-to-zero = true - diff --git a/veza-chat-server/.dockerignore b/veza-chat-server/.dockerignore deleted file mode 100644 index 5e716a2e6..000000000 --- a/veza-chat-server/.dockerignore +++ /dev/null @@ -1,68 +0,0 @@ -# Rust build artifacts -target/ -**/*.rs.bk -*.pdb - -# Cargo -# Cargo.lock removed to allow reproducible builds -# Note: We DO want Cargo.lock in the container for reproducible builds - -# Test files -**/*_test.rs -**/test_*.rs -tests/ -*.test - -# Documentation -*.md -docs/ -README.md -docs/ - -# Git -.git -.gitignore -.gitattributes - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db - -# Logs -*.log -logs/ - -# Environment -.env -.env.local -.env.*.local - -# Build scripts (not needed in container) -Makefile -deploy-simple.sh - -# Docker -Dockerfile* -.dockerignore -docker-compose*.yml - -# Temporary files -tmp/ -temp/ -*.tmp - -# SQLx metadata - KEEP sqlx-data.json for SQLX_OFFLINE build (v0.101) -# sqlx-data.json removed from ignore to allow Docker build without DB -# SQLX_METADATA.md - -# Config files (not needed in container if using env vars) -config/ -# proto/ removed to allow building protos - diff --git a/veza-chat-server/.env.lab.example b/veza-chat-server/.env.lab.example deleted file mode 100644 index 66b7e157d..000000000 --- a/veza-chat-server/.env.lab.example +++ /dev/null @@ -1,18 +0,0 @@ -# Configuration Lab pour Veza Chat Server -# Copiez ce fichier vers .env.lab - -# Base de données (avec schema chat forcé) -# Note: Les scripts lab ajoutent automatiquement options=-c search_path=chat si absent -VEZA_LAB_DSN="postgres://veza:veza_password@veza.fr:5432/veza_lab?sslmode=disable" -DATABASE_URL="postgres://veza:veza_password@veza.fr:5432/veza_lab?sslmode=disable&options=-c%20search_path=chat" - -# Serveur -CHAT_SERVER_PORT=8081 -CHAT_SERVER_HOST=0.0.0.0 -RUST_LOG=info,chat_server=debug - -# Sécurité (Généré auto par start_lab.sh si absent) -# JWT_SECRET=... - -# RabbitMQ (Désactivé par défaut en lab) -RABBITMQ_ENABLE=false diff --git a/veza-chat-server/.gitignore b/veza-chat-server/.gitignore deleted file mode 100644 index d63bcfd5f..000000000 --- a/veza-chat-server/.gitignore +++ /dev/null @@ -1,42 +0,0 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.test - -# Output of the go coverage tool, specifically when used with LiteIDE -*.out - -# Dependency directories (remove the comment below to include it) -# vendor/ - -# Go workspace file -go.work -go.work.sum - -# env file -.env -.env.* -!.env.lab.example -target/ -*.zip -tree.txt -all/ -bin/ -node_modules/ -.parcel-cache/ -dist/ -.DS_Store -versions_details/ -tests/ -static/ -go.sum -Cargo.lock -.cursor* diff --git a/veza-chat-server/AUDIT_CHAT_SERVER_RUST.md b/veza-chat-server/AUDIT_CHAT_SERVER_RUST.md deleted file mode 100644 index 2557c56bf..000000000 --- a/veza-chat-server/AUDIT_CHAT_SERVER_RUST.md +++ /dev/null @@ -1,1019 +0,0 @@ -# 🔍 AUDIT TECHNIQUE EXHAUSTIF - CHAT SERVER RUST -## Veza/Talas - Serveur de Chat Temps-Réel - -**Date**: 2025-01-27 -**Version analysée**: 0.2.0 -**Auditeur**: Auto (AI Agent) -**Durée audit**: Exhaustif multi-phases - ---- - -## 📋 SECTION 1 : RÉSUMÉ EXÉCUTIF - -### État Global du Projet - -| Composant | État | Complétude | Notes | -|-----------|------|------------|-------| -| **WebSocket Server** | 🟡 Partiel | 65% | Framework Axum OK, auth JWT présente mais incomplète | -| **Messages 1-to-1** | 🟢 Fonctionnel | 80% | Implémenté via conversations, persistence OK | -| **Group Chat/Channels** | 🟡 Partiel | 50% | Structure présente, permissions incomplètes | -| **Typing Indicators** | 🟢 Fonctionnel | 75% | Implémenté, timeout 3s, cleanup présent | -| **Read Receipts** | 🟡 Partiel | 40% | Structure DB présente, logique TODO ligne 258 | -| **Presence** | 🟢 Fonctionnel | 70% | Online/Away/Busy/Offline, multi-device partiel | -| **Modération** | 🟡 Partiel | 60% | Auto-modération basique, sanctions DB présentes | -| **Performance** | 🟡 À améliorer | 55% | Pas de benchmarks, optimisations manquantes | -| **Sécurité** | 🟠 Critique | 45% | Auth WebSocket OK, rate limiting présent, validation input partielle | -| **Tests** | 🔴 Insuffisant | 25% | Tests unitaires rares, integration tests ignorés | -| **Documentation** | 🟡 Partielle | 40% | Doc comments présents, exemples manquants | - -### Top 10 Problèmes Critiques - -1. **🔴 P0 - Auth WebSocket Token dans Query Params** : Token JWT exposé dans URL (ligne 43 `main.rs`) -2. **🔴 P0 - Read Receipts Non Implémentés** : TODO ligne 258 `websocket/handler.rs` -3. **🔴 P0 - Tests Coverage < 30%** : Tests d'intégration ignorés, unitaires rares -4. **🟠 P1 - Clippy Warnings Non Résolus** : 10+ warnings compilation (unused imports, PartialEq manquant) -5. **🟠 P1 - Rate Limiting Non Appliqué** : `DosProtectionManager` présent mais non utilisé dans handler -6. **🟠 P1 - Validation Input Messages** : Pas de sanitization XSS, longueur max non vérifiée -7. **🟠 P1 - Heartbeat/Ping-Pong Incomplet** : Ping géré, mais pas de timeout idle connections -8. **🟡 P2 - Modération Auto Basique** : Détection spam simpliste (similarité Levenshtein) -9. **🟡 P2 - Pas de Redis Pub/Sub** : Broadcast limité à instance unique (pas de scaling horizontal) -10. **🟡 P2 - Metrics Prometheus Non Utilisées** : Métriques définies mais non enregistrées dans handlers - -### Effort Total Correction Estimé - -- **P0 (Bloquants)**: 3-5 jours -- **P1 (Critiques)**: 5-7 jours -- **P2 (Majeurs)**: 7-10 jours -- **P3 (Mineurs)**: 3-5 jours -- **TOTAL**: 18-27 jours (3.5-5 semaines) - ---- - -## 📊 SECTION 2 : CARTOGRAPHIE - -### 2.1 Arborescence Rust - -``` -veza-chat-server/ -├── src/ -│ ├── main.rs ✅ Point d'entrée (Axum server) -│ ├── lib.rs ✅ Library root -│ ├── config.rs ✅ Configuration (env vars) -│ ├── error.rs ✅ Error types (thiserror) -│ ├── jwt_manager.rs ✅ JWT avec refresh tokens -│ ├── websocket/ -│ │ ├── mod.rs ✅ Types messages WS -│ │ ├── handler.rs ⚠️ Handler WS (auth query param) -│ │ └── broadcast.rs ✅ Broadcast manager -│ ├── repository/ -│ │ ├── message_repository.rs ✅ CRUD messages (SQLx) -│ │ └── room_repository.rs ✅ CRUD rooms -│ ├── models/ -│ │ └── message.rs ✅ Modèle Message (UUID) -│ ├── services/ -│ │ └── room_service.rs ✅ Service rooms -│ ├── auth.rs ⚠️ Auth manager (non utilisé) -│ ├── authentication.rs ⚠️ Auth alternatif (non utilisé) -│ ├── typing_indicator.rs ✅ Typing indicators -│ ├── read_receipts.rs ⚠️ Read receipts (i64 au lieu UUID) -│ ├── presence.rs ✅ Presence manager -│ ├── moderation.rs ⚠️ Modération (i32 user_id) -│ ├── rate_limiter.rs ⚠️ Rate limiter (non utilisé) -│ ├── prometheus_metrics.rs ⚠️ Metrics définies mais non utilisées -│ ├── event_bus.rs ✅ RabbitMQ EventBus -│ ├── database/ -│ │ └── pool.rs ✅ Pool PostgreSQL -│ ├── security/ -│ │ └── csrf.rs ✅ CSRF tokens -│ ├── core/ ⚠️ Modules non utilisés (duplication?) -│ └── hub/ ⚠️ Modules non utilisés (duplication?) -├── migrations/ -│ ├── 001_create_clean_database.sql ✅ Schéma base -│ └── 002_advanced_features.sql ✅ Features avancées -├── tests/ -│ └── integration_test.rs ⚠️ Tests ignorés (#[ignore]) -├── Cargo.toml ✅ Dépendances complètes -└── env.example ✅ Configuration exemple -``` - -**Problèmes détectés**: -- Duplication: `auth.rs` vs `authentication.rs` vs `jwt_manager.rs` -- Modules non utilisés: `core/`, `hub/` (code mort?) -- Incohérence types: `i32` vs `UUID` (read_receipts, moderation) - -### 2.2 Dépendances Cargo - -| Catégorie | Dépendance | Version | État | Notes | -|-----------|------------|--------|------|-------| -| **Runtime** | tokio | 1.35 | ✅ | Full features | -| **WebSocket** | axum | 0.8 | ✅ | Framework moderne | -| **WebSocket** | tokio-tungstenite | 0.21 | ✅ | Core WS | -| **Database** | sqlx | 0.8.6 | ✅ | PostgreSQL, macros | -| **Cache** | redis | 0.32 | ⚠️ | Optional, non utilisé | -| **Auth** | jsonwebtoken | 9.2 | ✅ | JWT | -| **Serialization** | serde | 1.0 | ✅ | JSON | -| **Logging** | tracing | 0.1 | ✅ | Structured logging | -| **Metrics** | metrics | 0.22 | ⚠️ | Optional, non utilisé | -| **gRPC** | tonic | 0.11 | ⚠️ | Présent mais non utilisé | -| **Event Bus** | lapin | 2.3 | ✅ | RabbitMQ | -| **Validation** | validator | 0.20 | ⚠️ | Présent mais non utilisé | -| **Security** | bcrypt | 0.17 | ⚠️ | Présent mais non utilisé | - -**Problèmes**: -- Dépendances optionnelles non utilisées (redis, metrics, validator) -- gRPC présent mais serveur non démarré dans `main.rs` -- Versions à jour ✅ - -### 2.3 Configuration - -**Fichiers config**: -- `env.example` : ✅ Complet (131 lignes) -- `.env` : Présent (non lu pour audit) -- `config.rs` : ✅ Chargement depuis env vars - -**Paramètres hardcodés**: -- `main.rs:162` : JWT secret par défaut hardcodé (fallback) -- `typing_indicator.rs:19` : Timeout 3s hardcodé -- `rate_limiter.rs:138` : MAX_MESSAGES_PER_MINUTE = 60 hardcodé - -**Secrets management**: -- ⚠️ JWT secret dans env var (pas de vault) -- ⚠️ Pas de rotation automatique secrets - ---- - -## ⚡ SECTION 3 : WEBSOCKET & PROTOCOLE - -### 3.1 Serveur WebSocket - -**Framework**: Axum 0.8 avec `ws` feature ✅ - -**Architecture**: -- Single-threaded tokio runtime (par défaut) -- Connection pooling via `WebSocketManager` (Vec>) -- Broadcast via `broadcast.rs` (tokio::sync::broadcast channels) -- State: `Arc>` pour clients - -**Gestion connections**: -- ✅ Handshake WebSocket via Axum `WebSocketUpgrade` -- ⚠️ **PROBLÈME P0**: Token JWT dans query params (ligne 43 `handler.rs`) -- ✅ Ping/Pong implémenté (lignes 129-138 `handler.rs`) -- ❌ Pas de timeout idle connections automatique -- ❌ Pas de heartbeat périodique (seulement ping client-initiated) - -**Code problématique**: -```rust -// src/websocket/handler.rs:43 -let token = match params.get("token") { - Some(t) => t, - None => { - error!("❌ Token manquant"); - return (StatusCode::UNAUTHORIZED, "Missing token").into_response(); - } -}; -``` -**Impact**: Token exposé dans logs serveur, URL, historique navigateur. - -### 3.2 Protocole Messages - -**Format**: JSON via `serde_json` ✅ - -**Schéma messages**: -```rust -// IncomingMessage (src/websocket/mod.rs:24) -pub enum IncomingMessage { - SendMessage { conversation_id, content, parent_message_id }, - JoinConversation { conversation_id }, - LeaveConversation { conversation_id }, - MarkAsRead { conversation_id, message_id }, - Ping, -} - -// OutgoingMessage (src/websocket/mod.rs:47) -pub enum OutgoingMessage { - NewMessage { conversation_id, message_id, sender_id, content, created_at }, - ActionConfirmed { action, success }, - Error { message }, - Pong, -} -``` - -**Validation**: -- ✅ Deserialization avec `serde_json::from_str` -- ⚠️ Pas de validation longueur message (MAX_MESSAGE_LENGTH non appliqué) -- ❌ Pas de sanitization XSS (ammonia présent mais non utilisé) -- ❌ Pas de validation conversation_id (existence, permissions) - -### 3.3 Authentification WebSocket - -**Mécanisme**: JWT dans query params ⚠️ - -**Validation**: -- ✅ `JwtManager::validate_access_token` (ligne 52 `handler.rs`) -- ✅ Claims extraits (`AccessTokenClaims`) -- ⚠️ Pas de refresh token support dans WS -- ❌ Pas de validation permissions conversation - -**Failles**: -1. Token dans URL (exposé logs, cache, referrer) -2. Pas de validation conversation access avant join -3. Pas de rate limiting par user_id (seulement par connection_id) - ---- - -## 💬 SECTION 4 : FONCTIONNALITÉS CHAT - -### 4.1 Messages 1-to-1 - -| Feature | Status | Implémentation | Notes | -|---------|--------|----------------|-------| -| Envoi message direct | ✅ | `MessageRepository::create` | UUID-based | -| Réception temps-réel | ✅ | `broadcast_to_conversation` | Via WebSocket | -| Historique messages | ✅ | `get_conversation_messages` | Pagination LIMIT | -| Chiffrement E2E | ❌ | Absent | Non implémenté | - -**Code**: -```rust -// src/repository/message_repository.rs:18 -pub async fn create( - &self, - conversation_id: Uuid, - sender_id: Uuid, - content: &str, -) -> Result -``` - -**Gaps**: -- Pas de chiffrement end-to-end -- Pagination basique (LIMIT/OFFSET, pas de cursor) -- Pas de recherche full-text - -### 4.2 Group Chat & Channels - -| Feature | Status | Implémentation | Notes | -|---------|--------|----------------|-------| -| Création groupe | ✅ | `RoomService::create_room` | UUID-based | -| Ajout membres | ✅ | `RoomService::add_user` | Via conversation_members | -| Suppression membres | ✅ | `RoomService::remove_user` | | -| Permissions | ⚠️ | Partiel | Rôles présents, vérification incomplète | -| Broadcast messages | ✅ | `broadcast_to_conversation` | | -| Historique groupe | ✅ | `get_conversation_messages` | | - -**Permissions**: -- Rôles définis: `admin`, `moderator`, `member` (migration 001) -- ⚠️ Vérification permissions non appliquée dans handler WS - -### 4.3 Features Temps-Réel - -#### Typing Indicators - -**Status**: ✅ Fonctionnel (75%) - -**Implémentation**: -- `TypingIndicatorManager` (src/typing_indicator.rs) -- Timeout: 3s (hardcodé ligne 19) -- Cleanup périodique présent -- ⚠️ Pas de broadcast WebSocket intégré (manager isolé) - -**Code**: -```rust -// src/typing_indicator.rs:25 -pub async fn set_typing(&self, conversation_id: &str, user_id: &str) -``` - -**Gaps**: -- Pas de message WebSocket pour typing events -- Timeout non configurable -- Pas de debouncing côté client - -#### Read Receipts - -**Status**: 🟡 Partiel (40%) - -**Implémentation**: -- Structure DB présente (`read_receipts` table) -- `ReadReceiptManager` présent (src/read_receipts.rs) -- ❌ **PROBLÈME P0**: TODO ligne 258 `websocket/handler.rs` -- ⚠️ Types incohérents: `i64` au lieu de `UUID` - -**Code problématique**: -```rust -// src/websocket/handler.rs:249 -IncomingMessage::MarkAsRead { conversation_id, message_id } => { - debug!("👁️ Client {} marque le message {} comme lu", client.id, message_id); - // TODO: Implémenter la logique de marquage comme lu - let outgoing = OutgoingMessage::ActionConfirmed { - action: "marked_as_read".to_string(), - success: true, - }; - client.send_message(outgoing).await?; -} -``` - -**Gaps**: -- Logique non implémentée -- Pas de synchronisation multi-device -- Pas de UI double-check marks - -#### Presence - -**Status**: ✅ Fonctionnel (70%) - -**Implémentation**: -- `PresenceManager` (src/presence.rs) -- Statuts: Online, Away, Busy, Invisible, Offline -- Cleanup inactifs (5 min threshold) -- ⚠️ Pas de persistence DB (mémoire uniquement) -- ⚠️ Pas de broadcast WebSocket intégré - -### 4.4 Modération - -| Feature | Status | Implémentation | Notes | -|---------|--------|----------------|-------| -| Détection spam | ⚠️ | Basique | Similarité Levenshtein simplifiée | -| Blocage users | ✅ | `ModerationSystem::apply_sanction` | DB présente | -| Suppression messages | ✅ | `MessageRepository::delete` | Soft delete (is_deleted) | -| Modération auto | ⚠️ | Basique | Seuil 3 messages similaires | -| Logs modération | ✅ | Tracing + DB | Audit trail présent | - -**Code modération auto**: -```rust -// src/moderation.rs:270 -async fn detect_spam(&self, user_id: i32, content: &str) -> Result { - // Vérifier les messages répétitifs (5 dernières minutes) - let similar_count = recent_messages.iter() - .filter(|row| { - let msg_content: String = row.get("content"); - self.calculate_similarity(content, &msg_content) > 0.8 - }) - .count(); - Ok(similar_count >= 3) -} -``` - -**Problèmes**: -- Détection spam trop simpliste -- Pas de ML/AI (TODO ligne 329 `security.rs`) -- Incohérence types: `i32` user_id au lieu UUID - ---- - -## 🚀 SECTION 5 : PERFORMANCE - -### 5.1 Benchmarks - -**Status**: ❌ Absent - -- Pas de dossier `benches/` -- Criterion présent dans `Cargo.toml` mais non utilisé -- Pas de métriques de performance documentées - -**Recommandation**: Créer benchmarks pour: -- Messages/seconde supportés -- Latence p50/p95/p99 -- Concurrent connections max -- Memory usage par connection - -### 5.2 Gestion Mémoire - -**Allocations**: -- ⚠️ Clones excessifs: `OutgoingMessage::clone()` ligne 144 `websocket/mod.rs` -- ✅ Arc> utilisé correctement -- ⚠️ Vec> dans WebSocketManager (croissance linéaire) - -**Lifetimes**: -- ✅ Lifetimes Rust corrects (pas de dangling refs) -- ⚠️ Pas de vérification memory leaks (tests manquants) - -**Buffer sizes**: -- ⚠️ Pas de limite taille message WebSocket (64KB max recommandé) -- ⚠️ Pas de backpressure handling - -### 5.3 Concurrence - -**Tokio**: -- ✅ Async/await partout -- ⚠️ Pas de `spawn_blocking()` pour opérations CPU-intensive -- ✅ Channels utilisés (tokio::sync::broadcast) - -**Deadlocks potentiels**: -- ⚠️ Ordre locks: `WebSocketManager::clients` puis `client.conversations` (ligne 141 `websocket/mod.rs`) -- ⚠️ Pas de timeout sur locks (RwLock peut bloquer indéfiniment) - -**Race conditions**: -- ⚠️ `WebSocketManager::remove_client` peut race avec `broadcast_to_conversation` - -### 5.4 Scalabilité - -**Horizontal scaling**: -- ❌ Pas de Redis pub/sub pour multi-instances -- ❌ Broadcast limité à instance unique -- ⚠️ RabbitMQ EventBus présent mais non utilisé pour broadcast - -**Session sticky**: -- ❌ Pas de support load balancer aware -- ⚠️ Pas de session externalisée (Redis) - -**Graceful shutdown**: -- ⚠️ Pas de drain connections -- ⚠️ Pas de timeout shutdown - ---- - -## 🔒 SECTION 6 : SÉCURITÉ - -### 6.1 Validation Input - -**Handlers WebSocket**: -- ⚠️ Message length: Pas de vérification MAX_MESSAGE_LENGTH (2000 chars config) -- ❌ Sanitization XSS: `ammonia` présent mais non utilisé -- ⚠️ Injection: SQLx prepared statements ✅ (protection SQL) -- ❌ File upload: Non implémenté (feature flag présent) - -**Code problématique**: -```rust -// src/websocket/handler.rs:170 -IncomingMessage::SendMessage { conversation_id, content, parent_message_id } => { - // Pas de validation longueur content - // Pas de sanitization XSS - let message = state.message_repo.create(conversation_id, sender_uuid, &content).await?; -} -``` - -### 6.2 Rate Limiting - -**Implémentation**: -- ✅ `DosProtectionManager` présent (src/rate_limiter.rs) -- ❌ **PROBLÈME P1**: Non utilisé dans `websocket/handler.rs` -- ✅ Rate limiting par user_id et IP -- ⚠️ Configuration hardcodée (60 msg/min) - -**Code manquant**: -```rust -// src/websocket/handler.rs devrait avoir: -let dos_manager = state.dos_manager.clone(); -if !dos_manager.check_message_allowed(claims.user_id).await? { - return Err(ChatError::rate_limit_error("Rate limit exceeded")); -} -``` - -### 6.3 Chiffrement - -**TLS/WSS**: -- ⚠️ Pas de configuration TLS dans `main.rs` -- ⚠️ `ENABLE_TLS=false` dans `env.example` -- ❌ Pas de certificats validation - -**End-to-end encryption**: -- ❌ Non implémenté -- ❌ Pas de Signal Protocol ou équivalent - -**Storage at-rest**: -- ⚠️ Messages en clair dans PostgreSQL -- ⚠️ Pas de chiffrement colonnes sensibles - -### 6.4 Audit Sécurité Rust - -**Clippy**: -```bash -cargo clippy --all-targets --all-features -- -D warnings -``` -**Résultats**: -- 10+ warnings (unused imports, PartialEq manquant) -- Pas de `unsafe` blocks (bon ✅) - -**Cargo audit**: -- ⚠️ Non exécuté (recommandé: `cargo audit`) - -**Vulnérabilités connues**: -- À vérifier avec `cargo audit` - ---- - -## 💾 SECTION 7 : PERSISTENCE - -### 7.1 Stockage Messages - -**Backend**: PostgreSQL via SQLx ✅ - -**Schéma**: -- Table `messages` (UUID-based) -- Index: `conversation_id`, `sender_id`, `created_at` ✅ -- Soft delete: `is_deleted` BOOLEAN ✅ -- ⚠️ Pas de partitioning par date -- ⚠️ Pas de retention policy (purge old messages) - -**Migrations**: -- `001_create_clean_database.sql` ✅ -- `002_advanced_features.sql` ✅ -- Schéma complet et cohérent - -### 7.2 Queries Database - -**N+1 queries**: -- ⚠️ Potentiel dans `get_conversation_messages` (pas de JOIN users) -- ✅ Pagination présente (LIMIT) - -**Prepared statements**: -- ✅ SQLx `query!` macros utilisées ✅ -- ✅ Protection injection SQL - -**Queries lentes**: -- ⚠️ Pas d'EXPLAIN ANALYZE effectué -- ⚠️ Pas de monitoring query duration - -### 7.3 Cache Strategy - -**Redis**: -- ⚠️ Dépendance présente mais non utilisée -- ❌ Pas de cache recent messages -- ❌ Pas de cache user online status -- ❌ Pas de cache typing indicators -- ❌ Pas de pub/sub pour broadcast multi-instances - -**Recommandation**: Implémenter cache Redis pour: -- Messages récents (TTL 1h) -- User presence (TTL 5min) -- Typing indicators (TTL 10s) - ---- - -## ✅ SECTION 8 : QUALITÉ CODE - -### 8.1 Linting - -**Clippy violations** (catégorisées): - -| Catégorie | Count | Exemples | -|-----------|-------|----------| -| **Correctness** | 2 | `PartialEq` manquant `SslMode` | -| **Performance** | 0 | Aucun | -| **Style** | 8 | Unused imports | -| **Complexity** | 0 | Aucun | - -**Fichiers problématiques**: -- `src/config.rs`: Unused imports `Pool`, `Postgres`, `error` -- `src/event_bus.rs`: Unused imports `ExchangeDeclareOptions`, `LapinError`, `ExchangeKind` -- `src/websocket/handler.rs`: Unused import `SinkExt`, `SimpleMessageStore` -- `src/config.rs:552`: `SslMode` manque `#[derive(PartialEq)]` - -### 8.2 Formatting - -**Status**: ⚠️ Non vérifié - -**Recommandation**: Exécuter `cargo fmt --check` - -### 8.3 Tests - -**Coverage estimé**: 25% - -**Tests présents**: -- `tests/integration_test.rs`: 6 tests, tous `#[ignore]` -- Tests unitaires: Rares (typing_indicator, jwt_manager, rate_limiter) - -**Modules sans tests**: -- `websocket/handler.rs` ❌ -- `repository/message_repository.rs` ❌ -- `services/room_service.rs` ❌ -- `moderation.rs` ❌ -- `presence.rs` ❌ - -**Objectif ORIGIN_**: 75%+ coverage (non atteint) - -### 8.4 Documentation - -**Doc comments**: -- ✅ Présents sur modules principaux (`//!`) -- ⚠️ Manquants sur fonctions publiques -- ❌ Pas d'exemples dans doc (`#[doc = include_str!("...")]`) - -**Cargo doc**: -- ⚠️ Non généré (recommandé: `cargo doc --no-deps --open`) - ---- - -## 📈 SECTION 9 : OBSERVABILITÉ - -### 9.1 Logging - -**Framework**: `tracing` ✅ - -**Structured logging**: -- ✅ Utilisé partout -- ✅ Niveaux: trace/debug/info/warn/error -- ⚠️ Pas de logs sensibles détectés (tokens, passwords) - -**Configuration**: -- JSON en production (ligne 106 `main.rs`) -- Debug en dev (ligne 109 `main.rs`) - -### 9.2 Metrics - -**Prometheus**: -- ✅ `PrometheusMetrics` défini (src/prometheus_metrics.rs) -- ❌ **PROBLÈME P2**: Métriques non enregistrées dans handlers -- ✅ Endpoint `/metrics` présent (ligne 203 `main.rs`) - -**Métriques manquantes**: -- Messages/seconde non enregistrés -- Latence messages non mesurée -- Erreurs non comptées - -### 9.3 Tracing - -**OpenTelemetry**: -- ❌ Non implémenté -- ❌ Pas de spans (connection lifecycle, message flow) -- ❌ Pas de trace_id propagation - ---- - -## 🔗 SECTION 10 : INTÉGRATION - -### 10.1 API REST - -**Endpoints exposés**: -- `GET /health` ✅ -- `GET /healthz` ✅ (liveness) -- `GET /readyz` ✅ (readiness) -- `GET /metrics` ✅ (Prometheus) -- `GET /api/messages/{conversation_id}` ✅ -- `POST /api/messages` ✅ -- `GET /api/messages/stats` ⚠️ (hardcodé) - -**Cohérence avec backend Go**: -- ⚠️ Non vérifiée (backend Go non analysé) - -### 10.2 Communication Inter-Services - -**RabbitMQ EventBus**: -- ✅ `RabbitMQEventBus` présent (src/event_bus.rs) -- ⚠️ Initialisé dans `main.rs` mais non utilisé pour events -- ✅ Retry logic présent -- ⚠️ Pas de circuit breaker - -**gRPC**: -- ⚠️ `grpc_server.rs` présent mais non démarré -- ⚠️ `grpc_client.rs` présent mais non utilisé - -### 10.3 Authentification Partagée - -**JWT shared secret**: -- ✅ `JWT_SECRET` env var -- ⚠️ Fallback hardcodé (ligne 162 `main.rs`) -- ✅ Même algorithme (HS256 par défaut) -- ⚠️ Audience/Issuer configurables - -**Incohérences**: -- ⚠️ Non vérifiées (backend Go non analysé) - ---- - -## 📐 SECTION 11 : GAP ANALYSIS ORIGIN_ - -### 11.1 Matrice Complétude Features - -| Feature ORIGIN_ | Status | Complétude | Gaps | -|-----------------|--------|------------|------| -| WebSocket temps-réel | ✅ | 80% | Heartbeat timeout, idle connections | -| Messages 1-to-1 | ✅ | 80% | E2E encryption manquant | -| Group chat | ✅ | 70% | Permissions incomplètes | -| Channels | ⚠️ | 50% | Structure présente, features manquantes | -| Typing indicators | ✅ | 75% | Broadcast WS manquant | -| Read receipts | 🟡 | 40% | **TODO ligne 258** | -| Presence | ✅ | 70% | Persistence DB manquante | -| Modération auto | ⚠️ | 60% | ML/AI manquant | -| Rate limiting | ⚠️ | 50% | Non appliqué dans handler | -| Redis pub/sub | ❌ | 0% | Non implémenté | -| Metrics Prometheus | ⚠️ | 30% | Définies mais non utilisées | -| Tests coverage 75%+ | ❌ | 25% | Tests ignorés, unitaires rares | - -### 11.2 Écarts Architecture - -**ORIGIN_ attendu**: -- Event-driven via RabbitMQ ✅ (présent mais non utilisé) -- Redis pub/sub pour scaling ❌ (non implémenté) -- gRPC inter-services ⚠️ (présent mais non démarré) - -**Architecture réelle**: -- WebSocket direct (Axum) -- Broadcast in-memory (tokio::sync::broadcast) -- Pas de scaling horizontal - ---- - -## 🎯 SECTION 12 : PLAN D'ACTION PRIORISÉ - -### RUST-CHAT-001 | Auth WebSocket Token dans Query Params -``` -├─ Gravité : 🔴 P0 - BLOQUANT -├─ Description : Token JWT exposé dans URL query params (ligne 43 handler.rs) -├─ Impact : Token visible dans logs, cache navigateur, referrer headers -├─ Effort : 2-3 heures -├─ Dépendances : Aucune -└─ Action : Déplacer token dans header Authorization ou cookie HTTP-only -``` - -**Solution**: -```rust -// Avant (handler.rs:43) -let token = params.get("token")?; - -// Après -let auth_header = request.headers().get("authorization")?; -let token = extract_token_from_header(auth_header.to_str()?)?; -``` - ---- - -### RUST-CHAT-002 | Read Receipts Non Implémentés -``` -├─ Gravité : 🔴 P0 - BLOQUANT -├─ Description : TODO ligne 258 websocket/handler.rs - logique marquage comme lu absente -├─ Impact : Feature read receipts non fonctionnelle -├─ Effort : 1 jour -├─ Dépendances : ReadReceiptManager présent mais types i64 vs UUID -└─ Action : Implémenter logique + corriger types UUID -``` - -**Solution**: -```rust -// handler.rs:249 -IncomingMessage::MarkAsRead { conversation_id, message_id } => { - let read_receipt_manager = state.read_receipt_manager.clone(); - read_receipt_manager.mark_as_read( - message_id, - claims.user_id.parse()?, - conversation_id - ).await?; - // Broadcast read receipt to conversation - state.ws_manager.broadcast_read_receipt(conversation_id, message_id, claims.user_id).await?; -} -``` - ---- - -### RUST-CHAT-003 | Tests Coverage < 30% -``` -├─ Gravité : 🔴 P0 - BLOQUANT -├─ Description : Tests d'intégration ignorés, unitaires rares -├─ Impact : Pas de confiance dans refactoring, bugs non détectés -├─ Effort : 5-7 jours -├─ Dépendances : Setup DB de test -└─ Action : Activer tests integration, ajouter tests unitaires critiques -``` - -**Actions**: -1. Retirer `#[ignore]` des tests integration -2. Ajouter tests unitaires: `websocket/handler.rs`, `message_repository.rs` -3. Setup CI/CD avec coverage reporting (tarpaulin) - ---- - -### RUST-CHAT-004 | Clippy Warnings Non Résolus -``` -├─ Gravité : 🟠 P1 - CRITIQUE -├─ Description : 10+ warnings compilation (unused imports, PartialEq manquant) -├─ Impact : Code quality dégradée, warnings masquent vrais problèmes -├─ Effort : 1-2 heures -├─ Dépendances : Aucune -└─ Action : Corriger tous les warnings clippy -``` - -**Actions**: -1. Supprimer unused imports -2. Ajouter `#[derive(PartialEq)]` à `SslMode` -3. Ajouter `#[allow(dead_code)]` si nécessaire (temporaire) - ---- - -### RUST-CHAT-005 | Rate Limiting Non Appliqué -``` -├─ Gravité : 🟠 P1 - CRITIQUE -├─ Description : DosProtectionManager présent mais non utilisé dans handler -├─ Impact : Pas de protection DoS, spam possible -├─ Effort : 2-3 heures -├─ Dépendances : DosProtectionManager présent -└─ Action : Intégrer rate limiting dans websocket handler -``` - -**Solution**: -```rust -// Ajouter dans WebSocketState -pub dos_manager: Arc, - -// Dans handle_incoming_message -if !state.dos_manager.check_message_allowed(claims.user_id.parse()?).await? { - return Err(ChatError::rate_limit_error("Rate limit exceeded")); -} -``` - ---- - -### RUST-CHAT-006 | Validation Input Messages -``` -├─ Gravité : 🟠 P1 - CRITIQUE -├─ Description : Pas de sanitization XSS, longueur max non vérifiée -├─ Impact : XSS possible, DoS via messages géants -├─ Effort : 1 jour -├─ Dépendances : ammonia présent, validator présent -└─ Action : Ajouter validation longueur + sanitization XSS -``` - -**Solution**: -```rust -// handler.rs:170 -const MAX_MESSAGE_LENGTH: usize = 2000; -if content.len() > MAX_MESSAGE_LENGTH { - return Err(ChatError::validation_error("Message too long")); -} -let sanitized = ammonia::clean(content); -let message = state.message_repo.create(conversation_id, sender_uuid, &sanitized).await?; -``` - ---- - -### RUST-CHAT-007 | Heartbeat/Ping-Pong Incomplet -``` -├─ Gravité : 🟠 P1 - CRITIQUE -├─ Description : Ping géré, mais pas de timeout idle connections -├─ Impact : Connections zombies consomment ressources -├─ Effort : 1 jour -├─ Dépendances : Aucune -└─ Action : Implémenter timeout idle + heartbeat périodique -``` - -**Solution**: -```rust -// Ajouter dans handle_socket -let mut last_ping = Instant::now(); -const IDLE_TIMEOUT: Duration = Duration::from_secs(300); // 5 min - -tokio::select! { - msg = receiver.next() => { /* ... */ }, - _ = tokio::time::sleep(Duration::from_secs(30)) => { - // Heartbeat périodique - if last_ping.elapsed() > IDLE_TIMEOUT { - break; // Timeout - } - client.send_message(OutgoingMessage::Ping).await?; - } -} -``` - ---- - -### RUST-CHAT-008 | Modération Auto Basique -``` -├─ Gravité : 🟡 P2 - MAJEUR -├─ Description : Détection spam simpliste (similarité Levenshtein) -├─ Impact : Faux positifs/négatifs, spam non détecté -├─ Effort : 3-5 jours -├─ Dépendances : ML model (optionnel) -└─ Action : Améliorer détection spam (rate limiting + patterns) -``` - -**Solution**: -- Combiner rate limiting + similarité -- Ajouter détection patterns (URLs répétées, mentions massives) -- Intégrer ML model (optionnel, TODO ligne 329) - ---- - -### RUST-CHAT-009 | Pas de Redis Pub/Sub -``` -├─ Gravité : 🟡 P2 - MAJEUR -├─ Description : Broadcast limité à instance unique -├─ Impact : Pas de scaling horizontal -├─ Effort : 3-5 jours -├─ Dépendances : Redis présent mais non utilisé -└─ Action : Implémenter Redis pub/sub pour broadcast multi-instances -``` - -**Solution**: -```rust -// Ajouter Redis pub/sub dans broadcast.rs -pub async fn broadcast_to_conversation_redis( - &self, - conversation_id: Uuid, - message: OutgoingMessage, -) -> Result<()> { - // Publier sur Redis channel - let channel = format!("conversation:{}", conversation_id); - let json = serde_json::to_string(&message)?; - redis_client.publish(channel, json).await?; - Ok(()) -} -``` - ---- - -### RUST-CHAT-010 | Metrics Prometheus Non Utilisées -``` -├─ Gravité : 🟡 P2 - MAJEUR -├─ Description : Métriques définies mais non enregistrées dans handlers -├─ Impact : Pas de monitoring production -├─ Effort : 1 jour -├─ Dépendances : PrometheusMetrics présent -└─ Action : Enregistrer métriques dans tous les handlers -``` - -**Solution**: -```rust -// Ajouter dans WebSocketState -pub metrics: Arc, - -// Dans handle_incoming_message -state.metrics.record_message_received(content.len() as u64); -let start = Instant::now(); -// ... traitement ... -state.metrics.record_message_processing_duration(start.elapsed()); -``` - ---- - -### RUST-CHAT-011 | Incohérence Types i32 vs UUID -``` -├─ Gravité : 🟡 P2 - MAJEUR -├─ Description : read_receipts.rs et moderation.rs utilisent i32 au lieu UUID -├─ Impact : Incompatibilité avec schéma DB (UUID) -├─ Effort : 1 jour -├─ Dépendances : Migration DB vers UUID -└─ Action : Migrer read_receipts et moderation vers UUID -``` - ---- - -### RUST-CHAT-012 | Modules Non Utilisés (Code Mort) -``` -├─ Gravité : 🟢 P3 - MINEUR -├─ Description : core/ et hub/ présents mais non utilisés -├─ Impact : Code mort, confusion -├─ Effort : 2 heures -├─ Dépendances : Aucune -└─ Action : Supprimer ou intégrer modules -``` - ---- - -### RUST-CHAT-013 | Duplication Auth Modules -``` -├─ Gravité : 🟢 P3 - MINEUR -├─ Description : auth.rs, authentication.rs, jwt_manager.rs (overlap) -├─ Impact : Confusion, maintenance difficile -├─ Effort : 1 jour -├─ Dépendances : Aucune -└─ Action : Consolider en un seul module auth -``` - ---- - -### RUST-CHAT-014 | Pas de Graceful Shutdown -``` -├─ Gravité : 🟡 P2 - MAJEUR -├─ Description : Pas de drain connections au shutdown -├─ Impact : Perte messages en cours, connections brutalement fermées -├─ Effort : 1 jour -├─ Dépendances : Tokio signal -└─ Action : Implémenter graceful shutdown avec drain -``` - -**Solution**: -```rust -// main.rs -tokio::select! { - result = axum::serve(listener, app) => { result? }, - _ = tokio::signal::ctrl_c() => { - info!("🛑 Shutdown signal reçu, drain connections..."); - // Drain WebSocket connections - ws_manager.drain_all().await; - info!("✅ Shutdown gracieux terminé"); - } -} -``` - ---- - -## 📝 CONCLUSION - -Le Chat Server Rust présente une **base solide** avec: -- ✅ Architecture WebSocket moderne (Axum) -- ✅ Persistence PostgreSQL robuste (SQLx) -- ✅ Structure modulaire claire -- ✅ Features temps-réel partiellement implémentées - -**Problèmes critiques** à résoudre en priorité: -1. 🔴 Auth WebSocket (token dans URL) -2. 🔴 Read receipts non implémentés -3. 🔴 Tests coverage insuffisant -4. 🟠 Rate limiting non appliqué -5. 🟠 Validation input manquante - -**Effort total estimé**: 18-27 jours pour atteindre production-ready avec toutes les features ORIGIN_. - -**Recommandation**: Commencer par P0 (3-5 jours), puis P1 (5-7 jours) avant déploiement production. - ---- - -**Fin du rapport d'audit** - diff --git a/veza-chat-server/AUDIT_EXHAUSTIF_CHAT_SERVER.md b/veza-chat-server/AUDIT_EXHAUSTIF_CHAT_SERVER.md deleted file mode 100644 index 57677bee1..000000000 --- a/veza-chat-server/AUDIT_EXHAUSTIF_CHAT_SERVER.md +++ /dev/null @@ -1,636 +0,0 @@ -# 🔍 AUDIT EXHAUSTIF - VEZA CHAT SERVER -## Module: `veza-chat-server` (Rust) - -**Date**: 2025-01-27 -**Auditeur**: Auto (Cursor AI) -**Version analysée**: 0.2.0 -**Statut compilation**: ❌ **ÉCHEC** (conflit de dépendances SQLx) - ---- - -# PHASE A — CARTOGRAPHIE DU MODULE - -## 1. But du module - -**Rôle**: Serveur de chat temps réel avec WebSocket pour la plateforme Veza. - -**Fonctionnalités principales**: -- Communication WebSocket bidirectionnelle (Axum + tokio-tungstenite) -- Gestion de conversations (directes, groupes, channels) -- Messages avec édition/suppression -- Read receipts et delivered status -- Typing indicators -- Recherche et synchronisation d'historique -- Authentification JWT avec refresh tokens -- Permissions RBAC (admin, moderator, member) -- Event Bus RabbitMQ (optionnel) -- Métriques Prometheus - -## 2. Entrées / Sorties - -### APIs exposées - -**HTTP REST** (port 8081 par défaut): -- `GET /health` - Health check -- `GET /healthz` - Health check (alias) -- `GET /readyz` - Readiness check (DB + RabbitMQ) -- `GET /metrics` - Métriques Prometheus -- `GET /api/messages/stats` - Statistiques serveur -- `GET /api/messages/{conversation_id}` - Récupération messages (authentifié) -- `POST /api/messages` - Envoi message (authentifié) - -**WebSocket** (port 8081): -- `GET /ws?token=` - Connexion WebSocket - -**Formats**: -- JSON pour HTTP REST -- JSON pour WebSocket (messages structurés) -- Protobuf pour gRPC (présent mais non utilisé dans main.rs) - -### Events WebSocket - -**Incoming** (`IncomingMessage`): -- `SendMessage`, `JoinConversation`, `LeaveConversation` -- `MarkAsRead`, `Typing`, `Delivered` -- `EditMessage`, `DeleteMessage` -- `FetchHistory`, `SearchMessages`, `SyncMessages` -- `Ping` - -**Outgoing** (`OutgoingMessage`): -- `NewMessage`, `MessageRead`, `MessageDelivered` -- `UserTyping`, `MessageEdited`, `MessageDeleted` -- `HistoryChunk`, `SearchResults`, `SyncChunk` -- `ActionConfirmed`, `Error`, `Pong` - -## 3. Dépendances internes - -- `veza-common` (path: `../veza-common`) - Types partagés -- Modules internes: `config`, `database`, `error`, `jwt_manager`, `repository`, `security`, `services`, `websocket` - -## 4. Dépendances externes - -**Base de données**: -- PostgreSQL (via SQLx 0.8.6) -- Migrations SQL dans `migrations/` - -**Cache** (optionnel): -- Redis (via `redis` crate, feature `redis-cache`) - -**Message Broker** (optionnel): -- RabbitMQ (via `lapin` 2.3) - -**Monitoring**: -- Prometheus (via `metrics-exporter-prometheus`) - -## 5. Exécution - -### Build -```bash -cargo build --release -``` - -### Run -```bash -./target/release/chat-server -``` - -### Variables d'environnement critiques -- `DATABASE_URL` (requis) - PostgreSQL connection string -- `JWT_SECRET` (requis, min 32 chars) - Secret pour JWT -- `CHAT_SERVER_PORT` (défaut: 8081) -- `CHAT_SERVER_HOST` (défaut: 0.0.0.0) -- `RABBITMQ_URL` (optionnel) -- `RABBITMQ_ENABLE` (défaut: true) - -### Docker -- `Dockerfile` présent (multi-stage, Alpine) -- `docker-compose.yml` présent -- Healthcheck configuré (`/health`) - -## 6. Points d'intégration - -**Backend Go**: -- JWT tokens partagés (audience: `veza-chat`, issuer: `veza-backend`) -- Schéma DB partagé (UUID pour users, conversations, messages) - -**Frontend React**: -- WebSocket: `ws://:8081/ws?token=` -- REST API: `http://:8081/api/*` -- Headers: `Authorization: Bearer ` - -**Auth**: -- JWT HS256 (configurable) -- Claims: `sub` (user_id UUID), `username`, `role`, `aud`, `iss`, `exp`, `iat`, `jti` - ---- - -# PHASE B — SANTÉ TECHNIQUE - -## Build Status - -### ❌ **P0 - BUILD CASSÉ** - -**Erreur**: Conflit de dépendances SQLx - -``` -error: failed to select a version for `libsqlite3-sys`. -package `libsqlite3-sys v0.30.1` (sqlx 0.8.6) -conflicts with `libsqlite3-sys v0.26.0` (sqlx 0.7.0 via veza-common) -``` - -**Fichiers concernés**: -- `Cargo.toml` ligne 43: `sqlx = "0.8.6"` -- `veza-common` (externe): `sqlx = "^0.7"` - -**Impact**: **Impossible de compiler le projet** - -**Fix minimal**: Aligner les versions SQLx entre `chat_server` et `veza-common`, ou exclure SQLite de `veza-common` si non utilisé. - -## Tests - -### Couverture -- Tests unitaires présents dans plusieurs modules -- Tests d'intégration avec `#[ignore]` (nécessitent DB) -- Tests JWT présents (`jwt_manager.rs`) -- Tests permissions partiels - -### Problèmes détectés -- Beaucoup de tests ignorés (`#[ignore]`) car nécessitent DB -- Pas de tests E2E WebSocket -- Tests de sécurité manquants (injection, rate limiting) - -## Gestion des erreurs - -### ✅ Points positifs -- Type `ChatError` exhaustif avec `thiserror` -- Helpers pour création d'erreurs (`ChatError::not_found`, etc.) -- Mapping HTTP status codes approprié -- Logging structuré avec `tracing` - -### ⚠️ Points d'attention -- **169 occurrences de `unwrap()` / `expect()` / `panic!`** détectées -- Certains `unwrap()` dans code de production (ex: `src/config.rs:21`, `src/main.rs:489`) -- Panics possibles dans `SecurityConfig::default()` (ligne 192) si appelé hors test - -## Linters / Qualité - -### Clippy -- Non exécuté dans l'audit (nécessite build) -- Recommandation: `cargo clippy --all-targets --all-features -- -D warnings` - -### Conventions -- ✅ Structure modulaire claire -- ✅ Documentation rustdoc présente -- ⚠️ Mix de noms français/anglais (ex: `conversation_id` vs `room_id`) -- ⚠️ Code mort potentiel (`security_legacy.rs`, `simple_message_store.rs`) - ---- - -# PHASE C — SÉCURITÉ - -## Top 10 Risques Critiques - -### **P0-001: Conflit de dépendances SQLx (Build cassé)** -- **Impact**: Impossible de déployer -- **Fichier**: `Cargo.toml:43` vs `veza-common` -- **Fix**: Aligner versions ou exclure SQLite -- **Effort**: S (1h) - -### **P0-002: JWT Secret faible par défaut** -- **Impact**: Tokens compromis si secret faible -- **Fichier**: `src/main.rs:158`, `env.example:20` -- **Preuve**: `JWT_SECRET=your-super-secret-jwt-key-change-this-in-production` -- **Fix**: Validation stricte min 64 chars, génération aléatoire au démarrage si absent -- **Effort**: S (30min) - -### **P0-003: Panics dans code de production** -- **Impact**: Crash serveur -- **Fichiers**: - - `src/main.rs:489` - `expect("failed to install Ctrl+C handler")` - - `src/config.rs:192` - `panic!` dans `SecurityConfig::default()` - - `src/env.rs:30,61` - `panic!` dans helpers -- **Fix**: Remplacer par `Result` et gestion d'erreurs -- **Effort**: M (2h) - -### **P0-004: Validation JWT incomplète** -- **Impact**: Tokens expirés ou invalides acceptés -- **Fichier**: `src/jwt_manager.rs:266-303` -- **Preuve**: Vérification `exp` manuelle après décodage (ligne 290-293), mais pas de vérification `nbf` (not before) -- **Fix**: Ajouter validation `nbf`, vérifier `iss`/`aud` strictement -- **Effort**: S (1h) - -### **P1-005: SQL Injection potentielle dans recherche** -- **Impact**: Exécution de requêtes SQL arbitraires -- **Fichier**: `src/repository/message_repository.rs:563` -- **Preuve**: `format!("%{}%", query)` - si `query` contient `%` ou `_`, comportement inattendu -- **Fix**: Échapper caractères spéciaux ou utiliser `ILIKE` avec paramètre bindé -- **Effort**: S (30min) - -### **P1-006: Rate limiting non implémenté** -- **Impact**: DoS possible, spam -- **Fichier**: `src/security/mod.rs:94` - TODO comment -- **Preuve**: `// TODO: Implémenter le Rate Limiting réel via Redis ou mémoire partagée` -- **Fix**: Implémenter rate limiter avec Redis ou in-memory (DashMap) -- **Effort**: M (4h) - -### **P1-007: CORS non configuré** -- **Impact**: XSS via requêtes cross-origin -- **Fichier**: `src/main.rs` - Pas de middleware CORS -- **Preuve**: Aucune configuration CORS dans Axum router -- **Fix**: Ajouter middleware CORS avec origines whitelistées -- **Effort**: S (1h) - -### **P1-008: Secrets dans logs** -- **Impact**: Fuite de secrets en production -- **Fichier**: `src/config.rs:55` - Logging de `database_url` potentiellement -- **Preuve**: `info!("Initializing database connection pool with config: {:?}", config)` - peut logger credentials -- **Fix**: Masquer credentials dans logs (remplacer par `***`) -- **Effort**: S (30min) - -### **P1-009: WebSocket sans rate limiting** -- **Impact**: Spam de messages, DoS -- **Fichier**: `src/websocket/handler.rs:200-894` -- **Preuve**: Aucune limite sur fréquence de messages WebSocket -- **Fix**: Ajouter rate limiter par client (ex: max 100 messages/sec) -- **Effort**: M (3h) - -### **P1-010: Blacklist JWT en mémoire (non persistant)** -- **Impact**: Tokens révoqués revalidés après redémarrage -- **Fichier**: `src/jwt_manager.rs:142` -- **Preuve**: `revoked_tokens: Arc>>` - perdu au redémarrage -- **Fix**: Persister blacklist dans Redis ou DB -- **Effort**: M (2h) - -## Autres Risques (P2/P3) - -### P2-011: Validation de contenu basique -- **Fichier**: `src/security_legacy.rs` - Regex patterns, mais module "legacy" -- **Fix**: Utiliser `ammonia` (déjà dans deps) pour sanitization HTML - -### P2-012: Pas de protection CSRF pour REST API -- **Fichier**: `src/security/csrf.rs` existe mais non utilisé dans `main.rs` -- **Fix**: Activer middleware CSRF pour routes POST/PUT/DELETE - -### P2-013: Heartbeat WebSocket fixe (60s) -- **Fichier**: `src/websocket/handler.rs:121` -- **Preuve**: `keepalive_timeout = Duration::from_secs(60)` - hardcodé -- **Fix**: Configurable via env var - -### P2-014: Pas de validation de taille de message -- **Fichier**: `src/websocket/handler.rs:214` - Pas de check `content.len()` -- **Fix**: Valider `MAX_MESSAGE_LENGTH` (défini dans `env.example:57` mais non utilisé) - -### P3-015: Logs verbeux en production -- **Fichier**: `src/main.rs:84-101` - Logs avec `debug`/`info` même en prod -- **Fix**: Utiliser `RUST_LOG` avec niveaux appropriés - ---- - -# PHASE D — ROBUSTESSE & OBSERVABILITÉ - -## Logs structurés - -### ✅ Points positifs -- `tracing` avec `tracing-subscriber` configuré -- Format JSON en production (`main.rs:92`) -- Format détaillé en dev (`main.rs:95-100`) -- Champs structurés (`user_id = %user_id`, `conversation_id = %conversation_id`) - -### ⚠️ Gaps -- **Pas de `request_id` / `trace_id`** pour corrélation -- **Pas de log rotation** configuré (mentionné dans `env.example:83` mais non implémenté) -- **Secrets potentiellement loggés** (voir P1-008) - -## Métriques - -### ✅ Présent -- Prometheus exporter (`/metrics`) -- `ChatMetrics` avec compteurs/gauges -- Métriques système (CPU, mémoire) via `sysinfo` - -### ⚠️ Manquants -- **Latence P50/P95/P99** pour requêtes DB -- **Taux d'erreur par endpoint** -- **Connexions WebSocket actives** (compteur) -- **Taille de la blacklist JWT** - -## Healthchecks - -### ✅ Présent -- `/health` - Basic health check -- `/readyz` - Readiness (DB + RabbitMQ) - -### ⚠️ Améliorations -- **Liveness check** séparé (actuellement `/health` fait DB check aussi) -- **Timeout configurable** pour healthchecks -- **Circuit breaker** pour DB/RabbitMQ - -## Timeouts & Retries - -### ✅ Présent -- Timeout WebSocket inactivité (60s) -- Retry RabbitMQ (`max_retries`, `retry_interval_secs`) - -### ⚠️ Manquants -- **Timeout pour requêtes DB** (seulement `acquire_timeout` dans pool) -- **Retry avec backoff exponentiel** pour DB -- **Circuit breaker** pour services externes - -## Gestion de charge - -### ✅ Présent -- Pool DB configuré (max 20, min 5) -- Limite messages (100 max dans `fetch_history`) - -### ⚠️ Gaps -- **Pas de backpressure** pour WebSocket (clients peuvent spam) -- **Pas de limite de connexions WebSocket simultanées** -- **Pas de queue pour messages en attente** - -## Migrations - -### ✅ Présent -- Migrations SQL dans `migrations/` -- SQLx migrate supporté - -### ⚠️ Problèmes -- **16 fichiers de migration** - risque de confusion -- **Migrations archivées** dans `migrations/archive/` - à nettoyer -- **Pas de rollback** automatique en cas d'échec - ---- - -# PHASE E — PERFORMANCE & SCALABILITÉ - -## Hotspots identifiés - -### 1. Broadcast WebSocket inefficace -**Fichier**: `src/websocket/mod.rs:228-244` -**Problème**: Itération sur tous les clients pour chaque broadcast -```rust -for client in clients.iter() { - let conversations = client.conversations.read().await; - if conversations.contains(&conversation_id) { - let _ = client.send_message(message.clone()).await; - } -} -``` -**Impact**: O(n) où n = nombre total de clients, même si seulement quelques-uns sont dans la conversation -**Fix**: Index inversé `conversation_id -> Vec` pour O(1) lookup -**Effort**: M (3h) - -### 2. Clonage de messages pour broadcast -**Fichier**: `src/websocket/mod.rs:239` -**Problème**: `message.clone()` pour chaque client -**Impact**: Allocations inutiles pour gros messages -**Fix**: Utiliser `Arc` ou sérialiser une fois, cloner bytes -**Effort**: S (1h) - -### 3. Requêtes DB N+1 potentielles -**Fichier**: `src/repository/message_repository.rs:82-144` -**Problème**: Pas de batch loading pour conversations multiples -**Impact**: Latence élevée si plusieurs conversations chargées -**Fix**: Ajouter méthode `get_multiple_conversations_messages` -**Effort**: M (2h) - -### 4. Blacklist JWT en mémoire (HashSet) -**Fichier**: `src/jwt_manager.rs:142` -**Problème**: `HashSet` - recherche O(1) mais mémoire illimitée -**Impact**: Fuite mémoire si beaucoup de tokens révoqués -**Fix**: LRU cache ou TTL-based cleanup (déjà partiellement implémenté ligne 473) -**Effort**: S (1h) - -### 5. Parsing JSON répété -**Fichier**: `src/websocket/handler.rs:210` -**Problème**: `serde_json::from_str(text)` à chaque message -**Impact**: CPU overhead pour gros payloads -**Fix**: Cache de schémas JSON ou validation pré-compilée -**Effort**: P3 (optimisation future) - -## Streaming & I/O - -### WebSocket -- ✅ Utilisation de `tokio-tungstenite` (async) -- ⚠️ Pas de compression WebSocket (per-message deflate) -- ⚠️ Pas de fragmentation pour gros messages - -### Base de données -- ✅ Pool de connexions configuré -- ⚠️ Pas de prepared statements caching explicite (SQLx le fait mais non configuré) -- ⚠️ Pas de connection pooling metrics exposées - -## Async Runtime - -### ✅ Points positifs -- Tokio avec features "full" -- Pas de blocking dans async (sauf `sysinfo` potentiellement) - -### ⚠️ Points d'attention -- **Pas de configuration de worker threads** (utilise par défaut) -- **Pas de metrics Tokio** (task spawns, park/unpark) - ---- - -# PHASE F — LISTE EXHAUSTIVE DES PROBLÈMES - -## P0 - CRITIQUES (Build / Sécurité / Crash) - -| ID | Titre | Impact | Fichier | Fix minimal | Validation | Effort | -|---|---|---|---|---|---|---| -| **MOD-P0-001** | Conflit dépendances SQLx (build cassé) | Impossible de compiler | `Cargo.toml:43` | Aligner versions SQLx (0.8.6 partout) ou exclure SQLite de veza-common | `cargo check` passe | S (1h) | -| **MOD-P0-002** | JWT Secret faible par défaut | Tokens compromis | `src/main.rs:158`, `env.example:20` | Validation min 64 chars, génération aléatoire si absent | Test: secret < 64 chars rejeté | S (30min) | -| **MOD-P0-003** | Panics dans code production | Crash serveur | `src/main.rs:489`, `src/config.rs:192`, `src/env.rs:30,61` | Remplacer `expect/panic` par `Result` | Tests: pas de panic sur erreurs attendues | M (2h) | -| **MOD-P0-004** | Validation JWT incomplète | Tokens invalides acceptés | `src/jwt_manager.rs:266-303` | Ajouter validation `nbf`, vérifier `iss/aud` strictement | Test: token avec `nbf` futur rejeté | S (1h) | - -## P1 - HAUTE PRIORITÉ (Bugs fréquents / Dette bloquante) - -| ID | Titre | Impact | Fichier | Fix minimal | Validation | Effort | -|---|---|---|---|---|---|---| -| **MOD-P1-005** | SQL Injection potentielle recherche | Exécution SQL arbitraire | `src/repository/message_repository.rs:563` | Échapper `%` et `_` ou utiliser paramètre bindé | Test: query avec `%` ne match pas littéralement | S (30min) | -| **MOD-P1-006** | Rate limiting non implémenté | DoS, spam | `src/security/mod.rs:94` | Implémenter avec Redis ou DashMap | Test: 1000 req/sec rejetées | M (4h) | -| **MOD-P1-007** | CORS non configuré | XSS cross-origin | `src/main.rs:246` | Ajouter middleware CORS Axum | Test: requête cross-origin rejetée sans header | S (1h) | -| **MOD-P1-008** | Secrets dans logs | Fuite credentials | `src/config.rs:264` | Masquer credentials (remplacer par `***`) | Test: logs ne contiennent pas `password=` | S (30min) | -| **MOD-P1-009** | WebSocket sans rate limiting | Spam messages, DoS | `src/websocket/handler.rs:200` | Rate limiter par client (100 msg/sec) | Test: client spammant rejeté | M (3h) | -| **MOD-P1-010** | Blacklist JWT non persistant | Tokens révoqués revalidés | `src/jwt_manager.rs:142` | Persister dans Redis ou DB | Test: token révoqué reste révoqué après restart | M (2h) | -| **MOD-P1-011** | Pas de validation taille message | Messages trop longs | `src/websocket/handler.rs:214` | Valider `MAX_MESSAGE_LENGTH` (2000 chars) | Test: message > 2000 chars rejeté | S (30min) | -| **MOD-P1-012** | Broadcast WebSocket O(n) | Performance dégradée | `src/websocket/mod.rs:228` | Index inversé conversation -> clients | Test: broadcast à 1000 clients < 10ms | M (3h) | -| **MOD-P1-013** | Pas de limite connexions WS | DoS par connexions | `src/websocket/manager.rs:209` | Limiter max connexions (ex: 10000) | Test: 10001ème connexion rejetée | S (1h) | -| **MOD-P1-014** | Healthcheck timeout non configuré | Healthcheck bloque | `src/main.rs:298` | Timeout configurable (ex: 5s) | Test: DB lent retourne 503 | S (30min) | - -## P2 - MOYENNE PRIORITÉ (Qualité / Maintenabilité) - -| ID | Titre | Impact | Fichier | Fix minimal | Validation | Effort | -|---|---|---|---|---|---|---| -| **MOD-P2-015** | Pas de request_id/trace_id | Debug difficile | `src/main.rs:82` | Ajouter middleware tracing avec `request_id` | Test: logs contiennent `request_id` | M (2h) | -| **MOD-P2-016** | Log rotation non implémenté | Disque plein | `env.example:83` | Implémenter avec `tracing-appender` | Test: logs rotent après 100MB | M (2h) | -| **MOD-P2-017** | Métriques latence manquantes | Monitoring incomplet | `src/monitoring.rs` | Ajouter histogrammes P50/P95/P99 | Test: métriques exposées sur `/metrics` | M (2h) | -| **MOD-P2-018** | Circuit breaker manquant | Cascading failures | N/A | Implémenter avec `tower` ou custom | Test: DB down -> circuit ouvert | M (4h) | -| **MOD-P2-019** | Migrations multiples confuses | Risque d'erreur | `migrations/` (16 fichiers) | Nettoyer migrations archivées | Test: migrations appliquent dans ordre | S (1h) | -| **MOD-P2-020** | Code mort (security_legacy) | Maintenance inutile | `src/security_legacy.rs` | Supprimer ou documenter usage | Test: build sans ce fichier | S (30min) | -| **MOD-P2-021** | CSRF non activé | CSRF attacks | `src/security/csrf.rs` | Activer middleware CSRF | Test: requête sans token CSRF rejetée | M (2h) | -| **MOD-P2-022** | Heartbeat WebSocket hardcodé | Non configurable | `src/websocket/handler.rs:121` | Configurable via env var | Test: heartbeat = 30s fonctionne | S (30min) | -| **MOD-P2-023** | Clonage messages broadcast | Allocations inutiles | `src/websocket/mod.rs:239` | Utiliser `Arc` | Test: broadcast 100 clients < 5ms | S (1h) | -| **MOD-P2-024** | Requêtes N+1 potentielles | Latence élevée | `src/repository/message_repository.rs` | Batch loading conversations | Test: 10 conversations < 100ms | M (2h) | - -## P3 - BASSE PRIORITÉ (Cosmétique / Refactors) - -| ID | Titre | Impact | Fichier | Fix minimal | Validation | Effort | -|---|---|---|---|---|---|---| -| **MOD-P3-025** | Mix français/anglais | Confusion | Multiple | Standardiser sur anglais | Review code | L (8h) | -| **MOD-P3-026** | Logs verbeux en production | Bruit | `src/main.rs:84` | Utiliser `RUST_LOG` approprié | Test: prod logs = info seulement | S (30min) | -| **MOD-P3-027** | Pas de compression WebSocket | Bande passante | `src/websocket/handler.rs:47` | Activer per-message deflate | Test: messages compressés | M (2h) | -| **MOD-P3-028** | Tests ignorés nombreux | Couverture faible | Multiple `#[ignore]` | Setup DB de test ou mocks | Test: tous tests passent | L (8h) | -| **MOD-P3-029** | Documentation rustdoc incomplète | DX | Multiple | Compléter doc comments | Test: `cargo doc` sans warnings | M (4h) | -| **MOD-P3-030** | Pas de benchmarks | Performance non mesurée | N/A | Ajouter `criterion` benchmarks | Test: benchmarks passent | M (4h) | - ---- - -# PHASE G — PLAN D'EXÉCUTION - -## Checklist P0 (Ordre strict) - -1. ✅ **MOD-P0-001**: Fix conflit SQLx - - Modifier `veza-common/Cargo.toml` pour utiliser `sqlx = "0.8.6"` OU exclure SQLite - - Vérifier: `cargo check` passe - - **PR**: `fix: align sqlx versions to 0.8.6` - -2. ✅ **MOD-P0-002**: Validation JWT Secret - - Modifier `src/env.rs` pour valider min 64 chars - - Générer secret aléatoire si absent (dev seulement) - - **PR**: `security: enforce strong JWT secret (min 64 chars)` - -3. ✅ **MOD-P0-003**: Remplacer panics - - `src/main.rs:489` -> `Result` pour signal handlers - - `src/config.rs:192` -> Supprimer `panic!` ou rendre test-only - - `src/env.rs:30,61` -> Retourner `Result` au lieu de `panic!` - - **PR**: `fix: replace panics with Result types` - -4. ✅ **MOD-P0-004**: Validation JWT complète - - Ajouter validation `nbf` dans `validate_access_token` - - Vérifier `iss`/`aud` strictement (déjà fait partiellement) - - **PR**: `security: add nbf validation to JWT tokens` - -## Checklist P1 (Par lots) - -### Lot 1: Sécurité WebSocket (1 sprint) -- **MOD-P1-009**: Rate limiting WebSocket -- **MOD-P1-011**: Validation taille message -- **MOD-P1-013**: Limite connexions WS -- **PR**: `security: add rate limiting and validation for WebSocket` - -### Lot 2: Sécurité REST (1 sprint) -- **MOD-P1-006**: Rate limiting REST -- **MOD-P1-007**: CORS middleware -- **MOD-P1-008**: Masquer secrets dans logs -- **PR**: `security: add rate limiting, CORS, and secure logging` - -### Lot 3: Persistance & Robustesse (1 sprint) -- **MOD-P1-010**: Blacklist JWT persistant -- **MOD-P1-014**: Healthcheck timeout -- **MOD-P2-015**: Request ID tracing -- **PR**: `feat: persistent JWT blacklist, configurable healthcheck, request tracing` - -### Lot 4: Performance WebSocket (1 sprint) -- **MOD-P1-012**: Index inversé broadcast -- **MOD-P2-023**: Arc pour messages -- **PR**: `perf: optimize WebSocket broadcast with index and Arc` - -### Lot 5: Base de données (1 sprint) -- **MOD-P1-005**: Fix SQL injection recherche -- **MOD-P2-024**: Batch loading conversations -- **PR**: `fix: SQL injection in search, add batch loading` - -## Quick Wins (≤ 1h chacun) - -1. **MOD-P2-022**: Heartbeat configurable (30min) -2. **MOD-P2-020**: Supprimer code mort (30min) -3. **MOD-P3-026**: Logs production (30min) -4. **MOD-P2-019**: Nettoyer migrations (1h) - -## Tests à ajouter en priorité - -### Tests de sécurité -- [ ] Test: JWT secret < 64 chars rejeté -- [ ] Test: Token avec `nbf` futur rejeté -- [ ] Test: Recherche avec `%` ne match pas littéralement -- [ ] Test: Rate limiting (1000 req/sec rejetées) -- [ ] Test: CORS sans header rejeté -- [ ] Test: Message > 2000 chars rejeté - -### Tests de robustesse -- [ ] Test: DB down -> healthcheck 503 -- [ ] Test: Token révoqué reste révoqué après restart -- [ ] Test: Broadcast 1000 clients < 10ms -- [ ] Test: 10001ème connexion WS rejetée - -### Tests E2E -- [ ] Test: Connexion WebSocket complète (join, send, leave) -- [ ] Test: Édition/suppression message -- [ ] Test: Read receipts et delivered status -- [ ] Test: Recherche et synchronisation - -## PR Plan (Découpe proposée) - -1. **`fix: resolve sqlx dependency conflict`** (P0-001) -2. **`security: enforce strong JWT secret and validation`** (P0-002, P0-004) -3. **`fix: replace panics with Result types`** (P0-003) -4. **`security: add rate limiting and validation for WebSocket`** (P1-009, P1-011, P1-013) -5. **`security: add rate limiting, CORS, and secure logging`** (P1-006, P1-007, P1-008) -6. **`feat: persistent JWT blacklist and request tracing`** (P1-010, P1-014, P2-015) -7. **`perf: optimize WebSocket broadcast`** (P1-012, P2-023) -8. **`fix: SQL injection and batch loading`** (P1-005, P2-024) -9. **`chore: cleanup migrations and dead code`** (P2-019, P2-020) -10. **`feat: add observability improvements`** (P2-016, P2-017, P2-018) - ---- - -# RÉSUMÉ EXÉCUTIF - -## Statut global: 🔴 **NON PRODUCTION-READY** - -### Bloqueurs (P0): 4 -- Build cassé (conflit SQLx) -- Sécurité JWT faible -- Panics en production -- Validation JWT incomplète - -### Critiques (P1): 10 -- SQL injection potentielle -- Rate limiting manquant -- CORS non configuré -- Secrets dans logs -- Performance WebSocket - -### Améliorations (P2): 10 -- Observabilité incomplète -- Robustesse (circuit breakers, timeouts) -- Code mort - -### Cosmétiques (P3): 6 -- Documentation -- Tests -- Refactors - -## Estimation totale - -- **P0**: 4.5h (1 sprint) -- **P1**: 20h (2-3 sprints) -- **P2**: 18h (2 sprints) -- **P3**: 24h (3 sprints) - -**Total**: ~66.5h (~8-9 jours de travail) - -## Recommandation - -**Priorité immédiate**: Fixer P0 (build + sécurité) avant toute autre chose. - -**Roadmap suggérée**: -1. **Sprint 1**: P0 complet (build + sécurité critique) -2. **Sprint 2-3**: P1 sécurité (rate limiting, CORS, validation) -3. **Sprint 4-5**: P1 performance + P2 observabilité -4. **Sprint 6+**: P2 robustesse + P3 refactors - ---- - -**Fin du rapport d'audit** - diff --git a/veza-chat-server/Cargo.toml b/veza-chat-server/Cargo.toml deleted file mode 100644 index c895419da..000000000 --- a/veza-chat-server/Cargo.toml +++ /dev/null @@ -1,252 +0,0 @@ -#file: backend/modules/chat_server/Cargo.toml - -[package] -name = "chat_server" -version = "0.2.0" -edition = "2021" -authors = ["Veza Team "] -description = "Serveur de chat WebSocket sécurisé et haute performance" -repository = "https://github.com/veza/chat-server" -license = "MIT" -keywords = ["websocket", "chat", "real-time", "rust", "tokio"] -categories = ["network-programming", "web-programming::websocket"] -readme = "README.md" - -[lib] -name = "chat_server" -path = "src/lib.rs" - -[[bin]] -name = "chat-server" -path = "src/main.rs" - -# Les binaires de test sont dans le dossier target/debug et ne sont pas définis ici - -[dependencies] -# ═══════════════════════════════════════════════════════════════════════ -# RUNTIME ASYNCHRONE ET RÉSEAU -# ═══════════════════════════════════════════════════════════════════════ -tokio = { version = "1.35", features = [ - "full", # Toutes les fonctionnalités - "tracing", # Support tracing - "signal", # Signaux système pour shutdown gracieux -] } -tokio-tungstenite = "0.21" # WebSocket server/client -tungstenite = "0.21" # Core WebSocket -futures-util = "0.3" # Utilitaires futures -hyper = { version = "1.0", features = ["full"] } # Client HTTP pour webhooks -axum = { version = "0.8", features = ["macros", "ws"] } # Framework web moderne - -# ═══════════════════════════════════════════════════════════════════════ -# BASE DE DONNÉES ET CACHE -# ═══════════════════════════════════════════════════════════════════════ -sqlx = { version = "0.8", features = [ - "postgres", # Support PostgreSQL - "runtime-tokio-rustls", # Runtime async avec TLS rustls - "chrono", # Support des types de date - "uuid", # Support UUID - "json", # Support JSON/JSONB - "migrate", # Migrations de base de données - "macros", # Macros query! -] } -redis = { version = "0.32", features = [ - "tokio-comp", # Support Tokio - "connection-manager", # Gestionnaire de connexions -], optional = true } -lz4 = "1.24" # Compression pour message storage - -# ═══════════════════════════════════════════════════════════════════════ -# SÉRIALISATION ET FORMATS -# ═══════════════════════════════════════════════════════════════════════ -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -toml = "0.9" # Configuration TOML -rmp-serde = "1.1" # MessagePack pour cache efficace - -# ═══════════════════════════════════════════════════════════════════════ -# AUTHENTIFICATION ET SÉCURITÉ -# ═══════════════════════════════════════════════════════════════════════ -jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } # JWT tokens -bcrypt = "0.17" # Hachage de mots de passe -ring = "0.17" # Cryptographie (signatures, HMAC) -argon2 = "0.5" # Hachage de mots de passe moderne (alternative à bcrypt) -sha2 = "0.10" # Hachage SHA-2 -totp-rs = { version = "5.4", features = ["qr"] } # TOTP 2FA -qrcode = "0.14" # Génération QR codes pour 2FA - -# ═══════════════════════════════════════════════════════════════════════ -# TYPES ET UTILITAIRES -# ═══════════════════════════════════════════════════════════════════════ -veza-common = { path = "../veza-common" } -chrono = { version = "0.4", features = ["serde"] } -uuid = { version = "1.6", features = ["v4", "serde"] } -url = { version = "2.5", features = ["serde"] } # Parsing d'URLs -percent-encoding = "2.3" # Encodage URL -base64 = "0.21" # Encodage base64 -hex = "0.4" # Encodage hexadécimal - -# ═══════════════════════════════════════════════════════════════════════ -# VALIDATION ET NETTOYAGE -# ═══════════════════════════════════════════════════════════════════════ -regex = "1.10" # Expressions régulières -validator = { version = "0.20", features = ["derive"] } # Validation des données -ammonia = "3.3" # Nettoyage HTML/XSS -linkify = "0.10" # Détection automatique de liens - -# ═══════════════════════════════════════════════════════════════════════ -# GESTION D'ERREURS ET LOGGING -# ═══════════════════════════════════════════════════════════════════════ -thiserror = "2.0" # Macros d'erreurs -anyhow = "1.0" # Gestion d'erreurs contextuelles -async-trait = "0.1" # Async trait support -tracing = "0.1" # Logging structuré -tracing-subscriber = { version = "0.3", features = [ - "env-filter", # Filtrage par variables d'env - "fmt", # Formatage console - "json", # Format JSON pour production - "ansi", # Couleurs ANSI - "chrono", # Timestamps -] } -tracing-appender = "0.2" # Rotation des logs - -# ═══════════════════════════════════════════════════════════════════════ -# CONFIGURATION ET ENVIRONNEMENT -# ═══════════════════════════════════════════════════════════════════════ -dotenvy = "0.15" # Variables d'environnement (.env) -config = "0.15" # Configuration multi-sources -clap = { version = "4.4", features = ["derive", "env"] } # CLI arguments - -# ═══════════════════════════════════════════════════════════════════════ -# PERFORMANCE ET MONITORING -# ═══════════════════════════════════════════════════════════════════════ -metrics = { version = "0.22", optional = true } # Métriques de performance -metrics-exporter-prometheus = { version = "0.13", optional = true } # Export Prometheus -dashmap = "6.1" # HashMap concurrent -parking_lot = "0.12" # Mutex plus performants -rayon = "1.10" # Parallel processing pour batching -bytes = "1.6" # Zero-copy message handling -once_cell = "1.19" # Initialisation paresseuse thread-safe - -# ═══════════════════════════════════════════════════════════════════════ -# FONCTIONNALITÉS AVANCÉES -# ═══════════════════════════════════════════════════════════════════════ -notify = "8.2" # Surveillance système de fichiers -image = { version = "0.24", features = ["png", "jpeg", "webp"], optional = true } # Traitement d'images -infer = { version = "0.15", optional = true } # Détection de type de fichier -mime = "0.3" # Types MIME -tempfile = { version = "3.8", optional = true } # Fichiers temporaires -zip = { version = "0.6", optional = true } # Archives ZIP - -# ═══════════════════════════════════════════════════════════════════════ -# ═══════════════════════════════════════════════════════════════════════ -# gRPC ET COMMUNICATION INTER-SERVICES -# ═══════════════════════════════════════════════════════════════════════ -tonic = { version = "0.11", features = ["transport", "prost"] } -prost = "0.12" -prost-types = "0.14" -tokio-stream = "0.1" - -# RabbitMQ client (ORIGIN Architecture - Event Bus) -lapin = "2.3" - -# ═══════════════════════════════════════════════════════════════════════ -# INTÉGRATIONS EXTERNES (OPTIONAL) -# ═══════════════════════════════════════════════════════════════════════ -lettre = { version = "0.11", features = ["tokio1-native-tls"], optional = true } # Envoi d'emails -reqwest = { version = "0.11", features = ["json", "rustls-tls"], optional = true } # Client HTTP -webhook = { version = "2.1", optional = true } # Webhooks sortants -sysinfo = "0.37.2" - -[dev-dependencies] -# ═══════════════════════════════════════════════════════════════════════ -# DÉPENDANCES DE TEST ET DÉVELOPPEMENT -# ═══════════════════════════════════════════════════════════════════════ -tokio-test = "0.4" # Utilitaires de test async -mockall = "0.12" # Mocking -proptest = "1.4" # Property testing -criterion = { version = "0.5", features = ["html_reports"] } # Benchmarks -insta = "1.34" # Tests de snapshot -test-log = "0.2" # Logging dans les tests -pretty_assertions = "1.4" # Assertions plus lisibles - -[build-dependencies] -tonic-build = "0.11" # Génération de code protobuf - -[features] -# Fonctionnalités par défaut -default = [ - "redis-cache", - "file-uploads", - "webhooks", - "metrics", - "email" -] - -# Cache Redis (désactivable pour dev/test) -redis-cache = ["dep:redis"] - -# Upload de fichiers avec validation -file-uploads = ["dep:image", "dep:infer", "dep:tempfile", "dep:zip"] - -# Support des webhooks sortants -webhooks = ["dep:reqwest", "dep:webhook"] - -# Métriques et monitoring -metrics = ["dep:metrics", "dep:metrics-exporter-prometheus"] - -# Envoi d'emails -email = ["dep:lettre"] - -# Mode de développement avec fonctionnalités de debug -dev = ["tokio/test-util"] - -# Version sans dépendances optionnelles (pour déploiements légers) -minimal = [] - -# Tests de base de données (nécessite une DB active) -test-db = [] - -[profile.dev] -# Configuration pour le développement -opt-level = 0 # Pas d'optimisation pour compilation rapide -debug = true # Symboles de debug complets -split-debuginfo = "unpacked" # Debug info séparé (macOS/Linux) -overflow-checks = true # Vérifications d'overflow -lto = false # Pas de LTO pour compilation rapide - -[profile.release] -# Configuration pour la production -opt-level = 3 # Optimisation maximale -debug = false # Pas de symboles de debug -strip = true # Supprimer les symboles -lto = "fat" # Link Time Optimization complète -codegen-units = 1 # Compilation en une seule unité pour optimisation -panic = "abort" # Abort au lieu d'unwind pour performance - -[profile.bench] -# Configuration pour les benchmarks -inherits = "release" -debug = true # Conserver debug pour profiling - -# ═══════════════════════════════════════════════════════════════════════ -# MÉTADONNÉES CARGO -# ═══════════════════════════════════════════════════════════════════════ - -[package.metadata.docs.rs] -# Configuration pour docs.rs -all-features = true -rustdoc-args = ["--cfg", "docsrs"] - -# Badges pour crates.io -[badges] -maintenance = { status = "actively-developed" } - -# Scripts personnalisés -[package.metadata.scripts] -# cargo run-script db-setup -db-setup = "sqlx database create && sqlx migrate run" -# cargo run-script test-all -test-all = "cargo test --all-features --all-targets" -# cargo run-script security-audit -security-audit = "cargo audit" - diff --git a/veza-chat-server/Dockerfile b/veza-chat-server/Dockerfile deleted file mode 100644 index 3c0751b50..000000000 --- a/veza-chat-server/Dockerfile +++ /dev/null @@ -1,65 +0,0 @@ -# Build stage - context: repo root (for veza-common path dep) -FROM rust:alpine AS builder - -WORKDIR /build - -# Copy veza-common (path dependency) and chat-server -COPY veza-common ./veza-common -COPY veza-chat-server/Cargo.toml veza-chat-server/Cargo.lock ./veza-chat-server/ - -# Install build dependencies -RUN apk add --no-cache musl-dev ca-certificates perl make pkgconfig openssl-dev protobuf-dev openssl-libs-static - -WORKDIR /build/veza-chat-server - -# Fetch dependencies (this layer will be cached if Cargo.toml/Cargo.lock don't change) -RUN cargo fetch --locked - -# Copy source code -COPY veza-chat-server/src ./src -COPY veza-chat-server/migrations ./migrations -# SQLx offline build (v0.101) - no DB needed at compile time -COPY veza-chat-server/sqlx-data.json ./ -ENV SQLX_OFFLINE=true -COPY veza-chat-server/proto ./proto -COPY veza-chat-server/build.rs ./ - -# Build the application -# Using --locked to ensure reproducible builds -RUN cargo build --release --locked --target x86_64-unknown-linux-musl - -# Runtime stage -FROM alpine:latest - -# Install runtime dependencies -RUN apk --no-cache add ca-certificates tzdata && \ - # Add wget for health checks - apk --no-cache add wget && \ - # Clean up apk cache - rm -rf /var/cache/apk/* - -# Create non-root user for security -RUN addgroup -g 1001 -S app && \ - adduser -S app -u 1001 -G app -h /app -s /bin/sh - -# Set working directory -WORKDIR /app - -# Copy binary from builder -COPY --from=builder --chown=app:app /build/veza-chat-server/target/x86_64-unknown-linux-musl/release/chat-server /app/chat-server - -# Copy migrations if they exist -COPY --from=builder --chown=app:app /build/veza-chat-server/migrations ./migrations - -# Switch to app user -USER app - -# Expose port -EXPOSE 8081 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1 - -# Run the application -CMD ["./chat-server"] \ No newline at end of file diff --git a/veza-chat-server/Dockerfile.production b/veza-chat-server/Dockerfile.production deleted file mode 100644 index 1fc087cb4..000000000 --- a/veza-chat-server/Dockerfile.production +++ /dev/null @@ -1,66 +0,0 @@ -# Production Dockerfile for Chat Server -# Optimized for smaller size and security - -# Build stage -FROM rust:1.84-alpine AS builder - -WORKDIR /app - -# Install build dependencies -RUN apk add --no-cache musl-dev ca-certificates - -# Copy Cargo files first for better caching -COPY Cargo.toml Cargo.lock ./ - -# Fetch dependencies (this layer will be cached if Cargo.toml/Cargo.lock don't change) -RUN cargo fetch --locked - -# Copy source code -COPY src ./src -COPY migrations ./migrations -COPY build.rs ./ - -# Build the application with optimizations -# - --locked: ensures reproducible builds -# - --target x86_64-unknown-linux-musl: static binary for alpine -# - Strip symbols in release profile (configured in Cargo.toml) -RUN cargo build --release --locked --target x86_64-unknown-linux-musl && \ - # Strip the binary to reduce size - strip /app/target/x86_64-unknown-linux-musl/release/chat-server - -# Runtime stage - minimal alpine -FROM alpine:3.21 - -# Install only runtime dependencies -RUN apk --no-cache add ca-certificates tzdata && \ - # Add wget for health checks - apk --no-cache add wget && \ - # Clean up apk cache - rm -rf /var/cache/apk/* - -# Create non-root user for security -RUN addgroup -g 1001 -S app && \ - adduser -S app -u 1001 -G app -h /app -s /bin/sh - -# Set working directory -WORKDIR /app - -# Copy binary from builder -COPY --from=builder --chown=app:app /app/target/x86_64-unknown-linux-musl/release/chat-server /app/chat-server - -# Copy migrations if they exist -COPY --from=builder --chown=app:app /app/migrations ./migrations 2>/dev/null || true - -# Switch to app user -USER app - -# Expose port -EXPOSE 8081 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1 - -# Run the application -ENTRYPOINT ["./chat-server"] - diff --git a/veza-chat-server/Makefile b/veza-chat-server/Makefile deleted file mode 100644 index 3d9522318..000000000 --- a/veza-chat-server/Makefile +++ /dev/null @@ -1,221 +0,0 @@ -# Makefile - Chat Server Phase 4 - Optimisations avancées -# -# Ce Makefile gère les opérations pour les optimisations Phase 4 : -# - Connection Pool 10k connexions -# - Persistence < 5ms -# - Modération automatique 99.9% -# - Analytics temps réel - -.PHONY: help build test validate clean dev docker phase4 - -# Couleurs pour l'affichage -BLUE = \033[0;34m -GREEN = \033[0;32m -YELLOW = \033[1;33m -RED = \033[0;31m -NC = \033[0m # No Color - -# Configuration -RUST_LOG ?= info -CHAT_PORT ?= 3030 -GRPC_PORT ?= 50051 - -help: ## Affiche l'aide - @echo -e "$(BLUE)🎯 MAKEFILE CHAT SERVER - PHASE 4 OPTIMISATIONS$(NC)" - @echo "==================================================" - @echo "" - @echo -e "$(YELLOW)📋 COMMANDES DISPONIBLES :$(NC)" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ - awk 'BEGIN {FS = ":.*?## "}; {printf " $(GREEN)%-20s$(NC) %s\n", $$1, $$2}' - @echo "" - @echo -e "$(BLUE)🚀 OBJECTIFS PHASE 4 :$(NC)" - @echo " • Connection Pool : 10,000 connexions simultanées" - @echo " • Latence Persistence : < 5ms (L1<1ms, L2<3ms, L3<5ms)" - @echo " • Détection Spam : 99.9% efficacité" - @echo " • Analytics : Temps réel" - -build: ## Compile le chat server en mode optimisé - @echo -e "$(BLUE)🔧 Compilation Chat Server Phase 4...$(NC)" - cargo build --release - @echo -e "$(GREEN)✅ Compilation terminée$(NC)" - -test: ## Lance les tests unitaires - @echo -e "$(BLUE)🧪 Tests Chat Server Phase 4...$(NC)" - cargo test --release - @echo -e "$(GREEN)✅ Tests terminés$(NC)" - -validate: ## Valide les optimisations Phase 4 - @echo -e "$(BLUE)✅ Validation Phase 4...$(NC)" - @chmod +x scripts/validate_phase4.sh - @./scripts/validate_phase4.sh || echo -e "$(YELLOW)⚠️ Validation partielle$(NC)" - -clean: ## Nettoie les fichiers de build - @echo -e "$(BLUE)🧹 Nettoyage...$(NC)" - cargo clean - @echo -e "$(GREEN)✅ Nettoyage terminé$(NC)" - -dev: build ## Lance le serveur en mode développement - @echo -e "$(BLUE)🚀 Démarrage Chat Server Phase 4...$(NC)" - @echo -e "$(YELLOW)📡 Chat WebSocket : ws://localhost:$(CHAT_PORT)/ws$(NC)" - @echo -e "$(YELLOW)🔗 gRPC API : localhost:$(GRPC_PORT)$(NC)" - @echo -e "$(YELLOW)📊 Métriques : http://localhost:$(CHAT_PORT)/metrics$(NC)" - @echo "" - RUST_LOG=$(RUST_LOG) ./target/release/veza-chat-server - -dev-debug: ## Lance le serveur avec logs détaillés - @echo -e "$(BLUE)🔍 Chat Server Phase 4 (Debug)...$(NC)" - RUST_LOG=debug ./target/release/veza-chat-server - -bench: ## Tests de performance - @echo -e "$(BLUE)⚡ Benchmarks Phase 4...$(NC)" - cargo bench - @echo -e "$(GREEN)✅ Benchmarks terminés$(NC)" - -docker: ## Build l'image Docker optimisée - @echo -e "$(BLUE)🐳 Build Docker Chat Server Phase 4...$(NC)" - docker build -t veza-chat-server:phase4 . - @echo -e "$(GREEN)✅ Image Docker créée$(NC)" - -docker-run: docker ## Lance le container Docker - @echo -e "$(BLUE)🐳 Démarrage Container Chat Server...$(NC)" - docker run -p $(CHAT_PORT):$(CHAT_PORT) -p $(GRPC_PORT):$(GRPC_PORT) \ - -e RUST_LOG=$(RUST_LOG) \ - veza-chat-server:phase4 - -metrics: ## Affiche les métriques de performance - @echo -e "$(BLUE)📊 Métriques Chat Server Phase 4...$(NC)" - @echo "" - @echo -e "$(YELLOW)🔍 ANALYSE BINAIRE :$(NC)" - @if [ -f target/release/veza-chat-server ]; then \ - echo -e " Taille binaire : $$(du -h target/release/veza-chat-server | cut -f1)"; \ - echo -e " Dernière build : $$(stat -c %y target/release/veza-chat-server | cut -d. -f1)"; \ - else \ - echo -e " $(RED)❌ Binaire non trouvé - exécuter 'make build'$(NC)"; \ - fi - @echo "" - @echo -e "$(YELLOW)🏗️ ARCHITECTURE MODULES :$(NC)" - @for module in connection_pool advanced_moderation optimized_persistence; do \ - if [ -f src/$$module.rs ]; then \ - lines=$$(wc -l < src/$$module.rs); \ - size=$$(du -h src/$$module.rs | cut -f1); \ - echo -e " $$module : $$lines lignes ($$size)"; \ - fi; \ - done - @echo "" - @echo -e "$(YELLOW)⚡ OPTIMISATIONS DÉTECTÉES :$(NC)" - @if grep -q "max_connections.*10000" src/connection_pool.rs 2>/dev/null; then \ - echo -e " $(GREEN)✅ Connection Pool 10k$(NC)"; \ - else \ - echo -e " $(RED)❌ Connection Pool$(NC)"; \ - fi - @if grep -q "l1_cache.*l2_cache" src/optimized_persistence.rs 2>/dev/null; then \ - echo -e " $(GREEN)✅ Cache multi-niveaux$(NC)"; \ - else \ - echo -e " $(RED)❌ Cache multi-niveaux$(NC)"; \ - fi - @if grep -q "detect_spam.*detect_toxicity" src/advanced_moderation.rs 2>/dev/null; then \ - echo -e " $(GREEN)✅ Modération ML$(NC)"; \ - else \ - echo -e " $(RED)❌ Modération ML$(NC)"; \ - fi - -status: ## Affiche le statut du développement Phase 4 - @echo -e "$(BLUE)📋 STATUT DÉVELOPPEMENT PHASE 4$(NC)" - @echo "==================================" - @echo "" - @echo -e "$(YELLOW)📁 MODULES PHASE 4 :$(NC)" - @for module in connection_pool advanced_moderation optimized_persistence; do \ - if [ -f src/$$module.rs ]; then \ - echo -e " $(GREEN)✅ src/$$module.rs$(NC)"; \ - else \ - echo -e " $(RED)❌ src/$$module.rs$(NC)"; \ - fi; \ - done - @echo "" - @echo -e "$(YELLOW)🔧 ÉTAT COMPILATION :$(NC)" - @if [ -f target/release/veza-chat-server ]; then \ - echo -e " $(GREEN)✅ Binaire optimisé disponible$(NC)"; \ - else \ - echo -e " $(RED)❌ Binaire à compiler (make build)$(NC)"; \ - fi - @echo "" - @echo -e "$(YELLOW)🧪 TESTS :$(NC)" - @if cargo test --release >/dev/null 2>&1; then \ - echo -e " $(GREEN)✅ Tests passent$(NC)"; \ - else \ - echo -e " $(RED)❌ Tests échouent$(NC)"; \ - fi - -install-deps: ## Installe les dépendances système - @echo -e "$(BLUE)📦 Installation dépendances Phase 4...$(NC)" - @echo -e "$(YELLOW)🔍 Vérification dépendances Rust...$(NC)" - @if ! command -v cargo >/dev/null 2>&1; then \ - echo -e "$(RED)❌ Rust non installé$(NC)"; \ - exit 1; \ - fi - @echo -e " $(GREEN)✅ Rust/Cargo disponible$(NC)" - @if ! command -v redis-cli >/dev/null 2>&1; then \ - echo -e "$(YELLOW)⚠️ Redis CLI recommandé pour tests$(NC)"; \ - else \ - echo -e " $(GREEN)✅ Redis CLI disponible$(NC)"; \ - fi - @if ! command -v psql >/dev/null 2>&1; then \ - echo -e "$(YELLOW)⚠️ PostgreSQL CLI recommandé pour tests$(NC)"; \ - else \ - echo -e " $(GREEN)✅ PostgreSQL CLI disponible$(NC)"; \ - fi - -lint: ## Vérifie la qualité du code - @echo -e "$(BLUE)🔍 Analyse qualité code Phase 4...$(NC)" - cargo clippy --all-targets --all-features -- -D warnings - cargo fmt --check - @echo -e "$(GREEN)✅ Code quality check terminé$(NC)" - -fix: ## Corrige automatiquement le code - @echo -e "$(BLUE)🔧 Correction automatique code...$(NC)" - cargo fmt - cargo fix --allow-dirty --allow-staged - @echo -e "$(GREEN)✅ Corrections appliquées$(NC)" - -load-test: build ## Test de charge basique - @echo -e "$(BLUE)⚡ Test de charge Chat Server...$(NC)" - @echo -e "$(YELLOW)📡 Démarrage serveur test...$(NC)" - @# Simuler une charge basique - @if command -v ab >/dev/null 2>&1; then \ - echo -e " $(GREEN)✅ Apache Bench disponible$(NC)"; \ - timeout 10s ./target/release/veza-chat-server & \ - sleep 2 && \ - ab -n 1000 -c 10 http://localhost:$(CHAT_PORT)/health || true; \ - pkill -f veza-chat-server || true; \ - else \ - echo -e " $(YELLOW)⚠️ Apache Bench non installé (sudo dnf install httpd-tools)$(NC)"; \ - fi - -phase4: build validate metrics ## Validation complète Phase 4 - @echo -e "$(BLUE)🎯 VALIDATION COMPLÈTE PHASE 4$(NC)" - @echo "=================================" - @echo "" - @echo -e "$(GREEN)✅ PHASE 4 - OPTIMISATION CHAT SERVER VALIDÉE !$(NC)" - @echo "" - @echo -e "$(YELLOW)🏆 RÉALISATIONS :$(NC)" - @echo -e " • Connection Pool haute performance (10k connexions)" - @echo -e " • Persistence ultra-rapide (cache L1/L2/L3 < 5ms)" - @echo -e " • Modération automatique avancée (ML + patterns)" - @echo -e " • Analytics temps réel (métriques complètes)" - @echo "" - @echo -e "$(BLUE)🚀 PRÊT POUR PHASE 5 - OPTIMISATION STREAM SERVER !$(NC)" - -# Targets pour développement rapide -quick: build dev ## Build et lance rapidement - -restart: ## Relance le serveur - @pkill -f veza-chat-server || true - @sleep 1 - @make dev - -logs: ## Affiche les logs du serveur - @echo -e "$(BLUE)📋 Logs Chat Server...$(NC)" - @tail -f /tmp/veza-chat-server.log 2>/dev/null || echo "Aucun log trouvé" - -# Aide par défaut -.DEFAULT_GOAL := help \ No newline at end of file diff --git a/veza-chat-server/build.rs b/veza-chat-server/build.rs deleted file mode 100644 index a5385b364..000000000 --- a/veza-chat-server/build.rs +++ /dev/null @@ -1,41 +0,0 @@ -fn main() -> Result<(), Box> { - // Générer les bindings Rust à partir des fichiers .proto - let proto_dir = "proto"; - let proto_files = vec!["proto/chat/chat.proto", "proto/common/auth.proto"]; - - // Vérifier si protoc est disponible - // Si les fichiers générés existent déjà, on peut continuer sans protoc - let generated_dir = std::path::Path::new("src/generated"); - let required_files = vec![ - generated_dir.join("veza.chat.rs"), - generated_dir.join("veza.common.auth.rs"), - ]; - - let all_generated_exist = required_files.iter().all(|p| p.exists()); - - if all_generated_exist { - // Les fichiers générés existent, on peut continuer sans protoc - println!("cargo:warning=Using pre-generated protobuf files. protoc not required."); - for proto_file in &proto_files { - println!("cargo:rerun-if-changed={}", proto_file); - } - println!("cargo:rerun-if-changed=build.rs"); - return Ok(()); - } - - // Configuration tonic-build - tonic_build::configure() - .build_server(true) - .build_client(false) // Chat server est serveur, pas client - .out_dir("src/generated") - .compile(&proto_files, &[proto_dir])?; - - // Recompiler si les fichiers .proto changent - for proto_file in &proto_files { - println!("cargo:rerun-if-changed={}", proto_file); - } - - println!("cargo:rerun-if-changed=build.rs"); - - Ok(()) -} diff --git a/veza-chat-server/check_output.txt b/veza-chat-server/check_output.txt deleted file mode 100644 index feb52c2ca..000000000 --- a/veza-chat-server/check_output.txt +++ /dev/null @@ -1,16 +0,0 @@ - Updating crates.io index -error: failed to select a version for `libsqlite3-sys`. - ... required by package `sqlx-sqlite v0.7.0` - ... which satisfies dependency `sqlx-sqlite = "=0.7.0"` of package `sqlx v0.7.0` - ... which satisfies dependency `sqlx = "^0.7"` of package `veza-common v0.1.0 (/home/senke/git/talas/veza/veza-common)` - ... which satisfies path dependency `veza-common` of package `chat_server v0.2.0 (/home/senke/git/talas/veza/veza-chat-server)` -versions that meet the requirements `^0.26.0` are: 0.26.0 - -package `libsqlite3-sys` links to the native library `sqlite3`, but it conflicts with a previous package which links to `sqlite3` as well: -package `libsqlite3-sys v0.30.1` - ... which satisfies dependency `libsqlite3-sys = "^0.30.1"` of package `sqlx-sqlite v0.8.6` - ... which satisfies dependency `sqlx-sqlite = "=0.8.6"` of package `sqlx v0.8.6` - ... which satisfies dependency `sqlx = "^0.8.6"` of package `chat_server v0.2.0 (/home/senke/git/talas/veza/veza-chat-server)` -Only one package in the dependency graph may specify the same links value. This helps ensure that only one copy of a native library is linked in the final binary. Try to adjust your dependencies so that only one package uses the `links = "sqlite3"` value. For more information, see https://doc.rust-lang.org/cargo/reference/resolver.html#links. - -failed to select a version for `libsqlite3-sys` which could resolve this conflict diff --git a/veza-chat-server/check_output_2.txt b/veza-chat-server/check_output_2.txt deleted file mode 100644 index b12e58ab7..000000000 --- a/veza-chat-server/check_output_2.txt +++ /dev/null @@ -1,94 +0,0 @@ - Checking chat_server v0.2.0 (/home/senke/Documents/veza/veza-chat-server) -warning: unused imports: `Pool` and `Postgres` - --> src/config.rs:2:20 - | -2 | use sqlx::{PgPool, Pool, Postgres}; - | ^^^^ ^^^^^^^^ - | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default - -warning: unused import: `error` - --> src/config.rs:5:22 - | -5 | use tracing::{debug, error, info, warn}; - | ^^^^^ - -warning: unused imports: `Error as LapinError`, `ExchangeKind`, and `options::ExchangeDeclareOptions` - --> src/event_bus.rs:2:5 - | -2 | options::ExchangeDeclareOptions, types::FieldTable, Channel, Connection, ConnectionProperties, - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -3 | Error as LapinError, ExchangeKind, - | ^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^ - -warning: unused import: `warn` - --> src/typing_indicator.rs:5:40 - | -5 | use tracing::{info, debug, instrument, warn}; - | ^^^^ - -warning: variable does not need to be mutable - --> src/delivered_status.rs:57:21 - | -57 | if let Some(mut status) = existing { - | ----^^^^^^ - | | - | help: remove this `mut` - | - = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default - -warning: variable does not need to be mutable - --> src/read_receipts.rs:86:21 - | -86 | if let Some(mut receipt) = existing { - | ----^^^^^^^ - | | - | help: remove this `mut` - -error[E0599]: no method named `get_all_metrics` found for reference `&ChatMetrics` in the current scope - --> src/monitoring.rs:269:36 - | -269 | let metrics_data = metrics.get_all_metrics().await; - | ^^^^^^^^^^^^^^^ - | -help: one of the expressions' fields has a method of the same name - | -269 | let metrics_data = metrics.collector.get_all_metrics().await; - | ++++++++++ -help: there is a method `get_system_metrics` with a similar name - | -269 - let metrics_data = metrics.get_all_metrics().await; -269 + let metrics_data = metrics.get_system_metrics().await; - | - -warning: unreachable expression - --> src/config.rs:201:9 - | -194 | / panic!( -195 | | "SecurityConfig::default() cannot be used in production. \ -196 | | Create SecurityConfig manually with require_env_min_length(\"JWT_SECRET\", 32)" -197 | | ); - | |_____________- any code following this expression is unreachable -... -201 | / Self { -202 | | jwt_secret: "test_jwt_secret_minimum_32_characters_long".to_string(), -203 | | jwt_access_duration: Duration::from_secs(900), // 15 min -204 | | jwt_refresh_duration: Duration::from_secs(86400 * 30), // 30 days -... | -212 | | bcrypt_cost: 12, -213 | | } - | |_________^ unreachable expression - | - = note: `#[warn(unreachable_code)]` (part of `#[warn(unused)]`) on by default - -warning: unused variable: `user_id` - --> src/security/permission.rs:54:17 - | -54 | user_id, - | ^^^^^^^ help: try ignoring the field: `user_id: _` - | - = note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default - -For more information about this error, try `rustc --explain E0599`. -warning: `chat_server` (lib) generated 8 warnings -error: could not compile `chat_server` (lib) due to 1 previous error; 8 warnings emitted diff --git a/veza-chat-server/config/grafana/dashboards/veza-dashboard.json b/veza-chat-server/config/grafana/dashboards/veza-dashboard.json deleted file mode 100644 index 976fdee38..000000000 --- a/veza-chat-server/config/grafana/dashboards/veza-dashboard.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "dashboard": { - "id": null, - "title": "Veza Platform - Local Dashboard", - "tags": ["veza", "local"], - "style": "dark", - "timezone": "browser", - "panels": [ - { - "id": 1, - "title": "System Overview", - "type": "stat", - "targets": [ - { - "expr": "up", - "legendFormat": "{{job}}" - } - ] - } - ], - "time": { - "from": "now-1h", - "to": "now" - } - } -} diff --git a/veza-chat-server/config/grafana/datasources/prometheus.yml b/veza-chat-server/config/grafana/datasources/prometheus.yml deleted file mode 100644 index 86fd3465e..000000000 --- a/veza-chat-server/config/grafana/datasources/prometheus.yml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: 1 - -datasources: - - name: Prometheus - type: prometheus - access: proxy - url: http://prometheus:9090 - isDefault: true diff --git a/veza-chat-server/config/prometheus.yml b/veza-chat-server/config/prometheus.yml deleted file mode 100644 index 51e989d2a..000000000 --- a/veza-chat-server/config/prometheus.yml +++ /dev/null @@ -1,27 +0,0 @@ -global: - scrape_interval: 15s - evaluation_interval: 15s - -rule_files: - # - "first_rules.yml" - # - "second_rules.yml" - -scrape_configs: - - job_name: 'prometheus' - static_configs: - - targets: ['localhost:9090'] - - - job_name: 'veza-backend' - static_configs: - - targets: ['host.docker.internal:8080'] - metrics_path: '/metrics' - - - job_name: 'veza-chat' - static_configs: - - targets: ['host.docker.internal:3001'] - metrics_path: '/metrics' - - - job_name: 'veza-stream' - static_configs: - - targets: ['host.docker.internal:3002'] - metrics_path: '/metrics' diff --git a/veza-chat-server/deploy-simple.sh b/veza-chat-server/deploy-simple.sh deleted file mode 100644 index 141efd299..000000000 --- a/veza-chat-server/deploy-simple.sh +++ /dev/null @@ -1,112 +0,0 @@ -#!/bin/bash - -set -e - -echo "🚀 Déploiement du serveur de chat Veza (version simplifiée)" -echo "============================================================" - -# Configuration -CONTAINER_NAME="veza-chat" -BINARY_NAME="chat-server" -PORT=3001 - -# Fonctions -build_server() { - echo "📦 Compilation du serveur de chat..." - cargo build --release --bin chat-server - echo "✅ Compilation réussie" -} - -deploy_to_container() { - echo "🚢 Déploiement dans le container $CONTAINER_NAME..." - - # Copier le binaire - incus file push target/release/chat-server $CONTAINER_NAME/opt/veza/ - - # Rendre exécutable - incus exec $CONTAINER_NAME -- chmod +x /opt/veza/chat-server - - # Créer le service systemd - incus exec $CONTAINER_NAME -- tee /etc/systemd/system/veza-chat.service > /dev/null << 'EOF' -[Unit] -Description=Veza Chat Server -After=network.target - -[Service] -Type=simple -User=www-data -WorkingDirectory=/opt/veza -ExecStart=/opt/veza/chat-server -Restart=always -RestartSec=10 -Environment=RUST_LOG=info - -[Install] -WantedBy=multi-user.target -EOF - - # Activer et démarrer le service - incus exec $CONTAINER_NAME -- systemctl daemon-reload - incus exec $CONTAINER_NAME -- systemctl enable veza-chat - incus exec $CONTAINER_NAME -- systemctl restart veza-chat - - echo "✅ Service déployé et démarré" -} - -test_deployment() { - echo "🧪 Test du déploiement..." - - # Récupérer l'IP du container - IP=$(incus list $CONTAINER_NAME -c 4 --format csv | cut -d' ' -f1) - - if [ -z "$IP" ]; then - echo "❌ Impossible de récupérer l'IP du container" - return 1 - fi - - echo "📡 Test de santé sur http://$IP:$PORT/health" - - # Attendre que le service démarre - sleep 5 - - # Test de l'endpoint de santé - if curl -s "http://$IP:$PORT/health" | grep -q "healthy"; then - echo "✅ Serveur de chat opérationnel sur $IP:$PORT" - echo "📊 Endpoints disponibles :" - echo " - GET http://$IP:$PORT/health" - echo " - GET http://$IP:$PORT/api/messages?room=general" - echo " - POST http://$IP:$PORT/api/messages" - echo " - GET http://$IP:$PORT/api/messages/stats" - return 0 - else - echo "❌ Le serveur ne répond pas correctement" - echo "📝 Logs du service :" - incus exec $CONTAINER_NAME -- journalctl -u veza-chat --no-pager -n 20 - return 1 - fi -} - -# Vérifications préliminaires -if ! command -v incus &> /dev/null; then - echo "❌ Incus non installé" - exit 1 -fi - -if ! incus list | grep -q $CONTAINER_NAME; then - echo "❌ Container $CONTAINER_NAME non trouvé" - echo "📋 Containers disponibles :" - incus list - exit 1 -fi - -# Déploiement -echo "🎯 Déploiement vers le container : $CONTAINER_NAME" - -build_server -deploy_to_container -test_deployment - -echo "" -echo "🎉 Déploiement terminé avec succès !" -echo "📊 Pour tester l'API :" -echo " curl http://$(incus list $CONTAINER_NAME -c 4 --format csv | cut -d' ' -f1):$PORT/health" \ No newline at end of file diff --git a/veza-chat-server/docker-compose.local.yml b/veza-chat-server/docker-compose.local.yml deleted file mode 100644 index 9649000c9..000000000 --- a/veza-chat-server/docker-compose.local.yml +++ /dev/null @@ -1,120 +0,0 @@ -version: '3.8' - -services: - # Base de données PostgreSQL - postgres: - image: postgres:15-alpine - container_name: veza-postgres-local - environment: - POSTGRES_DB: veza_local - POSTGRES_USER: veza_user - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-devpassword} - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - - ./scripts/database/init.sql:/docker-entrypoint-initdb.d/init.sql - networks: - - veza-network - - # Cache Redis - redis: - image: redis:7-alpine - container_name: veza-redis-local - ports: - - "6379:6379" - volumes: - - redis_data:/data - networks: - - veza-network - - # Monitoring - Prometheus - prometheus: - image: prom/prometheus:latest - container_name: veza-prometheus-local - ports: - - "9090:9090" - volumes: - - ./config/prometheus.yml:/etc/prometheus/prometheus.yml - - prometheus_data:/prometheus - command: - - '--config.file=/etc/prometheus/prometheus.yml' - - '--storage.tsdb.path=/prometheus' - - '--web.console.libraries=/etc/prometheus/console_libraries' - - '--web.console.templates=/etc/prometheus/consoles' - - '--storage.tsdb.retention.time=200h' - - '--web.enable-lifecycle' - networks: - - veza-network - - # Monitoring - Grafana - grafana: - image: grafana/grafana:latest - container_name: veza-grafana-local - ports: - - "3000:3000" - environment: - GF_SECURITY_ADMIN_PASSWORD: admin - GF_USERS_ALLOW_SIGN_UP: "false" - volumes: - - grafana_data:/var/lib/grafana - - ./config/grafana/dashboards:/etc/grafana/provisioning/dashboards - - ./config/grafana/datasources:/etc/grafana/provisioning/datasources - networks: - - veza-network - - # Logging - Elasticsearch - elasticsearch: - image: docker.elastic.co/elasticsearch/elasticsearch:8.8.0 - container_name: veza-elasticsearch-local - environment: - - discovery.type=single-node - - xpack.security.enabled=false - - "ES_JAVA_OPTS=-Xms512m -Xmx512m" - ports: - - "9200:9200" - volumes: - - elasticsearch_data:/usr/share/elasticsearch/data - networks: - - veza-network - - # Logging - Kibana - kibana: - image: docker.elastic.co/kibana/kibana:8.8.0 - container_name: veza-kibana-local - ports: - - "5601:5601" - environment: - ELASTICSEARCH_HOSTS: http://elasticsearch:9200 - volumes: - - kibana_data:/usr/share/kibana/data - networks: - - veza-network - depends_on: - - elasticsearch - - # Logging - Filebeat - filebeat: - image: docker.elastic.co/beats/filebeat:8.8.0 - container_name: veza-filebeat-local - user: root - volumes: - - ./config/filebeat.yml:/usr/share/filebeat/filebeat.yml:ro - - /var/lib/docker/containers:/var/lib/docker/containers:ro - - /var/run/docker.sock:/var/run/docker.sock:ro - networks: - - veza-network - depends_on: - - elasticsearch - -volumes: - postgres_data: - redis_data: - prometheus_data: - grafana_data: - elasticsearch_data: - kibana_data: - -networks: - veza-network: - driver: bridge diff --git a/veza-chat-server/docker-compose.yml b/veza-chat-server/docker-compose.yml deleted file mode 100644 index a50643a8f..000000000 --- a/veza-chat-server/docker-compose.yml +++ /dev/null @@ -1,181 +0,0 @@ -version: '3.8' - -services: - # ==================================== - # SERVEUR CHAT RUST PRINCIPAL - # ==================================== - chat-server: - build: - context: . - dockerfile: Dockerfile - ports: - - "8080:8080" - - "9090:9090" # Métriques Prometheus - environment: - - RUST_ENV=development - - RUST_LOG=info - - DATABASE_URL=postgresql://${POSTGRES_USER:-veza_user}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/veza_chat - - REDIS_URL=redis://redis:6379 - # DEV ONLY: Override via .env in production. Never use this compose for prod. - - JWT_SECRET=${JWT_SECRET:-dev-only-secret-min-32-chars-for-local} - - SERVER_BIND_ADDR=0.0.0.0:8080 - - PROMETHEUS_BIND_ADDR=0.0.0.0:9090 - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - volumes: - - ./logs:/app/logs - - ./config:/app/config - restart: unless-stopped - networks: - - veza-network - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] - interval: 30s - timeout: 10s - retries: 3 - - # ==================================== - # BASE DE DONNÉES POSTGRESQL - # ==================================== - postgres: - image: postgres:15-alpine - environment: - POSTGRES_DB: veza_chat - POSTGRES_USER: ${POSTGRES_USER:-veza_user} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-devpassword} - POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256" - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - - ./scripts/database/init.sql:/docker-entrypoint-initdb.d/init.sql:ro - restart: unless-stopped - networks: - - veza-network - healthcheck: - test: ["CMD-SHELL", "pg_isready -U veza_user -d veza_chat"] - interval: 10s - timeout: 5s - retries: 5 - - # ==================================== - # CACHE REDIS - # ==================================== - redis: - image: redis:7-alpine - ports: - - "6379:6379" - volumes: - - redis_data:/data - - ./config/redis.conf:/usr/local/etc/redis/redis.conf:ro - command: redis-server /usr/local/etc/redis/redis.conf - restart: unless-stopped - networks: - - veza-network - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 3 - - # ==================================== - # MONITORING PROMETHEUS (OPTIONNEL) - # ==================================== - prometheus: - image: prom/prometheus:latest - ports: - - "9091:9090" - volumes: - - ./config/prometheus.yml:/etc/prometheus/prometheus.yml:ro - - prometheus_data:/prometheus - command: - - '--config.file=/etc/prometheus/prometheus.yml' - - '--storage.tsdb.path=/prometheus' - - '--web.console.libraries=/etc/prometheus/console_libraries' - - '--web.console.templates=/etc/prometheus/consoles' - - '--storage.tsdb.retention.time=200h' - - '--web.enable-lifecycle' - restart: unless-stopped - networks: - - veza-network - profiles: - - monitoring - - # ==================================== - # GRAFANA POUR VISUALISATION (OPTIONNEL) - # ==================================== - grafana: - image: grafana/grafana:latest - ports: - - "3000:3000" - environment: - - GF_SECURITY_ADMIN_PASSWORD=admin - - GF_USERS_ALLOW_SIGN_UP=false - volumes: - - grafana_data:/var/lib/grafana - - ./config/grafana/datasources:/etc/grafana/provisioning/datasources:ro - - ./config/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro - restart: unless-stopped - networks: - - veza-network - profiles: - - monitoring - - # ==================================== - # NGINX REVERSE PROXY (OPTIONNEL) - # ==================================== - nginx: - image: nginx:alpine - ports: - - "80:80" - - "443:443" - volumes: - - ./config/nginx/nginx.conf:/etc/nginx/nginx.conf:ro - - ./config/nginx/ssl:/etc/nginx/ssl:ro - - ./logs/nginx:/var/log/nginx - depends_on: - - chat-server - restart: unless-stopped - networks: - - veza-network - profiles: - - production - -# ==================================== -# VOLUMES PERSISTANTS -# ==================================== -volumes: - postgres_data: - driver: local - redis_data: - driver: local - prometheus_data: - driver: local - grafana_data: - driver: local - -# ==================================== -# RÉSEAU INTERNE -# ==================================== -networks: - veza-network: - driver: bridge - ipam: - config: - - subnet: 172.20.0.0/16 - -# ==================================== -# PROFILS DE DÉPLOIEMENT -# ==================================== - -# Démarrage basique (développement) -# docker-compose up chat-server postgres redis - -# Avec monitoring complet -# docker-compose --profile monitoring up - -# Production avec proxy -# docker-compose --profile production --profile monitoring up \ No newline at end of file diff --git a/veza-chat-server/docs/AUDIT_DELIVERED_TYPING.md b/veza-chat-server/docs/AUDIT_DELIVERED_TYPING.md deleted file mode 100644 index ef81e0115..000000000 --- a/veza-chat-server/docs/AUDIT_DELIVERED_TYPING.md +++ /dev/null @@ -1,167 +0,0 @@ -# 🔍 AUDIT INITIAL — Delivered Status + Typing Indicators - -**Date** : 2025-01-27 -**Cible** : `veza-chat-server` -**Objectif** : État actuel avant implémentation P1 - ---- - -## 1. TYPING INDICATORS — État actuel - -### 1.1. Module existant : `src/typing_indicator.rs` - -✅ **Structure présente** : -- `TypingIndicatorManager` existe avec : - - `typing_users: Arc>>>>` - - `timeout_duration: Duration::seconds(3)` (hardcodé) - -✅ **Méthodes disponibles** : -- `set_typing(conversation_id, user_id)` — marque un user comme "typing" -- `stop_typing(conversation_id, user_id)` — retire un user -- `get_typing_users(conversation_id)` — liste les users actifs (filtre les expirés) -- `cleanup_expired()` — nettoie les entrées expirées - -❌ **Manques identifiés** : -1. **Pas de task de monitoring automatique** : `cleanup_expired()` existe mais n'est jamais appelée automatiquement -2. **Pas de broadcast automatique** : le manager ne déclenche pas de broadcast quand un timeout expire -3. **Pas intégré dans WebSocketState** : le manager n'est pas instancié dans `WebSocketState` -4. **Pas de méthode `monitor_timeouts()`** : pas de boucle de fond pour détecter les expirations - -### 1.2. WebSocket Messages - -❌ **IncomingMessage::Typing** : **N'EXISTE PAS** -- Seuls existent : `SendMessage`, `JoinConversation`, `LeaveConversation`, `MarkAsRead`, `Ping` - -❌ **OutgoingMessage::UserTyping** : **N'EXISTE PAS** -- Seuls existent : `NewMessage`, `MessageRead`, `ActionConfirmed`, `Error`, `Pong` - -### 1.3. Handler WebSocket - -❌ **Pas de branchement pour Typing** dans `handle_incoming_message()` (`src/websocket/handler.rs`) - ---- - -## 2. DELIVERED STATUS — État actuel - -### 2.1. Enum MessageReadStatus - -✅ **Existe dans `src/read_receipts.rs`** : -```rust -pub enum MessageReadStatus { - Sent, - Delivered, // ✅ Existe mais non utilisé - Read, -} -``` - -⚠️ **Problème** : `Delivered` existe dans l'enum mais : -- `get_message_status()` retourne toujours `Sent` si pas de read receipt (ligne 230) -- Commentaire TODO : "Implémenter un système de tracking delivered si nécessaire" - -### 2.2. Base de données - -❌ **Table `delivered_status`** : **N'EXISTE PAS** -- Aucune migration trouvée pour cette table -- Seule table `read_receipts` existe pour les messages lus - -### 2.3. Manager dédié - -❌ **DeliveredStatusManager** : **N'EXISTE PAS** -- `ReadReceiptManager` gère uniquement les read receipts -- Pas de module `src/delivered_status.rs` - -### 2.4. WebSocket Messages - -❌ **IncomingMessage::Delivered** : **N'EXISTE PAS** - -❌ **OutgoingMessage::MessageDelivered** : **N'EXISTE PAS** - -### 2.5. Handler WebSocket - -❌ **Pas de branchement pour Delivered** dans `handle_incoming_message()` - ---- - -## 3. PERMISSIONS — État actuel - -✅ **PermissionService existe** (`src/security/permission.rs`) : -- `can_read_conversation(user_id, conversation_id)` -- `can_send_message(user_id, conversation_id)` -- `can_mark_read(user_id, conversation_id)` - -✅ **Intégration dans handler** : -- `MarkAsRead` utilise déjà `can_mark_read()` -- `SendMessage` utilise déjà `can_send_message()` - ---- - -## 4. ARCHITECTURE ACTUELLE - -### 4.1. WebSocketState - -```rust -pub struct WebSocketState { - pub message_repo: Arc, - pub read_receipt_manager: Arc, - pub ws_manager: Arc, - pub jwt_manager: Arc, - pub permission_service: Arc, -} -``` - -❌ **TypingIndicatorManager manquant** dans `WebSocketState` - -❌ **DeliveredStatusManager manquant** dans `WebSocketState` - -### 4.2. Main.rs - -- `ReadReceiptManager` est instancié (ligne 147) -- `PermissionService` est instancié (ligne 148) -- `TypingIndicatorManager` **n'est pas instancié** -- `DeliveredStatusManager` **n'existe pas encore** - ---- - -## 5. RÉSUMÉ DES MANQUES - -### Typing Indicators -- ✅ Manager existe mais incomplet -- ❌ Pas de task de monitoring automatique -- ❌ Pas intégré dans WebSocketState -- ❌ Pas de messages WebSocket (Incoming/Outgoing) -- ❌ Pas de branchement dans handler - -### Delivered Status -- ✅ Enum `Delivered` existe mais non utilisé -- ❌ Pas de table DB -- ❌ Pas de manager dédié -- ❌ Pas de messages WebSocket (Incoming/Outgoing) -- ❌ Pas de branchement dans handler - ---- - -## 6. PLAN D'IMPLÉMENTATION - -### Phase 1 : Infrastructure -1. Créer migration SQL pour `delivered_status` -2. Créer `src/delivered_status.rs` avec `DeliveredStatusManager` -3. Améliorer `TypingIndicatorManager` avec task de monitoring - -### Phase 2 : WebSocket Messages -4. Ajouter `IncomingMessage::Typing` et `IncomingMessage::Delivered` -5. Ajouter `OutgoingMessage::UserTyping` et `OutgoingMessage::MessageDelivered` - -### Phase 3 : Intégration -6. Ajouter managers dans `WebSocketState` -7. Brancher handlers dans `handle_incoming_message()` -8. Démarrer task de monitoring typing dans `main.rs` - -### Phase 4 : Tests & Documentation -9. Tests unitaires -10. Tests d'intégration -11. Documentation complète - ---- - -**Prochaine étape** : Implémentation selon le design cible. - diff --git a/veza-chat-server/docs/AUDIT_HISTORY_SEARCH_SYNC.md b/veza-chat-server/docs/AUDIT_HISTORY_SEARCH_SYNC.md deleted file mode 100644 index 40ad5c1db..000000000 --- a/veza-chat-server/docs/AUDIT_HISTORY_SEARCH_SYNC.md +++ /dev/null @@ -1,212 +0,0 @@ -# 🔍 Audit Initial - Message Search, History Pagination, and Offline Sync - -**Date**: 2025-12-05 -**Objectif**: Analyser l'état actuel avant implémentation des fonctionnalités P1 - ---- - -## 1. AUDIT DES FONCTIONNALITÉS EXISTANTES - -### 1.1 Recherche de messages -**❌ N'EXISTE PAS** -- Aucune fonction dans `MessageRepository` pour rechercher des messages -- Aucune route WebSocket ou REST pour la recherche -- Aucun index de recherche textuelle sur la colonne `content` - -### 1.2 Pagination de l'historique -**⚠️ PARTIELLEMENT EXISTANT** -- `MessageRepository::get_conversation_messages()` existe mais : - - Ne supporte que `LIMIT` (pas de cursors `before`/`after`) - - Ne retourne pas `has_more_before`/`has_more_after` - - Tri toujours `DESC` sans possibilité de tri `ASC` pour `after` -- Aucune route WebSocket pour `FetchHistory` - -### 1.3 Synchronisation hors ligne -**❌ N'EXISTE PAS** -- Aucune fonction pour récupérer les messages depuis un timestamp -- Aucune route WebSocket pour `SyncMessages` -- Pas de mécanisme pour tracker le dernier timestamp de sync - ---- - -## 2. AUDIT DES INDEX SQL - -### 2.1 Index existants sur `messages` -```sql --- Migration 001 -idx_messages_conversation_id ON messages(conversation_id) -idx_messages_sender_id ON messages(sender_id) -idx_messages_created_at ON messages(created_at) - --- Migration 005 -idx_messages_deleted_at ON messages(deleted_at) WHERE deleted_at IS NOT NULL -idx_messages_edited_at ON messages(edited_at) WHERE edited_at IS NOT NULL -``` - -### 2.2 Index manquants (REQUIS) -**❌ Index composite pour pagination** -```sql -CREATE INDEX idx_messages_conv_created_at -ON messages(conversation_id, created_at DESC); -``` - -**❌ Index GIN pour recherche textuelle** -```sql --- Option 1: Index GIN avec tsvector (recherche avancée) -ALTER TABLE messages ADD COLUMN tsv tsvector; -CREATE INDEX idx_messages_tsv ON messages USING GIN(tsv); - --- Option 2: Index trigram pour recherche ILIKE (plus simple) -CREATE EXTENSION IF NOT EXISTS pg_trgm; -CREATE INDEX idx_messages_content_trgm ON messages USING GIN(content gin_trgm_ops); -``` - ---- - -## 3. AUDIT DES CHAMPS DE TIMESTAMPS - -### 3.1 Format stocké dans la table `messages` -- ✅ `edited_at`: `TIMESTAMP WITH TIME ZONE` (Option> en Rust) -- ✅ `deleted_at`: `TIMESTAMP WITH TIME ZONE` (Option> en Rust) -- ✅ `created_at`: `TIMESTAMP WITH TIME ZONE` (DateTime en Rust) -- ✅ `updated_at`: `TIMESTAMP WITH TIME ZONE` (DateTime en Rust) - -### 3.2 Format stocké dans les tables séparées -- ✅ `read_at`: Dans `read_receipts` table (Migration 003) -- ✅ `delivered_at`: Dans `delivered_status` table (Migration 004) - -**Note**: Les statuts `read` et `delivered` sont dans des tables séparées, pas dans `messages`. Pour la sync offline, il faudra joindre ces tables ou les inclure dans la réponse. - ---- - -## 4. AUDIT DES TYPES WEBSOCKET - -### 4.1 IncomingMessage (src/websocket/mod.rs) -**Types existants**: -- `SendMessage` -- `JoinConversation` -- `LeaveConversation` -- `MarkAsRead` -- `Typing` -- `Delivered` -- `EditMessage` -- `DeleteMessage` -- `Ping` - -**Types manquants**: -- ❌ `FetchHistory` -- ❌ `SearchMessages` -- ❌ `SyncMessages` - -### 4.2 OutgoingMessage (src/websocket/mod.rs) -**Types existants**: -- `NewMessage` -- `MessageRead` -- `MessageDelivered` -- `UserTyping` -- `MessageEdited` -- `MessageDeleted` -- `ActionConfirmed` -- `Error` -- `Pong` - -**Types manquants**: -- ❌ `HistoryChunk` -- ❌ `SearchResults` -- ❌ `SyncChunk` - ---- - -## 5. AUDIT DU REPOSITORY - -### 5.1 MessageRepository (src/repository/message_repository.rs) -**Méthodes existantes**: -- ✅ `create()` - Créer un message -- ✅ `get_conversation_messages()` - Récupérer messages avec LIMIT -- ✅ `get_by_id()` - Récupérer un message par ID -- ✅ `update()` - Mettre à jour un message -- ✅ `delete()` - Soft delete un message -- ✅ `get_by_id_including_deleted()` - Récupérer même si supprimé - -**Méthodes manquantes**: -- ❌ `fetch_history()` - Pagination avec before/after -- ❌ `search_messages()` - Recherche textuelle -- ❌ `fetch_since()` - Sync depuis timestamp - ---- - -## 6. AUDIT DES PERMISSIONS - -### 6.1 PermissionService (src/security/permission.rs) -**Méthodes existantes** (à vérifier): -- `can_send_message()` -- `can_read_conversation()` -- `can_join_conversation()` -- `can_mark_read()` - -**Méthodes nécessaires**: -- ✅ Les méthodes existantes suffisent pour les nouvelles fonctionnalités -- La recherche nécessite `can_read_conversation()` -- La pagination nécessite `can_read_conversation()` -- La sync nécessite `can_read_conversation()` - ---- - -## 7. RÉSUMÉ DES ACTIONS REQUISES - -### 7.1 Migration SQL -1. ✅ Créer index composite `(conversation_id, created_at DESC)` -2. ✅ Créer index GIN pour recherche textuelle (tsvector ou trigram) -3. ✅ Ajouter colonne `tsv` si choix tsvector - -### 7.2 Repository -1. ✅ Implémenter `fetch_history()` avec before/after -2. ✅ Implémenter `search_messages()` avec query -3. ✅ Implémenter `fetch_since()` avec timestamp - -### 7.3 WebSocket -1. ✅ Ajouter `FetchHistory`, `SearchMessages`, `SyncMessages` dans `IncomingMessage` -2. ✅ Ajouter `HistoryChunk`, `SearchResults`, `SyncChunk` dans `OutgoingMessage` -3. ✅ Implémenter handlers dans `websocket/handler.rs` - -### 7.4 Tests -1. ✅ Tests unitaires pour chaque méthode repository -2. ✅ Tests d'intégration pour les handlers WebSocket -3. ✅ Tests de permissions - -### 7.5 Documentation -1. ✅ Créer `docs/CHAT_HISTORY_SEARCH_SYNC.md` -2. ✅ Mettre à jour `TRIAGE.md` - ---- - -## 8. DÉCISIONS TECHNIQUES - -### 8.1 Recherche textuelle -**Choix**: Commencer avec `ILIKE` (plus simple), possibilité d'upgrade vers `tsvector` plus tard. - -**Raison**: -- Plus simple à implémenter -- Pas besoin de trigger pour maintenir `tsv` -- Suffisant pour la plupart des cas d'usage - -### 8.2 Pagination -**Choix**: Cursors basés sur `created_at` (timestamp). - -**Raison**: -- Plus fiable que les offsets -- Meilleure performance -- Supporte les insertions concurrentes - -### 8.3 Sync offline -**Choix**: Récupérer tous les messages depuis `since`, inclure les updates (edited, deleted). - -**Raison**: -- Permet une vraie synchronisation fiable -- Compatible avec les statuts edited/deleted -- Nécessaire pour les clients mobiles - ---- - -**Fin de l'audit** - diff --git a/veza-chat-server/docs/CHAT_DB_STRATEGY.md b/veza-chat-server/docs/CHAT_DB_STRATEGY.md deleted file mode 100644 index a87da5b63..000000000 --- a/veza-chat-server/docs/CHAT_DB_STRATEGY.md +++ /dev/null @@ -1,35 +0,0 @@ -# Strategie de Base de Données pour Veza Chat Server - -## Isolation par Schema - -Le `veza-chat-server` partage l'instance PostgreSQL `veza_lab` avec d'autres services (Backend API, Stream Server), mais utilise un **schema dédié** nommé `chat`. - -Cette isolation permet de : -1. Éviter les conflits de noms de tables (ex: `users`) avec le Backend API (schema `public`). -2. Gérer des migrations SQLx indépendantes et spécifiques au Chat. -3. Réinitialiser les données du Chat sans impacter le reste du système. - -## Configuration - -Pour se connecter à la base de données du chat, l'URL de connexion (DSN) doit inclure l'option `search_path=chat`. - -### Exemple de DSN -```bash -export DATABASE_URL="postgres://user:pass@localhost:5432/veza_lab?sslmode=disable&options=-c%20search_path=chat" -``` - -### Scripts Lab -Les scripts dans `scripts/` configurent automatiquement cet environnement : - -- **`start_lab.sh`** : Démarre le serveur en configurant le schema `chat`. -- **`reset_lab_db.sh`** : Supprime et recrée le schema `chat`, puis joue les migrations. - -## Migrations - -Les migrations SQLx se trouvent dans `migrations/`. Elles s'appliquent uniquement au schema `chat`. - -```bash -# Appliquer manuellement les migrations -export DATABASE_URL="..." # avec search_path=chat -sqlx migrate run -``` diff --git a/veza-chat-server/docs/CHAT_DELIVERED_AND_TYPING.md b/veza-chat-server/docs/CHAT_DELIVERED_AND_TYPING.md deleted file mode 100644 index 3df335ff9..000000000 --- a/veza-chat-server/docs/CHAT_DELIVERED_AND_TYPING.md +++ /dev/null @@ -1,412 +0,0 @@ -# 📬 Delivered Status + Typing Indicators — Documentation complète - -**Date** : 2025-01-27 -**Version** : 1.0.0 -**Cible** : `veza-chat-server` - ---- - -## 📋 TABLE DES MATIÈRES - -1. [Vue d'ensemble](#vue-densemble) -2. [Delivered Status](#delivered-status) -3. [Typing Indicators](#typing-indicators) -4. [Messages WebSocket](#messages-websocket) -5. [Permissions](#permissions) -6. [Exemples de payloads](#exemples-de-payloads) -7. [Limites et considérations](#limites-et-considérations) - ---- - -## 🎯 VUE D'ENSEMBLE - -Deux fonctionnalités essentielles du chat moderne ont été implémentées : - -1. **Delivered Status** : Tracking persistant des messages reçus (mais pas encore lus) -2. **Typing Indicators** : Indicateurs en temps réel de frappe avec timeout automatique - -Ces systèmes s'intègrent avec : -- ✅ La couche de permissions (P0) -- ✅ Les Read Receipts (P0) -- ✅ Les événements WebSocket inbound/outbound -- ✅ La base de données PostgreSQL (pour Delivered Status) -- ✅ Un système de timeout interne (pour Typing Indicators) - ---- - -## 📬 DELIVERED STATUS - -### Architecture - -Le Delivered Status est **persistant** et stocké en base de données PostgreSQL. - -### Flux - -``` -1. Client reçoit un message via WebSocket - ↓ -2. Client envoie IncomingMessage::Delivered { message_id, conversation_id } - ↓ -3. Serveur : - - Vérifie permission can_read_conversation - - Vérifie que message appartient à conversation - - Stocke en DB (table delivered_status) - - Broadcast OutgoingMessage::MessageDelivered -``` - -### Base de données - -**Table** : `delivered_status` - -```sql -CREATE TABLE delivered_status ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, - delivered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(message_id, user_id) -); -``` - -**Index** : -- `idx_delivered_status_message_id` : Recherche par message -- `idx_delivered_status_user_id` : Recherche par utilisateur -- `idx_delivered_status_conversation_id` : Recherche par conversation -- `idx_delivered_status_conversation_user` : Composite pour requêtes fréquentes - -### Manager - -**Module** : `src/delivered_status.rs` - -**Méthodes principales** : -- `mark_delivered(user_id, message_id, conversation_id)` : Marque un message comme délivré -- `get_delivered_for_message(message_id)` : Récupère tous les delivered status pour un message -- `is_delivered(message_id, user_id)` : Vérifie si un message a été délivré à un utilisateur -- `verify_message_belongs_to_conversation(message_id, conversation_id)` : Vérifie l'appartenance - -### Règles - -- ✅ Un seul delivered status par (message_id, user_id) — contrainte UNIQUE -- ✅ Mise à jour automatique de `delivered_at` si le status existe déjà -- ✅ Vérification de permission `can_read_conversation` avant marquage -- ✅ Vérification que le message appartient à la conversation -- ✅ Broadcast automatique à tous les participants de la conversation - ---- - -## ⌨️ TYPING INDICATORS - -### Architecture - -Les Typing Indicators sont **éphémères** (non persistants) et gérés en mémoire. - -### Flux - -``` -1. Client commence à taper - ↓ -2. Client envoie IncomingMessage::Typing { conversation_id, is_typing: true } - ↓ -3. Serveur : - - Vérifie permission can_send_message - - Enregistre dans TypingIndicatorManager - - Reset timeout de 3 secondes - - Broadcast OutgoingMessage::UserTyping { is_typing: true } - ↓ -4. Si pas de nouveau signal pendant 3s : - - Task de monitoring détecte expiration - - Broadcast OutgoingMessage::UserTyping { is_typing: false } -``` - -### Manager - -**Module** : `src/typing_indicator.rs` - -**Structure interne** : -```rust -HashMap> -``` - -**Méthodes principales** : -- `user_started_typing(user_id, conversation_id)` : Marque un user comme "typing" -- `user_stopped_typing(user_id, conversation_id)` : Retire un user -- `get_typing_users(conversation_id)` : Liste les users actifs (filtre les expirés) -- `monitor_timeouts()` : Détecte les expirations et retourne les changements - -### Task de monitoring - -Un task Tokio tourne en arrière-plan toutes les **500ms** : - -```rust -tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_millis(500)); - loop { - interval.tick().await; - let expired_changes = typing_manager.monitor_timeouts().await; - // Broadcast les changements (is_typing = false) - } -}); -``` - -### Règles - -- ✅ Timeout de **3 secondes** (hardcodé, configurable via `timeout_duration`) -- ✅ Un seul statut actif par (user_id, conversation_id) -- ✅ Reset automatique du timeout à chaque nouveau signal `is_typing: true` -- ✅ Broadcast automatique après expiration (via task de monitoring) -- ✅ Vérification de permission `can_send_message` avant enregistrement -- ✅ Pas de persistance — tout en mémoire - ---- - -## 🔌 MESSAGES WEBSOCKET - -### Incoming Messages - -#### Typing - -```json -{ - "type": "Typing", - "conversation_id": "550e8400-e29b-41d4-a716-446655440000", - "is_typing": true -} -``` - -**Rust** : -```rust -IncomingMessage::Typing { - conversation_id: Uuid, - is_typing: bool, -} -``` - -#### Delivered - -```json -{ - "type": "Delivered", - "conversation_id": "550e8400-e29b-41d4-a716-446655440000", - "message_id": "660e8400-e29b-41d4-a716-446655440001" -} -``` - -**Rust** : -```rust -IncomingMessage::Delivered { - conversation_id: Uuid, - message_id: Uuid, -} -``` - -### Outgoing Messages - -#### UserTyping - -```json -{ - "type": "UserTyping", - "conversation_id": "550e8400-e29b-41d4-a716-446655440000", - "user_id": "770e8400-e29b-41d4-a716-446655440002", - "is_typing": true -} -``` - -**Rust** : -```rust -OutgoingMessage::UserTyping { - conversation_id: Uuid, - user_id: Uuid, - is_typing: bool, -} -``` - -#### MessageDelivered - -```json -{ - "type": "MessageDelivered", - "message_id": "660e8400-e29b-41d4-a716-446655440001", - "user_id": "770e8400-e29b-41d4-a716-446655440002", - "conversation_id": "550e8400-e29b-41d4-a716-446655440000", - "delivered_at": "2025-01-27T10:30:00Z" -} -``` - -**Rust** : -```rust -OutgoingMessage::MessageDelivered { - message_id: Uuid, - user_id: Uuid, - conversation_id: Uuid, - delivered_at: DateTime, -} -``` - ---- - -## 🔐 PERMISSIONS - -### Delivered Status - -**Permission requise** : `can_read_conversation(user_id, conversation_id)` - -**Vérifications** : -1. L'utilisateur est membre de la conversation -2. Le message appartient à la conversation indiquée -3. Le message existe - -**Erreurs possibles** : -- `PermissionError::NotMember` : Utilisateur non membre -- `ChatError::NotFound` : Message inexistant -- `ChatError::Validation` : Message n'appartient pas à la conversation - -### Typing Indicators - -**Permission requise** : `can_send_message(user_id, conversation_id)` - -**Vérifications** : -1. L'utilisateur peut envoyer des messages dans la conversation - -**Erreurs possibles** : -- `PermissionError::NotMember` : Utilisateur non membre -- `PermissionError::CannotSend` : Pas de permission d'écriture - ---- - -## 📝 EXEMPLES DE PAYLOADS - -### Scénario 1 : Typing Indicator - -**Client A commence à taper** : -```json -// Incoming -{ "type": "Typing", "conversation_id": "conv-123", "is_typing": true } - -// Outgoing (broadcast à tous sauf Client A) -{ "type": "UserTyping", "conversation_id": "conv-123", "user_id": "user-a", "is_typing": true } -``` - -**Client A continue (reset timeout)** : -```json -// Incoming (après 2s) -{ "type": "Typing", "conversation_id": "conv-123", "is_typing": true } -// → Timeout reset à 3s -``` - -**Client A arrête (timeout après 3s)** : -```json -// Outgoing (automatique après 3s sans signal) -{ "type": "UserTyping", "conversation_id": "conv-123", "user_id": "user-a", "is_typing": false } -``` - -### Scénario 2 : Delivered Status - -**Client B reçoit un message** : -```json -// Outgoing (nouveau message) -{ - "type": "NewMessage", - "conversation_id": "conv-123", - "message_id": "msg-456", - "sender_id": "user-a", - "content": "Hello!", - "created_at": "2025-01-27T10:30:00Z" -} -``` - -**Client B marque comme délivré** : -```json -// Incoming -{ "type": "Delivered", "conversation_id": "conv-123", "message_id": "msg-456" } - -// Outgoing (broadcast à tous) -{ - "type": "MessageDelivered", - "message_id": "msg-456", - "user_id": "user-b", - "conversation_id": "conv-123", - "delivered_at": "2025-01-27T10:30:01Z" -} -``` - -**Client A voit que le message est délivré** : -```json -// Outgoing (reçu par Client A) -{ - "type": "MessageDelivered", - "message_id": "msg-456", - "user_id": "user-b", - "conversation_id": "conv-123", - "delivered_at": "2025-01-27T10:30:01Z" -} -``` - ---- - -## ⚠️ LIMITES ET CONSIDÉRATIONS - -### Delivered Status - -- ✅ **Persistant** : Stocké en DB, survit aux redémarrages -- ⚠️ **Latence** : Dépend de la latence réseau client → serveur -- ⚠️ **Pas de garantie** : Si le client se déconnecte avant d'envoyer `Delivered`, le status n'est pas enregistré -- ✅ **Déduplication** : UNIQUE constraint empêche les doublons - -### Typing Indicators - -- ⚠️ **Non persistant** : Perdu au redémarrage du serveur -- ⚠️ **Latence de détection** : Maximum 500ms (intervalle du task de monitoring) -- ⚠️ **Pas de garantie** : Si le serveur crash, les typing indicators sont perdus -- ✅ **Performance** : Tout en mémoire, très rapide -- ⚠️ **Scalabilité** : En cas de scaling horizontal, chaque instance a son propre état (nécessiterait Redis pour partager) - -### Recommandations - -1. **Typing Indicators** : Pour la scalabilité horizontale, considérer Redis pour partager l'état entre instances -2. **Delivered Status** : La latence est acceptable pour la plupart des cas d'usage -3. **Monitoring** : Surveiller la taille de la HashMap des typing indicators en production -4. **Cleanup** : Le task de monitoring nettoie automatiquement les entrées expirées - ---- - -## 🧪 TESTS - -### Tests unitaires - -**Delivered Status** : -- ✅ `test_mark_delivered_creates_status` -- ✅ `test_mark_delivered_updates_existing` -- ✅ `test_get_delivered_for_message` -- ✅ `test_is_delivered` - -**Typing Indicators** : -- ✅ `test_typing_indicator_manager` -- ✅ Tests de timeout (à implémenter) - -### Tests d'intégration - -**À implémenter** : -- Test WebSocket : Client A tape → Client B reçoit event -- Test WebSocket : Timeout après 3s → Client B reçoit `is_typing: false` -- Test WebSocket : Delivered → Broadcast OK -- Test WebSocket : Delivered sans permission → Refus - ---- - -## 📚 RÉFÉRENCES - -- **Migration SQL** : `migrations/004_delivered_status.sql` -- **Manager Delivered** : `src/delivered_status.rs` -- **Manager Typing** : `src/typing_indicator.rs` -- **Handler WebSocket** : `src/websocket/handler.rs` -- **Messages WebSocket** : `src/websocket/mod.rs` -- **Audit initial** : `docs/AUDIT_DELIVERED_TYPING.md` - ---- - -**✅ Implémentation complète — Prêt pour production** - diff --git a/veza-chat-server/docs/CHAT_HISTORY_SEARCH_SYNC.md b/veza-chat-server/docs/CHAT_HISTORY_SEARCH_SYNC.md deleted file mode 100644 index b551d83ba..000000000 --- a/veza-chat-server/docs/CHAT_HISTORY_SEARCH_SYNC.md +++ /dev/null @@ -1,593 +0,0 @@ -# 📜 Message Search, History Pagination, and Offline Sync - -**Date**: 2025-12-05 -**Version**: 1.0.0 -**Statut**: ✅ Implémenté - ---- - -## 📋 Table des matières - -1. [Vue d'ensemble](#vue-densemble) -2. [History Pagination](#history-pagination) -3. [Message Search](#message-search) -4. [Offline Sync](#offline-sync) -5. [Spécifications techniques](#spécifications-techniques) -6. [Exemples d'utilisation](#exemples-dutilisation) -7. [Limites et bonnes pratiques](#limites-et-bonnes-pratiques) -8. [Impact sur l'UI](#impact-sur-lui) - ---- - -## 🎯 Vue d'ensemble - -Ce document décrit trois fonctionnalités majeures ajoutées au `veza-chat-server` : - -1. **History Pagination** : Pagination efficace de l'historique avec cursors `before`/`after` -2. **Message Search** : Recherche textuelle de messages dans une conversation -3. **Offline Sync** : Synchronisation des messages manquants depuis la dernière connexion - -Toutes ces fonctionnalités sont : -- ✅ Sécurisées (permissions strictes via `PermissionService`) -- ✅ Performantes (index SQL optimisés) -- ✅ Compatibles avec les statuts (edited, deleted, delivered, read) -- ✅ Disponibles via WebSocket - ---- - -## 📜 History Pagination - -### Description - -Permet de récupérer l'historique d'une conversation avec pagination par cursors basés sur `created_at`. Plus efficace que l'offset/limit classique car : -- Supporte les insertions concurrentes -- Meilleure performance avec les index -- Pas de problèmes de doublons lors de nouvelles insertions - -### Inbound WebSocket Message - -```json -{ - "type": "FetchHistory", - "conversation_id": "550e8400-e29b-41d4-a716-446655440000", - "before": "2025-12-05T10:30:00Z", - "after": null, - "limit": 50 -} -``` - -**Paramètres**: -- `conversation_id` (UUID, requis) : ID de la conversation -- `before` (DateTime ISO8601, optionnel) : Récupère les messages avant ce timestamp -- `after` (DateTime ISO8601, optionnel) : Récupère les messages après ce timestamp -- `limit` (usize, optionnel, défaut: 50, max: 100) : Nombre de messages à récupérer - -**Règles**: -- Si `before` est fourni : tri DESC (messages plus anciens) -- Si `after` est fourni : tri ASC (messages plus récents) -- Si les deux sont fournis : messages entre `after` et `before` (tri ASC) -- Si aucun n'est fourni : messages les plus récents (tri DESC) -- Les résultats sont **toujours retournés en ordre ASC** (du plus ancien au plus récent) - -### Outbound WebSocket Message - -```json -{ - "type": "HistoryChunk", - "conversation_id": "550e8400-e29b-41d4-a716-446655440000", - "messages": [ - { - "id": "...", - "conversation_id": "...", - "sender_id": "...", - "content": "Hello world", - "created_at": "2025-12-05T10:00:00Z", - "is_edited": false, - "is_deleted": false, - ... - } - ], - "has_more_before": true, - "has_more_after": false -} -``` - -**Champs**: -- `messages` : Liste des messages (toujours triés ASC) -- `has_more_before` : Indique s'il y a des messages plus anciens -- `has_more_after` : Indique s'il y a des messages plus récents - -### Exemples d'utilisation - -#### Charger les messages les plus récents -```json -{ - "type": "FetchHistory", - "conversation_id": "...", - "before": null, - "after": null, - "limit": 50 -} -``` - -#### Charger les messages plus anciens (scroll up) -```json -{ - "type": "FetchHistory", - "conversation_id": "...", - "before": "2025-12-05T10:00:00Z", - "after": null, - "limit": 50 -} -``` - -#### Charger les nouveaux messages (scroll down) -```json -{ - "type": "FetchHistory", - "conversation_id": "...", - "before": null, - "after": "2025-12-05T10:00:00Z", - "limit": 50 -} -``` - -### Index SQL - -```sql -CREATE INDEX idx_messages_conv_created_at -ON messages(conversation_id, created_at DESC); - -CREATE INDEX idx_messages_conv_created_not_deleted -ON messages(conversation_id, created_at DESC) -WHERE is_deleted = false; -``` - ---- - -## 🔍 Message Search - -### Description - -Recherche textuelle de messages dans une conversation. Utilise `ILIKE` avec index trigram pour une recherche performante et insensible à la casse. - -### Inbound WebSocket Message - -```json -{ - "type": "SearchMessages", - "conversation_id": "550e8400-e29b-41d4-a716-446655440000", - "query": "hello world", - "limit": 50, - "offset": 0 -} -``` - -**Paramètres**: -- `conversation_id` (UUID, requis) : ID de la conversation -- `query` (String, requis) : Terme de recherche (ne peut pas être vide) -- `limit` (usize, optionnel, défaut: 50, max: 100) : Nombre de résultats par page -- `offset` (usize, optionnel, défaut: 0) : Offset pour pagination - -### Outbound WebSocket Message - -```json -{ - "type": "SearchResults", - "conversation_id": "550e8400-e29b-41d4-a716-446655440000", - "messages": [ - { - "id": "...", - "content": "Hello world!", - "created_at": "2025-12-05T10:00:00Z", - ... - } - ], - "query": "hello world", - "total": 123 -} -``` - -**Champs**: -- `messages` : Liste des messages correspondants (triés par `created_at DESC`) -- `query` : La requête de recherche originale -- `total` : Nombre total de résultats (pour pagination) - -### Exemples d'utilisation - -#### Recherche simple -```json -{ - "type": "SearchMessages", - "conversation_id": "...", - "query": "meeting", - "limit": 20, - "offset": 0 -} -``` - -#### Pagination des résultats -```json -{ - "type": "SearchMessages", - "conversation_id": "...", - "query": "meeting", - "limit": 20, - "offset": 20 -} -``` - -### Index SQL - -```sql -CREATE EXTENSION IF NOT EXISTS pg_trgm; - -CREATE INDEX idx_messages_content_trgm -ON messages USING GIN(content gin_trgm_ops); - -CREATE INDEX idx_messages_conv_content_trgm -ON messages USING GIN(conversation_id, content gin_trgm_ops); -``` - -### Comportement - -- ✅ Recherche insensible à la casse (`ILIKE`) -- ✅ Recherche partielle (contient le terme) -- ✅ Exclut les messages supprimés par défaut -- ✅ Tri par `created_at DESC` (plus récents en premier) - ---- - -## 🔄 Offline Sync - -### Description - -Synchronise tous les messages manquants depuis la dernière connexion. Inclut : -- Messages créés depuis `since` -- Messages édités depuis `since` (même si créés avant) -- Messages supprimés depuis `since` (même si créés avant) - -Permet aux clients mobiles d'avoir une synchronisation fiable après une déconnexion. - -### Inbound WebSocket Message - -```json -{ - "type": "SyncMessages", - "conversation_id": "550e8400-e29b-41d4-a716-446655440000", - "since": "2025-12-05T09:00:00Z" -} -``` - -**Paramètres**: -- `conversation_id` (UUID, requis) : ID de la conversation -- `since` (DateTime ISO8601, requis) : Timestamp de la dernière synchronisation - -### Outbound WebSocket Message - -```json -{ - "type": "SyncChunk", - "conversation_id": "550e8400-e29b-41d4-a716-446655440000", - "messages": [ - { - "id": "...", - "content": "New message", - "created_at": "2025-12-05T10:00:00Z", - "is_edited": false, - "is_deleted": false, - ... - }, - { - "id": "...", - "content": "Edited content", - "created_at": "2025-12-05T08:00:00Z", - "is_edited": true, - "edited_at": "2025-12-05T10:30:00Z", - ... - }, - { - "id": "...", - "content": "Deleted message", - "created_at": "2025-12-05T08:30:00Z", - "is_deleted": true, - "deleted_at": "2025-12-05T10:45:00Z", - ... - } - ], - "last_sync": "2025-12-05T11:00:00Z" -} -``` - -**Champs**: -- `messages` : Tous les messages créés ou modifiés depuis `since` (triés par `created_at ASC`) -- `last_sync` : Timestamp actuel (à utiliser pour la prochaine sync) - -### Exemples d'utilisation - -#### Synchronisation initiale -```json -{ - "type": "SyncMessages", - "conversation_id": "...", - "since": "2025-12-05T00:00:00Z" -} -``` - -#### Synchronisation après déconnexion -```json -{ - "type": "SyncMessages", - "conversation_id": "...", - "since": "2025-12-05T09:30:00Z" -} -``` - -### Index SQL - -```sql -CREATE INDEX idx_messages_conv_created_sync -ON messages(conversation_id, created_at ASC) -WHERE is_deleted = false; - -CREATE INDEX idx_messages_conv_updated_sync -ON messages(conversation_id, updated_at ASC) -WHERE is_deleted = false; -``` - -### Comportement - -- ✅ Inclut tous les messages créés depuis `since` -- ✅ Inclut tous les messages édités depuis `since` (même créés avant) -- ✅ Inclut tous les messages supprimés depuis `since` (même créés avant) -- ✅ Tri par `created_at ASC` (du plus ancien au plus récent) -- ✅ Le client doit gérer les updates (édits) et deletes (suppressions) - ---- - -## 🔧 Spécifications techniques - -### Repository Methods - -#### `fetch_history` -```rust -pub async fn fetch_history( - &self, - conversation_id: Uuid, - before: Option>, - after: Option>, - limit: usize, - include_deleted: bool, -) -> Result<(Vec, bool, bool)> -``` - -Retourne : `(messages, has_more_before, has_more_after)` - -#### `search_messages` -```rust -pub async fn search_messages( - &self, - conversation_id: Uuid, - query: &str, - limit: usize, - offset: usize, - include_deleted: bool, -) -> Result<(Vec, i64)> -``` - -Retourne : `(messages, total_count)` - -#### `fetch_since` -```rust -pub async fn fetch_since( - &self, - conversation_id: Uuid, - since: DateTime, -) -> Result> -``` - -### Permissions - -Toutes les fonctionnalités nécessitent : -- `can_read_conversation(user_id, conversation_id)` : L'utilisateur doit avoir accès à la conversation - -### Erreurs possibles - -- `ChatError::Unauthorized` : Pas de permission pour lire la conversation -- `ChatError::ValidationError` : Query de recherche vide -- `ChatError::InternalError` : Erreur de base de données - ---- - -## 📱 Exemples d'utilisation - -### Client Web (React) - -```typescript -// History Pagination -const fetchHistory = async (conversationId: string, before?: Date) => { - ws.send(JSON.stringify({ - type: "FetchHistory", - conversation_id: conversationId, - before: before?.toISOString(), - after: null, - limit: 50 - })); -}; - -// Message Search -const searchMessages = async (conversationId: string, query: string) => { - ws.send(JSON.stringify({ - type: "SearchMessages", - conversation_id: conversationId, - query: query, - limit: 50, - offset: 0 - })); -}; - -// Offline Sync -const syncMessages = async (conversationId: string, lastSync: Date) => { - ws.send(JSON.stringify({ - type: "SyncMessages", - conversation_id: conversationId, - since: lastSync.toISOString() - })); -}; -``` - -### Client Mobile (React Native) - -```typescript -// Sync après reconnexion -const syncAfterReconnect = async (conversationId: string) => { - const lastSync = await AsyncStorage.getItem(`last_sync_${conversationId}`); - const since = lastSync ? new Date(lastSync) : new Date(0); - - ws.send(JSON.stringify({ - type: "SyncMessages", - conversation_id: conversationId, - since: since.toISOString() - })); - - // Écouter SyncChunk et mettre à jour last_sync - ws.on('message', (msg) => { - if (msg.type === 'SyncChunk') { - AsyncStorage.setItem(`last_sync_${conversationId}`, msg.last_sync); - // Mettre à jour l'UI avec les messages - } - }); -}; -``` - ---- - -## ⚠️ Limites et bonnes pratiques - -### Limites - -1. **History Pagination** : - - `limit` max : 100 messages - - Utiliser `before`/`after` plutôt que offset pour de meilleures performances - -2. **Message Search** : - - `limit` max : 100 résultats - - `query` minimum : 1 caractère - - Recherche partielle (contient), pas de recherche exacte - -3. **Offline Sync** : - - Pas de limite sur le nombre de messages (peut être volumineux) - - Le client doit gérer les updates et deletes - -### Bonnes pratiques - -1. **History Pagination** : - - Toujours utiliser `before` pour charger plus d'anciens messages - - Utiliser `after` pour charger les nouveaux messages - - Stocker le `created_at` du premier/dernier message pour la pagination - -2. **Message Search** : - - Implémenter un debounce sur la recherche (300-500ms) - - Limiter la longueur minimale de la query (3 caractères recommandé) - - Afficher un indicateur de chargement pendant la recherche - -3. **Offline Sync** : - - Stocker `last_sync` localement (AsyncStorage, localStorage) - - Sync automatique après reconnexion - - Gérer les conflits si un message est édité localement et sur le serveur - ---- - -## 🎨 Impact sur l'UI - -### History Pagination - -**Scroll infini vers le haut** : -```typescript -const [messages, setMessages] = useState([]); -const [hasMore, setHasMore] = useState(true); - -const loadMore = async () => { - if (!hasMore) return; - - const oldestMessage = messages[0]; - const before = oldestMessage?.created_at; - - fetchHistory(conversationId, before).then((chunk) => { - setMessages([...chunk.messages, ...messages]); - setHasMore(chunk.has_more_before); - }); -}; -``` - -### Message Search - -**Barre de recherche avec résultats** : -```typescript -const [searchQuery, setSearchQuery] = useState(""); -const [searchResults, setSearchResults] = useState([]); - -const handleSearch = debounce((query: string) => { - if (query.length < 3) return; - - searchMessages(conversationId, query).then((results) => { - setSearchResults(results.messages); - }); -}, 300); -``` - -### Offline Sync - -**Indicateur de synchronisation** : -```typescript -const [isSyncing, setIsSyncing] = useState(false); - -const sync = async () => { - setIsSyncing(true); - const lastSync = await getLastSync(conversationId); - syncMessages(conversationId, lastSync); - // setIsSyncing(false) dans le handler SyncChunk -}; -``` - ---- - -## 📊 Performance - -### Index utilisés - -- `idx_messages_conv_created_at` : Pagination efficace -- `idx_messages_content_trgm` : Recherche textuelle rapide -- `idx_messages_conv_created_sync` : Sync optimisée - -### Métriques attendues - -- **History Pagination** : < 50ms pour 50 messages -- **Message Search** : < 100ms pour 1000 messages -- **Offline Sync** : < 200ms pour 100 messages - ---- - -## 🔐 Sécurité - -- ✅ Toutes les fonctionnalités vérifient les permissions via `PermissionService` -- ✅ Les messages supprimés sont exclus par défaut (sauf si `include_deleted = true`) -- ✅ Validation des paramètres (query non vide, limit max, etc.) -- ✅ Pas d'injection SQL (utilisation de paramètres liés) - ---- - -## 📝 Migration - -Pour activer ces fonctionnalités, exécuter : - -```bash -psql -d veza_db -f migrations/006_history_search_sync.sql -``` - -Cette migration crée tous les index nécessaires. - ---- - -**Fin du document** - diff --git a/veza-chat-server/docs/CHAT_MESSAGE_EDIT_DELETE.md b/veza-chat-server/docs/CHAT_MESSAGE_EDIT_DELETE.md deleted file mode 100644 index 2cd391535..000000000 --- a/veza-chat-server/docs/CHAT_MESSAGE_EDIT_DELETE.md +++ /dev/null @@ -1,444 +0,0 @@ -# Documentation : Édition et Suppression de Messages - -**Date de création** : 2025-12-05 -**Version** : 1.0.0 -**Statut** : ✅ Implémenté - -## Vue d'ensemble - -Ce document décrit l'implémentation complète de l'édition et de la suppression (soft delete) de messages dans le serveur de chat Veza. Ces fonctionnalités sont essentielles pour un système de chat moderne et respectent les meilleures pratiques de sécurité, permissions et cohérence temps réel. - -## Table des matières - -1. [Architecture](#architecture) -2. [Événements WebSocket](#événements-websocket) -3. [Permissions](#permissions) -4. [Base de données](#base-de-données) -5. [Services](#services) -6. [Exemples d'utilisation](#exemples-dutilisation) -7. [Conséquences UX](#conséquences-ux) -8. [Impact sur la recherche et pagination](#impact-sur-la-recherche-et-pagination) - ---- - -## Architecture - -### Composants principaux - -1. **Migration SQL** (`migrations/005_message_edit_delete.sql`) - - Ajoute `deleted_at` pour la traçabilité - - Index pour les requêtes de nettoyage - -2. **PermissionService** (`src/security/permission.rs`) - - `can_edit_message()` : Vérifie les permissions d'édition - - `can_delete_message()` : Vérifie les permissions de suppression - -3. **MessageEditService** (`src/services/message_edit_service.rs`) - - `edit_message()` : Édite un message avec validation - - `delete_message()` : Supprime un message (soft delete) - -4. **MessageRepository** (`src/repository/message_repository.rs`) - - `update()` : Met à jour le contenu d'un message - - `delete()` : Marque un message comme supprimé - - `get_by_id_including_deleted()` : Récupère même les messages supprimés - -5. **WebSocket Handlers** (`src/websocket/handler.rs`) - - Gère les événements `EditMessage` et `DeleteMessage` - - Broadcast les événements `MessageEdited` et `MessageDeleted` - ---- - -## Événements WebSocket - -### Inbound Events (Client → Serveur) - -#### EditMessage - -Édite un message existant. - -```json -{ - "type": "EditMessage", - "message_id": "550e8400-e29b-41d4-a716-446655440000", - "conversation_id": "660e8400-e29b-41d4-a716-446655440000", - "new_content": "Nouveau contenu du message" -} -``` - -**Règles de validation** : -- `new_content` doit être différent du contenu précédent -- `new_content` ne peut pas être vide (après trim) -- `new_content` ne peut pas dépasser 4000 caractères -- Le message ne doit pas être supprimé -- L'utilisateur doit avoir les permissions d'édition - -#### DeleteMessage - -Supprime un message (soft delete). - -```json -{ - "type": "DeleteMessage", - "message_id": "550e8400-e29b-41d4-a716-446655440000", - "conversation_id": "660e8400-e29b-41d4-a716-446655440000" -} -``` - -**Règles de validation** : -- L'utilisateur doit avoir les permissions de suppression -- L'opération est idempotente (supprimer un message déjà supprimé retourne OK) - -### Outbound Events (Serveur → Client) - -#### MessageEdited - -Notifie tous les clients d'une conversation qu'un message a été édité. - -```json -{ - "type": "MessageEdited", - "message_id": "550e8400-e29b-41d4-a716-446655440000", - "conversation_id": "660e8400-e29b-41d4-a716-446655440000", - "editor_id": "770e8400-e29b-41d4-a716-446655440000", - "edited_at": "2025-12-05T10:30:00Z", - "new_content": "Nouveau contenu du message" -} -``` - -#### MessageDeleted - -Notifie tous les clients d'une conversation qu'un message a été supprimé. - -```json -{ - "type": "MessageDeleted", - "message_id": "550e8400-e29b-41d4-a716-446655440000", - "conversation_id": "660e8400-e29b-41d4-a716-446655440000", - "deleter_id": "770e8400-e29b-41d4-a716-446655440000", - "deleted_at": "2025-12-05T10:30:00Z" -} -``` - ---- - -## Permissions - -### Règles d'édition - -Un utilisateur peut éditer un message si : - -1. **Il est l'auteur du message** : L'auteur peut toujours éditer son propre message -2. **Il est admin/modérateur de la conversation** : Les admins et modérateurs peuvent éditer n'importe quel message dans leur conversation -3. **Le message n'est pas supprimé** : Un message supprimé ne peut jamais être édité - -### Règles de suppression - -Un utilisateur peut supprimer un message si : - -1. **Il est l'auteur du message** : L'auteur peut toujours supprimer son propre message -2. **Il est admin/modérateur de la conversation** : Les admins et modérateurs peuvent supprimer n'importe quel message dans leur conversation - -### Limitations de temps - -Actuellement, il n'y a pas de limitation de temps pour l'édition ou la suppression. Un message peut être édité ou supprimé à tout moment tant que les permissions sont respectées. - -**Note** : Pour une implémentation future, on pourrait ajouter : -- Fenêtre d'édition limitée (ex: 15 minutes après l'envoi) -- Fenêtre de suppression limitée (ex: 5 minutes après l'envoi) - ---- - -## Base de données - -### Schéma - -La table `messages` contient les colonnes suivantes pour l'édition et la suppression : - -```sql -CREATE TABLE messages ( - id UUID PRIMARY KEY, - conversation_id UUID NOT NULL, - sender_id UUID NOT NULL, - content TEXT NOT NULL, - -- ... autres colonnes ... - is_edited BOOLEAN NOT NULL DEFAULT FALSE, - is_deleted BOOLEAN NOT NULL DEFAULT FALSE, - edited_at TIMESTAMPTZ, - deleted_at TIMESTAMPTZ, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -``` - -### Migration - -La migration `005_message_edit_delete.sql` ajoute : -- `deleted_at` : Timestamp de suppression (pour la traçabilité) -- Index sur `deleted_at` pour les requêtes de nettoyage -- Index sur `edited_at` pour les requêtes de recherche - -### Soft Delete - -Les messages ne sont **jamais supprimés physiquement** de la base de données. Au lieu de cela : -- `is_deleted` est mis à `true` -- `deleted_at` est mis à `NOW()` -- Le contenu reste dans la base de données (pour audit futur) - -**Note** : Pour une implémentation future, on pourrait : -- Créer une table `message_archive` pour stocker les messages supprimés -- Vider le contenu du message après suppression (mettre `content` à `NULL` ou `""`) - ---- - -## Services - -### MessageEditService - -Service centralisé pour l'édition et la suppression de messages. - -#### `edit_message(user_id, message_id, new_content) -> Result` - -Édite un message avec validation complète. - -**Validation** : -1. Contenu non vide (après trim) -2. Longueur maximale (4000 caractères) -3. Contenu différent de l'original -4. Message non supprimé -5. Permissions d'édition - -**Mise à jour DB** : -- `content` = nouveau contenu -- `is_edited` = `true` -- `edited_at` = `NOW()` -- `updated_at` = `NOW()` - -#### `delete_message(user_id, message_id) -> Result` - -Supprime un message (soft delete). - -**Validation** : -1. Permissions de suppression - -**Mise à jour DB** : -- `is_deleted` = `true` -- `deleted_at` = `NOW()` -- `updated_at` = `NOW()` - -**Idempotence** : Si le message est déjà supprimé, retourne le message tel quel sans erreur. - ---- - -## Exemples d'utilisation - -### Édition d'un message - -**Client** : -```json -{ - "type": "EditMessage", - "message_id": "550e8400-e29b-41d4-a716-446655440000", - "conversation_id": "660e8400-e29b-41d4-a716-446655440000", - "new_content": "Correction : Nouveau contenu" -} -``` - -**Réponse (confirmation)** : -```json -{ - "type": "ActionConfirmed", - "action": "message_edited", - "success": true -} -``` - -**Broadcast (tous les clients de la conversation)** : -```json -{ - "type": "MessageEdited", - "message_id": "550e8400-e29b-41d4-a716-446655440000", - "conversation_id": "660e8400-e29b-41d4-a716-446655440000", - "editor_id": "770e8400-e29b-41d4-a716-446655440000", - "edited_at": "2025-12-05T10:30:00Z", - "new_content": "Correction : Nouveau contenu" -} -``` - -### Suppression d'un message - -**Client** : -```json -{ - "type": "DeleteMessage", - "message_id": "550e8400-e29b-41d4-a716-446655440000", - "conversation_id": "660e8400-e29b-41d4-a716-446655440000" -} -``` - -**Réponse (confirmation)** : -```json -{ - "type": "ActionConfirmed", - "action": "message_deleted", - "success": true -} -``` - -**Broadcast (tous les clients de la conversation)** : -```json -{ - "type": "MessageDeleted", - "message_id": "550e8400-e29b-41d4-a716-446655440000", - "conversation_id": "660e8400-e29b-41d4-a716-446655440000", - "deleter_id": "770e8400-e29b-41d4-a716-446655440000", - "deleted_at": "2025-12-05T10:30:00Z" -} -``` - -### Gestion des erreurs - -**Permission refusée** : -```json -{ - "type": "Error", - "message": "Permissions insuffisantes pour edit_message dans la conversation 660e8400-e29b-41d4-a716-446655440000" -} -``` - -**Message introuvable** : -```json -{ - "type": "Error", - "message": "Message 550e8400-e29b-41d4-a716-446655440000 introuvable" -} -``` - -**Message supprimé (tentative d'édition)** : -```json -{ - "type": "Error", - "message": "Un message supprimé ne peut pas être édité" -} -``` - ---- - -## Conséquences UX - -### Affichage des messages édités - -Lorsqu'un message est édité, l'interface utilisateur doit : - -1. **Afficher le nouveau contenu** : Remplacer l'ancien contenu par le nouveau -2. **Indicateur visuel** : Afficher un indicateur "Édité" (ex: "✏️ Édité") -3. **Timestamp d'édition** : Optionnellement afficher `edited_at` au survol -4. **Historique** : Pour une implémentation future, on pourrait afficher l'historique des éditions - -**Exemple d'affichage** : -``` -[Utilisateur] Message original ✏️ Édité -``` - -### Affichage des messages supprimés - -Lorsqu'un message est supprimé, l'interface utilisateur doit : - -1. **Placeholder** : Afficher un placeholder comme "Message supprimé" ou "Ce message a été supprimé" -2. **Style visuel** : Utiliser un style atténué (gris, italique) -3. **Informations limitées** : Ne pas afficher le contenu original -4. **Timestamp** : Optionnellement afficher `deleted_at` - -**Exemple d'affichage** : -``` -[Utilisateur] Ce message a été supprimé -``` - -### Cohérence multi-device - -Les événements WebSocket garantissent que : -- Tous les clients connectés à la conversation reçoivent les mises à jour en temps réel -- Les modifications sont synchronisées instantanément -- Pas besoin de rafraîchir la page - ---- - -## Impact sur la recherche et pagination - -### Recherche - -Les messages supprimés sont **exclus** des résultats de recherche par défaut. - -**Requête SQL** : -```sql -SELECT * FROM messages -WHERE conversation_id = $1 - AND is_deleted = false - AND content ILIKE $2 -ORDER BY created_at DESC; -``` - -**Note** : Pour une implémentation future, on pourrait : -- Permettre aux admins de rechercher dans les messages supprimés -- Créer une vue `messages_active` qui exclut automatiquement les messages supprimés - -### Pagination - -Les messages supprimés sont **exclus** de la pagination par défaut. - -**Requête SQL** : -```sql -SELECT * FROM messages -WHERE conversation_id = $1 - AND is_deleted = false -ORDER BY created_at DESC -LIMIT $2 OFFSET $3; -``` - -**Placeholder dans la liste** : Si un message est supprimé pendant qu'un utilisateur consulte l'historique, il peut être remplacé par un placeholder dans la liste. - -### Impact sur les métriques - -- Les messages supprimés ne sont pas comptés dans les statistiques de messages -- Les messages édités sont comptés comme des messages normaux (pas de double comptage) - ---- - -## Tests - -Les tests sont disponibles dans `tests/chat_edit_delete.rs` : - -- ✅ Édition par l'auteur -- ✅ Édition interdite pour un non-auteur -- ✅ Édition interdite pour un message supprimé -- ✅ Édition avec contenu identique interdite -- ✅ Édition avec contenu vide interdite -- ✅ Suppression par l'auteur -- ✅ Suppression par un admin -- ✅ Suppression interdite pour un non-auteur -- ✅ Suppression idempotente -- ✅ Validation de la longueur maximale - -**Note** : Les tests nécessitent une base de données de test et sont marqués avec `#[ignore]`. - ---- - -## Améliorations futures - -1. **Limitation de temps** : Fenêtre d'édition/suppression limitée -2. **Historique d'édition** : Stocker l'historique des modifications -3. **Archive de messages** : Table séparée pour les messages supprimés -4. **Raison de suppression** : Champ optionnel pour la raison de suppression (modération) -5. **Recherche dans les supprimés** : Permettre aux admins de rechercher dans les messages supprimés -6. **Notifications** : Notifier l'auteur lorsqu'un admin supprime son message - ---- - -## Références - -- Migration : `migrations/005_message_edit_delete.sql` -- Service : `src/services/message_edit_service.rs` -- Permissions : `src/security/permission.rs` -- Repository : `src/repository/message_repository.rs` -- WebSocket : `src/websocket/handler.rs` -- Tests : `tests/chat_edit_delete.rs` - diff --git a/veza-chat-server/docs/CHAT_PANIC_CLEANUP.md b/veza-chat-server/docs/CHAT_PANIC_CLEANUP.md deleted file mode 100644 index 5b83480c7..000000000 --- a/veza-chat-server/docs/CHAT_PANIC_CLEANUP.md +++ /dev/null @@ -1,241 +0,0 @@ -# 🎯 CHAT SERVER — ZERO PANIC CLEANUP - -**Date** : 2025-01-27 -**Objectif** : Éliminer tous les `unwrap()` / `expect()` déclenchables par des inputs extérieurs -**Status** : 🔄 En cours - ---- - -## 📊 RÉSUMÉ EXÉCUTIF - -| Catégorie | 🔴 Critique | 🟠 Moyen | 🟢 Acceptable | Total | -|-----------|-------------|----------|---------------|-------| -| **Config & Init** | 2 | 1 | 0 | 3 | -| **DB** | 0 | 0 | 0 | 0 | -| **JWT & Auth** | 2 | 0 | 0 | 2 | -| **WebSocket & Handlers** | 0 | 0 | 0 | 0 | -| **Managers** | 3 | 0 | 0 | 3 | -| **Security/Regex** | 0 | 0 | 70+ | 70+ | -| **Tests** | 0 | 0 | 30+ | 30+ | -| **TOTAL** | **7** | **1** | **100+** | **108+** | - ---- - -## 🔴 CRITIQUE — À CORRIGER IMMÉDIATEMENT - -### 1. Config & Init - -#### `main.rs:127` — Prometheus recorder -```rust -let prometheus_handle = builder - .install_recorder() - .expect("failed to install Prometheus recorder"); -``` -- **Risque** : 🔴 Peut échouer si Prometheus est mal configuré -- **Impact** : Crash au démarrage -- **Solution** : Retourner `ChatError::Configuration` et loguer l'erreur - -#### `main.rs:148` — Database pool required -```rust -let pool_ref = database_pool.as_ref().expect("Database pool is required"); -``` -- **Risque** : 🔴 Crash si DB pool n'est pas initialisé (même si c'est optionnel) -- **Impact** : Crash au démarrage si DB down -- **Solution** : Vérifier `if let Some(pool) = database_pool.as_ref()` et retourner erreur appropriée - -#### `main.rs:326` — EventBus unwrap -```rust -if state.event_bus.is_none() || !state.event_bus.as_ref().unwrap().is_enabled { -``` -- **Risque** : 🔴 Panic si `event_bus` est `None` après le check -- **Impact** : Panic dans readiness check -- **Solution** : Utiliser `if let Some(ref bus) = state.event_bus` - -### 2. JWT & Auth - -#### `jwt_manager.rs:516,529,535,545,553,565,577,589,592,598` — Tests avec unwrap -- **Risque** : 🔴 Tests qui peuvent panic -- **Impact** : Tests instables -- **Solution** : Utiliser `?` et propager les erreurs dans les tests - -#### `auth.rs:312-313` — SystemTime duration_since -```rust -exp: (SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()) + 3600, -iat: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(), -``` -- **Risque** : 🔴 Panic si l'horloge système est réglée en arrière (rare mais possible) -- **Impact** : Panic lors de la création de tokens de test -- **Solution** : Utiliser `chrono::Utc::now()` ou gérer l'erreur explicitement - -### 3. Managers - -#### `authentication.rs:177` — Session get unwrap -```rust -Ok(self.sessions.get(&user_id).unwrap()) -``` -- **Risque** : 🔴 Panic si la session n'existe pas après insertion (race condition) -- **Impact** : Panic lors de la création de session -- **Solution** : Utiliser `ok_or_else` avec `ChatError::Internal` - -#### `core/advanced_rate_limiter.rs:378,457` — Bucket get_mut unwrap -```rust -let bucket = ip_limiter.buckets.get_mut(limit_type).unwrap(); -let bucket = user_limiter.buckets.get_mut(limit_type).unwrap(); -``` -- **Risque** : 🔴 Panic si le `limit_type` n'existe pas dans la HashMap -- **Impact** : Panic lors du rate limiting -- **Solution** : Utiliser `get_or_insert_with` ou vérifier l'existence - -#### `security_legacy.rs:409` — User actions get_mut unwrap -```rust -let actions = self.user_actions.get_mut(&key).unwrap(); -``` -- **Risque** : 🔴 Panic si la clé n'existe pas -- **Impact** : Panic lors de la gestion des actions utilisateur -- **Solution** : Utiliser `entry().or_insert_with()` ou vérifier l'existence - ---- - -## 🟠 MOYEN — À CORRIGER - -### 1. Config & Init - -#### `lib.rs:42` — Unwrap dans lib -- **Risque** : 🟠 Peut échouer selon le contexte -- **Impact** : Crash au démarrage -- **Solution** : Retourner une erreur appropriée - ---- - -## 🟢 ACCEPTABLE — Regex patterns (statiques) - -### `security_legacy.rs:37-101` — 70+ Regex::new().unwrap() - -Ces `unwrap()` sont **acceptables** car : -- Les patterns sont **statiques** et compilés au démarrage -- Ils ne peuvent pas échouer sauf si le code est mal écrit (bug interne) -- Ils sont dans un contexte d'initialisation de sécurité - -**Recommandation** : Documenter explicitement pourquoi ils sont sûrs, ou utiliser `lazy_static` avec `once_cell::sync::Lazy` pour une meilleure gestion. - ---- - -## 🟢 ACCEPTABLE — Tests - -### Tests avec `unwrap()` / `expect()` - -Les tests dans : -- `jwt_manager.rs` (tests) -- `config.rs` (tests) -- `delivered_status.rs` (tests) -- `read_receipts.rs` (tests) -- `repository/tests.rs` (tests) -- `security/csrf.rs` (tests) -- `rate_limiter.rs` (tests) -- `message_store.rs` (tests) -- `core/rich_messages.rs` (tests) -- `chat_management.rs` (tests) -- `services/room_service.rs` (tests commentés) -- `services/message_edit_service.rs` (tests commentés) - -**Recommandation** : Les `unwrap()` dans les tests sont généralement acceptables, mais on peut améliorer en utilisant `?` pour propager les erreurs de manière plus propre. - ---- - -## 📋 PLAN D'ACTION - -### Phase 1 : Cartographie ✅ -- [x] Identifier tous les `unwrap()` / `expect()` -- [x] Classer par catégorie et gravité -- [x] Documenter dans ce fichier - -### Phase 2 : Design d'erreurs -- [x] Vérifier que `ChatError` existe et est complet -- [ ] Ajouter helpers manquants si nécessaire - -### Phase 3 : Remplacement systématique ✅ -- [x] Corriger `main.rs:127` (Prometheus) - Retourne `ChatError::Configuration` -- [x] Corriger `main.rs:148` (DB pool) - Utilise `ok_or_else` avec `ChatError` -- [x] Corriger `main.rs:326` (EventBus) - Utilise `if let Some(ref event_bus)` -- [x] Corriger `auth.rs:312-313` (SystemTime) - Documenté avec expect justifié -- [x] Corriger `authentication.rs:177` (Session) - Utilise `ok_or_else` avec `ChatError` -- [x] Corriger `core/advanced_rate_limiter.rs:378,457` (Buckets) - Utilise `ok_or_else` avec `ChatError` -- [x] Corriger `security_legacy.rs:409` (User actions) - Utilise `ok_or_else` avec `ChatError` - -### Phase 4 : Panic Boundaries ✅ -- [x] Documentation ajoutée pour `handle_socket` - Toutes les erreurs gérées explicitement -- [x] Documentation ajoutée pour les tasks `tokio::spawn` - Tokio capture automatiquement les panics -- [x] Supervision documentée pour le typing monitor task - Toutes les erreurs gérées explicitement - -### Phase 5 : Tests anti-panic ✅ -- [x] Créer `tests/panic_safety_tests.rs` -- [x] Tests pour JWT invalides -- [x] Tests pour UUID invalides -- [x] Tests pour JSON malformé -- [x] Tests pour messages WebSocket invalides -- [x] Tests de résilience générale - -### Phase 6 : Documentation finale ✅ -- [x] Mettre à jour ce fichier avec les corrections -- [ ] Mettre à jour `TRIAGE.md` -- [x] Documenter les invariants restants - ---- - -## 📝 NOTES - -### Invariants documentés (🟢 Acceptables) - -1. **Regex patterns statiques** (`security_legacy.rs`) : Patterns compilés au démarrage, ne peuvent pas échouer sauf bug interne. -2. **Tests** : Les `unwrap()` dans les tests sont généralement acceptables pour simplifier le code de test. - -### Changements structurants - -- ✅ `ChatError` existe déjà et est complet -- ✅ Type `Result = std::result::Result` déjà défini -- ⏳ Panic boundaries à ajouter -- ⏳ Supervision des tasks à améliorer - ---- - -## ✅ CRITÈRES DE FIN - -- [x] Tous les 🔴 critiques corrigés -- [x] Tous les 🟠 moyens corrigés (1 seul, dans lib.rs:42 - test, acceptable) -- [x] Panic boundaries documentées (tokio gère automatiquement, toutes erreurs explicites) -- [x] Tasks supervisées (toutes erreurs gérées explicitement) -- [x] Tests anti-panic créés -- [x] Documentation à jour - -## 📝 RÉSUMÉ DES CORRECTIONS - -### Corrections appliquées - -1. **main.rs:127** - Prometheus recorder : `expect()` → `map_err()` avec `ChatError::Configuration` -2. **main.rs:148** - DB pool : `expect()` → `ok_or_else()` avec `ChatError::Configuration` -3. **main.rs:326** - EventBus unwrap : `unwrap()` → `if let Some(ref event_bus)` -4. **authentication.rs:177** - Session get : `unwrap()` → `ok_or_else()` avec `ChatError::Internal` -5. **core/advanced_rate_limiter.rs:378,457** - Buckets get_mut : `unwrap()` → `ok_or_else()` avec `ChatError::Internal` -6. **security_legacy.rs:409** - User actions get_mut : `unwrap()` → `ok_or_else()` avec `ChatError::Internal` -7. **auth.rs:312-313** - SystemTime : Documenté avec `expect()` justifié (très rare, bug système) - -### Approche des panic boundaries - -Au lieu d'utiliser `catch_unwind()` (qui ne fonctionne pas bien avec les types async contenant de la mutabilité intérieure), nous avons : - -1. **Géré toutes les erreurs explicitement** : Tous les `unwrap()`/`expect()` déclenchables par des inputs extérieurs ont été remplacés par une gestion d'erreurs explicite avec `ChatError`. - -2. **Documenté la supervision** : Tokio capture automatiquement les panics dans les tasks `tokio::spawn`, mais nous nous assurons que toutes les erreurs sont gérées explicitement pour éviter les panics en premier lieu. - -3. **Handler WebSocket** : Toutes les erreurs sont gérées avec `?` ou `match`, aucune panic possible sur des inputs malformés. - -### Tests créés - -- `tests/panic_safety_tests.rs` : Tests pour JWT invalides, UUID invalides, JSON malformé, messages WebSocket invalides, et résilience générale. - -### Invariants documentés (🟢 Acceptables) - -1. **Regex patterns statiques** (`security_legacy.rs`) : Patterns compilés au démarrage, ne peuvent pas échouer sauf bug interne. -2. **Tests** : Les `unwrap()` dans les tests sont généralement acceptables pour simplifier le code de test. -3. **SystemTime::duration_since** (`auth.rs`) : Très rare (bug système), documenté avec `expect()` justifié. - diff --git a/veza-chat-server/docs/CHAT_PERMISSIONS.md b/veza-chat-server/docs/CHAT_PERMISSIONS.md deleted file mode 100644 index e59bcd9d4..000000000 --- a/veza-chat-server/docs/CHAT_PERMISSIONS.md +++ /dev/null @@ -1,328 +0,0 @@ -# Système de Permissions du Chat Server - -## Vue d'ensemble - -Le système de permissions du chat server Veza fournit un contrôle d'accès granulaire pour les conversations, avec support des rôles (admin, moderator, member) et vérifications centralisées. - -## Architecture - -### Module `security/permission.rs` - -Le module `PermissionService` centralise toutes les vérifications de permissions : - -```rust -pub struct PermissionService { - pool: PgPool, -} -``` - -### Fonctions principales - -#### `user_in_conversation(user_id, conversation_id) -> Result` - -Vérifie si un utilisateur est membre d'une conversation. - -**Retourne** : `true` si membre, `false` sinon. - -#### `user_role_in_conversation(user_id, conversation_id) -> Result` - -Récupère le rôle d'un utilisateur dans une conversation spécifique. - -**Retourne** : Le rôle (`Admin`, `Moderator`, `User`, `SuperAdmin`) ou une erreur si non membre. - -#### `user_global_role(user_id) -> Result` - -Récupère le rôle global d'un utilisateur depuis la table `users`. - -**Retourne** : Le rôle global, ou `User` par défaut. - -#### `can_send_message(user_id, conversation_id) -> Result<()>` - -Vérifie si un utilisateur peut envoyer un message dans une conversation. - -**Règles** : -- Les membres peuvent envoyer des messages -- Les admins globaux peuvent envoyer des messages même sans être membres -- Les non-membres (non-admin) sont refusés - -#### `can_read_conversation(user_id, conversation_id) -> Result<()>` - -Vérifie si un utilisateur peut lire une conversation. - -**Règles** : -- Les membres peuvent lire -- Les admins globaux peuvent lire même sans être membres -- Les non-membres (non-admin) sont refusés - -#### `can_mark_read(user_id, conversation_id) -> Result<()>` - -Vérifie si un utilisateur peut marquer un message comme lu. - -**Règles** : Identiques à `can_read_conversation`. - -#### `can_join_conversation(user_id, conversation_id) -> Result<()>` - -Vérifie si un utilisateur peut rejoindre une conversation. - -**Règles** : -- Les conversations publiques peuvent être rejointes par tous -- Les conversations privées nécessitent d'être membre ou admin global - -## Rôles et Permissions - -### Rôles disponibles - -| Rôle | Description | -|------|-------------| -| `User` | Utilisateur standard | -| `Moderator` | Modérateur avec permissions étendues | -| `Admin` | Administrateur avec tous les pouvoirs | -| `SuperAdmin` | Super administrateur | - -### Matrice des permissions - -| Action | User | Moderator | Admin | SuperAdmin | -|--------|------|-----------|-------|------------| -| Envoyer message (membre) | ✅ | ✅ | ✅ | ✅ | -| Envoyer message (non-membre) | ❌ | ❌ | ✅ | ✅ | -| Lire conversation (membre) | ✅ | ✅ | ✅ | ✅ | -| Lire conversation (non-membre) | ❌ | ❌ | ✅ | ✅ | -| Marquer comme lu | ✅ | ✅ | ✅ | ✅ | -| Rejoindre conversation publique | ✅ | ✅ | ✅ | ✅ | -| Rejoindre conversation privée | ❌* | ❌* | ✅ | ✅ | - -\* Nécessite d'être membre de la conversation - -## Intégration dans les Handlers - -### WebSocket Handler (`websocket/handler.rs`) - -Tous les handlers WebSocket vérifient les permissions avant d'exécuter les actions : - -#### `SendMessage` - -```rust -// Vérifier les permissions avant d'envoyer le message -state - .permission_service - .can_send_message(sender_uuid, conversation_id) - .await?; -``` - -#### `JoinConversation` - -```rust -// Vérifier les permissions avant de rejoindre -state - .permission_service - .can_join_conversation(user_uuid, conversation_id) - .await?; -``` - -#### `MarkAsRead` - -```rust -// Vérifier les permissions pour marquer comme lu -state - .permission_service - .can_mark_read(user_uuid, conversation_id) - .await?; -``` - -### Message Handler (`message_handler.rs`) - -Les handlers de messages vérifient également les permissions : - -#### `handle_room_message` - -Vérifie `can_send_message` avant d'envoyer un message dans un salon. - -#### `handle_direct_message` - -Vérifie `can_send_message` avant d'envoyer un message direct. - -#### `handle_room_history` - -Vérifie `can_read_conversation` via `can_read_room_history`. - -#### `handle_dm_history` - -Vérifie `can_read_conversation` via `can_read_dm_conversation`. - -## Schéma de Base de Données - -### Table `conversation_members` - -```sql -CREATE TABLE conversation_members ( - conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE, - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - role VARCHAR(50) NOT NULL DEFAULT 'user', - joined_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - PRIMARY KEY (conversation_id, user_id) -); -``` - -**Colonne `role`** : Peut être `'user'`, `'moderator'`, `'admin'`, ou `'superadmin'`. - -### Table `users` - -```sql -CREATE TABLE users ( - id UUID PRIMARY KEY, - username VARCHAR(50) UNIQUE NOT NULL, - email VARCHAR(255) UNIQUE NOT NULL, - role VARCHAR(20) DEFAULT 'user', -- Rôle global - ... -); -``` - -**Colonne `role`** : Rôle global de l'utilisateur dans le système. - -## Gestion des Erreurs - -### Types d'erreurs - -#### `PermissionError::NotMember` - -L'utilisateur n'est pas membre de la conversation. - -**Code HTTP** : 403 Forbidden - -#### `PermissionError::InsufficientPermissions` - -L'utilisateur n'a pas les permissions suffisantes pour l'action. - -**Code HTTP** : 403 Forbidden - -#### `PermissionError::InvalidRole` - -Le rôle spécifié est invalide. - -**Code HTTP** : 500 Internal Server Error - -### Logging - -Toutes les violations de permissions sont loggées avec `tracing::warn!` : - -```rust -warn!( - user_id = %user_id, - conversation_id = %conversation_id, - error = %e, - "Permission refusée pour l'envoi de message" -); -``` - -## Messages WebSocket d'Erreur - -Lorsqu'une permission est refusée, le client reçoit un message d'erreur : - -```json -{ - "type": "error", - "message": "Permission refusée: Utilisateur non membre de la conversation", - "code": "permission_denied" -} -``` - -## JWT Manager - -Le `JwtManager` a été mis à jour pour récupérer les informations utilisateur depuis la base de données lors du refresh token : - -```rust -// Récupérer username et role depuis la DB -let user_info: Option<(String, Option)> = sqlx::query_as( - r#" - SELECT username, role FROM users - WHERE id = $1 - "#, -) -.bind(user_uuid) -.fetch_optional(pool) -.await?; -``` - -**Fallback** : Si l'utilisateur n'est pas trouvé ou si le pool DB n'est pas disponible, utilise `"user"` / `"user"` par défaut (avec warning). - -## Tests - -Les tests sont disponibles dans `tests/test_permissions.rs` : - -- `test_can_send_message_non_member` : Vérifie qu'un non-membre ne peut pas envoyer -- `test_can_send_message_member` : Vérifie qu'un membre peut envoyer -- `test_can_send_message_admin_global` : Vérifie qu'un admin global peut envoyer sans être membre -- `test_can_read_conversation_non_member` : Vérifie qu'un non-membre ne peut pas lire -- `test_can_read_conversation_member` : Vérifie qu'un membre peut lire -- `test_user_in_conversation` : Vérifie la fonction `user_in_conversation` -- `test_user_role_in_conversation` : Vérifie la fonction `user_role_in_conversation` -- `test_integration_send_message_with_permissions` : Test d'intégration complet - -**Note** : Les tests nécessitent une base de données de test et sont marqués avec `#[ignore]`. - -## Exemples d'utilisation - -### Vérifier les permissions avant d'envoyer un message - -```rust -use chat_server::security::permission::PermissionService; - -let permission_service = PermissionService::new(pool); - -// Vérifier avant d'envoyer -permission_service - .can_send_message(user_id, conversation_id) - .await?; - -// Envoyer le message... -``` - -### Vérifier les permissions avant de lire - -```rust -// Vérifier avant de lire -permission_service - .can_read_conversation(user_id, conversation_id) - .await?; - -// Récupérer les messages... -``` - -### Récupérer le rôle d'un utilisateur - -```rust -// Rôle dans une conversation spécifique -let role = permission_service - .user_role_in_conversation(user_id, conversation_id) - .await?; - -// Rôle global -let global_role = permission_service - .user_global_role(user_id) - .await?; -``` - -## Sécurité - -### Bonnes pratiques - -1. **Toujours vérifier les permissions** avant d'exécuter une action -2. **Logger les violations** pour audit et monitoring -3. **Ne jamais faire confiance au client** : toutes les vérifications sont côté serveur -4. **Utiliser le service centralisé** : ne pas dupliquer la logique de vérification -5. **Gérer les erreurs gracieusement** : envoyer des messages d'erreur clairs au client - -### Points d'attention - -- Les admins globaux peuvent contourner certaines restrictions (par design) -- Les conversations privées nécessitent une vérification explicite d'appartenance -- Le rôle dans `conversation_members` peut différer du rôle global dans `users` - -## Évolution future - -- Support de permissions custom par conversation -- Permissions granulaires (edit, delete, pin, etc.) -- Système de rôles hiérarchiques -- Permissions temporaires (time-based) -- Audit trail des changements de permissions - diff --git a/veza-chat-server/docs/CHAT_READ_RECEIPTS.md b/veza-chat-server/docs/CHAT_READ_RECEIPTS.md deleted file mode 100644 index ce1fc1a46..000000000 --- a/veza-chat-server/docs/CHAT_READ_RECEIPTS.md +++ /dev/null @@ -1,352 +0,0 @@ -# Système de Read Receipts - Veza Chat Server - -## Vue d'ensemble - -Le système de read receipts permet de tracker quels messages ont été lus par quels utilisateurs dans une conversation. Cette fonctionnalité est essentielle pour fournir un feedback visuel aux utilisateurs (indicateurs "lu" / "non lu") et améliorer l'expérience utilisateur. - -**Statut** : ✅ **Opérationnel** (implémenté et testé) - -**Date d'implémentation** : 2025-12-05 - ---- - -## Architecture - -### Composants principaux - -1. **Table de base de données** : `read_receipts` -2. **Manager** : `ReadReceiptManager` (`src/read_receipts.rs`) -3. **Handler WebSocket** : Intégration dans `src/websocket/handler.rs` -4. **Messages WebSocket** : `MarkAsRead` (inbound) et `MessageRead` (outbound) - -### Schéma de base de données - -La table `read_receipts` est créée par la migration `003_read_receipts.sql` : - -```sql -CREATE TABLE read_receipts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, - read_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - UNIQUE(message_id, user_id) -); -``` - -**Index** : -- `idx_read_receipts_message_id` : Recherche par message -- `idx_read_receipts_user_id` : Recherche par utilisateur -- `idx_read_receipts_conversation_id` : Recherche par conversation -- `idx_read_receipts_conversation_user` : Requêtes fréquentes (dernière lecture) - ---- - -## Contrat WebSocket - -### Message Inbound : `MarkAsRead` - -Envoyé par le client pour marquer un message comme lu. - -```json -{ - "type": "MarkAsRead", - "conversation_id": "uuid-de-la-conversation", - "message_id": "uuid-du-message" -} -``` - -**Validation côté serveur** : -1. Le message existe et appartient à la conversation indiquée -2. L'utilisateur est membre de la conversation -3. Le JWT est valide (vérifié automatiquement par le handler) - -**Réponses possibles** : -- ✅ `ActionConfirmed` : Le message a été marqué comme lu -- ❌ `Error` : Erreur de validation ou de permission - -### Message Outbound : `MessageRead` - -Envoyé à tous les participants de la conversation lorsqu'un message est marqué comme lu. - -```json -{ - "type": "MessageRead", - "message_id": "uuid-du-message", - "user_id": "uuid-de-l-utilisateur-qui-a-lu", - "conversation_id": "uuid-de-la-conversation", - "read_at": "2025-12-05T10:30:00Z" -} -``` - -**Broadcast** : Ce message est automatiquement diffusé à tous les clients connectés à la conversation (sauf l'utilisateur qui a initié l'action, qui reçoit `ActionConfirmed`). - ---- - -## Comportement serveur - -### Flux de traitement - -1. **Réception** : Le client envoie `MarkAsRead` via WebSocket -2. **Validation** : - - Vérification de l'existence du message - - Vérification de l'appartenance du message à la conversation - - Vérification de l'appartenance de l'utilisateur à la conversation -3. **Persistance** : - - Création d'un nouveau read receipt si inexistant - - Mise à jour du timestamp `read_at` si le read receipt existe déjà -4. **Notification** : - - Broadcast de `MessageRead` à tous les participants - - Envoi de `ActionConfirmed` au client initiateur - -### Gestion des erreurs - -| Erreur | Code | Comportement | -|--------|------|--------------| -| Message inexistant | `not_found` | Retourne une erreur au client | -| Message n'appartient pas à la conversation | `validation_error` | Retourne une erreur au client | -| Utilisateur non membre | `unauthorized` | Retourne une erreur au client | -| Erreur DB | `internal_error` | Log l'erreur, retourne une erreur générique au client | - -### Logs structurés - -Les événements suivants sont loggés avec `tracing` : -- ✅ Message marqué comme lu (info) -- ✅ Read receipt créé (info) -- ✅ Read receipt mis à jour (debug) -- ❌ Erreurs de validation/permission (error) - ---- - -## API du ReadReceiptManager - -### Méthodes principales - -#### `mark_as_read(user_id, message_id, conversation_id)` - -Marque un message comme lu par un utilisateur. - -**Retourne** : `ReadReceipt` (créé ou mis à jour) - -#### `get_receipt(message_id, user_id)` - -Récupère le read receipt pour un message et un utilisateur spécifiques. - -**Retourne** : `Option` - -#### `get_receipts_for_message(message_id)` - -Récupère tous les read receipts pour un message (tous les utilisateurs qui l'ont lu). - -**Retourne** : `Vec` - -#### `get_message_status(message_id, user_id)` - -Récupère le statut de lecture d'un message pour un utilisateur. - -**Retourne** : `MessageReadStatus` (`Sent`, `Delivered`, ou `Read`) - -#### `is_user_in_conversation(user_id, conversation_id)` - -Vérifie si un utilisateur est membre d'une conversation. - -**Retourne** : `bool` - -#### `get_last_read_message(conversation_id, user_id)` - -Récupère l'ID du dernier message lu par un utilisateur dans une conversation. - -**Retourne** : `Option` - -#### `get_unread_count(conversation_id, user_id, last_read_message_id)` - -Calcule le nombre de messages non lus pour un utilisateur dans une conversation. - -**Retourne** : `i64` - ---- - -## Prérequis - -### Base de données - -1. **Migration** : Exécuter `migrations/003_read_receipts.sql` -2. **Extensions PostgreSQL** : `uuid-ossp` (déjà requis par les migrations précédentes) - -### Configuration - -Aucune configuration spécifique requise. Le système utilise le pool de connexions PostgreSQL déjà configuré. - ---- - -## Tests - -### Tests unitaires - -Les tests unitaires sont dans `src/read_receipts.rs` (module `tests`). - -**Exécution** : -```bash -cd veza-chat-server -cargo test --lib read_receipts -- --ignored -``` - -**Tests disponibles** : -- `test_mark_as_read_creates_receipt` : Vérifie la création d'un read receipt -- `test_mark_as_read_updates_existing` : Vérifie la mise à jour d'un read receipt existant -- `test_get_receipt` : Vérifie la récupération d'un read receipt -- `test_get_message_status` : Vérifie le statut de lecture -- `test_get_receipts_for_message` : Vérifie la récupération de tous les read receipts d'un message - -### Tests d'intégration - -Le test d'intégration est dans `tests/integration_test.rs` : `test_read_receipts_websocket`. - -**Exécution** : -```bash -cd veza-chat-server -# 1. Démarrer le serveur : cargo run -# 2. Dans un autre terminal : -cargo test --test integration_test test_read_receipts_websocket -- --ignored -``` - -**Prérequis** : -- Serveur chat-server en cours d'exécution -- Base de données avec migrations appliquées -- Variable d'environnement `DATABASE_URL` configurée - ---- - -## Exemples d'utilisation - -### Côté client (WebSocket) - -```javascript -// Marquer un message comme lu -const markAsRead = { - type: "MarkAsRead", - conversation_id: "conversation-uuid", - message_id: "message-uuid" -}; - -websocket.send(JSON.stringify(markAsRead)); - -// Écouter les notifications de lecture -websocket.onmessage = (event) => { - const message = JSON.parse(event.data); - - if (message.type === "MessageRead") { - console.log(`Message ${message.message_id} lu par ${message.user_id}`); - // Mettre à jour l'UI pour afficher l'indicateur "lu" - } - - if (message.type === "ActionConfirmed" && message.action === "marked_as_read") { - console.log("Message marqué comme lu avec succès"); - } -}; -``` - -### Côté serveur (Rust) - -```rust -use chat_server::read_receipts::ReadReceiptManager; - -// Dans votre handler -let manager = ReadReceiptManager::new(pool); - -// Marquer un message comme lu -let receipt = manager - .mark_as_read(user_id, message_id, conversation_id) - .await?; - -// Vérifier le statut -let status = manager - .get_message_status(message_id, user_id) - .await?; - -match status { - MessageReadStatus::Read => println!("Message lu"), - MessageReadStatus::Sent => println!("Message envoyé"), - MessageReadStatus::Delivered => println!("Message livré"), -} -``` - ---- - -## Limitations et améliorations futures - -### Limitations actuelles - -1. **Statut "Delivered"** : Le système ne track pas encore le statut "livré" (message reçu mais pas encore lu). Actuellement, un message est soit `Sent` soit `Read`. - -2. **Batch operations** : La méthode `mark_multiple_as_read` existe mais n'est pas encore exposée via WebSocket. - -### Améliorations possibles - -1. **Support "Delivered"** : Implémenter un système de tracking "delivered" (message reçu par le client mais pas encore ouvert). - -2. **API REST** : Exposer une API REST pour : - - Récupérer les read receipts d'un message - - Récupérer le nombre de messages non lus - - Marquer plusieurs messages comme lus en une requête - -3. **Optimisations** : - - Cache des read receipts fréquemment consultés - - Batch processing pour les marquages multiples - -4. **Métriques** : Ajouter des métriques Prometheus pour : - - Nombre de read receipts créés par seconde - - Temps moyen entre l'envoi et la lecture d'un message - - Taux de lecture par conversation - ---- - -## Migration depuis l'ancien système - -Si vous migrez depuis un système utilisant `i64` pour les IDs : - -1. **Exécuter la migration** : `migrations/003_read_receipts.sql` -2. **Migrer les données existantes** (si applicable) : - ```sql - -- Exemple de migration de données (à adapter selon votre schéma) - INSERT INTO read_receipts (message_id, user_id, conversation_id, read_at) - SELECT - message_id::uuid, - user_id::uuid, - conversation_id::uuid, - read_at - FROM old_read_receipts; - ``` -3. **Mettre à jour le code client** : S'assurer que les clients utilisent des UUID au lieu d'entiers - ---- - -## Support et maintenance - -### Logs à surveiller - -- Erreurs de validation/permission lors du marquage comme lu -- Erreurs de base de données lors de la création/mise à jour de read receipts -- Temps de réponse élevés pour les requêtes de read receipts - -### Monitoring recommandé - -- Nombre de read receipts créés par minute -- Taux d'erreur lors du marquage comme lu -- Temps de réponse des requêtes `get_receipts_for_message` - ---- - -## Références - -- **Migration** : `migrations/003_read_receipts.sql` -- **Code source** : `src/read_receipts.rs` -- **Handler WebSocket** : `src/websocket/handler.rs` -- **Types WebSocket** : `src/websocket/mod.rs` - ---- - -**Dernière mise à jour** : 2025-12-05 - diff --git a/veza-chat-server/env.example b/veza-chat-server/env.example deleted file mode 100644 index 89ca236a4..000000000 --- a/veza-chat-server/env.example +++ /dev/null @@ -1,132 +0,0 @@ -# ================================================================= -# CONFIGURATION SERVEUR VEZA CHAT -# ================================================================= - -# Environnement (development, staging, production) -RUST_ENV=development -RUST_LOG=debug - -# ================================================================= -# BASE DE DONNÉES -# ================================================================= -DATABASE_URL=postgresql://veza_user:veza_password@localhost:5432/veza_chat -DB_MAX_CONNECTIONS=10 -DB_CONNECT_TIMEOUT=10 -DB_AUTO_MIGRATE=true - -# ================================================================= -# SÉCURITÉ ET AUTHENTIFICATION -# ================================================================= -JWT_SECRET=your-super-secret-jwt-key-change-this-in-production -JWT_ACCESS_DURATION=15m -JWT_REFRESH_DURATION=7d -JWT_ALGORITHM=HS256 -JWT_AUDIENCE=veza-chat -JWT_ISSUER=veza-chat-server - -# Authentification 2FA -ENABLE_2FA=false -TOTP_WINDOW=30 - -# Sécurité des mots de passe -PASSWORD_MIN_LENGTH=8 -BCRYPT_COST=12 - -# ================================================================= -# SERVEUR ET RÉSEAU -# ================================================================= -SERVER_BIND_ADDR=127.0.0.1:8080 -SERVER_WORKERS=0 -CONNECTION_TIMEOUT=30 -HEARTBEAT_INTERVAL=30 -SHUTDOWN_TIMEOUT=10 - -# ================================================================= -# CACHE REDIS (OPTIONNEL) -# ================================================================= -REDIS_URL=redis://localhost:6379 -REDIS_POOL_SIZE=10 -REDIS_CONNECT_TIMEOUT=5 -REDIS_DEFAULT_TTL=3600 -REDIS_KEY_PREFIX=veza_chat: -REDIS_ENABLED=true - -# ================================================================= -# LIMITES ET QUOTAS -# ================================================================= -MAX_MESSAGE_LENGTH=2000 -MAX_CONNECTIONS_PER_USER=5 -MAX_MESSAGES_PER_MINUTE=60 -MAX_FILE_SIZE=10485760 -MAX_FILES_PER_USER=100 -MAX_ROOMS_PER_USER=50 -MAX_MEMBERS_PER_ROOM=1000 - -# ================================================================= -# FONCTIONNALITÉS -# ================================================================= -ENABLE_FILE_UPLOADS=true -ENABLE_MESSAGE_REACTIONS=true -ENABLE_USER_MENTIONS=true -ENABLE_PINNED_MESSAGES=true -ENABLE_MESSAGE_THREADS=true -ENABLE_WEBHOOKS=true -ENABLE_PUSH_NOTIFICATIONS=false -ENABLE_MESSAGE_HISTORY=true - -# ================================================================= -# LOGGING -# ================================================================= -LOG_LEVEL=info -LOG_FORMAT=json -LOG_FILE=logs/chat-server.log -LOG_ROTATION_SIZE=100MB -LOG_ROTATION_FILES=10 -LOG_COMPRESSION=true - -# ================================================================= -# INTÉGRATIONS EXTERNES -# ================================================================= - -# Email (optionnel) -SMTP_HOST=smtp.gmail.com -SMTP_PORT=587 -SMTP_USERNAME=your-email@gmail.com -SMTP_PASSWORD=your-app-password -EMAIL_FROM_ADDRESS=noreply@veza-chat.com -EMAIL_FROM_NAME=Veza Chat - -# Prometheus métriques (optionnel) -PROMETHEUS_BIND_ADDR=127.0.0.1:9090 -PROMETHEUS_PATH=/metrics - -# Webhooks sortants (optionnel) -WEBHOOK_USER_EVENTS=https://api.yourapp.com/webhooks/user-events -WEBHOOK_MESSAGE_EVENTS=https://api.yourapp.com/webhooks/message-events -WEBHOOK_SECRET=your-webhook-secret - -# ================================================================= -# DÉVELOPPEMENT -# ================================================================= - -# Base de données de test -TEST_DATABASE_URL=postgresql://veza_test:veza_test@localhost:5432/veza_chat_test - -# Debug et développement -ENABLE_DEBUG_ENDPOINTS=true -ENABLE_CORS=true -CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001 - -# ================================================================= -# PRODUCTION -# ================================================================= - -# SSL/TLS (production uniquement) -ENABLE_TLS=false -TLS_CERT_PATH=/path/to/cert.pem -TLS_KEY_PATH=/path/to/key.pem - -# Performance (production) -ENABLE_COMPRESSION=true -ENABLE_METRICS=true -ENABLE_HEALTH_CHECK=true \ No newline at end of file diff --git a/veza-chat-server/migrations/001_create_clean_database.sql b/veza-chat-server/migrations/001_create_clean_database.sql deleted file mode 100644 index 4fd795eb9..000000000 --- a/veza-chat-server/migrations/001_create_clean_database.sql +++ /dev/null @@ -1,116 +0,0 @@ --- Migration: Structure de base de données simplifiée pour chat server --- Création: 2025-07-26 --- Version: 1.0.0 Production Ready - --- Extensions requises -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -CREATE EXTENSION IF NOT EXISTS "pgcrypto"; - --- ================================================================ --- TABLE UTILISATEURS --- ================================================================ - -CREATE TABLE IF NOT EXISTS users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - username VARCHAR(50) UNIQUE NOT NULL, - email VARCHAR(255) UNIQUE NOT NULL, - display_name VARCHAR(100), - avatar_url TEXT, - is_active BOOLEAN NOT NULL DEFAULT true, - last_seen TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() -); - --- ================================================================ --- TABLE CONVERSATIONS --- ================================================================ - -CREATE TABLE IF NOT EXISTS conversations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL, - description TEXT, - conversation_type VARCHAR(50) NOT NULL DEFAULT 'direct', - is_private BOOLEAN NOT NULL DEFAULT false, - created_by UUID REFERENCES users(id) ON DELETE CASCADE, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() -); - --- ================================================================ --- TABLE MEMBRES DES CONVERSATIONS --- ================================================================ - -CREATE TABLE IF NOT EXISTS conversation_members ( - conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE, - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - role VARCHAR(50) NOT NULL DEFAULT 'user', - joined_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - PRIMARY KEY (conversation_id, user_id) -); - --- ================================================================ --- TABLE MESSAGES --- ================================================================ - -CREATE TABLE IF NOT EXISTS messages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE, - sender_id UUID REFERENCES users(id) ON DELETE CASCADE, - content TEXT NOT NULL, - message_type VARCHAR(50) NOT NULL DEFAULT 'text', - parent_message_id UUID REFERENCES messages(id) ON DELETE CASCADE, - is_pinned BOOLEAN NOT NULL DEFAULT false, - is_deleted BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - status VARCHAR(50) NOT NULL DEFAULT 'sent' -); - --- ================================================================ --- INDEX POUR PERFORMANCE --- ================================================================ - -CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id); -CREATE INDEX IF NOT EXISTS idx_messages_sender_id ON messages(sender_id); -CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at); -CREATE INDEX IF NOT EXISTS idx_conversation_members_conversation_id ON conversation_members(conversation_id); -CREATE INDEX IF NOT EXISTS idx_conversation_members_user_id ON conversation_members(user_id); -CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); -CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); - --- ================================================================ --- TRIGGERS POUR MISE À JOUR AUTOMATIQUE --- ================================================================ - -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ language 'plpgsql'; - -CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_conversations_updated_at BEFORE UPDATE ON conversations - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_messages_updated_at BEFORE UPDATE ON messages - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - --- ================================================================ --- DONNÉES DE TEST --- ================================================================ - --- Insérer un utilisateur de test -INSERT INTO users (username, email, display_name) -VALUES ('test_user', 'test@veza.com', 'Test User') -ON CONFLICT (username) DO NOTHING; - --- Insérer une conversation de test -INSERT INTO conversations (name, description, conversation_type, created_by) -SELECT 'Test Room', 'Room de test pour Veza', 'group', id -FROM users WHERE username = 'test_user' -ON CONFLICT DO NOTHING; \ No newline at end of file diff --git a/veza-chat-server/migrations/002_advanced_features.sql b/veza-chat-server/migrations/002_advanced_features.sql deleted file mode 100644 index 1d24cdcaf..000000000 --- a/veza-chat-server/migrations/002_advanced_features.sql +++ /dev/null @@ -1,223 +0,0 @@ --- Migration pour les fonctionnalités avancées du serveur de chat --- Exécuter après les migrations de base - --- Table des sanctions/modération -CREATE TABLE IF NOT EXISTS sanctions ( - id SERIAL PRIMARY KEY, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - moderator_id UUID NOT NULL REFERENCES users(id), -- NULL pour système automatique - sanction_type VARCHAR(50) NOT NULL, -- JSON serialized SanctionType - reason VARCHAR(100) NOT NULL, -- JSON serialized SanctionReason - message TEXT, -- Message optionnel du modérateur - expires_at TIMESTAMP WITH TIME ZONE, -- Expiration pour sanctions temporaires - is_active BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- Index pour les sanctions -CREATE INDEX idx_sanctions_user_id ON sanctions(user_id); -CREATE INDEX idx_sanctions_active ON sanctions(user_id, is_active) WHERE is_active = true; - --- Table des réactions aux messages -CREATE TABLE IF NOT EXISTS message_reactions ( - id SERIAL PRIMARY KEY, - message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - reaction_type VARCHAR(100) NOT NULL, -- JSON serialized ReactionType - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - - -- Un utilisateur ne peut avoir qu'une réaction de chaque type par message - UNIQUE(message_id, user_id, reaction_type) -); - --- Index pour les réactions -CREATE INDEX idx_message_reactions_message ON message_reactions(message_id); - --- Table des blocages entre utilisateurs -CREATE TABLE IF NOT EXISTS user_blocks ( - id SERIAL PRIMARY KEY, - blocker_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - blocked_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - reason VARCHAR(255), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - - -- Un utilisateur ne peut bloquer un autre qu'une seule fois - UNIQUE(blocker_id, blocked_id), - - -- Un utilisateur ne peut pas se bloquer lui-même - CHECK (blocker_id != blocked_id) -); - --- Index pour les blocages -CREATE INDEX idx_user_blocks_blocker ON user_blocks(blocker_id); -CREATE INDEX idx_user_blocks_blocked ON user_blocks(blocked_id); - --- Table des salons avec métadonnées -CREATE TABLE IF NOT EXISTS rooms ( - id SERIAL PRIMARY KEY, - name VARCHAR(50) NOT NULL UNIQUE, - display_name VARCHAR(100), - description TEXT, - creator_id UUID NOT NULL REFERENCES users(id), - is_private BOOLEAN NOT NULL DEFAULT false, - max_members INTEGER DEFAULT 100, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- Index pour les salons -CREATE INDEX idx_rooms_name ON rooms(name); -CREATE INDEX idx_rooms_creator ON rooms(creator_id); -CREATE INDEX idx_rooms_private ON rooms(is_private); - --- Table des membres de salons avec rôles -CREATE TABLE IF NOT EXISTS room_members ( - id SERIAL PRIMARY KEY, - room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role VARCHAR(20) NOT NULL DEFAULT 'member', -- 'admin', 'moderator', 'member' - joined_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_read_at TIMESTAMP WITH TIME ZONE, - - UNIQUE(room_id, user_id) -); - --- Index pour les membres de salon -CREATE INDEX idx_room_members_room ON room_members(room_id); -CREATE INDEX idx_room_members_user ON room_members(user_id); -CREATE INDEX idx_room_members_role ON room_members(room_id, role); - --- Table des notifications -CREATE TABLE IF NOT EXISTS notifications ( - id SERIAL PRIMARY KEY, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - type VARCHAR(50) NOT NULL, -- 'dm', 'mention', 'room_invite', etc. - title VARCHAR(255) NOT NULL, - content TEXT NOT NULL, - metadata JSONB, -- Données additionnelles spécifiques au type - is_read BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - read_at TIMESTAMP WITH TIME ZONE -); - --- Index pour les notifications -CREATE INDEX idx_notifications_user ON notifications(user_id); -CREATE INDEX idx_notifications_unread ON notifications(user_id, is_read) WHERE is_read = false; -CREATE INDEX idx_notifications_type ON notifications(type); - --- Table des sessions utilisateur (pour la gestion des connexions multiples) -CREATE TABLE IF NOT EXISTS user_sessions ( - id SERIAL PRIMARY KEY, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - session_token VARCHAR(255) NOT NULL UNIQUE, - device_info VARCHAR(255), -- User-Agent ou info appareil - ip_address INET, - last_activity TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TIMESTAMP WITH TIME ZONE NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - is_active BOOLEAN NOT NULL DEFAULT true -); - --- Index pour les sessions -CREATE INDEX idx_user_sessions_user ON user_sessions(user_id); -CREATE INDEX idx_user_sessions_token ON user_sessions(session_token); -CREATE INDEX idx_user_sessions_active ON user_sessions(user_id, is_active) WHERE is_active = true; -CREATE INDEX idx_user_sessions_expires ON user_sessions(expires_at); - --- Table des logs d'audit pour le monitoring -CREATE TABLE IF NOT EXISTS audit_logs ( - id SERIAL PRIMARY KEY, - user_id UUID REFERENCES users(id), -- NULL pour les actions système - action VARCHAR(100) NOT NULL, -- 'login', 'message_sent', 'user_banned', etc. - resource_type VARCHAR(50), -- 'user', 'message', 'room', etc. - resource_id VARCHAR(100), -- ID de la ressource concernée - details JSONB, -- Détails spécifiques à l'action - ip_address INET, - user_agent TEXT, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- Index pour les logs d'audit -CREATE INDEX idx_audit_logs_user ON audit_logs(user_id); -CREATE INDEX idx_audit_logs_action ON audit_logs(action); -CREATE INDEX idx_audit_logs_resource ON audit_logs(resource_type, resource_id); -CREATE INDEX idx_audit_logs_created ON audit_logs(created_at); - --- Mise à jour de la table messages pour supporter plus de métadonnées -ALTER TABLE messages -ADD COLUMN IF NOT EXISTS message_type VARCHAR(20) DEFAULT 'text', -ADD COLUMN IF NOT EXISTS reply_to_id UUID REFERENCES messages(id), -ADD COLUMN IF NOT EXISTS is_edited BOOLEAN DEFAULT false, -ADD COLUMN IF NOT EXISTS edited_at TIMESTAMP WITH TIME ZONE, -ADD COLUMN IF NOT EXISTS metadata JSONB; - --- Index pour les nouvelles colonnes de messages -CREATE INDEX IF NOT EXISTS idx_messages_type ON messages(message_type); -CREATE INDEX IF NOT EXISTS idx_messages_reply ON messages(reply_to_id); -CREATE INDEX IF NOT EXISTS idx_messages_edited ON messages(is_edited) WHERE is_edited = true; - --- Mise à jour de la table users pour supporter les rôles et statuts -ALTER TABLE users -ADD COLUMN IF NOT EXISTS role VARCHAR(20) DEFAULT 'user', -ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'offline', -ADD COLUMN IF NOT EXISTS last_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, -ADD COLUMN IF NOT EXISTS reputation_score INTEGER DEFAULT 100, -ADD COLUMN IF NOT EXISTS is_banned BOOLEAN DEFAULT false, -ADD COLUMN IF NOT EXISTS is_muted BOOLEAN DEFAULT false; - --- Index pour les nouvelles colonnes utilisateurs -CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); -CREATE INDEX IF NOT EXISTS idx_users_status ON users(status); - --- Vue pour les statistiques en temps réel -CREATE OR REPLACE VIEW server_stats AS -SELECT - (SELECT COUNT(*) FROM users WHERE last_seen > CURRENT_TIMESTAMP - INTERVAL '5 minutes') as active_users, - (SELECT COUNT(*) FROM users) as total_users, - (SELECT COUNT(*) FROM messages WHERE created_at > CURRENT_DATE) as messages_today, - (SELECT COUNT(*) FROM messages) as total_messages; - --- Fonction pour nettoyer les sessions expirées -CREATE OR REPLACE FUNCTION cleanup_expired_sessions() -RETURNS INTEGER AS $$ -DECLARE - deleted_count INTEGER; -BEGIN - DELETE FROM user_sessions WHERE expires_at < CURRENT_TIMESTAMP; - GET DIAGNOSTICS deleted_count = ROW_COUNT; - RETURN deleted_count; -END; -$$ LANGUAGE plpgsql; - --- Fonction pour nettoyer les anciens logs d'audit (garder 30 jours) -CREATE OR REPLACE FUNCTION cleanup_old_audit_logs() -RETURNS INTEGER AS $$ -DECLARE - deleted_count INTEGER; -BEGIN - DELETE FROM audit_logs WHERE created_at < CURRENT_TIMESTAMP - INTERVAL '30 days'; - GET DIAGNOSTICS deleted_count = ROW_COUNT; - RETURN deleted_count; -END; -$$ LANGUAGE plpgsql; - --- Contraintes de sécurité -ALTER TABLE messages ADD CONSTRAINT chk_message_content_length CHECK (length(content) <= 4000); -ALTER TABLE rooms ADD CONSTRAINT chk_room_name_length CHECK (length(name) <= 50 AND length(name) >= 1); -ALTER TABLE rooms ADD CONSTRAINT chk_room_max_members CHECK (max_members > 0 AND max_members <= 1000); - --- Commentaires pour la documentation -COMMENT ON TABLE sanctions IS 'Table des sanctions de modération (warnings, mutes, bans)'; -COMMENT ON TABLE message_reactions IS 'Table des réactions aux messages (like, love, etc.)'; -COMMENT ON TABLE user_blocks IS 'Table des blocages entre utilisateurs'; -COMMENT ON TABLE rooms IS 'Table des salons de chat avec métadonnées'; -COMMENT ON TABLE room_members IS 'Table des membres de salon avec leurs rôles'; -COMMENT ON TABLE notifications IS 'Table des notifications push/in-app'; -COMMENT ON TABLE user_sessions IS 'Table des sessions utilisateur actives'; -COMMENT ON TABLE audit_logs IS 'Table des logs d''audit pour le monitoring'; - -COMMENT ON VIEW server_stats IS 'Vue des statistiques serveur en temps réel'; - --- Permissions par défaut (ajuster selon vos besoins) --- GRANT SELECT ON server_stats TO chat_readonly_user; --- GRANT SELECT, INSERT ON audit_logs TO chat_api_user; \ No newline at end of file diff --git a/veza-chat-server/migrations/003_read_receipts.sql b/veza-chat-server/migrations/003_read_receipts.sql deleted file mode 100644 index 582b709c9..000000000 --- a/veza-chat-server/migrations/003_read_receipts.sql +++ /dev/null @@ -1,58 +0,0 @@ --- Migration: Table read_receipts pour le système de read receipts --- Création: 2025-12-05 --- Version: 1.0.0 - --- ================================================================ --- TABLE READ RECEIPTS --- ================================================================ - --- Table pour tracker les read receipts (marquage de messages comme lus) -CREATE TABLE IF NOT EXISTS read_receipts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, - read_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - - -- Un utilisateur ne peut avoir qu'un seul read receipt par message - UNIQUE(message_id, user_id) -); - --- ================================================================ --- INDEX POUR PERFORMANCE --- ================================================================ - --- Index pour rechercher les read receipts par message -CREATE INDEX IF NOT EXISTS idx_read_receipts_message_id ON read_receipts(message_id); - --- Index pour rechercher les read receipts par utilisateur -CREATE INDEX IF NOT EXISTS idx_read_receipts_user_id ON read_receipts(user_id); - --- Index pour rechercher les read receipts par conversation -CREATE INDEX IF NOT EXISTS idx_read_receipts_conversation_id ON read_receipts(conversation_id); - --- Index composite pour les requêtes fréquentes (dernière lecture dans une conversation) -CREATE INDEX IF NOT EXISTS idx_read_receipts_conversation_user ON read_receipts(conversation_id, user_id, read_at DESC); - --- Index pour les requêtes de comptage de messages non lus -CREATE INDEX IF NOT EXISTS idx_read_receipts_message_user ON read_receipts(message_id, user_id); - --- ================================================================ --- TRIGGERS POUR MISE À JOUR AUTOMATIQUE --- ================================================================ - -CREATE TRIGGER update_read_receipts_updated_at BEFORE UPDATE ON read_receipts - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - --- ================================================================ --- COMMENTAIRES POUR DOCUMENTATION --- ================================================================ - -COMMENT ON TABLE read_receipts IS 'Table des read receipts pour tracker quels messages ont été lus par quels utilisateurs'; -COMMENT ON COLUMN read_receipts.message_id IS 'ID du message marqué comme lu'; -COMMENT ON COLUMN read_receipts.user_id IS 'ID de l''utilisateur qui a lu le message'; -COMMENT ON COLUMN read_receipts.conversation_id IS 'ID de la conversation (pour optimiser les requêtes)'; -COMMENT ON COLUMN read_receipts.read_at IS 'Timestamp de la lecture du message'; - diff --git a/veza-chat-server/migrations/004_delivered_status.sql b/veza-chat-server/migrations/004_delivered_status.sql deleted file mode 100644 index feb7cb8e7..000000000 --- a/veza-chat-server/migrations/004_delivered_status.sql +++ /dev/null @@ -1,58 +0,0 @@ --- Migration: Table delivered_status pour le système de delivered status --- Création: 2025-01-27 --- Version: 1.0.0 - --- ================================================================ --- TABLE DELIVERED STATUS --- ================================================================ - --- Table pour tracker les delivered status (messages reçus mais pas encore lus) -CREATE TABLE IF NOT EXISTS delivered_status ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, - delivered_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - - -- Un utilisateur ne peut avoir qu'un seul delivered status par message - UNIQUE(message_id, user_id) -); - --- ================================================================ --- INDEX POUR PERFORMANCE --- ================================================================ - --- Index pour rechercher les delivered status par message -CREATE INDEX IF NOT EXISTS idx_delivered_status_message_id ON delivered_status(message_id); - --- Index pour rechercher les delivered status par utilisateur -CREATE INDEX IF NOT EXISTS idx_delivered_status_user_id ON delivered_status(user_id); - --- Index pour rechercher les delivered status par conversation -CREATE INDEX IF NOT EXISTS idx_delivered_status_conversation_id ON delivered_status(conversation_id); - --- Index composite pour les requêtes fréquentes (dernière délivrance dans une conversation) -CREATE INDEX IF NOT EXISTS idx_delivered_status_conversation_user ON delivered_status(conversation_id, user_id, delivered_at DESC); - --- Index pour les requêtes de comptage de messages non délivrés -CREATE INDEX IF NOT EXISTS idx_delivered_status_message_user ON delivered_status(message_id, user_id); - --- ================================================================ --- TRIGGERS POUR MISE À JOUR AUTOMATIQUE --- ================================================================ - -CREATE TRIGGER update_delivered_status_updated_at BEFORE UPDATE ON delivered_status - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - --- ================================================================ --- COMMENTAIRES POUR DOCUMENTATION --- ================================================================ - -COMMENT ON TABLE delivered_status IS 'Table des delivered status pour tracker quels messages ont été délivrés (reçus) par quels utilisateurs'; -COMMENT ON COLUMN delivered_status.message_id IS 'ID du message délivré'; -COMMENT ON COLUMN delivered_status.user_id IS 'ID de l''utilisateur qui a reçu le message'; -COMMENT ON COLUMN delivered_status.conversation_id IS 'ID de la conversation (pour optimiser les requêtes)'; -COMMENT ON COLUMN delivered_status.delivered_at IS 'Timestamp de la délivrance du message'; - diff --git a/veza-chat-server/migrations/005_message_edit_delete.sql b/veza-chat-server/migrations/005_message_edit_delete.sql deleted file mode 100644 index eb9dc9928..000000000 --- a/veza-chat-server/migrations/005_message_edit_delete.sql +++ /dev/null @@ -1,22 +0,0 @@ --- Migration: Support pour l'édition et la suppression de messages --- Création: 2025-12-05 --- Version: 1.0.0 --- Description: Ajoute les colonnes nécessaires pour l'édition et la suppression (soft delete) de messages - --- Ajouter deleted_at pour la traçabilité (is_deleted existe déjà) -ALTER TABLE messages -ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE; - --- Index pour les messages supprimés (pour les requêtes de nettoyage) -CREATE INDEX IF NOT EXISTS idx_messages_deleted_at ON messages(deleted_at) WHERE deleted_at IS NOT NULL; - --- Index pour les messages édités (pour les requêtes de recherche) -CREATE INDEX IF NOT EXISTS idx_messages_edited_at ON messages(edited_at) WHERE edited_at IS NOT NULL; - --- Commentaire pour la documentation -COMMENT ON COLUMN messages.deleted_at IS 'Timestamp de suppression du message (soft delete)'; -COMMENT ON COLUMN messages.edited_at IS 'Timestamp de dernière édition du message'; -COMMENT ON COLUMN messages.is_edited IS 'Indicateur si le message a été édité'; -COMMENT ON COLUMN messages.is_deleted IS 'Indicateur si le message a été supprimé (soft delete)'; - - diff --git a/veza-chat-server/migrations/006_history_search_sync.sql b/veza-chat-server/migrations/006_history_search_sync.sql deleted file mode 100644 index e6576c161..000000000 --- a/veza-chat-server/migrations/006_history_search_sync.sql +++ /dev/null @@ -1,59 +0,0 @@ --- Migration: Support pour History Pagination, Message Search, et Offline Sync --- Création: 2025-12-05 --- Version: 1.0.0 --- Description: Ajoute les index nécessaires pour la pagination, recherche et synchronisation - --- ================================================================ --- INDEX POUR PAGINATION (HISTORY) --- ================================================================ - --- Index composite pour la pagination efficace par conversation et date --- Permet les requêtes ORDER BY created_at avec WHERE conversation_id -CREATE INDEX IF NOT EXISTS idx_messages_conv_created_at -ON messages(conversation_id, created_at DESC); - --- Index pour les requêtes avec filtre is_deleted (pour exclure les messages supprimés) -CREATE INDEX IF NOT EXISTS idx_messages_conv_created_not_deleted -ON messages(conversation_id, created_at DESC) -WHERE is_deleted = false; - --- ================================================================ --- INDEX POUR RECHERCHE TEXTUELLE --- ================================================================ - --- Extension pour recherche trigram (recherche partielle efficace) -CREATE EXTENSION IF NOT EXISTS pg_trgm; - --- Index GIN trigram pour recherche ILIKE performante sur content -CREATE INDEX IF NOT EXISTS idx_messages_content_trgm -ON messages USING GIN(content gin_trgm_ops); - --- Index pour recherche avec filtre conversation_id + content -CREATE INDEX IF NOT EXISTS idx_messages_conv_content_trgm -ON messages USING GIN(conversation_id, content gin_trgm_ops); - --- ================================================================ --- INDEX POUR SYNC OFFLINE --- ================================================================ - --- Index pour les requêtes WHERE created_at > timestamp (sync depuis) -CREATE INDEX IF NOT EXISTS idx_messages_conv_created_sync -ON messages(conversation_id, created_at ASC) -WHERE is_deleted = false; - --- Index pour les requêtes WHERE updated_at > timestamp (pour les edits) -CREATE INDEX IF NOT EXISTS idx_messages_conv_updated_sync -ON messages(conversation_id, updated_at ASC) -WHERE is_deleted = false; - --- ================================================================ --- COMMENTAIRES POUR DOCUMENTATION --- ================================================================ - -COMMENT ON INDEX idx_messages_conv_created_at IS 'Index pour pagination efficace de l''historique par conversation'; -COMMENT ON INDEX idx_messages_conv_created_not_deleted IS 'Index pour pagination en excluant les messages supprimés'; -COMMENT ON INDEX idx_messages_content_trgm IS 'Index GIN trigram pour recherche textuelle performante sur le contenu'; -COMMENT ON INDEX idx_messages_conv_content_trgm IS 'Index pour recherche textuelle par conversation'; -COMMENT ON INDEX idx_messages_conv_created_sync IS 'Index pour synchronisation offline (messages depuis timestamp)'; -COMMENT ON INDEX idx_messages_conv_updated_sync IS 'Index pour synchronisation offline (updates depuis timestamp)'; - diff --git a/veza-chat-server/migrations/1000_dm_enriched.sql b/veza-chat-server/migrations/1000_dm_enriched.sql deleted file mode 100644 index 085d960f6..000000000 --- a/veza-chat-server/migrations/1000_dm_enriched.sql +++ /dev/null @@ -1,69 +0,0 @@ --- Migration pour les DM enrichis - Veza Chat Server --- Ajoute la table dm_conversations pour séparer les DM des salons - -BEGIN; - --- Table pour les conversations DM enrichies -CREATE TABLE IF NOT EXISTS dm_conversations ( - id BIGSERIAL PRIMARY KEY, - uuid UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(), - user1_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - user2_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - is_blocked BOOLEAN NOT NULL DEFAULT FALSE, - blocked_by UUID REFERENCES users(id) ON DELETE SET NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - - -- Contraintes - CONSTRAINT dm_conversations_different_users CHECK (user1_id != user2_id), - CONSTRAINT dm_conversations_ordered_users CHECK (user1_id < user2_id), - CONSTRAINT dm_conversations_unique_pair UNIQUE (user1_id, user2_id) -); - --- Index pour les performances -CREATE INDEX IF NOT EXISTS idx_dm_conversations_user1 ON dm_conversations(user1_id); -CREATE INDEX IF NOT EXISTS idx_dm_conversations_user2 ON dm_conversations(user2_id); -CREATE INDEX IF NOT EXISTS idx_dm_conversations_updated_at ON dm_conversations(updated_at DESC); -CREATE INDEX IF NOT EXISTS idx_dm_conversations_uuid ON dm_conversations(uuid); - --- Trigger pour mettre à jour updated_at automatiquement -CREATE OR REPLACE FUNCTION update_dm_conversations_updated_at() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trigger_dm_conversations_updated_at - BEFORE UPDATE ON dm_conversations - FOR EACH ROW - EXECUTE FUNCTION update_dm_conversations_updated_at(); - --- Migration des données DM supprimée car les colonnes from_user, to_user, room n'existent pas --- dans le schéma de base. La table dm_conversations est créée vide et sera peuplée --- par l'application lors de la création de nouveaux DM. - --- Log de la migration supprimé car la table audit_logs n'existe pas dans le schéma de base - -COMMIT; - --- Vérifications post-migration -DO $$ -DECLARE - dm_conversations_count INTEGER; - migrated_messages_count INTEGER; -BEGIN - SELECT COUNT(*) INTO dm_conversations_count FROM dm_conversations; - SELECT COUNT(*) INTO migrated_messages_count FROM messages WHERE conversation_id IN (SELECT uuid FROM dm_conversations); - - RAISE NOTICE 'Migration DM enrichis terminée:'; - RAISE NOTICE ' - % conversations DM créées', dm_conversations_count; - RAISE NOTICE ' - % messages DM migrés', migrated_messages_count; - - IF dm_conversations_count = 0 THEN - RAISE NOTICE ' ⚠️ Aucune conversation DM trouvée (normal si pas de DM existants)'; - ELSE - RAISE NOTICE ' ✅ Migration réussie'; - END IF; -END $$; \ No newline at end of file diff --git a/veza-chat-server/migrations/1001_post_migration_fixes.sql b/veza-chat-server/migrations/1001_post_migration_fixes.sql deleted file mode 100644 index 85f377447..000000000 --- a/veza-chat-server/migrations/1001_post_migration_fixes.sql +++ /dev/null @@ -1,87 +0,0 @@ --- ================================================================ --- CORRECTIONS POST-MIGRATION --- Corrige les erreurs résiduelles de la migration principale --- ================================================================ - --- ================================================================ --- ÉTAPE 1: FINALISER LA TABLE USERS --- ================================================================ - --- Colonnes de profil supprimées car elles existent déjà dans la migration de base - --- Type user_role supprimé car non défini dans le schéma de base - --- ================================================================ --- ÉTAPE 2: FINALISER LA TABLE MESSAGES --- ================================================================ - --- Colonnes de messages supprimées car elles existent déjà dans la migration de base - --- Type message_status supprimé car non défini dans le schéma de base - --- ================================================================ --- ÉTAPE 3: FINALISER LA TABLE MESSAGE_REACTIONS --- ================================================================ - --- Colonne emoji supprimée car la table message_reactions utilise reaction_type - --- ================================================================ --- ÉTAPE 4: CRÉATION DES INDEX MANQUÉS --- ================================================================ - --- Index supprimés car les colonnes référencées n'existent pas dans le schéma de base - --- ================================================================ --- ÉTAPE 5: NETTOYAGE DES DÉPENDANCES PROBLÉMATIQUES --- ================================================================ - --- Supprimer le trigger problématique avant la fonction -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM information_schema.triggers - WHERE trigger_name = 'trigger_handle_mentions_secure') THEN - DROP TRIGGER IF EXISTS trigger_handle_mentions_secure ON messages; - RAISE NOTICE 'Trigger trigger_handle_mentions_secure supprimé'; - END IF; - - -- Maintenant supprimer la fonction - DROP FUNCTION IF EXISTS handle_mentions_secure() CASCADE; - RAISE NOTICE 'Fonction handle_mentions_secure supprimée'; -EXCEPTION - WHEN OTHERS THEN - RAISE NOTICE 'Erreur lors du nettoyage: %', SQLERRM; -END $$; - --- ================================================================ --- ÉTAPE 6: MISE À JOUR DES DONNÉES EXISTANTES --- ================================================================ - --- Migration des données supprimée car les colonnes room n'existent pas dans le schéma de base - --- Migration des conversations supprimée car les colonnes et types n'existent pas dans le schéma de base - --- ================================================================ --- ÉTAPE 7: VÉRIFICATIONS FINALES --- ================================================================ - --- Vérifications finales simplifiées -DO $$ -DECLARE - orphan_messages INTEGER; -BEGIN - -- Compter les messages sans conversation - SELECT COUNT(*) INTO orphan_messages FROM messages WHERE conversation_id IS NULL; - - RAISE NOTICE 'Vérifications finales:'; - RAISE NOTICE '- Messages orphelins: %', orphan_messages; - - IF orphan_messages > 0 THEN - RAISE WARNING 'Il reste % messages sans conversation_id', orphan_messages; - END IF; -END $$; - --- Actualiser les statistiques -ANALYZE users; -ANALYZE messages; -ANALYZE conversations; -ANALYZE message_reactions; \ No newline at end of file diff --git a/veza-chat-server/migrations/1002_add_missing_uuids.sql b/veza-chat-server/migrations/1002_add_missing_uuids.sql deleted file mode 100644 index 481deba9f..000000000 --- a/veza-chat-server/migrations/1002_add_missing_uuids.sql +++ /dev/null @@ -1,58 +0,0 @@ --- Migration: Ajout de colonnes UUID aux tables manquantes --- Création: 2025-01-27 --- Version: 1.0.0 --- Description: Ajoute des colonnes UUID aux tables conversation_members, audit_logs et security_events --- pour permettre la migration du code Rust de i64 vers Uuid - --- ================================================================ --- TABLE conversation_members --- ================================================================ - --- Ajouter la colonne uuid (cette table n'a pas de colonne id, seulement une PK composite) -ALTER TABLE conversation_members -ADD COLUMN IF NOT EXISTS uuid UUID DEFAULT gen_random_uuid(); - --- Ajouter la contrainte UNIQUE -ALTER TABLE conversation_members -ADD CONSTRAINT conversation_members_uuid_unique UNIQUE (uuid); - --- Ajouter la contrainte NOT NULL (après le backfill par default) --- Note: Les valeurs existantes ont déjà été remplies par DEFAULT, donc on peut ajouter NOT NULL -ALTER TABLE conversation_members -ALTER COLUMN uuid SET NOT NULL; - --- Index pour performance -CREATE INDEX IF NOT EXISTS idx_conversation_members_uuid ON conversation_members(uuid); - --- ================================================================ --- TABLE audit_logs --- ================================================================ - --- Ajouter la colonne uuid (cette table a déjà un id SERIAL) -ALTER TABLE audit_logs -ADD COLUMN IF NOT EXISTS uuid UUID DEFAULT gen_random_uuid(); - --- Ajouter la contrainte UNIQUE -ALTER TABLE audit_logs -ADD CONSTRAINT audit_logs_uuid_unique UNIQUE (uuid); - --- Ajouter la contrainte NOT NULL (après le backfill par default) -ALTER TABLE audit_logs -ALTER COLUMN uuid SET NOT NULL; - --- Index pour performance -CREATE INDEX IF NOT EXISTS idx_audit_logs_uuid ON audit_logs(uuid); - --- ================================================================ --- TABLE security_events (block suppressed) --- ================================================================ --- Table externe non gérée dans ce schéma isolé. - --- ================================================================ --- COMMENTAIRES --- ================================================================ - -COMMENT ON COLUMN conversation_members.uuid IS 'UUID unique pour chaque membre de conversation (pour migration i64 -> UUID)'; -COMMENT ON COLUMN audit_logs.uuid IS 'UUID unique pour chaque log d''audit (pour migration i64 -> UUID)'; - - diff --git a/veza-chat-server/migrations/999_cleanup_production_ready_fixed.sql b/veza-chat-server/migrations/999_cleanup_production_ready_fixed.sql deleted file mode 100644 index b5cd1ce63..000000000 --- a/veza-chat-server/migrations/999_cleanup_production_ready_fixed.sql +++ /dev/null @@ -1,402 +0,0 @@ --- ================================================================ --- MIGRATION DE NETTOYAGE ET MISE À JOUR POUR PRODUCTION --- Version: 0.2.0 - Compatible avec structure existante --- ================================================================ --- ⚠️ ATTENTION: Cette migration est partiellement destructive --- 🔒 Assurez-vous d'avoir une sauvegarde complète avant exécution --- --- Utilisation: --- psql -h 10.5.191.47 -U veza -d veza_db -f migrations/999_cleanup_production_ready_fixed.sql --- ================================================================ - --- Début de la migration de production - --- Vérifier que nous sommes dans la bonne base -SELECT current_database() as current_db, current_user as current_user_name; - --- Créer l'extension UUID si pas présente --- Extension uuid-ossp non nécessaire, gen_random_uuid() est utilisé - --- ================================================================ --- ÉTAPE 1: SAUVEGARDE DES DONNÉES EXISTANTES --- ================================================================ - --- Sauvegarde des données existantes - --- Sauvegarder les utilisateurs existants -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'users') THEN - CREATE TEMP TABLE temp_old_users AS - SELECT id, username, email, created_at, role - FROM users; - - RAISE NOTICE 'Sauvegarde de % utilisateurs', (SELECT COUNT(*) FROM temp_old_users); - ELSE - RAISE NOTICE 'Table users non trouvée, création nécessaire'; - END IF; -END $$; - --- Sauvegarder les messages existants -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'messages') THEN - CREATE TEMP TABLE temp_old_messages AS - SELECT id, sender_id, content, created_at, message_type - FROM messages; - - RAISE NOTICE 'Sauvegarde de % messages', (SELECT COUNT(*) FROM temp_old_messages); - ELSE - RAISE NOTICE 'Table messages non trouvée, création nécessaire'; - END IF; -END $$; - --- ================================================================ --- ÉTAPE 2: SUPPRESSION SÉCURISÉE DES TABLES REDONDANTES --- ================================================================ - --- Suppression des tables redondantes - --- Supprimer uniquement les tables qui existent et sont redondantes -DROP TABLE IF EXISTS users_enhanced CASCADE; -DROP TABLE IF EXISTS users_backup CASCADE; -DROP TABLE IF EXISTS rooms_enhanced CASCADE; -DROP TABLE IF EXISTS messages_enhanced CASCADE; -DROP TABLE IF EXISTS message_mentions_enhanced CASCADE; -DROP TABLE IF EXISTS message_mentions_secure CASCADE; -DROP TABLE IF EXISTS message_reactions_enhanced CASCADE; -DROP TABLE IF EXISTS room_members_enhanced CASCADE; -DROP TABLE IF EXISTS user_sessions_enhanced CASCADE; -DROP TABLE IF EXISTS user_sessions_secure CASCADE; -DROP TABLE IF EXISTS user_blocks_enhanced CASCADE; -DROP TABLE IF EXISTS user_blocks_secure CASCADE; -DROP TABLE IF EXISTS security_events_enhanced CASCADE; -DROP TABLE IF EXISTS security_events_secure CASCADE; - --- Supprimer les tables métier obsolètes (si elles existent) -DROP TABLE IF EXISTS listings CASCADE; -DROP TABLE IF EXISTS categories CASCADE; -DROP TABLE IF EXISTS user_products CASCADE; -DROP TABLE IF EXISTS internal_documents CASCADE; -DROP TABLE IF EXISTS shared_ressources CASCADE; -DROP TABLE IF EXISTS shared_ressource_tags CASCADE; -DROP TABLE IF EXISTS ressource_tags CASCADE; -DROP TABLE IF EXISTS tracks CASCADE; - --- ================================================================ --- ÉTAPE 3: CRÉATION DES TYPES ENUMS NÉCESSAIRES --- ================================================================ - --- Création des types énumérés - --- Type user_role supprimé car non nécessaire pour la migration de base - --- Type message_status supprimé car non nécessaire pour la migration de base - --- Type conversation_type supprimé car non nécessaire pour la migration de base - --- ================================================================ --- ÉTAPE 4: MISE À JOUR DE LA TABLE USERS --- ================================================================ - --- Mise à jour de la table users - --- Colonnes uuid supprimées car les tables utilisent déjà id avec UUID - --- Ajouter les nouvelles colonnes de sécurité -DO $$ -BEGIN - -- Colonnes de sécurité 2FA supprimées car non nécessaires pour la migration de base - - -- Colonnes de profil (display_name et avatar_url existent déjà dans la migration de base) - -- Colonne bio supprimée car non nécessaire pour la migration de base - - -- Colonnes de métadonnées - -- Colonne last_login supprimée car non nécessaire pour la migration de base - - -- Colonne last_activity supprimée car non nécessaire pour la migration de base - - -- updated_at existe déjà dans la migration de base, pas besoin de l'ajouter - - -- Colonnes de permissions (is_active existe déjà dans la migration de base) - -- Colonne is_verified supprimée car non nécessaire pour la migration de base -END $$; - --- Mise à jour du type de rôle -DO $$ -BEGIN - -- Vérifier si la colonne role existe et la convertir - IF EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'users' AND column_name = 'role') THEN - -- Sauvegarder les valeurs existantes avant conversion - UPDATE users SET role = 'user' WHERE role IS NULL OR role = ''; - - -- Convertir vers le nouveau type (si ce n'est pas déjà fait) - BEGIN - ALTER TABLE users ALTER COLUMN role TYPE user_role USING role::user_role; - RAISE NOTICE 'Colonne role convertie vers user_role'; - EXCEPTION - WHEN OTHERS THEN - RAISE NOTICE 'Colonne role déjà au bon type ou erreur: %', SQLERRM; - END; - ELSE - ALTER TABLE users ADD COLUMN role user_role DEFAULT 'user' NOT NULL; - RAISE NOTICE 'Colonne role ajoutée avec type user_role'; - END IF; -END $$; - --- ================================================================ --- ÉTAPE 5: CRÉATION DE LA TABLE CONVERSATIONS --- ================================================================ - --- Création de la table conversations - --- Table conversations existe déjà dans la migration 001, pas besoin de la redéfinir - --- ================================================================ --- ÉTAPE 6: MISE À JOUR DE LA TABLE MESSAGES --- ================================================================ - --- Mise à jour de la table messages - --- Colonnes uuid supprimées car les tables utilisent déjà id avec UUID - --- Colonnes déjà cohérentes avec la migration de base - --- Colonne conversation_id existe déjà dans la migration de base - --- Colonnes avancées existent déjà dans la migration de base - --- ================================================================ --- ÉTAPE 7: CRÉATION DES TABLES COMPLÉMENTAIRES --- ================================================================ - --- Création des tables complémentaires - --- Table conversation_members existe déjà dans la migration 001, pas besoin de la redéfinir - --- Table message_reactions existe déjà dans la migration 002, pas besoin de la redéfinir - --- Table pour les mentions -CREATE TABLE IF NOT EXISTS message_mentions ( - id BIGSERIAL PRIMARY KEY, - message_id UUID NOT NULL, - mentioned_user_id UUID NOT NULL, - is_read BOOLEAN DEFAULT FALSE, - created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, - - CONSTRAINT message_mentions_message_fk FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, - CONSTRAINT message_mentions_user_fk FOREIGN KEY (mentioned_user_id) REFERENCES users(id) ON DELETE CASCADE, - CONSTRAINT message_mentions_unique UNIQUE (message_id, mentioned_user_id) -); - --- Table pour l'historique des messages -CREATE TABLE IF NOT EXISTS message_history ( - id BIGSERIAL PRIMARY KEY, - message_id UUID NOT NULL, - old_content TEXT NOT NULL, - edited_by UUID NOT NULL, - edited_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, - - CONSTRAINT message_history_message_fk FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, - CONSTRAINT message_history_user_fk FOREIGN KEY (edited_by) REFERENCES users(id) ON DELETE CASCADE -); - --- Table pour les sessions utilisateur -CREATE TABLE IF NOT EXISTS user_sessions ( - id BIGSERIAL PRIMARY KEY, - user_id UUID NOT NULL, - session_token VARCHAR(255) UNIQUE NOT NULL, - refresh_token VARCHAR(255) UNIQUE, - device_info TEXT, - ip_address INET, - user_agent TEXT, - is_active BOOLEAN DEFAULT TRUE, - expires_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, - last_activity TIMESTAMPTZ DEFAULT NOW() NOT NULL, - - CONSTRAINT user_sessions_user_fk FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - --- ================================================================ --- ÉTAPE 8: CRÉATION DES INDEX DE PERFORMANCE --- ================================================================ - --- Création des index de performance - --- Index pour users -CREATE INDEX IF NOT EXISTS idx_users_username_active -ON users(username) WHERE is_active = TRUE; - --- Index last_login supprimé car la colonne n'existe pas dans la migration de base - --- Index email_verified supprimé car la colonne is_verified n'existe pas dans la migration de base - --- Index last_activity supprimé car la colonne n'existe pas dans la migration de base - --- Index pour conversations -CREATE INDEX IF NOT EXISTS idx_conversations_type_public -ON conversations(conversation_type) WHERE is_private = FALSE; - -CREATE INDEX IF NOT EXISTS idx_conversations_owner_active -ON conversations(created_by); - --- Index pour messages (performance critique) -CREATE INDEX IF NOT EXISTS idx_messages_conversation_time -ON messages(conversation_id, created_at DESC); - -CREATE INDEX IF NOT EXISTS idx_messages_sender_time -ON messages(sender_id, created_at DESC); - -CREATE INDEX IF NOT EXISTS idx_messages_threads -ON messages(parent_message_id, created_at) WHERE parent_message_id IS NOT NULL; - -CREATE INDEX IF NOT EXISTS idx_messages_pinned -ON messages(conversation_id) WHERE is_pinned = TRUE; - --- Index pour réactions (utilise reaction_type au lieu d'emoji) -CREATE INDEX IF NOT EXISTS idx_reactions_message -ON message_reactions(message_id, reaction_type); - -CREATE INDEX IF NOT EXISTS idx_reactions_user -ON message_reactions(user_id, created_at DESC); - --- Index pour mentions -CREATE INDEX IF NOT EXISTS idx_mentions_user_unread -ON message_mentions(mentioned_user_id) WHERE is_read = FALSE; - --- Index pour sessions -CREATE INDEX IF NOT EXISTS idx_sessions_user_active -ON user_sessions(user_id) WHERE is_active = TRUE; - -CREATE INDEX IF NOT EXISTS idx_sessions_token -ON user_sessions(session_token); - --- ================================================================ --- ÉTAPE 9: CRÉATION DES FONCTIONS UTILITAIRES --- ================================================================ - --- Création des fonctions utilitaires - --- Fonction pour obtenir l'ID utilisateur courant (à implémenter côté app) -CREATE OR REPLACE FUNCTION current_user_id() -RETURNS BIGINT AS $$ -BEGIN - -- Cette fonction doit être implémentée côté application - -- Pour l'instant, elle retourne NULL - RETURN NULL; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- Fonction pour nettoyer les sessions expirées -CREATE OR REPLACE FUNCTION cleanup_expired_sessions() -RETURNS INTEGER AS $$ -DECLARE - deleted_count INTEGER; -BEGIN - DELETE FROM user_sessions - WHERE expires_at < NOW() OR (last_activity < NOW() - INTERVAL '30 days'); - - GET DIAGNOSTICS deleted_count = ROW_COUNT; - RETURN deleted_count; -END; -$$ LANGUAGE plpgsql; - --- Fonction trigger pour mettre à jour updated_at -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- ================================================================ --- ÉTAPE 10: CRÉATION DES TRIGGERS --- ================================================================ - --- Création des triggers - --- Triggers pour updated_at -DROP TRIGGER IF EXISTS update_users_updated_at ON users; -CREATE TRIGGER update_users_updated_at - BEFORE UPDATE ON users - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - -DROP TRIGGER IF EXISTS update_conversations_updated_at ON conversations; -CREATE TRIGGER update_conversations_updated_at - BEFORE UPDATE ON conversations - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - -DROP TRIGGER IF EXISTS update_messages_updated_at ON messages; -CREATE TRIGGER update_messages_updated_at - BEFORE UPDATE ON messages - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - --- ================================================================ --- ÉTAPE 11: VÉRIFICATION ET NETTOYAGE FINAL --- ================================================================ - --- Vérification finale - --- Nettoyer les fonctions obsolètes -DROP FUNCTION IF EXISTS cleanup_expired_sessions_secure(); -DROP FUNCTION IF EXISTS cleanup_old_audit_logs(); -DROP FUNCTION IF EXISTS cleanup_old_data_secure(); -DROP FUNCTION IF EXISTS handle_mentions_secure(); - --- Mettre à jour les statistiques -ANALYZE users; -ANALYZE conversations; -ANALYZE messages; -ANALYZE message_reactions; -ANALYZE message_mentions; -ANALYZE user_sessions; - --- Validation finale -DO $$ -DECLARE - user_count INTEGER; - message_count INTEGER; - conversation_count INTEGER; -BEGIN - SELECT COUNT(*) INTO user_count FROM users; - SELECT COUNT(*) INTO message_count FROM messages; - SELECT COUNT(*) INTO conversation_count FROM conversations; - - RAISE NOTICE '✅ Migration terminée avec succès:'; - RAISE NOTICE ' - Utilisateurs: %', user_count; - RAISE NOTICE ' - Messages: %', message_count; - RAISE NOTICE ' - Conversations: %', conversation_count; - - -- Warnings si problèmes détectés - IF user_count = 0 THEN - RAISE WARNING '⚠️ Aucun utilisateur trouvé après migration'; - END IF; - - IF message_count > 0 AND conversation_count = 0 THEN - RAISE WARNING '⚠️ Messages présents mais aucune conversation'; - END IF; -END $$; - --- Nettoyer les tables temporaires -DROP TABLE IF EXISTS temp_old_users; -DROP TABLE IF EXISTS temp_old_messages; - --- ================================================================ --- FINALISATION --- ================================================================ - --- Migration de production terminée avec succès -SELECT - schemaname, - tablename, - pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as size -FROM pg_tables -WHERE schemaname = 'public' -AND tablename IN ('users', 'conversations', 'messages', 'message_reactions', 'message_mentions', 'user_sessions') -ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC; \ No newline at end of file diff --git a/veza-chat-server/migrations/archive/003_enhanced_schema.sql b/veza-chat-server/migrations/archive/003_enhanced_schema.sql deleted file mode 100644 index 64148ddc8..000000000 --- a/veza-chat-server/migrations/archive/003_enhanced_schema.sql +++ /dev/null @@ -1,270 +0,0 @@ --- Migration 003: Schéma amélioré avec sécurité renforcée et séparation DM/salons - --- Extension pour UUID -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -CREATE EXTENSION IF NOT EXISTS pgcrypto; - --- ================================================ --- UTILISATEURS AVEC SÉCURITÉ RENFORCÉE --- ================================================ - -CREATE TABLE IF NOT EXISTS users_enhanced ( - id SERIAL PRIMARY KEY, - username VARCHAR(50) NOT NULL UNIQUE, - email VARCHAR(255) NOT NULL UNIQUE, - password_hash VARCHAR(255) NOT NULL, - - -- Rôles et permissions - role VARCHAR(20) NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'moderator', 'user', 'guest')), - - -- Statut et sécurité - is_active BOOLEAN NOT NULL DEFAULT true, - is_banned BOOLEAN NOT NULL DEFAULT false, - is_verified BOOLEAN NOT NULL DEFAULT false, - - -- Statut de présence - status VARCHAR(20) DEFAULT 'offline' CHECK (status IN ('online', 'away', 'busy', 'invisible', 'offline')), - status_message VARCHAR(100), - - -- Modération - reputation_score INTEGER DEFAULT 100, - - -- Métadonnées - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- ================================================ --- SALONS AVEC GESTION AVANCÉE --- ================================================ - -CREATE TABLE IF NOT EXISTS rooms_enhanced ( - id VARCHAR(100) PRIMARY KEY, - name VARCHAR(100) NOT NULL, - description TEXT, - - -- Propriétaire - owner_id INTEGER NOT NULL REFERENCES users_enhanced(id) ON DELETE CASCADE, - - -- Configuration - is_public BOOLEAN NOT NULL DEFAULT true, - is_archived BOOLEAN DEFAULT false, - max_members INTEGER DEFAULT 1000, - - -- Métadonnées - member_count INTEGER DEFAULT 0, - message_count INTEGER DEFAULT 0, - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- ================================================ --- MESSAGES UNIFIÉS --- ================================================ - -CREATE TABLE IF NOT EXISTS messages_enhanced ( - id BIGSERIAL PRIMARY KEY, - - -- Type et contenu - message_type VARCHAR(20) NOT NULL CHECK (message_type IN ('room_message', 'direct_message', 'system_message')), - content TEXT NOT NULL CHECK (LENGTH(content) <= 4000), - - -- Auteur - author_id INTEGER NOT NULL REFERENCES users_enhanced(id) ON DELETE CASCADE, - author_username VARCHAR(50) NOT NULL, - - -- Destination (exclusion mutuelle) - room_id VARCHAR(100) REFERENCES rooms_enhanced(id) ON DELETE CASCADE, - recipient_id INTEGER REFERENCES users_enhanced(id) ON DELETE CASCADE, - recipient_username VARCHAR(50), - - -- Threading - parent_message_id BIGINT REFERENCES messages_enhanced(id) ON DELETE SET NULL, - thread_count INTEGER DEFAULT 0, - - -- Statut - status VARCHAR(20) DEFAULT 'sent' CHECK (status IN ('sent', 'delivered', 'read', 'edited', 'deleted')), - is_pinned BOOLEAN DEFAULT false, - is_edited BOOLEAN DEFAULT false, - original_content TEXT, - - -- Modération - is_flagged BOOLEAN DEFAULT false, - moderation_notes TEXT, - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ, - - -- Contraintes logiques - CONSTRAINT message_destination_check CHECK ( - (message_type = 'room_message' AND room_id IS NOT NULL AND recipient_id IS NULL) OR - (message_type = 'direct_message' AND room_id IS NULL AND recipient_id IS NOT NULL) OR - (message_type = 'system_message') - ) -); - --- ================================================ --- RÉACTIONS AUX MESSAGES --- ================================================ - -CREATE TABLE IF NOT EXISTS message_reactions_enhanced ( - id BIGSERIAL PRIMARY KEY, - message_id BIGINT NOT NULL REFERENCES messages_enhanced(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users_enhanced(id) ON DELETE CASCADE, - emoji VARCHAR(100) NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE (message_id, user_id, emoji) -); - --- ================================================ --- MENTIONS DANS LES MESSAGES --- ================================================ - -CREATE TABLE IF NOT EXISTS message_mentions_enhanced ( - id BIGSERIAL PRIMARY KEY, - message_id BIGINT NOT NULL REFERENCES messages_enhanced(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users_enhanced(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - is_read BOOLEAN DEFAULT false, - - UNIQUE (message_id, user_id) -); - --- ================================================ --- MEMBRES DES SALONS --- ================================================ - -CREATE TABLE IF NOT EXISTS room_members_enhanced ( - room_id VARCHAR(100) NOT NULL REFERENCES rooms_enhanced(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users_enhanced(id) ON DELETE CASCADE, - - role VARCHAR(20) DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'moderator', 'member')), - joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_read_message_id BIGINT, - - PRIMARY KEY (room_id, user_id) -); - --- ================================================ --- BLOCAGES UTILISATEURS --- ================================================ - -CREATE TABLE IF NOT EXISTS user_blocks_enhanced ( - id BIGSERIAL PRIMARY KEY, - blocker_id INTEGER NOT NULL REFERENCES users_enhanced(id) ON DELETE CASCADE, - blocked_id INTEGER NOT NULL REFERENCES users_enhanced(id) ON DELETE CASCADE, - reason VARCHAR(500), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE (blocker_id, blocked_id), - CONSTRAINT no_self_block CHECK (blocker_id != blocked_id) -); - --- ================================================ --- SESSIONS SÉCURISÉES --- ================================================ - -CREATE TABLE IF NOT EXISTS user_sessions_enhanced ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id INTEGER NOT NULL REFERENCES users_enhanced(id) ON DELETE CASCADE, - - token_hash VARCHAR(128) NOT NULL UNIQUE, - ip_address INET, - user_agent TEXT, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_activity TIMESTAMPTZ NOT NULL DEFAULT NOW(), - expires_at TIMESTAMPTZ NOT NULL, - is_active BOOLEAN DEFAULT true -); - --- ================================================ --- LOGS DE SÉCURITÉ --- ================================================ - -CREATE TABLE IF NOT EXISTS security_events_enhanced ( - id BIGSERIAL PRIMARY KEY, - - event_type VARCHAR(50) NOT NULL, - severity VARCHAR(20) DEFAULT 'info' CHECK (severity IN ('debug', 'info', 'warning', 'error', 'critical')), - - user_id INTEGER REFERENCES users_enhanced(id) ON DELETE SET NULL, - ip_address INET, - user_agent TEXT, - - details JSONB DEFAULT '{}'::jsonb, - success BOOLEAN, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- ================================================ --- INDEX POUR PERFORMANCE --- ================================================ - --- Messages -CREATE INDEX IF NOT EXISTS idx_messages_room_enhanced ON messages_enhanced (room_id, created_at DESC) WHERE message_type = 'room_message' AND status != 'deleted'; -CREATE INDEX IF NOT EXISTS idx_messages_dm_enhanced ON messages_enhanced (author_id, recipient_id, created_at DESC) WHERE message_type = 'direct_message' AND status != 'deleted'; -CREATE INDEX IF NOT EXISTS idx_messages_dm_reverse_enhanced ON messages_enhanced (recipient_id, author_id, created_at DESC) WHERE message_type = 'direct_message' AND status != 'deleted'; - --- Réactions -CREATE INDEX IF NOT EXISTS idx_reactions_message_enhanced ON message_reactions_enhanced (message_id); -CREATE INDEX IF NOT EXISTS idx_reactions_user_enhanced ON message_reactions_enhanced (user_id); - --- Mentions -CREATE INDEX IF NOT EXISTS idx_mentions_user_enhanced ON message_mentions_enhanced (user_id, is_read); - --- Sessions -CREATE INDEX IF NOT EXISTS idx_sessions_user_enhanced ON user_sessions_enhanced (user_id, is_active); -CREATE INDEX IF NOT EXISTS idx_sessions_token_enhanced ON user_sessions_enhanced (token_hash); - --- ================================================ --- TRIGGERS --- ================================================ - --- Fonction pour mettre à jour updated_at -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- Appliquer aux tables principales -CREATE TRIGGER update_users_enhanced_updated_at - BEFORE UPDATE ON users_enhanced - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_rooms_enhanced_updated_at - BEFORE UPDATE ON rooms_enhanced - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - --- ================================================ --- FONCTIONS UTILITAIRES --- ================================================ - --- Fonction pour nettoyer les sessions expirées -CREATE OR REPLACE FUNCTION cleanup_expired_sessions() -RETURNS void AS $$ -BEGIN - DELETE FROM user_sessions_enhanced - WHERE expires_at < NOW() AND is_active = false; -END; -$$ LANGUAGE plpgsql; - --- ================================================ --- COMMENTAIRES --- ================================================ - -COMMENT ON TABLE users_enhanced IS 'Utilisateurs avec sécurité renforcée'; -COMMENT ON TABLE messages_enhanced IS 'Messages unifiés avec séparation logique DM/salons'; -COMMENT ON TABLE message_reactions_enhanced IS 'Réactions aux messages'; -COMMENT ON TABLE message_mentions_enhanced IS 'Mentions d''utilisateurs'; -COMMENT ON TABLE user_blocks_enhanced IS 'Blocages entre utilisateurs'; -COMMENT ON TABLE user_sessions_enhanced IS 'Sessions sécurisées'; -COMMENT ON TABLE security_events_enhanced IS 'Journal de sécurité'; \ No newline at end of file diff --git a/veza-chat-server/migrations/archive/003_enhanced_schema_fixed.sql b/veza-chat-server/migrations/archive/003_enhanced_schema_fixed.sql deleted file mode 100644 index 759be3f11..000000000 --- a/veza-chat-server/migrations/archive/003_enhanced_schema_fixed.sql +++ /dev/null @@ -1,130 +0,0 @@ --- Migration 003: Schéma amélioré - VERSION CORRIGÉE - -BEGIN; - --- Supprimer les fonctions qui pourraient être en conflit -DROP FUNCTION IF EXISTS cleanup_expired_sessions(); -DROP FUNCTION IF EXISTS cleanup_old_data(); - --- Supprimer les vues qui pourraient être en conflit -DROP VIEW IF EXISTS server_stats CASCADE; - --- Extensions nécessaires -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -CREATE EXTENSION IF NOT EXISTS "pg_trgm"; - --- Table des sessions utilisateur sécurisées -CREATE TABLE IF NOT EXISTS user_sessions_secure ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash VARCHAR(255) NOT NULL UNIQUE, - refresh_token_hash VARCHAR(255) UNIQUE, - device_info JSONB DEFAULT '{}', - ip_address INET NOT NULL, - user_agent TEXT, - is_active BOOLEAN DEFAULT true, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '7 days'), - last_used TIMESTAMPTZ DEFAULT NOW() -); - --- Table des événements de sécurité -CREATE TABLE IF NOT EXISTS security_events_secure ( - id BIGSERIAL PRIMARY KEY, - user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - event_type VARCHAR(50) NOT NULL, - severity VARCHAR(20) DEFAULT 'info' CHECK (severity IN ('critical', 'high', 'medium', 'low', 'info')), - description TEXT NOT NULL, - ip_address INET, - user_agent TEXT, - additional_data JSONB DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - resolved_at TIMESTAMPTZ, - resolved_by INTEGER REFERENCES users(id) ON DELETE SET NULL -); - --- Table des mentions -CREATE TABLE IF NOT EXISTS message_mentions_secure ( - id BIGSERIAL PRIMARY KEY, - message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - is_read BOOLEAN DEFAULT false, - read_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (message_id, user_id) -); - --- Table des blocages utilisateur -CREATE TABLE IF NOT EXISTS user_blocks_secure ( - id BIGSERIAL PRIMARY KEY, - blocker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - blocked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - reason VARCHAR(500), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (blocker_id, blocked_id), - CONSTRAINT no_self_block CHECK (blocker_id != blocked_id) -); - --- Index pour les performances -CREATE INDEX IF NOT EXISTS idx_sessions_secure_user ON user_sessions_secure (user_id, is_active); -CREATE INDEX IF NOT EXISTS idx_sessions_secure_expires ON user_sessions_secure (expires_at); -CREATE INDEX IF NOT EXISTS idx_security_events_user ON security_events_secure (user_id, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_mentions_secure_user ON message_mentions_secure (user_id, is_read); -CREATE INDEX IF NOT EXISTS idx_blocks_secure_blocker ON user_blocks_secure (blocker_id); - --- Fonction de nettoyage des sessions expirées (nom unique) -CREATE OR REPLACE FUNCTION cleanup_expired_sessions_secure() -RETURNS INTEGER AS $$ -DECLARE - deleted_count INTEGER; -BEGIN - DELETE FROM user_sessions_secure - WHERE expires_at < NOW() OR last_used < NOW() - INTERVAL '30 days'; - - GET DIAGNOSTICS deleted_count = ROW_COUNT; - RETURN deleted_count; -END; -$$ LANGUAGE plpgsql; - --- Fonction de nettoyage général (nom unique) -CREATE OR REPLACE FUNCTION cleanup_old_data_secure() -RETURNS void AS $$ -BEGIN - -- Supprimer les sessions expirées - PERFORM cleanup_expired_sessions_secure(); - - -- Nettoyer les événements de sécurité anciens - DELETE FROM security_events_secure - WHERE created_at < NOW() - INTERVAL '6 months' AND severity = 'info'; - - RAISE NOTICE 'Nettoyage terminé'; -END; -$$ LANGUAGE plpgsql; - --- Trigger pour les mentions automatiques -CREATE OR REPLACE FUNCTION handle_mentions_secure() -RETURNS TRIGGER AS $$ -BEGIN - -- Extraire les mentions @username du contenu - INSERT INTO message_mentions_secure (message_id, user_id) - SELECT NEW.id, u.id - FROM users u - WHERE NEW.content ~* ('@' || u.username || '\M') - AND u.id != NEW.from_user - ON CONFLICT (message_id, user_id) DO NOTHING; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS trigger_handle_mentions_secure ON messages; -CREATE TRIGGER trigger_handle_mentions_secure - AFTER INSERT ON messages - FOR EACH ROW EXECUTE FUNCTION handle_mentions_secure(); - --- Permissions -GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO veza; -GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO veza; -GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO veza; - -COMMIT; \ No newline at end of file diff --git a/veza-chat-server/migrations/archive/003_enhanced_schema_simple.sql b/veza-chat-server/migrations/archive/003_enhanced_schema_simple.sql deleted file mode 100644 index d199acff5..000000000 --- a/veza-chat-server/migrations/archive/003_enhanced_schema_simple.sql +++ /dev/null @@ -1,121 +0,0 @@ --- Migration 003: Schéma amélioré - VERSION SIMPLIFIÉE - -BEGIN; - --- Supprimer les fonctions qui pourraient être en conflit -DROP FUNCTION IF EXISTS cleanup_expired_sessions(); -DROP FUNCTION IF EXISTS cleanup_old_data(); - --- Extensions nécessaires -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- Table des sessions utilisateur sécurisées -CREATE TABLE IF NOT EXISTS user_sessions_secure ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash VARCHAR(255) NOT NULL UNIQUE, - refresh_token_hash VARCHAR(255) UNIQUE, - device_info JSONB DEFAULT '{}', - ip_address INET NOT NULL, - user_agent TEXT, - is_active BOOLEAN DEFAULT true, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '7 days'), - last_used TIMESTAMPTZ DEFAULT NOW() -); - --- Table des événements de sécurité -CREATE TABLE IF NOT EXISTS security_events_secure ( - id BIGSERIAL PRIMARY KEY, - user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - event_type VARCHAR(50) NOT NULL, - severity VARCHAR(20) DEFAULT 'info' CHECK (severity IN ('critical', 'high', 'medium', 'low', 'info')), - description TEXT NOT NULL, - ip_address INET, - user_agent TEXT, - additional_data JSONB DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - resolved_at TIMESTAMPTZ, - resolved_by INTEGER REFERENCES users(id) ON DELETE SET NULL -); - --- Table des mentions -CREATE TABLE IF NOT EXISTS message_mentions_secure ( - id BIGSERIAL PRIMARY KEY, - message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - is_read BOOLEAN DEFAULT false, - read_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (message_id, user_id) -); - --- Table des blocages utilisateur -CREATE TABLE IF NOT EXISTS user_blocks_secure ( - id BIGSERIAL PRIMARY KEY, - blocker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - blocked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - reason VARCHAR(500), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (blocker_id, blocked_id), - CONSTRAINT no_self_block CHECK (blocker_id != blocked_id) -); - --- Index pour les performances -CREATE INDEX IF NOT EXISTS idx_sessions_secure_user ON user_sessions_secure (user_id, is_active); -CREATE INDEX IF NOT EXISTS idx_sessions_secure_expires ON user_sessions_secure (expires_at); -CREATE INDEX IF NOT EXISTS idx_security_events_user ON security_events_secure (user_id, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_mentions_secure_user ON message_mentions_secure (user_id, is_read); -CREATE INDEX IF NOT EXISTS idx_blocks_secure_blocker ON user_blocks_secure (blocker_id); - --- Fonction de nettoyage des sessions expirées -CREATE OR REPLACE FUNCTION cleanup_expired_sessions_secure() -RETURNS INTEGER AS $$ -DECLARE - deleted_count INTEGER; -BEGIN - DELETE FROM user_sessions_secure - WHERE expires_at < NOW() OR last_used < NOW() - INTERVAL '30 days'; - - GET DIAGNOSTICS deleted_count = ROW_COUNT; - RETURN deleted_count; -END; -$$ LANGUAGE plpgsql; - --- Fonction de nettoyage général -CREATE OR REPLACE FUNCTION cleanup_old_data_secure() -RETURNS void AS $$ -BEGIN - -- Supprimer les sessions expirées - PERFORM cleanup_expired_sessions_secure(); - - -- Nettoyer les événements de sécurité anciens - DELETE FROM security_events_secure - WHERE created_at < NOW() - INTERVAL '6 months' AND severity = 'info'; - - RAISE NOTICE 'Nettoyage terminé'; -END; -$$ LANGUAGE plpgsql; - --- Trigger pour les mentions automatiques -CREATE OR REPLACE FUNCTION handle_mentions_secure() -RETURNS TRIGGER AS $$ -BEGIN - -- Extraire les mentions @username du contenu - INSERT INTO message_mentions_secure (message_id, user_id) - SELECT NEW.id, u.id - FROM users u - WHERE NEW.content ~* ('@' || u.username || '\M') - AND u.id != NEW.from_user - ON CONFLICT (message_id, user_id) DO NOTHING; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS trigger_handle_mentions_secure ON messages; -CREATE TRIGGER trigger_handle_mentions_secure - AFTER INSERT ON messages - FOR EACH ROW EXECUTE FUNCTION handle_mentions_secure(); - -COMMIT; \ No newline at end of file diff --git a/veza-chat-server/migrations/archive/004_corrective_fix.sql b/veza-chat-server/migrations/archive/004_corrective_fix.sql deleted file mode 100644 index 44b6a3c23..000000000 --- a/veza-chat-server/migrations/archive/004_corrective_fix.sql +++ /dev/null @@ -1,69 +0,0 @@ --- Migration 004: Correction et compatibilité avec le schéma existant - -BEGIN; - --- CORRECTIONS DES FONCTIONS EXISTANTES AVEC CONFLITS --- Supprimer les fonctions existantes qui ont des conflits de type de retour -DROP FUNCTION IF EXISTS cleanup_expired_sessions(); -DROP FUNCTION IF EXISTS cleanup_expired_sessions(integer); -DROP FUNCTION IF EXISTS cleanup_old_data(); -DROP FUNCTION IF EXISTS calculate_user_reputation(integer); - --- CORRECTIONS DE LA TABLE ROOMS -ALTER TABLE rooms ADD COLUMN IF NOT EXISTS creator_id INTEGER; -ALTER TABLE rooms ADD COLUMN IF NOT EXISTS max_members INTEGER DEFAULT 1000; -ALTER TABLE rooms ADD COLUMN IF NOT EXISTS description TEXT; - --- CORRECTIONS DE LA TABLE MESSAGES -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'messages' AND column_name = 'timestamp') THEN - ALTER TABLE messages RENAME COLUMN timestamp TO created_at; - END IF; -END $$; - -ALTER TABLE messages ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN DEFAULT false; -ALTER TABLE messages ADD COLUMN IF NOT EXISTS thread_count INTEGER DEFAULT 0; -ALTER TABLE messages ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'sent'; - --- AMÉLIORER LA TABLE USERS -ALTER TABLE users ADD COLUMN IF NOT EXISTS reputation_score INTEGER DEFAULT 100; -ALTER TABLE users ADD COLUMN IF NOT EXISTS is_banned BOOLEAN DEFAULT false; -ALTER TABLE users ADD COLUMN IF NOT EXISTS last_seen TIMESTAMPTZ DEFAULT NOW(); -ALTER TABLE users ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'offline'; - --- INDEX DE PERFORMANCE -CREATE INDEX IF NOT EXISTS idx_messages_pinned ON messages (is_pinned) WHERE is_pinned = true; - --- CORRECTIONS DES CONTRAINTES EN CONFLIT --- Supprimer les contraintes qui pourraient être en conflit -ALTER TABLE audit_logs DROP CONSTRAINT IF EXISTS audit_logs_pkey CASCADE; - --- CORRECTIONS DES VUES EN CONFLIT -DROP VIEW IF EXISTS server_stats CASCADE; -DROP VIEW IF EXISTS user_activity_stats CASCADE; - --- CORRECTIONS DES TRIGGERS EN CONFLIT -DROP TRIGGER IF EXISTS update_user_last_activity ON messages; -DROP TRIGGER IF EXISTS log_user_activity ON messages; - --- CORRECTION DES ERREURS DE SYNTAXE DANS LES COMMENTAIRES --- Nettoyer les commentaires qui causent des erreurs de syntaxe -DO $$ -BEGIN - -- Éviter les erreurs de commentaires avec apostrophes - PERFORM 1; -END $$; - --- MISE À JOUR DES DONNÉES -UPDATE messages SET status = 'sent' WHERE status IS NULL; -UPDATE users SET reputation_score = 100 WHERE reputation_score IS NULL; -UPDATE users SET status = 'offline' WHERE status IS NULL; - --- NETTOYAGE DES DOUBLONS POTENTIELS --- Supprimer les index doublons s'ils existent -DROP INDEX IF EXISTS idx_users_role; -DROP INDEX IF EXISTS idx_messages_created_at; - -COMMIT; \ No newline at end of file diff --git a/veza-chat-server/migrations/archive/004_corrective_migration.sql b/veza-chat-server/migrations/archive/004_corrective_migration.sql deleted file mode 100644 index 5374740e4..000000000 --- a/veza-chat-server/migrations/archive/004_corrective_migration.sql +++ /dev/null @@ -1,337 +0,0 @@ --- Migration 004: Correction et compatibilité avec le schéma existant --- Cette migration corrige les erreurs de la migration précédente - -BEGIN; - --- ================================================ --- CORRECTIONS DE LA TABLE ROOMS --- ================================================ - --- Ajouter les colonnes manquantes à la table rooms existante -ALTER TABLE rooms ADD COLUMN IF NOT EXISTS creator_id INTEGER; -ALTER TABLE rooms ADD COLUMN IF NOT EXISTS max_members INTEGER DEFAULT 1000; -ALTER TABLE rooms ADD COLUMN IF NOT EXISTS description TEXT; -ALTER TABLE rooms ADD COLUMN IF NOT EXISTS is_archived BOOLEAN DEFAULT false; - --- Ajouter les contraintes de clés étrangères -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.table_constraints - WHERE constraint_name = 'rooms_creator_id_fkey' - ) THEN - ALTER TABLE rooms ADD CONSTRAINT rooms_creator_id_fkey - FOREIGN KEY (creator_id) REFERENCES users(id) ON DELETE SET NULL; - END IF; -END $$; - --- Créer l'index manquant -CREATE INDEX IF NOT EXISTS idx_rooms_creator ON rooms (creator_id); - --- ================================================ --- CORRECTIONS DE LA TABLE MESSAGES --- ================================================ - --- Renommer la colonne timestamp vers created_at pour cohérence -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'messages' AND column_name = 'timestamp') - AND NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'messages' AND column_name = 'created_at') THEN - ALTER TABLE messages RENAME COLUMN timestamp TO created_at; - END IF; -END $$; - --- Ajouter des colonnes manquantes pour les nouvelles fonctionnalités -ALTER TABLE messages ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN DEFAULT false; -ALTER TABLE messages ADD COLUMN IF NOT EXISTS is_flagged BOOLEAN DEFAULT false; -ALTER TABLE messages ADD COLUMN IF NOT EXISTS moderation_notes TEXT; -ALTER TABLE messages ADD COLUMN IF NOT EXISTS thread_count INTEGER DEFAULT 0; -ALTER TABLE messages ADD COLUMN IF NOT EXISTS original_content TEXT; - --- Ajouter une colonne status si elle n'existe pas -ALTER TABLE messages ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'sent'; - --- Ajouter des index pour les nouvelles colonnes -CREATE INDEX IF NOT EXISTS idx_messages_pinned ON messages (is_pinned) WHERE is_pinned = true; -CREATE INDEX IF NOT EXISTS idx_messages_flagged ON messages (is_flagged) WHERE is_flagged = true; -CREATE INDEX IF NOT EXISTS idx_messages_status ON messages (status); -CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages (thread_count) WHERE thread_count > 0; - --- ================================================ --- AMÉLIORER LA TABLE USERS EXISTANTE --- ================================================ - --- Ajouter des colonnes pour la sécurité et la modération -ALTER TABLE users ADD COLUMN IF NOT EXISTS reputation_score INTEGER DEFAULT 100; -ALTER TABLE users ADD COLUMN IF NOT EXISTS is_banned BOOLEAN DEFAULT false; -ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_expires_at TIMESTAMPTZ; -ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_reason TEXT; -ALTER TABLE users ADD COLUMN IF NOT EXISTS warning_count INTEGER DEFAULT 0; -ALTER TABLE users ADD COLUMN IF NOT EXISTS mute_expires_at TIMESTAMPTZ; -ALTER TABLE users ADD COLUMN IF NOT EXISTS last_seen TIMESTAMPTZ DEFAULT NOW(); -ALTER TABLE users ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'offline'; -ALTER TABLE users ADD COLUMN IF NOT EXISTS status_message VARCHAR(100); - --- Ajouter des index pour les nouvelles colonnes users -CREATE INDEX IF NOT EXISTS idx_users_reputation ON users (reputation_score); -CREATE INDEX IF NOT EXISTS idx_users_banned ON users (is_banned) WHERE is_banned = true; -CREATE INDEX IF NOT EXISTS idx_users_status ON users (status); -CREATE INDEX IF NOT EXISTS idx_users_last_seen ON users (last_seen); - --- ================================================ --- CRÉER LES TABLES MANQUANTES --- ================================================ - --- Table des mentions (si elle n'existe pas) -CREATE TABLE IF NOT EXISTS message_mentions ( - id BIGSERIAL PRIMARY KEY, - message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - is_read BOOLEAN DEFAULT false, - - UNIQUE (message_id, user_id) -); - -CREATE INDEX IF NOT EXISTS idx_mentions_user ON message_mentions (user_id, is_read); -CREATE INDEX IF NOT EXISTS idx_mentions_message ON message_mentions (message_id); - --- Table des blocages utilisateurs (si elle n'existe pas) -CREATE TABLE IF NOT EXISTS user_blocks ( - id BIGSERIAL PRIMARY KEY, - blocker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - blocked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - reason VARCHAR(500), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE (blocker_id, blocked_id), - CONSTRAINT no_self_block CHECK (blocker_id != blocked_id) -); - -CREATE INDEX IF NOT EXISTS idx_blocks_blocker ON user_blocks (blocker_id); -CREATE INDEX IF NOT EXISTS idx_blocks_blocked ON user_blocks (blocked_id); - --- Table des logs de modération (si elle n'existe pas) -CREATE TABLE IF NOT EXISTS moderation_log ( - id BIGSERIAL PRIMARY KEY, - - moderator_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - target_type VARCHAR(20) NOT NULL CHECK (target_type IN ('user', 'message', 'room')), - target_id TEXT NOT NULL, - action VARCHAR(50) NOT NULL, - - reason TEXT, - details JSONB DEFAULT '{}'::jsonb, - duration INTERVAL, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - ip_address INET -); - -CREATE INDEX IF NOT EXISTS idx_moderation_log_moderator ON moderation_log (moderator_id, created_at); -CREATE INDEX IF NOT EXISTS idx_moderation_log_target ON moderation_log (target_type, target_id); -CREATE INDEX IF NOT EXISTS idx_moderation_log_action ON moderation_log (action, created_at); - --- ================================================ --- VUES CORRIGÉES AVEC LES BONS NOMS DE COLONNES --- ================================================ - --- Vue des statistiques serveur (corrigée) -DROP VIEW IF EXISTS server_stats; -CREATE OR REPLACE VIEW server_stats AS -SELECT - 'total_users'::text as metric, - COUNT(*)::bigint as value -FROM users -WHERE id IS NOT NULL - -UNION ALL - -SELECT - 'active_users'::text as metric, - COUNT(*)::bigint as value -FROM users -WHERE last_seen > NOW() - INTERVAL '1 hour' - -UNION ALL - -SELECT - 'total_rooms'::text as metric, - COUNT(*)::bigint as value -FROM rooms - -UNION ALL - -SELECT - 'total_messages'::text as metric, - COUNT(*)::bigint as value -FROM messages - -UNION ALL - -SELECT - 'messages_today'::text as metric, - COUNT(*)::bigint as value -FROM messages -WHERE created_at >= CURRENT_DATE; - --- ================================================ --- FONCTIONS UTILITAIRES CORRIGÉES --- ================================================ - --- Fonction pour calculer la réputation (corrigée) -CREATE OR REPLACE FUNCTION calculate_user_reputation(user_id_param INTEGER) -RETURNS INTEGER AS $$ -DECLARE - base_score INTEGER := 100; - warnings INTEGER := 0; - bans INTEGER := 0; - recent_messages INTEGER := 0; -BEGIN - -- Compter les avertissements - SELECT COALESCE(warning_count, 0) INTO warnings - FROM users WHERE id = user_id_param; - - -- Compter les messages récents (bonus) - SELECT COUNT(*) INTO recent_messages - FROM messages - WHERE from_user = user_id_param - AND created_at > NOW() - INTERVAL '30 days'; - - -- Calculer le score final - RETURN GREATEST(0, base_score - (warnings * 5) + (recent_messages / 10)); -END; -$$ LANGUAGE plpgsql; - --- Fonction de nettoyage des données anciennes (corrigée) -CREATE OR REPLACE FUNCTION cleanup_old_data() -RETURNS void AS $$ -BEGIN - -- Supprimer les sessions expirées anciennes - DELETE FROM user_sessions - WHERE created_at < NOW() - INTERVAL '30 days'; - - -- Marquer les anciens messages comme archivés (soft delete) - UPDATE messages - SET status = 'archived' - WHERE created_at < NOW() - INTERVAL '1 year' - AND status = 'sent'; - - -- Nettoyer les logs de modération anciens - DELETE FROM moderation_log - WHERE created_at < NOW() - INTERVAL '6 months'; - - RAISE NOTICE 'Nettoyage des données anciennes terminé'; -END; -$$ LANGUAGE plpgsql; - --- ================================================ --- TRIGGERS POUR MAINTENIR LA COHÉRENCE --- ================================================ - --- Trigger pour mettre à jour last_seen automatiquement -CREATE OR REPLACE FUNCTION update_user_last_seen() -RETURNS TRIGGER AS $$ -BEGIN - UPDATE users SET last_seen = NOW() WHERE id = NEW.from_user; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS trigger_update_last_seen ON messages; -CREATE TRIGGER trigger_update_last_seen - AFTER INSERT ON messages - FOR EACH ROW EXECUTE FUNCTION update_user_last_seen(); - --- Trigger pour compter les threads -CREATE OR REPLACE FUNCTION update_thread_count() -RETURNS TRIGGER AS $$ -BEGIN - IF NEW.reply_to_id IS NOT NULL THEN - UPDATE messages - SET thread_count = thread_count + 1 - WHERE id = NEW.reply_to_id; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS trigger_thread_count ON messages; -CREATE TRIGGER trigger_thread_count - AFTER INSERT ON messages - FOR EACH ROW EXECUTE FUNCTION update_thread_count(); - --- ================================================ --- CONTRAINTES DE SÉCURITÉ SUPPLÉMENTAIRES --- ================================================ - --- Limiter la longueur des messages épinglés -ALTER TABLE messages ADD CONSTRAINT chk_pinned_content_reasonable -CHECK (NOT is_pinned OR LENGTH(content) <= 500); - --- Limiter le nombre de réactions par utilisateur et message -ALTER TABLE message_reactions ADD CONSTRAINT chk_emoji_reasonable -CHECK (LENGTH(emoji) <= 10); - --- Vérifier que les statuts utilisateur sont valides -ALTER TABLE users ADD CONSTRAINT chk_user_status_valid -CHECK (status IN ('online', 'away', 'busy', 'invisible', 'offline')); - --- ================================================ --- DONNÉES DE TEST ET INITIALISATION --- ================================================ - --- Mettre à jour les données existantes pour la compatibilité -UPDATE messages SET status = 'sent' WHERE status IS NULL; -UPDATE users SET reputation_score = 100 WHERE reputation_score IS NULL; -UPDATE users SET status = 'offline' WHERE status IS NULL; - --- Créer un salon général s'il n'existe pas -INSERT INTO rooms (name, is_private, description, creator_id) -SELECT 'général', false, 'Salon de discussion générale', - (SELECT id FROM users ORDER BY id LIMIT 1) -WHERE NOT EXISTS (SELECT 1 FROM rooms WHERE name = 'général'); - --- ================================================ --- COMMENTAIRES POUR DOCUMENTATION --- ================================================ - -COMMENT ON TABLE message_mentions IS 'Mentions d''utilisateurs dans les messages'; -COMMENT ON TABLE user_blocks IS 'Blocages entre utilisateurs pour empêcher les DM'; -COMMENT ON TABLE moderation_log IS 'Journal des actions de modération'; -COMMENT ON VIEW server_stats IS 'Statistiques temps réel du serveur'; - --- ================================================ --- PERMISSIONS ET SÉCURITÉ --- ================================================ - --- Accorder les permissions nécessaires à l'utilisateur veza -GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO veza; -GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO veza; -GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO veza; - -COMMIT; - --- ================================================ --- VÉRIFICATIONS POST-MIGRATION --- ================================================ - --- Vérifier que les colonnes essentielles existent -DO $$ -BEGIN - -- Vérifier messages.created_at - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'messages' AND column_name = 'created_at') THEN - RAISE EXCEPTION 'Colonne messages.created_at manquante après migration'; - END IF; - - -- Vérifier rooms.creator_id - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'rooms' AND column_name = 'creator_id') THEN - RAISE EXCEPTION 'Colonne rooms.creator_id manquante après migration'; - END IF; - - RAISE NOTICE 'Vérifications post-migration réussies'; -END $$; \ No newline at end of file diff --git a/veza-chat-server/migrations/archive/999_cleanup_production_ready.sql b/veza-chat-server/migrations/archive/999_cleanup_production_ready.sql deleted file mode 100644 index 62a1c2b9d..000000000 --- a/veza-chat-server/migrations/archive/999_cleanup_production_ready.sql +++ /dev/null @@ -1,386 +0,0 @@ --- Migration de nettoyage et préparation pour production --- Cette migration supprime toutes les tables redondantes et optimise la base --- ⚠️ ATTENTION: Cette migration est destructive, assurez-vous d'avoir une sauvegarde - --- ================================================================ --- ÉTAPE 1: MIGRATION DES DONNÉES EXISTANTES --- ================================================================ - --- Sauvegarde temporaire des données importantes -CREATE TEMP TABLE temp_old_users AS -SELECT id, username, email, created_at -FROM users -WHERE EXISTS (SELECT 1 FROM users); - -CREATE TEMP TABLE temp_old_messages AS -SELECT id, from_user, to_user, room, content, created_at, message_type -FROM messages -WHERE EXISTS (SELECT 1 FROM messages); - --- ================================================================ --- ÉTAPE 2: SUPPRESSION DES TABLES REDONDANTES --- ================================================================ - --- Supprimer toutes les tables dupliquées (_enhanced, _secure, etc.) -DROP TABLE IF EXISTS users_enhanced CASCADE; -DROP TABLE IF EXISTS users_backup CASCADE; -DROP TABLE IF EXISTS rooms_enhanced CASCADE; -DROP TABLE IF EXISTS messages_enhanced CASCADE; -DROP TABLE IF EXISTS message_mentions_enhanced CASCADE; -DROP TABLE IF EXISTS message_mentions_secure CASCADE; -DROP TABLE IF EXISTS message_reactions_enhanced CASCADE; -DROP TABLE IF EXISTS room_members_enhanced CASCADE; -DROP TABLE IF EXISTS user_sessions_enhanced CASCADE; -DROP TABLE IF EXISTS user_sessions_secure CASCADE; -DROP TABLE IF EXISTS user_blocks_enhanced CASCADE; -DROP TABLE IF EXISTS user_blocks_secure CASCADE; -DROP TABLE IF EXISTS security_events_enhanced CASCADE; -DROP TABLE IF EXISTS security_events_secure CASCADE; - --- Supprimer les anciennes tables métier mal conçues -DROP TABLE IF EXISTS offers CASCADE; -DROP TABLE IF EXISTS listings CASCADE; -DROP TABLE IF EXISTS categories CASCADE; -DROP TABLE IF EXISTS products CASCADE; -DROP TABLE IF EXISTS user_products CASCADE; -DROP TABLE IF EXISTS internal_documents CASCADE; -DROP TABLE IF EXISTS shared_ressources CASCADE; -DROP TABLE IF EXISTS shared_ressource_tags CASCADE; -DROP TABLE IF EXISTS ressource_tags CASCADE; -DROP TABLE IF EXISTS tracks CASCADE; -DROP TABLE IF EXISTS sanctions CASCADE; -- Remplacée par moderation_actions -DROP TABLE IF EXISTS refresh_tokens CASCADE; -- Intégré dans user_sessions - --- Supprimer les anciennes tables de base -DROP TABLE IF EXISTS rooms CASCADE; -DROP TABLE IF EXISTS room_members CASCADE; -DROP TABLE IF EXISTS user_sessions CASCADE; -DROP TABLE IF EXISTS user_blocks CASCADE; -DROP TABLE IF EXISTS files CASCADE; -- Sera recréée avec la nouvelle structure - --- ================================================================ --- ÉTAPE 3: NETTOYAGE DES FONCTIONS OBSOLÈTES --- ================================================================ - -DROP FUNCTION IF EXISTS cleanup_expired_sessions_secure(); -DROP FUNCTION IF EXISTS cleanup_old_audit_logs(); -DROP FUNCTION IF EXISTS cleanup_old_data_secure(); -DROP FUNCTION IF EXISTS handle_mentions_secure(); - --- ================================================================ --- ÉTAPE 4: APPLICATIONS DES NOUVELLES CONTRAINTES --- ================================================================ - --- Mise à jour de la table users existante avec nouvelles contraintes -ALTER TABLE users DROP CONSTRAINT IF EXISTS users_username_key; -ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key; - --- Ajouter UUID si pas déjà présent -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'users' AND column_name = 'uuid') THEN - ALTER TABLE users ADD COLUMN uuid UUID DEFAULT uuid_generate_v4(); - ALTER TABLE users ADD CONSTRAINT users_uuid_unique UNIQUE (uuid); - END IF; -END $$; - --- Ajouter les nouvelles colonnes de sécurité -DO $$ -BEGIN - -- Colonnes de sécurité 2FA - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'users' AND column_name = 'two_factor_enabled') THEN - ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN DEFAULT FALSE; - ALTER TABLE users ADD COLUMN two_factor_secret VARCHAR(32); - ALTER TABLE users ADD COLUMN password_reset_token VARCHAR(100); - ALTER TABLE users ADD COLUMN password_reset_expires TIMESTAMPTZ; - ALTER TABLE users ADD COLUMN email_verification_token VARCHAR(100); - END IF; - - -- Colonnes de profil - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'users' AND column_name = 'display_name') THEN - ALTER TABLE users ADD COLUMN display_name VARCHAR(100); - ALTER TABLE users ADD COLUMN avatar_url TEXT; - ALTER TABLE users ADD COLUMN bio TEXT CHECK (LENGTH(bio) <= 500); - END IF; - - -- Colonnes de métadonnées - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'users' AND column_name = 'last_login') THEN - ALTER TABLE users ADD COLUMN last_login TIMESTAMPTZ; - ALTER TABLE users ADD COLUMN last_activity TIMESTAMPTZ DEFAULT NOW(); - ALTER TABLE users ADD COLUMN updated_at TIMESTAMPTZ DEFAULT NOW(); - END IF; - - -- Colonnes de permissions - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'users' AND column_name = 'is_verified') THEN - ALTER TABLE users ADD COLUMN is_verified BOOLEAN DEFAULT FALSE; - ALTER TABLE users ADD COLUMN is_active BOOLEAN DEFAULT TRUE; - END IF; -END $$; - --- Mise à jour du type de rôle si existant -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'users' AND column_name = 'role') THEN - -- Convertir l'ancien système de rôles - UPDATE users SET role = 'user' WHERE role IS NULL OR role = ''; - ELSE - ALTER TABLE users ADD COLUMN role user_role DEFAULT 'user' NOT NULL; - END IF; -END $$; - --- ================================================================ --- ÉTAPE 5: OPTIMISATION DE LA TABLE MESSAGES --- ================================================================ - --- Ajouter UUID aux messages si pas présent -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'messages' AND column_name = 'uuid') THEN - ALTER TABLE messages ADD COLUMN uuid UUID DEFAULT uuid_generate_v4(); - ALTER TABLE messages ADD CONSTRAINT messages_uuid_unique UNIQUE (uuid); - END IF; -END $$; - --- Renommer les colonnes pour cohérence -DO $$ -BEGIN - -- Renommer from_user en author_id - IF EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'messages' AND column_name = 'from_user') THEN - ALTER TABLE messages RENAME COLUMN from_user TO author_id; - END IF; - - -- Ajouter conversation_id basé sur room/to_user - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'messages' AND column_name = 'conversation_id') THEN - ALTER TABLE messages ADD COLUMN conversation_id BIGINT; - - -- Mise à jour temporaire: créer un ID de conversation basé sur room ou DM - UPDATE messages SET conversation_id = CASE - WHEN room IS NOT NULL THEN ( - SELECT id FROM conversations - WHERE type = 'public_room' AND name = room - LIMIT 1 - ) - WHEN to_user IS NOT NULL THEN ( - SELECT id FROM conversations - WHERE type = 'direct_message' - AND ( - (owner_id = author_id AND id IN ( - SELECT conversation_id FROM conversation_members - WHERE user_id = to_user - )) OR - (owner_id = to_user AND id IN ( - SELECT conversation_id FROM conversation_members - WHERE user_id = author_id - )) - ) - LIMIT 1 - ) - ELSE 1 -- Conversation par défaut - END; - END IF; -END $$; - --- Ajouter les nouvelles colonnes pour fonctionnalités avancées -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'messages' AND column_name = 'parent_message_id') THEN - ALTER TABLE messages ADD COLUMN parent_message_id BIGINT REFERENCES messages(id) ON DELETE SET NULL; - ALTER TABLE messages ADD COLUMN thread_count INTEGER DEFAULT 0; - ALTER TABLE messages ADD COLUMN status message_status DEFAULT 'sent' NOT NULL; - ALTER TABLE messages ADD COLUMN is_edited BOOLEAN DEFAULT FALSE; - ALTER TABLE messages ADD COLUMN edit_count INTEGER DEFAULT 0; - ALTER TABLE messages ADD COLUMN metadata JSONB DEFAULT '{}'; - ALTER TABLE messages ADD COLUMN updated_at TIMESTAMPTZ DEFAULT NOW(); - ALTER TABLE messages ADD COLUMN edited_at TIMESTAMPTZ; - END IF; -END $$; - --- ================================================================ --- ÉTAPE 6: CRÉATION DES INDEX OPTIMISÉS --- ================================================================ - --- Index pour users -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_username_active -ON users(username) WHERE is_active = TRUE; - -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email_verified -ON users(email) WHERE is_verified = TRUE; - -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_last_activity -ON users(last_activity DESC) WHERE is_active = TRUE; - --- Index pour conversations -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_conversations_type_public -ON conversations(type) WHERE is_public = TRUE; - -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_conversations_owner_active -ON conversations(owner_id) WHERE NOT is_archived; - --- Index pour messages (performance critique) -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_messages_conversation_time -ON messages(conversation_id, created_at DESC); - -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_messages_author_time -ON messages(author_id, created_at DESC); - -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_messages_threads -ON messages(parent_message_id, created_at) WHERE parent_message_id IS NOT NULL; - -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_messages_pinned -ON messages(conversation_id) WHERE is_pinned = TRUE; - --- Index pour recherche full-text -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_messages_content_search -ON messages USING gin(to_tsvector('french', content)); - --- Index pour réactions -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_reactions_message -ON message_reactions(message_id, emoji); - -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_reactions_user -ON message_reactions(user_id, created_at DESC); - --- Index pour mentions -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_mentions_user_unread -ON message_mentions(mentioned_user_id) WHERE is_read = FALSE; - --- Index pour audit et sécurité -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_audit_user_action -ON audit_logs(user_id, action, created_at DESC); - -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_security_severity_time -ON security_events(severity, created_at DESC); - --- ================================================================ --- ÉTAPE 7: STATISTIQUES ET MAINTENANCE --- ================================================================ - --- Mettre à jour les statistiques des tables -ANALYZE users; -ANALYZE conversations; -ANALYZE messages; -ANALYZE message_reactions; -ANALYZE message_mentions; -ANALYZE audit_logs; -ANALYZE security_events; - --- Nettoyer l'espace inutilisé -VACUUM (ANALYZE, FREEZE) users; -VACUUM (ANALYZE, FREEZE) conversations; -VACUUM (ANALYZE, FREEZE) messages; - --- ================================================================ --- ÉTAPE 8: CONFIGURATION FINALE DE SÉCURITÉ --- ================================================================ - --- Activer Row Level Security sur les nouvelles tables -ALTER TABLE messages ENABLE ROW LEVEL SECURITY; -ALTER TABLE user_sessions ENABLE ROW LEVEL SECURITY; -ALTER TABLE message_mentions ENABLE ROW LEVEL SECURITY; -ALTER TABLE conversation_members ENABLE ROW LEVEL SECURITY; - --- Créer des politiques RLS basiques -CREATE POLICY messages_access_policy ON messages - FOR ALL TO PUBLIC - USING ( - author_id = current_user_id() OR - conversation_id IN ( - SELECT conversation_id FROM conversation_members - WHERE user_id = current_user_id() - ) - ); - -CREATE POLICY sessions_owner_policy ON user_sessions - FOR ALL TO PUBLIC - USING (user_id = current_user_id()); - --- ================================================================ --- ÉTAPE 9: JOURNALISATION ET VALIDATION --- ================================================================ - --- Insérer un événement d'audit pour la migration -INSERT INTO audit_logs (action, details, created_at) -VALUES ( - 'system_migration', - jsonb_build_object( - 'migration', 'cleanup_production_ready', - 'version', '0.2.0', - 'tables_dropped', ARRAY[ - 'users_enhanced', 'messages_enhanced', 'rooms_enhanced', - 'security_events_enhanced', 'user_sessions_secure' - ], - 'optimization', 'indexes_created_and_statistics_updated' - ), - NOW() -); - --- Vérification de l'intégrité des données -DO $$ -DECLARE - user_count INTEGER; - message_count INTEGER; - conversation_count INTEGER; -BEGIN - SELECT COUNT(*) INTO user_count FROM users; - SELECT COUNT(*) INTO message_count FROM messages; - SELECT COUNT(*) INTO conversation_count FROM conversations; - - RAISE NOTICE 'Migration terminée avec succès:'; - RAISE NOTICE '- Utilisateurs: %', user_count; - RAISE NOTICE '- Messages: %', message_count; - RAISE NOTICE '- Conversations: %', conversation_count; - - -- Validation basique - IF user_count = 0 THEN - RAISE WARNING 'Aucun utilisateur trouvé après migration'; - END IF; - - IF message_count > 0 AND conversation_count = 0 THEN - RAISE WARNING 'Messages présents mais aucune conversation'; - END IF; -END $$; - --- ================================================================ --- ÉTAPE 10: COMMENTAIRES ET DOCUMENTATION --- ================================================================ - -COMMENT ON TABLE users IS 'Table utilisateurs unifiée - Production Ready v0.2.0'; -COMMENT ON TABLE conversations IS 'Conversations unifiées (DM + Rooms) avec types stricts'; -COMMENT ON TABLE messages IS 'Messages avec support threads, épinglage et métadonnées'; -COMMENT ON TABLE message_reactions IS 'Réactions emoji avec contraintes unicité'; -COMMENT ON TABLE message_mentions IS 'Mentions @utilisateur avec notifications'; -COMMENT ON TABLE message_history IS 'Historique des modifications de messages'; -COMMENT ON TABLE files IS 'Fichiers uploadés avec validation de sécurité'; -COMMENT ON TABLE audit_logs IS 'Audit trail complet de toutes les actions'; -COMMENT ON TABLE security_events IS 'Journal des événements de sécurité'; -COMMENT ON TABLE moderation_actions IS 'Actions de modération avec appeals'; - --- ================================================================ --- FIN DE LA MIGRATION --- ================================================================ - -RAISE NOTICE '🎉 Migration de nettoyage terminée avec succès!'; -RAISE NOTICE '📊 Base de données optimisée pour la production'; -RAISE NOTICE '🔒 Sécurité renforcée avec RLS activée'; -RAISE NOTICE '⚡ Index de performance créés'; -RAISE NOTICE '🧹 Tables redondantes supprimées'; - --- Nettoyer les tables temporaires -DROP TABLE IF EXISTS temp_old_users; -DROP TABLE IF EXISTS temp_old_messages; - --- Optimisation finale -VACUUM FULL; - --- Mettre à jour les statistiques -ANALYZE; \ No newline at end of file diff --git a/veza-chat-server/package.json b/veza-chat-server/package.json deleted file mode 100644 index 3c5c4c827..000000000 --- a/veza-chat-server/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "veza-chat-server", - "private": true, - "scripts": { - "build": "cargo build --verbose", - "test": "cargo test --verbose", - "lint": "cargo fmt --all -- --check" - } -} diff --git a/veza-chat-server/proto/chat/chat.proto b/veza-chat-server/proto/chat/chat.proto deleted file mode 100644 index 134c1e2a0..000000000 --- a/veza-chat-server/proto/chat/chat.proto +++ /dev/null @@ -1,320 +0,0 @@ -syntax = "proto3"; - -package veza.chat; - -option go_package = "veza-backend-api/proto/chat"; - -import "common/auth.proto"; - -// Service Chat pour communication avec le module Rust -service ChatService { - // Gestion des salles - rpc CreateRoom(CreateRoomRequest) returns (CreateRoomResponse); - rpc JoinRoom(JoinRoomRequest) returns (JoinRoomResponse); - rpc LeaveRoom(LeaveRoomRequest) returns (LeaveRoomResponse); - rpc GetRoomInfo(GetRoomInfoRequest) returns (Room); - rpc ListRooms(ListRoomsRequest) returns (ListRoomsResponse); - - // Gestion des messages - rpc SendMessage(SendMessageRequest) returns (SendMessageResponse); - rpc GetMessageHistory(GetMessageHistoryRequest) returns (GetMessageHistoryResponse); - rpc DeleteMessage(DeleteMessageRequest) returns (DeleteMessageResponse); - - // Messages directs - rpc SendDirectMessage(SendDirectMessageRequest) returns (SendDirectMessageResponse); - rpc GetDirectMessages(GetDirectMessagesRequest) returns (GetDirectMessagesResponse); - - // Modération - rpc MuteUser(MuteUserRequest) returns (MuteUserResponse); - rpc BanUser(BanUserRequest) returns (BanUserResponse); - rpc ModerateMessage(ModerateMessageRequest) returns (ModerateMessageResponse); - - // Statistiques temps réel - rpc GetRoomStats(GetRoomStatsRequest) returns (RoomStats); - rpc GetUserActivity(GetUserActivityRequest) returns (UserActivity); -} - -// Messages pour les salles -message CreateRoomRequest { - string name = 1; - string description = 2; - RoomType type = 3; - RoomVisibility visibility = 4; - int64 created_by = 5; - string auth_token = 6; -} - -message CreateRoomResponse { - Room room = 1; - string error = 2; -} - -message JoinRoomRequest { - string room_id = 1; - int64 user_id = 2; - string auth_token = 3; -} - -message JoinRoomResponse { - bool success = 1; - RoomMember member = 2; - string error = 3; -} - -message LeaveRoomRequest { - string room_id = 1; - int64 user_id = 2; - string auth_token = 3; -} - -message LeaveRoomResponse { - bool success = 1; - string error = 2; -} - -message GetRoomInfoRequest { - string room_id = 1; - string auth_token = 2; -} - -message ListRoomsRequest { - RoomVisibility visibility = 1; - int32 page = 2; - int32 limit = 3; - string auth_token = 4; -} - -message ListRoomsResponse { - repeated Room rooms = 1; - int32 total = 2; - string error = 3; -} - -// Messages pour les messages -message SendMessageRequest { - string room_id = 1; - int64 sender_id = 2; - string content = 3; - MessageType type = 4; - string auth_token = 5; - string reply_to = 6; // ID du message parent -} - -message SendMessageResponse { - Message message = 1; - string error = 2; -} - -message GetMessageHistoryRequest { - string room_id = 1; - int32 limit = 2; - string before_id = 3; // pagination - string auth_token = 4; -} - -message GetMessageHistoryResponse { - repeated Message messages = 1; - bool has_more = 2; - string error = 3; -} - -message DeleteMessageRequest { - string message_id = 1; - int64 user_id = 2; - string auth_token = 3; -} - -message DeleteMessageResponse { - bool success = 1; - string error = 2; -} - -// Messages directs -message SendDirectMessageRequest { - int64 sender_id = 1; - int64 recipient_id = 2; - string content = 3; - MessageType type = 4; - string auth_token = 5; -} - -message SendDirectMessageResponse { - DirectMessage message = 1; - string error = 2; -} - -message GetDirectMessagesRequest { - int64 user_id = 1; - int64 other_user_id = 2; - int32 limit = 3; - string before_id = 4; - string auth_token = 5; -} - -message GetDirectMessagesResponse { - repeated DirectMessage messages = 1; - bool has_more = 2; - string error = 3; -} - -// Modération -message MuteUserRequest { - string room_id = 1; - int64 user_id = 2; - int64 moderator_id = 3; - int64 duration_seconds = 4; - string reason = 5; - string auth_token = 6; -} - -message MuteUserResponse { - bool success = 1; - string error = 2; -} - -message BanUserRequest { - string room_id = 1; - int64 user_id = 2; - int64 moderator_id = 3; - string reason = 4; - string auth_token = 5; -} - -message BanUserResponse { - bool success = 1; - string error = 2; -} - -message ModerateMessageRequest { - string message_id = 1; - int64 moderator_id = 2; - ModerationAction action = 3; - string reason = 4; - string auth_token = 5; -} - -message ModerateMessageResponse { - bool success = 1; - string error = 2; -} - -// Statistiques -message GetRoomStatsRequest { - string room_id = 1; - string auth_token = 2; -} - -message GetUserActivityRequest { - int64 user_id = 1; - string auth_token = 2; -} - -// Types de données -message Room { - string id = 1; - string name = 2; - string description = 3; - RoomType type = 4; - RoomVisibility visibility = 5; - int64 created_by = 6; - int64 created_at = 7; - int32 member_count = 8; - int32 online_count = 9; - bool is_active = 10; -} - -message RoomMember { - int64 user_id = 1; - string username = 2; - RoomRole role = 3; - int64 joined_at = 4; - bool is_online = 5; - int64 last_seen = 6; -} - -message Message { - string id = 1; - string room_id = 2; - int64 sender_id = 3; - string sender_username = 4; - string content = 5; - MessageType type = 6; - int64 created_at = 7; - int64 updated_at = 8; - bool is_edited = 9; - bool is_deleted = 10; - string reply_to = 11; - repeated MessageReaction reactions = 12; -} - -message DirectMessage { - string id = 1; - int64 sender_id = 2; - int64 recipient_id = 3; - string content = 4; - MessageType type = 5; - int64 created_at = 6; - bool is_read = 7; - bool is_deleted = 8; -} - -message MessageReaction { - string emoji = 1; - repeated int64 user_ids = 2; - int32 count = 3; -} - -message RoomStats { - string room_id = 1; - int32 total_members = 2; - int32 online_members = 3; - int32 messages_today = 4; - int32 total_messages = 5; - repeated int64 active_users = 6; -} - -message UserActivity { - int64 user_id = 1; - int32 rooms_joined = 2; - int32 messages_sent = 3; - int64 last_activity = 4; - bool is_online = 5; - string current_status = 6; -} - -// Énumérations -enum RoomType { - PUBLIC = 0; - PRIVATE = 1; - DIRECT = 2; - PREMIUM = 3; -} - -enum RoomVisibility { - OPEN = 0; - INVITE_ONLY = 1; - HIDDEN = 2; -} - -enum RoomRole { - MEMBER = 0; - MODERATOR = 1; - ADMIN = 2; - OWNER = 3; -} - -enum MessageType { - TEXT = 0; - IMAGE = 1; - FILE = 2; - AUDIO = 3; - VIDEO = 4; - SYSTEM = 5; -} - -enum ModerationAction { - WARN = 0; - DELETE = 1; - EDIT = 2; - FLAG = 3; -} \ No newline at end of file diff --git a/veza-chat-server/proto/common/auth.proto b/veza-chat-server/proto/common/auth.proto deleted file mode 100644 index 1f8ae114a..000000000 --- a/veza-chat-server/proto/common/auth.proto +++ /dev/null @@ -1,89 +0,0 @@ -syntax = "proto3"; - -package veza.common.auth; - -option go_package = "veza-backend-api/proto/common/auth"; - -// Service d'authentification partagé -service AuthService { - // Valider un JWT token - rpc ValidateToken(ValidateTokenRequest) returns (ValidateTokenResponse); - - // Obtenir les informations utilisateur - rpc GetUserInfo(GetUserInfoRequest) returns (GetUserInfoResponse); - - // Vérifier les permissions - rpc CheckPermissions(CheckPermissionsRequest) returns (CheckPermissionsResponse); - - // Révoquer un token - rpc RevokeToken(RevokeTokenRequest) returns (RevokeTokenResponse); -} - -// Messages de requête/réponse -message ValidateTokenRequest { - string token = 1; - string service = 2; // service qui fait la demande (chat, stream) -} - -message ValidateTokenResponse { - bool valid = 1; - UserClaims user = 2; - string error = 3; -} - -message GetUserInfoRequest { - int64 user_id = 1; - string token = 2; -} - -message GetUserInfoResponse { - UserInfo user = 1; - string error = 2; -} - -message CheckPermissionsRequest { - int64 user_id = 1; - string resource = 2; // "chat.room", "stream.channel" - string action = 3; // "read", "write", "moderate" - string resource_id = 4; -} - -message CheckPermissionsResponse { - bool allowed = 1; - repeated string permissions = 2; - string error = 3; -} - -message RevokeTokenRequest { - string token = 1; - string reason = 2; -} - -message RevokeTokenResponse { - bool success = 1; - string error = 2; -} - -// Types de données -message UserClaims { - int64 user_id = 1; - string username = 2; - string email = 3; - string role = 4; - bool is_active = 5; - int64 issued_at = 6; - int64 expires_at = 7; -} - -message UserInfo { - int64 id = 1; - string username = 2; - string email = 3; - string first_name = 4; - string last_name = 5; - string role = 6; - bool is_active = 7; - bool is_verified = 8; - int64 created_at = 9; - int64 last_login_at = 10; -} \ No newline at end of file diff --git a/veza-chat-server/scripts/reset_lab_db.sh b/veza-chat-server/scripts/reset_lab_db.sh deleted file mode 100755 index 40f18f0cb..000000000 --- a/veza-chat-server/scripts/reset_lab_db.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/bash -# Script pour réinitialiser la base de données lab (SCHEMA CHAT UNIQUEMENT) -# ATTENTION: Supprime toutes les données du chat ! - -set -euo pipefail - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -if [ -z "${VEZA_LAB_DSN:-}" ]; then - echo -e "${RED}❌ VEZA_LAB_DSN n'est pas défini${NC}" - exit 1 -fi - -echo -e "${YELLOW}⚠️ ATTENTION: Cette opération va supprimer toutes les données du schema 'chat' dans veza_lab${NC}" -echo -e "${YELLOW}⚠️ Les données du backend (schema public) ne seront par touchées.${NC}" -read -p "Continuer ? (yes/no): " confirm - -if [ "$confirm" != "yes" ]; then - echo "Annulé" - exit 0 -fi - -# Construire l'URL avec le search_path pour sqlx -if [[ "$VEZA_LAB_DSN" == *"?"* ]]; then - export DATABASE_URL="${VEZA_LAB_DSN}&options=-c%20search_path=chat" -else - export DATABASE_URL="${VEZA_LAB_DSN}?options=-c%20search_path=chat" -fi - -echo -e "${BLUE}🗑️ Reset du schema 'chat'...${NC}" -psql "$VEZA_LAB_DSN" -c "DROP SCHEMA IF EXISTS chat CASCADE; CREATE SCHEMA chat;" 2>&1 - -echo -e "${BLUE}📦 Application des migrations...${NC}" -# On force search_path=chat via l'URL -sqlx migrate run --database-url "$DATABASE_URL" - -echo -e "${GREEN}✅ Base de données chat réinitialisée${NC}" - diff --git a/veza-chat-server/scripts/start_lab.sh b/veza-chat-server/scripts/start_lab.sh deleted file mode 100755 index 76e2ff478..000000000 --- a/veza-chat-server/scripts/start_lab.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/bin/bash -# Script de démarrage lab pour veza-chat-server -# Utilise la vraie base de données PostgreSQL veza_lab avec schema dédié 'chat' - -set -euo pipefail - -# Couleurs -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -# Vérifier que VEZA_LAB_DSN est défini -if [ -z "${VEZA_LAB_DSN:-}" ]; then - echo -e "${RED}❌ ERREUR: VEZA_LAB_DSN n'est pas défini${NC}" - echo "Définissez-le avec:" - echo " export VEZA_LAB_DSN='postgres://veza:veza_password@localhost:5432/veza_lab?sslmode=disable'" - exit 1 -fi - -# S'assurer que le schema 'chat' existe -echo -e "${BLUE}🔧 Vérification du schema 'chat'...${NC}" -psql "$VEZA_LAB_DSN" -c "CREATE SCHEMA IF NOT EXISTS chat;" > /dev/null - -# Configuration de l'environnement avec schema dédié -# On ajoute options=-c search_path=chat pour forcer le schema par défaut -if [[ "$VEZA_LAB_DSN" == *"?"* ]]; then - export DATABASE_URL="${VEZA_LAB_DSN}&options=-c%20search_path=chat" -else - export DATABASE_URL="${VEZA_LAB_DSN}?options=-c%20search_path=chat" -fi - -export CHAT_SERVER_PORT="${CHAT_SERVER_PORT:-8081}" -export CHAT_SERVER_HOST="${CHAT_SERVER_HOST:-0.0.0.0}" -export RUST_LOG="${RUST_LOG:-info}" -export RABBITMQ_ENABLE="${RABBITMQ_ENABLE:-false}" - -# Vérifier que le binaire existe -if [ ! -f "./target/release/chat-server" ]; then - echo -e "${YELLOW}⚠️ Binaire non trouvé, compilation...${NC}" - cargo build --release -fi - -# Générer JWT_SECRET si non défini -if [ -z "${JWT_SECRET:-}" ]; then - export JWT_SECRET=$(openssl rand -base64 32) - echo -e "${YELLOW}⚠️ JWT_SECRET généré automatiquement${NC}" -fi - -echo -e "${BLUE}🚀 Démarrage veza-chat-server (lab)${NC}" -echo "==================================" -echo -e "${GREEN}✅ DATABASE_URL: ${DATABASE_URL%%@*}@***${NC}" -echo -e "${GREEN}✅ Schema: chat${NC}" -echo -e "${GREEN}✅ Port: $CHAT_SERVER_PORT${NC}" -echo -e "${GREEN}✅ Host: $CHAT_SERVER_HOST${NC}" -echo -e "${GREEN}✅ RUST_LOG: $RUST_LOG${NC}" -echo "" - -# Démarrer le serveur -exec ./target/release/chat-server - diff --git a/veza-chat-server/scripts/test_lab.sh b/veza-chat-server/scripts/test_lab.sh deleted file mode 100755 index 2049b4ffd..000000000 --- a/veza-chat-server/scripts/test_lab.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/bin/bash -# Script de test lab pour veza-chat-server -# Utilise la vraie base de données PostgreSQL veza_lab - -set -euo pipefail - -# Couleurs -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -echo -e "${BLUE}🧪 TEST LAB - veza-chat-server${NC}" -echo "==================================" -echo "" - -# Vérifier que VEZA_LAB_DSN est défini -if [ -z "${VEZA_LAB_DSN:-}" ]; then - echo -e "${RED}❌ ERREUR: VEZA_LAB_DSN n'est pas défini${NC}" - echo "Définissez-le avec:" - echo " export VEZA_LAB_DSN='postgres://veza:veza_password@localhost:5432/veza_lab?sslmode=disable'" - exit 1 -fi - -echo -e "${GREEN}✅ VEZA_LAB_DSN est défini${NC}" -echo "" - -# Exporter DATABASE_URL pour sqlx et l'application -if [[ "$VEZA_LAB_DSN" == *"?"* ]]; then - export DATABASE_URL="${VEZA_LAB_DSN}&options=-c%20search_path=chat" -else - export DATABASE_URL="${VEZA_LAB_DSN}?options=-c%20search_path=chat" -fi - -# Vérifier la connexion à la base de données -echo -e "${BLUE}🔍 Vérification de la connexion à la base de données...${NC}" -# On vérifie aussi que le schema existe ou on le crée si besoin pour le test ? -# Le script de reset le fait. Ici on suppose que l'environnement est prêt ou on le prépare. -# Utilisons psql pour vérifier que le serveur répond. -if psql "$VEZA_LAB_DSN" -c "SELECT 1;" > /dev/null 2>&1; then - echo -e "${GREEN}✅ Connexion PostgreSQL réussie${NC}" - # S'assurer que le schema 'chat' existe pour les tests - psql "$VEZA_LAB_DSN" -c "CREATE SCHEMA IF NOT EXISTS chat;" > /dev/null - echo -e "${GREEN}✅ Schema 'chat' vérifié${NC}" -else - echo -e "${RED}❌ Impossible de se connecter à la base de données${NC}" - echo "Vérifiez que PostgreSQL est démarré et que la base veza_lab existe" - exit 1 -fi -echo "" - -# Appliquer les migrations -echo -e "${BLUE}📦 Application des migrations (Schema: chat)...${NC}" -if sqlx migrate run --database-url "$DATABASE_URL"; then - echo -e "${GREEN}✅ Migrations appliquées avec succès${NC}" -else - echo -e "${RED}❌ Erreur lors de l'application des migrations${NC}" - exit 1 -fi -echo "" - -# Vérifier que JWT_SECRET est défini -if [ -z "${JWT_SECRET:-}" ]; then - echo -e "${YELLOW}⚠️ JWT_SECRET n'est pas défini, génération d'un secret temporaire...${NC}" - export JWT_SECRET=$(openssl rand -base64 32) - echo -e "${GREEN}✅ JWT_SECRET généré: ${JWT_SECRET:0:20}...${NC}" -fi -echo "" - -# Configuration par défaut pour le lab -export CHAT_SERVER_PORT="${CHAT_SERVER_PORT:-8081}" -export CHAT_SERVER_HOST="${CHAT_SERVER_HOST:-0.0.0.0}" -export RUST_LOG="${RUST_LOG:-info}" -export RABBITMQ_ENABLE="${RABBITMQ_ENABLE:-false}" - -echo -e "${BLUE}📋 Configuration:${NC}" -echo " DATABASE_URL: ${DATABASE_URL%%@*}@***" -echo " CHAT_SERVER_PORT: $CHAT_SERVER_PORT" -echo " CHAT_SERVER_HOST: $CHAT_SERVER_HOST" -echo " RUST_LOG: $RUST_LOG" -echo " RABBITMQ_ENABLE: $RABBITMQ_ENABLE" -echo "" - -echo -e "${GREEN}✅ Environnement prêt pour le démarrage${NC}" -echo "" -echo -e "${YELLOW}Pour démarrer le serveur:${NC}" -echo " ./target/release/chat-server" -echo "" -echo -e "${YELLOW}Ou avec make:${NC}" -echo " make dev" -echo "" - diff --git a/veza-chat-server/sqlx-data.json b/veza-chat-server/sqlx-data.json deleted file mode 100644 index 1868abf8c..000000000 --- a/veza-chat-server/sqlx-data.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/veza-chat-server/src/advanced_moderation.rs b/veza-chat-server/src/advanced_moderation.rs deleted file mode 100644 index 5c0246516..000000000 --- a/veza-chat-server/src/advanced_moderation.rs +++ /dev/null @@ -1,864 +0,0 @@ -//! Module Advanced Moderation - Système de modération automatique 99.9% efficace -//! -//! Ce module implémente un système de modération ultra-avancé avec : -//! - Détection de spam par ML (Machine Learning) -//! - Analyse sémantique du contenu -//! - Détection de patterns comportementaux -//! - Classification automatique des violations -//! - Sanctions adaptatives et progressives -//! - Détection de fraude et d'abus - -use std::collections::{HashMap, VecDeque}; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use serde::{Serialize, Deserialize}; -use regex::Regex; -use dashmap::DashMap; -use chrono::{DateTime, Utc, Timelike}; - -use crate::error::{ChatError, Result}; -use crate::monitoring::ChatMetrics; -use crate::moderation::{SanctionType, SanctionReason}; - -/// Score de confiance pour la détection (0.0 à 1.0) -pub type ConfidenceScore = f32; - -/// Types de violations détectées -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum ViolationType { - /// Spam (messages répétitifs, publicité) - Spam { confidence: ConfidenceScore, pattern: String }, - /// Contenu toxique (insultes, harcèlement) - Toxicity { confidence: ConfidenceScore, severity: ToxicitySeverity }, - /// Contenu inapproprié (NSFW, violence) - Inappropriate { confidence: ConfidenceScore, category: String }, - /// Fraude (phishing, escroquerie) - Fraud { confidence: ConfidenceScore, scheme_type: String }, - /// Abus (flood, raid) - Abuse { confidence: ConfidenceScore, abuse_type: AbuseType }, - /// Comportement suspect (bot, activité anormale) - Suspicious { confidence: ConfidenceScore, indicators: Vec }, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum ToxicitySeverity { - Low, // Léger - Medium, // Modéré - High, // Sévère - Extreme // Extrême -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum AbuseType { - MessageFlood, - RoomRaid, - UserHarassment, - SystemAbuse, -} - -/// Profil comportemental d'un utilisateur -#[derive(Debug, Clone, Serialize)] -pub struct UserBehaviorProfile { - pub user_id: i32, - pub username: String, - pub created_at: DateTime, - pub last_updated: DateTime, - - // Statistiques de base - pub total_messages: u64, - pub total_violations: u64, - pub trust_score: f32, // 0.0 (suspect) à 1.0 (confiance totale) - - // Patterns de comportement - pub message_frequency: VecDeque>, // Fréquence des messages - pub repeated_content: HashMap, // Contenu répété - pub room_activity: HashMap, // Activité par salon - pub warning_history: Vec, // Historique des violations - - // Métriques avancées - pub avg_message_length: f32, - pub unique_words_ratio: f32, // Ratio de mots uniques - pub conversation_engagement: f32, // Engagement dans conversations - pub off_topic_ratio: f32, // Ratio de messages hors sujet - - // Détection de bot - pub typing_speed: f32, // Vitesse de frappe (caractères/seconde) - pub response_time_pattern: VecDeque, // Pattern de temps de réponse - pub human_like_errors: u32, // Erreurs humaines (typos, corrections) - - // Timestamps suspects - pub activity_hours: HashMap, // Activité par heure (0-23) - pub consecutive_days: u32, // Jours consécutifs d'activité -} - -impl UserBehaviorProfile { - pub fn new(user_id: i32, username: String) -> Self { - Self { - user_id, - username, - created_at: Utc::now(), - last_updated: Utc::now(), - total_messages: 0, - total_violations: 0, - trust_score: 0.5, // Score neutre initial - message_frequency: VecDeque::with_capacity(100), - repeated_content: HashMap::new(), - room_activity: HashMap::new(), - warning_history: Vec::new(), - avg_message_length: 0.0, - unique_words_ratio: 0.0, - conversation_engagement: 0.0, - off_topic_ratio: 0.0, - typing_speed: 0.0, - response_time_pattern: VecDeque::with_capacity(50), - human_like_errors: 0, - activity_hours: HashMap::new(), - consecutive_days: 0, - } - } - - /// Met à jour le profil avec un nouveau message - pub fn update_with_message(&mut self, content: &str, room: &str, typing_duration: Option) { - self.total_messages += 1; - self.last_updated = Utc::now(); - - // Fréquence des messages - let now = Utc::now(); - self.message_frequency.push_back(now); - if self.message_frequency.len() > 100 { - self.message_frequency.pop_front(); - } - - // Contenu répété - let content_hash = self.normalize_content(content); - *self.repeated_content.entry(content_hash).or_insert(0) += 1; - - // Activité par salon - *self.room_activity.entry(room.to_string()).or_insert(0) += 1; - - // Longueur moyenne des messages - let new_length = content.len() as f32; - self.avg_message_length = (self.avg_message_length * (self.total_messages - 1) as f32 + new_length) / self.total_messages as f32; - - // Ratio de mots uniques - self.update_unique_words_ratio(content); - - // Vitesse de frappe - if let Some(duration) = typing_duration { - self.typing_speed = content.len() as f32 / duration.as_secs_f32(); - } - - // Activité par heure - let hour = chrono::Utc::now().hour() as u8; - *self.activity_hours.entry(hour).or_insert(0) += 1; - - // Détecter les erreurs humaines - if self.contains_human_errors(content) { - self.human_like_errors += 1; - } - } - - /// Normalise le contenu pour détecter les répétitions - fn normalize_content(&self, content: &str) -> String { - content.to_lowercase() - .chars() - .filter(|c| c.is_alphanumeric() || c.is_whitespace()) - .collect::() - .split_whitespace() - .collect::>() - .join(" ") - } - - /// Met à jour le ratio de mots uniques - fn update_unique_words_ratio(&mut self, content: &str) { - let words: Vec<&str> = content.split_whitespace().collect(); - let unique_words: std::collections::HashSet<&str> = words.iter().cloned().collect(); - - if !words.is_empty() { - let ratio = unique_words.len() as f32 / words.len() as f32; - self.unique_words_ratio = (self.unique_words_ratio + ratio) / 2.0; - } - } - - /// Détecte si le message contient des erreurs humaines - fn contains_human_errors(&self, content: &str) -> bool { - // Recherche de typos courants, corrections, etc. - let error_patterns = [ - r"\b\w+\*\w+\b", // Corrections avec * - r"\b\w+\s+\w+\b", // Mots dupliqués - r"[a-zA-Z]{3,}\d+[a-zA-Z]{3,}", // Mélange lettres/chiffres suspect - ]; - - for pattern in &error_patterns { - if let Ok(regex) = Regex::new(pattern) { - if regex.is_match(content) { - return true; - } - } - } - - false - } - - /// Calcule le score de suspicion (0.0 = normal, 1.0 = très suspect) - pub fn calculate_suspicion_score(&self) -> f32 { - let mut suspicion = 0.0; - - // Fréquence anormale de messages - if self.message_frequency.len() >= 10 { - let recent_messages = self.message_frequency.iter().rev().take(10).collect::>(); - let avg_interval = recent_messages.windows(2) - .map(|w| w[0].signed_duration_since(*w[1]).num_seconds() as f32) - .sum::() / (recent_messages.len() - 1) as f32; - - if avg_interval < 1.0 { // Moins d'1 seconde entre messages - suspicion += 0.3; - } - } - - // Contenu répétitif - let max_repetitions = self.repeated_content.values().max().unwrap_or(&0); - if *max_repetitions > 3 { - suspicion += 0.2 * (*max_repetitions as f32 / 10.0).min(1.0); - } - - // Faible ratio de mots uniques - if self.unique_words_ratio < 0.3 { - suspicion += 0.2; - } - - // Vitesse de frappe anormale - if self.typing_speed > 20.0 || self.typing_speed < 0.5 { - suspicion += 0.1; - } - - // Manque d'erreurs humaines - if self.total_messages > 50 && self.human_like_errors == 0 { - suspicion += 0.2; - } - - // Activité 24h/24 - let active_hours = self.activity_hours.len(); - if active_hours > 20 && self.consecutive_days > 7 { - suspicion += 0.15; - } - - suspicion.min(1.0) - } - - /// Détermine si l'utilisateur est probablement un bot - pub fn is_likely_bot(&self) -> bool { - self.calculate_suspicion_score() > 0.7 - } -} - -/// Configuration du système de modération avancé -#[derive(Debug, Clone)] -pub struct AdvancedModerationConfig { - /// Seuil de confiance pour action automatique - pub auto_action_threshold: f32, - /// Seuil de confiance pour alerter les modérateurs - pub alert_threshold: f32, - /// Nombre maximum de violations avant escalade - pub max_violations_before_escalation: u32, - /// Durée de rétention des profils utilisateur - pub profile_retention_duration: Duration, - /// Limite de messages par minute pour détection de flood - pub flood_detection_threshold: u32, - /// Patterns de spam prédéfinis - pub spam_patterns: Vec, - /// Mots interdits avec pondération - pub forbidden_words: HashMap, -} - -impl Default for AdvancedModerationConfig { - fn default() -> Self { - let spam_patterns = vec![ - r"(?i)(buy|sell|cheap|discount|offer|deal|promo|sale).*(?:http|www|\.com|\.org)".to_string(), - r"(?i)(click|visit|check|follow).*(?:link|site|channel|profile)".to_string(), - r"(?i)(free|win|earn|make money|get rich|opportunity)".to_string(), - r"(?i)(join|subscribe|follow).*(?:now|today|quickly|fast)".to_string(), - ]; - - let mut forbidden_words = HashMap::new(); - // Mots de toxicité avec scores de pondération - forbidden_words.insert("spam".to_string(), 0.3); - forbidden_words.insert("scam".to_string(), 0.8); - forbidden_words.insert("hack".to_string(), 0.6); - forbidden_words.insert("cheat".to_string(), 0.5); - - Self { - auto_action_threshold: 0.85, - alert_threshold: 0.7, - max_violations_before_escalation: 3, - profile_retention_duration: Duration::from_secs(30 * 24 * 3600), // 30 jours - flood_detection_threshold: 10, - spam_patterns, - forbidden_words, - } - } -} - -/// Système de modération automatique avancé -#[derive(Debug)] -pub struct AdvancedModerationEngine { - config: AdvancedModerationConfig, - user_profiles: Arc>, - violation_cache: Arc>, // Cache des violations détectées - metrics: Arc, - - // Regex compilées pour performance - spam_regexes: Vec, - url_regex: Regex, - phone_regex: Regex, - email_regex: Regex, -} - -impl AdvancedModerationEngine { - /// Crée un nouveau moteur de modération avancé - pub fn new(config: AdvancedModerationConfig, metrics: Arc) -> Result { - // Compiler les regex de spam - let mut spam_regexes = Vec::new(); - for pattern in &config.spam_patterns { - match Regex::new(pattern) { - Ok(regex) => spam_regexes.push(regex), - Err(e) => tracing::warn!(pattern = %pattern, error = %e, "⚠️ Regex spam invalide"), - } - } - - // Regex pour détecter URLs, téléphones, emails - let url_regex = Regex::new(r"(?i)https?://[^\s]+|www\.[^\s]+|[a-zA-Z0-9-]+\.(com|org|net|edu|gov|mil|int|co|io|me|tv|info|biz)[^\s]*") - .map_err(|e| ChatError::configuration_error(&format!("Regex URL invalide: {}", e)))?; - - let phone_regex = Regex::new(r"(?:\+?1[-.\s]?)?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}") - .map_err(|e| ChatError::configuration_error(&format!("Regex téléphone invalide: {}", e)))?; - - let email_regex = Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}") - .map_err(|e| ChatError::configuration_error(&format!("Regex email invalide: {}", e)))?; - - Ok(Self { - config, - user_profiles: Arc::new(DashMap::new()), - violation_cache: Arc::new(DashMap::new()), - metrics, - spam_regexes, - url_regex, - phone_regex, - email_regex, - }) - } - - /// Analyse un message pour détecter les violations - pub async fn analyze_message( - &self, - user_id: i32, - username: &str, - content: &str, - room: &str, - typing_duration: Option, - ) -> Result> { - let start_time = Instant::now(); - - // Mettre à jour le profil utilisateur - let mut profile = self.user_profiles.entry(user_id) - .or_insert_with(|| UserBehaviorProfile::new(user_id, username.to_string())); - profile.update_with_message(content, room, typing_duration); - - let mut violations = Vec::new(); - - // 1. Détection de spam - if let Some(spam_violation) = self.detect_spam(content, &profile).await? { - violations.push(spam_violation); - } - - // 2. Détection de toxicité - if let Some(toxicity_violation) = self.detect_toxicity(content).await? { - violations.push(toxicity_violation); - } - - // 3. Détection de contenu inapproprié - if let Some(inappropriate_violation) = self.detect_inappropriate_content(content).await? { - violations.push(inappropriate_violation); - } - - // 4. Détection de fraude - if let Some(fraud_violation) = self.detect_fraud(content).await? { - violations.push(fraud_violation); - } - - // 5. Détection d'abus - if let Some(abuse_violation) = self.detect_abuse(&profile).await? { - violations.push(abuse_violation); - } - - // 6. Détection de comportement suspect - if let Some(suspicious_violation) = self.detect_suspicious_behavior(&profile).await? { - violations.push(suspicious_violation); - } - - // Mettre à jour les métriques - let processing_time = start_time.elapsed(); - self.metrics.message_processing_time(processing_time, "advanced_moderation").await; - - if !violations.is_empty() { - // Mettre à jour le profil avec les violations - profile.total_violations += violations.len() as u64; - profile.warning_history.extend(violations.clone()); - - // Ajuster le score de confiance - let violation_impact = violations.len() as f32 * 0.1; - profile.trust_score = (profile.trust_score - violation_impact).max(0.0); - - tracing::warn!( - user_id = %user_id, - username = %username, - violations_count = %violations.len(), - processing_time = ?processing_time, - "🚨 Violations détectées" - ); - } - - Ok(violations) - } - - /// Détecte les messages de spam - async fn detect_spam(&self, content: &str, profile: &UserBehaviorProfile) -> Result> { - let mut spam_score = 0.0; - let mut detected_patterns = Vec::new(); - - // Vérifier les patterns de spam - for regex in &self.spam_regexes { - if regex.is_match(content) { - spam_score += 0.3; - detected_patterns.push(regex.as_str().to_string()); - } - } - - // Détecter les URLs suspectes - if self.url_regex.is_match(content) { - spam_score += 0.2; - detected_patterns.push("URL détectée".to_string()); - } - - // Détecter emails et téléphones (souvent spam) - if self.email_regex.is_match(content) || self.phone_regex.is_match(content) { - spam_score += 0.15; - detected_patterns.push("Contact info détectée".to_string()); - } - - // Analyser le comportement répétitif - if let Some(max_repetitions) = profile.repeated_content.values().max() { - if *max_repetitions > 3 { - spam_score += 0.2 * (*max_repetitions as f32 / 10.0).min(1.0); - detected_patterns.push(format!("Contenu répété {} fois", max_repetitions)); - } - } - - // Vérifier la fréquence des messages - if profile.message_frequency.len() >= 5 { - let recent_messages = profile.message_frequency.iter().rev().take(5).collect::>(); - if let (Some(first), Some(last)) = (recent_messages.first(), recent_messages.last()) { - let total_duration = first.signed_duration_since(**last); - if total_duration.num_seconds() < 10 { // 5 messages en moins de 10 secondes - spam_score += 0.25; - detected_patterns.push("Flood détecté".to_string()); - } - } - } - - // Détecter les mots interdits - let content_lower = content.to_lowercase(); - for (word, weight) in &self.config.forbidden_words { - if content_lower.contains(word) { - spam_score += weight; - detected_patterns.push(format!("Mot interdit: {}", word)); - } - } - - // Facteur de longueur anormale - if content.len() > 500 { - spam_score += 0.1; - detected_patterns.push("Message très long".to_string()); - } - - // Facteur de répétition de caractères - if self.has_character_repetition(content) { - spam_score += 0.15; - detected_patterns.push("Répétition de caractères".to_string()); - } - - if spam_score > 0.5 { - Ok(Some(ViolationType::Spam { - confidence: spam_score.min(1.0), - pattern: detected_patterns.join(", "), - })) - } else { - Ok(None) - } - } - - /// Détecte le contenu toxique - async fn detect_toxicity(&self, content: &str) -> Result> { - let mut toxicity_score = 0.0; - let content_lower = content.to_lowercase(); - - // Mots toxiques avec différents niveaux de sévérité - let toxic_words = [ - // Sévérité faible - ("stupid", 0.2), ("dumb", 0.2), ("idiot", 0.3), - // Sévérité moyenne - ("hate", 0.4), ("kill", 0.5), ("die", 0.4), - // Sévérité élevée - ("kys", 0.8), ("suicide", 0.7), - ]; - - for (word, weight) in &toxic_words { - if content_lower.contains(word) { - toxicity_score += weight; - } - } - - // Détecter les CAPS LOCK excessives (cris) - let caps_ratio = content.chars().filter(|c| c.is_uppercase()).count() as f32 / content.len() as f32; - if caps_ratio > 0.7 && content.len() > 10 { - toxicity_score += 0.2; - } - - // Détecter les points d'exclamation excessifs - let exclamation_count = content.matches('!').count(); - if exclamation_count > 3 { - toxicity_score += 0.1 * (exclamation_count as f32 / 10.0).min(1.0); - } - - if toxicity_score > 0.3 { - let severity = match toxicity_score { - s if s < 0.5 => ToxicitySeverity::Low, - s if s < 0.7 => ToxicitySeverity::Medium, - s if s < 0.9 => ToxicitySeverity::High, - _ => ToxicitySeverity::Extreme, - }; - - Ok(Some(ViolationType::Toxicity { - confidence: toxicity_score.min(1.0), - severity, - })) - } else { - Ok(None) - } - } - - /// Détecte le contenu inapproprié - async fn detect_inappropriate_content(&self, content: &str) -> Result> { - let content_lower = content.to_lowercase(); - let mut inappropriate_score: f32 = 0.0; - let mut category = String::new(); - - // Contenu NSFW - let nsfw_indicators = ["nsfw", "18+", "adult", "porn", "sex", "nude"]; - for indicator in &nsfw_indicators { - if content_lower.contains(indicator) { - inappropriate_score += 0.4; - category = "NSFW".to_string(); - break; - } - } - - // Contenu violent - let violence_indicators = ["violence", "blood", "murder", "weapon", "gun", "knife"]; - for indicator in &violence_indicators { - if content_lower.contains(indicator) { - inappropriate_score += 0.3; - if category.is_empty() { - category = "Violence".to_string(); - } - break; - } - } - - // Contenu de drogue - let drug_indicators = ["drug", "cocaine", "heroin", "weed", "marijuana"]; - for indicator in &drug_indicators { - if content_lower.contains(indicator) { - inappropriate_score += 0.25; - if category.is_empty() { - category = "Drogues".to_string(); - } - break; - } - } - - if inappropriate_score > 0.2 { - Ok(Some(ViolationType::Inappropriate { - confidence: inappropriate_score.min(1.0), - category, - })) - } else { - Ok(None) - } - } - - /// Détecte les tentatives de fraude - async fn detect_fraud(&self, content: &str) -> Result> { - let content_lower = content.to_lowercase(); - let mut fraud_score: f32 = 0.0; - let mut scheme_type = String::new(); - - // Phishing - let phishing_indicators = ["click here", "verify account", "suspended", "urgent", "immediate action"]; - for indicator in &phishing_indicators { - if content_lower.contains(indicator) { - fraud_score += 0.3; - scheme_type = "Phishing".to_string(); - break; - } - } - - // Escroqueries financières - let financial_scam_indicators = ["investment", "guaranteed profit", "easy money", "double your money"]; - for indicator in &financial_scam_indicators { - if content_lower.contains(indicator) { - fraud_score += 0.4; - if scheme_type.is_empty() { - scheme_type = "Escroquerie financière".to_string(); - } - break; - } - } - - // Combinaison URL + mots suspects - if self.url_regex.is_match(content) { - let suspicious_with_url = ["free", "win", "prize", "congratulations", "selected"]; - for word in &suspicious_with_url { - if content_lower.contains(word) { - fraud_score += 0.2; - if scheme_type.is_empty() { - scheme_type = "Lien suspect".to_string(); - } - break; - } - } - } - - if fraud_score > 0.3 { - Ok(Some(ViolationType::Fraud { - confidence: fraud_score.min(1.0), - scheme_type, - })) - } else { - Ok(None) - } - } - - /// Détecte les abus (flood, raid, etc.) - async fn detect_abuse(&self, profile: &UserBehaviorProfile) -> Result> { - let mut abuse_score: f32 = 0.0; - let mut abuse_type = AbuseType::SystemAbuse; - - // Flood de messages - if profile.message_frequency.len() >= 10 { - let recent_messages = profile.message_frequency.iter().rev().take(10).collect::>(); - if let (Some(first), Some(last)) = (recent_messages.first(), recent_messages.last()) { - let total_duration = first.signed_duration_since(**last); - if total_duration.num_seconds() < 30 { // 10 messages en moins de 30 secondes - abuse_score += 0.6; - abuse_type = AbuseType::MessageFlood; - } - } - } - - // Activité suspecte sur plusieurs salons - if profile.room_activity.len() > 5 { - let recent_activity: u32 = profile.room_activity.values().sum(); - if recent_activity > 50 { - abuse_score += 0.4; - abuse_type = AbuseType::RoomRaid; - } - } - - // Comportement de harcèlement (beaucoup de violations) - if profile.total_violations > 10 { - abuse_score += 0.3; - abuse_type = AbuseType::UserHarassment; - } - - if abuse_score > 0.4 { - Ok(Some(ViolationType::Abuse { - confidence: abuse_score.min(1.0), - abuse_type, - })) - } else { - Ok(None) - } - } - - /// Détecte les comportements suspects (bots, etc.) - async fn detect_suspicious_behavior(&self, profile: &UserBehaviorProfile) -> Result> { - let suspicion_score = profile.calculate_suspicion_score(); - - if suspicion_score > 0.6 { - let mut indicators = Vec::new(); - - if profile.typing_speed > 20.0 { - indicators.push("Vitesse de frappe anormale".to_string()); - } - - if profile.human_like_errors == 0 && profile.total_messages > 50 { - indicators.push("Absence d'erreurs humaines".to_string()); - } - - if profile.unique_words_ratio < 0.3 { - indicators.push("Vocabulaire limité".to_string()); - } - - if profile.activity_hours.len() > 20 { - indicators.push("Activité 24h/24".to_string()); - } - - if profile.is_likely_bot() { - indicators.push("Patterns de bot détectés".to_string()); - } - - Ok(Some(ViolationType::Suspicious { - confidence: suspicion_score, - indicators, - })) - } else { - Ok(None) - } - } - - /// Détermine la sanction appropriée basée sur les violations - pub async fn determine_sanction(&self, violations: &[ViolationType], profile: &UserBehaviorProfile) -> Result> { - if violations.is_empty() { - return Ok(None); - } - - // Calculer le score de sévérité total - let mut severity_score = 0.0; - let mut primary_reason = SanctionReason::Other("Violation détectée".to_string()); - - for violation in violations { - match violation { - ViolationType::Spam { confidence, .. } => { - severity_score += confidence * 0.5; - primary_reason = SanctionReason::Spam; - } - ViolationType::Toxicity { confidence, severity, .. } => { - let multiplier = match severity { - ToxicitySeverity::Low => 0.6, - ToxicitySeverity::Medium => 0.8, - ToxicitySeverity::High => 1.0, - ToxicitySeverity::Extreme => 1.2, - }; - severity_score += confidence * multiplier; - primary_reason = SanctionReason::Toxicity; - } - ViolationType::Inappropriate { confidence, .. } => { - severity_score += confidence * 0.7; - primary_reason = SanctionReason::Inappropriate; - } - ViolationType::Fraud { confidence, .. } => { - severity_score += confidence * 1.0; - primary_reason = SanctionReason::Abuse; - } - ViolationType::Abuse { confidence, .. } => { - severity_score += confidence * 0.8; - primary_reason = SanctionReason::Abuse; - } - ViolationType::Suspicious { confidence, .. } => { - severity_score += confidence * 0.4; - primary_reason = SanctionReason::RuleViolation; - } - } - } - - // Ajuster en fonction de l'historique - let history_multiplier = match profile.warning_history.len() { - 0..=2 => 1.0, - 3..=5 => 1.2, - 6..=10 => 1.5, - _ => 2.0, - }; - - severity_score *= history_multiplier; - - // Déterminer la sanction - let (sanction_type, duration) = match severity_score { - s if s < 0.5 => return Ok(None), // Pas de sanction - s if s < 0.7 => (SanctionType::Warning, Duration::from_secs(0)), - s if s < 1.0 => (SanctionType::Mute, Duration::from_secs(3600)), // 1 heure - s if s < 1.5 => (SanctionType::TempBan, Duration::from_secs(24 * 3600)), // 24 heures - _ => (SanctionType::TempBan, Duration::from_secs(7 * 24 * 3600)), // 7 jours - }; - - Ok(Some((sanction_type, primary_reason, duration))) - } - - /// Utilitaire pour détecter la répétition de caractères - fn has_character_repetition(&self, content: &str) -> bool { - let chars: Vec = content.chars().collect(); - let mut consecutive_count = 1; - - for i in 1..chars.len() { - if chars[i] == chars[i-1] { - consecutive_count += 1; - if consecutive_count >= 4 { // 4 caractères identiques consécutifs - return true; - } - } else { - consecutive_count = 1; - } - } - - false - } - - /// Nettoie les profils anciens - pub async fn cleanup_old_profiles(&self) { - let cutoff_time = Utc::now() - - chrono::Duration::from_std(self.config.profile_retention_duration) - .unwrap_or(chrono::Duration::zero()); - let mut removed_count = 0; - - self.user_profiles.retain(|_, profile| { - if profile.last_updated < cutoff_time { - removed_count += 1; - false - } else { - true - } - }); - - if removed_count > 0 { - tracing::info!(removed_count = %removed_count, "🧹 Profils utilisateur anciens supprimés"); - } - } - - /// Obtient les statistiques de modération - pub async fn get_moderation_stats(&self) -> HashMap { - let mut stats = HashMap::new(); - - stats.insert("active_profiles".to_string(), self.user_profiles.len() as u64); - stats.insert("cached_violations".to_string(), self.violation_cache.len() as u64); - - let mut total_violations = 0; - let mut bot_count = 0; - let mut high_risk_users = 0; - - for profile in self.user_profiles.iter() { - total_violations += profile.total_violations; - if profile.is_likely_bot() { - bot_count += 1; - } - if profile.calculate_suspicion_score() > 0.8 { - high_risk_users += 1; - } - } - - stats.insert("total_violations".to_string(), total_violations); - stats.insert("detected_bots".to_string(), bot_count); - stats.insert("high_risk_users".to_string(), high_risk_users); - - stats - } -} \ No newline at end of file diff --git a/veza-chat-server/src/auth.rs b/veza-chat-server/src/auth.rs deleted file mode 100644 index 19cee4b8f..000000000 --- a/veza-chat-server/src/auth.rs +++ /dev/null @@ -1,331 +0,0 @@ -//! Module d'authentification WebSocket pour le serveur de chat -//! -//! Ce module implémente l'authentification JWT pour les connexions WebSocket, -//! la validation des permissions par conversation, et le rate limiting. - -use crate::error::{ChatError, Result}; -use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use tokio::sync::RwLock; -use uuid::Uuid; - -/// Claims JWT pour l'authentification -#[derive(Debug, Serialize, Deserialize)] -pub struct JwtClaims { - pub user_id: Uuid, - pub username: String, - pub exp: u64, - pub iat: u64, - pub permissions: Vec, -} - -/// Gestionnaire d'authentification WebSocket -pub struct WebSocketAuthManager { - jwt_secret: String, - active_sessions: Arc>>, - rate_limits: Arc>>, -} - -/// Session utilisateur active -#[derive(Debug, Clone)] -pub struct UserSession { - pub user_id: Uuid, - pub username: String, - pub connected_at: SystemTime, - pub last_activity: SystemTime, - pub permissions: Vec, - pub conversation_access: Vec, -} - -/// État du rate limiting par utilisateur -#[derive(Debug, Clone)] -pub struct RateLimitState { - pub message_count: u32, - pub window_start: SystemTime, - pub last_message_time: SystemTime, -} - -impl WebSocketAuthManager { - /// Crée un nouveau gestionnaire d'authentification - pub fn new(jwt_secret: String) -> Self { - Self { - jwt_secret, - active_sessions: Arc::new(RwLock::new(HashMap::new())), - rate_limits: Arc::new(RwLock::new(HashMap::new())), - } - } - - /// Valide un token JWT et retourne les claims - pub fn validate_jwt_token(&self, token: &str) -> Result { - let decoding_key = DecodingKey::from_secret(self.jwt_secret.as_ref()); - let validation = Validation::default(); - - match decode::(token, &decoding_key, &validation) { - Ok(token_data) => { - // Vérifier l'expiration - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|e| ChatError::authentication_error(&format!("Time error: {}", e)))? - .as_secs(); - - if token_data.claims.exp < now { - return Err(ChatError::authentication_error("Token expired")); - } - - Ok(token_data.claims) - } - Err(e) => Err(ChatError::authentication_error(&format!("Invalid token: {}", e))), - } - } - - /// Authentifie un utilisateur WebSocket - pub async fn authenticate_websocket_user( - &self, - token: &str, - connection_id: Uuid, - ) -> Result { - let claims = self.validate_jwt_token(token)?; - - // Créer la session utilisateur - let session = UserSession { - user_id: claims.user_id, - username: claims.username, - connected_at: SystemTime::now(), - last_activity: SystemTime::now(), - permissions: claims.permissions, - conversation_access: Vec::new(), // Sera rempli lors de la jointure aux conversations - }; - - // Enregistrer la session active - let mut sessions = self.active_sessions.write().await; - sessions.insert(connection_id, session.clone()); - - // Initialiser le rate limiting - let mut rate_limits = self.rate_limits.write().await; - rate_limits.insert(connection_id, RateLimitState { - message_count: 0, - window_start: SystemTime::now(), - last_message_time: SystemTime::now(), - }); - - Ok(session) - } - - /// Vérifie les permissions pour une conversation - pub async fn check_conversation_permission( - &self, - connection_id: Uuid, - conversation_id: Uuid, - ) -> Result { - let sessions = self.active_sessions.read().await; - - if let Some(session) = sessions.get(&connection_id) { - // Vérifier si l'utilisateur a accès à cette conversation - // Pour l'instant, on autorise tous les utilisateurs authentifiés - // Dans une implémentation complète, on vérifierait les permissions spécifiques - Ok(session.conversation_access.contains(&conversation_id) || - session.permissions.contains(&"chat:all".to_string())) - } else { - Err(ChatError::authentication_error("Session not found")) - } - } - - /// Vérifie le rate limiting pour les messages - pub async fn check_message_rate_limit(&self, connection_id: Uuid) -> Result { - const MAX_MESSAGES_PER_MINUTE: u32 = 60; - const WINDOW_DURATION_SECONDS: u64 = 60; - - let mut rate_limits = self.rate_limits.write().await; - - if let Some(rate_limit) = rate_limits.get_mut(&connection_id) { - let now = SystemTime::now(); - - // Vérifier si la fenêtre de temps a expiré - if now.duration_since(rate_limit.window_start) - .map_err(|e| ChatError::rate_limit_error(&format!("Time error: {}", e)))? - .as_secs() >= WINDOW_DURATION_SECONDS { - // Réinitialiser le compteur - rate_limit.message_count = 0; - rate_limit.window_start = now; - } - - // Vérifier la limite - if rate_limit.message_count >= MAX_MESSAGES_PER_MINUTE { - return Ok(false); - } - - // Incrémenter le compteur - rate_limit.message_count += 1; - rate_limit.last_message_time = now; - - Ok(true) - } else { - Err(ChatError::authentication_error("Rate limit state not found")) - } - } - - /// Met à jour l'activité d'un utilisateur - pub async fn update_user_activity(&self, connection_id: Uuid) -> Result<()> { - let mut sessions = self.active_sessions.write().await; - - if let Some(session) = sessions.get_mut(&connection_id) { - session.last_activity = SystemTime::now(); - } - - Ok(()) - } - - /// Ajoute l'accès à une conversation pour un utilisateur - pub async fn grant_conversation_access( - &self, - connection_id: Uuid, - conversation_id: Uuid, - ) -> Result<()> { - let mut sessions = self.active_sessions.write().await; - - if let Some(session) = sessions.get_mut(&connection_id) { - if !session.conversation_access.contains(&conversation_id) { - session.conversation_access.push(conversation_id); - } - } - - Ok(()) - } - - /// Retire l'accès à une conversation pour un utilisateur - pub async fn revoke_conversation_access( - &self, - connection_id: Uuid, - conversation_id: Uuid, - ) -> Result<()> { - let mut sessions = self.active_sessions.write().await; - - if let Some(session) = sessions.get_mut(&connection_id) { - session.conversation_access.retain(|&id| id != conversation_id); - } - - Ok(()) - } - - /// Déconnecte un utilisateur - pub async fn disconnect_user(&self, connection_id: Uuid) -> Result<()> { - let mut sessions = self.active_sessions.write().await; - sessions.remove(&connection_id); - - let mut rate_limits = self.rate_limits.write().await; - rate_limits.remove(&connection_id); - - Ok(()) - } - - /// Nettoie les sessions expirées - pub async fn cleanup_expired_sessions(&self, max_idle_duration: Duration) -> Result<()> { - let now = SystemTime::now(); - let mut sessions = self.active_sessions.write().await; - let mut rate_limits = self.rate_limits.write().await; - - let expired_connections: Vec = sessions - .iter() - .filter(|(_, session)| { - now.duration_since(session.last_activity) - .map(|d| d > max_idle_duration) - .unwrap_or(true) - }) - .map(|(id, _)| *id) - .collect(); - - for connection_id in expired_connections { - sessions.remove(&connection_id); - rate_limits.remove(&connection_id); - } - - Ok(()) - } - - /// Obtient les statistiques des sessions actives - pub async fn get_session_stats(&self) -> Result { - let sessions = self.active_sessions.read().await; - - let total_sessions = sessions.len(); - let now = SystemTime::now(); - - let active_last_hour = sessions - .values() - .filter(|session| { - now.duration_since(session.last_activity) - .map(|d| d.as_secs() < 3600) - .unwrap_or(false) - }) - .count(); - - Ok(SessionStats { - total_sessions, - active_last_hour, - }) - } -} - -/// Statistiques des sessions -#[derive(Debug, Serialize)] -pub struct SessionStats { - pub total_sessions: usize, - pub active_last_hour: usize, -} - -impl Default for WebSocketAuthManager { - fn default() -> Self { - // SECURITY: Default impl ne doit pas être utilisé en production - // Utiliser WebSocketAuthManager::new() avec require_env_min_length("JWT_SECRET", 32) - panic!( - "WebSocketAuthManager::default() cannot be used in production. \ - Use WebSocketAuthManager::new() with require_env_min_length(\"JWT_SECRET\", 32)" - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - - #[tokio::test] - async fn test_jwt_validation() { - let auth_manager = WebSocketAuthManager::new("test_secret".to_string()); - - // Test avec un token invalide - let result = auth_manager.validate_jwt_token("invalid_token"); - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_rate_limiting() { - let auth_manager = WebSocketAuthManager::new("test_secret".to_string()); - let connection_id = Uuid::new_v4(); - - // Simuler l'authentification - // Note: SystemTime::duration_since peut échouer si l'horloge est réglée en arrière, - // mais c'est très rare. Dans un vrai test, on utiliserait chrono::Utc::now(). - let now = SystemTime::now().duration_since(UNIX_EPOCH) - .expect("System time before UNIX epoch (should never happen)"); - let claims = JwtClaims { - user_id: Uuid::new_v4(), - username: "test_user".to_string(), - exp: now.as_secs() + 3600, - iat: now.as_secs(), - permissions: vec!["chat:all".to_string()], - }; - - // Test du rate limiting - for _ in 0..65 { - let result = auth_manager.check_message_rate_limit(connection_id).await; - if let Ok(allowed) = result { - if !allowed { - break; // Rate limit atteint - } - } - } - } -} \ No newline at end of file diff --git a/veza-chat-server/src/authentication.rs b/veza-chat-server/src/authentication.rs deleted file mode 100644 index 5069ae902..000000000 --- a/veza-chat-server/src/authentication.rs +++ /dev/null @@ -1,259 +0,0 @@ -//! Module d'authentification pour le serveur de chat -//! -//! Gère l'authentification des utilisateurs, les sessions et les rôles - -use serde::{Deserialize, Serialize}; -use chrono::{DateTime, Utc}; -use std::collections::HashMap; -use crate::error::{ChatError, Result}; - -/// Rôles des utilisateurs dans le système de chat -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum Role { - /// Utilisateur standard - User, - /// Modérateur - peut modérer les messages et gérer les salons - Moderator, - /// Administrateur - accès complet au système - Admin, - /// Utilisateur banni - accès restreint - Banned, -} - -impl Role { - /// Vérifie si le rôle a les permissions d'administrateur - pub fn is_admin(&self) -> bool { - matches!(self, Role::Admin) - } - - /// Vérifie si le rôle a les permissions de modérateur ou plus - pub fn is_moderator_or_above(&self) -> bool { - matches!(self, Role::Admin | Role::Moderator) - } - - /// Vérifie si l'utilisateur est banni - pub fn is_banned(&self) -> bool { - matches!(self, Role::Banned) - } - - /// Vérifie si l'utilisateur peut envoyer des messages - pub fn can_send_messages(&self) -> bool { - !self.is_banned() - } - - /// Vérifie si l'utilisateur peut créer des salons - pub fn can_create_rooms(&self) -> bool { - matches!(self, Role::Admin | Role::Moderator | Role::User) - } -} - -/// Session utilisateur active -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UserSession { - /// ID unique de l'utilisateur - pub user_id: i32, - /// Nom d'utilisateur - pub username: String, - /// Rôle de l'utilisateur - pub role: Role, - /// Timestamp de connexion - pub connected_at: DateTime, - /// Dernière activité - pub last_activity: DateTime, - /// Adresse IP de connexion - pub ip_address: String, - /// User agent du client - pub user_agent: Option, - /// Salons auxquels l'utilisateur est connecté - pub active_rooms: Vec, - /// Statut de présence - pub presence_status: PresenceStatus, -} - -/// Statut de présence de l'utilisateur -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum PresenceStatus { - Online, - Away, - Busy, - Offline, -} - -impl UserSession { - /// Crée une nouvelle session utilisateur - pub fn new( - user_id: i32, - username: String, - role: Role, - ip_address: String, - user_agent: Option, - ) -> Self { - let now = Utc::now(); - Self { - user_id, - username, - role, - connected_at: now, - last_activity: now, - ip_address, - user_agent, - active_rooms: Vec::new(), - presence_status: PresenceStatus::Online, - } - } - - /// Met à jour la dernière activité - pub fn update_activity(&mut self) { - self.last_activity = Utc::now(); - } - - /// Ajoute l'utilisateur à un salon - pub fn join_room(&mut self, room_id: String) { - if !self.active_rooms.contains(&room_id) { - self.active_rooms.push(room_id); - } - self.update_activity(); - } - - /// Retire l'utilisateur d'un salon - pub fn leave_room(&mut self, room_id: &str) { - self.active_rooms.retain(|r| r != room_id); - self.update_activity(); - } - - /// Change le statut de présence - pub fn set_presence(&mut self, status: PresenceStatus) { - self.presence_status = status; - self.update_activity(); - } - - /// Vérifie si l'utilisateur est dans un salon spécifique - pub fn is_in_room(&self, room_id: &str) -> bool { - self.active_rooms.contains(&room_id.to_string()) - } - - /// Vérifie si la session est expirée (inactivité > 1 heure) - pub fn is_expired(&self) -> bool { - let now = Utc::now(); - let duration = now.signed_duration_since(self.last_activity); - duration.num_hours() > 1 - } -} - -/// Gestionnaire d'authentification -pub struct AuthManager { - /// Sessions actives (user_id -> session) - sessions: HashMap, - /// Connexions WebSocket (connection_id -> user_id) - connections: HashMap, -} - -impl AuthManager { - /// Crée un nouveau gestionnaire d'authentification - pub fn new() -> Self { - Self { - sessions: HashMap::new(), - connections: HashMap::new(), - } - } - - /// Authentifie un utilisateur et crée une session - pub fn authenticate_user( - &mut self, - user_id: i32, - username: String, - role: Role, - connection_id: String, - ip_address: String, - user_agent: Option, - ) -> Result<&UserSession> { - // Créer ou mettre à jour la session - let session = UserSession::new(user_id, username, role, ip_address, user_agent); - - // Stocker la session - self.sessions.insert(user_id, session.clone()); - self.connections.insert(connection_id, user_id); - - // Récupérer la session insérée (ne peut pas échouer car on vient de l'insérer) - Ok(self.sessions.get(&user_id).ok_or_else(|| { - ChatError::internal_error(format!( - "Session not found after insertion for user_id: {}", - user_id - )) - })?) - } - - /// Récupère une session par ID utilisateur - pub fn get_session(&self, user_id: i32) -> Option<&UserSession> { - self.sessions.get(&user_id) - } - - /// Récupère une session par ID de connexion - pub fn get_session_by_connection(&self, connection_id: &str) -> Option<&UserSession> { - let user_id = self.connections.get(connection_id)?; - self.sessions.get(user_id) - } - - /// Met à jour l'activité d'une session - pub fn update_activity(&mut self, user_id: i32) -> Result<()> { - if let Some(session) = self.sessions.get_mut(&user_id) { - session.update_activity(); - Ok(()) - } else { - Err(ChatError::unauthorized("Session non trouvée")) - } - } - - /// Déconnecte un utilisateur - pub fn disconnect_user(&mut self, connection_id: &str) -> Option { - if let Some(user_id) = self.connections.remove(connection_id) { - // Retirer de tous les salons - if let Some(mut session) = self.sessions.remove(&user_id) { - session.active_rooms.clear(); - session.presence_status = PresenceStatus::Offline; - Some(session) - } else { - None - } - } else { - None - } - } - - /// Nettoie les sessions expirées - pub fn cleanup_expired_sessions(&mut self) -> Vec { - let mut expired_users = Vec::new(); - - self.sessions.retain(|&user_id, session| { - if session.is_expired() { - expired_users.push(user_id); - false - } else { - true - } - }); - - // Nettoyer aussi les connexions - self.connections.retain(|_, &mut user_id| { - !expired_users.contains(&user_id) - }); - - expired_users - } - - /// Récupère toutes les sessions actives - pub fn get_active_sessions(&self) -> Vec<&UserSession> { - self.sessions.values().collect() - } - - /// Récupère le nombre de sessions actives - pub fn active_session_count(&self) -> usize { - self.sessions.len() - } -} - -impl Default for AuthManager { - fn default() -> Self { - Self::new() - } -} \ No newline at end of file diff --git a/veza-chat-server/src/cache.rs b/veza-chat-server/src/cache.rs deleted file mode 100644 index 92e87ea2d..000000000 --- a/veza-chat-server/src/cache.rs +++ /dev/null @@ -1,297 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::RwLock; -use serde::{Serialize, Deserialize}; - -/// Cache entry avec expiration -#[derive(Debug, Clone)] -pub struct CacheEntry { - pub value: T, - pub expires_at: Instant, - pub hit_count: u64, - pub last_accessed: Instant, -} - -impl CacheEntry { - pub fn new(value: T, ttl: Duration) -> Self { - let now = Instant::now(); - Self { - value, - expires_at: now + ttl, - hit_count: 0, - last_accessed: now, - } - } - - pub fn is_expired(&self) -> bool { - Instant::now() > self.expires_at - } - - pub fn touch(&mut self) { - self.hit_count += 1; - self.last_accessed = Instant::now(); - } -} - -/// Cache intelligent avec LRU et expiration -pub struct SmartCache -where - K: Clone + std::hash::Hash + Eq, - V: Clone, -{ - entries: Arc>>>, - max_size: usize, - default_ttl: Duration, -} - -impl SmartCache -where - K: Clone + std::hash::Hash + Eq, - V: Clone, -{ - pub fn new(max_size: usize, default_ttl: Duration) -> Self { - Self { - entries: Arc::new(RwLock::new(HashMap::new())), - max_size, - default_ttl, - } - } - - /// Insère une valeur dans le cache - pub async fn insert(&self, key: K, value: V) { - self.insert_with_ttl(key, value, self.default_ttl).await; - } - - /// Insère une valeur avec un TTL personnalisé - pub async fn insert_with_ttl(&self, key: K, value: V, ttl: Duration) { - let mut entries = self.entries.write().await; - - // Nettoyage des entrées expirées - self.cleanup_expired(&mut entries).await; - - // Éviction LRU si le cache est plein - if entries.len() >= self.max_size { - self.evict_lru(&mut entries).await; - } - - entries.insert(key, CacheEntry::new(value, ttl)); - } - - /// Récupère une valeur du cache - pub async fn get(&self, key: &K) -> Option { - let mut entries = self.entries.write().await; - - if let Some(entry) = entries.get_mut(key) { - if entry.is_expired() { - entries.remove(key); - return None; - } - - entry.touch(); - Some(entry.value.clone()) - } else { - None - } - } - - /// Supprime une entrée du cache - pub async fn remove(&self, key: &K) -> Option { - let mut entries = self.entries.write().await; - entries.remove(key).map(|entry| entry.value) - } - - /// Nettoie les entrées expirées - async fn cleanup_expired(&self, entries: &mut HashMap>) { - let expired_keys: Vec = entries.iter() - .filter(|(_, entry)| entry.is_expired()) - .map(|(key, _)| key.clone()) - .collect(); - - for key in expired_keys { - entries.remove(&key); - } - } - - /// Éviction LRU (Least Recently Used) - async fn evict_lru(&self, entries: &mut HashMap>) { - if let Some((lru_key, _)) = entries.iter() - .min_by_key(|(_, entry)| entry.last_accessed) - .map(|(key, entry)| (key.clone(), entry.clone())) { - entries.remove(&lru_key); - } - } - - /// Statistiques du cache - pub async fn stats(&self) -> CacheStats { - let entries = self.entries.read().await; - let total_hits: u64 = entries.values().map(|entry| entry.hit_count).sum(); - - CacheStats { - total_entries: entries.len(), - max_size: self.max_size, - total_hits, - hit_rate: if entries.is_empty() { 0.0 } else { total_hits as f64 / entries.len() as f64 }, - } - } - - /// Vide le cache - pub async fn clear(&self) { - let mut entries = self.entries.write().await; - entries.clear(); - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct CacheStats { - pub total_entries: usize, - pub max_size: usize, - pub total_hits: u64, - pub hit_rate: f64, -} - -/// Cache spécialisé pour les messages de salon -pub type RoomMessageCache = SmartCache>; - -/// Cache spécialisé pour les messages directs -pub type DirectMessageCache = SmartCache<(i32, i32), Vec>; - -/// Cache spécialisé pour les utilisateurs en ligne -pub type UserPresenceCache = SmartCache; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageCacheEntry { - pub id: i32, - pub user_id: i32, - pub username: String, - pub content: String, - pub timestamp: chrono::DateTime, - pub message_type: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UserPresenceEntry { - pub user_id: i32, - pub username: String, - pub status: String, - pub last_seen: chrono::DateTime, - pub current_room: Option, -} - -/// Gestionnaire centralisé de tous les caches -pub struct CacheManager { - pub room_messages: RoomMessageCache, - pub direct_messages: DirectMessageCache, - pub user_presence: UserPresenceCache, - pub user_sessions: SmartCache, // JWT token -> user_id -} - -impl Default for CacheManager { - fn default() -> Self { - Self::new() - } -} - -impl CacheManager { - pub fn new() -> Self { - Self { - // Cache des messages de salon (30 min TTL) - room_messages: SmartCache::new(1000, Duration::from_secs(1800)), - - // Cache des messages directs (1 heure TTL) - direct_messages: SmartCache::new(500, Duration::from_secs(3600)), - - // Cache de présence utilisateur (5 min TTL) - user_presence: SmartCache::new(10000, Duration::from_secs(300)), - - // Cache des sessions JWT (24 heures TTL) - user_sessions: SmartCache::new(50000, Duration::from_secs(86400)), - } - } - - /// Met en cache les messages d'un salon - pub async fn cache_room_messages(&self, room: &str, messages: Vec) { - self.room_messages.insert(room.to_string(), messages).await; - } - - /// Récupère les messages mis en cache d'un salon - pub async fn get_cached_room_messages(&self, room: &str) -> Option> { - self.room_messages.get(&room.to_string()).await - } - - /// Met en cache les messages directs entre deux utilisateurs - pub async fn cache_direct_messages(&self, user1: i32, user2: i32, messages: Vec) { - // Normaliser la clé pour éviter les doublons (user1, user2) et (user2, user1) - let key = if user1 < user2 { (user1, user2) } else { (user2, user1) }; - self.direct_messages.insert(key, messages).await; - } - - /// Récupère les messages directs mis en cache - pub async fn get_cached_direct_messages(&self, user1: i32, user2: i32) -> Option> { - let key = if user1 < user2 { (user1, user2) } else { (user2, user1) }; - self.direct_messages.get(&key).await - } - - /// Met en cache la présence d'un utilisateur - pub async fn cache_user_presence(&self, user_id: i32, presence: UserPresenceEntry) { - self.user_presence.insert(user_id, presence).await; - } - - /// Récupère la présence mise en cache d'un utilisateur - pub async fn get_cached_user_presence(&self, user_id: i32) -> Option { - self.user_presence.get(&user_id).await - } - - /// Met en cache une session utilisateur - pub async fn cache_user_session(&self, token: &str, user_id: i32) { - self.user_sessions.insert(token.to_string(), user_id).await; - } - - /// Récupère l'ID utilisateur d'un token mis en cache - pub async fn get_cached_user_session(&self, token: &str) -> Option { - self.user_sessions.get(&token.to_string()).await - } - - /// Invalide la session d'un utilisateur - pub async fn invalidate_user_session(&self, token: &str) { - self.user_sessions.remove(&token.to_string()).await; - } - - /// Nettoie tous les caches expirés - pub async fn cleanup_all(&self) { - // Le nettoyage est automatique lors des opérations get/insert - tracing::info!("🧹 Nettoyage automatique des caches effectué"); - } - - /// Statistiques globales des caches - pub async fn global_stats(&self) -> GlobalCacheStats { - let room_stats = self.room_messages.stats().await; - let dm_stats = self.direct_messages.stats().await; - let presence_stats = self.user_presence.stats().await; - let session_stats = self.user_sessions.stats().await; - - GlobalCacheStats { - room_messages: room_stats, - direct_messages: dm_stats, - user_presence: presence_stats, - user_sessions: session_stats, - } - } - - /// Vide tous les caches (pour le débogage/maintenance) - pub async fn clear_all(&self) { - self.room_messages.clear().await; - self.direct_messages.clear().await; - self.user_presence.clear().await; - self.user_sessions.clear().await; - tracing::warn!("🗑️ Tous les caches ont été vidés"); - } -} - -#[derive(Debug, Serialize)] -pub struct GlobalCacheStats { - pub room_messages: CacheStats, - pub direct_messages: CacheStats, - pub user_presence: CacheStats, - pub user_sessions: CacheStats, -} \ No newline at end of file diff --git a/veza-chat-server/src/chat_management.rs b/veza-chat-server/src/chat_management.rs deleted file mode 100644 index 0d77098ce..000000000 --- a/veza-chat-server/src/chat_management.rs +++ /dev/null @@ -1,794 +0,0 @@ -//! Gestion unifiée des salons, messages directs et modération -//! -//! Ce module fournit une interface unifiée pour: -//! - Gestion des salons (création, suppression, permissions) -//! - Messages directs entre utilisateurs -//! - Système de modération avancé -//! - Gestion des rôles et permissions -//! - Intégration avec les métriques et logs - -use crate::authentication::{Role, UserSession}; -use crate::error::{ChatError, Result}; -use crate::prometheus_metrics::PrometheusMetrics; -use crate::structured_logging::chat_logs; -use chrono::{DateTime, Utc}; -use std::time::Duration; -use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; -use std::sync::Arc; -use tokio::sync::RwLock; -use uuid::Uuid; - -/// Types de salons -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum RoomType { - /// Salon public - visible par tous - Public, - /// Salon privé - invitation uniquement - Private, - /// Salon direct - conversation entre 2 utilisateurs - Direct, - /// Salon système - géré par le système - System, -} - -/// Permissions dans un salon -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] -pub enum RoomPermission { - /// Lire les messages - Read, - /// Envoyer des messages - Write, - /// Modifier les messages - Edit, - /// Supprimer les messages - Delete, - /// Inviter des utilisateurs - Invite, - /// Gérer le salon - Manage, - /// Modérer le salon - Moderate, -} - -/// Statut d'un salon -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum RoomStatus { - /// Salon actif - Active, - /// Salon archivé - Archived, - /// Salon supprimé - Deleted, - /// Salon suspendu - Suspended, -} - -/// Configuration d'un salon -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RoomConfig { - /// Nom du salon - pub name: String, - /// Description du salon - pub description: Option, - /// Type du salon - pub room_type: RoomType, - /// Permissions par défaut - pub default_permissions: HashSet, - /// Limite de membres (None = illimitée) - pub max_members: Option, - /// Activer l'historique des messages - pub enable_history: bool, - /// Activer les réactions - pub enable_reactions: bool, - /// Activer les mentions - pub enable_mentions: bool, - /// Activer les fils de discussion - pub enable_threads: bool, - /// Mots-clés interdits - pub forbidden_words: HashSet, - /// Activer la modération automatique - pub auto_moderation: bool, -} - -impl Default for RoomConfig { - fn default() -> Self { - Self { - name: "Nouveau salon".to_string(), - description: None, - room_type: RoomType::Public, - default_permissions: HashSet::from([RoomPermission::Read, RoomPermission::Write]), - max_members: Some(1000), - enable_history: true, - enable_reactions: true, - enable_mentions: true, - enable_threads: true, - forbidden_words: HashSet::new(), - auto_moderation: false, - } - } -} - -/// Salon de chat -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Room { - /// ID unique du salon - pub id: Uuid, - /// Configuration du salon - pub config: RoomConfig, - /// Créateur du salon - pub creator_id: i32, - /// Date de création - pub created_at: DateTime, - /// Date de dernière activité - pub last_activity: DateTime, - /// Statut du salon - pub status: RoomStatus, - /// Membres du salon (user_id -> permissions) - pub members: HashMap>, - /// Modérateurs du salon - pub moderators: HashSet, - /// Administrateurs du salon - pub administrators: HashSet, - /// Messages épinglés - pub pinned_messages: Vec, - /// Tags du salon - pub tags: HashSet, -} - -impl Room { - /// Crée un nouveau salon - pub fn new(creator_id: i32, config: RoomConfig) -> Self { - let now = Utc::now(); - Self { - id: Uuid::new_v4(), - config, - creator_id, - created_at: now, - last_activity: now, - status: RoomStatus::Active, - members: HashMap::new(), - moderators: HashSet::new(), - administrators: HashSet::new(), - pinned_messages: Vec::new(), - tags: HashSet::new(), - } - } - - /// Ajoute un membre au salon - pub fn add_member(&mut self, user_id: i32, permissions: HashSet) { - self.members.insert(user_id, permissions); - self.last_activity = Utc::now(); - } - - /// Retire un membre du salon - pub fn remove_member(&mut self, user_id: i32) { - self.members.remove(&user_id); - self.moderators.remove(&user_id); - self.administrators.remove(&user_id); - self.last_activity = Utc::now(); - } - - /// Vérifie si un utilisateur a une permission spécifique - pub fn has_permission(&self, user_id: i32, permission: &RoomPermission) -> bool { - // L'administrateur du salon a toutes les permissions - if self.administrators.contains(&user_id) { - return true; - } - - // Vérifier les permissions du membre - if let Some(member_permissions) = self.members.get(&user_id) { - member_permissions.contains(permission) - } else { - false - } - } - - /// Vérifie si un utilisateur peut envoyer des messages - pub fn can_send_messages(&self, user_id: i32) -> bool { - self.has_permission(user_id, &RoomPermission::Write) - } - - /// Vérifie si un utilisateur peut modérer - pub fn can_moderate(&self, user_id: i32) -> bool { - self.administrators.contains(&user_id) - || self.moderators.contains(&user_id) - || self.has_permission(user_id, &RoomPermission::Moderate) - } - - /// Ajoute un modérateur - pub fn add_moderator(&mut self, user_id: i32) -> Result<()> { - if !self.members.contains_key(&user_id) { - return Err(ChatError::validation_error( - "Utilisateur n'est pas membre du salon", - )); - } - - self.moderators.insert(user_id); - self.last_activity = Utc::now(); - Ok(()) - } - - /// Retire un modérateur - pub fn remove_moderator(&mut self, user_id: i32) { - self.moderators.remove(&user_id); - self.last_activity = Utc::now(); - } - - /// Épingle un message - pub fn pin_message(&mut self, message_id: Uuid) -> Result<()> { - if self.pinned_messages.len() >= 10 { - return Err(ChatError::validation_error("Trop de messages épinglés")); - } - - if !self.pinned_messages.contains(&message_id) { - self.pinned_messages.push(message_id); - self.last_activity = Utc::now(); - } - - Ok(()) - } - - /// Désépingle un message - pub fn unpin_message(&mut self, message_id: Uuid) { - self.pinned_messages.retain(|&id| id != message_id); - self.last_activity = Utc::now(); - } - - /// Met à jour la dernière activité - pub fn update_activity(&mut self) { - self.last_activity = Utc::now(); - } - - /// Archive le salon - pub fn archive(&mut self) { - self.status = RoomStatus::Archived; - self.last_activity = Utc::now(); - } - - /// Supprime le salon - pub fn delete(&mut self) { - self.status = RoomStatus::Deleted; - self.last_activity = Utc::now(); - } -} - -/// Message de chat -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatMessage { - /// ID unique du message - pub id: Uuid, - /// ID du salon ou de la conversation - pub room_id: Uuid, - /// ID de l'expéditeur - pub sender_id: i32, - /// Nom d'utilisateur de l'expéditeur - pub sender_username: String, - /// Contenu du message - pub content: String, - /// Type de message - pub message_type: MessageType, - /// Message parent (pour les fils de discussion) - pub parent_message_id: Option, - /// Date d'envoi - pub sent_at: DateTime, - /// Date de modification - pub edited_at: Option>, - /// Message supprimé - pub deleted: bool, - /// Réactions au message - pub reactions: HashMap>, - /// Mentions dans le message - pub mentions: Vec, - /// Fichiers joints - pub attachments: Vec, - /// Métadonnées du message - pub metadata: HashMap, -} - -/// Types de messages -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum MessageType { - /// Message texte normal - Text, - /// Message système - System, - /// Message de bienvenue - Welcome, - /// Message de modération - Moderation, - /// Message de fichier - File, - /// Message d'image - Image, - /// Message de code - Code, - /// Message de commande - Command, -} - -/// Fichier joint -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Attachment { - /// ID du fichier - pub id: Uuid, - /// Nom du fichier - pub filename: String, - /// Type MIME - pub mime_type: String, - /// Taille en bytes - pub size_bytes: u64, - /// URL de téléchargement - pub download_url: String, - /// Date d'upload - pub uploaded_at: DateTime, -} - -/// Action de modération -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModerationAction { - /// ID de l'action - pub id: Uuid, - /// Type d'action - pub action_type: ModerationActionType, - /// ID du modérateur - pub moderator_id: i32, - /// ID de l'utilisateur ciblé - pub target_user_id: Option, - /// ID du message ciblé - pub target_message_id: Option, - /// ID du salon - pub room_id: Uuid, - /// Raison de l'action - pub reason: String, - /// Durée de la sanction (si applicable) - pub duration: Option, - /// Date de l'action - pub created_at: DateTime, - /// Action active - pub active: bool, -} - -/// Types d'actions de modération -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum ModerationActionType { - /// Avertissement - Warning, - /// Suppression de message - DeleteMessage, - /// Modification de message - EditMessage, - /// Bannissement temporaire - TemporaryBan, - /// Bannissement permanent - PermanentBan, - /// Mute temporaire - TemporaryMute, - /// Mute permanent - PermanentMute, - /// Kick du salon - Kick, - /// Suspension du salon - SuspendRoom, - /// Suppression du salon - DeleteRoom, -} - -/// Gestionnaire de chat unifié -pub struct ChatManager { - /// Salons actifs - rooms: Arc>>, - /// Messages par salon - messages: Arc>>>, - /// Actions de modération - moderation_actions: Arc>>, - /// Métriques Prometheus - metrics: Option>, -} - -impl ChatManager { - /// Crée un nouveau gestionnaire de chat - pub fn new(metrics: Option>) -> Self { - Self { - rooms: Arc::new(RwLock::new(HashMap::new())), - messages: Arc::new(RwLock::new(HashMap::new())), - moderation_actions: Arc::new(RwLock::new(Vec::new())), - metrics, - } - } - - /// Crée un nouveau salon - pub async fn create_room( - &self, - creator_id: i32, - creator_username: &str, - config: RoomConfig, - ) -> Result { - let room = Room::new(creator_id, config.clone()); - let room_id = room.id; - - // Ajouter le créateur comme administrateur - let mut room = room; - room.administrators.insert(creator_id); - room.add_member(creator_id, room.config.default_permissions.clone()); - - // Sauvegarder le salon - { - let mut rooms = self.rooms.write().await; - rooms.insert(room_id, room); - } - - // Enregistrer les métriques - if let Some(metrics) = &self.metrics { - metrics.record_room_created(); - metrics.update_active_rooms(self.get_active_rooms_count().await); - } - - chat_logs::room_created( - &room_id.to_string(), - &config.name, - creator_id, - creator_username, - ); - - Ok(room_id) - } - - /// Supprime un salon - pub async fn delete_room( - &self, - room_id: Uuid, - deleter_id: i32, - deleter_username: &str, - ) -> Result<()> { - let room_name = { - let rooms = self.rooms.read().await; - let room = rooms - .get(&room_id) - .ok_or_else(|| ChatError::validation_error("Salon non trouvé"))?; - - // Vérifier les permissions - if !room.administrators.contains(&deleter_id) { - return Err(ChatError::unauthorized("Permissions insuffisantes")); - } - - room.config.name.clone() - }; - - // Supprimer le salon - { - let mut rooms = self.rooms.write().await; - if let Some(room) = rooms.get_mut(&room_id) { - room.delete(); - } - } - - // Supprimer les messages - { - let mut messages = self.messages.write().await; - messages.remove(&room_id); - } - - // Enregistrer les métriques - if let Some(metrics) = &self.metrics { - metrics.record_room_deleted(); - metrics.update_active_rooms(self.get_active_rooms_count().await); - } - - chat_logs::room_deleted( - &room_id.to_string(), - &room_name, - deleter_id, - deleter_username, - ); - - Ok(()) - } - - /// Rejoint un salon - pub async fn join_room(&self, room_id: Uuid, user_id: i32, username: &str) -> Result<()> { - let mut rooms = self.rooms.write().await; - let room = rooms - .get_mut(&room_id) - .ok_or_else(|| ChatError::validation_error("Salon non trouvé"))?; - - // Vérifier si le salon est actif - if room.status != RoomStatus::Active { - return Err(ChatError::validation_error("Salon non actif")); - } - - // Vérifier la limite de membres - if let Some(max_members) = room.config.max_members { - if room.members.len() >= max_members as usize { - return Err(ChatError::validation_error("Salon plein")); - } - } - - // Ajouter le membre - room.add_member(user_id, room.config.default_permissions.clone()); - - chat_logs::room_created(&room_id.to_string(), &room.config.name, user_id, username); - - Ok(()) - } - - /// Quitte un salon - pub async fn leave_room(&self, room_id: Uuid, user_id: i32, username: &str) -> Result<()> { - let mut rooms = self.rooms.write().await; - let room = rooms - .get_mut(&room_id) - .ok_or_else(|| ChatError::validation_error("Salon non trouvé"))?; - - room.remove_member(user_id); - - chat_logs::room_deleted(&room_id.to_string(), &room.config.name, user_id, username); - - Ok(()) - } - - /// Envoie un message dans un salon - pub async fn send_message( - &self, - room_id: Uuid, - sender_id: i32, - sender_username: &str, - content: String, - message_type: MessageType, - ) -> Result { - // Vérifier les permissions - { - let rooms = self.rooms.read().await; - let room = rooms - .get(&room_id) - .ok_or_else(|| ChatError::validation_error("Salon non trouvé"))?; - - if !room.can_send_messages(sender_id) { - return Err(ChatError::unauthorized("Permissions insuffisantes")); - } - } - - // Créer le message - let message = ChatMessage { - id: Uuid::new_v4(), - room_id, - sender_id, - sender_username: sender_username.to_string(), - content: content.clone(), - message_type, - parent_message_id: None, - sent_at: Utc::now(), - edited_at: None, - deleted: false, - reactions: HashMap::new(), - mentions: Vec::new(), - attachments: Vec::new(), - metadata: HashMap::new(), - }; - - let message_id = message.id; - - // Sauvegarder le message - { - let mut messages = self.messages.write().await; - messages.entry(room_id).or_default().push(message); - } - - // Mettre à jour l'activité du salon - { - let mut rooms = self.rooms.write().await; - if let Some(room) = rooms.get_mut(&room_id) { - room.update_activity(); - } - } - - // Enregistrer les métriques - if let Some(metrics) = &self.metrics { - metrics.record_message_sent("text", "room"); - metrics.record_message_size(content.len() as u64, "text"); - } - - chat_logs::message_sent( - message_id.to_string(), - sender_id, - sender_username, - &room_id.to_string(), - "text", - content.len(), - ); - - Ok(message_id) - } - - /// Récupère les messages d'un salon - pub async fn get_room_messages( - &self, - room_id: Uuid, - limit: Option, - offset: Option, - ) -> Result> { - let messages = self.messages.read().await; - let room_messages = messages - .get(&room_id) - .ok_or_else(|| ChatError::validation_error("Salon non trouvé"))?; - - let offset = offset.unwrap_or(0); - let limit = limit.unwrap_or(50).min(100); - - let start = room_messages.len().saturating_sub(offset + limit); - let end = room_messages.len().saturating_sub(offset); - - Ok(room_messages[start..end].to_vec()) - } - - /// Crée une conversation directe - pub async fn create_direct_conversation(&self, user1_id: i32, user2_id: i32) -> Result { - let config = RoomConfig { - name: format!("DM_{}_{}", user1_id, user2_id), - room_type: RoomType::Direct, - max_members: Some(2), - ..Default::default() - }; - - let room_id = self.create_room(user1_id, "system", config).await?; - - // Ajouter le deuxième utilisateur - self.join_room(room_id, user2_id, "user").await?; - - Ok(room_id) - } - - /// Applique une action de modération - pub async fn apply_moderation_action(&self, action: ModerationAction) -> Result<()> { - // Sauvegarder l'action - { - let mut actions = self.moderation_actions.write().await; - actions.push(action.clone()); - } - - // Appliquer l'action selon le type - match action.action_type { - ModerationActionType::DeleteMessage => { - if let Some(message_id) = action.target_message_id { - self.delete_message(message_id, action.moderator_id).await?; - } - } - ModerationActionType::Kick => { - if let Some(user_id) = action.target_user_id { - self.leave_room(action.room_id, user_id, "moderated") - .await?; - } - } - ModerationActionType::SuspendRoom => { - self.suspend_room(action.room_id, action.moderator_id) - .await?; - } - _ => { - // Autres actions de modération - } - } - - // Enregistrer les métriques - if let Some(metrics) = &self.metrics { - metrics.record_moderation_action(&format!("{:?}", action.action_type)); - } - - Ok(()) - } - - /// Supprime un message - async fn delete_message(&self, message_id: Uuid, moderator_id: i32) -> Result<()> { - let mut messages = self.messages.write().await; - - for room_messages in messages.values_mut() { - if let Some(message) = room_messages.iter_mut().find(|m| m.id == message_id) { - message.deleted = true; - message - .metadata - .insert("deleted_by".to_string(), moderator_id.to_string()); - message - .metadata - .insert("deleted_at".to_string(), Utc::now().to_rfc3339()); - return Ok(()); - } - } - - Err(ChatError::validation_error("Message non trouvé")) - } - - /// Suspend un salon - async fn suspend_room(&self, room_id: Uuid, _moderator_id: i32) -> Result<()> { - let mut rooms = self.rooms.write().await; - if let Some(room) = rooms.get_mut(&room_id) { - room.status = RoomStatus::Suspended; - room.last_activity = Utc::now(); - } - Ok(()) - } - - /// Récupère le nombre de salons actifs - async fn get_active_rooms_count(&self) -> u64 { - let rooms = self.rooms.read().await; - rooms - .values() - .filter(|room| room.status == RoomStatus::Active) - .count() as u64 - } - - /// Récupère les statistiques du chat - pub async fn get_chat_stats(&self) -> ChatStats { - let rooms = self.rooms.read().await; - let messages = self.messages.read().await; - - let total_rooms = rooms.len(); - let active_rooms = rooms - .values() - .filter(|room| room.status == RoomStatus::Active) - .count(); - - let total_messages = messages.values().map(|msgs| msgs.len()).sum::(); - - let total_members = rooms.values().map(|room| room.members.len()).sum::(); - - ChatStats { - total_rooms, - active_rooms, - total_messages, - total_members, - } - } -} - -/// Statistiques du chat -#[derive(Debug, Clone, Serialize)] -pub struct ChatStats { - pub total_rooms: usize, - pub active_rooms: usize, - pub total_messages: usize, - pub total_members: usize, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_room_creation() { - let manager = ChatManager::new(None); - let config = RoomConfig::default(); - - let room_id = manager.create_room(1, "testuser", config).await.unwrap(); - assert!(!room_id.is_nil()); - } - - #[tokio::test] - async fn test_room_permissions() { - let room = Room::new(1, RoomConfig::default()); - - // Le créateur doit avoir toutes les permissions - assert!(room.can_send_messages(1)); - assert!(room.can_moderate(1)); - - // Un utilisateur non membre ne doit pas avoir de permissions - assert!(!room.can_send_messages(2)); - } - - #[tokio::test] - async fn test_message_sending() { - let manager = ChatManager::new(None); - let config = RoomConfig::default(); - - let room_id = manager.create_room(1, "testuser", config).await.unwrap(); - let message_id = manager - .send_message( - room_id, - 1, - "testuser", - "Hello world".to_string(), - MessageType::Text, - ) - .await - .unwrap(); - - assert!(!message_id.is_nil()); - } -} diff --git a/veza-chat-server/src/client.rs b/veza-chat-server/src/client.rs deleted file mode 100644 index c0bef6be3..000000000 --- a/veza-chat-server/src/client.rs +++ /dev/null @@ -1,79 +0,0 @@ -//file: backend/modules/chat_server/src/client.rs - -use tokio::sync::mpsc::UnboundedSender; -use tokio_tungstenite::tungstenite::Message; -use std::time::{Duration, Instant}; - -#[derive(Debug, Clone)] -pub struct Client { - pub user_id: i32, - pub username: String, - pub sender: UnboundedSender, - pub last_heartbeat: std::sync::Arc>, - pub connected_at: Instant, -} - -impl Client { - pub fn new(user_id: i32, username: String, sender: UnboundedSender) -> Self { - Self { - user_id, - username, - sender, - last_heartbeat: std::sync::Arc::new(std::sync::RwLock::new(Instant::now())), - connected_at: Instant::now(), - } - } - - /// Envoie un message texte au client - pub fn send_text(&self, text: &str) -> bool { - tracing::debug!(user_id = %self.user_id, username = %self.username, text_length = %text.len(), "🔧 Tentative d'envoi de message texte"); - - match self.sender.send(Message::Text(text.to_string())) { - Ok(_) => { - tracing::debug!(user_id = %self.user_id, username = %self.username, "✅ Message texte envoyé au canal"); - true - } - Err(e) => { - tracing::error!(user_id = %self.user_id, username = %self.username, error = %e, "❌ Erreur envoi message texte au canal"); - false - } - } - } - - /// Envoie un ping pour vérifier la connexion - pub fn send_ping(&self) -> bool { - tracing::debug!(user_id = %self.user_id, username = %self.username, "🏓 Envoi ping"); - - match self.sender.send(Message::Ping(vec![])) { - Ok(_) => { - tracing::debug!(user_id = %self.user_id, username = %self.username, "✅ Ping envoyé"); - true - } - Err(e) => { - tracing::error!(user_id = %self.user_id, username = %self.username, error = %e, "❌ Erreur envoi ping"); - false - } - } - } - - /// Met à jour le timestamp du dernier heartbeat - pub fn update_heartbeat(&self) { - if let Ok(mut last_heartbeat) = self.last_heartbeat.write() { - *last_heartbeat = Instant::now(); - } - } - - /// Vérifie si la connexion est encore active (basé sur le heartbeat) - pub fn is_alive(&self, timeout: Duration) -> bool { - if let Ok(last_heartbeat) = self.last_heartbeat.read() { - last_heartbeat.elapsed() < timeout - } else { - false - } - } - - /// Retourne la durée de connexion - pub fn connection_duration(&self) -> Duration { - self.connected_at.elapsed() - } -} \ No newline at end of file diff --git a/veza-chat-server/src/config.rs b/veza-chat-server/src/config.rs deleted file mode 100644 index d6635fb72..000000000 --- a/veza-chat-server/src/config.rs +++ /dev/null @@ -1,670 +0,0 @@ -use serde::{Deserialize, Serialize}; -use sqlx::PgPool; -use std::env; -use std::time::Duration; -use tracing::{debug, info, warn}; - -/// Configuration pour la connexion RabbitMQ -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RabbitMQConfig { - pub url: String, - pub max_retries: u32, - pub retry_interval_secs: u64, - pub enable: bool, -} - -impl RabbitMQConfig { - pub fn from_env() -> Self { - dotenvy::dotenv().ok(); // S'assurer que les .env sont chargés - Self { - url: env::var("RABBITMQ_URL") - .unwrap_or_else(|_| "amqp://guest:guest@localhost:5672/".to_string()), - max_retries: env::var("RABBITMQ_MAX_RETRIES") - .unwrap_or_else(|_| "3".to_string()) - .parse() - .unwrap_or(3), - retry_interval_secs: env::var("RABBITMQ_RETRY_INTERVAL_SECS") - .unwrap_or_else(|_| "2".to_string()) - .parse() - .unwrap_or(2), - enable: env::var("RABBITMQ_ENABLE") - .unwrap_or_else(|_| "true".to_string()) - .parse() - .unwrap_or(true), - } - } -} - -/// Configuration simple du chat server depuis variables d'environnement -#[derive(Debug, Clone)] -pub struct Config { - /// URL de connexion à la base de données PostgreSQL - pub database_url: String, - /// Port d'écoute du serveur - pub port: u16, - /// Adresse IP d'écoute du serveur - pub host: String, - /// Configuration RabbitMQ - pub rabbit_mq: RabbitMQConfig, -} - -impl Config { - /// Charge la configuration depuis les variables d'environnement - /// - /// Utilise `dotenvy` pour charger le fichier `.env` si présent. - /// Variables d'environnement: - /// - `DATABASE_URL`: URL de connexion PostgreSQL (requis) - /// - `CHAT_SERVER_PORT`: Port d'écoute (défaut: 8081) - /// - `CHAT_SERVER_HOST`: Adresse d'écoute (défaut: 0.0.0.0) - /// - `RABBITMQ_URL`: URL de connexion RabbitMQ (défaut: amqp://guest:guest@localhost:5672/) - /// - `RABBITMQ_MAX_RETRIES`: Nb max de tentatives RabbitMQ (défaut: 3) - /// - `RABBITMQ_RETRY_INTERVAL_SECS`: Intervalle retry RabbitMQ (défaut: 2s) - /// - `RABBITMQ_ENABLE`: Activer RabbitMQ (défaut: true) - /// - /// # Returns - /// - /// Un `Config` ou une erreur si `DATABASE_URL` n'est pas définie - pub fn from_env() -> Result> { - // Charger le fichier .env si présent (dotenvy ignore silencieusement si absent) - dotenvy::dotenv().ok(); - - Ok(Config { - database_url: env::var("DATABASE_URL") - .map_err(|_| "DATABASE_URL environment variable is required")?, - port: env::var("CHAT_SERVER_PORT") - .unwrap_or_else(|_| "8081".to_string()) - .parse() - .map_err(|e| format!("Invalid CHAT_SERVER_PORT: {}", e))?, - host: env::var("CHAT_SERVER_HOST").unwrap_or_else(|_| "0.0.0.0".to_string()), - rabbit_mq: RabbitMQConfig::from_env(), - }) - } -} - -/// Configuration optimisée pour le pool de connexions SQLx -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DatabaseConfig { - /// URL de connexion à la base de données - pub database_url: String, - - /// Nombre maximum de connexions ouvertes - pub max_connections: u32, - - /// Nombre minimum de connexions maintenues - pub min_connections: u32, - - /// Timeout pour établir une nouvelle connexion - pub connect_timeout: Duration, - - /// Timeout pour les requêtes - pub acquire_timeout: Duration, - - /// Durée de vie maximale d'une connexion - pub max_lifetime: Duration, - - /// Durée d'inactivité avant fermeture d'une connexion - pub idle_timeout: Duration, - - /// Test des connexions avant utilisation - pub test_before_acquire: bool, - - /// Configuration SSL - pub ssl_mode: SslMode, - - /// Configuration du pool pour les performances - pub pool_config: PoolConfig, -} - -/// Mode SSL pour la connexion PostgreSQL -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum SslMode { - Disable, - Prefer, - Require, - VerifyCa, - VerifyFull, -} - -impl SslMode { - pub fn to_string(&self) -> &'static str { - match self { - SslMode::Disable => "disable", - SslMode::Prefer => "prefer", - SslMode::Require => "require", - SslMode::VerifyCa => "verify-ca", - SslMode::VerifyFull => "verify-full", - } - } -} - -/// Configuration spécifique au pool -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PoolConfig { - /// Taille du buffer pour les requêtes préparées - pub prepared_statement_cache_size: usize, - - /// Nombre de connexions à créer au démarrage - pub initial_connections: u32, - - /// Intervalle de nettoyage des connexions inactives - pub cleanup_interval: Duration, - - /// Seuil pour déclencher un warning sur le nombre de connexions - pub connection_warning_threshold: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SecurityConfig { - pub jwt_secret: String, - pub jwt_access_duration: Duration, - pub jwt_refresh_duration: Duration, - pub jwt_algorithm: String, - pub jwt_audience: String, - pub jwt_issuer: String, - pub enable_2fa: bool, - pub totp_window: u32, - pub content_filtering: bool, - pub password_min_length: u32, - pub bcrypt_cost: u32, -} - -impl Default for DatabaseConfig { - fn default() -> Self { - let connect_secs = env::var("CHAT_CONNECT_TIMEOUT_SECS") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(5); - let acquire_secs = env::var("CHAT_ACQUIRE_TIMEOUT_SECS") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(10); - let max_lifetime_secs = env::var("CHAT_MAX_LIFETIME_SECS") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(1800); - let idle_secs = env::var("CHAT_IDLE_TIMEOUT_SECS") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(600); - Self { - database_url: "postgresql://localhost/veza_chat".to_string(), - max_connections: 20, - min_connections: 5, - connect_timeout: Duration::from_secs(connect_secs), - acquire_timeout: Duration::from_secs(acquire_secs), - max_lifetime: Duration::from_secs(max_lifetime_secs), - idle_timeout: Duration::from_secs(idle_secs), - test_before_acquire: true, - ssl_mode: SslMode::Prefer, - pool_config: PoolConfig::default(), - } - } -} - -impl Default for SecurityConfig { - fn default() -> Self { - #[cfg(not(test))] - { - panic!( - "SecurityConfig::default() cannot be used in production. \ - Create SecurityConfig manually with require_env_min_length(\"JWT_SECRET\", 32)" - ); - } - - #[cfg(test)] - Self { - jwt_secret: env::var("TEST_JWT_SECRET") - .unwrap_or_else(|_| format!("test_{}_{}", uuid::Uuid::new_v4(), "x".repeat(20))), - jwt_access_duration: Duration::from_secs(900), // 15 min - jwt_refresh_duration: Duration::from_secs(86400 * 30), // 30 days - jwt_algorithm: "HS256".to_string(), - jwt_audience: "veza-chat".to_string(), - jwt_issuer: "veza-backend".to_string(), - enable_2fa: false, - totp_window: 1, - content_filtering: false, - password_min_length: 8, - bcrypt_cost: 12, - } - } -} - -impl Default for PoolConfig { - fn default() -> Self { - Self { - prepared_statement_cache_size: 100, - initial_connections: 5, - cleanup_interval: Duration::from_secs(300), // 5 minutes - connection_warning_threshold: 15, - } - } -} - -/// Gestionnaire de pool de base de données optimisé -pub struct DatabaseManager { - pool: PgPool, - config: DatabaseConfig, - metrics: DatabaseMetrics, -} - -/// Métriques du pool de base de données -#[derive(Debug, Clone)] -pub struct DatabaseMetrics { - pub total_connections: u32, - pub idle_connections: u32, - pub active_connections: u32, - pub waiting_requests: u32, - pub connection_errors: u64, - pub query_errors: u64, - pub avg_query_duration_ms: f64, - pub last_cleanup: std::time::Instant, -} - -impl Default for DatabaseMetrics { - fn default() -> Self { - Self { - total_connections: 0, - idle_connections: 0, - active_connections: 0, - waiting_requests: 0, - connection_errors: 0, - query_errors: 0, - avg_query_duration_ms: 0.0, - last_cleanup: std::time::Instant::now(), - } - } -} - -impl DatabaseManager { - /// Crée une nouvelle instance du gestionnaire de base de données - pub async fn new(config: DatabaseConfig) -> Result { - info!( - "Initializing database connection pool with config: {:?}", - config - ); - - // Construire l'URL de connexion avec les paramètres SSL - let mut database_url = config.database_url.clone(); - if !database_url.contains("sslmode=") { - let separator = if database_url.contains('?') { "&" } else { "?" }; - database_url.push_str(&format!( - "{}sslmode={}", - separator, - config.ssl_mode.to_string() - )); - } - - // Créer le pool avec la configuration optimisée - let pool_options = sqlx::postgres::PgPoolOptions::new() - .max_connections(config.max_connections) - .min_connections(config.min_connections) - .acquire_timeout(config.acquire_timeout) - .max_lifetime(config.max_lifetime) - .idle_timeout(config.idle_timeout) - .test_before_acquire(config.test_before_acquire); - - let pool = pool_options.connect(&database_url).await?; - - // Tester la connexion - sqlx::query("SELECT 1").fetch_one(&pool).await?; - - info!( - "Database connection pool initialized successfully. Max connections: {}, Min connections: {}", - config.max_connections, config.min_connections - ); - - Ok(Self { - pool, - config, - metrics: DatabaseMetrics::default(), - }) - } - - /// Obtient le pool de connexions - pub fn pool(&self) -> &PgPool { - &self.pool - } - - /// Obtient les métriques actuelles du pool - pub async fn get_metrics(&self) -> DatabaseMetrics { - let pool_status = self.pool.size(); - let idle_count = self.pool.num_idle(); - let active_count = pool_status - idle_count as u32; - - DatabaseMetrics { - total_connections: pool_status, - idle_connections: idle_count as u32, - active_connections: active_count, - waiting_requests: 0, // SQLx ne fournit pas cette info directement - connection_errors: self.metrics.connection_errors, - query_errors: self.metrics.query_errors, - avg_query_duration_ms: self.metrics.avg_query_duration_ms, - last_cleanup: self.metrics.last_cleanup, - } - } - - /// Nettoie les connexions inactives - pub async fn cleanup_idle_connections(&mut self) { - let now = std::time::Instant::now(); - - if now.duration_since(self.metrics.last_cleanup) >= self.config.pool_config.cleanup_interval - { - // SQLx gère automatiquement le nettoyage des connexions inactives - // On peut juste mettre à jour nos métriques - self.metrics.last_cleanup = now; - - let metrics = self.get_metrics().await; - debug!( - "Database cleanup completed. Active: {}, Idle: {}, Total: {}", - metrics.active_connections, metrics.idle_connections, metrics.total_connections - ); - - // Warning si trop de connexions actives - if metrics.active_connections > self.config.pool_config.connection_warning_threshold { - warn!( - "High number of active database connections: {} (threshold: {})", - metrics.active_connections, - self.config.pool_config.connection_warning_threshold - ); - } - } - } - - /// Teste la santé de la base de données - pub async fn health_check(&self) -> Result<(), sqlx::Error> { - let start = std::time::Instant::now(); - - // Test de connexion simple - sqlx::query("SELECT 1").fetch_one(&self.pool).await?; - - let duration = start.elapsed(); - debug!("Database health check completed in {:?}", duration); - - // Mettre à jour les métriques de performance - // Note: Dans une vraie implémentation, on utiliserait des atomics pour thread-safety - // self.metrics.avg_query_duration_ms = // calculer la moyenne - - Ok(()) - } - - /// Ferme le pool de connexions - pub async fn close(self) { - info!("Closing database connection pool"); - self.pool.close().await; - } - - /// Obtient la configuration actuelle - pub fn config(&self) -> &DatabaseConfig { - &self.config - } - - /// Met à jour la configuration (nécessite un redémarrage du pool) - pub fn update_config(&mut self, new_config: DatabaseConfig) { - self.config = new_config; - info!("Database configuration updated"); - } - - /// Obtient des statistiques détaillées du pool - pub async fn get_detailed_stats(&self) -> DetailedPoolStats { - let metrics = self.get_metrics().await; - - DetailedPoolStats { - pool_size: metrics.total_connections, - idle_connections: metrics.idle_connections, - active_connections: metrics.active_connections, - max_connections: self.config.max_connections, - min_connections: self.config.min_connections, - connection_utilization: (metrics.active_connections as f64 - / self.config.max_connections as f64) - * 100.0, - avg_query_duration_ms: metrics.avg_query_duration_ms, - connection_errors: metrics.connection_errors, - query_errors: metrics.query_errors, - last_cleanup: metrics.last_cleanup, - } - } -} - -/// Statistiques détaillées du pool -#[derive(Debug, Clone)] -pub struct DetailedPoolStats { - pub pool_size: u32, - pub idle_connections: u32, - pub active_connections: u32, - pub max_connections: u32, - pub min_connections: u32, - pub connection_utilization: f64, - pub avg_query_duration_ms: f64, - pub connection_errors: u64, - pub query_errors: u64, - pub last_cleanup: std::time::Instant, -} - -/// Configuration optimisée pour différents environnements -impl DatabaseConfig { - /// Configuration pour le développement local - pub fn development() -> Self { - Self { - database_url: "postgresql://localhost/veza_chat_dev".to_string(), - max_connections: 10, - min_connections: 2, - connect_timeout: Duration::from_secs(5), - acquire_timeout: Duration::from_secs(10), - max_lifetime: Duration::from_secs(1800), - idle_timeout: Duration::from_secs(300), - test_before_acquire: true, - ssl_mode: SslMode::Disable, - pool_config: PoolConfig { - prepared_statement_cache_size: 50, - initial_connections: 2, - cleanup_interval: Duration::from_secs(300), - connection_warning_threshold: 8, - }, - } - } - - /// Configuration pour la production - pub fn production() -> Self { - Self { - database_url: "postgresql://localhost/veza_chat_prod".to_string(), - max_connections: 20, - min_connections: 5, - connect_timeout: Duration::from_secs(5), - acquire_timeout: Duration::from_secs(10), - max_lifetime: Duration::from_secs(1800), - idle_timeout: Duration::from_secs(600), - test_before_acquire: true, - ssl_mode: SslMode::Require, - pool_config: PoolConfig { - prepared_statement_cache_size: 100, - initial_connections: 5, - cleanup_interval: Duration::from_secs(300), - connection_warning_threshold: 15, - }, - } - } - - /// Configuration pour les tests - pub fn testing() -> Self { - Self { - database_url: "postgresql://localhost/veza_chat_test".to_string(), - max_connections: 5, - min_connections: 1, - connect_timeout: Duration::from_secs(2), - acquire_timeout: Duration::from_secs(5), - max_lifetime: Duration::from_secs(300), - idle_timeout: Duration::from_secs(60), - test_before_acquire: false, - ssl_mode: SslMode::Disable, - pool_config: PoolConfig { - prepared_statement_cache_size: 10, - initial_connections: 1, - cleanup_interval: Duration::from_secs(60), - connection_warning_threshold: 3, - }, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - - #[test] - fn test_config_from_env() { - // Sauvegarder les valeurs originales - let original_db_url = std::env::var("DATABASE_URL").ok(); - let original_port = std::env::var("CHAT_SERVER_PORT").ok(); - let original_host = std::env::var("CHAT_SERVER_HOST").ok(); - - // Test avec des variables d'environnement définies - std::env::set_var("DATABASE_URL", "postgresql://test:test@localhost/test_db"); - std::env::set_var("CHAT_SERVER_PORT", "9999"); - std::env::set_var("CHAT_SERVER_HOST", "127.0.0.1"); - - let config = Config::from_env().unwrap(); - assert_eq!( - config.database_url, - "postgresql://test:test@localhost/test_db" - ); - assert_eq!(config.port, 9999); - assert_eq!(config.host, "127.0.0.1"); - - // Restaurer les valeurs originales - if let Some(url) = original_db_url { - std::env::set_var("DATABASE_URL", url); - } else { - std::env::remove_var("DATABASE_URL"); - } - if let Some(port) = original_port { - std::env::set_var("CHAT_SERVER_PORT", port); - } else { - std::env::remove_var("CHAT_SERVER_PORT"); - } - if let Some(host) = original_host { - std::env::set_var("CHAT_SERVER_HOST", host); - } else { - std::env::remove_var("CHAT_SERVER_HOST"); - } - } - - #[test] - #[cfg_attr(not(feature = "serial-test"), ignore)] // Ignorer si pas de serial-test - fn test_config_from_env_defaults() { - // Sauvegarder les valeurs originales - let original_db_url = std::env::var("DATABASE_URL").ok(); - let original_port = std::env::var("CHAT_SERVER_PORT").ok(); - let original_host = std::env::var("CHAT_SERVER_HOST").ok(); - - // S'assurer que les variables sont bien supprimées - std::env::remove_var("CHAT_SERVER_PORT"); - std::env::remove_var("CHAT_SERVER_HOST"); - - // Test avec DATABASE_URL uniquement - std::env::set_var("DATABASE_URL", "postgresql://test:test@localhost/test_db"); - - let config = Config::from_env().unwrap(); - assert_eq!( - config.database_url, - "postgresql://test:test@localhost/test_db" - ); - assert_eq!(config.port, 8081, "Port should default to 8081"); // Défaut - assert_eq!(config.host, "0.0.0.0", "Host should default to 0.0.0.0"); // Défaut - - // Restaurer les valeurs originales - if let Some(url) = original_db_url { - std::env::set_var("DATABASE_URL", url); - } else { - std::env::remove_var("DATABASE_URL"); - } - if let Some(port) = original_port { - std::env::set_var("CHAT_SERVER_PORT", port); - } else { - std::env::remove_var("CHAT_SERVER_PORT"); - } - if let Some(host) = original_host { - std::env::set_var("CHAT_SERVER_HOST", host); - } else { - std::env::remove_var("CHAT_SERVER_HOST"); - } - } - - #[test] - #[cfg_attr(not(feature = "serial-test"), ignore)] // Ignorer si pas de serial-test - fn test_config_from_env_missing_database_url() { - // Sauvegarder la valeur originale - let original_db_url = std::env::var("DATABASE_URL").ok(); - - // S'assurer que DATABASE_URL est bien supprimé - std::env::remove_var("DATABASE_URL"); - - // Vérifier qu'il n'y a pas de .env qui pourrait définir DATABASE_URL - // En forçant le rechargement, on s'assure que la variable n'est pas chargée - let result = Config::from_env(); - - // Si dotenvy charge un .env avec DATABASE_URL, le test peut échouer - // Dans ce cas, on accepte que le test soit ignoré si DATABASE_URL est défini ailleurs - if original_db_url.is_none() && std::env::var("DATABASE_URL").is_ok() { - // DATABASE_URL a été chargé depuis .env, on ignore ce test - eprintln!("Warning: DATABASE_URL found in .env, skipping test"); - return; - } - - assert!(result.is_err(), "Should fail when DATABASE_URL is missing"); - - // Restaurer la valeur originale - if let Some(url) = original_db_url { - std::env::set_var("DATABASE_URL", url); - } - } - - #[tokio::test] - async fn test_database_config_defaults() { - let config = DatabaseConfig::default(); - assert_eq!(config.max_connections, 20); - assert_eq!(config.min_connections, 5); - assert_eq!(config.connect_timeout, Duration::from_secs(5)); - } - - #[tokio::test] - async fn test_database_config_environments() { - let dev_config = DatabaseConfig::development(); - let prod_config = DatabaseConfig::production(); - let test_config = DatabaseConfig::testing(); - - assert!(dev_config.max_connections < prod_config.max_connections); - assert!(test_config.max_connections < dev_config.max_connections); - assert_eq!(prod_config.ssl_mode, SslMode::Require); - assert_eq!(dev_config.ssl_mode, SslMode::Disable); - } - - #[test] - fn test_ssl_mode_to_string() { - assert_eq!(SslMode::Disable.to_string(), "disable"); - assert_eq!(SslMode::Require.to_string(), "require"); - assert_eq!(SslMode::VerifyFull.to_string(), "verify-full"); - } - - #[test] - fn test_ssl_mode_all_variants() { - assert_eq!(SslMode::Prefer.to_string(), "prefer"); - assert_eq!(SslMode::VerifyCa.to_string(), "verify-ca"); - } - - #[test] - fn test_pool_config_default() { - let pool = PoolConfig::default(); - assert_eq!(pool.prepared_statement_cache_size, 100); - assert_eq!(pool.initial_connections, 5); - assert_eq!(pool.connection_warning_threshold, 15); - } - - #[test] - fn test_rabbitmq_config_defaults() { - let config = RabbitMQConfig::from_env(); - assert!(!config.url.is_empty()); - assert!(config.max_retries >= 1); - assert!(config.retry_interval_secs >= 1); - } -} diff --git a/veza-chat-server/src/core/advanced_rate_limiter.rs b/veza-chat-server/src/core/advanced_rate_limiter.rs deleted file mode 100644 index d399aaf3d..000000000 --- a/veza-chat-server/src/core/advanced_rate_limiter.rs +++ /dev/null @@ -1,829 +0,0 @@ -use std::collections::{HashMap, VecDeque}; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use std::net::IpAddr; -use serde::{Deserialize, Serialize}; -use dashmap::DashMap; -use tokio::sync::RwLock; -use parking_lot::Mutex; - -use crate::error::{ChatError, Result}; - -/// Service de rate limiting avancé anti-DDoS -#[derive(Debug)] -pub struct AdvancedRateLimiter { - /// Limiteurs par IP - ip_limiters: Arc>, - - /// Limiteurs par utilisateur - user_limiters: Arc>, - - /// Limiteurs par canal - channel_limiters: Arc>, - - /// Patterns d'attaque détectés - attack_patterns: Arc>, - - /// Liste noire temporaire - blacklist: Arc>, - - /// Configuration globale - config: Arc>, - - /// Métriques de performance - metrics: Arc, -} - -/// Limiteur par IP avec détection de patterns -#[derive(Debug)] -pub struct IpRateLimiter { - pub ip: IpAddr, - pub buckets: HashMap, - pub last_activity: Instant, - pub violation_count: u32, - pub trust_score: f32, - pub request_patterns: VecDeque, - pub status: IpStatus, -} - -/// Limiteur par utilisateur -#[derive(Debug)] -pub struct UserRateLimiter { - pub user_id: i64, - pub buckets: HashMap, - pub last_activity: Instant, - pub violation_count: u32, - pub reputation: UserReputation, - pub daily_limits: DailyLimits, -} - -/// Limiteur par canal -#[derive(Debug)] -pub struct ChannelRateLimiter { - pub channel_id: String, - pub message_bucket: TokenBucket, - pub concurrent_users: u32, - pub last_activity: Instant, - pub spam_threshold: f32, - pub moderation_level: ModerationLevel, -} - -/// Implémentation du Token Bucket -#[derive(Debug, Clone)] -pub struct TokenBucket { - pub capacity: u32, - pub tokens: u32, - pub refill_rate: f32, // tokens per second - pub last_refill: Instant, - pub burst_allowance: u32, -} - -/// Types de limitations -#[derive(Debug, Clone, Hash, PartialEq, Eq)] -pub enum LimitType { - /// Messages par minute - MessagesPerMinute, - /// Connexions par heure - ConnectionsPerHour, - /// Tentatives d'authentification - AuthAttempts, - /// Requêtes API - ApiRequests, - /// Upload de fichiers - FileUploads, - /// Création de channels - ChannelCreation, - /// Invitations envoyées - Invitations, - /// Réactions ajoutées - Reactions, -} - -/// Statut d'une IP -#[derive(Debug, Clone, PartialEq)] -pub enum IpStatus { - /// IP normale - Normal, - /// IP suspecte (surveillance accrue) - Suspicious, - /// IP en liste noire temporaire - Blacklisted, - /// IP bloquée définitivement - Banned, - /// IP de confiance (VPN/Proxy autorisé) - Trusted, -} - -/// Réputation d'un utilisateur -#[derive(Debug, Clone)] -pub struct UserReputation { - pub score: f32, // 0.0 - 1.0 - pub level: ReputationLevel, - pub violations_today: u32, - pub positive_actions: u32, - pub last_violation: Option, -} - -/// Niveau de réputation -#[derive(Debug, Clone, PartialEq)] -pub enum ReputationLevel { - NewUser, // Nouvels utilisateurs (restrictions strictes) - Normal, // Utilisateurs normaux - Trusted, // Utilisateurs de confiance - VIP, // Utilisateurs VIP (modérateurs, abonnés) - System, // Comptes système (bots officiels) -} - -/// Limites quotidiennes -#[derive(Debug, Clone)] -pub struct DailyLimits { - pub messages_sent: u32, - pub max_messages: u32, - pub files_uploaded: u32, - pub max_files: u32, - pub reset_time: Instant, -} - -/// Niveau de modération d'un canal -#[derive(Debug, Clone, PartialEq)] -pub enum ModerationLevel { - Low, // Modération allégée - Normal, // Modération standard - High, // Modération stricte - Lockdown, // Canal en verrouillage -} - -/// Pattern d'attaque détecté -#[derive(Debug, Clone)] -pub struct AttackPattern { - pub pattern_id: String, - pub pattern_type: AttackType, - pub source_ips: Vec, - pub detection_time: Instant, - pub severity: f32, - pub requests_count: u32, - pub geographic_spread: f32, - pub user_agents: Vec, -} - -/// Types d'attaques détectées -#[derive(Debug, Clone, PartialEq)] -pub enum AttackType { - /// Attaque DDoS classique - DDoS, - /// Spam de messages - MessageSpam, - /// Brute force sur l'authentification - BruteForce, - /// Scraping de données - Scraping, - /// Attaque par déni de service applicatif - SlowLoris, - /// Comportement suspect automatisé - BotActivity, -} - -/// Entrée de liste noire -#[derive(Debug, Clone)] -pub struct BlacklistEntry { - pub ip: IpAddr, - pub reason: String, - pub blocked_at: Instant, - pub expires_at: Option, - pub violation_count: u32, - pub auto_generated: bool, -} - -/// Événement de requête pour la détection de patterns -#[derive(Debug, Clone)] -pub struct RequestEvent { - pub timestamp: Instant, - pub request_type: String, - pub path: String, - pub user_agent: Option, - pub response_time: Duration, - pub status_code: u16, -} - -/// Configuration du rate limiting -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RateLimitConfig { - /// Messages par minute par utilisateur - pub messages_per_minute: u32, - /// Connexions par heure par IP - pub connections_per_hour: u32, - /// Tentatives d'auth par IP - pub auth_attempts_per_minute: u32, - /// Taille maximale des buckets - pub max_bucket_capacity: u32, - /// Seuil de détection d'attaque - pub attack_detection_threshold: f32, - /// Durée de blacklist automatique - pub auto_blacklist_duration: Duration, - /// Activar la géolocalisation - pub enable_geolocation: bool, - /// IPs de confiance (CDN, proxies autorisés) - pub trusted_ips: Vec, -} - -/// Métriques du rate limiting -#[derive(Debug, Default)] -pub struct RateLimitMetrics { - pub requests_processed: Arc, - pub requests_blocked: Arc, - pub attacks_detected: Arc, - pub false_positives: Arc, - pub avg_response_time: Arc>, -} - -/// Résultat d'une vérification de rate limit -#[derive(Debug, Clone)] -pub struct RateLimitResult { - pub allowed: bool, - pub reason: Option, - pub retry_after: Option, - pub remaining_tokens: u32, - pub burst_remaining: u32, - pub reputation_impact: f32, -} - -use std::collections::VecDeque; - -impl AdvancedRateLimiter { - /// Crée un nouveau rate limiter avancé - pub fn new(config: RateLimitConfig) -> Self { - Self { - ip_limiters: Arc::new(DashMap::new()), - user_limiters: Arc::new(DashMap::new()), - channel_limiters: Arc::new(DashMap::new()), - attack_patterns: Arc::new(DashMap::new()), - blacklist: Arc::new(DashMap::new()), - config: Arc::new(RwLock::new(config)), - metrics: Arc::new(RateLimitMetrics::default()), - } - } - - /// Vérifie si une requête est autorisée (point d'entrée principal) - pub async fn check_rate_limit( - &self, - ip: IpAddr, - user_id: Option, - channel_id: Option, - limit_type: LimitType, - request_info: RequestInfo, - ) -> Result { - let start_time = Instant::now(); - - // Incrémenter les métriques - self.metrics.requests_processed.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - - // 1. Vérifier la liste noire d'abord - if let Some(blacklist_entry) = self.blacklist.get(&ip) { - if blacklist_entry.expires_at.map_or(true, |exp| exp > Instant::now()) { - self.metrics.requests_blocked.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - return Ok(RateLimitResult { - allowed: false, - reason: Some(format!("IP blacklisted: {}", blacklist_entry.reason)), - retry_after: blacklist_entry.expires_at.map(|exp| exp.duration_since(Instant::now())), - remaining_tokens: 0, - burst_remaining: 0, - reputation_impact: -0.1, - }); - } else { - // Entrée expirée, la supprimer - self.blacklist.remove(&ip); - } - } - - // 2. Vérifier le rate limiting par IP - let ip_result = self.check_ip_rate_limit(ip, &limit_type, &request_info).await?; - if !ip_result.allowed { - return Ok(ip_result); - } - - // 3. Vérifier le rate limiting par utilisateur si applicable - if let Some(uid) = user_id { - let user_result = self.check_user_rate_limit(uid, &limit_type).await?; - if !user_result.allowed { - return Ok(user_result); - } - } - - // 4. Vérifier le rate limiting par canal si applicable - if let Some(cid) = channel_id { - let channel_result = self.check_channel_rate_limit(&cid, &limit_type).await?; - if !channel_result.allowed { - return Ok(channel_result); - } - } - - // 5. Analyser les patterns d'attaque - self.analyze_request_pattern(ip, &request_info).await?; - - // 6. Mettre à jour les métriques de performance - let elapsed = start_time.elapsed(); - *self.metrics.avg_response_time.lock() = elapsed; - - Ok(RateLimitResult { - allowed: true, - reason: None, - retry_after: None, - remaining_tokens: ip_result.remaining_tokens, - burst_remaining: ip_result.burst_remaining, - reputation_impact: 0.0, - }) - } - - /// Vérifie le rate limiting par IP - async fn check_ip_rate_limit( - &self, - ip: IpAddr, - limit_type: &LimitType, - request_info: &RequestInfo, - ) -> Result { - let config = self.config.read().await; - - // Récupérer ou créer le limiteur IP - let mut ip_limiter = self.ip_limiters.entry(ip).or_insert_with(|| { - IpRateLimiter::new(ip, &config) - }); - - // Vérifier le statut de l'IP - match ip_limiter.status { - IpStatus::Banned => { - return Ok(RateLimitResult { - allowed: false, - reason: Some("IP permanently banned".to_string()), - retry_after: None, - remaining_tokens: 0, - burst_remaining: 0, - reputation_impact: -0.2, - }); - } - IpStatus::Blacklisted => { - return Ok(RateLimitResult { - allowed: false, - reason: Some("IP temporarily blacklisted".to_string()), - retry_after: Some(Duration::from_secs(300)), // 5 minutes - remaining_tokens: 0, - burst_remaining: 0, - reputation_impact: -0.1, - }); - } - _ => {} - } - - // Appliquer le rate limiting avec token bucket - let remaining_tokens = { - let bucket = ip_limiter.buckets.get_mut(limit_type) - .ok_or_else(|| ChatError::internal_error(format!( - "Rate limit bucket not initialized for limit type: {:?}", - limit_type - )))?; - bucket.refill(); - - if bucket.tokens > 0 { - bucket.tokens -= 1; - bucket.tokens - } else { - 0 - } - }; - - if remaining_tokens > 0 { - ip_limiter.last_activity = Instant::now(); - - // Enregistrer l'événement pour l'analyse de patterns - ip_limiter.request_patterns.push_back(RequestEvent { - timestamp: Instant::now(), - request_type: format!("{:?}", limit_type), - path: request_info.path.clone(), - user_agent: request_info.user_agent.clone(), - response_time: Duration::from_millis(0), - status_code: 200, - }); - - // Garder seulement les 100 derniers événements - if ip_limiter.request_patterns.len() > 100 { - ip_limiter.request_patterns.pop_front(); - } - - Ok(RateLimitResult { - allowed: true, - reason: None, - retry_after: None, - remaining_tokens, - burst_remaining: remaining_tokens, - reputation_impact: 0.0, - }) - } else { - // Rate limit dépassé - ip_limiter.violation_count += 1; - - // Escalade automatique si trop de violations - if ip_limiter.violation_count >= 5 { - ip_limiter.status = IpStatus::Suspicious; - } - if ip_limiter.violation_count >= 10 { - self.auto_blacklist_ip(ip, "Too many violations".to_string()).await?; - } - - Ok(RateLimitResult { - allowed: false, - reason: Some(format!("Rate limit exceeded for {:?}", limit_type)), - retry_after: Some(Duration::from_secs(60)), - remaining_tokens: 0, - burst_remaining: 0, - reputation_impact: 0.0, - }) - } - } - - /// Vérifie le rate limiting par utilisateur - async fn check_user_rate_limit(&self, user_id: i64, limit_type: &LimitType) -> Result { - let config = self.config.read().await; - - let mut user_limiter = self.user_limiters.entry(user_id).or_insert_with(|| { - UserRateLimiter::new(user_id, &config) - }); - - // Vérifier la réputation d'abord - let capacity_multiplier = match user_limiter.reputation.level { - ReputationLevel::NewUser => 0.5, - ReputationLevel::Normal => 1.0, - ReputationLevel::Trusted => 1.5, - ReputationLevel::VIP => 2.0, - ReputationLevel::System => 5.0, - }; - - // Puis accéder au bucket avec la capacité ajustée - let remaining_tokens = { - let bucket = user_limiter.buckets.get_mut(limit_type) - .ok_or_else(|| ChatError::internal_error(format!( - "Rate limit bucket not initialized for limit type: {:?}", - limit_type - )))?; - bucket.capacity = (bucket.capacity as f32 * capacity_multiplier) as u32; - bucket.refill(); - - if bucket.tokens > 0 { - bucket.tokens -= 1; - bucket.tokens - } else { - 0 - } - }; - - if remaining_tokens > 0 { - user_limiter.last_activity = Instant::now(); - - Ok(RateLimitResult { - allowed: true, - reason: None, - retry_after: None, - remaining_tokens, - burst_remaining: remaining_tokens, - reputation_impact: 0.0, - }) - } else { - user_limiter.violation_count += 1; - user_limiter.reputation.violations_today += 1; - user_limiter.reputation.score = (user_limiter.reputation.score - 0.05).max(0.0); - - Ok(RateLimitResult { - allowed: false, - reason: Some(format!("User rate limit exceeded for {:?}", limit_type)), - retry_after: Some(Duration::from_secs(30)), - remaining_tokens: 0, - burst_remaining: 0, - reputation_impact: 0.0, - }) - } - } - - /// Vérifie le rate limiting par canal - async fn check_channel_rate_limit(&self, channel_id: &str, limit_type: &LimitType) -> Result { - let config = self.config.read().await; - - let mut channel_limiter = self.channel_limiters.entry(channel_id.to_string()).or_insert_with(|| { - ChannelRateLimiter::new(channel_id.to_string(), &config) - }); - - // Appliquer la modération selon le niveau du canal - let rate_multiplier = match channel_limiter.moderation_level { - ModerationLevel::Low => 1.5, - ModerationLevel::Normal => 1.0, - ModerationLevel::High => 0.5, - ModerationLevel::Lockdown => 0.1, - }; - - channel_limiter.message_bucket.refill(); - let tokens_needed = (1.0 / rate_multiplier) as u32; - - if channel_limiter.message_bucket.tokens >= tokens_needed { - channel_limiter.message_bucket.tokens -= tokens_needed; - channel_limiter.last_activity = Instant::now(); - - Ok(RateLimitResult { - allowed: true, - reason: None, - retry_after: None, - remaining_tokens: channel_limiter.message_bucket.tokens, - burst_remaining: channel_limiter.message_bucket.burst_allowance, - reputation_impact: 0.0, - }) - } else { - Ok(RateLimitResult { - allowed: false, - reason: Some(format!("Channel rate limit exceeded (moderation: {:?})", channel_limiter.moderation_level)), - retry_after: Some(Duration::from_secs(10)), - remaining_tokens: 0, - burst_remaining: 0, - reputation_impact: 0.0, - }) - } - } - - /// Analyse les patterns de requêtes pour détecter les attaques - async fn analyze_request_pattern(&self, ip: IpAddr, request_info: &RequestInfo) -> Result<()> { - // Récupérer l'historique des requêtes pour cette IP - if let Some(ip_limiter) = self.ip_limiters.get(&ip) { - let recent_requests: Vec<_> = ip_limiter.request_patterns.iter() - .filter(|event| event.timestamp.elapsed() < Duration::from_secs(60)) - .collect(); - - // Détecter différents types d'attaques - - // 1. DDoS - Trop de requêtes dans un court laps de temps - if recent_requests.len() > 100 { - self.detect_ddos_attack(ip, &recent_requests).await?; - } - - // 2. Brute Force - Tentatives répétées sur l'authentification - if request_info.path.contains("/auth") || request_info.path.contains("/login") { - let auth_attempts = recent_requests.iter() - .filter(|event| event.path.contains("/auth")) - .count(); - - if auth_attempts > 10 { - self.detect_brute_force_attack(ip).await?; - } - } - - // 3. Bot Activity - Patterns suspects (même User-Agent, timing régulier) - if let Some(user_agent) = &request_info.user_agent { - let same_ua_count = recent_requests.iter() - .filter(|event| event.user_agent.as_ref() == Some(user_agent)) - .count(); - - if same_ua_count > 50 && self.is_suspicious_user_agent(user_agent) { - self.detect_bot_activity(ip, user_agent.clone()).await?; - } - } - } - - Ok(()) - } - - /// Détecte une attaque DDoS - async fn detect_ddos_attack(&self, ip: IpAddr, recent_requests: &[&RequestEvent]) -> Result<()> { - let pattern = AttackPattern { - pattern_id: format!("ddos_{}_{}",ip, Instant::now().elapsed().as_secs()), - pattern_type: AttackType::DDoS, - source_ips: vec![ip], - detection_time: Instant::now(), - severity: 0.9, - requests_count: recent_requests.len() as u32, - geographic_spread: 0.0, // Single IP - user_agents: recent_requests.iter() - .filter_map(|event| event.user_agent.clone()) - .collect::>() - .into_iter() - .collect(), - }; - - self.attack_patterns.insert(pattern.pattern_id.clone(), pattern); - self.auto_blacklist_ip(ip, "DDoS attack detected".to_string()).await?; - - self.metrics.attacks_detected.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - tracing::warn!("DDoS attack detected from IP: {}", ip); - - Ok(()) - } - - /// Détecte une attaque de brute force - async fn detect_brute_force_attack(&self, ip: IpAddr) -> Result<()> { - let pattern = AttackPattern { - pattern_id: format!("bruteforce_{}_{}", ip, Instant::now().elapsed().as_secs()), - pattern_type: AttackType::BruteForce, - source_ips: vec![ip], - detection_time: Instant::now(), - severity: 0.8, - requests_count: 10, - geographic_spread: 0.0, - user_agents: vec![], - }; - - self.attack_patterns.insert(pattern.pattern_id.clone(), pattern); - self.auto_blacklist_ip(ip, "Brute force attack detected".to_string()).await?; - - tracing::warn!("Brute force attack detected from IP: {}", ip); - Ok(()) - } - - /// Détecte une activité de bot - async fn detect_bot_activity(&self, ip: IpAddr, user_agent: String) -> Result<()> { - let pattern = AttackPattern { - pattern_id: format!("bot_{}_{}", ip, Instant::now().elapsed().as_secs()), - pattern_type: AttackType::BotActivity, - source_ips: vec![ip], - detection_time: Instant::now(), - severity: 0.6, - requests_count: 50, - geographic_spread: 0.0, - user_agents: vec![user_agent], - }; - - self.attack_patterns.insert(pattern.pattern_id.clone(), pattern); - - // Marquer l'IP comme suspecte plutôt que de la blacklister immédiatement - if let Some(mut ip_limiter) = self.ip_limiters.get_mut(&ip) { - ip_limiter.status = IpStatus::Suspicious; - ip_limiter.trust_score = (ip_limiter.trust_score - 0.3).max(0.0); - } - - tracing::info!("Bot activity detected from IP: {}", ip); - Ok(()) - } - - /// Vérifie si un User-Agent est suspect - fn is_suspicious_user_agent(&self, user_agent: &str) -> bool { - let suspicious_patterns = [ - "bot", "crawler", "spider", "scraper", "curl", "wget", "python", "java", - "headless", "selenium", "phantom", "automated" - ]; - - let ua_lower = user_agent.to_lowercase(); - suspicious_patterns.iter().any(|pattern| ua_lower.contains(pattern)) - } - - /// Ajoute automatiquement une IP à la liste noire - async fn auto_blacklist_ip(&self, ip: IpAddr, reason: String) -> Result<()> { - let reason_clone = reason.clone(); - let config = self.config.read().await; - - let blacklist_entry = BlacklistEntry { - ip, - reason, - blocked_at: Instant::now(), - expires_at: Some(Instant::now() + config.auto_blacklist_duration), - violation_count: 1, - auto_generated: true, - }; - - self.blacklist.insert(ip, blacklist_entry); - self.metrics.attacks_detected.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - - tracing::warn!("IP {} automatically blacklisted: {}", ip, reason_clone); - Ok(()) - } - - /// Nettoie les entrées expirées - pub async fn cleanup_expired_entries(&self) { - let now = Instant::now(); - - // Nettoyer les blacklists expirées - self.blacklist.retain(|_, entry| { - entry.expires_at.map_or(true, |exp| exp > now) - }); - - // Nettoyer les limiteurs inactifs (plus de 1 heure) - self.ip_limiters.retain(|_, limiter| { - now.duration_since(limiter.last_activity) < Duration::from_secs(3600) - }); - - self.user_limiters.retain(|_, limiter| { - now.duration_since(limiter.last_activity) < Duration::from_secs(3600) - }); - - self.channel_limiters.retain(|_, limiter| { - now.duration_since(limiter.last_activity) < Duration::from_secs(3600) - }); - } -} - -/// Informations sur une requête -#[derive(Debug, Clone)] -pub struct RequestInfo { - pub path: String, - pub user_agent: Option, - pub method: String, - pub content_length: Option, -} - -impl TokenBucket { - pub fn new(capacity: u32, refill_rate: f32, burst_allowance: u32) -> Self { - Self { - capacity, - tokens: capacity, - refill_rate, - last_refill: Instant::now(), - burst_allowance, - } - } - - pub fn refill(&mut self) { - let now = Instant::now(); - let elapsed = now.duration_since(self.last_refill).as_secs_f32(); - let tokens_to_add = (elapsed * self.refill_rate) as u32; - - if tokens_to_add > 0 { - self.tokens = (self.tokens + tokens_to_add).min(self.capacity); - self.last_refill = now; - } - } -} - -impl IpRateLimiter { - pub fn new(ip: IpAddr, config: &RateLimitConfig) -> Self { - let mut buckets = HashMap::new(); - - // Créer les buckets pour différents types de limitations - buckets.insert(LimitType::MessagesPerMinute, TokenBucket::new(config.messages_per_minute, config.messages_per_minute as f32 / 60.0, 10)); - buckets.insert(LimitType::ConnectionsPerHour, TokenBucket::new(config.connections_per_hour, config.connections_per_hour as f32 / 3600.0, 5)); - buckets.insert(LimitType::AuthAttempts, TokenBucket::new(config.auth_attempts_per_minute, config.auth_attempts_per_minute as f32 / 60.0, 2)); - buckets.insert(LimitType::ApiRequests, TokenBucket::new(1000, 16.67, 50)); // 1000/min - buckets.insert(LimitType::FileUploads, TokenBucket::new(10, 0.17, 2)); // 10/min - - Self { - ip, - buckets, - last_activity: Instant::now(), - violation_count: 0, - trust_score: 0.5, // Score neutre initial - request_patterns: VecDeque::new(), - status: IpStatus::Normal, - } - } -} - -impl UserRateLimiter { - pub fn new(user_id: i64, config: &RateLimitConfig) -> Self { - let mut buckets = HashMap::new(); - - buckets.insert(LimitType::MessagesPerMinute, TokenBucket::new(config.messages_per_minute, config.messages_per_minute as f32 / 60.0, 5)); - buckets.insert(LimitType::FileUploads, TokenBucket::new(20, 0.33, 3)); // 20/min - buckets.insert(LimitType::ChannelCreation, TokenBucket::new(5, 0.083, 1)); // 5/min - buckets.insert(LimitType::Invitations, TokenBucket::new(10, 0.17, 2)); // 10/min - buckets.insert(LimitType::Reactions, TokenBucket::new(60, 1.0, 10)); // 60/min - - Self { - user_id, - buckets, - last_activity: Instant::now(), - violation_count: 0, - reputation: UserReputation { - score: 0.5, - level: ReputationLevel::NewUser, - violations_today: 0, - positive_actions: 0, - last_violation: None, - }, - daily_limits: DailyLimits { - messages_sent: 0, - max_messages: 1000, - files_uploaded: 0, - max_files: 50, - reset_time: Instant::now() + Duration::from_secs(86400), // 24h - }, - } - } -} - -impl ChannelRateLimiter { - pub fn new(channel_id: String, config: &RateLimitConfig) -> Self { - Self { - channel_id, - message_bucket: TokenBucket::new(config.messages_per_minute * 10, (config.messages_per_minute * 10) as f32 / 60.0, 20), - concurrent_users: 0, - last_activity: Instant::now(), - spam_threshold: 0.7, - moderation_level: ModerationLevel::Normal, - } - } -} - -impl Default for RateLimitConfig { - fn default() -> Self { - Self { - messages_per_minute: 30, - connections_per_hour: 100, - auth_attempts_per_minute: 5, - max_bucket_capacity: 1000, - attack_detection_threshold: 0.8, - auto_blacklist_duration: Duration::from_secs(900), // 15 minutes - enable_geolocation: true, - trusted_ips: vec![], - } - } -} \ No newline at end of file diff --git a/veza-chat-server/src/core/channels.rs b/veza-chat-server/src/core/channels.rs deleted file mode 100644 index 45bc67dbb..000000000 --- a/veza-chat-server/src/core/channels.rs +++ /dev/null @@ -1,495 +0,0 @@ -//! Système de channels Discord-like avancé -//! -//! Ce module implémente un système de channels complet avec : -//! - Types de channels variés (Text, Voice, Stage, Forum, etc.) -//! - Permissions granulaires par rôle et utilisateur -//! - Catégories et organisation hiérarchique -//! - Support vocal avec gestion des membres connectés -//! - Slow mode et limitations -//! - Statistiques détaillées - -use std::collections::{HashMap, HashSet}; -use std::sync::Arc; -use serde::{Deserialize, Serialize}; -use chrono::{DateTime, Utc}; -use dashmap::DashMap; -use uuid::Uuid; - -use crate::permissions::{Permission, UserPermissions}; -use crate::error::{ChatError, Result}; - -/// Types de channels disponibles (Discord-like) -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum ChannelType { - /// Channel texte standard - Text, - /// Channel vocal - Voice, - /// Channel d'annonces (un seul sens) - Announcement, - /// Channel stage pour events - Stage, - /// Channel forum avec threads - Forum, - /// Channel de news - News, - /// Channel privé (DM) - DirectMessage, -} - -/// Configuration d'un channel -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChannelConfig { - /// Nom du channel - pub name: String, - /// Description/Topic - pub topic: Option, - /// Type de channel - pub channel_type: ChannelType, - /// NSFW ? - pub nsfw: bool, - /// Slow mode (secondes entre messages) - pub slowmode_delay: Option, - /// Bitrate pour les channels vocaux (kbps) - pub bitrate: Option, - /// Limite d'utilisateurs pour channels vocaux - pub user_limit: Option, - /// Position dans la liste - pub position: u32, - /// ID de la catégorie parent - pub parent_id: Option, -} - -/// Permissions spécifiques à un channel -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChannelPermissions { - /// Permissions par rôle - pub role_permissions: HashMap>, - /// Permissions par utilisateur (overrides) - pub user_permissions: HashMap>, -} - -/// Permissions granulaires pour les channels -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum ChannelPermission { - // Permissions générales - ViewChannel, - ManageChannel, - ManagePermissions, - CreateInvite, - - // Messages - SendMessages, - SendTTSMessages, - ManageMessages, - EmbedLinks, - AttachFiles, - ReadMessageHistory, - MentionEveryone, - UseExternalEmojis, - UseExternalStickers, - AddReactions, - UseSlashCommands, - UseThreads, - CreatePublicThreads, - CreatePrivateThreads, - SendMessagesInThreads, - - // Vocal - Connect, - Speak, - MuteMembers, - DeafenMembers, - MoveMembers, - UseVoiceActivity, - Priorityspeaker, - Stream, - UseEmbeddedActivities, - UseSoundboard, - - // Avancé - ManageWebhooks, - ManageEvents, - RequestToSpeak, -} - -/// Structure d'un channel Discord-like -#[derive(Debug, Clone, Serialize)] -pub struct Channel { - pub id: String, - pub config: ChannelConfig, - pub permissions: ChannelPermissions, - pub created_at: DateTime, - pub last_message_id: Option, - pub last_activity: DateTime, - - /// Membres connectés (pour channels vocaux) - #[serde(skip)] - pub connected_members: Arc>, - - /// Statistiques du channel - pub stats: ChannelStats, -} - -/// Membre connecté à un channel vocal -#[derive(Debug, Clone, Serialize)] -pub struct VoiceMember { - pub user_id: i64, - pub username: String, - pub joined_at: DateTime, - pub is_muted: bool, - pub is_deafened: bool, - pub is_streaming: bool, - pub is_camera_on: bool, -} - -/// Statistiques d'un channel -#[derive(Debug, Default, Clone, Serialize)] -pub struct ChannelStats { - pub total_messages: u64, - pub total_members: u64, - pub active_members_today: u64, - pub peak_concurrent_users: u64, - pub last_peak_at: Option>, -} - -/// Gestionnaire de channels -#[derive(Debug)] -pub struct ChannelManager { - /// Channels par ID - channels: Arc>, - /// Index des channels par serveur - server_channels: Arc>>, - /// Catégories - categories: Arc>, -} - -/// Catégorie de channels -#[derive(Debug, Clone, Serialize)] -pub struct ChannelCategory { - pub id: String, - pub name: String, - pub position: u32, - pub server_id: String, - pub created_at: DateTime, -} - -impl Default for ChannelManager { - fn default() -> Self { - Self::new() - } -} - -impl ChannelManager { - pub fn new() -> Self { - Self { - channels: Arc::new(DashMap::new()), - server_channels: Arc::new(DashMap::new()), - categories: Arc::new(DashMap::new()), - } - } - - /// Crée un nouveau channel - pub async fn create_channel( - &self, - server_id: &str, - config: ChannelConfig, - _creator_id: i64, - creator_permissions: &UserPermissions, - ) -> Result { - // Vérifier les permissions - if !creator_permissions.has_permission(&Permission::ManageChannels) { - return Err(ChatError::unauthorized_simple("insufficient_permissions")); - } - - let channel_id = format!("ch_{}", Uuid::new_v4()); - - let channel = Channel { - id: channel_id.clone(), - config, - permissions: ChannelPermissions { - role_permissions: HashMap::new(), - user_permissions: HashMap::new(), - }, - created_at: Utc::now(), - last_message_id: None, - last_activity: Utc::now(), - connected_members: Arc::new(DashMap::new()), - stats: ChannelStats::default(), - }; - - // Ajouter le channel - self.channels.insert(channel_id.clone(), channel); - - // Indexer par serveur - self.server_channels - .entry(server_id.to_string()) - .or_default() - .insert(channel_id.clone()); - - Ok(channel_id) - } - - /// Vérifie si un utilisateur peut voir un channel - pub fn can_view_channel(&self, channel_id: &str, user_permissions: &UserPermissions) -> bool { - if let Some(channel) = self.channels.get(channel_id) { - self.check_channel_permission( - &channel.permissions, - user_permissions, - &ChannelPermission::ViewChannel, - ) - } else { - false - } - } - - /// Vérifie si un utilisateur peut envoyer des messages dans un channel - pub fn can_send_messages(&self, channel_id: &str, user_permissions: &UserPermissions) -> bool { - if let Some(channel) = self.channels.get(channel_id) { - self.check_channel_permission( - &channel.permissions, - user_permissions, - &ChannelPermission::SendMessages, - ) - } else { - false - } - } - - /// Joint un utilisateur à un channel vocal - pub async fn join_voice_channel( - &self, - channel_id: &str, - user_id: i64, - username: String, - user_permissions: &UserPermissions, - ) -> Result<()> { - let channel = self.channels.get(channel_id) - .ok_or_else(|| ChatError::not_found_simple("channel_not_found"))?; - - // Vérifier le type de channel - if !matches!(channel.config.channel_type, ChannelType::Voice | ChannelType::Stage) { - return Err(ChatError::validation_error("not_voice_channel")); - } - - // Vérifier les permissions - if !self.check_channel_permission( - &channel.permissions, - user_permissions, - &ChannelPermission::Connect, - ) { - return Err(ChatError::unauthorized_simple("cannot_connect")); - } - - // Vérifier la limite d'utilisateurs - if let Some(limit) = channel.config.user_limit { - if channel.connected_members.len() >= limit as usize { - return Err(ChatError::validation_error("channel_full")); - } - } - - let voice_member = VoiceMember { - user_id, - username, - joined_at: Utc::now(), - is_muted: false, - is_deafened: false, - is_streaming: false, - is_camera_on: false, - }; - - channel.connected_members.insert(user_id, voice_member); - - Ok(()) - } - - /// Quitte un channel vocal - pub async fn leave_voice_channel(&self, channel_id: &str, user_id: i64) -> Result<()> { - if let Some(channel) = self.channels.get(channel_id) { - channel.connected_members.remove(&user_id); - } - Ok(()) - } - - /// Met à jour les permissions d'un channel - pub async fn update_channel_permissions( - &self, - channel_id: &str, - target_type: PermissionTargetType, - target_id: String, - permissions: HashSet, - user_permissions: &UserPermissions, - ) -> Result<()> { - if !user_permissions.has_permission(&Permission::ManageChannels) { - return Err(ChatError::unauthorized_simple("insufficient_permissions")); - } - - if let Some(mut channel) = self.channels.get_mut(channel_id) { - match target_type { - PermissionTargetType::Role => { - channel.permissions.role_permissions.insert(target_id, permissions); - } - PermissionTargetType::User => { - if let Ok(user_id) = target_id.parse::() { - channel.permissions.user_permissions.insert(user_id, permissions); - } - } - } - } - - Ok(()) - } - - /// Active le slow mode sur un channel - pub async fn set_slowmode( - &self, - channel_id: &str, - delay_seconds: Option, - user_permissions: &UserPermissions, - ) -> Result<()> { - if !user_permissions.has_permission(&Permission::ManageChannels) { - return Err(ChatError::unauthorized_simple("insufficient_permissions")); - } - - if let Some(mut channel) = self.channels.get_mut(channel_id) { - channel.config.slowmode_delay = delay_seconds; - } - - Ok(()) - } - - /// Obtient les channels d'un serveur organisés par catégories - pub fn get_server_channels(&self, server_id: &str) -> Vec { - let mut result = Vec::new(); - - if let Some(channel_ids) = self.server_channels.get(server_id) { - for channel_id in channel_ids.iter() { - if let Some(channel) = self.channels.get(channel_id) { - let category = channel.config.parent_id.as_ref() - .and_then(|id| self.categories.get(id)) - .map(|cat| cat.value().clone()); - - result.push(ChannelWithCategory { - channel: channel.value().clone(), - category, - }); - } - } - } - - // Trier par position - result.sort_by_key(|ch| ch.channel.config.position); - result - } - - /// Vérifie une permission spécifique pour un channel - fn check_channel_permission( - &self, - channel_permissions: &ChannelPermissions, - user_permissions: &UserPermissions, - required_permission: &ChannelPermission, - ) -> bool { - // Permissions utilisateur spécifiques (override) - if let Some(user_perms) = channel_permissions.user_permissions.get(&user_permissions.user_id) { - return user_perms.contains(required_permission); - } - - // Permissions de rôle - for role in &user_permissions.roles { - let role_str = format!("{:?}", role); - if let Some(role_perms) = channel_permissions.role_permissions.get(&role_str) { - if role_perms.contains(required_permission) { - return true; - } - } - } - - // Permissions par défaut (everyone peut voir les channels publics) - matches!(required_permission, ChannelPermission::ViewChannel | ChannelPermission::SendMessages) - } -} - -/// Type de cible pour les permissions -#[derive(Debug, Clone)] -pub enum PermissionTargetType { - Role, - User, -} - -/// Channel avec sa catégorie -#[derive(Debug, Clone, Serialize)] -pub struct ChannelWithCategory { - pub channel: Channel, - pub category: Option, -} - -impl Default for ChannelType { - fn default() -> Self { - Self::Text - } -} - -impl Default for ChannelConfig { - fn default() -> Self { - Self { - name: "general".to_string(), - topic: None, - channel_type: ChannelType::Text, - nsfw: false, - slowmode_delay: None, - bitrate: Some(64), // 64 kbps par défaut - user_limit: None, - position: 0, - parent_id: None, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::permissions::{Role, UserPermissions}; - - #[tokio::test] - async fn test_channel_creation() { - let manager = ChannelManager::new(); - let mut permissions = UserPermissions::new_user(123); - permissions.add_role(Role::Admin); - - let config = ChannelConfig { - name: "test-channel".to_string(), - channel_type: ChannelType::Text, - ..Default::default() - }; - - let channel_id = manager.create_channel("server1", config, 123, &permissions) - .await.unwrap(); - - assert!(manager.channels.contains_key(&channel_id)); - assert!(manager.can_view_channel(&channel_id, &permissions)); - } - - #[tokio::test] - async fn test_voice_channel_join() { - let manager = ChannelManager::new(); - let mut permissions = UserPermissions::new_user(123); - permissions.add_role(Role::User); - - let config = ChannelConfig { - name: "voice-channel".to_string(), - channel_type: ChannelType::Voice, - user_limit: Some(10), - ..Default::default() - }; - - let channel_id = manager.create_channel("server1", config, 123, &permissions) - .await.unwrap(); - - manager.join_voice_channel(&channel_id, 123, "testuser".to_string(), &permissions) - .await.unwrap(); - - if let Some(channel) = manager.channels.get(&channel_id) { - assert_eq!(channel.connected_members.len(), 1); - } - } -} \ No newline at end of file diff --git a/veza-chat-server/src/core/connection.rs b/veza-chat-server/src/core/connection.rs deleted file mode 100644 index 44a82c5e6..000000000 --- a/veza-chat-server/src/core/connection.rs +++ /dev/null @@ -1,263 +0,0 @@ -//! Connection Manager Production-Ready -//! -//! Gestionnaire de connexions optimisé pour 100k+ WebSocket simultanées -//! avec zero-copy broadcasting et métriques en temps réel. - -use std::sync::Arc; -use std::time::Duration; -use std::collections::HashSet; -use dashmap::DashMap; -use tokio::sync::{RwLock, broadcast}; -use uuid::Uuid; -use bytes::Bytes; -use serde::{Serialize, Deserialize}; -use tracing::{info, warn, error, debug}; -use chrono::{DateTime, Utc}; - -use crate::error::ChatError; - -/// Gestionnaire principal des connexions WebSocket -/// Optimisé pour 100k+ connexions simultanées -#[derive(Debug, Clone)] -pub struct ConnectionManager { - /// Connexions actives indexées par ID - connections: Arc>, - - /// Salles de chat avec leurs membres - rooms: Arc>, - - /// Broadcaster pour diffusion efficace - broadcaster: Arc, - - /// Configuration - config: Arc, -} - -/// Configuration du gestionnaire de connexions -#[derive(Debug, Clone)] -pub struct ConnectionConfig { - /// Nombre maximum de connexions simultanées - pub max_connections: usize, - - /// Timeout d'inactivité avant déconnexion - pub idle_timeout: Duration, - - /// Taille du buffer de diffusion - pub broadcast_buffer_size: usize, - - /// Limite de messages par seconde par connexion - pub rate_limit_per_second: u32, -} - -impl Default for ConnectionConfig { - fn default() -> Self { - Self { - max_connections: 100_000, // 100k connexions - idle_timeout: Duration::from_secs(300), // 5 minutes - broadcast_buffer_size: 1024, - rate_limit_per_second: 10, - } - } -} - -/// Connexion utilisateur individuelle -pub struct UserConnection { - /// Identifiant unique de la connexion - pub id: Uuid, - - /// ID de l'utilisateur connecté - pub user_id: i64, - - /// Sender pour envoyer des messages à ce client - pub sender: broadcast::Sender, - - /// Rate limiter individuel - pub rate_limiter: Arc, - - /// Dernière activité - pub last_activity: DateTime, - - /// Salles auxquelles l'utilisateur est abonné - pub subscriptions: HashSet, - - /// Métadonnées de connexion - pub metadata: ConnectionMetadata, -} - -/// Métadonnées de connexion -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConnectionMetadata { - pub ip_address: String, - pub user_agent: String, - pub connected_at: DateTime, - pub platform: String, -} - -pub use super::room::*; - -/// Rate limiter par connexion -pub struct RateLimiter { - tokens: Arc>, - last_refill: Arc>>, - rate: f64, - burst: f64, -} - -/// Optimiseur de diffusion zero-copy -pub struct BroadcastOptimizer { - /// Cache de messages pré-sérialisés - message_cache: Arc>, - - /// Groupes de connexions pour routage efficace - connection_groups: Arc>>>, -} - -impl ConnectionManager { - /// Crée un nouveau gestionnaire de connexions - pub fn new(config: ConnectionConfig) -> Self { - Self { - connections: Arc::new(DashMap::new()), - rooms: Arc::new(DashMap::new()), - broadcaster: Arc::new(BroadcastOptimizer::new()), - config: Arc::new(config), - } - } - - /// Ajoute une nouvelle connexion - pub async fn add_connection( - &self, - user_id: i64, - metadata: ConnectionMetadata, - ) -> Result<(Uuid, broadcast::Receiver), ChatError> { - // Vérifier la limite de connexions - if self.connections.len() >= self.config.max_connections { - return Err(ChatError::configuration_error("Maximum connections reached")); - } - - let connection_id = Uuid::new_v4(); - let (sender, receiver) = broadcast::channel(self.config.broadcast_buffer_size); - - let connection = UserConnection { - id: connection_id, - user_id, - sender, - rate_limiter: Arc::new(RateLimiter::new( - self.config.rate_limit_per_second as f64, - 10.0, // burst - )), - last_activity: Utc::now(), - subscriptions: HashSet::new(), - metadata, - }; - - self.connections.insert(connection_id, connection); - - info!( - connection_id = %connection_id, - user_id = user_id, - total_connections = self.connections.len(), - "🔌 Nouvelle connexion établie" - ); - - Ok((connection_id, receiver)) - } - - /// Diffuse un message à une salle avec parallélisation rayon - pub async fn broadcast_to_room( - &self, - room_id: &str, - message: Bytes, - ) -> Result { - let start = Utc::now(); - let mut sent_count = 0; - - if let Some(room) = self.rooms.get(room_id) { - // Utiliser rayon pour diffusion parallèle optimisée - use rayon::prelude::*; - - let member_ids: Vec = room.members.iter() - .map(|entry| *entry.key()) - .collect(); - - sent_count = member_ids.par_iter() - .map(|&connection_id| { - if let Some(connection) = self.connections.get(&connection_id) { - match connection.sender.send(message.clone()) { - Ok(_) => 1, - Err(_) => 0 - } - } else { - 0 - } - }) - .sum(); - } - - let duration = Utc::now().signed_duration_since(start).num_milliseconds() as u128; - debug!( - room_id = room_id, - recipients = sent_count, - duration_ms = duration.as_millis(), - "📡 Message diffusé" - ); - - Ok(sent_count) - } - - /// Statistiques en temps réel - pub fn get_stats(&self) -> ConnectionStats { - ConnectionStats { - active_connections: self.connections.len(), - active_rooms: self.rooms.len(), - total_members: self.rooms.iter() - .map(|room| room.members.len()) - .sum(), - } - } -} - -/// Statistiques de connexion -#[derive(Debug, Serialize)] -pub struct ConnectionStats { - pub active_connections: usize, - pub active_rooms: usize, - pub total_members: usize, -} - -impl BroadcastOptimizer { - pub fn new() -> Self { - Self { - message_cache: Arc::new(DashMap::new()), - connection_groups: Arc::new(DashMap::new()), - } - } -} - -impl RateLimiter { - pub fn new(rate: f64, burst: f64) -> Self { - Self { - tokens: Arc::new(parking_lot::Mutex::new(burst)), - last_refill: Arc::new(parking_lot::Mutex::new(Utc::now())), - rate, - burst, - } - } - - pub fn check_rate_limit(&self) -> bool { - let now = Utc::now(); - let mut tokens = self.tokens.lock(); - let mut last_refill = self.last_refill.lock(); - - // Token bucket algorithm - let elapsed = now.signed_duration_since(*last_refill).num_seconds() as f64; - *tokens = (*tokens + elapsed * self.rate).min(self.burst); - *last_refill = now; - - if *tokens >= 1.0 { - *tokens -= 1.0; - true - } else { - false - } - } -} diff --git a/veza-chat-server/src/core/encryption.rs b/veza-chat-server/src/core/encryption.rs deleted file mode 100644 index 231e66858..000000000 --- a/veza-chat-server/src/core/encryption.rs +++ /dev/null @@ -1,504 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; -use serde::{Deserialize, Serialize}; -use dashmap::DashMap; -use ring::{aead, rand::{SystemRandom, SecureRandom}}; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; -use tokio::sync::RwLock; - -use crate::error::{ChatError, Result}; - -/// Service de chiffrement bout-en-bout -#[derive(Debug)] -pub struct E2EEncryptionService { - /// Générateur de nombres aléatoires sécurisé - rng: SystemRandom, - - /// Sessions de chiffrement actives par channel - encryption_sessions: Arc>, - - /// Clés publiques des utilisateurs - user_public_keys: Arc>, - - /// Préférences de chiffrement par utilisateur - user_preferences: Arc>, -} - -/// Session de chiffrement pour un channel -#[derive(Debug, Clone)] -pub struct EncryptionSession { - /// ID unique de la session - pub session_id: String, - - /// Channel concerné - pub channel_id: String, - - /// Participants à la session - pub participants: Vec, - - /// Clés de session partagées (chiffrées pour chaque participant) - pub encrypted_keys: HashMap>, - - /// Algorithme de chiffrement utilisé - pub algorithm: EncryptionAlgorithm, - - /// Statut de la session - pub status: SessionStatus, -} - -/// Paire de clés d'un utilisateur -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UserKeyPair { - /// ID de l'utilisateur - pub user_id: i64, - - /// Clé publique (pour chiffrement asymétrique) - pub public_key: Vec, - - /// Fingerprint de la clé pour vérification - pub fingerprint: String, - - /// Statut de la clé - pub status: KeyStatus, -} - -/// Préférences de chiffrement d'un utilisateur -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EncryptionPreferences { - /// Chiffrement activé par défaut - pub enabled_by_default: bool, - - /// Algorithme préféré - pub preferred_algorithm: EncryptionAlgorithm, - - /// Rotation automatique des clés - pub auto_key_rotation: bool, - - /// Période de rotation (en jours) - pub rotation_period_days: u32, - - /// Vérification des empreintes obligatoire - pub require_fingerprint_verification: bool, - - /// Channels où le chiffrement est obligatoire - pub mandatory_channels: Vec, -} - -/// Algorithmes de chiffrement supportés -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum EncryptionAlgorithm { - /// AES-256-GCM (recommandé) - AES256GCM, - - /// ChaCha20-Poly1305 (alternative) - ChaCha20Poly1305, -} - -/// Statut d'une session de chiffrement -#[derive(Debug, Clone, PartialEq)] -pub enum SessionStatus { - /// Session active - Active, - - /// Session expirée - Expired, - - /// Session révoquée - Revoked, -} - -/// Statut d'une clé -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum KeyStatus { - /// Clé active - Active, - - /// Clé révoquée - Revoked, - - /// Clé expirée - Expired, -} - -/// Message chiffré -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EncryptedMessage { - /// ID du message - pub message_id: String, - - /// ID de la session de chiffrement - pub session_id: String, - - /// Contenu chiffré - pub encrypted_content: Vec, - - /// Nonce utilisé pour le chiffrement - pub nonce: Vec, - - /// Tag d'authentification - pub auth_tag: Vec, - - /// Algorithme utilisé - pub algorithm: EncryptionAlgorithm, -} - -/// Métadonnées non chiffrées d'un message -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageMetadata { - /// ID de l'expéditeur - pub sender_id: i64, - - /// Timestamp d'envoi - pub timestamp: chrono::DateTime, - - /// Type de message - pub message_type: String, - - /// Taille du contenu original - pub content_size: usize, -} - -/// Résultat d'une opération de chiffrement -#[derive(Debug)] -pub struct EncryptionResult { - /// Message chiffré - pub encrypted_message: EncryptedMessage, - - /// Clés de session pour les participants - pub session_keys: HashMap>, - - /// Participants qui ont reçu les clés - pub delivered_to: Vec, -} - -impl E2EEncryptionService { - /// Crée un nouveau service de chiffrement - pub fn new() -> Self { - Self { - rng: SystemRandom::new(), - encryption_sessions: Arc::new(DashMap::new()), - user_public_keys: Arc::new(DashMap::new()), - user_preferences: Arc::new(DashMap::new()), - } - } - - /// Génère une nouvelle paire de clés pour un utilisateur - pub async fn generate_user_keypair(&self, user_id: i64) -> Result { - let mut public_key = vec![0u8; 32]; - self.rng.fill(&mut public_key) - .map_err(|_| ChatError::internal_error("Failed to generate key"))?; - - let fingerprint = self.generate_fingerprint(&public_key); - - let keypair = UserKeyPair { - user_id, - public_key, - fingerprint, - status: KeyStatus::Active, - }; - - self.user_public_keys.insert(user_id, keypair.clone()); - Ok(keypair) - } - - /// Établit une session de chiffrement pour un channel - pub async fn establish_session( - &self, - channel_id: String, - participants: Vec, - algorithm: EncryptionAlgorithm, - ) -> Result { - for &user_id in &participants { - if !self.user_public_keys.contains_key(&user_id) { - return Err(ChatError::validation_error( - &format!("User {} has no public key", user_id) - )); - } - } - - let session_key = self.generate_session_key()?; - let mut encrypted_keys = HashMap::new(); - - for &user_id in &participants { - if let Some(user_keypair) = self.user_public_keys.get(&user_id) { - let encrypted_key = self.encrypt_session_key(&session_key, &user_keypair.public_key)?; - encrypted_keys.insert(user_id, encrypted_key); - } - } - - let session = EncryptionSession { - session_id: format!("session_{}", uuid::Uuid::new_v4()), - channel_id: channel_id.clone(), - participants, - encrypted_keys, - algorithm, - status: SessionStatus::Active, - }; - - self.encryption_sessions.insert(channel_id, session.clone()); - Ok(session) - } - - /// Chiffre un message pour un channel - pub async fn encrypt_message( - &self, - channel_id: &str, - sender_id: i64, - content: &str, - message_id: String, - ) -> Result { - let session = self.encryption_sessions.get(channel_id) - .ok_or_else(|| ChatError::not_found("session", "session_not_found"))?; - - if !session.participants.contains(&sender_id) { - return Err(ChatError::permission_denied("User not authorized")); - } - - let nonce = self.generate_nonce()?; - let session_key = &session.encrypted_keys[&sender_id]; - - let (encrypted_content, auth_tag) = self.encrypt_content( - content.as_bytes(), - session_key, - &nonce, - &session.algorithm, - )?; - - Ok(EncryptedMessage { - message_id, - session_id: session.session_id.clone(), - encrypted_content, - nonce, - auth_tag, - algorithm: session.algorithm.clone(), - }) - } - - /// Déchiffre un message - pub async fn decrypt_message( - &self, - encrypted_message: &EncryptedMessage, - recipient_id: i64, - ) -> Result { - let session = self.encryption_sessions.iter() - .find(|entry| entry.value().session_id == encrypted_message.session_id) - .ok_or_else(|| ChatError::not_found("session", "session_not_found"))?; - - if !session.participants.contains(&recipient_id) { - return Err(ChatError::permission_denied("Not authorized")); - } - - let encrypted_session_key = session.encrypted_keys.get(&recipient_id) - .ok_or_else(|| ChatError::not_found("session_key", "key_not_found"))?; - - let decrypted_content = self.decrypt_content( - &encrypted_message.encrypted_content, - &encrypted_message.auth_tag, - encrypted_session_key, - &encrypted_message.nonce, - &encrypted_message.algorithm, - )?; - - String::from_utf8(decrypted_content) - .map_err(|_| ChatError::internal_error("Invalid decrypted content")) - } - - /// Révoque une session de chiffrement - pub async fn revoke_session(&self, channel_id: &str, revoked_by: i64) -> Result<()> { - if let Some(mut session) = self.encryption_sessions.get_mut(channel_id) { - // Vérifier que l'utilisateur peut révoquer la session - if !session.participants.contains(&revoked_by) { - return Err(ChatError::permission_denied("Non autorisé à révoquer cette session")); - } - - session.status = SessionStatus::Revoked; - tracing::info!("Session {} révoquée par l'utilisateur {}", session.session_id, revoked_by); - } - - Ok(()) - } - - /// Configure les préférences de chiffrement d'un utilisateur - pub async fn set_user_preferences( - &self, - user_id: i64, - preferences: EncryptionPreferences, - ) -> Result<()> { - self.user_preferences.insert(user_id, preferences); - Ok(()) - } - - /// Vérifie si le chiffrement est requis pour un channel - pub fn is_encryption_required(&self, channel_id: &str, user_id: i64) -> bool { - if let Some(prefs) = self.user_preferences.get(&user_id) { - prefs.mandatory_channels.contains(&channel_id.to_string()) || prefs.enabled_by_default - } else { - false - } - } - - /// Vérifie si une session existe et est active - pub fn has_active_session(&self, channel_id: &str) -> bool { - self.encryption_sessions.get(channel_id) - .map(|session| session.status == SessionStatus::Active) - .unwrap_or(false) - } - - // === Méthodes privées utilitaires === - - /// Génère une clé de session aléatoire - fn generate_session_key(&self) -> Result> { - let mut key = vec![0u8; 32]; - self.rng.fill(&mut key) - .map_err(|_| ChatError::internal_error("Failed to generate session key"))?; - Ok(key) - } - - /// Génère un nonce aléatoire - fn generate_nonce(&self) -> Result> { - let mut nonce = vec![0u8; 12]; - self.rng.fill(&mut nonce) - .map_err(|_| ChatError::internal_error("Failed to generate nonce"))?; - Ok(nonce) - } - - /// Génère le fingerprint d'une clé publique - fn generate_fingerprint(&self, public_key: &[u8]) -> String { - use ring::digest; - let digest = digest::digest(&digest::SHA256, public_key); - BASE64.encode(digest.as_ref()) - } - - /// Chiffre une clé de session avec une clé publique - fn encrypt_session_key(&self, session_key: &[u8], public_key: &[u8]) -> Result> { - let mut encrypted = session_key.to_vec(); - for (i, &byte) in public_key.iter().enumerate() { - if i < encrypted.len() { - encrypted[i] ^= byte; - } - } - Ok(encrypted) - } - - /// Chiffre du contenu avec AES-GCM - fn encrypt_content( - &self, - content: &[u8], - key: &[u8], - nonce: &[u8], - algorithm: &EncryptionAlgorithm, - ) -> Result<(Vec, Vec)> { - match algorithm { - EncryptionAlgorithm::AES256GCM => self.encrypt_aes_gcm(content, key, nonce), - EncryptionAlgorithm::ChaCha20Poly1305 => self.encrypt_chacha20_poly1305(content, key, nonce), - } - } - - /// Déchiffre du contenu - fn decrypt_content( - &self, - encrypted_content: &[u8], - auth_tag: &[u8], - key: &[u8], - nonce: &[u8], - algorithm: &EncryptionAlgorithm, - ) -> Result> { - match algorithm { - EncryptionAlgorithm::AES256GCM => self.decrypt_aes_gcm(encrypted_content, auth_tag, key, nonce), - EncryptionAlgorithm::ChaCha20Poly1305 => self.decrypt_chacha20_poly1305(encrypted_content, auth_tag, key, nonce), - } - } - - /// Chiffrement AES-256-GCM - fn encrypt_aes_gcm(&self, content: &[u8], key: &[u8], nonce: &[u8]) -> Result<(Vec, Vec)> { - let unbound_key = aead::UnboundKey::new(&aead::AES_256_GCM, key) - .map_err(|_| ChatError::internal_error("Invalid AES key"))?; - - let key = aead::LessSafeKey::new(unbound_key); - let nonce = aead::Nonce::try_assume_unique_for_key(nonce) - .map_err(|_| ChatError::internal_error("Invalid nonce"))?; - - let mut in_out = content.to_vec(); - let tag = key.seal_in_place_separate_tag(nonce, aead::Aad::empty(), &mut in_out) - .map_err(|_| ChatError::internal_error("Encryption failed"))?; - - Ok((in_out, tag.as_ref().to_vec())) - } - - /// Déchiffrement AES-256-GCM - fn decrypt_aes_gcm( - &self, - encrypted_content: &[u8], - auth_tag: &[u8], - key: &[u8], - nonce: &[u8], - ) -> Result> { - let unbound_key = aead::UnboundKey::new(&aead::AES_256_GCM, key) - .map_err(|_| ChatError::internal_error("Invalid AES key"))?; - - let key = aead::LessSafeKey::new(unbound_key); - let nonce = aead::Nonce::try_assume_unique_for_key(nonce) - .map_err(|_| ChatError::internal_error("Invalid nonce"))?; - - let mut in_out = encrypted_content.to_vec(); - in_out.extend_from_slice(auth_tag); - - let plaintext = key.open_in_place(nonce, aead::Aad::empty(), &mut in_out) - .map_err(|_| ChatError::internal_error("Decryption failed"))?; - - Ok(plaintext.to_vec()) - } - - /// Chiffrement ChaCha20-Poly1305 - fn encrypt_chacha20_poly1305(&self, content: &[u8], key: &[u8], nonce: &[u8]) -> Result<(Vec, Vec)> { - let unbound_key = aead::UnboundKey::new(&aead::CHACHA20_POLY1305, key) - .map_err(|_| ChatError::internal_error("Invalid ChaCha20 key"))?; - - let key = aead::LessSafeKey::new(unbound_key); - let nonce = aead::Nonce::try_assume_unique_for_key(nonce) - .map_err(|_| ChatError::internal_error("Invalid nonce"))?; - - let mut in_out = content.to_vec(); - let tag = key.seal_in_place_separate_tag(nonce, aead::Aad::empty(), &mut in_out) - .map_err(|_| ChatError::internal_error("Encryption failed"))?; - - Ok((in_out, tag.as_ref().to_vec())) - } - - /// Déchiffrement ChaCha20-Poly1305 - fn decrypt_chacha20_poly1305( - &self, - encrypted_content: &[u8], - auth_tag: &[u8], - key: &[u8], - nonce: &[u8], - ) -> Result> { - let unbound_key = aead::UnboundKey::new(&aead::CHACHA20_POLY1305, key) - .map_err(|_| ChatError::internal_error("Invalid ChaCha20 key"))?; - - let key = aead::LessSafeKey::new(unbound_key); - let nonce = aead::Nonce::try_assume_unique_for_key(nonce) - .map_err(|_| ChatError::internal_error("Invalid nonce"))?; - - let mut in_out = encrypted_content.to_vec(); - in_out.extend_from_slice(auth_tag); - - let plaintext = key.open_in_place(nonce, aead::Aad::empty(), &mut in_out) - .map_err(|_| ChatError::internal_error("Decryption failed"))?; - - Ok(plaintext.to_vec()) - } -} - -impl Default for EncryptionPreferences { - fn default() -> Self { - Self { - enabled_by_default: false, - preferred_algorithm: EncryptionAlgorithm::AES256GCM, - auto_key_rotation: true, - rotation_period_days: 90, - require_fingerprint_verification: true, - mandatory_channels: vec![], - } - } -} \ No newline at end of file diff --git a/veza-chat-server/src/core/message.rs b/veza-chat-server/src/core/message.rs deleted file mode 100644 index 72138eca1..000000000 --- a/veza-chat-server/src/core/message.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Message Types et Protocol -//! -//! Types de messages optimisés pour Discord-like features -//! avec support threads, réactions, mentions, etc. - -use uuid::Uuid; -use serde::{Serialize, Deserialize}; -use chrono::{DateTime, Utc}; - -/// Message stocké avec métadonnées complètes -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StoredMessage { - pub id: Uuid, - pub content: String, - pub author_id: i64, - pub timestamp: DateTime, - pub message_type: MessageType, - pub room_id: String, - - // Features Discord-like - pub thread_id: Option, - pub reply_to: Option, - pub mentions: Vec, - pub reactions: Vec, - pub attachments: Vec, - pub embeds: Vec, - - // Modération - pub edited_at: Option>, - pub deleted_at: Option>, - pub moderation_flags: ModerationFlags, -} - -/// Type de message -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum MessageType { - Text, - File, - Image, - Voice, - Video, - System, - ThreadStart, - ThreadReply, -} - -/// Réaction à un message -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageReaction { - pub emoji: String, - pub users: Vec, - pub count: u32, -} - -/// Pièce jointe -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageAttachment { - pub id: Uuid, - pub filename: String, - pub content_type: String, - pub size: u64, - pub url: String, - pub proxy_url: Option, - pub width: Option, - pub height: Option, -} - -/// Embed riche (Discord-like) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageEmbed { - pub title: Option, - pub description: Option, - pub url: Option, - pub color: Option, - pub timestamp: Option>, - pub footer: Option, - pub image: Option, - pub thumbnail: Option, - pub author: Option, - pub fields: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EmbedFooter { - pub text: String, - pub icon_url: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EmbedImage { - pub url: String, - pub width: Option, - pub height: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EmbedThumbnail { - pub url: String, - pub width: Option, - pub height: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EmbedAuthor { - pub name: String, - pub url: Option, - pub icon_url: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EmbedField { - pub name: String, - pub value: String, - pub inline: bool, -} - -/// Flags de modération -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ModerationFlags { - pub is_flagged: bool, - pub is_spam: bool, - pub toxicity_score: Option, - pub auto_moderated: bool, - pub manual_review: bool, -} - -/// Message entrant du WebSocket -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", content = "data")] -pub enum IncomingMessage { - // Messages basiques - SendMessage { - room_id: String, - content: String, - reply_to: Option, - thread_id: Option, - }, - - EditMessage { - message_id: Uuid, - content: String, - }, - - DeleteMessage { - message_id: Uuid, - }, - - // Réactions - AddReaction { - message_id: Uuid, - emoji: String, - }, - - RemoveReaction { - message_id: Uuid, - emoji: String, - }, - - // Salles - JoinRoom { - room_id: String, - }, - - LeaveRoom { - room_id: String, - }, - - // Présence - UpdatePresence { - status: super::user::PresenceStatus, - activity: Option, - }, - - StartTyping { - room_id: String, - }, - - StopTyping { - room_id: String, - }, - - // Threads - CreateThread { - message_id: Uuid, - name: String, - }, - - // Modération - ReportMessage { - message_id: Uuid, - reason: String, - }, -} - -/// Message sortant vers le WebSocket -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", content = "data")] -pub enum OutgoingMessage { - // Messages - MessageReceived { - message: StoredMessage, - }, - - MessageEdited { - message_id: Uuid, - content: String, - edited_at: DateTime, - }, - - MessageDeleted { - message_id: Uuid, - deleted_at: DateTime, - }, - - // Réactions - ReactionAdded { - message_id: Uuid, - emoji: String, - user_id: i64, - }, - - ReactionRemoved { - message_id: Uuid, - emoji: String, - user_id: i64, - }, - - // Présence - UserPresenceUpdate { - user_id: i64, - status: super::user::PresenceStatus, - activity: Option, - }, - - TypingStart { - room_id: String, - user_id: i64, - }, - - TypingStop { - room_id: String, - user_id: i64, - }, - - // Salles - RoomJoined { - room_id: String, - user_id: i64, - }, - - RoomLeft { - room_id: String, - user_id: i64, - }, - - // Système - Error { - message: String, - code: Option, - }, - - ActionConfirmed { - action: String, - success: bool, - }, - - // Threads - ThreadCreated { - thread_id: Uuid, - parent_message_id: Uuid, - name: String, - creator_id: i64, - }, -} - -impl StoredMessage { - pub fn new_text_message( - author_id: i64, - room_id: String, - content: String, - ) -> Self { - Self { - id: Uuid::new_v4(), - content, - author_id, - timestamp: Utc::now(), - message_type: MessageType::Text, - room_id, - thread_id: None, - reply_to: None, - mentions: Vec::new(), - reactions: Vec::new(), - attachments: Vec::new(), - embeds: Vec::new(), - edited_at: None, - deleted_at: None, - moderation_flags: ModerationFlags::default(), - } - } - - pub fn add_reaction(&mut self, emoji: String, user_id: i64) { - if let Some(reaction) = self.reactions.iter_mut() - .find(|r| r.emoji == emoji) { - if !reaction.users.contains(&user_id) { - reaction.users.push(user_id); - reaction.count += 1; - } - } else { - self.reactions.push(MessageReaction { - emoji, - users: vec![user_id], - count: 1, - }); - } - } - - pub fn remove_reaction(&mut self, emoji: &str, user_id: i64) { - if let Some(reaction) = self.reactions.iter_mut() - .find(|r| r.emoji == emoji) { - if let Some(pos) = reaction.users.iter().position(|&id| id == user_id) { - reaction.users.remove(pos); - reaction.count -= 1; - - // Supprimer la réaction si plus personne - if reaction.count == 0 { - self.reactions.retain(|r| r.emoji != emoji); - } - } - } - } - - pub fn is_deleted(&self) -> bool { - self.deleted_at.is_some() - } - - pub fn is_edited(&self) -> bool { - self.edited_at.is_some() - } -} diff --git a/veza-chat-server/src/core/mod.rs b/veza-chat-server/src/core/mod.rs deleted file mode 100644 index 66bf41548..000000000 --- a/veza-chat-server/src/core/mod.rs +++ /dev/null @@ -1,19 +0,0 @@ -pub mod connection; -pub mod room; -pub mod message; -pub mod user; -pub mod channels; -pub mod rich_messages; -pub mod moderation_integration; -pub mod encryption; -pub mod advanced_rate_limiter; - -pub use connection::*; -pub use room::*; -pub use message::*; -pub use user::*; -pub use channels::*; -pub use rich_messages::*; -pub use moderation_integration::*; -pub use encryption::*; -pub use advanced_rate_limiter::*; diff --git a/veza-chat-server/src/core/moderation_integration.rs b/veza-chat-server/src/core/moderation_integration.rs deleted file mode 100644 index a6fd8591c..000000000 --- a/veza-chat-server/src/core/moderation_integration.rs +++ /dev/null @@ -1,295 +0,0 @@ -//! Intégration de la Modération IA dans le Core -//! -//! Ce module connecte l'AdvancedModerationEngine avec : -//! - Le système de messages en temps réel -//! - Les actions automatiques (mute, ban, delete) -//! - Les notifications de modération -//! - Les métriques de sécurité - -use std::sync::Arc; -use std::time::Duration; -use serde::{Deserialize, Serialize}; -use chrono::{DateTime, Utc}; -use dashmap::DashMap; -use tokio::sync::mpsc; - -use crate::advanced_moderation::{ - AdvancedModerationEngine, - AdvancedModerationConfig, - ViolationType, - UserBehaviorProfile -}; -use crate::moderation::{SanctionType, SanctionReason}; -use crate::monitoring::ChatMetrics; -use crate::permissions::{Permission, UserPermissions}; -use crate::core::{ConnectionManager, RichMessage, RichMessageManager}; -use crate::error::{ChatError, Result}; - -/// Service d'intégration de modération IA -#[derive(Debug)] -pub struct ModerationIntegrationService { - /// Engine de modération IA - moderation_engine: Arc, - - /// Gestionnaire de connexions pour actions en temps réel - connection_manager: Arc, - - /// Gestionnaire de messages riches - message_manager: Arc, - - /// Channel pour les actions de modération - action_sender: mpsc::UnboundedSender, - - /// Historique des sanctions - sanction_history: Arc>>, - - /// Whitelist d'utilisateurs de confiance - trusted_users: Arc>, - - /// Métriques de modération - metrics: Arc, -} - -/// Action de modération à exécuter -#[derive(Debug, Clone)] -pub enum ModerationAction { - /// Supprimer un message - DeleteMessage { - message_id: String, - channel_id: String, - reason: String, - }, - - /// Muter un utilisateur - MuteUser { - user_id: i64, - duration: Duration, - reason: String, - }, - - /// Bannir un utilisateur - BanUser { - user_id: i64, - duration: Option, - reason: String, - }, - - /// Avertir un utilisateur - WarnUser { - user_id: i64, - reason: String, - violation_count: u32, - }, - - /// Alerter les modérateurs - AlertModerators { - user_id: i64, - violations: Vec, - confidence: f32, - urgent: bool, - }, - - /// Shadowban (restrictions invisibles) - ShadowBan { - user_id: i64, - restrictions: ShadowBanRestrictions, - duration: Duration, - }, -} - -/// Sévérité d'une violation -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ViolationSeverity { - Low, // Warning - Medium, // Temporary restrictions - High, // Temporary ban - Critical, // Permanent ban -} - -/// Restrictions de shadowban -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ShadowBanRestrictions { - pub message_delay: Option, - pub limited_channels: bool, - pub no_mentions: bool, - pub no_reactions: bool, - pub reduced_visibility: bool, -} - -/// Enregistrement d'une sanction -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SanctionRecord { - pub id: String, - pub user_id: i64, - pub reason: String, - pub applied_at: DateTime, -} - -/// Niveau de confiance d'un utilisateur -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum TrustLevel { - /// Utilisateur nouveau (surveillance accrue) - New, - /// Utilisateur normal - Normal, - /// Utilisateur de confiance (modération allégée) - Trusted, - /// Modérateur/VIP (bypass certaines vérifications) - Privileged, -} - -/// Métriques de modération -#[derive(Debug, Default)] -pub struct ModerationMetrics { - pub messages_analyzed: Arc, - pub violations_detected: Arc, - pub auto_actions_taken: Arc, - pub false_positives: Arc, - pub manual_overrides: Arc, -} - -impl ModerationIntegrationService { - pub fn new(connection_manager: Arc) -> Result { - let moderation_config = crate::advanced_moderation::AdvancedModerationConfig::default(); - let metrics = Arc::new(crate::monitoring::ChatMetrics::new()); - let moderation_engine = Arc::new(crate::advanced_moderation::AdvancedModerationEngine::new(moderation_config, metrics)?); - - Ok(Self { - moderation_engine, - connection_manager, - message_manager: Arc::new(RichMessageManager::default()), - action_sender: mpsc::unbounded_channel().0, - sanction_history: Arc::new(DashMap::new()), - trusted_users: Arc::new(DashMap::new()), - metrics: Arc::new(ModerationMetrics::default()), - }) - } - - pub async fn analyze_message(&self, message: &RichMessage) -> Result { - let violations = self.moderation_engine.analyze_message( - message.author_id as i32, - &message.author_username, - &message.content, - &message.channel_id, - None, - ).await?; - - let decision = if violations.is_empty() { - ModerationDecision { - allowed: true, - action: None, - violations: vec![], - confidence: 0.0, - reason: "Aucune violation détectée".to_string(), - } - } else { - self.make_decision(message, &violations).await? - }; - - Ok(decision) - } - - async fn make_decision(&self, message: &RichMessage, violations: &[ViolationType]) -> Result { - let confidence = self.calculate_confidence(violations); - - let action = if confidence > 0.8 { - Some(ModerationAction::BanUser { - user_id: message.author_id, - duration: Some(Duration::from_secs(3600)), - reason: "Violations critiques détectées".to_string(), - }) - } else if confidence > 0.5 { - Some(ModerationAction::DeleteMessage { - message_id: message.id.clone(), - channel_id: message.channel_id.clone(), - reason: "Contenu inapproprié".to_string(), - }) - } else { - None - }; - - Ok(ModerationDecision { - allowed: action.is_none(), - action, - violations: violations.to_vec(), - confidence, - reason: self.generate_reason(violations), - }) - } - - fn calculate_confidence(&self, violations: &[ViolationType]) -> f32 { - violations.iter() - .map(|v| match v { - ViolationType::Spam { confidence, .. } => *confidence, - ViolationType::Toxicity { confidence, .. } => *confidence, - ViolationType::Inappropriate { confidence, .. } => *confidence, - ViolationType::Fraud { confidence, .. } => *confidence, - ViolationType::Abuse { confidence, .. } => *confidence, - ViolationType::Suspicious { confidence, .. } => *confidence, - }) - .fold(0.0, |acc, x| acc.max(x)) - } - - fn generate_reason(&self, violations: &[ViolationType]) -> String { - if violations.is_empty() { - return "Aucune violation".to_string(); - } - - violations.iter() - .map(|v| match v { - ViolationType::Spam { .. } => "Spam", - ViolationType::Toxicity { .. } => "Toxicité", - ViolationType::Inappropriate { .. } => "Contenu inapproprié", - ViolationType::Fraud { .. } => "Fraude", - ViolationType::Abuse { .. } => "Abus", - ViolationType::Suspicious { .. } => "Suspect", - }) - .collect::>() - .join(", ") - } -} - -/// Décision de modération pour un message -#[derive(Debug, Clone)] -pub struct ModerationDecision { - /// Le message est-il autorisé ? - pub allowed: bool, - /// Action à exécuter (si any) - pub action: Option, - /// Violations détectées - pub violations: Vec, - /// Score de confiance - pub confidence: f32, - /// Raison lisible - pub reason: String, -} - -// Implémentation de Clone pour le service (pour le worker) -impl Clone for ModerationIntegrationService { - fn clone(&self) -> Self { - Self { - moderation_engine: self.moderation_engine.clone(), - connection_manager: self.connection_manager.clone(), - message_manager: self.message_manager.clone(), - action_sender: self.action_sender.clone(), - sanction_history: self.sanction_history.clone(), - trusted_users: self.trusted_users.clone(), - metrics: self.metrics.clone(), - } - } -} - -// Extensions pour ConnectionManager -impl ConnectionManager { - pub async fn mute_user(&self, user_id: i64, duration: Duration) -> Result<()> { - // Implémentation pour muter un utilisateur - tracing::info!("Muting user {} for {:?}", user_id, duration); - Ok(()) - } - - pub async fn ban_user(&self, user_id: i64, duration: Option) -> Result<()> { - // Implémentation pour bannir un utilisateur - tracing::info!("Banning user {} for {:?}", user_id, duration); - Ok(()) - } -} \ No newline at end of file diff --git a/veza-chat-server/src/core/rich_messages.rs b/veza-chat-server/src/core/rich_messages.rs deleted file mode 100644 index e8844b1a7..000000000 --- a/veza-chat-server/src/core/rich_messages.rs +++ /dev/null @@ -1,643 +0,0 @@ -//! Système de Rich Messages Discord-like -//! -//! Ce module implémente : -//! - Messages avec embeds riches -//! - Système de threads -//! - Réactions avec émojis -//! - Attachements multiples -//! - Mentions et replies -//! - Message pinning et édition - -use std::collections::{HashMap, HashSet}; -use std::sync::Arc; -use serde::{Deserialize, Serialize}; -use chrono::{DateTime, Utc}; -use dashmap::DashMap; -use uuid::Uuid; - -use crate::error::{ChatError, Result}; -use crate::core::message::{StoredMessage, MessageType}; - -/// Message riche Discord-like avec toutes les fonctionnalités -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RichMessage { - pub id: String, - pub channel_id: String, - pub author_id: i64, - pub author_username: String, - pub content: String, - pub message_type: RichMessageType, - pub created_at: DateTime, - pub edited_at: Option>, - - /// Embeds riches - pub embeds: Vec, - - /// Attachements (fichiers, images, etc.) - pub attachments: Vec, - - /// Mentions dans le message - pub mentions: MessageMentions, - - /// Réactions au message - pub reactions: HashMap, - - /// Thread associé (si c'est un message thread) - pub thread: Option, - - /// Référence à un autre message (reply) - pub message_reference: Option, - - /// Flags du message - pub flags: MessageFlags, - - /// Activités intégrées (si applicable) - pub activity: Option, - - /// Application qui a envoyé le message (pour les bots) - pub application: Option, -} - -/// Types de messages riches -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum RichMessageType { - /// Message normal - Default, - /// Message de réponse - Reply, - /// Message slash command - ChatInputCommand, - /// Message système - ChannelNameChange, - ChannelIconChange, - UserJoin, - UserPremiumGuildSubscription, - UserPremiumGuildSubscriptionTier1, - UserPremiumGuildSubscriptionTier2, - UserPremiumGuildSubscriptionTier3, - ChannelFollowAdd, - /// Message d'appel - Call, - /// Message stage - StageStart, - StageEnd, - /// Thread - ThreadCreated, - ThreadStarterMessage, -} - -/// Embed riche Discord-like -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageEmbed { - /// Titre de l'embed - pub title: Option, - /// Description - pub description: Option, - /// URL de titre - pub url: Option, - /// Timestamp - pub timestamp: Option>, - /// Couleur (format hex) - pub color: Option, - /// Footer - pub footer: Option, - /// Image - pub image: Option, - /// Thumbnail - pub thumbnail: Option, - /// Video - pub video: Option, - /// Provider - pub provider: Option, - /// Auteur - pub author: Option, - /// Champs - pub fields: Vec, -} - -/// Footer d'un embed -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EmbedFooter { - pub text: String, - pub icon_url: Option, - pub proxy_icon_url: Option, -} - -/// Image d'un embed -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EmbedImage { - pub url: String, - pub proxy_url: Option, - pub height: Option, - pub width: Option, -} - -/// Thumbnail d'un embed -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EmbedThumbnail { - pub url: String, - pub proxy_url: Option, - pub height: Option, - pub width: Option, -} - -/// Video d'un embed -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EmbedVideo { - pub url: Option, - pub proxy_url: Option, - pub height: Option, - pub width: Option, -} - -/// Provider d'un embed -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EmbedProvider { - pub name: Option, - pub url: Option, -} - -/// Auteur d'un embed -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EmbedAuthor { - pub name: String, - pub url: Option, - pub icon_url: Option, - pub proxy_icon_url: Option, -} - -/// Champ d'un embed -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EmbedField { - pub name: String, - pub value: String, - pub inline: bool, -} - -/// Attachement de message -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageAttachment { - pub id: String, - pub filename: String, - pub description: Option, - pub content_type: Option, - pub size: u64, - pub url: String, - pub proxy_url: String, - pub height: Option, - pub width: Option, - pub ephemeral: bool, -} - -/// Mentions dans un message -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageMentions { - /// Utilisateurs mentionnés - pub users: Vec, - /// Rôles mentionnés - pub roles: Vec, - /// Channels mentionnés - pub channels: Vec, - /// @everyone/@here - pub everyone: bool, -} - -/// Réaction à un message -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageReaction { - /// Nombre de réactions - pub count: u32, - /// L'utilisateur actuel a-t-il réagi ? - pub me: bool, - /// Emoji utilisé - pub emoji: ReactionEmoji, - /// Utilisateurs qui ont réagi - pub users: HashSet, -} - -/// Emoji de réaction -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReactionEmoji { - pub id: Option, - pub name: String, - pub animated: bool, -} - -/// Thread associé à un message -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageThread { - pub id: String, - pub name: String, - pub message_count: u32, - pub member_count: u32, - pub last_message_id: Option, - pub rate_limit_per_user: Option, - pub flags: u32, - pub total_message_sent: u32, - pub created_at: DateTime, - pub auto_archive_duration: u32, - pub archive_timestamp: Option>, - pub locked: bool, - pub invitable: bool, -} - -/// Référence à un autre message (reply) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageReference { - pub message_id: String, - pub channel_id: String, - pub guild_id: Option, - pub fail_if_not_exists: bool, -} - -/// Flags d'un message -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageFlags { - pub crossposted: bool, - pub is_crosspost: bool, - pub suppress_embeds: bool, - pub source_message_deleted: bool, - pub urgent: bool, - pub has_thread: bool, - pub ephemeral: bool, - pub loading: bool, - pub failed_to_mention_some_roles_in_thread: bool, -} - -/// Activité de message (jeux, etc.) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageActivity { - pub activity_type: u8, - pub party_id: Option, -} - -/// Application qui a envoyé le message -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageApplication { - pub id: String, - pub name: String, - pub icon: Option, - pub description: String, -} - -/// Gestionnaire de messages riches -#[derive(Debug)] -pub struct RichMessageManager { - /// Messages par ID - messages: Arc>, - /// Index des messages par channel - channel_messages: Arc>>, - /// Index des threads - threads: Arc>, - /// Index des réactions - reactions: Arc>>, -} - -impl RichMessageManager { - pub fn new() -> Self { - Self { - messages: Arc::new(DashMap::new()), - channel_messages: Arc::new(DashMap::new()), - threads: Arc::new(DashMap::new()), - reactions: Arc::new(DashMap::new()), - } - } - - /// Crée un nouveau message riche - pub async fn create_message(&self, mut message: RichMessage) -> Result { - let message_id = format!("msg_{}", Uuid::new_v4()); - message.id = message_id.clone(); - message.created_at = Utc::now(); - - // Valider le contenu - self.validate_message(&message)?; - - // Ajouter le message - self.messages.insert(message_id.clone(), message.clone()); - - // Indexer par channel - self.channel_messages - .entry(message.channel_id.clone()) - .or_insert_with(Vec::new) - .push(message_id.clone()); - - // Créer un thread si nécessaire - if let Some(thread) = &message.thread { - self.threads.insert(thread.id.clone(), thread.clone()); - } - - Ok(message_id) - } - - /// Édite un message existant - pub async fn edit_message( - &self, - message_id: &str, - new_content: String, - new_embeds: Option>, - editor_id: i64, - ) -> Result<()> { - let mut message = self.messages.get_mut(message_id) - .ok_or_else(|| ChatError::not_found_simple("message_not_found"))?; - - // Vérifier les permissions (l'auteur peut éditer son message) - if message.author_id != editor_id { - return Err(ChatError::unauthorized_simple("cannot_edit_message")); - } - - // Mettre à jour le message - message.content = new_content; - message.edited_at = Some(Utc::now()); - - if let Some(embeds) = new_embeds { - message.embeds = embeds; - } - - Ok(()) - } - - /// Ajoute une réaction à un message - pub async fn add_reaction( - &self, - message_id: &str, - emoji: ReactionEmoji, - user_id: i64, - ) -> Result<()> { - let emoji_clone = emoji.clone(); - let emoji_key = format!("{}:{}", emoji.name, emoji.id.clone().unwrap_or_default()); - - // Mettre à jour les réactions du message - if let Some(mut message) = self.messages.get_mut(message_id) { - let reaction = message.reactions - .entry(emoji_key.clone()) - .or_insert_with(|| MessageReaction { - count: 0, - me: false, - emoji: emoji_clone.clone(), - users: HashSet::new(), - }); - - if !reaction.users.contains(&user_id) { - reaction.users.insert(user_id); - reaction.count += 1; - } - } - - Ok(()) - } - - /// Retire une réaction d'un message - pub async fn remove_reaction( - &self, - message_id: &str, - emoji: &ReactionEmoji, - user_id: i64, - ) -> Result<()> { - let emoji_key = format!("{}:{}", emoji.name, emoji.id.as_ref().unwrap_or(&String::new())); - - if let Some(mut message) = self.messages.get_mut(message_id) { - if let Some(reaction) = message.reactions.get_mut(&emoji_key) { - if reaction.users.remove(&user_id) { - reaction.count = reaction.count.saturating_sub(1); - - // Supprimer la réaction si plus personne n'a réagi - if reaction.count == 0 { - message.reactions.remove(&emoji_key); - } - } - } - } - - Ok(()) - } - - /// Crée un thread à partir d'un message - pub async fn create_thread( - &self, - message_id: &str, - thread_name: String, - auto_archive_duration: u32, - _creator_id: i64, - ) -> Result { - let _message = self.messages.get(message_id) - .ok_or_else(|| ChatError::not_found_simple("message_not_found"))?; - - let thread_id = format!("thread_{}", Uuid::new_v4()); - - let thread = MessageThread { - id: thread_id.clone(), - name: thread_name, - message_count: 0, - member_count: 1, // Le créateur - last_message_id: None, - rate_limit_per_user: None, - flags: 0, - total_message_sent: 0, - created_at: Utc::now(), - auto_archive_duration, - archive_timestamp: None, - locked: false, - invitable: true, - }; - - // Ajouter le thread - self.threads.insert(thread_id.clone(), thread.clone()); - - // Mettre à jour le message pour indiquer qu'il a un thread - if let Some(mut msg) = self.messages.get_mut(message_id) { - msg.thread = Some(thread); - msg.flags.has_thread = true; - } - - Ok(thread_id) - } - - /// Pin/Unpin un message dans un channel - pub async fn toggle_pin( - &self, - _message_id: &str, - _pinner_id: i64, - ) -> Result { - // Dans une vraie implémentation, on vérifierait les permissions ici - // Pour l'instant, on simule juste le changement d'état - - // Retourner le nouvel état (pinned ou non) - Ok(true) // Simulé - } - - /// Obtient les messages d'un channel avec pagination - pub fn get_channel_messages( - &self, - channel_id: &str, - limit: usize, - before: Option<&str>, - after: Option<&str>, - ) -> Vec { - if let Some(message_ids) = self.channel_messages.get(channel_id) { - let mut messages = Vec::new(); - - for msg_id in message_ids.iter() { - if let Some(message) = self.messages.get(msg_id) { - messages.push(message.value().clone()); - } - } - - // Trier par date (plus récent en premier) - messages.sort_by(|a, b| b.created_at.cmp(&a.created_at)); - - // Appliquer la pagination - if let Some(before_id) = before { - if let Some(pos) = messages.iter().position(|m| m.id == before_id) { - messages = messages.into_iter().skip(pos + 1).collect(); - } - } - - if let Some(after_id) = after { - if let Some(pos) = messages.iter().position(|m| m.id == after_id) { - messages = messages.into_iter().take(pos).collect(); - } - } - - messages.into_iter().take(limit).collect() - } else { - Vec::new() - } - } - - /// Valide un message - fn validate_message(&self, message: &RichMessage) -> Result<()> { - // Vérifier la longueur du contenu - if message.content.len() > 2000 { - return Err(ChatError::validation_error("message_too_long")); - } - - // Vérifier le nombre d'embeds - if message.embeds.len() > 10 { - return Err(ChatError::validation_error("too_many_embeds")); - } - - // Vérifier les attachements - if message.attachments.len() > 10 { - return Err(ChatError::validation_error("too_many_attachments")); - } - - // Vérifier la taille totale des attachements - let total_size: u64 = message.attachments.iter().map(|a| a.size).sum(); - if total_size > 100 * 1024 * 1024 { // 100 MB - return Err(ChatError::validation_error("attachments_too_large")); - } - - Ok(()) - } -} - -impl Default for MessageFlags { - fn default() -> Self { - Self { - crossposted: false, - is_crosspost: false, - suppress_embeds: false, - source_message_deleted: false, - urgent: false, - has_thread: false, - ephemeral: false, - loading: false, - failed_to_mention_some_roles_in_thread: false, - } - } -} - -impl Default for MessageMentions { - fn default() -> Self { - Self { - users: Vec::new(), - roles: Vec::new(), - channels: Vec::new(), - everyone: false, - } - } -} - -impl Default for RichMessageManager { - fn default() -> Self { - Self { - messages: Arc::new(DashMap::new()), - channel_messages: Arc::new(DashMap::new()), - threads: Arc::new(DashMap::new()), - reactions: Arc::new(DashMap::new()), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_rich_message_creation() { - let manager = RichMessageManager::new(); - - let message = RichMessage { - id: String::new(), // Sera généré - channel_id: "channel123".to_string(), - author_id: 456, - author_username: "testuser".to_string(), - content: "Hello **world**!".to_string(), - message_type: RichMessageType::Default, - created_at: Utc::now(), - edited_at: None, - embeds: vec![], - attachments: vec![], - mentions: MessageMentions::default(), - reactions: HashMap::new(), - thread: None, - message_reference: None, - flags: MessageFlags::default(), - activity: None, - application: None, - }; - - let message_id = manager.create_message(message).await.unwrap(); - assert!(manager.messages.contains_key(&message_id)); - } - - #[tokio::test] - async fn test_message_reactions() { - let manager = RichMessageManager::new(); - - let message = RichMessage { - id: "msg123".to_string(), - channel_id: "channel123".to_string(), - author_id: 456, - author_username: "testuser".to_string(), - content: "React to this!".to_string(), - message_type: RichMessageType::Default, - created_at: Utc::now(), - edited_at: None, - embeds: vec![], - attachments: vec![], - mentions: MessageMentions::default(), - reactions: HashMap::new(), - thread: None, - message_reference: None, - flags: MessageFlags::default(), - activity: None, - application: None, - }; - - manager.messages.insert("msg123".to_string(), message); - - let emoji = ReactionEmoji { - id: None, - name: "👍".to_string(), - animated: false, - }; - - manager.add_reaction("msg123", emoji.clone(), 789).await.unwrap(); - - let message = manager.messages.get("msg123").unwrap(); - let reaction_key = format!("{}:{}", emoji.name, ""); - assert!(message.reactions.contains_key(&reaction_key)); - assert_eq!(message.reactions[&reaction_key].count, 1); - } -} \ No newline at end of file diff --git a/veza-chat-server/src/core/room.rs b/veza-chat-server/src/core/room.rs deleted file mode 100644 index 39b07e56b..000000000 --- a/veza-chat-server/src/core/room.rs +++ /dev/null @@ -1,239 +0,0 @@ -//! Room Management pour Chat Production -//! -//! Gestion des salles de chat avec permissions Discord-like -//! et optimisations pour haute performance. - -use std::sync::Arc; -use dashmap::DashMap; -use tokio::sync::RwLock; -use uuid::Uuid; -use serde::{Serialize, Deserialize}; -use chrono::{DateTime, Utc}; - -use super::message::*; -use super::user::*; - -/// Salle de chat optimisée -#[derive(Debug)] -pub struct Room { - /// Identifiant de la salle - pub id: String, - - /// Nom de la salle - pub name: String, - - /// Membres connectés - pub members: Arc>, - - /// Configuration de la salle - pub settings: RoomSettings, - - /// Buffer circulaire pour messages récents - pub message_buffer: Arc>, - - /// Tracker de présence - pub presence_tracker: Arc, -} - -/// Membre d'une salle -#[derive(Debug, Clone)] -pub struct RoomMember { - pub connection_id: Uuid, - pub user_id: i64, - pub joined_at: DateTime, - pub permissions: RoomPermissions, - pub status: PresenceStatus, -} - -/// Permissions dans une salle (Discord-like) -#[derive(Debug, Clone, PartialEq)] -pub struct RoomPermissions { - // Permissions générales - pub view_channel: bool, - pub send_messages: bool, - pub embed_links: bool, - pub attach_files: bool, - pub read_message_history: bool, - pub mention_everyone: bool, - pub use_external_emojis: bool, - pub add_reactions: bool, - - // Permissions modération - pub manage_messages: bool, - pub manage_channel: bool, - pub kick_members: bool, - pub ban_members: bool, - - // Permissions voix - pub connect_voice: bool, - pub speak: bool, - pub mute_members: bool, - pub move_members: bool, -} - -impl Default for RoomPermissions { - fn default() -> Self { - Self { - view_channel: true, - send_messages: true, - embed_links: true, - attach_files: true, - read_message_history: true, - mention_everyone: false, - use_external_emojis: true, - add_reactions: true, - manage_messages: false, - manage_channel: false, - kick_members: false, - ban_members: false, - connect_voice: true, - speak: true, - mute_members: false, - move_members: false, - } - } -} - -/// Configuration d'une salle -#[derive(Debug, Clone)] -pub struct RoomSettings { - pub is_public: bool, - pub max_members: Option, - pub rate_limit: Option, - pub enable_file_upload: bool, - pub enable_voice: bool, - pub channel_type: ChannelType, - pub topic: Option, - pub slow_mode: Option, // secondes entre messages -} - -/// Type de channel Discord-like -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum ChannelType { - Text, - Voice, - Announcement, - Stage, - Forum, - Category, -} - -impl Default for RoomSettings { - fn default() -> Self { - Self { - is_public: true, - max_members: None, - rate_limit: Some(10), - enable_file_upload: true, - enable_voice: false, - channel_type: ChannelType::Text, - topic: None, - slow_mode: None, - } - } -} - -/// Buffer circulaire pour messages récents -#[derive(Debug)] -pub struct MessageBuffer { - messages: Vec, - capacity: usize, - index: usize, -} - -impl MessageBuffer { - pub fn new(capacity: usize) -> Self { - Self { - messages: Vec::with_capacity(capacity), - capacity, - index: 0, - } - } - - pub fn add_message(&mut self, message: StoredMessage) { - if self.messages.len() < self.capacity { - self.messages.push(message); - } else { - self.messages[self.index] = message; - self.index = (self.index + 1) % self.capacity; - } - } - - pub fn get_recent_messages(&self, limit: usize) -> Vec<&StoredMessage> { - let len = self.messages.len().min(limit); - if self.messages.len() < self.capacity { - self.messages.iter().rev().take(len).collect() - } else { - let mut result = Vec::with_capacity(len); - for i in 0..len { - let idx = (self.index + self.capacity - 1 - i) % self.capacity; - result.push(&self.messages[idx]); - } - result - } - } -} - -impl Room { - pub fn new(id: String, name: String, settings: RoomSettings) -> Self { - Self { - id, - name, - members: Arc::new(DashMap::new()), - settings, - message_buffer: Arc::new(RwLock::new(MessageBuffer::new(1000))), - presence_tracker: Arc::new(PresenceTracker::new()), - } - } - - /// Ajoute un membre à la salle - pub async fn add_member( - &self, - connection_id: Uuid, - user_id: i64, - permissions: RoomPermissions, - ) -> Result<(), &'static str> { - if let Some(max) = self.settings.max_members { - if self.members.len() >= max { - return Err("Room is full"); - } - } - - let member = RoomMember { - connection_id, - user_id, - joined_at: Utc::now(), - permissions, - status: PresenceStatus::Online, - }; - - self.members.insert(connection_id, member); - self.presence_tracker.update_status(user_id, PresenceStatus::Online); - - Ok(()) - } - - /// Retire un membre de la salle - pub async fn remove_member(&self, connection_id: Uuid) { - if let Some((_, member)) = self.members.remove(&connection_id) { - // Vérifier si c'était la dernière connexion de cet utilisateur - let user_still_connected = self.members.iter() - .any(|entry| entry.value().user_id == member.user_id); - - if !user_still_connected { - self.presence_tracker.update_status(member.user_id, PresenceStatus::Invisible); - } - } - } - - /// Vérifie les permissions d'un membre - pub fn check_permission( - &self, - connection_id: Uuid, - permission: fn(&RoomPermissions) -> bool, - ) -> bool { - self.members.get(&connection_id) - .map(|member| permission(&member.permissions)) - .unwrap_or(false) - } -} diff --git a/veza-chat-server/src/core/user.rs b/veza-chat-server/src/core/user.rs deleted file mode 100644 index 704fc34b9..000000000 --- a/veza-chat-server/src/core/user.rs +++ /dev/null @@ -1,251 +0,0 @@ -//! User Management et Présence -//! -//! Gestion des utilisateurs connectés avec tracking de présence -//! et activités Discord-like. - -use std::sync::Arc; -use dashmap::DashMap; -use serde::{Serialize, Deserialize}; -use chrono::{DateTime, Utc}; - -/// Status de présence Discord-like -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum PresenceStatus { - Online, - Idle, // Inactif (>10 min) - DoNotDisturb, - Invisible, // Apparaît offline -} - -/// Activité utilisateur -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UserActivity { - pub activity_type: ActivityType, - pub name: String, - pub details: Option, - pub state: Option, - pub started_at: Option>, -} - -/// Type d'activité -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ActivityType { - Playing, // Joue à un jeu - Streaming, // Stream - Listening, // Écoute de la musique - Watching, // Regarde - Custom, // Status custom - Competing, // Compétition -} - -/// Tracker de présence optimisé pour haute performance -#[derive(Debug)] -pub struct PresenceTracker { - /// Status des utilisateurs - statuses: Arc>, - - /// Dernière activité - last_seen: Arc>>, - - /// Activités en cours - activities: Arc>, - - /// Utilisateurs en train d'écrire par salle - typing_users: Arc>>>, -} - -impl Default for PresenceTracker { - fn default() -> Self { - Self::new() - } -} - -impl PresenceTracker { - pub fn new() -> Self { - Self { - statuses: Arc::new(DashMap::new()), - last_seen: Arc::new(DashMap::new()), - activities: Arc::new(DashMap::new()), - typing_users: Arc::new(DashMap::new()), - } - } - - /// Met à jour le status d'un utilisateur - pub fn update_status(&self, user_id: i64, status: PresenceStatus) { - self.statuses.insert(user_id, status); - self.last_seen.insert(user_id, Utc::now()); - } - - /// Met à jour l'activité d'un utilisateur - pub fn update_activity(&self, user_id: i64, activity: Option) { - match activity { - Some(activity) => { - self.activities.insert(user_id, activity); - } - None => { - self.activities.remove(&user_id); - } - } - self.last_seen.insert(user_id, Utc::now()); - } - - /// Obtient le status d'un utilisateur - pub fn get_status(&self, user_id: i64) -> Option { - self.statuses.get(&user_id).map(|entry| entry.value().clone()) - } - - /// Obtient l'activité d'un utilisateur - pub fn get_activity(&self, user_id: i64) -> Option { - self.activities.get(&user_id).map(|entry| entry.value().clone()) - } - - /// Vérifie si un utilisateur est en ligne - pub fn is_online(&self, user_id: i64) -> bool { - matches!( - self.get_status(user_id), - Some(PresenceStatus::Online | PresenceStatus::Idle | PresenceStatus::DoNotDisturb) - ) - } - - /// Démarre l'indicateur "en train d'écrire" - pub fn start_typing(&self, user_id: i64, room_id: &str) { - let room_key = room_id.to_string(); - let typing_room = self.typing_users.entry(room_key) - .or_default(); - typing_room.insert(user_id, Utc::now()); - } - - /// Arrête l'indicateur "en train d'écrire" - pub fn stop_typing(&self, user_id: i64, room_id: &str) { - if let Some(typing_room) = self.typing_users.get(room_id) { - typing_room.remove(&user_id); - } - } - - /// Obtient la liste des utilisateurs en train d'écrire - pub fn get_typing_users(&self, room_id: &str) -> Vec { - if let Some(typing_room) = self.typing_users.get(room_id) { - let now = Utc::now(); - let timeout = std::time::Duration::from_secs(5); // 5 secondes timeout - - // Nettoyer les anciens indicateurs et retourner les actifs - typing_room.retain(|_, last_typing| { - now.signed_duration_since(*last_typing) < chrono::Duration::from_std(timeout).unwrap_or(chrono::Duration::seconds(5)) - }); - - typing_room.iter().map(|entry| *entry.key()).collect() - } else { - Vec::new() - } - } - - /// Nettoie les utilisateurs inactifs - pub fn cleanup_inactive_users(&self, inactive_threshold: std::time::Duration) -> usize { - let now = Utc::now(); - let mut cleaned = 0; - - // Nettoyer les statuses des utilisateurs inactifs - self.statuses.retain(|user_id, _| { - if let Some(last_seen) = self.last_seen.get(user_id) { - let is_active = now.signed_duration_since(*last_seen.value()) < chrono::Duration::from_std(inactive_threshold).unwrap_or(chrono::Duration::hours(1)); - if !is_active { - cleaned += 1; - // Nettoyer aussi l'activité - self.activities.remove(user_id); - } - is_active - } else { - false - } - }); - - // Nettoyer les anciens indicateurs de frappe - for typing_room in self.typing_users.iter() { - typing_room.value().retain(|_, last_typing| { - now.signed_duration_since(*last_typing) < chrono::Duration::seconds(5) - }); - } - - // Supprimer les salles vides de typing - self.typing_users.retain(|_, typing_room| { - !typing_room.is_empty() - }); - - cleaned - } - - /// Obtient les statistiques de présence - pub fn get_presence_stats(&self) -> PresenceStats { - let mut stats = PresenceStats::default(); - - for entry in self.statuses.iter() { - match entry.value() { - PresenceStatus::Online => stats.online += 1, - PresenceStatus::Idle => stats.idle += 1, - PresenceStatus::DoNotDisturb => stats.dnd += 1, - PresenceStatus::Invisible => stats.invisible += 1, - } - } - - stats.total = stats.online + stats.idle + stats.dnd + stats.invisible; - stats - } -} - -/// Statistiques de présence -#[derive(Debug, Default, Serialize)] -pub struct PresenceStats { - pub total: usize, - pub online: usize, - pub idle: usize, - pub dnd: usize, - pub invisible: usize, -} - -impl Default for PresenceStatus { - fn default() -> Self { - Self::Online - } -} - -impl UserActivity { - pub fn playing(name: String) -> Self { - Self { - activity_type: ActivityType::Playing, - name, - details: None, - state: None, - started_at: Some(Utc::now()), - } - } - - pub fn listening(name: String) -> Self { - Self { - activity_type: ActivityType::Listening, - name, - details: None, - state: None, - started_at: Some(Utc::now()), - } - } - - pub fn streaming(name: String, url: String) -> Self { - Self { - activity_type: ActivityType::Streaming, - name, - details: Some(url), - state: None, - started_at: Some(Utc::now()), - } - } - - pub fn custom(status: String) -> Self { - Self { - activity_type: ActivityType::Custom, - name: status, - details: None, - state: None, - started_at: Some(Utc::now()), - } - } -} diff --git a/veza-chat-server/src/database/mod.rs b/veza-chat-server/src/database/mod.rs deleted file mode 100644 index 7f2d72a39..000000000 --- a/veza-chat-server/src/database/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Module database pour la gestion des connexions PostgreSQL - -pub mod pool; - -pub use pool::{create_pool, create_pool_from_env}; diff --git a/veza-chat-server/src/database/pool.rs b/veza-chat-server/src/database/pool.rs deleted file mode 100644 index 4b79a540e..000000000 --- a/veza-chat-server/src/database/pool.rs +++ /dev/null @@ -1,88 +0,0 @@ -//! Gestionnaire de connection pool PostgreSQL pour chat server -//! -//! Ce module fournit une fonction pour créer et configurer un pool de connexions -//! PostgreSQL optimisé pour le chat server. - -use sqlx::postgres::PgPoolOptions; -use sqlx::PgPool; -use std::time::Duration; - -/// Crée un pool de connexions PostgreSQL avec configuration optimale -/// -/// # Arguments -/// -/// * `database_url` - URL de connexion à la base de données PostgreSQL -/// -/// # Returns -/// -/// Un `PgPool` configuré ou une erreur si la connexion échoue -/// -/// # Configuration -/// -/// - `max_connections`: 20 connexions maximum -/// - `min_connections`: 5 connexions minimum maintenues -/// - `acquire_timeout`: 30 secondes pour acquérir une connexion -/// - `idle_timeout`: 600 secondes (10 minutes) avant fermeture d'une connexion inactive -/// - `max_lifetime`: 1800 secondes (30 minutes) durée de vie maximale d'une connexion -/// -/// # Exemple -/// -/// ```rust,no_run -/// use chat_server::database::pool::create_pool; -/// -/// #[tokio::main] -/// async fn main() { -/// let database_url = "postgresql://user:password@localhost/veza_db"; -/// let pool = create_pool(database_url).await.expect("Failed to create pool"); -/// // Utiliser le pool... -/// } -/// ``` -pub async fn create_pool(database_url: &str) -> Result { - PgPoolOptions::new() - .max_connections(20) - .min_connections(5) - .acquire_timeout(Duration::from_secs(30)) - .idle_timeout(Duration::from_secs(600)) - .max_lifetime(Duration::from_secs(1800)) - .connect(database_url) - .await -} - -/// Crée un pool de connexions avec une URL depuis une variable d'environnement -/// -/// # Arguments -/// -/// * `env_var` - Nom de la variable d'environnement contenant l'URL (par défaut "DATABASE_URL") -/// -/// # Returns -/// -/// Un `PgPool` configuré ou une erreur si la connexion échoue -pub async fn create_pool_from_env(env_var: Option<&str>) -> Result { - let var_name = env_var.unwrap_or("DATABASE_URL"); - let database_url = std::env::var(var_name).map_err(|_| { - sqlx::Error::Configuration(format!("Environment variable {} not set", var_name).into()) - })?; - create_pool(&database_url).await -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - #[ignore] // Nécessite une base de données de test - async fn test_create_pool() { - let database_url = std::env::var("TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgresql://localhost/veza_chat_test".to_string()); - let pool = create_pool(&database_url).await; - assert!(pool.is_ok(), "Pool creation should succeed"); - } - - #[tokio::test] - #[ignore] // Nécessite une base de données de test - async fn test_create_pool_from_env() { - std::env::set_var("TEST_DATABASE_URL", "postgresql://localhost/veza_chat_test"); - let pool = create_pool_from_env(Some("TEST_DATABASE_URL")).await; - assert!(pool.is_ok(), "Pool creation from env should succeed"); - } -} diff --git a/veza-chat-server/src/delivered_status.rs b/veza-chat-server/src/delivered_status.rs deleted file mode 100644 index 86255b876..000000000 --- a/veza-chat-server/src/delivered_status.rs +++ /dev/null @@ -1,315 +0,0 @@ -//! Module de gestion des delivered status (messages reçus mais pas encore lus) -//! -//! Ce module fournit un système complet pour tracker quels messages -//! ont été délivrés (reçus par le client WebSocket) par quels utilisateurs. - -use serde::{Deserialize, Serialize}; -use sqlx::types::chrono::{DateTime, Utc}; -use sqlx::{FromRow, Pool, Postgres}; -use tracing::{debug, info, instrument, warn}; -use uuid::Uuid; - -/// Représente un delivered status pour un message -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -pub struct DeliveredStatus { - pub id: Uuid, - pub message_id: Uuid, - pub user_id: Uuid, - pub conversation_id: Uuid, - pub delivered_at: DateTime, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// Manager pour gérer les delivered status -pub struct DeliveredStatusManager { - pool: Pool, -} - -impl DeliveredStatusManager { - /// Crée un nouveau DeliveredStatusManager - pub fn new(pool: Pool) -> Self { - Self { pool } - } - - /// Marquer un message comme délivré pour un utilisateur - /// - /// Si le delivered status existe déjà, met à jour le timestamp `delivered_at`. - /// Retourne le delivered status créé ou mis à jour. - #[instrument(skip(self))] - pub async fn mark_delivered( - &self, - user_id: Uuid, - message_id: Uuid, - conversation_id: Uuid, - ) -> Result { - // Vérifier si le delivered status existe déjà - let existing: Option = sqlx::query_as::<_, DeliveredStatus>( - "SELECT id, message_id, user_id, conversation_id, delivered_at, created_at, updated_at - FROM delivered_status - WHERE message_id = $1 AND user_id = $2", - ) - .bind(message_id) - .bind(user_id) - .fetch_optional(&self.pool) - .await?; - - if let Some(status) = existing { - // Mettre à jour le timestamp de délivrance - let updated = sqlx::query_as::<_, DeliveredStatus>( - "UPDATE delivered_status - SET delivered_at = NOW(), updated_at = NOW() - WHERE id = $1 - RETURNING id, message_id, user_id, conversation_id, delivered_at, created_at, updated_at" - ) - .bind(status.id) - .fetch_one(&self.pool) - .await?; - - debug!( - message_id = %message_id, - user_id = %user_id, - conversation_id = %conversation_id, - "Delivered status updated" - ); - - return Ok(updated); - } - - // Créer un nouveau delivered status - let status = sqlx::query_as::<_, DeliveredStatus>( - "INSERT INTO delivered_status (message_id, user_id, conversation_id, delivered_at, created_at, updated_at) - VALUES ($1, $2, $3, NOW(), NOW(), NOW()) - RETURNING id, message_id, user_id, conversation_id, delivered_at, created_at, updated_at" - ) - .bind(message_id) - .bind(user_id) - .bind(conversation_id) - .fetch_one(&self.pool) - .await?; - - info!( - message_id = %message_id, - user_id = %user_id, - conversation_id = %conversation_id, - "Message marked as delivered" - ); - - Ok(status) - } - - /// Obtenir tous les delivered status pour un message - #[instrument(skip(self))] - pub async fn get_delivered_for_message( - &self, - message_id: Uuid, - ) -> Result, sqlx::Error> { - let statuses = sqlx::query_as::<_, DeliveredStatus>( - "SELECT id, message_id, user_id, conversation_id, delivered_at, created_at, updated_at - FROM delivered_status - WHERE message_id = $1 - ORDER BY delivered_at ASC", - ) - .bind(message_id) - .fetch_all(&self.pool) - .await?; - - Ok(statuses) - } - - /// Obtenir un delivered status spécifique - #[instrument(skip(self))] - pub async fn get_delivered_status( - &self, - message_id: Uuid, - user_id: Uuid, - ) -> Result, sqlx::Error> { - let status = sqlx::query_as::<_, DeliveredStatus>( - "SELECT id, message_id, user_id, conversation_id, delivered_at, created_at, updated_at - FROM delivered_status - WHERE message_id = $1 AND user_id = $2", - ) - .bind(message_id) - .bind(user_id) - .fetch_optional(&self.pool) - .await?; - - Ok(status) - } - - /// Vérifier si un message a été délivré à un utilisateur - #[instrument(skip(self))] - pub async fn is_delivered(&self, message_id: Uuid, user_id: Uuid) -> Result { - let exists: bool = sqlx::query_scalar( - "SELECT EXISTS( - SELECT 1 FROM delivered_status - WHERE message_id = $1 AND user_id = $2 - )", - ) - .bind(message_id) - .bind(user_id) - .fetch_one(&self.pool) - .await?; - - Ok(exists) - } - - /// Vérifier que le message appartient à la conversation indiquée - #[instrument(skip(self))] - pub async fn verify_message_belongs_to_conversation( - &self, - message_id: Uuid, - conversation_id: Uuid, - ) -> Result { - let belongs: bool = sqlx::query_scalar( - "SELECT EXISTS( - SELECT 1 FROM messages - WHERE id = $1 AND conversation_id = $2 - )", - ) - .bind(message_id) - .bind(conversation_id) - .fetch_one(&self.pool) - .await?; - - if !belongs { - warn!( - message_id = %message_id, - conversation_id = %conversation_id, - "Message does not belong to conversation" - ); - } - - Ok(belongs) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use sqlx::PgPool; - - /// Setup une base de données de test - async fn setup_test_db() -> PgPool { - let database_url = - std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for tests"); - - sqlx::PgPool::connect(&database_url) - .await - .expect("Failed to connect to test database") - } - - #[tokio::test] - #[ignore] // Nécessite une base de données de test - async fn test_mark_delivered_creates_status() { - let pool = setup_test_db().await; - let manager = DeliveredStatusManager::new(pool); - - // Créer des UUIDs de test - let user_id = Uuid::new_v4(); - let message_id = Uuid::new_v4(); - let conversation_id = Uuid::new_v4(); - - // Marquer comme délivré - let status = manager - .mark_delivered(user_id, message_id, conversation_id) - .await - .expect("Should mark message as delivered"); - - assert_eq!(status.message_id, message_id); - assert_eq!(status.user_id, user_id); - assert_eq!(status.conversation_id, conversation_id); - } - - #[tokio::test] - #[ignore] // Nécessite une base de données de test - async fn test_mark_delivered_updates_existing() { - let pool = setup_test_db().await; - let manager = DeliveredStatusManager::new(pool); - - let user_id = Uuid::new_v4(); - let message_id = Uuid::new_v4(); - let conversation_id = Uuid::new_v4(); - - // Première délivrance - let status1 = manager - .mark_delivered(user_id, message_id, conversation_id) - .await - .expect("Should mark message as delivered"); - - // Attendre un peu pour que le timestamp change - tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; - - // Deuxième délivrance (devrait mettre à jour) - let status2 = manager - .mark_delivered(user_id, message_id, conversation_id) - .await - .expect("Should update existing status"); - - // Le delivered_at devrait être mis à jour - assert!(status2.delivered_at >= status1.delivered_at); - assert_eq!(status1.id, status2.id); // Même ID - } - - #[tokio::test] - #[ignore] // Nécessite une base de données de test - async fn test_get_delivered_for_message() { - let pool = setup_test_db().await; - let manager = DeliveredStatusManager::new(pool); - - let message_id = Uuid::new_v4(); - let conversation_id = Uuid::new_v4(); - let user1 = Uuid::new_v4(); - let user2 = Uuid::new_v4(); - - // Marquer comme délivré par deux utilisateurs - manager - .mark_delivered(user1, message_id, conversation_id) - .await - .expect("Should mark as delivered"); - manager - .mark_delivered(user2, message_id, conversation_id) - .await - .expect("Should mark as delivered"); - - // Récupérer tous les delivered status - let statuses = manager - .get_delivered_for_message(message_id) - .await - .expect("Should get statuses"); - - assert_eq!(statuses.len(), 2); - assert!(statuses.iter().any(|s| s.user_id == user1)); - assert!(statuses.iter().any(|s| s.user_id == user2)); - } - - #[tokio::test] - #[ignore] // Nécessite une base de données de test - async fn test_is_delivered() { - let pool = setup_test_db().await; - let manager = DeliveredStatusManager::new(pool); - - let user_id = Uuid::new_v4(); - let message_id = Uuid::new_v4(); - let conversation_id = Uuid::new_v4(); - - // Avant le marquage - let is_delivered_before = manager - .is_delivered(message_id, user_id) - .await - .expect("Should check status"); - assert!(!is_delivered_before); - - // Après le marquage - manager - .mark_delivered(user_id, message_id, conversation_id) - .await - .expect("Should mark as delivered"); - - let is_delivered_after = manager - .is_delivered(message_id, user_id) - .await - .expect("Should check status"); - assert!(is_delivered_after); - } -} diff --git a/veza-chat-server/src/env.rs b/veza-chat-server/src/env.rs deleted file mode 100644 index 091b2d859..000000000 --- a/veza-chat-server/src/env.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! Module pour la gestion des variables d'environnement requises -//! -//! Ce module fournit des fonctions helper pour récupérer des variables d'environnement -//! avec validation stricte. L'application refuse de démarrer si les secrets requis -//! ne sont pas définis. - -use std::env; - -/// Récupère une variable d'environnement requise. -/// -/// Panic si la variable n'est pas définie ou est vide. -/// -/// # Arguments -/// -/// * `key` - Le nom de la variable d'environnement -/// -/// # Panics -/// -/// Panic avec un message d'erreur clair si la variable n'est pas définie. -/// -/// # Example -/// -/// ```rust,should_panic -/// # use chat_server::env::require_env; -/// // Panic si JWT_SECRET n'est pas défini -/// let secret = require_env("JWT_SECRET"); -/// ``` -pub fn require_env(key: &str) -> String { - env::var(key).unwrap_or_else(|_| { - panic!( - "FATAL: Required environment variable {} is not set. \ - Application cannot start without this configuration.", - key - ) - }) -} - -/// Récupère une variable d'environnement requise avec validation de longueur minimale. -/// -/// Utile pour les secrets qui doivent avoir une certaine complexité. -/// -/// # Arguments -/// -/// * `key` - Le nom de la variable d'environnement -/// * `min_length` - Longueur minimale requise -/// -/// # Panics -/// -/// Panic si la variable n'est pas définie ou si sa longueur est inférieure à `min_length`. -/// -/// # Example -/// -/// ```rust,should_panic -/// # use chat_server::env::require_env_min_length; -/// // Panic si JWT_SECRET n'est pas défini ou fait moins de 32 caractères -/// let secret = require_env_min_length("JWT_SECRET", 32); -/// ``` -pub fn require_env_min_length(key: &str, min_length: usize) -> String { - let value = require_env(key); - if value.len() < min_length { - panic!( - "FATAL: Environment variable {} must be at least {} characters long (got {})", - key, - min_length, - value.len() - ) - } - value -} - -#[cfg(test)] -mod tests { - use super::*; - use std::panic; - - #[test] - fn test_require_env_panics_on_missing() { - let key = "TEST_NONEXISTENT_VAR_12345"; - env::remove_var(key); - - let result = panic::catch_unwind(|| require_env(key)); - - assert!( - result.is_err(), - "require_env should panic on missing variable" - ); - } - - #[test] - fn test_require_env_returns_value_when_set() { - let key = "TEST_EXISTING_VAR"; - let value = "test_value_123"; - env::set_var(key, value); - - let result = require_env(key); - assert_eq!(result, value); - - env::remove_var(key); - } - - #[test] - fn test_require_env_min_length_panics_on_short() { - let key = "TEST_SHORT_SECRET"; - env::set_var(key, "short"); - - let result = panic::catch_unwind(|| require_env_min_length(key, 32)); - - env::remove_var(key); - assert!( - result.is_err(), - "require_env_min_length should panic on short value" - ); - } - - #[test] - fn test_require_env_min_length_returns_value_when_valid() { - let key = "TEST_LONG_SECRET"; - let value = "this_is_a_long_secret_key_that_meets_the_minimum_length_requirement"; - env::set_var(key, value); - - let result = require_env_min_length(key, 32); - assert_eq!(result, value); - - env::remove_var(key); - } - - #[test] - fn test_require_env_min_length_exact_boundary() { - let key = "TEST_EXACT_32"; - let value = "12345678901234567890123456789012"; - env::set_var(key, value); - - let result = require_env_min_length(key, 32); - assert_eq!(result.len(), 32); - - env::remove_var(key); - } - - #[test] - fn test_require_env_min_length_panics_on_empty() { - let key = "TEST_EMPTY_VAR"; - env::set_var(key, ""); - - let result = panic::catch_unwind(|| require_env_min_length(key, 32)); - env::remove_var(key); - assert!(result.is_err()); - } -} diff --git a/veza-chat-server/src/error.rs b/veza-chat-server/src/error.rs deleted file mode 100644 index 087938fa4..000000000 --- a/veza-chat-server/src/error.rs +++ /dev/null @@ -1,744 +0,0 @@ -//! # Gestion d'erreurs unifiée pour Veza Chat Server -//! -//! Ce module fournit un système d'erreurs cohérent et complet avec: -//! - Catégorisation des erreurs par domaine -//! - Codes d'erreur standardisés -//! - Logging automatique selon la gravité -//! - Sérialisation pour l'API - -use serde::{Deserialize, Serialize}; -use std::fmt; -use thiserror::Error; - -/// Type alias pour Result avec notre erreur personnalisée -pub type Result = std::result::Result; - -/// Erreurs principales du système de chat -#[derive(Error, Debug)] -pub enum ChatError { - // ═══════════════════════════════════════════════════════════════════════ - // ERREURS D'AUTHENTIFICATION ET AUTORISATION - // ═══════════════════════════════════════════════════════════════════════ - /// Token JWT invalide ou expiré - #[error("Token d'authentification invalide: {reason}")] - InvalidToken { reason: String }, - - /// Utilisateur non autorisé pour cette action - #[error("Accès refusé: {action}")] - Unauthorized { action: String }, - - /// Utilisateur banni ou suspendu - #[error("Compte suspendu: {reason}")] - AccountSuspended { reason: String }, - - /// Tentative de connexion avec des identifiants invalides - #[error("Identifiants invalides")] - InvalidCredentials, - - /// Authentification à deux facteurs requise - #[error("Authentification 2FA requise")] - TwoFactorRequired, - - /// Code 2FA invalide - #[error("Code d'authentification 2FA invalide")] - InvalidTwoFactorCode, - - // ═══════════════════════════════════════════════════════════════════════ - // ERREURS DE VALIDATION ET CONTENU - // ═══════════════════════════════════════════════════════════════════════ - /// Contenu de message trop long - #[error("Message trop long: {actual} caractères (max: {max})")] - MessageTooLong { actual: usize, max: usize }, - - /// Contenu inapproprié détecté - #[error("Contenu inapproprié détecté: {reason}")] - InappropriateContent { reason: String }, - - /// Spam détecté par les filtres - #[error("Contenu identifié comme spam")] - SpamDetected, - - /// Format de données invalide - #[error("Format invalide pour {field}: {reason}")] - InvalidFormat { field: String, reason: String }, - - /// Paramètre requis manquant - #[error("Paramètre requis manquant: {param}")] - MissingParameter { param: String }, - - /// Valeur hors limites acceptables - #[error("{field} hors limites: {value} (min: {min}, max: {max})")] - OutOfRange { - field: String, - value: i64, - min: i64, - max: i64, - }, - - // ═══════════════════════════════════════════════════════════════════════ - // ERREURS DE RATE LIMITING ET QUOTA - // ═══════════════════════════════════════════════════════════════════════ - /// Limite de taux dépassée - #[error("Limite de taux dépassée pour {action}: {current}/{limit} dans {window}s")] - RateLimitExceeded { - action: String, - current: u32, - limit: u32, - window: u64, - }, - - /// Quota utilisateur dépassé - #[error("Quota {quota_type} dépassé: {used}/{limit}")] - QuotaExceeded { - quota_type: String, - used: u64, - limit: u64, - }, - - /// Trop de connexions simultanées - #[error("Trop de connexions simultanées: {current}/{max}")] - TooManyConnections { current: u32, max: u32 }, - - // ═══════════════════════════════════════════════════════════════════════ - // ERREURS RÉSEAU ET WEBSOCKET - // ═══════════════════════════════════════════════════════════════════════ - /// Erreur de connexion WebSocket - #[error("Erreur WebSocket: {source}")] - WebSocket { - #[source] - source: tokio_tungstenite::tungstenite::Error, - }, - - /// Connexion fermée de manière inattendue - #[error("Connexion fermée: {reason}")] - ConnectionClosed { reason: String }, - - /// Timeout de connexion - #[error("Timeout de connexion après {seconds}s")] - ConnectionTimeout { seconds: u64 }, - - /// Erreur réseau générale - #[error("Erreur réseau: {message}")] - NetworkError { message: String }, - - // ═══════════════════════════════════════════════════════════════════════ - // ERREURS DE BASE DE DONNÉES - // ═══════════════════════════════════════════════════════════════════════ - /// Erreur de base de données - #[error("Erreur base de données: {operation}")] - Database { - operation: String, - #[source] - source: sqlx::Error, - }, - - /// Ressource non trouvée - #[error("{resource} non trouvé(e): {id}")] - NotFound { resource: String, id: String }, - - /// Conflit de données (ex: violation de contrainte unique) - #[error("Conflit de données: {reason}")] - Conflict { reason: String }, - - /// Transaction échouée - #[error("Transaction échouée: {reason}")] - TransactionFailed { reason: String }, - - // ═══════════════════════════════════════════════════════════════════════ - // ERREURS DE CONVERSATIONS ET MESSAGES - // ═══════════════════════════════════════════════════════════════════════ - /// Conversation inexistante - #[error("Conversation {id} inexistante")] - ConversationNotFound { id: String }, - - /// Utilisateur pas membre de la conversation - #[error("Utilisateur non membre de la conversation {conversation_id}")] - NotMember { conversation_id: String }, - - /// Permissions insuffisantes dans la conversation - #[error("Permissions insuffisantes pour {action} dans {conversation_id}")] - InsufficientPermissions { - action: String, - conversation_id: String, - }, - - /// Conversation archivée - #[error("Conversation {id} archivée")] - ConversationArchived { id: String }, - - /// Message non trouvé - #[error("Message {id} non trouvé")] - MessageNotFound { id: String }, - - /// Impossible d'éditer le message - #[error("Edition impossible: {reason}")] - EditForbidden { reason: String }, - - // ═══════════════════════════════════════════════════════════════════════ - // ERREURS DE FICHIERS ET UPLOAD - // ═══════════════════════════════════════════════════════════════════════ - /// Fichier trop volumineux - #[error("Fichier trop volumineux: {size} bytes (max: {max_size})")] - FileTooLarge { size: u64, max_size: u64 }, - - /// Type de fichier non autorisé - #[error("Type de fichier non autorisé: {mime_type}")] - UnsupportedFileType { mime_type: String }, - - /// Fichier infecté détecté - #[error("Fichier potentiellement dangereux détecté")] - MaliciousFile, - - /// Erreur d'upload - #[error("Erreur upload: {reason}")] - UploadError { reason: String }, - - // ═══════════════════════════════════════════════════════════════════════ - // ERREURS SYSTÈME ET CONFIGURATION - // ═══════════════════════════════════════════════════════════════════════ - /// Erreur de configuration - #[error("Erreur configuration: {message}")] - Configuration { message: String }, - - /// Service indisponible - #[error("Service {service} indisponible: {reason}")] - ServiceUnavailable { service: String, reason: String }, - - /// Erreur de cache - #[error("Erreur cache: {operation}")] - Cache { operation: String }, - - /// Timeout d'arrêt du serveur - #[error("Timeout lors de l'arrêt du serveur")] - ShutdownTimeout, - - /// Erreur interne non spécifiée - #[error("Erreur interne: {message}")] - Internal { message: String }, - - /// EventBus RabbitMQ indisponible - #[error("EventBus indisponible: {source}")] - EventBusUnavailable { - #[from] - source: crate::event_bus::EventBusUnavailableError, - }, - - // ═══════════════════════════════════════════════════════════════════════ - // ERREURS DE PERMISSIONS ET RÉACTIONS - // ═══════════════════════════════════════════════════════════════════════ - /// Permission refusée - #[error("Permission refusée: {message}")] - PermissionDenied { message: String }, - - /// Réaction déjà existante - #[error("Réaction déjà existante pour ce message")] - ReactionAlreadyExists, - - /// Réaction non trouvée - #[error("Réaction non trouvée")] - ReactionNotFound, - - // ═══════════════════════════════════════════════════════════════════════ - // ERREURS DE SÉCURITÉ - // ═══════════════════════════════════════════════════════════════════════ - /// Activité suspecte détectée - #[error("Activité suspecte détectée: {reason}")] - SuspiciousActivity { reason: String }, - - /// IP bloquée - #[error("Adresse IP {ip} bloquée: {reason}")] - IpBlocked { ip: String, reason: String }, - - /// Tentative d'injection détectée - #[error("Tentative d'injection détectée")] - InjectionAttempt, - - /// Validation de sécurité échouée - #[error("Validation sécurité échouée: {check}")] - SecurityValidationFailed { check: String }, - - /// Erreur de sérialisation JSON - #[error("Erreur JSON: {source}")] - Json { - #[source] - source: serde_json::Error, - }, - - /// Erreur de sérialisation générale - #[error("Erreur de sérialisation {operation}: {message}")] - Serialization { operation: String, message: String }, - - /// Fonctionnalité non disponible - #[error("Fonctionnalité {feature} non disponible: {reason}")] - FeatureNotAvailable { feature: String, reason: String }, - - /// Erreur de validation - #[error("Erreur de validation pour {field}: {reason}")] - ValidationError { field: String, reason: String }, - - /// Erreur de parsing - #[error("Erreur de parsing: {reason}")] - ParseError { reason: String }, - - /// Limite de connexions atteinte - #[error("Limite de connexions simultanées atteinte")] - ConnectionLimitReached, -} - -impl ChatError { - /// Retourne le code d'erreur HTTP approprié - pub fn http_status(&self) -> u16 { - match self { - // 400 Bad Request - Self::InvalidFormat { .. } - | Self::MissingParameter { .. } - | Self::OutOfRange { .. } - | Self::MessageTooLong { .. } - | Self::FileTooLarge { .. } - | Self::UnsupportedFileType { .. } => 400, - - // 401 Unauthorized - Self::InvalidToken { .. } - | Self::InvalidCredentials - | Self::TwoFactorRequired - | Self::InvalidTwoFactorCode => 401, - - // 403 Forbidden - Self::Unauthorized { .. } - | Self::AccountSuspended { .. } - | Self::InsufficientPermissions { .. } - | Self::EditForbidden { .. } - | Self::IpBlocked { .. } => 403, - - // 404 Not Found - Self::NotFound { .. } - | Self::ConversationNotFound { .. } - | Self::MessageNotFound { .. } => 404, - - // 409 Conflict - Self::Conflict { .. } => 409, - - // 413 Payload Too Large - - // 422 Unprocessable Entity - Self::InappropriateContent { .. } | Self::SpamDetected | Self::MaliciousFile => 422, - - // 429 Too Many Requests - Self::RateLimitExceeded { .. } - | Self::QuotaExceeded { .. } - | Self::TooManyConnections { .. } => 429, - - // 500 Internal Server Error - Self::Database { .. } - | Self::Internal { .. } - | Self::Configuration { .. } - | Self::TransactionFailed { .. } - | Self::UploadError { .. } - | Self::Cache { .. } => 500, - - // 503 Service Unavailable - Self::ServiceUnavailable { .. } - | Self::ShutdownTimeout - | Self::EventBusUnavailable { .. } => 503, // Added EventBusUnavailable - - // 418 I'm a teapot (pour les tentatives d'injection) - Self::InjectionAttempt => 418, - - // Autres erreurs -> 500 - Self::Json { .. } - | Self::Serialization { .. } - | Self::FeatureNotAvailable { .. } - | Self::ConnectionLimitReached - | Self::SecurityValidationFailed { .. } - | Self::SuspiciousActivity { .. } - | Self::ConversationArchived { .. } - | Self::NetworkError { .. } - | Self::ConnectionClosed { .. } - | Self::ConnectionTimeout { .. } - | Self::WebSocket { .. } - | Self::NotMember { .. } => 500, - - // Nouvelles erreurs - Self::PermissionDenied { .. } => 403, - Self::ReactionAlreadyExists => 409, - Self::ReactionNotFound => 404, - Self::ValidationError { .. } => 400, - Self::ParseError { .. } => 400, - } - } - - /// Retourne la sévérité de l'erreur pour les logs - pub fn severity(&self) -> ErrorSeverity { - match self { - // CRITICAL - Erreur système critique - Self::Database { .. } - | Self::ServiceUnavailable { .. } - | Self::ShutdownTimeout - | Self::SuspiciousActivity { .. } - | Self::InjectionAttempt - | Self::IpBlocked { .. } - | Self::EventBusUnavailable { .. } => ErrorSeverity::High, // Added EventBusUnavailable - // HIGH - Problème sérieux à traiter rapidement - Self::InvalidToken { .. } - | Self::AccountSuspended { .. } - | Self::InvalidFormat { .. } - | Self::MissingParameter { .. } - | Self::OutOfRange { .. } - | Self::MessageTooLong { .. } - | Self::FileTooLarge { .. } - | Self::UnsupportedFileType { .. } - | Self::TransactionFailed { .. } - | Self::UploadError { .. } - | Self::InvalidCredentials - | Self::InvalidTwoFactorCode - | Self::InappropriateContent { .. } - | Self::SpamDetected - | Self::MaliciousFile - | Self::ConversationNotFound { .. } - | Self::InsufficientPermissions { .. } - | Self::MessageNotFound { .. } - | Self::EditForbidden { .. } - | Self::Conflict { .. } - | Self::ConnectionLimitReached - | Self::SecurityValidationFailed { .. } => ErrorSeverity::Medium, - - // Gravité moyenne - Erreurs qui affectent l'utilisateur - Self::RateLimitExceeded { .. } - | Self::QuotaExceeded { .. } - | Self::TooManyConnections { .. } - | Self::Unauthorized { .. } - | Self::NotFound { .. } => ErrorSeverity::Low, - - // INFO - Information - Self::ConnectionClosed { .. } - | Self::TwoFactorRequired - | Self::NotMember { .. } - | Self::Json { .. } - | Self::Serialization { .. } - | Self::FeatureNotAvailable { .. } - | Self::ConversationArchived { .. } - | Self::WebSocket { .. } - | Self::NetworkError { .. } - | Self::ConnectionTimeout { .. } - | Self::Cache { .. } - | Self::Internal { .. } - | Self::Configuration { .. } => ErrorSeverity::Info, - - // Nouvelles erreurs - Self::PermissionDenied { .. } => ErrorSeverity::Warning, - Self::ReactionAlreadyExists => ErrorSeverity::Info, - Self::ReactionNotFound => ErrorSeverity::Info, - Self::ValidationError { .. } => ErrorSeverity::Low, - Self::ParseError { .. } => ErrorSeverity::Low, - } - } - - /// Retourne un message d'erreur sécurisé pour le client - pub fn public_message(&self) -> String { - match self { - // Messages détaillés OK pour le client - Self::InvalidFormat { field, .. } => format!("Format invalide pour {}", field), - Self::MissingParameter { param } => format!("Paramètre manquant: {}", param), - Self::MessageTooLong { max, .. } => { - format!("Message trop long (max: {} caractères)", max) - } - Self::RateLimitExceeded { action, window, .. } => { - format!( - "Trop de requêtes pour {}, veuillez patienter {}s", - action, window - ) - } - - // Messages génériques pour éviter la divulgation d'informations - Self::Database { .. } => "Erreur temporaire, veuillez réessayer".to_string(), - Self::Internal { .. } => "Erreur interne du serveur".to_string(), - Self::Configuration { .. } => "Service temporairement indisponible".to_string(), - Self::InjectionAttempt => "Requête rejetée".to_string(), - Self::SuspiciousActivity { .. } => "Activité inhabituelle détectée".to_string(), - - // Message par défaut - _ => self.to_string(), - } - } - - /// Crée une erreur de base de données avec contexte - pub fn database_error(operation: &str, source: sqlx::Error) -> Self { - Self::Database { - operation: operation.to_string(), - source, - } - } - - /// Crée une erreur d'autorisation avec contexte - pub fn unauthorized(action: &str) -> Self { - Self::Unauthorized { - action: action.to_string(), - } - } - - /// Crée une erreur de ressource non trouvée - pub fn not_found(resource: &str, id: &str) -> Self { - Self::NotFound { - resource: resource.to_string(), - id: id.to_string(), - } - } - - /// Helper pour les erreurs de configuration - pub fn configuration_error(message: &str) -> Self { - Self::Configuration { - message: message.to_string(), - } - } - - /// Helper pour les erreurs de message trop long - pub fn message_too_long(actual: usize, max: usize) -> Self { - Self::MessageTooLong { actual, max } - } - - /// Helper pour les erreurs de sérialisation - pub fn serialization_error(type_name: &str, _data: &str, source: serde_json::Error) -> Self { - Self::Serialization { - operation: format!("serialize {}", type_name), - message: source.to_string(), - } - } - - /// Helper pour les erreurs WebSocket - pub fn websocket_error( - _operation: &str, - source: tokio_tungstenite::tungstenite::Error, - ) -> Self { - Self::WebSocket { source } - } - - /// Helper pour les fonctionnalités non disponibles - pub fn feature_not_available(feature: &str, reason: &str) -> Self { - Self::FeatureNotAvailable { - feature: feature.to_string(), - reason: reason.to_string(), - } - } - - /// Helper pour convertir sqlx::Error avec une meilleure gestion - pub fn from_sqlx_error(operation: &str, error: sqlx::Error) -> Self { - Self::Database { - operation: operation.to_string(), - source: error, - } - } - - /// Helper pour les erreurs JSON - pub fn from_json_error(error: serde_json::Error) -> Self { - Self::Json { source: error } - } - - /// Helper pour les erreurs de rate limiting avec des valeurs par défaut - pub fn rate_limit_exceeded_simple(action: &str) -> Self { - Self::RateLimitExceeded { - action: action.to_string(), - current: 0, - limit: 0, - window: 60, - } - } - - /// Helper pour les erreurs d'autorisation - pub fn unauthorized_simple(action: &str) -> Self { - Self::Unauthorized { - action: action.to_string(), - } - } - - /// Helper pour les erreurs de contenu inapproprié - pub fn inappropriate_content_simple(reason: &str) -> Self { - Self::InappropriateContent { - reason: reason.to_string(), - } - } - - /// Helper pour les erreurs de validation - pub fn validation_error(reason: &str) -> Self { - Self::ValidationError { - field: "general".to_string(), - reason: reason.to_string(), - } - } - - /// Helper pour les erreurs de permission - pub fn permission_denied(message: &str) -> Self { - Self::PermissionDenied { - message: message.to_string(), - } - } - - /// Helper pour les erreurs internes - pub fn internal_error(message: String) -> Self { - // Changed to String - Self::Internal { - message, // Direct assignment - } - } - - /// Helper pour les erreurs not found avec un seul paramètre - pub fn not_found_simple(message: &str) -> Self { - Self::NotFound { - resource: "resource".to_string(), - id: message.to_string(), - } - } -} - -/// Niveaux de sévérité des erreurs -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ErrorSeverity { - Info, - Low, - Medium, - High, - Critical, - Warning, -} - -impl fmt::Display for ErrorSeverity { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Info => write!(f, "INFO"), - Self::Low => write!(f, "LOW"), - Self::Medium => write!(f, "MEDIUM"), - Self::High => write!(f, "HIGH"), - Self::Critical => write!(f, "CRITICAL"), - Self::Warning => write!(f, "WARNING"), - } - } -} - -/// Implémentations de conversion depuis des erreurs externes -impl From for ChatError { - fn from(err: sqlx::Error) -> Self { - Self::database_error("query", err) - } -} - -impl From for ChatError { - fn from(err: tokio_tungstenite::tungstenite::Error) -> Self { - Self::WebSocket { source: err } - } -} - -impl From for ChatError { - fn from(err: serde_json::Error) -> Self { - Self::InvalidFormat { - field: "json".to_string(), - reason: err.to_string(), - } - } -} - -impl From for ChatError { - fn from(err: std::env::VarError) -> Self { - Self::Configuration { - message: format!("Variable d'environnement manquante: {}", err), - } - } -} - -/// Macro pour simplifier la création d'erreurs -#[macro_export] -macro_rules! chat_error { - ($variant:ident, $($field:ident = $value:expr),*) => { - $crate::error::ChatError::$variant { - $($field: $value.into()),* - } - }; - ($variant:ident) => { - $crate::error::ChatError::$variant - }; -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_error_http_status() { - assert_eq!(ChatError::InvalidCredentials.http_status(), 401); - assert_eq!(ChatError::not_found("user", "123").http_status(), 404); - assert_eq!(ChatError::unauthorized("send_message").http_status(), 403); - } - - #[test] - fn test_error_severity() { - assert_eq!(ChatError::InjectionAttempt.severity(), ErrorSeverity::High); - assert_eq!( - ChatError::InvalidCredentials.severity(), - ErrorSeverity::Medium - ); - assert_eq!(ChatError::SpamDetected.severity(), ErrorSeverity::Medium); - } - - #[test] - fn test_public_message() { - let error = ChatError::InvalidFormat { - field: "email".to_string(), - reason: "invalid format".to_string(), - }; - assert_eq!(error.public_message(), "Format invalide pour email"); - - let db_error = ChatError::database_error("insert", sqlx::Error::RowNotFound); - assert_eq!( - db_error.public_message(), - "Erreur temporaire, veuillez réessayer" - ); - } - - #[test] - fn test_error_creation_helpers() { - let error = ChatError::not_found("conversation", "room_123"); - match error { - ChatError::NotFound { resource, id } => { - assert_eq!(resource, "conversation"); - assert_eq!(id, "room_123"); - } - _ => panic!("Wrong error type"), - } - } - - #[test] - fn test_macro() { - let error = chat_error!(MessageTooLong, actual = 5000_usize, max = 4000_usize); - match error { - ChatError::MessageTooLong { actual, max } => { - assert_eq!(actual, 5000); - assert_eq!(max, 4000); - } - _ => panic!("Wrong error type"), - } - } - - #[test] - fn test_error_severity_display() { - assert_eq!(format!("{}", ErrorSeverity::Info), "INFO"); - assert_eq!(format!("{}", ErrorSeverity::High), "HIGH"); - assert_eq!(format!("{}", ErrorSeverity::Critical), "CRITICAL"); - } - - #[test] - fn test_error_severity_equality() { - assert_eq!(ErrorSeverity::Low, ErrorSeverity::Low); - assert_ne!(ErrorSeverity::Info, ErrorSeverity::Warning); - } - - #[test] - fn test_message_too_long_helper() { - let err = ChatError::message_too_long(500, 400); - assert_eq!(err.http_status(), 400); - assert!(err.public_message().contains("400")); - } - - #[test] - fn test_validation_error_helper() { - let err = ChatError::validation_error("invalid input"); - assert_eq!(err.http_status(), 400); - } -} diff --git a/veza-chat-server/src/event_bus.rs b/veza-chat-server/src/event_bus.rs deleted file mode 100644 index 4935106d9..000000000 --- a/veza-chat-server/src/event_bus.rs +++ /dev/null @@ -1,209 +0,0 @@ -use lapin::{types::FieldTable, Channel, Connection, ConnectionProperties}; -use tokio::time::{sleep, Duration}; -use tracing::{error, info, warn}; - -use crate::{ - config, - error::{ChatError, Result}, -}; - -/// Erreur spécifique pour l'indisponibilité de l'Event Bus -#[derive(Debug, thiserror::Error)] -#[error("RabbitMQ EventBus is unavailable: {message}")] -pub struct EventBusUnavailableError { - message: String, -} - -impl EventBusUnavailableError { - pub fn new(message: &str) -> Self { - Self { - message: message.to_string(), - } - } -} - -/// Gestionnaire de connexion RabbitMQ -pub struct RabbitMQEventBus { - _config: config::RabbitMQConfig, // Use the canonical config type - _connection: Option, - channel: Option, - pub is_enabled: bool, -} - -impl RabbitMQEventBus { - /// Tente de se connecter à RabbitMQ avec une politique de retry - pub async fn new_with_retry(config: config::RabbitMQConfig) -> Result { - if !config.enable { - info!("📴 EventBus RabbitMQ désactivé par configuration."); - return Ok(Self { - _config: config, - _connection: None, - channel: None, - is_enabled: false, - }); - } - - let mut attempts = 0; - let max_attempts = config.max_retries; - let retry_interval = Duration::from_secs(config.retry_interval_secs); - - while attempts < max_attempts { - info!( - "🔄 Tentative de connexion à RabbitMQ (url: {}) - Essai {}/{}", - config.url, - attempts + 1, - max_attempts - ); - - match Connection::connect(&config.url, ConnectionProperties::default()).await { - Ok(conn) => { - info!("✅ Connexion à RabbitMQ établie avec succès."); - let channel = conn.create_channel().await.map_err(|e| { - ChatError::internal_error(format!("Failed to open RabbitMQ channel: {}", e)) - })?; - return Ok(Self { - _config: config, - _connection: Some(conn), - channel: Some(channel), - is_enabled: true, - }); - } - Err(e) => { - warn!( - "❌ Échec de connexion à RabbitMQ (url: {}): {}. Retrying in {} seconds...", - config.url, e, config.retry_interval_secs - ); - attempts += 1; - sleep(retry_interval).await; - } - } - } - - error!( - "❌ Échec de connexion à RabbitMQ après {} tentatives. L'EventBus est indisponible.", - max_attempts - ); - Err(ChatError::EventBusUnavailable { - source: EventBusUnavailableError::new( - "Failed to connect to RabbitMQ after multiple retries", - ), - }) - } - - /// Publie un message sur un exchange RabbitMQ - pub async fn publish(&self, exchange: &str, routing_key: &str, payload: &[u8]) -> Result<()> { - if !self.is_enabled { - return Err(ChatError::EventBusUnavailable { - source: EventBusUnavailableError::new("EventBus is disabled"), - }); - } - - let channel = self - .channel - .as_ref() - .ok_or_else(|| ChatError::EventBusUnavailable { - source: EventBusUnavailableError::new("RabbitMQ channel is not available"), - })?; - - channel - .basic_publish( - exchange, - routing_key, - lapin::options::BasicPublishOptions::default(), - payload, - lapin::BasicProperties::default(), - ) - .await - .map(|_| ()) - .map_err(|e| ChatError::internal_error(format!("Failed to publish message: {}", e))) - } - - /// Crée une queue - pub async fn create_queue(&self, queue_name: &str) -> Result<()> { - if !self.is_enabled { - warn!( - "⚠️ Tentative de déclaration d'exchange sur EventBus désactivé ({})", - queue_name - ); // Fixed: changed exchange_name to queue_name - return Err(ChatError::EventBusUnavailable { - source: EventBusUnavailableError::new("EventBus is disabled"), - }); - } - let channel = self - .channel - .as_ref() - .ok_or_else(|| ChatError::EventBusUnavailable { - source: EventBusUnavailableError::new("RabbitMQ channel is not available"), - })?; - - channel - .queue_declare( - queue_name, - lapin::options::QueueDeclareOptions { - durable: true, - ..lapin::options::QueueDeclareOptions::default() - }, - FieldTable::default(), - ) - .await - .map(|_| ()) - .map_err(|e| { - ChatError::internal_error(format!("Failed to declare queue {}: {}", queue_name, e)) - }) - } - - /// Lie une queue à un exchange - pub async fn bind_queue( - &self, - queue_name: &str, - exchange_name: &str, - routing_key: &str, - ) -> Result<()> { - if !self.is_enabled { - warn!( - "⚠️ Tentative de lier une queue sur EventBus désactivé (queue: {}, exchange: {})", - queue_name, exchange_name - ); - return Err(ChatError::EventBusUnavailable { - source: EventBusUnavailableError::new("EventBus is disabled"), - }); - } - let channel = self - .channel - .as_ref() - .ok_or_else(|| ChatError::EventBusUnavailable { - source: EventBusUnavailableError::new("RabbitMQ channel is not available"), - })?; - - channel - .queue_bind( - queue_name, - exchange_name, - routing_key, - lapin::options::QueueBindOptions::default(), - FieldTable::default(), - ) - .await - .map(|_| ()) - .map_err(|e| { - ChatError::internal_error(format!( - "Failed to bind queue {} to exchange {}: {}", - queue_name, exchange_name, e - )) - }) - } - - #[allow(dead_code)] - fn exchange_kind_from_str(s: &str) -> lapin::ExchangeKind { - match s.to_lowercase().as_str() { - "direct" => lapin::ExchangeKind::Direct, - "fanout" => lapin::ExchangeKind::Fanout, - "topic" => lapin::ExchangeKind::Topic, - "headers" => lapin::ExchangeKind::Headers, - _ => { - warn!("Unknown exchange type: {}. Defaulting to Topic.", s); - lapin::ExchangeKind::Topic - } - } - } -} diff --git a/veza-chat-server/src/generated/veza.chat.rs b/veza-chat-server/src/generated/veza.chat.rs deleted file mode 100644 index 6fd4a8a29..000000000 --- a/veza-chat-server/src/generated/veza.chat.rs +++ /dev/null @@ -1,1509 +0,0 @@ -// This file is @generated by prost-build. -/// Messages pour les salles -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct CreateRoomRequest { - #[prost(string, tag = "1")] - pub name: ::prost::alloc::string::String, - #[prost(string, tag = "2")] - pub description: ::prost::alloc::string::String, - #[prost(enumeration = "RoomType", tag = "3")] - pub r#type: i32, - #[prost(enumeration = "RoomVisibility", tag = "4")] - pub visibility: i32, - #[prost(int64, tag = "5")] - pub created_by: i64, - #[prost(string, tag = "6")] - pub auth_token: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct CreateRoomResponse { - #[prost(message, optional, tag = "1")] - pub room: ::core::option::Option, - #[prost(string, tag = "2")] - pub error: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct JoinRoomRequest { - #[prost(string, tag = "1")] - pub room_id: ::prost::alloc::string::String, - #[prost(int64, tag = "2")] - pub user_id: i64, - #[prost(string, tag = "3")] - pub auth_token: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct JoinRoomResponse { - #[prost(bool, tag = "1")] - pub success: bool, - #[prost(message, optional, tag = "2")] - pub member: ::core::option::Option, - #[prost(string, tag = "3")] - pub error: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct LeaveRoomRequest { - #[prost(string, tag = "1")] - pub room_id: ::prost::alloc::string::String, - #[prost(int64, tag = "2")] - pub user_id: i64, - #[prost(string, tag = "3")] - pub auth_token: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct LeaveRoomResponse { - #[prost(bool, tag = "1")] - pub success: bool, - #[prost(string, tag = "2")] - pub error: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct GetRoomInfoRequest { - #[prost(string, tag = "1")] - pub room_id: ::prost::alloc::string::String, - #[prost(string, tag = "2")] - pub auth_token: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct ListRoomsRequest { - #[prost(enumeration = "RoomVisibility", tag = "1")] - pub visibility: i32, - #[prost(int32, tag = "2")] - pub page: i32, - #[prost(int32, tag = "3")] - pub limit: i32, - #[prost(string, tag = "4")] - pub auth_token: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct ListRoomsResponse { - #[prost(message, repeated, tag = "1")] - pub rooms: ::prost::alloc::vec::Vec, - #[prost(int32, tag = "2")] - pub total: i32, - #[prost(string, tag = "3")] - pub error: ::prost::alloc::string::String, -} -/// Messages pour les messages -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct SendMessageRequest { - #[prost(string, tag = "1")] - pub room_id: ::prost::alloc::string::String, - #[prost(int64, tag = "2")] - pub sender_id: i64, - #[prost(string, tag = "3")] - pub content: ::prost::alloc::string::String, - #[prost(enumeration = "MessageType", tag = "4")] - pub r#type: i32, - #[prost(string, tag = "5")] - pub auth_token: ::prost::alloc::string::String, - /// ID du message parent - #[prost(string, tag = "6")] - pub reply_to: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct SendMessageResponse { - #[prost(message, optional, tag = "1")] - pub message: ::core::option::Option, - #[prost(string, tag = "2")] - pub error: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct GetMessageHistoryRequest { - #[prost(string, tag = "1")] - pub room_id: ::prost::alloc::string::String, - #[prost(int32, tag = "2")] - pub limit: i32, - /// pagination - #[prost(string, tag = "3")] - pub before_id: ::prost::alloc::string::String, - #[prost(string, tag = "4")] - pub auth_token: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct GetMessageHistoryResponse { - #[prost(message, repeated, tag = "1")] - pub messages: ::prost::alloc::vec::Vec, - #[prost(bool, tag = "2")] - pub has_more: bool, - #[prost(string, tag = "3")] - pub error: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct DeleteMessageRequest { - #[prost(string, tag = "1")] - pub message_id: ::prost::alloc::string::String, - #[prost(int64, tag = "2")] - pub user_id: i64, - #[prost(string, tag = "3")] - pub auth_token: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct DeleteMessageResponse { - #[prost(bool, tag = "1")] - pub success: bool, - #[prost(string, tag = "2")] - pub error: ::prost::alloc::string::String, -} -/// Messages directs -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct SendDirectMessageRequest { - #[prost(int64, tag = "1")] - pub sender_id: i64, - #[prost(int64, tag = "2")] - pub recipient_id: i64, - #[prost(string, tag = "3")] - pub content: ::prost::alloc::string::String, - #[prost(enumeration = "MessageType", tag = "4")] - pub r#type: i32, - #[prost(string, tag = "5")] - pub auth_token: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct SendDirectMessageResponse { - #[prost(message, optional, tag = "1")] - pub message: ::core::option::Option, - #[prost(string, tag = "2")] - pub error: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct GetDirectMessagesRequest { - #[prost(int64, tag = "1")] - pub user_id: i64, - #[prost(int64, tag = "2")] - pub other_user_id: i64, - #[prost(int32, tag = "3")] - pub limit: i32, - #[prost(string, tag = "4")] - pub before_id: ::prost::alloc::string::String, - #[prost(string, tag = "5")] - pub auth_token: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct GetDirectMessagesResponse { - #[prost(message, repeated, tag = "1")] - pub messages: ::prost::alloc::vec::Vec, - #[prost(bool, tag = "2")] - pub has_more: bool, - #[prost(string, tag = "3")] - pub error: ::prost::alloc::string::String, -} -/// Modération -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct MuteUserRequest { - #[prost(string, tag = "1")] - pub room_id: ::prost::alloc::string::String, - #[prost(int64, tag = "2")] - pub user_id: i64, - #[prost(int64, tag = "3")] - pub moderator_id: i64, - #[prost(int64, tag = "4")] - pub duration_seconds: i64, - #[prost(string, tag = "5")] - pub reason: ::prost::alloc::string::String, - #[prost(string, tag = "6")] - pub auth_token: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct MuteUserResponse { - #[prost(bool, tag = "1")] - pub success: bool, - #[prost(string, tag = "2")] - pub error: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct BanUserRequest { - #[prost(string, tag = "1")] - pub room_id: ::prost::alloc::string::String, - #[prost(int64, tag = "2")] - pub user_id: i64, - #[prost(int64, tag = "3")] - pub moderator_id: i64, - #[prost(string, tag = "4")] - pub reason: ::prost::alloc::string::String, - #[prost(string, tag = "5")] - pub auth_token: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct BanUserResponse { - #[prost(bool, tag = "1")] - pub success: bool, - #[prost(string, tag = "2")] - pub error: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct ModerateMessageRequest { - #[prost(string, tag = "1")] - pub message_id: ::prost::alloc::string::String, - #[prost(int64, tag = "2")] - pub moderator_id: i64, - #[prost(enumeration = "ModerationAction", tag = "3")] - pub action: i32, - #[prost(string, tag = "4")] - pub reason: ::prost::alloc::string::String, - #[prost(string, tag = "5")] - pub auth_token: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct ModerateMessageResponse { - #[prost(bool, tag = "1")] - pub success: bool, - #[prost(string, tag = "2")] - pub error: ::prost::alloc::string::String, -} -/// Statistiques -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct GetRoomStatsRequest { - #[prost(string, tag = "1")] - pub room_id: ::prost::alloc::string::String, - #[prost(string, tag = "2")] - pub auth_token: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct GetUserActivityRequest { - #[prost(int64, tag = "1")] - pub user_id: i64, - #[prost(string, tag = "2")] - pub auth_token: ::prost::alloc::string::String, -} -/// Types de données -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct Room { - #[prost(string, tag = "1")] - pub id: ::prost::alloc::string::String, - #[prost(string, tag = "2")] - pub name: ::prost::alloc::string::String, - #[prost(string, tag = "3")] - pub description: ::prost::alloc::string::String, - #[prost(enumeration = "RoomType", tag = "4")] - pub r#type: i32, - #[prost(enumeration = "RoomVisibility", tag = "5")] - pub visibility: i32, - #[prost(int64, tag = "6")] - pub created_by: i64, - #[prost(int64, tag = "7")] - pub created_at: i64, - #[prost(int32, tag = "8")] - pub member_count: i32, - #[prost(int32, tag = "9")] - pub online_count: i32, - #[prost(bool, tag = "10")] - pub is_active: bool, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct RoomMember { - #[prost(int64, tag = "1")] - pub user_id: i64, - #[prost(string, tag = "2")] - pub username: ::prost::alloc::string::String, - #[prost(enumeration = "RoomRole", tag = "3")] - pub role: i32, - #[prost(int64, tag = "4")] - pub joined_at: i64, - #[prost(bool, tag = "5")] - pub is_online: bool, - #[prost(int64, tag = "6")] - pub last_seen: i64, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct Message { - #[prost(string, tag = "1")] - pub id: ::prost::alloc::string::String, - #[prost(string, tag = "2")] - pub room_id: ::prost::alloc::string::String, - #[prost(int64, tag = "3")] - pub sender_id: i64, - #[prost(string, tag = "4")] - pub sender_username: ::prost::alloc::string::String, - #[prost(string, tag = "5")] - pub content: ::prost::alloc::string::String, - #[prost(enumeration = "MessageType", tag = "6")] - pub r#type: i32, - #[prost(int64, tag = "7")] - pub created_at: i64, - #[prost(int64, tag = "8")] - pub updated_at: i64, - #[prost(bool, tag = "9")] - pub is_edited: bool, - #[prost(bool, tag = "10")] - pub is_deleted: bool, - #[prost(string, tag = "11")] - pub reply_to: ::prost::alloc::string::String, - #[prost(message, repeated, tag = "12")] - pub reactions: ::prost::alloc::vec::Vec, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct DirectMessage { - #[prost(string, tag = "1")] - pub id: ::prost::alloc::string::String, - #[prost(int64, tag = "2")] - pub sender_id: i64, - #[prost(int64, tag = "3")] - pub recipient_id: i64, - #[prost(string, tag = "4")] - pub content: ::prost::alloc::string::String, - #[prost(enumeration = "MessageType", tag = "5")] - pub r#type: i32, - #[prost(int64, tag = "6")] - pub created_at: i64, - #[prost(bool, tag = "7")] - pub is_read: bool, - #[prost(bool, tag = "8")] - pub is_deleted: bool, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct MessageReaction { - #[prost(string, tag = "1")] - pub emoji: ::prost::alloc::string::String, - #[prost(int64, repeated, tag = "2")] - pub user_ids: ::prost::alloc::vec::Vec, - #[prost(int32, tag = "3")] - pub count: i32, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct RoomStats { - #[prost(string, tag = "1")] - pub room_id: ::prost::alloc::string::String, - #[prost(int32, tag = "2")] - pub total_members: i32, - #[prost(int32, tag = "3")] - pub online_members: i32, - #[prost(int32, tag = "4")] - pub messages_today: i32, - #[prost(int32, tag = "5")] - pub total_messages: i32, - #[prost(int64, repeated, tag = "6")] - pub active_users: ::prost::alloc::vec::Vec, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct UserActivity { - #[prost(int64, tag = "1")] - pub user_id: i64, - #[prost(int32, tag = "2")] - pub rooms_joined: i32, - #[prost(int32, tag = "3")] - pub messages_sent: i32, - #[prost(int64, tag = "4")] - pub last_activity: i64, - #[prost(bool, tag = "5")] - pub is_online: bool, - #[prost(string, tag = "6")] - pub current_status: ::prost::alloc::string::String, -} -/// Énumérations -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum RoomType { - Public = 0, - Private = 1, - Direct = 2, - Premium = 3, -} -impl RoomType { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - RoomType::Public => "PUBLIC", - RoomType::Private => "PRIVATE", - RoomType::Direct => "DIRECT", - RoomType::Premium => "PREMIUM", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "PUBLIC" => Some(Self::Public), - "PRIVATE" => Some(Self::Private), - "DIRECT" => Some(Self::Direct), - "PREMIUM" => Some(Self::Premium), - _ => None, - } - } -} -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum RoomVisibility { - Open = 0, - InviteOnly = 1, - Hidden = 2, -} -impl RoomVisibility { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - RoomVisibility::Open => "OPEN", - RoomVisibility::InviteOnly => "INVITE_ONLY", - RoomVisibility::Hidden => "HIDDEN", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "OPEN" => Some(Self::Open), - "INVITE_ONLY" => Some(Self::InviteOnly), - "HIDDEN" => Some(Self::Hidden), - _ => None, - } - } -} -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum RoomRole { - Member = 0, - Moderator = 1, - Admin = 2, - Owner = 3, -} -impl RoomRole { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - RoomRole::Member => "MEMBER", - RoomRole::Moderator => "MODERATOR", - RoomRole::Admin => "ADMIN", - RoomRole::Owner => "OWNER", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "MEMBER" => Some(Self::Member), - "MODERATOR" => Some(Self::Moderator), - "ADMIN" => Some(Self::Admin), - "OWNER" => Some(Self::Owner), - _ => None, - } - } -} -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum MessageType { - Text = 0, - Image = 1, - File = 2, - Audio = 3, - Video = 4, - System = 5, -} -impl MessageType { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - MessageType::Text => "TEXT", - MessageType::Image => "IMAGE", - MessageType::File => "FILE", - MessageType::Audio => "AUDIO", - MessageType::Video => "VIDEO", - MessageType::System => "SYSTEM", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "TEXT" => Some(Self::Text), - "IMAGE" => Some(Self::Image), - "FILE" => Some(Self::File), - "AUDIO" => Some(Self::Audio), - "VIDEO" => Some(Self::Video), - "SYSTEM" => Some(Self::System), - _ => None, - } - } -} -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum ModerationAction { - Warn = 0, - Delete = 1, - Edit = 2, - Flag = 3, -} -impl ModerationAction { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - ModerationAction::Warn => "WARN", - ModerationAction::Delete => "DELETE", - ModerationAction::Edit => "EDIT", - ModerationAction::Flag => "FLAG", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "WARN" => Some(Self::Warn), - "DELETE" => Some(Self::Delete), - "EDIT" => Some(Self::Edit), - "FLAG" => Some(Self::Flag), - _ => None, - } - } -} -/// Generated server implementations. -pub mod chat_service_server { - #![allow(unused_variables, dead_code, missing_docs, clippy::let_unit_value)] - use tonic::codegen::*; - /// Generated trait containing gRPC methods that should be implemented for use with ChatServiceServer. - #[async_trait] - pub trait ChatService: Send + Sync + 'static { - /// Gestion des salles - async fn create_room( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - async fn join_room( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - async fn leave_room( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - async fn get_room_info( - &self, - request: tonic::Request, - ) -> std::result::Result, tonic::Status>; - async fn list_rooms( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - /// Gestion des messages - async fn send_message( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - async fn get_message_history( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - async fn delete_message( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - /// Messages directs - async fn send_direct_message( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - async fn get_direct_messages( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - /// Modération - async fn mute_user( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - async fn ban_user( - &self, - request: tonic::Request, - ) -> std::result::Result, tonic::Status>; - async fn moderate_message( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - /// Statistiques temps réel - async fn get_room_stats( - &self, - request: tonic::Request, - ) -> std::result::Result, tonic::Status>; - async fn get_user_activity( - &self, - request: tonic::Request, - ) -> std::result::Result, tonic::Status>; - } - /// Service Chat pour communication avec le module Rust - #[derive(Debug)] - pub struct ChatServiceServer { - inner: _Inner, - accept_compression_encodings: EnabledCompressionEncodings, - send_compression_encodings: EnabledCompressionEncodings, - max_decoding_message_size: Option, - max_encoding_message_size: Option, - } - struct _Inner(Arc); - impl ChatServiceServer { - pub fn new(inner: T) -> Self { - Self::from_arc(Arc::new(inner)) - } - pub fn from_arc(inner: Arc) -> Self { - let inner = _Inner(inner); - Self { - inner, - accept_compression_encodings: Default::default(), - send_compression_encodings: Default::default(), - max_decoding_message_size: None, - max_encoding_message_size: None, - } - } - pub fn with_interceptor( - inner: T, - interceptor: F, - ) -> InterceptedService - where - F: tonic::service::Interceptor, - { - InterceptedService::new(Self::new(inner), interceptor) - } - /// Enable decompressing requests with the given encoding. - #[must_use] - pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { - self.accept_compression_encodings.enable(encoding); - self - } - /// Compress responses with the given encoding, if the client supports it. - #[must_use] - pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { - self.send_compression_encodings.enable(encoding); - self - } - /// Limits the maximum size of a decoded message. - /// - /// Default: `4MB` - #[must_use] - pub fn max_decoding_message_size(mut self, limit: usize) -> Self { - self.max_decoding_message_size = Some(limit); - self - } - /// Limits the maximum size of an encoded message. - /// - /// Default: `usize::MAX` - #[must_use] - pub fn max_encoding_message_size(mut self, limit: usize) -> Self { - self.max_encoding_message_size = Some(limit); - self - } - } - impl tonic::codegen::Service> for ChatServiceServer - where - T: ChatService, - B: Body + Send + 'static, - B::Error: Into + Send + 'static, - { - type Response = http::Response; - type Error = std::convert::Infallible; - type Future = BoxFuture; - fn poll_ready( - &mut self, - _cx: &mut Context<'_>, - ) -> Poll> { - Poll::Ready(Ok(())) - } - fn call(&mut self, req: http::Request) -> Self::Future { - let inner = self.inner.clone(); - match req.uri().path() { - "/veza.chat.ChatService/CreateRoom" => { - #[allow(non_camel_case_types)] - struct CreateRoomSvc(pub Arc); - impl< - T: ChatService, - > tonic::server::UnaryService - for CreateRoomSvc { - type Response = super::CreateRoomResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::create_room(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let inner = inner.0; - let method = CreateRoomSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/veza.chat.ChatService/JoinRoom" => { - #[allow(non_camel_case_types)] - struct JoinRoomSvc(pub Arc); - impl< - T: ChatService, - > tonic::server::UnaryService - for JoinRoomSvc { - type Response = super::JoinRoomResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::join_room(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let inner = inner.0; - let method = JoinRoomSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/veza.chat.ChatService/LeaveRoom" => { - #[allow(non_camel_case_types)] - struct LeaveRoomSvc(pub Arc); - impl< - T: ChatService, - > tonic::server::UnaryService - for LeaveRoomSvc { - type Response = super::LeaveRoomResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::leave_room(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let inner = inner.0; - let method = LeaveRoomSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/veza.chat.ChatService/GetRoomInfo" => { - #[allow(non_camel_case_types)] - struct GetRoomInfoSvc(pub Arc); - impl< - T: ChatService, - > tonic::server::UnaryService - for GetRoomInfoSvc { - type Response = super::Room; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::get_room_info(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let inner = inner.0; - let method = GetRoomInfoSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/veza.chat.ChatService/ListRooms" => { - #[allow(non_camel_case_types)] - struct ListRoomsSvc(pub Arc); - impl< - T: ChatService, - > tonic::server::UnaryService - for ListRoomsSvc { - type Response = super::ListRoomsResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::list_rooms(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let inner = inner.0; - let method = ListRoomsSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/veza.chat.ChatService/SendMessage" => { - #[allow(non_camel_case_types)] - struct SendMessageSvc(pub Arc); - impl< - T: ChatService, - > tonic::server::UnaryService - for SendMessageSvc { - type Response = super::SendMessageResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::send_message(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let inner = inner.0; - let method = SendMessageSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/veza.chat.ChatService/GetMessageHistory" => { - #[allow(non_camel_case_types)] - struct GetMessageHistorySvc(pub Arc); - impl< - T: ChatService, - > tonic::server::UnaryService - for GetMessageHistorySvc { - type Response = super::GetMessageHistoryResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::get_message_history(&inner, request) - .await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let inner = inner.0; - let method = GetMessageHistorySvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/veza.chat.ChatService/DeleteMessage" => { - #[allow(non_camel_case_types)] - struct DeleteMessageSvc(pub Arc); - impl< - T: ChatService, - > tonic::server::UnaryService - for DeleteMessageSvc { - type Response = super::DeleteMessageResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::delete_message(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let inner = inner.0; - let method = DeleteMessageSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/veza.chat.ChatService/SendDirectMessage" => { - #[allow(non_camel_case_types)] - struct SendDirectMessageSvc(pub Arc); - impl< - T: ChatService, - > tonic::server::UnaryService - for SendDirectMessageSvc { - type Response = super::SendDirectMessageResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::send_direct_message(&inner, request) - .await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let inner = inner.0; - let method = SendDirectMessageSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/veza.chat.ChatService/GetDirectMessages" => { - #[allow(non_camel_case_types)] - struct GetDirectMessagesSvc(pub Arc); - impl< - T: ChatService, - > tonic::server::UnaryService - for GetDirectMessagesSvc { - type Response = super::GetDirectMessagesResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::get_direct_messages(&inner, request) - .await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let inner = inner.0; - let method = GetDirectMessagesSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/veza.chat.ChatService/MuteUser" => { - #[allow(non_camel_case_types)] - struct MuteUserSvc(pub Arc); - impl< - T: ChatService, - > tonic::server::UnaryService - for MuteUserSvc { - type Response = super::MuteUserResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::mute_user(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let inner = inner.0; - let method = MuteUserSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/veza.chat.ChatService/BanUser" => { - #[allow(non_camel_case_types)] - struct BanUserSvc(pub Arc); - impl< - T: ChatService, - > tonic::server::UnaryService - for BanUserSvc { - type Response = super::BanUserResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::ban_user(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let inner = inner.0; - let method = BanUserSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/veza.chat.ChatService/ModerateMessage" => { - #[allow(non_camel_case_types)] - struct ModerateMessageSvc(pub Arc); - impl< - T: ChatService, - > tonic::server::UnaryService - for ModerateMessageSvc { - type Response = super::ModerateMessageResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::moderate_message(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let inner = inner.0; - let method = ModerateMessageSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/veza.chat.ChatService/GetRoomStats" => { - #[allow(non_camel_case_types)] - struct GetRoomStatsSvc(pub Arc); - impl< - T: ChatService, - > tonic::server::UnaryService - for GetRoomStatsSvc { - type Response = super::RoomStats; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::get_room_stats(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let inner = inner.0; - let method = GetRoomStatsSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/veza.chat.ChatService/GetUserActivity" => { - #[allow(non_camel_case_types)] - struct GetUserActivitySvc(pub Arc); - impl< - T: ChatService, - > tonic::server::UnaryService - for GetUserActivitySvc { - type Response = super::UserActivity; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::get_user_activity(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let inner = inner.0; - let method = GetUserActivitySvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - _ => { - Box::pin(async move { - Ok( - http::Response::builder() - .status(200) - .header("grpc-status", "12") - .header("content-type", "application/grpc") - .body(empty_body()) - .unwrap(), - ) - }) - } - } - } - } - impl Clone for ChatServiceServer { - fn clone(&self) -> Self { - let inner = self.inner.clone(); - Self { - inner, - accept_compression_encodings: self.accept_compression_encodings, - send_compression_encodings: self.send_compression_encodings, - max_decoding_message_size: self.max_decoding_message_size, - max_encoding_message_size: self.max_encoding_message_size, - } - } - } - impl Clone for _Inner { - fn clone(&self) -> Self { - Self(Arc::clone(&self.0)) - } - } - impl std::fmt::Debug for _Inner { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{:?}", self.0) - } - } - impl tonic::server::NamedService for ChatServiceServer { - const NAME: &'static str = "veza.chat.ChatService"; - } -} diff --git a/veza-chat-server/src/generated/veza.common.auth.rs b/veza-chat-server/src/generated/veza.common.auth.rs deleted file mode 100644 index 4da34e46d..000000000 --- a/veza-chat-server/src/generated/veza.common.auth.rs +++ /dev/null @@ -1,465 +0,0 @@ -// This file is @generated by prost-build. -/// Messages de requête/réponse -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct ValidateTokenRequest { - #[prost(string, tag = "1")] - pub token: ::prost::alloc::string::String, - /// service qui fait la demande (chat, stream) - #[prost(string, tag = "2")] - pub service: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct ValidateTokenResponse { - #[prost(bool, tag = "1")] - pub valid: bool, - #[prost(message, optional, tag = "2")] - pub user: ::core::option::Option, - #[prost(string, tag = "3")] - pub error: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct GetUserInfoRequest { - #[prost(int64, tag = "1")] - pub user_id: i64, - #[prost(string, tag = "2")] - pub token: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct GetUserInfoResponse { - #[prost(message, optional, tag = "1")] - pub user: ::core::option::Option, - #[prost(string, tag = "2")] - pub error: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct CheckPermissionsRequest { - #[prost(int64, tag = "1")] - pub user_id: i64, - /// "chat.room", "stream.channel" - #[prost(string, tag = "2")] - pub resource: ::prost::alloc::string::String, - /// "read", "write", "moderate" - #[prost(string, tag = "3")] - pub action: ::prost::alloc::string::String, - #[prost(string, tag = "4")] - pub resource_id: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct CheckPermissionsResponse { - #[prost(bool, tag = "1")] - pub allowed: bool, - #[prost(string, repeated, tag = "2")] - pub permissions: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, - #[prost(string, tag = "3")] - pub error: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct RevokeTokenRequest { - #[prost(string, tag = "1")] - pub token: ::prost::alloc::string::String, - #[prost(string, tag = "2")] - pub reason: ::prost::alloc::string::String, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct RevokeTokenResponse { - #[prost(bool, tag = "1")] - pub success: bool, - #[prost(string, tag = "2")] - pub error: ::prost::alloc::string::String, -} -/// Types de données -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct UserClaims { - #[prost(int64, tag = "1")] - pub user_id: i64, - #[prost(string, tag = "2")] - pub username: ::prost::alloc::string::String, - #[prost(string, tag = "3")] - pub email: ::prost::alloc::string::String, - #[prost(string, tag = "4")] - pub role: ::prost::alloc::string::String, - #[prost(bool, tag = "5")] - pub is_active: bool, - #[prost(int64, tag = "6")] - pub issued_at: i64, - #[prost(int64, tag = "7")] - pub expires_at: i64, -} -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct UserInfo { - #[prost(int64, tag = "1")] - pub id: i64, - #[prost(string, tag = "2")] - pub username: ::prost::alloc::string::String, - #[prost(string, tag = "3")] - pub email: ::prost::alloc::string::String, - #[prost(string, tag = "4")] - pub first_name: ::prost::alloc::string::String, - #[prost(string, tag = "5")] - pub last_name: ::prost::alloc::string::String, - #[prost(string, tag = "6")] - pub role: ::prost::alloc::string::String, - #[prost(bool, tag = "7")] - pub is_active: bool, - #[prost(bool, tag = "8")] - pub is_verified: bool, - #[prost(int64, tag = "9")] - pub created_at: i64, - #[prost(int64, tag = "10")] - pub last_login_at: i64, -} -/// Generated server implementations. -pub mod auth_service_server { - #![allow(unused_variables, dead_code, missing_docs, clippy::let_unit_value)] - use tonic::codegen::*; - /// Generated trait containing gRPC methods that should be implemented for use with AuthServiceServer. - #[async_trait] - pub trait AuthService: Send + Sync + 'static { - /// Valider un JWT token - async fn validate_token( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - /// Obtenir les informations utilisateur - async fn get_user_info( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - /// Vérifier les permissions - async fn check_permissions( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - /// Révoquer un token - async fn revoke_token( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - } - /// Service d'authentification partagé - #[derive(Debug)] - pub struct AuthServiceServer { - inner: _Inner, - accept_compression_encodings: EnabledCompressionEncodings, - send_compression_encodings: EnabledCompressionEncodings, - max_decoding_message_size: Option, - max_encoding_message_size: Option, - } - struct _Inner(Arc); - impl AuthServiceServer { - pub fn new(inner: T) -> Self { - Self::from_arc(Arc::new(inner)) - } - pub fn from_arc(inner: Arc) -> Self { - let inner = _Inner(inner); - Self { - inner, - accept_compression_encodings: Default::default(), - send_compression_encodings: Default::default(), - max_decoding_message_size: None, - max_encoding_message_size: None, - } - } - pub fn with_interceptor( - inner: T, - interceptor: F, - ) -> InterceptedService - where - F: tonic::service::Interceptor, - { - InterceptedService::new(Self::new(inner), interceptor) - } - /// Enable decompressing requests with the given encoding. - #[must_use] - pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { - self.accept_compression_encodings.enable(encoding); - self - } - /// Compress responses with the given encoding, if the client supports it. - #[must_use] - pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { - self.send_compression_encodings.enable(encoding); - self - } - /// Limits the maximum size of a decoded message. - /// - /// Default: `4MB` - #[must_use] - pub fn max_decoding_message_size(mut self, limit: usize) -> Self { - self.max_decoding_message_size = Some(limit); - self - } - /// Limits the maximum size of an encoded message. - /// - /// Default: `usize::MAX` - #[must_use] - pub fn max_encoding_message_size(mut self, limit: usize) -> Self { - self.max_encoding_message_size = Some(limit); - self - } - } - impl tonic::codegen::Service> for AuthServiceServer - where - T: AuthService, - B: Body + Send + 'static, - B::Error: Into + Send + 'static, - { - type Response = http::Response; - type Error = std::convert::Infallible; - type Future = BoxFuture; - fn poll_ready( - &mut self, - _cx: &mut Context<'_>, - ) -> Poll> { - Poll::Ready(Ok(())) - } - fn call(&mut self, req: http::Request) -> Self::Future { - let inner = self.inner.clone(); - match req.uri().path() { - "/veza.common.auth.AuthService/ValidateToken" => { - #[allow(non_camel_case_types)] - struct ValidateTokenSvc(pub Arc); - impl< - T: AuthService, - > tonic::server::UnaryService - for ValidateTokenSvc { - type Response = super::ValidateTokenResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::validate_token(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let inner = inner.0; - let method = ValidateTokenSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/veza.common.auth.AuthService/GetUserInfo" => { - #[allow(non_camel_case_types)] - struct GetUserInfoSvc(pub Arc); - impl< - T: AuthService, - > tonic::server::UnaryService - for GetUserInfoSvc { - type Response = super::GetUserInfoResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::get_user_info(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let inner = inner.0; - let method = GetUserInfoSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/veza.common.auth.AuthService/CheckPermissions" => { - #[allow(non_camel_case_types)] - struct CheckPermissionsSvc(pub Arc); - impl< - T: AuthService, - > tonic::server::UnaryService - for CheckPermissionsSvc { - type Response = super::CheckPermissionsResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::check_permissions(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let inner = inner.0; - let method = CheckPermissionsSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/veza.common.auth.AuthService/RevokeToken" => { - #[allow(non_camel_case_types)] - struct RevokeTokenSvc(pub Arc); - impl< - T: AuthService, - > tonic::server::UnaryService - for RevokeTokenSvc { - type Response = super::RevokeTokenResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::revoke_token(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let inner = inner.0; - let method = RevokeTokenSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - _ => { - Box::pin(async move { - Ok( - http::Response::builder() - .status(200) - .header("grpc-status", "12") - .header("content-type", "application/grpc") - .body(empty_body()) - .unwrap(), - ) - }) - } - } - } - } - impl Clone for AuthServiceServer { - fn clone(&self) -> Self { - let inner = self.inner.clone(); - Self { - inner, - accept_compression_encodings: self.accept_compression_encodings, - send_compression_encodings: self.send_compression_encodings, - max_decoding_message_size: self.max_decoding_message_size, - max_encoding_message_size: self.max_encoding_message_size, - } - } - } - impl Clone for _Inner { - fn clone(&self) -> Self { - Self(Arc::clone(&self.0)) - } - } - impl std::fmt::Debug for _Inner { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{:?}", self.0) - } - } - impl tonic::server::NamedService for AuthServiceServer { - const NAME: &'static str = "veza.common.auth.AuthService"; - } -} diff --git a/veza-chat-server/src/grpc_client.rs b/veza-chat-server/src/grpc_client.rs deleted file mode 100644 index 03a9fb861..000000000 --- a/veza-chat-server/src/grpc_client.rs +++ /dev/null @@ -1,219 +0,0 @@ -//! Client gRPC pour communiquer avec veza-backend-api - -use crate::error::{ChatError, Result}; -use serde::{Deserialize, Serialize}; -use std::time::Duration; -use tokio::time::timeout; -use tracing::{info, warn, error}; - -/// Configuration du client gRPC -#[derive(Debug, Clone)] -pub struct GrpcClientConfig { - pub backend_api_url: String, - pub timeout_seconds: u64, - pub max_retries: u32, -} - -impl Default for GrpcClientConfig { - fn default() -> Self { - Self { - backend_api_url: std::env::var("BACKEND_API_URL") - .unwrap_or_else(|_| "http://localhost:8080".to_string()), - timeout_seconds: 30, - max_retries: 3, - } - } -} - -/// Informations utilisateur depuis le backend -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UserInfo { - pub id: i64, - pub username: String, - pub email: String, - pub role: String, - pub is_active: bool, -} - -/// Informations de session utilisateur -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionInfo { - pub user_id: i64, - pub session_token: String, - pub expires_at: String, - pub is_active: bool, -} - -/// Client gRPC pour communiquer avec le backend API -pub struct BackendApiClient { - config: GrpcClientConfig, - http_client: reqwest::Client, -} - -impl BackendApiClient { - /// Crée un nouveau client - pub fn new(config: GrpcClientConfig) -> Self { - let http_client = reqwest::Client::builder() - .timeout(Duration::from_secs(config.timeout_seconds)) - .build() - .unwrap_or_default(); - - Self { - config, - http_client, - } - } - - /// Crée un client avec la configuration par défaut - pub fn with_default_config() -> Self { - Self::new(GrpcClientConfig::default()) - } - - /// Vérifie la validité d'un token JWT - pub async fn verify_jwt_token(&self, token: &str) -> Result { - info!("🔍 Vérification du token JWT avec le backend API"); - - let url = format!("{}/api/v1/auth/profile", self.config.backend_api_url); - - let response = timeout( - Duration::from_secs(self.config.timeout_seconds), - self.http_client - .get(&url) - .header("Authorization", format!("Bearer {}", token)) - .send() - ).await - .map_err(|_| ChatError::configuration_error("Timeout lors de la vérification du token"))? - .map_err(|e| ChatError::configuration_error(&format!("Erreur HTTP: {}", e)))?; - - if response.status().is_success() { - let response_body: serde_json::Value = response.json().await - .map_err(|e| ChatError::configuration_error(&format!("Erreur parsing JSON: {}", e)))?; - - // Parser la réponse du backend - if let Some(data) = response_body.get("data") { - if let Some(user_data) = data.get("user") { - let user_info = UserInfo { - id: user_data.get("id").and_then(|v| v.as_i64()).unwrap_or(0), - username: user_data.get("username").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(), - email: user_data.get("email").and_then(|v| v.as_str()).unwrap_or("").to_string(), - role: user_data.get("role").and_then(|v| v.as_str()).unwrap_or("user").to_string(), - is_active: user_data.get("is_active").and_then(|v| v.as_bool()).unwrap_or(false), - }; - - info!("✅ Token valide pour l'utilisateur: {}", user_info.username); - return Ok(user_info); - } - } - - error!("❌ Réponse invalide du backend API"); - Err(ChatError::configuration_error("Réponse invalide du backend API")) - } else if response.status() == 401 { - warn!("⚠️ Token JWT invalide ou expiré"); - Err(ChatError::configuration_error("Token JWT invalide ou expiré")) - } else { - error!("❌ Erreur backend API: {}", response.status()); - Err(ChatError::configuration_error(&format!("Erreur backend API: {}", response.status()))) - } - } - - /// Synchronise les informations utilisateur - pub async fn sync_user_info(&self, user_id: i64) -> Result { - info!("🔄 Synchronisation des informations utilisateur ID: {}", user_id); - - let url = format!("{}/api/v1/users/{}", self.config.backend_api_url, user_id); - - let response = timeout( - Duration::from_secs(self.config.timeout_seconds), - self.http_client.get(&url).send() - ).await - .map_err(|_| ChatError::configuration_error("Timeout lors de la synchronisation utilisateur"))? - .map_err(|e| ChatError::configuration_error(&format!("Erreur HTTP: {}", e)))?; - - if response.status().is_success() { - let user_info: UserInfo = response.json().await - .map_err(|e| ChatError::configuration_error(&format!("Erreur parsing user info: {}", e)))?; - - info!("✅ Informations utilisateur synchronisées: {}", user_info.username); - Ok(user_info) - } else { - error!("❌ Impossible de récupérer les informations utilisateur: {}", response.status()); - Err(ChatError::configuration_error(&format!("Erreur récupération utilisateur: {}", response.status()))) - } - } - - /// Notifie le backend d'un nouveau message de chat - pub async fn notify_new_message(&self, message_id: i32, room_id: &str, sender_id: i64) -> Result<()> { - info!("📢 Notification nouveau message au backend: message={}, room={}, sender={}", - message_id, room_id, sender_id); - - let url = format!("{}/api/v1/chat/message-notifications", self.config.backend_api_url); - - let payload = serde_json::json!({ - "message_id": message_id, - "room_id": room_id, - "sender_id": sender_id, - "timestamp": chrono::Utc::now() - }); - - let response = timeout( - Duration::from_secs(self.config.timeout_seconds), - self.http_client - .post(&url) - .json(&payload) - .send() - ).await - .map_err(|_| ChatError::configuration_error("Timeout lors de la notification"))? - .map_err(|e| ChatError::configuration_error(&format!("Erreur HTTP notification: {}", e)))?; - - if response.status().is_success() { - info!("✅ Notification envoyée avec succès"); - Ok(()) - } else { - warn!("⚠️ Échec notification (non critique): {}", response.status()); - // Les notifications sont non-critiques, ne pas faire échouer l'opération - Ok(()) - } - } - - /// Vérifie la santé du backend API - pub async fn health_check(&self) -> Result { - let url = format!("{}/health", self.config.backend_api_url); - - match timeout( - Duration::from_secs(5), // Timeout court pour les health checks - self.http_client.get(&url).send() - ).await { - Ok(Ok(response)) => Ok(response.status().is_success()), - _ => Ok(false), - } - } - - /// Met à jour les statistiques d'activité utilisateur - pub async fn update_user_activity(&self, user_id: i64, activity_type: &str) -> Result<()> { - let url = format!("{}/api/v1/users/{}/activity", self.config.backend_api_url, user_id); - - let payload = serde_json::json!({ - "activity_type": activity_type, - "timestamp": chrono::Utc::now() - }); - - let response = timeout( - Duration::from_secs(self.config.timeout_seconds), - self.http_client - .post(&url) - .json(&payload) - .send() - ).await - .map_err(|_| ChatError::configuration_error("Timeout lors de la mise à jour d'activité"))? - .map_err(|e| ChatError::configuration_error(&format!("Erreur HTTP activité: {}", e)))?; - - if response.status().is_success() { - info!("✅ Activité utilisateur mise à jour: {} - {}", user_id, activity_type); - Ok(()) - } else { - warn!("⚠️ Échec mise à jour activité (non critique): {}", response.status()); - // Les mises à jour d'activité sont non-critiques - Ok(()) - } - } -} \ No newline at end of file diff --git a/veza-chat-server/src/grpc_server.rs b/veza-chat-server/src/grpc_server.rs deleted file mode 100644 index 26815685c..000000000 --- a/veza-chat-server/src/grpc_server.rs +++ /dev/null @@ -1,253 +0,0 @@ -//! Module serveur gRPC pour le Chat Server - -use std::sync::Arc; -use tonic::{transport::Server, Request, Response, Status}; -use tracing::{info, debug}; - -// Importation des bindings protobuf générés -pub mod chat { - include!("generated/veza.chat.rs"); -} - -pub mod auth { - include!("generated/veza.common.auth.rs"); -} - -use chat::{ - chat_service_server::{ChatService, ChatServiceServer}, - *, -}; - -use crate::{ - config::ServerConfig, - simple_message_store::SimpleMessageStore, -}; - -/// Implémentation du service gRPC Chat -#[derive(Clone)] -pub struct ChatServiceImpl { - pub config: Arc, - pub message_store: Arc, -} - -impl ChatServiceImpl { - pub fn new(config: Arc, message_store: Arc) -> Self { - Self { - config, - message_store, - } - } -} - -#[tonic::async_trait] -impl ChatService for ChatServiceImpl { - /// Créer une salle de chat - async fn create_room( - &self, - request: Request, - ) -> Result, Status> { - let _req = request.into_inner(); - debug!("Creating room: {}", _req.name); - - // Validation des données - if _req.name.trim().is_empty() { - return Ok(Response::new(CreateRoomResponse { - room: None, - error: "Room name cannot be empty".to_string(), - })); - } - - // Génération d'un ID unique pour la salle - let room_id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().timestamp(); - - // Création de la salle - let room = Room { - id: room_id.clone(), - name: _req.name.clone(), - description: _req.description.clone(), - r#type: _req.r#type, - visibility: _req.visibility, - created_by: _req.created_by, - created_at: now, - member_count: 1, - online_count: 1, - is_active: true, - }; - - info!("Room created: {} (ID: {})", _req.name, room_id); - - Ok(Response::new(CreateRoomResponse { - room: Some(room), - error: String::new(), - })) - } - - /// Rejoindre une salle - async fn join_room( - &self, - request: Request, - ) -> Result, Status> { - let _req = request.into_inner(); - debug!("User {} joining room {}", _req.user_id, _req.room_id); - - // Création du membre - let member = RoomMember { - user_id: _req.user_id, - username: format!("user_{}", _req.user_id), - role: 0, // Member - joined_at: chrono::Utc::now().timestamp(), - is_online: true, - last_seen: chrono::Utc::now().timestamp(), - }; - - info!("User {} joined room {}", _req.user_id, _req.room_id); - - Ok(Response::new(JoinRoomResponse { - success: true, - member: Some(member), - error: String::new(), - })) - } - - /// Envoyer un message - async fn send_message( - &self, - request: Request, - ) -> Result, Status> { - let _req = request.into_inner(); - debug!("Sending message to room {} from user {}", _req.room_id, _req.sender_id); - - // Validation - if _req.content.trim().is_empty() { - return Ok(Response::new(SendMessageResponse { - message: None, - error: "Message content cannot be empty".to_string(), - })); - } - - let message_id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().timestamp(); - - let message = Message { - id: message_id.clone(), - room_id: _req.room_id.clone(), - sender_id: _req.sender_id, - sender_username: format!("user_{}", _req.sender_id), - content: _req.content.clone(), - r#type: _req.r#type, - created_at: now, - updated_at: now, - is_edited: false, - is_deleted: false, - reply_to: _req.reply_to.clone(), - reactions: vec![], - }; - - info!("Message sent: {} in room {}", message_id, _req.room_id); - - Ok(Response::new(SendMessageResponse { - message: Some(message), - error: String::new(), - })) - } - - // Implémentation simplifiée des autres méthodes - async fn leave_room(&self, request: Request) -> Result, Status> { - let _req = request.into_inner(); - Ok(Response::new(LeaveRoomResponse { success: true, error: String::new() })) - } - - async fn get_room_info(&self, request: Request) -> Result, Status> { - let _req = request.into_inner(); - let room = Room { - id: _req.room_id, - name: "Demo Room".to_string(), - description: "Test room".to_string(), - r#type: 0, // Public - visibility: 0, // Open - created_by: 1, - created_at: chrono::Utc::now().timestamp(), - member_count: 1, - online_count: 1, - is_active: true, - }; - Ok(Response::new(room)) - } - - async fn list_rooms(&self, _request: Request) -> Result, Status> { - Ok(Response::new(ListRoomsResponse { rooms: vec![], total: 0, error: String::new() })) - } - - async fn get_message_history(&self, _request: Request) -> Result, Status> { - Ok(Response::new(GetMessageHistoryResponse { messages: vec![], has_more: false, error: String::new() })) - } - - async fn delete_message(&self, _request: Request) -> Result, Status> { - Ok(Response::new(DeleteMessageResponse { success: true, error: String::new() })) - } - - async fn send_direct_message(&self, _request: Request) -> Result, Status> { - Ok(Response::new(SendDirectMessageResponse { message: None, error: String::new() })) - } - - async fn get_direct_messages(&self, _request: Request) -> Result, Status> { - Ok(Response::new(GetDirectMessagesResponse { messages: vec![], has_more: false, error: String::new() })) - } - - async fn mute_user(&self, _request: Request) -> Result, Status> { - Ok(Response::new(MuteUserResponse { success: true, error: String::new() })) - } - - async fn ban_user(&self, _request: Request) -> Result, Status> { - Ok(Response::new(BanUserResponse { success: true, error: String::new() })) - } - - async fn moderate_message(&self, _request: Request) -> Result, Status> { - Ok(Response::new(ModerateMessageResponse { success: true, error: String::new() })) - } - - async fn get_room_stats(&self, request: Request) -> Result, Status> { - let _req = request.into_inner(); - let stats = RoomStats { - room_id: _req.room_id, - total_members: 1, - online_members: 1, - messages_today: 0, - total_messages: 0, - active_users: vec![], - }; - Ok(Response::new(stats)) - } - - async fn get_user_activity(&self, request: Request) -> Result, Status> { - let _req = request.into_inner(); - let activity = UserActivity { - user_id: _req.user_id, - rooms_joined: 0, - messages_sent: 0, - last_activity: chrono::Utc::now().timestamp(), - is_online: true, - current_status: "active".to_string(), - }; - Ok(Response::new(activity)) - } -} - -/// Démarrer le serveur gRPC du chat -pub async fn start_grpc_server( - config: Arc, - message_store: Arc, -) -> Result<(), Box> { - let addr = format!("0.0.0.0:{}", config.server.grpc_port).parse()?; - let chat_service = ChatServiceImpl::new(config.clone(), message_store); - - info!("🚀 Chat gRPC Server starting on {}", addr); - - Server::builder() - .add_service(ChatServiceServer::new(chat_service)) - .serve(addr) - .await?; - - Ok(()) -} \ No newline at end of file diff --git a/veza-chat-server/src/hub/audit.rs b/veza-chat-server/src/hub/audit.rs deleted file mode 100644 index c0a6793e4..000000000 --- a/veza-chat-server/src/hub/audit.rs +++ /dev/null @@ -1,681 +0,0 @@ -//! Module de gestion des logs d'audit et de sécurité -//! -//! Fonctionnalités : -//! - Audit des actions utilisateur -//! - Logs de sécurité et modération -//! - Historique des modifications -//! - Rapports d'activité -//! - Surveillance des patterns suspects - -use serde::{Serialize, Deserialize}; -use crate::hub::common::ChatHub; -use crate::error::{ChatError, Result}; -use serde_json::{json, Value}; -use chrono::{DateTime, Utc, Duration}; -use std::collections::HashMap; -use sqlx::{query, query_as, FromRow, Row}; -use uuid::Uuid; -// use crate::validation::{validate_user_id, validate_limit}; - -// ================================================================ -// STRUCTURES DE DONNÉES -// ================================================================ - -#[derive(Debug, FromRow, Serialize, Deserialize)] -pub struct AuditLog { - pub uuid: Uuid, - pub action: String, - pub details: Value, - pub user_id: Option, - pub ip_address: Option, - pub user_agent: Option, - pub created_at: DateTime, -} - -#[derive(Debug, FromRow, Serialize)] -pub struct SecurityEvent { - pub uuid: Uuid, - pub event_type: String, - pub severity: String, - pub description: String, - pub user_id: Option, - pub ip_address: Option, - pub metadata: Value, - pub created_at: DateTime, -} - -#[derive(Debug, Serialize)] -pub struct ActivityReport { - pub period_start: DateTime, - pub period_end: DateTime, - pub total_actions: i64, - pub unique_users: i64, - pub actions_by_type: HashMap, - pub top_users: Vec, - pub security_events: i64, -} - -#[derive(Debug, FromRow, Serialize)] -pub struct UserActivity { - pub user_id: Uuid, - pub username: String, - pub action_count: i64, - pub last_activity: DateTime, -} - -#[derive(Debug, FromRow, Serialize)] -pub struct RoomAuditSummary { - pub room_id: Uuid, - pub room_name: String, - pub total_messages: i64, - pub deleted_messages: i64, - pub pinned_messages: i64, - pub member_changes: i64, - pub moderation_actions: i64, - pub last_activity: Option>, -} - -// ================================================================ -// ENREGISTREMENT DES LOGS D'AUDIT -// ================================================================ - -/// Enregistrer une action d'audit -pub async fn log_action( - hub: &ChatHub, - action: &str, - details: Value, - user_id: Option, - ip_address: Option<&str>, - user_agent: Option<&str> -) -> Result { - use uuid::Uuid; - - tracing::debug!(action = %action, user_id = ?user_id, "📝 Enregistrement d'action d'audit"); - - let audit_id = query(" - INSERT INTO audit_logs (action, details, user_id, ip_address, user_agent) - VALUES ($1, $2, $3, $4, $5) - RETURNING uuid - ") - .bind(action) - .bind(&details) - .bind(user_id) - .bind(ip_address) - .bind(user_agent) - .fetch_one(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("insert_audit_log", e))? - .get::("uuid"); - - tracing::info!(action = %action, audit_id = %audit_id, "✅ Action d'audit enregistrée"); - Ok(audit_id) -} - -/// Enregistrer un événement de sécurité -pub async fn log_security_event( - hub: &ChatHub, - event_type: &str, - severity: &str, - description: &str, - user_id: Option, - ip_address: Option<&str>, - metadata: Value -) -> Result { - use uuid::Uuid; - - tracing::warn!( - event_type = %event_type, - severity = %severity, - user_id = ?user_id, - "🚨 Enregistrement d'événement de sécurité" - ); - - let event_id = query(" - INSERT INTO security_events (event_type, severity, description, user_id, ip_address, metadata) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING uuid - ") - .bind(event_type) - .bind(severity) - .bind(description) - .bind(user_id) - .bind(ip_address) - .bind(&metadata) - .fetch_one(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("insert_security_event", e))? - .get::("uuid"); - - tracing::warn!(event_type = %event_type, event_id = %event_id, "🚨 Événement de sécurité enregistré"); - Ok(event_id) -} - -// ================================================================ -// LOGS SPÉCIFIQUES AUX SALONS -// ================================================================ - -/// Logger la création d'un salon -pub async fn log_room_created( - hub: &ChatHub, - room_id: Uuid, - room_name: &str, - owner_id: Uuid, - is_public: bool -) -> Result<()> { - log_action( - hub, - "room_created", - json!({ - "room_id": room_id, - "room_name": room_name, - "is_public": is_public - }), - Some(owner_id), - None, - None - ).await?; - - Ok(()) -} - -/// Logger l'ajout/suppression d'un membre -pub async fn log_member_change( - hub: &ChatHub, - room_id: Uuid, - room_name: &str, - target_user_id: Uuid, - action_user_id: Option, - action: &str, // "joined", "left", "kicked", "banned" - reason: Option<&str> -) -> Result<()> { - let mut details = json!({ - "room_id": room_id, - "room_name": room_name, - "target_user_id": target_user_id, - "action": action - }); - - if let Some(reason) = reason { - details["reason"] = json!(reason); - } - - log_action( - hub, - &format!("member_{}", action), - details, - action_user_id, - None, - None - ).await?; - - Ok(()) -} - -/// Logger la modification d'un message -pub async fn log_message_modified( - hub: &ChatHub, - message_id: Uuid, - room_id: Uuid, - author_id: Uuid, - action: &str, // "edited", "deleted", "pinned", "unpinned" - old_content: Option<&str>, - new_content: Option<&str>, - moderator_id: Option -) -> Result<()> { - let mut details = json!({ - "message_id": message_id, - "room_id": room_id, - "author_id": author_id, - "action": action - }); - - if let Some(old) = old_content { - details["old_content"] = json!(old); - } - if let Some(new) = new_content { - details["new_content"] = json!(new); - } - - log_action( - hub, - &format!("message_{}", action), - details, - moderator_id.or(Some(author_id)), - None, - None - ).await?; - - Ok(()) -} - -/// Logger les actions de modération -pub async fn log_moderation_action( - hub: &ChatHub, - room_id: Uuid, - moderator_id: Uuid, - target_user_id: Uuid, - action: &str, // "warn", "mute", "unmute", "kick", "ban", "unban" - duration: Option, - reason: &str -) -> Result<()> { - let mut details = json!({ - "room_id": room_id, - "target_user_id": target_user_id, - "action": action, - "reason": reason - }); - - if let Some(duration) = duration { - details["duration_seconds"] = json!(duration.num_seconds()); - } - - log_action( - hub, - &format!("moderation_{}", action), - details.clone(), - Some(moderator_id), - None, - None - ).await?; - - // Aussi enregistrer comme événement de sécurité si c'est une action sévère - match action { - "ban" | "kick" => { - log_security_event( - hub, - "moderation_action", - "medium", - &format!("Utilisateur {} par modérateur {}: {}", action, moderator_id, reason), - Some(target_user_id), - None, - details - ).await?; - } - _ => {} - } - - Ok(()) -} - -// ================================================================ -// CONSULTATION DES LOGS -// ================================================================ - -/// Récupérer les logs d'audit d'un salon -pub async fn get_room_audit_logs( - hub: &ChatHub, - room_id: Uuid, - requesting_user_id: Uuid, - limit: i64, - before_date: Option> -) -> Result> { - tracing::info!(room_id = %room_id, user_id = %requesting_user_id, "📚 Récupération des logs d'audit du salon"); - - let validated_limit = validate_limit(limit)?; - - // Vérifier que l'utilisateur a les permissions pour voir les logs - check_audit_permissions(hub, room_id, requesting_user_id).await?; - - let mut query_str = " - SELECT uuid, action, details, user_id, ip_address, user_agent, created_at - FROM audit_logs - WHERE (details->>'room_id')::uuid = $1 - ".to_string(); - - let mut param_count = 1; - - if let Some(_before) = before_date { - param_count += 1; - query_str.push_str(&format!(" AND created_at < ${}", param_count)); - } - - query_str.push_str(" ORDER BY created_at DESC"); - - param_count += 1; - query_str.push_str(&format!(" LIMIT ${}", param_count)); - - let mut query_obj = query_as::<_, AuditLog>(&query_str).bind(room_id); - - if let Some(before) = before_date { - query_obj = query_obj.bind(before); - } - - let logs = query_obj - .bind(validated_limit) - .fetch_all(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("get_room_audit_logs", e))?; - - tracing::info!(room_id = %room_id, log_count = %logs.len(), "✅ Logs d'audit du salon récupérés"); - Ok(logs) -} - -/// Récupérer les événements de sécurité d'un salon -pub async fn get_room_security_events( - hub: &ChatHub, - room_id: Uuid, - requesting_user_id: Uuid, - severity_filter: Option<&str>, - limit: i64 -) -> Result> { - tracing::info!(room_id = %room_id, user_id = %requesting_user_id, "🚨 Récupération des événements de sécurité du salon"); - - let validated_limit = validate_limit(limit)?; - - // Vérifier les permissions - check_audit_permissions(hub, room_id, requesting_user_id).await?; - - let mut query_str = " - SELECT uuid, event_type, severity, description, user_id, ip_address, metadata, created_at - FROM security_events - WHERE (metadata->>'room_id')::uuid = $1 - ".to_string(); - - let mut param_count = 1; - - if let Some(_severity) = severity_filter { - param_count += 1; - query_str.push_str(&format!(" AND severity = ${}", param_count)); - } - - query_str.push_str(" ORDER BY created_at DESC"); - - param_count += 1; - query_str.push_str(&format!(" LIMIT ${}", param_count)); - - let mut query_obj = query_as::<_, SecurityEvent>(&query_str).bind(room_id); - - if let Some(severity) = severity_filter { - query_obj = query_obj.bind(severity); - } - - let events = query_obj - .bind(validated_limit) - .fetch_all(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("get_room_security_events", e))?; - - tracing::info!(room_id = %room_id, event_count = %events.len(), "✅ Événements de sécurité du salon récupérés"); - Ok(events) -} - -/// Générer un rapport d'activité pour un salon -pub async fn generate_room_activity_report( - hub: &ChatHub, - room_id: Uuid, - requesting_user_id: Uuid, - period_days: i32 -) -> Result { - tracing::info!(room_id = %room_id, user_id = %requesting_user_id, period_days = %period_days, "📊 Génération du rapport d'activité"); - - // Vérifier les permissions - check_audit_permissions(hub, room_id, requesting_user_id).await?; - - let period_start = Utc::now() - Duration::days(period_days as i64); - let period_end = Utc::now(); - - // Statistiques générales - let total_actions: i64 = query(" - SELECT COUNT(*) FROM audit_logs - WHERE (details->>'room_id')::uuid = $1 - AND created_at BETWEEN $2 AND $3 - ") - .bind(room_id) - .bind(period_start) - .bind(period_end) - .fetch_one(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("count_total_actions", e))? - .get(0); - - let unique_users: i64 = query(" - SELECT COUNT(DISTINCT user_id) FROM audit_logs - WHERE (details->>'room_id')::uuid = $1 - AND created_at BETWEEN $2 AND $3 - AND user_id IS NOT NULL - ") - .bind(room_id) - .bind(period_start) - .bind(period_end) - .fetch_one(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("count_unique_users", e))? - .get(0); - - // Actions par type - let actions_by_type_raw = query_as::<_, (String, i64)>(" - SELECT action, COUNT(*) as count - FROM audit_logs - WHERE (details->>'room_id')::uuid = $1 - AND created_at BETWEEN $2 AND $3 - GROUP BY action - ORDER BY count DESC - ") - .bind(room_id) - .bind(period_start) - .bind(period_end) - .fetch_all(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("get_actions_by_type", e))?; - - let actions_by_type: HashMap = actions_by_type_raw.into_iter().collect(); - - // Utilisateurs les plus actifs - let top_users = query_as::<_, UserActivity>(" - SELECT - al.user_id, - u.username, - COUNT(*) as action_count, - MAX(al.created_at) as last_activity - FROM audit_logs al - JOIN users u ON u.id = al.user_id - WHERE (al.details->>'room_id')::uuid = $1 - AND al.created_at BETWEEN $2 AND $3 - AND al.user_id IS NOT NULL - GROUP BY al.user_id, u.username - ORDER BY action_count DESC - LIMIT 10 - ") - .bind(room_id) - .bind(period_start) - .bind(period_end) - .fetch_all(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("get_top_users", e))?; - - // Événements de sécurité - let security_events: i64 = query(" - SELECT COUNT(*) FROM security_events - WHERE (metadata->>'room_id')::uuid = $1 - AND created_at BETWEEN $2 AND $3 - ") - .bind(room_id) - .bind(period_start) - .bind(period_end) - .fetch_one(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("count_security_events", e))? - .get(0); - - let report = ActivityReport { - period_start, - period_end, - total_actions, - unique_users, - actions_by_type, - top_users, - security_events, - }; - - tracing::info!(room_id = %room_id, total_actions = %total_actions, "✅ Rapport d'activité généré"); - Ok(report) -} - -/// Obtenir un résumé d'audit pour un salon -pub async fn get_room_audit_summary( - hub: &ChatHub, - room_id: Uuid, - requesting_user_id: Uuid -) -> Result { - tracing::info!(room_id = %room_id, user_id = %requesting_user_id, "📋 Récupération du résumé d'audit du salon"); - - check_audit_permissions(hub, room_id, requesting_user_id).await?; - - let summary = query_as::<_, RoomAuditSummary>(" - SELECT - c.id as room_id, - c.name as room_name, - COUNT(DISTINCT m.id) as total_messages, - COUNT(DISTINCT m.id) FILTER (WHERE m.status = 'deleted') as deleted_messages, - COUNT(DISTINCT m.id) FILTER (WHERE m.is_pinned = TRUE) as pinned_messages, - COUNT(DISTINCT al.id) FILTER (WHERE al.action LIKE 'member_%') as member_changes, - COUNT(DISTINCT al.id) FILTER (WHERE al.action LIKE 'moderation_%') as moderation_actions, - MAX(m.created_at) as last_activity - FROM conversations c - LEFT JOIN messages m ON m.conversation_id = c.id - LEFT JOIN audit_logs al ON (al.details->>'room_id')::uuid = c.id - WHERE c.id = $1 - GROUP BY c.id, c.name - ") - .bind(room_id) - .fetch_one(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("get_room_audit_summary", e))?; - - tracing::info!(room_id = %room_id, "✅ Résumé d'audit du salon récupéré"); - Ok(summary) -} - -// ================================================================ -// DÉTECTION D'ANOMALIES -// ================================================================ - -/// Détecter des patterns suspects d'activité -pub async fn detect_suspicious_patterns( - hub: &ChatHub, - room_id: Uuid, - hours_lookback: i32 -) -> Result> { - tracing::info!(room_id = %room_id, hours = %hours_lookback, "🔍 Détection de patterns suspects"); - - let lookback_time = Utc::now() - Duration::hours(hours_lookback as i64); - - // Détecter les utilisateurs avec trop d'actions en peu de temps - let suspicious_users = query(" - SELECT - user_id, - COUNT(*) as action_count, - COUNT(DISTINCT action) as unique_actions - FROM audit_logs - WHERE (details->>'room_id')::uuid = $1 - AND created_at > $2 - AND user_id IS NOT NULL - GROUP BY user_id - HAVING COUNT(*) > 50 OR COUNT(DISTINCT action) > 10 - ") - .bind(room_id) - .bind(lookback_time) - .fetch_all(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("detect_suspicious_users", e))?; - - let mut events = Vec::new(); - - for row in suspicious_users { - let user_id: Uuid = row.get("user_id"); - let action_count: i64 = row.get("action_count"); - let unique_actions: i64 = row.get("unique_actions"); - - let event_id = log_security_event( - hub, - "suspicious_activity", - "medium", - &format!("Activité suspecte détectée: {} actions, {} types différents en {} heures", - action_count, unique_actions, hours_lookback), - Some(user_id), - None, - json!({ - "room_id": room_id, - "action_count": action_count, - "unique_actions": unique_actions, - "detection_window_hours": hours_lookback - }) - ).await?; - - // Récupérer l'événement créé pour le retourner - if let Ok(event) = query_as::<_, SecurityEvent>(" - SELECT id, event_type, severity, description, user_id, ip_address, metadata, created_at - FROM security_events WHERE id = $1 - ") - .bind(event_id) - .fetch_one(&hub.db) - .await { - events.push(event); - } - } - - tracing::info!(room_id = %room_id, suspicious_events = %events.len(), "🔍 Détection terminée"); - Ok(events) -} - -// ================================================================ -// FONCTIONS UTILITAIRES -// ================================================================ - -/// Vérifier si un utilisateur a les permissions pour consulter les logs d'audit -async fn check_audit_permissions(hub: &ChatHub, room_id: Uuid, user_id: Uuid) -> Result<()> { - let user_role: Option = query(" - SELECT role FROM conversation_members - WHERE conversation_id = $1 AND user_id = $2 AND left_at IS NULL - ") - .bind(room_id) - .bind(user_id) - .fetch_optional(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("check_audit_permissions", e))? - .map(|row| row.get("role")); - - match user_role.as_deref() { - Some("owner") | Some("moderator") => Ok(()), - _ => { - // Vérifier si c'est un admin global - let is_admin: bool = query(" - SELECT role = 'admin' OR role = 'super_admin' - FROM users WHERE id = $1 - ") - .bind(user_id) - .fetch_one(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("check_global_admin", e))? - .get(0); - - if is_admin { - Ok(()) - } else { - Err(ChatError::unauthorized("access_audit_logs")) - } - } - } -} - -// Fonction temporaire pour validation -fn validate_limit(limit: i64) -> Result { - if limit > 100 { - return Err(ChatError::InvalidFormat { - field: "limit".to_string(), - reason: "Limit too high".to_string(), - }); - } - Ok(limit) -} - -// Fonction temporaire pour validation -fn validate_user_id(user_id: i32) -> Result<()> { - if user_id <= 0 { - return Err(ChatError::InvalidFormat { - field: "user_id".to_string(), - reason: "Invalid user ID".to_string(), - }); - } - Ok(()) -} \ No newline at end of file diff --git a/veza-chat-server/src/hub/channel_websocket.rs b/veza-chat-server/src/hub/channel_websocket.rs deleted file mode 100644 index 38c97692e..000000000 --- a/veza-chat-server/src/hub/channel_websocket.rs +++ /dev/null @@ -1,627 +0,0 @@ -//! Gestionnaire WebSocket enrichi pour les salons -//! -//! Nouvelles fonctionnalités : -//! - Gestion complète des salons avec rôles -//! - Réactions en temps réel -//! - Messages épinglés -//! - Threads et réponses -//! - Notifications d'audit -//! - Événements de modération - -use uuid::Uuid; -use crate::hub::{ChatHub, reactions, audit, channels}; -use crate::error::{ChatError, Result}; -use serde_json::{json, Value}; -use tracing::{info, warn}; - -// ================================================================ -// TYPES DE MESSAGES WEBSOCKET -// ================================================================ - -pub enum RoomWebSocketMessage { - // Messages de base - JoinRoom { room_id: Uuid, user_id: Uuid }, - LeaveRoom { room_id: Uuid, user_id: Uuid }, - SendMessage { room_id: Uuid, user_id: Uuid, username: String, content: String, parent_id: Option }, - - // Historique et recherche - GetHistory { room_id: Uuid, user_id: Uuid, limit: i64, before_id: Option }, - GetPinnedMessages { room_id: Uuid, user_id: Uuid }, - - // Réactions - AddReaction { message_id: Uuid, user_id: Uuid, emoji: String }, - RemoveReaction { message_id: Uuid, user_id: Uuid, emoji: String }, - GetReactions { message_id: Uuid, user_id: Uuid }, - - // Modération - PinMessage { room_id: Uuid, message_id: Uuid, user_id: Uuid }, - UnpinMessage { room_id: Uuid, message_id: Uuid, user_id: Uuid }, - - // Administration - GetRoomStats { room_id: Uuid, user_id: Uuid }, - GetMembers { room_id: Uuid, user_id: Uuid }, - GetAuditLogs { room_id: Uuid, user_id: Uuid, limit: i64 }, -} - -// ================================================================ -// GESTIONNAIRE PRINCIPAL -// ================================================================ - -pub async fn handle_room_websocket_message( - hub: &ChatHub, - message: RoomWebSocketMessage -) -> Result> { - match message { - // Messages de base - RoomWebSocketMessage::JoinRoom { room_id, user_id } => { - handle_join_room(hub, room_id, user_id).await - } - - RoomWebSocketMessage::LeaveRoom { room_id, user_id } => { - handle_leave_room(hub, room_id, user_id).await - } - - RoomWebSocketMessage::SendMessage { room_id, user_id, username, content, parent_id } => { - handle_send_message(hub, room_id, user_id, &username, &content, parent_id).await - } - - // Historique - RoomWebSocketMessage::GetHistory { room_id, user_id, limit, before_id } => { - handle_get_history(hub, room_id, user_id, limit, before_id).await - } - - RoomWebSocketMessage::GetPinnedMessages { room_id, user_id } => { - handle_get_pinned_messages(hub, room_id, user_id).await - } - - // Réactions - RoomWebSocketMessage::AddReaction { message_id, user_id, emoji } => { - handle_add_reaction(hub, message_id, user_id, &emoji).await - } - - RoomWebSocketMessage::RemoveReaction { message_id, user_id, emoji } => { - handle_remove_reaction(hub, message_id, user_id, &emoji).await - } - - RoomWebSocketMessage::GetReactions { message_id, user_id } => { - handle_get_reactions(hub, message_id, user_id).await - } - - // Modération - RoomWebSocketMessage::PinMessage { room_id, message_id, user_id } => { - handle_pin_message(hub, room_id, message_id, user_id, true).await - } - - RoomWebSocketMessage::UnpinMessage { room_id, message_id, user_id } => { - handle_pin_message(hub, room_id, message_id, user_id, false).await - } - - // Administration - RoomWebSocketMessage::GetRoomStats { room_id, user_id } => { - handle_get_room_stats(hub, room_id, user_id).await - } - - RoomWebSocketMessage::GetMembers { room_id, user_id } => { - handle_get_members(hub, room_id, user_id).await - } - - RoomWebSocketMessage::GetAuditLogs { room_id, user_id, limit } => { - handle_get_audit_logs(hub, room_id, user_id, limit).await - } - } -} - -// ================================================================ -// GESTIONNAIRES SPÉCIFIQUES -// ================================================================ - -async fn handle_join_room(hub: &ChatHub, room_id: Uuid, user_id: Uuid) -> Result> { - info!(room_id = %room_id, user_id = %user_id, "🚪 Tentative de rejoindre le salon"); - - match channels::join_room(hub, room_id, user_id).await { - Ok(()) => { - // Logger l'événement - audit::log_member_change(hub, room_id, "Salon", user_id, None, "joined", None).await?; - - Ok(Some(json!({ - "type": "room_joined", - "data": { - "roomId": room_id, - "userId": user_id, - "success": true - } - }).to_string())) - } - Err(e) => { - warn!(room_id = %room_id, user_id = %user_id, error = %e, "❌ Échec de rejoindre le salon"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "join_room", - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_leave_room(hub: &ChatHub, room_id: Uuid, user_id: Uuid) -> Result> { - info!(room_id = %room_id, user_id = %user_id, "🚪 Tentative de quitter le salon"); - - match channels::leave_room(hub, room_id, user_id).await { - Ok(()) => { - // Logger l'événement - audit::log_member_change(hub, room_id, "Salon", user_id, None, "left", None).await?; - - Ok(Some(json!({ - "type": "room_left", - "data": { - "roomId": room_id, - "userId": user_id, - "success": true - } - }).to_string())) - } - Err(e) => { - warn!(room_id = %room_id, user_id = %user_id, error = %e, "❌ Échec de quitter le salon"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "leave_room", - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_send_message( - hub: &ChatHub, - room_id: Uuid, - user_id: Uuid, - username: &str, - content: &str, - parent_id: Option -) -> Result> { - info!(room_id = %room_id, user_id = %user_id, content_length = %content.len(), "📝 Envoi de message dans le salon"); - - match channels::send_room_message(hub, room_id, user_id, username, content, parent_id, None).await { - Ok(message_id) => { - info!(room_id = %room_id, message_id = %message_id, "✅ Message envoyé dans le salon"); - Ok(Some(json!({ - "type": "message_sent", - "data": { - "messageId": message_id, - "roomId": room_id, - "success": true - } - }).to_string())) - } - Err(e) => { - warn!(room_id = %room_id, user_id = %user_id, error = %e, "❌ Échec d'envoi de message"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "send_message", - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_get_history( - hub: &ChatHub, - room_id: Uuid, - user_id: Uuid, - limit: i64, - before_id: Option -) -> Result> { - info!(room_id = %room_id, user_id = %user_id, limit = %limit, "📚 Récupération de l'historique du salon"); - - match channels::fetch_room_history(hub, room_id, user_id, limit, before_id).await { - Ok(messages) => { - info!(room_id = %room_id, message_count = %messages.len(), "✅ Historique récupéré"); - Ok(Some(json!({ - "type": "room_history", - "data": { - "roomId": room_id, - "messages": messages, - "hasMore": messages.len() as i64 == limit - } - }).to_string())) - } - Err(e) => { - warn!(room_id = %room_id, user_id = %user_id, error = %e, "❌ Échec de récupération de l'historique"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "get_history", - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_get_pinned_messages(hub: &ChatHub, room_id: Uuid, user_id: Uuid) -> Result> { - info!(room_id = %room_id, user_id = %user_id, "📌 Récupération des messages épinglés"); - - match channels::fetch_pinned_messages(hub, room_id, user_id).await { - Ok(messages) => { - info!(room_id = %room_id, pinned_count = %messages.len(), "✅ Messages épinglés récupérés"); - Ok(Some(json!({ - "type": "pinned_messages", - "data": { - "roomId": room_id, - "messages": messages - } - }).to_string())) - } - Err(e) => { - warn!(room_id = %room_id, user_id = %user_id, error = %e, "❌ Échec de récupération des messages épinglés"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "get_pinned_messages", - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_add_reaction(hub: &ChatHub, message_id: Uuid, user_id: Uuid, emoji: &str) -> Result> { - info!(message_id = %message_id, user_id = %user_id, emoji = %emoji, "😊 Ajout de réaction"); - - match reactions::add_reaction(hub, message_id, user_id, emoji).await { - Ok(()) => { - info!(message_id = %message_id, emoji = %emoji, "✅ Réaction ajoutée"); - Ok(Some(json!({ - "type": "reaction_added", - "data": { - "messageId": message_id, - "userId": user_id, - "emoji": emoji, - "success": true - } - }).to_string())) - } - Err(e) => { - warn!(message_id = %message_id, user_id = %user_id, error = %e, "❌ Échec d'ajout de réaction"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "add_reaction", - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_remove_reaction(hub: &ChatHub, message_id: Uuid, user_id: Uuid, emoji: &str) -> Result> { - info!(message_id = %message_id, user_id = %user_id, emoji = %emoji, "🗑️ Suppression de réaction"); - - match reactions::remove_reaction(hub, message_id, user_id, emoji).await { - Ok(()) => { - info!(message_id = %message_id, emoji = %emoji, "✅ Réaction supprimée"); - Ok(Some(json!({ - "type": "reaction_removed", - "data": { - "messageId": message_id, - "userId": user_id, - "emoji": emoji, - "success": true - } - }).to_string())) - } - Err(e) => { - warn!(message_id = %message_id, user_id = %user_id, error = %e, "❌ Échec de suppression de réaction"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "remove_reaction", - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_get_reactions(hub: &ChatHub, message_id: Uuid, user_id: Uuid) -> Result> { - info!(message_id = %message_id, user_id = %user_id, "📊 Récupération des réactions"); - - match reactions::get_message_reactions(hub, message_id, user_id).await { - Ok(message_reactions) => { - info!(message_id = %message_id, total_reactions = %message_reactions.total_reactions, "✅ Réactions récupérées"); - Ok(Some(json!({ - "type": "message_reactions", - "data": message_reactions - }).to_string())) - } - Err(e) => { - warn!(message_id = %message_id, user_id = %user_id, error = %e, "❌ Échec de récupération des réactions"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "get_reactions", - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_pin_message(hub: &ChatHub, room_id: Uuid, message_id: Uuid, user_id: Uuid, pin: bool) -> Result> { - let action_text = if pin { "épinglage" } else { "désépinglage" }; - info!(room_id = %room_id, message_id = %message_id, user_id = %user_id, pin = %pin, "📌 {} de message", action_text); - - match channels::pin_message(hub, room_id, message_id, user_id, pin).await { - Ok(()) => { - info!(message_id = %message_id, pin = %pin, "✅ Statut d'épinglage mis à jour"); - Ok(Some(json!({ - "type": if pin { "message_pinned" } else { "message_unpinned" }, - "data": { - "messageId": message_id, - "roomId": room_id, - "isPinned": pin, - "success": true - } - }).to_string())) - } - Err(e) => { - warn!(message_id = %message_id, user_id = %user_id, error = %e, "❌ Échec de {} de message", action_text); - Ok(Some(json!({ - "type": "error", - "data": { - "action": if pin { "pin_message" } else { "unpin_message" }, - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_get_room_stats(hub: &ChatHub, room_id: Uuid, user_id: Uuid) -> Result> { - info!(room_id = %room_id, user_id = %user_id, "📊 Récupération des statistiques du salon"); - - match channels::get_room_stats(hub, room_id).await { - Ok(stats) => { - info!(room_id = %room_id, "✅ Statistiques récupérées"); - Ok(Some(json!({ - "type": "room_stats", - "data": stats - }).to_string())) - } - Err(e) => { - warn!(room_id = %room_id, user_id = %user_id, error = %e, "❌ Échec de récupération des statistiques"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "get_room_stats", - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_get_members(hub: &ChatHub, room_id: Uuid, user_id: Uuid) -> Result> { - info!(room_id = %room_id, user_id = %user_id, "👥 Récupération de la liste des membres"); - - match channels::list_room_members(hub, room_id, user_id).await { - Ok(members) => { - info!(room_id = %room_id, member_count = %members.len(), "✅ Liste des membres récupérée"); - Ok(Some(json!({ - "type": "room_members", - "data": { - "roomId": room_id, - "members": members - } - }).to_string())) - } - Err(e) => { - warn!(room_id = %room_id, user_id = %user_id, error = %e, "❌ Échec de récupération des membres"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "get_members", - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_get_audit_logs(hub: &ChatHub, room_id: Uuid, user_id: Uuid, limit: i64) -> Result> { - info!(room_id = %room_id, user_id = %user_id, limit = %limit, "📋 Récupération des logs d'audit"); - - match audit::get_room_audit_logs(hub, room_id, user_id, limit, None).await { - Ok(logs) => { - info!(room_id = %room_id, log_count = %logs.len(), "✅ Logs d'audit récupérés"); - Ok(Some(json!({ - "type": "audit_logs", - "data": { - "roomId": room_id, - "logs": logs - } - }).to_string())) - } - Err(e) => { - warn!(room_id = %room_id, user_id = %user_id, error = %e, "❌ Échec de récupération des logs d'audit"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "get_audit_logs", - "error": e.to_string() - } - }).to_string())) - } - } -} - -// ================================================================ -// UTILITAIRES DE PARSING -// ================================================================ - -/// Parser un message JSON WebSocket en RoomWebSocketMessage -pub fn parse_websocket_message(message: &str) -> Result { - let value: Value = serde_json::from_str(message) - .map_err(|e| ChatError::configuration_error(&format!("JSON invalide: {}", e)))?; - - let msg_type = value.get("type") - .and_then(|v| v.as_str()) - .ok_or_else(|| ChatError::configuration_error("Type de message manquant"))?; - - let data = value.get("data") - .ok_or_else(|| ChatError::configuration_error("Données du message manquantes"))?; - - // Helper pour parser un UUID depuis une string JSON - fn parse_uuid_from_json(v: &Value) -> Result { - match v { - Value::String(s) => Uuid::parse_str(s) - .map_err(|e| ChatError::validation_error(&format!("UUID invalide: {}", e))), - _ => Err(ChatError::validation_error("UUID doit être une string")), - } - } - - match msg_type { - "join_room" => Ok(RoomWebSocketMessage::JoinRoom { - room_id: data.get("roomId") - .ok_or_else(|| ChatError::validation_error("roomId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - }), - - "leave_room" => Ok(RoomWebSocketMessage::LeaveRoom { - room_id: data.get("roomId") - .ok_or_else(|| ChatError::validation_error("roomId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - }), - - "send_message" => Ok(RoomWebSocketMessage::SendMessage { - room_id: data.get("roomId") - .ok_or_else(|| ChatError::validation_error("roomId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - username: data.get("username").and_then(|v| v.as_str()).unwrap_or("").to_string(), - content: data.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string(), - parent_id: data.get("parentId") - .map(|v| parse_uuid_from_json(v)) - .transpose()?, - }), - - "get_history" => Ok(RoomWebSocketMessage::GetHistory { - room_id: data.get("roomId") - .ok_or_else(|| ChatError::validation_error("roomId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - limit: data.get("limit").and_then(|v| v.as_i64()).unwrap_or(50), - before_id: data.get("beforeId") - .map(|v| parse_uuid_from_json(v)) - .transpose()?, - }), - - "get_pinned_messages" => Ok(RoomWebSocketMessage::GetPinnedMessages { - room_id: data.get("roomId") - .ok_or_else(|| ChatError::validation_error("roomId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - }), - - "add_reaction" => Ok(RoomWebSocketMessage::AddReaction { - message_id: data.get("messageId") - .ok_or_else(|| ChatError::validation_error("messageId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - emoji: data.get("emoji").and_then(|v| v.as_str()).unwrap_or("").to_string(), - }), - - "remove_reaction" => Ok(RoomWebSocketMessage::RemoveReaction { - message_id: data.get("messageId") - .ok_or_else(|| ChatError::validation_error("messageId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - emoji: data.get("emoji").and_then(|v| v.as_str()).unwrap_or("").to_string(), - }), - - "get_reactions" => Ok(RoomWebSocketMessage::GetReactions { - message_id: data.get("messageId") - .ok_or_else(|| ChatError::validation_error("messageId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - }), - - "pin_message" => Ok(RoomWebSocketMessage::PinMessage { - room_id: data.get("roomId") - .ok_or_else(|| ChatError::validation_error("roomId manquant")) - .and_then(parse_uuid_from_json)?, - message_id: data.get("messageId") - .ok_or_else(|| ChatError::validation_error("messageId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - }), - - "unpin_message" => Ok(RoomWebSocketMessage::UnpinMessage { - room_id: data.get("roomId") - .ok_or_else(|| ChatError::validation_error("roomId manquant")) - .and_then(parse_uuid_from_json)?, - message_id: data.get("messageId") - .ok_or_else(|| ChatError::validation_error("messageId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - }), - - "get_room_stats" => Ok(RoomWebSocketMessage::GetRoomStats { - room_id: data.get("roomId") - .ok_or_else(|| ChatError::validation_error("roomId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - }), - - "get_members" => Ok(RoomWebSocketMessage::GetMembers { - room_id: data.get("roomId") - .ok_or_else(|| ChatError::validation_error("roomId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - }), - - "get_audit_logs" => Ok(RoomWebSocketMessage::GetAuditLogs { - room_id: data.get("roomId") - .ok_or_else(|| ChatError::validation_error("roomId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - limit: data.get("limit").and_then(|v| v.as_i64()).unwrap_or(50), - }), - - _ => Err(ChatError::configuration_error(&format!("Type de message non supporté: {}", msg_type))) - } -} \ No newline at end of file diff --git a/veza-chat-server/src/hub/channels.rs b/veza-chat-server/src/hub/channels.rs deleted file mode 100644 index bd4ba204c..000000000 --- a/veza-chat-server/src/hub/channels.rs +++ /dev/null @@ -1,780 +0,0 @@ -//! Module enrichi pour la gestion des salons de chat -//! -//! Fonctionnalités complètes : -//! - Gestion des membres avec rôles -//! - Historique complet des messages -//! - Système de mentions -//! - Réactions aux messages -//! - Messages épinglés -//! - Threads de discussion -//! - Audit et logs de sécurité -//! - Gestion des permissions -//! - Modération intégrée - -use sqlx::{query, query_as, FromRow, Row, Transaction, Postgres}; -use serde::{Serialize, Deserialize}; -use once_cell::sync::Lazy; -use crate::hub::common::ChatHub; - -static MENTION_REGEX: Lazy = Lazy::new(|| { - regex::Regex::new(r"@(\w+)").expect("mention regex is valid") -}); -// use crate::validation::{validate_room_name, validate_message_content, validate_limit, validate_user_id}; -use crate::error::{ChatError, Result}; -use serde_json::{json, Value}; -use chrono::{DateTime, Utc}; -use uuid::Uuid; - - -// ================================================================ -// STRUCTURES DE DONNÉES -// ================================================================ - -#[derive(Debug, FromRow, Serialize, Deserialize)] -pub struct Room { - pub id: Uuid, - pub name: String, - pub description: Option, - pub owner_id: Uuid, - pub is_public: bool, - pub is_archived: bool, - pub max_members: Option, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Debug, FromRow, Serialize, Deserialize)] -pub struct RoomMember { - pub id: Uuid, - pub conversation_id: Uuid, - pub user_id: Uuid, - pub role: String, - pub joined_at: DateTime, - pub left_at: Option>, - pub is_muted: bool, -} - -#[derive(Debug, FromRow, Serialize)] -pub struct RoomMessage { - pub id: Uuid, - pub author_id: Uuid, - pub author_username: String, - pub conversation_id: Uuid, - pub content: String, - pub parent_message_id: Option, - pub thread_count: i32, - pub status: String, - pub is_edited: bool, - pub edit_count: i32, - pub is_pinned: bool, - pub metadata: Value, - pub created_at: DateTime, - pub updated_at: DateTime, - pub edited_at: Option>, - - // Informations des réactions - pub reactions: Option, - pub mention_count: i32, -} - -#[derive(Debug, FromRow, Serialize)] -pub struct RoomStats { - pub room_id: Uuid, - pub room_name: String, - pub total_messages: i64, - pub total_members: i64, - pub active_members: i64, - pub last_activity: Option>, - pub pinned_messages: i64, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct RoomPermissions { - pub can_send_messages: bool, - pub can_pin_messages: bool, - pub can_delete_messages: bool, - pub can_manage_members: bool, - pub can_edit_room: bool, -} - -// Note: EnhancedRoomMessage supprimé - maintenant on utilise directement RoomMessage avec Uuid - -// ================================================================ -// GESTION DES SALONS -// ================================================================ - -/// Crée un nouveau salon de chat -pub async fn create_room( - hub: &ChatHub, - owner_id: Uuid, - name: &str, - description: Option<&str>, - is_public: bool, - max_members: Option -) -> Result { - tracing::info!(owner_id = %owner_id, name = %name, is_public = %is_public, "🏗️ Création d'un nouveau salon"); - - // validate_room_name(name)?; - - let room_uuid = Uuid::new_v4(); - - let mut tx = hub.db.begin().await - .map_err(|e| ChatError::from_sqlx_error("begin_transaction", e))?; - - // Créer la conversation - let conversation = query_as::<_, Room>(" - INSERT INTO conversations (id, conversation_type, name, description, created_by, is_private) - VALUES ($1, 'public_room', $2, $3, $4, $5) - RETURNING id, name, description, created_by as owner_id, NOT is_private as is_public, false as is_archived, NULL::INTEGER as max_members, created_at, updated_at - ") - .bind(room_uuid) - .bind(name) - .bind(description) - .bind(owner_id) - .bind(!is_public) - .fetch_one(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("create_conversation", e))?; - - // Ajouter le propriétaire comme premier membre - query(" - INSERT INTO conversation_members (conversation_id, user_id, role) - VALUES ($1, $2, 'owner') - ") - .bind(conversation.id) - .bind(owner_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("add_owner_member", e))?; - - // Log d'audit - query(" - INSERT INTO audit_logs (action, details, user_id) - VALUES ('room_created', $1, $2) - ") - .bind(json!({ - "room_id": conversation.id, - "room_name": name, - "is_public": is_public, - "max_members": max_members - })) - .bind(owner_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("audit_log", e))?; - - tx.commit().await - .map_err(|e| ChatError::from_sqlx_error("commit_transaction", e))?; - - tracing::info!(room_id = %conversation.id, name = %name, "✅ Salon créé avec succès"); - Ok(conversation) -} - -/// Récupère l'ID d'un salon par son nom -pub async fn get_room_id_by_name(hub: &ChatHub, room_name: &str) -> Result { - let room_id: Option = sqlx::query_scalar( - "SELECT id FROM conversations WHERE conversation_type = 'public_room' AND name = $1", - ) - .bind(room_name) - .fetch_optional(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("get_room_by_name", e))?; - - room_id.ok_or_else(|| ChatError::not_found("salon", room_name)) -} - -/// Rejoindre un salon -pub async fn join_room(hub: &ChatHub, room_id: Uuid, user_id: Uuid) -> Result<()> { - tracing::info!(user_id = %user_id, room_id = %room_id, "👥 Tentative de rejoindre le salon"); - - let mut tx = hub.db.begin().await - .map_err(|e| ChatError::from_sqlx_error("begin_transaction", e))?; - - // Vérifier que le salon existe et n'est pas archivé - let room: Room = query_as(" - SELECT id, name, description, created_by as owner_id, NOT is_private as is_public, false as is_archived, NULL::INTEGER as max_members, created_at, updated_at - FROM conversations - WHERE id = $1 AND conversation_type = 'public_room' - ") - .bind(room_id) - .fetch_one(&mut *tx) - .await - .map_err(|_| ChatError::not_found("salon", &room_id.to_string()))?; - - // Vérifier si l'utilisateur est déjà membre - let is_member: bool = query(" - SELECT EXISTS( - SELECT 1 FROM conversation_members - WHERE conversation_id = $1 AND user_id = $2 - ) - ") - .bind(room_id) - .bind(user_id) - .fetch_one(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("check_membership", e))? - .get(0); - - if is_member { - return Err(ChatError::configuration_error("Utilisateur déjà membre du salon")); - } - - // Vérifier la limite de membres - if let Some(max_members) = room.max_members { - let current_count: i64 = query(" - SELECT COUNT(*) FROM conversation_members - WHERE conversation_id = $1 - ") - .bind(room_id) - .fetch_one(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("count_members", e))? - .get(0); - - if current_count >= max_members as i64 { - return Err(ChatError::configuration_error("Salon plein")); - } - } - - // Ajouter le membre - query(" - INSERT INTO conversation_members (conversation_id, user_id, role) - VALUES ($1, $2, 'member') - ON CONFLICT (conversation_id, user_id) - DO UPDATE SET joined_at = NOW() - ") - .bind(room_id) - .bind(user_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("add_member", e))?; - - // Log d'audit - query(" - INSERT INTO audit_logs (action, details, user_id) - VALUES ('room_joined', $1, $2) - ") - .bind(json!({ - "room_id": room_id, - "room_name": room.name - })) - .bind(user_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("audit_log", e))?; - - tx.commit().await - .map_err(|e| ChatError::from_sqlx_error("commit_transaction", e))?; - - tracing::info!(user_id = %user_id, room_id = %room_id, "✅ Utilisateur a rejoint le salon"); - Ok(()) -} - -/// Quitter un salon -pub async fn leave_room(hub: &ChatHub, room_id: Uuid, user_id: Uuid) -> Result<()> { - tracing::info!(user_id = %user_id, room_id = %room_id, "🚪 Tentative de quitter le salon"); - - let mut tx = hub.db.begin().await - .map_err(|e| ChatError::from_sqlx_error("begin_transaction", e))?; - - // Supprimer le membre (la table conversation_members n'a pas de left_at dans le schéma actuel) - let rows_affected = query(" - DELETE FROM conversation_members - WHERE conversation_id = $1 AND user_id = $2 - ") - .bind(room_id) - .bind(user_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("leave_room", e))? - .rows_affected(); - - if rows_affected == 0 { - return Err(ChatError::not_found("membre", &user_id.to_string())); - } - - // Log d'audit - query(" - INSERT INTO audit_logs (action, details, user_id) - VALUES ('room_left', $1, $2) - ") - .bind(json!({"room_id": room_id})) - .bind(user_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("audit_log", e))?; - - tx.commit().await - .map_err(|e| ChatError::from_sqlx_error("commit_transaction", e))?; - - tracing::info!(user_id = %user_id, room_id = %room_id, "✅ Utilisateur a quitté le salon"); - Ok(()) -} - -// ================================================================ -// GESTION DES MESSAGES -// ================================================================ - -/// Envoyer un message dans un salon -pub async fn send_room_message( - hub: &ChatHub, - room_id: Uuid, - author_id: Uuid, - username: &str, - content: &str, - parent_message_id: Option, - metadata: Option -) -> Result { - tracing::info!(author_id = %author_id, room_id = %room_id, "📝 Envoi d'un message dans le salon"); - - // validate_message_content(content, hub.config.limits.max_message_length)?; - - // Vérification du rate limiting - if !hub.check_rate_limit(author_id).await { - return Err(ChatError::rate_limit_exceeded_simple("send_message")); - } - let mut tx = hub.db.begin().await - .map_err(|e| ChatError::from_sqlx_error("begin_transaction", e))?; - - // Vérifier que l'utilisateur est membre du salon - let is_member: bool = query(" - SELECT EXISTS( - SELECT 1 FROM conversation_members - WHERE conversation_id = $1 AND user_id = $2 - ) - ") - .bind(room_id) - .bind(author_id) - .fetch_one(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("check_membership", e))? - .get(0); - - if !is_member { - return Err(ChatError::unauthorized("send_room_message")); - } - - // Insérer le message - let message_uuid = Uuid::new_v4(); - let message_metadata = metadata.unwrap_or_else(|| json!({})); - - let message = query(" - INSERT INTO messages (id, sender_id, conversation_id, content, parent_message_id, metadata, status) - VALUES ($1, $2, $3, $4, $5, $6, 'sent') - RETURNING id, created_at - ") - .bind(message_uuid) - .bind(author_id) - .bind(room_id) - .bind(content) - .bind(parent_message_id) - .bind(&message_metadata) - .fetch_one(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("insert_message", e))?; - - let message_id: Uuid = message.get("id"); - let timestamp: DateTime = message.get("created_at"); - - // Si c'est une réponse, incrémenter le compteur de thread - if let Some(parent_id) = parent_message_id { - query(" - UPDATE messages - SET thread_count = thread_count + 1 - WHERE id = $1 - ") - .bind(parent_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("update_thread_count", e))?; - } - - // Traiter les mentions (@username) - process_mentions(&mut tx, message_id, content).await?; - - tx.commit().await - .map_err(|e| ChatError::from_sqlx_error("commit_transaction", e))?; - - // Incrémentation des statistiques - hub.increment_message_count().await; - - // Diffusion en temps réel - broadcast_room_message(hub, room_id, message_id, author_id, username, content, timestamp, parent_message_id).await?; - - tracing::info!(message_id = %message_id, room_id = %room_id, "✅ Message envoyé dans le salon"); - Ok(message_id) -} - -/// Épingler/désépingler un message -pub async fn pin_message(hub: &ChatHub, room_id: Uuid, message_id: Uuid, user_id: Uuid, pin: bool) -> Result<()> { - tracing::info!(user_id = %user_id, room_id = %room_id, message_id = %message_id, pin = %pin, "📌 Épinglage de message"); - - let mut tx = hub.db.begin().await - .map_err(|e| ChatError::from_sqlx_error("begin_transaction", e))?; - - // Vérifier les permissions (propriétaire ou modérateur) - let user_role: Option = query(" - SELECT role FROM conversation_members - WHERE conversation_id = $1 AND user_id = $2 - ") - .bind(room_id) - .bind(user_id) - .fetch_optional(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("check_role", e))? - .map(|row| row.get("role")); - - match user_role.as_deref() { - Some("owner") | Some("moderator") => {}, - _ => return Err(ChatError::unauthorized("pin_message")) - } - - // Mettre à jour le statut d'épinglage - let rows_affected = query(" - UPDATE messages - SET is_pinned = $1, updated_at = NOW() - WHERE id = $2 AND conversation_id = $3 - ") - .bind(pin) - .bind(message_id) - .bind(room_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("update_pin_status", e))? - .rows_affected(); - - if rows_affected == 0 { - return Err(ChatError::not_found("message", &message_id.to_string())); - } - - // Log d'audit - query(" - INSERT INTO audit_logs (action, details, user_id) - VALUES ($1, $2, $3) - ") - .bind(if pin { "message_pinned" } else { "message_unpinned" }) - .bind(json!({ - "room_id": room_id, - "message_id": message_id - })) - .bind(user_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("audit_log", e))?; - - tx.commit().await - .map_err(|e| ChatError::from_sqlx_error("commit_transaction", e))?; - - tracing::info!(message_id = %message_id, pin = %pin, "✅ Statut d'épinglage mis à jour"); - Ok(()) -} - -// ================================================================ -// HISTORIQUE ET RECHERCHE -// ================================================================ - -/// Récupérer l'historique complet d'un salon -pub async fn fetch_room_history( - hub: &ChatHub, - room_id: Uuid, - user_id: Uuid, - limit: i64, - before_message_id: Option -) -> Result> { - tracing::info!(room_id = %room_id, user_id = %user_id, limit = %limit, "📚 Récupération de l'historique du salon"); - - let validated_limit = validate_limit(limit)?; - - // Vérifier que l'utilisateur est membre - let is_member: bool = query(" - SELECT EXISTS( - SELECT 1 FROM conversation_members - WHERE conversation_id = $1 AND user_id = $2 - ) - ") - .bind(room_id) - .bind(user_id) - .fetch_one(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("check_membership", e))? - .get(0); - - if !is_member { - return Err(ChatError::unauthorized("fetch_room_history")); - } - - let mut query_builder = " - SELECT - m.id, m.sender_id as author_id, u.username as author_username, - m.conversation_id, m.content, m.parent_message_id, 0 as thread_count, - m.status, m.is_edited, 0 as edit_count, m.is_pinned, COALESCE(m.metadata, '{}'::jsonb) as metadata, - m.created_at, m.updated_at, m.edited_at, - '[]'::json as reactions, - 0 as mention_count - FROM messages m - JOIN users u ON u.id = m.sender_id - WHERE m.conversation_id = $1 - ".to_string(); - - let mut param_count = 1; - - if let Some(_before_id) = before_message_id { - param_count += 1; - query_builder.push_str(&format!(" AND m.id < ${}", param_count)); - } - - query_builder.push_str(" - ORDER BY m.created_at DESC - "); - - param_count += 1; - query_builder.push_str(&format!(" LIMIT ${}", param_count)); - - let mut query_obj = query_as::<_, RoomMessage>(&query_builder) - .bind(room_id); - - if let Some(before_id) = before_message_id { - query_obj = query_obj.bind(before_id); - } - - let messages = query_obj - .bind(validated_limit) - .fetch_all(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("fetch_room_history", e))?; - - tracing::info!(room_id = %room_id, message_count = %messages.len(), "✅ Historique du salon récupéré"); - Ok(messages) -} - -/// Récupérer les messages épinglés d'un salon -pub async fn fetch_pinned_messages(hub: &ChatHub, room_id: Uuid, user_id: Uuid) -> Result> { - tracing::info!(room_id = %room_id, user_id = %user_id, "📌 Récupération des messages épinglés"); - - // Vérifier membership - let is_member: bool = query(" - SELECT EXISTS( - SELECT 1 FROM conversation_members - WHERE conversation_id = $1 AND user_id = $2 - ) - ") - .bind(room_id) - .bind(user_id) - .fetch_one(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("check_membership", e))? - .get(0); - - if !is_member { - return Err(ChatError::unauthorized("fetch_pinned_messages")); - } - - let messages = query_as::<_, RoomMessage>(" - SELECT - m.id, m.sender_id as author_id, u.username as author_username, - m.conversation_id, m.content, m.parent_message_id, 0 as thread_count, - m.status, m.is_edited, 0 as edit_count, m.is_pinned, COALESCE(m.metadata, '{}'::jsonb) as metadata, - m.created_at, m.updated_at, m.edited_at, - '[]'::json as reactions, - 0 as mention_count - FROM messages m - JOIN users u ON u.id = m.sender_id - WHERE m.conversation_id = $1 AND m.is_pinned = TRUE - ORDER BY m.created_at DESC - ") - .bind(room_id) - .fetch_all(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("fetch_pinned_messages", e))?; - - tracing::info!(room_id = %room_id, pinned_count = %messages.len(), "✅ Messages épinglés récupérés"); - Ok(messages) -} - -// ================================================================ -// STATISTIQUES ET ADMINISTRATION -// ================================================================ - -/// Obtenir les statistiques d'un salon -pub async fn get_room_stats(hub: &ChatHub, room_id: Uuid) -> Result { - tracing::info!(room_id = %room_id, "📊 Récupération des statistiques du salon"); - - let stats = query_as::<_, RoomStats>(" - SELECT - c.id as room_id, - c.name as room_name, - COUNT(DISTINCT m.id) as total_messages, - COUNT(DISTINCT cm.user_id) as total_members, - COUNT(DISTINCT cm.user_id) FILTER (WHERE u.last_seen > NOW() - INTERVAL '1 hour') as active_members, - MAX(m.created_at) as last_activity, - COUNT(DISTINCT m.id) FILTER (WHERE m.is_pinned = TRUE) as pinned_messages - FROM conversations c - LEFT JOIN conversation_members cm ON cm.conversation_id = c.id - LEFT JOIN users u ON u.id = cm.user_id - LEFT JOIN messages m ON m.conversation_id = c.id - WHERE c.id = $1 - GROUP BY c.id, c.name - ") - .bind(room_id) - .fetch_one(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("get_room_stats", e))?; - - tracing::info!(room_id = %room_id, "✅ Statistiques du salon récupérées"); - Ok(stats) -} - -/// Lister les membres d'un salon -pub async fn list_room_members(hub: &ChatHub, room_id: Uuid, requesting_user_id: Uuid) -> Result> { - tracing::info!(room_id = %room_id, requesting_user = %requesting_user_id, "👥 Récupération de la liste des membres"); - - // Vérifier que l'utilisateur est membre - let is_member: bool = query(" - SELECT EXISTS( - SELECT 1 FROM conversation_members - WHERE conversation_id = $1 AND user_id = $2 - ) - ") - .bind(room_id) - .bind(requesting_user_id) - .fetch_one(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("check_membership", e))? - .get(0); - - if !is_member { - return Err(ChatError::unauthorized("list_room_members")); - } - - let members = query_as::<_, RoomMember>(" - SELECT uuid as id, conversation_id, user_id, role, joined_at, NULL as left_at, false as is_muted - FROM conversation_members - WHERE conversation_id = $1 - ORDER BY - CASE role - WHEN 'owner' THEN 1 - WHEN 'moderator' THEN 2 - ELSE 3 - END, - joined_at ASC - ") - .bind(room_id) - .fetch_all(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("list_room_members", e))?; - - tracing::info!(room_id = %room_id, member_count = %members.len(), "✅ Liste des membres récupérée"); - Ok(members) -} - -// ================================================================ -// FONCTIONS UTILITAIRES -// ================================================================ - -/// Traiter les mentions dans un message -async fn process_mentions(tx: &mut Transaction<'_, Postgres>, message_id: Uuid, content: &str) -> Result<()> { - for cap in MENTION_REGEX.captures_iter(content) { - let username = &cap[1]; - - // Trouver l'ID de l'utilisateur mentionné - if let Ok(user_row) = query("SELECT id FROM users WHERE username = $1") - .bind(username) - .fetch_one(&mut **tx) - .await { - - let mentioned_user_id: Uuid = user_row.get("id"); - - // Ajouter la mention - query(" - INSERT INTO message_mentions (message_id, mentioned_user_id) - VALUES ($1, $2) - ON CONFLICT (message_id, mentioned_user_id) DO NOTHING - ") - .bind(message_id) - .bind(mentioned_user_id) - .execute(&mut **tx) - .await - .map_err(|e| ChatError::from_sqlx_error("insert_mention", e))?; - } - } - - Ok(()) -} - -/// Diffuser un message en temps réel aux membres du salon -async fn broadcast_room_message( - hub: &ChatHub, - room_id: Uuid, - message_id: Uuid, - author_id: Uuid, - username: &str, - content: &str, - timestamp: DateTime, - parent_message_id: Option -) -> Result<()> { - let clients = hub.clients.read().await; - - // Récupérer la liste des membres connectés - let member_ids: Vec = query(" - SELECT user_id - FROM conversation_members - WHERE conversation_id = $1 - ") - .bind(room_id) - .fetch_all(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("get_room_members", e))? - .into_iter() - .map(|row| row.get::("user_id")) - .collect(); - - let payload = json!({ - "type": "room_message", - "data": { - "id": message_id, - "roomId": room_id, - "authorId": author_id, - "username": username, - "content": content, - "timestamp": timestamp, - "parentMessageId": parent_message_id, - "isThread": parent_message_id.is_some() - } - }); - - let mut successful_sends = 0; - let mut failed_sends = 0; - - for user_id in member_ids { - if let Some(client) = clients.get(&user_id) { - if client.send_text(&payload.to_string()) { - successful_sends += 1; - } else { - failed_sends += 1; - } - } else { - failed_sends += 1; - } - } - - tracing::info!( - room_id = %room_id, - message_id = %message_id, - successful_sends = %successful_sends, - failed_sends = %failed_sends, - "📡 Message diffusé aux membres du salon" - ); - - Ok(()) -} - -// Fonction temporaire pour validation -fn validate_limit(limit: i64) -> Result { - if limit > 100 { - return Err(ChatError::ValidationError { - field: "limit".to_string(), - reason: "Limit too high".to_string(), - }); - } - Ok(limit) -} \ No newline at end of file diff --git a/veza-chat-server/src/hub/common.rs b/veza-chat-server/src/hub/common.rs deleted file mode 100644 index 6fdfd19e5..000000000 --- a/veza-chat-server/src/hub/common.rs +++ /dev/null @@ -1,283 +0,0 @@ -//file: backend/modules/chat_server/src/hub/common.rs - -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::RwLock; -use sqlx::PgPool; -use uuid::Uuid; - -use crate::client::Client; -// use crate::rate_limiter::RateLimiter; -use crate::config::ServerConfig; -// use crate::cache::CacheManager; -// use crate::monitoring::ChatMetrics; -// use crate::moderation::ModerationSystem; -// use crate::presence::PresenceManager; -// use crate::authentication::UserSession; - -// Types temporaires pour la compilation -#[derive(Debug, Clone)] -pub struct UserSession { - pub user_id: Uuid, - pub username: String, -} - -// Commenté car le ReactionManager n'est pas encore disponible -// use crate::hub::reactions::ReactionManager; - -pub struct ChatHub { - pub clients: Arc>>, - pub rooms: Arc>>>, - pub db: PgPool, - // pub rate_limiter: RateLimiter, - pub config: ServerConfig, - pub stats: Arc>, - - // Nouveaux systèmes intégrés (initialisés séparément) - // pub cache: CacheManager, - // pub metrics: ChatMetrics, - // pub presence: PresenceManager, - // pub connections: Arc>>, - // pub moderation: ModerationSystem, - // pub reactions: ReactionManager, // Commenté temporairement -} - -#[derive(Debug, Default, Clone)] -pub struct HubStats { - pub total_connections: u64, - pub active_connections: u64, - pub total_messages: u64, - pub total_rooms_created: u64, - pub uptime_start: Option, -} - -impl HubStats { - pub fn new() -> Self { - Self { - uptime_start: Some(Instant::now()), - ..Default::default() - } - } - - pub fn uptime(&self) -> Duration { - self.uptime_start.map_or(Duration::ZERO, |start| start.elapsed()) - } -} - -impl ChatHub { - pub fn new(db: PgPool) -> Self { - let config = ServerConfig::default(); - Self { - db, - clients: Arc::new(RwLock::new(HashMap::new())), - rooms: Arc::new(RwLock::new(HashMap::new())), - // rate_limiter: RateLimiter::new(config.limits.max_messages_per_minute), - config, - stats: Arc::new(RwLock::new(HubStats::new())), - - // Initialisation des nouveaux systèmes - // cache: CacheManager::new(), - // metrics: ChatMetrics::new(), - // presence, - // connections: Arc::new(RwLock::new(HashMap::new())), - // moderation, - // reactions: ReactionManager::new(), // Commenté temporairement - } - } - - pub async fn register(&self, user_id: Uuid, client: Client) { - tracing::debug!(user_id = %user_id, username = %client.username, "🔧 Début register"); - - let mut clients = self.clients.write().await; - let clients_before = clients.len(); - - clients.insert(user_id, client); - - // Mise à jour des statistiques - let mut stats = self.stats.write().await; - stats.total_connections += 1; - stats.active_connections = clients.len() as u64; - - tracing::info!( - user_id = %user_id, - clients_before = %clients_before, - clients_after = %clients.len(), - total_connections = %stats.total_connections, - "👤 Enregistrement du client" - ); - } - - pub async fn unregister(&self, user_id: Uuid) { - tracing::debug!(user_id = %user_id, "🔧 Début unregister"); - - let mut clients = self.clients.write().await; - let clients_before = clients.len(); - - if let Some(removed_client) = clients.remove(&user_id) { - // Mise à jour des statistiques - let mut stats = self.stats.write().await; - stats.active_connections = clients.len() as u64; - - tracing::info!( - user_id = %user_id, - username = %removed_client.username, - clients_before = %clients_before, - clients_after = %clients.len(), - active_connections = %stats.active_connections, - connection_duration = ?removed_client.connection_duration(), - "🚪 Déconnexion du client" - ); - } else { - tracing::warn!(user_id = %user_id, clients_count = %clients.len(), "⚠️ Tentative de déconnexion d'un client non enregistré"); - } - - // Nettoyer les salons - let mut rooms = self.rooms.write().await; - let mut rooms_cleaned = 0; - let mut total_removals = 0; - - for (room_name, user_list) in rooms.iter_mut() { - let before_len = user_list.len(); - user_list.retain(|&id| id != user_id); - let after_len = user_list.len(); - - if before_len != after_len { - total_removals += before_len - after_len; - rooms_cleaned += 1; - tracing::debug!(user_id = %user_id, room = %room_name, members_before = %before_len, members_after = %after_len, "🧹 Utilisateur retiré du salon"); - } - } - - if rooms_cleaned > 0 { - tracing::info!(user_id = %user_id, rooms_cleaned = %rooms_cleaned, total_removals = %total_removals, "🧹 Nettoyage des salons terminé"); - } else { - tracing::debug!(user_id = %user_id, "🧹 Aucun salon à nettoyer"); - } - } - - /// Vérifie le rate limiting pour un utilisateur - pub async fn check_rate_limit(&self, _user_id: Uuid) -> bool { - // self.rate_limiter.check_and_update(user_id).await - true // Temporairement toujours autorisé jusqu'à migration complète du rate limiter - } - - /// Incrémente le compteur de messages - pub async fn increment_message_count(&self) { - let mut stats = self.stats.write().await; - stats.total_messages += 1; - } - - /// Retourne les statistiques du hub - pub async fn get_stats(&self) -> HubStats { - self.stats.read().await.clone() - } - - /// Nettoie les connexions mortes (heartbeat timeout) - pub async fn cleanup_dead_connections(&self) { - let timeout = Duration::from_secs(self.config.server.heartbeat_interval.as_secs() * 3); // 3x heartbeat interval - let mut dead_clients = Vec::new(); - - { - let clients = self.clients.read().await; - for (user_id, client) in clients.iter() { - if !client.is_alive(timeout) { - dead_clients.push(*user_id); - } - } - } - - for user_id in dead_clients { - tracing::warn!(user_id = %user_id, timeout_seconds = %timeout.as_secs(), "💀 Connexion morte détectée, nettoyage"); - self.unregister(user_id).await; - } - } - - /// Envoie un ping à tous les clients connectés - pub async fn ping_all_clients(&self) { - let clients = self.clients.read().await; - let mut successful_pings = 0; - let mut failed_pings = 0; - - for client in clients.values() { - if client.send_ping() { - successful_pings += 1; - } else { - failed_pings += 1; - } - } - - if failed_pings > 0 { - tracing::warn!(successful_pings = %successful_pings, failed_pings = %failed_pings, "🏓 Ping terminé avec des échecs"); - } else { - tracing::debug!(successful_pings = %successful_pings, "🏓 Ping de tous les clients réussi"); - } - } - - /// Ajoute une connexion utilisateur - pub async fn add_connection(&self, _user_id: Uuid, _session: UserSession) { - // let mut connections = self.connections.write().await; - // connections.insert(user_id, session); - } - - /// Supprime une connexion utilisateur - pub async fn remove_connection(&self, _user_id: Uuid) { - // let mut connections = self.connections.write().await; - // connections.remove(&user_id); - } - - /// Vérifie si un utilisateur est connecté - pub async fn is_user_connected(&self, _user_id: Uuid) -> bool { - // let connections = self.connections.read().await; - // connections.contains_key(&user_id) - false - } - - /// Ajoute un utilisateur à un salon - pub async fn add_user_to_room(&self, room: &str, user_id: Uuid) { - let mut rooms = self.rooms.write().await; - rooms.entry(room.to_string()).or_default().push(user_id); - } - - /// Supprime un utilisateur d'un salon - pub async fn remove_user_from_room(&self, room: &str, user_id: Uuid) { - let mut rooms = self.rooms.write().await; - if let Some(users) = rooms.get_mut(room) { - users.retain(|&id| id != user_id); - if users.is_empty() { - rooms.remove(room); - } - } - } - - /// Récupère les utilisateurs d'un salon - pub async fn get_room_users(&self, room: &str) -> Vec { - let rooms = self.rooms.read().await; - rooms.get(room).cloned().unwrap_or_default() - } - - /// Diffuse un message à tous les utilisateurs d'un salon - pub async fn broadcast_to_room(&self, room: &str, _message: &str, exclude_user: Option) { - let users = self.get_room_users(room).await; - // let connections = self.connections.read().await; - - for user_id in users { - if let Some(excluded) = exclude_user { - if user_id == excluded { - continue; - } - } - - // if let Some(session) = connections.get(&user_id) { - // // Ici on devrait envoyer le message via WebSocket - // // Pour l'instant on fait juste un log - // tracing::info!( - // user_id = %user_id, - // room = %room, - // message = %message, - // "📡 Message diffusé" - // ); - // } - } - } -} diff --git a/veza-chat-server/src/hub/direct_messages.rs b/veza-chat-server/src/hub/direct_messages.rs deleted file mode 100644 index f4b4413c7..000000000 --- a/veza-chat-server/src/hub/direct_messages.rs +++ /dev/null @@ -1,861 +0,0 @@ -//! Module enrichi pour la gestion des messages directs (DM) -//! -//! Fonctionnalités complètes équivalentes aux salons : -//! - Messages avec threads et métadonnées -//! - Système de réactions -//! - Messages épinglés -//! - Système de mentions -//! - Audit et logs de sécurité -//! - Historique paginé avancé -//! - Modération (blocage, signalement) - -use sqlx::{query, query_as, FromRow, Row, Transaction, Postgres}; -use serde::{Serialize, Deserialize}; -use once_cell::sync::Lazy; -use crate::hub::common::ChatHub; - -static MENTION_REGEX: Lazy = Lazy::new(|| { - regex::Regex::new(r"@(\w+)").expect("mention regex is valid") -}); -// use crate::validation::{validate_message_content, validate_user_id, validate_limit}; -use crate::error::{ChatError, Result}; -use serde_json::{json, Value}; -use chrono::{DateTime, Utc}; -use uuid::Uuid; - - -// ================================================================ -// STRUCTURES DE DONNÉES -// ================================================================ - -#[derive(Debug, FromRow, Serialize, Deserialize)] -pub struct DmConversation { - pub id: Uuid, - pub user1_id: Uuid, - pub user2_id: Uuid, - pub is_blocked: bool, - pub blocked_by: Option, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Debug, FromRow, Serialize)] -pub struct DmMessage { - pub id: Uuid, - pub author_id: Uuid, - pub author_username: String, - pub conversation_id: Uuid, - pub content: String, - pub parent_message_id: Option, - pub thread_count: i32, - pub status: String, - pub is_edited: bool, - pub edit_count: i32, - pub is_pinned: bool, - pub metadata: Value, - pub created_at: DateTime, - pub updated_at: DateTime, - pub edited_at: Option>, - - // Informations des réactions - pub reactions: Option, - pub mention_count: i32, -} - -#[derive(Debug, FromRow, Serialize)] -pub struct DmStats { - pub conversation_id: Uuid, - pub total_messages: i64, - pub pinned_messages: i64, - pub thread_messages: i64, - pub total_reactions: i64, - pub last_activity: Option>, - pub is_blocked: bool, -} - -#[derive(Debug, Serialize)] -pub struct DmParticipant { - pub user_id: Uuid, - pub username: String, - pub is_online: bool, - pub last_seen: Option>, -} - -// Note: EnhancedDmMessage supprimé - maintenant on utilise directement DmMessage avec Uuid - -// ================================================================ -// GESTION DES CONVERSATIONS DM -// ================================================================ - -/// Créer ou récupérer une conversation DM entre deux utilisateurs -/// Note: Utilise la table conversations avec conversation_type = 'direct_message' -pub async fn get_or_create_dm_conversation( - hub: &ChatHub, - user1_id: Uuid, - user2_id: Uuid -) -> Result { - tracing::info!(user1_id = %user1_id, user2_id = %user2_id, "💬 Création/récupération conversation DM"); - - if user1_id == user2_id { - return Err(ChatError::configuration_error("Impossible de créer une conversation avec soi-même")); - } - - let mut tx = hub.db.begin().await - .map_err(|e| ChatError::from_sqlx_error("begin_transaction", e))?; - - // Chercher une conversation existante dans dm_conversations (dans les deux sens) - let existing = query_as::<_, DmConversation>(" - SELECT id, user1_id, user2_id, is_blocked, blocked_by, created_at, updated_at - FROM dm_conversations - WHERE (user1_id = $1 AND user2_id = $2) OR (user1_id = $2 AND user2_id = $1) - ") - .bind(user1_id) - .bind(user2_id) - .fetch_optional(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("find_existing_dm", e))?; - - if let Some(conversation) = existing { - tx.commit().await - .map_err(|e| ChatError::from_sqlx_error("commit_transaction", e))?; - return Ok(conversation); - } - - // Créer une nouvelle conversation DM - let dm_uuid = Uuid::new_v4(); - // Utiliser min/max pour ordre consistant (comme dans le schéma) - let (ordered_user1, ordered_user2) = if user1_id < user2_id { - (user1_id, user2_id) - } else { - (user2_id, user1_id) - }; - - let conversation = query_as::<_, DmConversation>(" - INSERT INTO dm_conversations (id, user1_id, user2_id) - VALUES ($1, $2, $3) - RETURNING id, user1_id, user2_id, is_blocked, blocked_by, created_at, updated_at - ") - .bind(dm_uuid) - .bind(ordered_user1) - .bind(ordered_user2) - .fetch_one(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("create_dm_conversation", e))?; - - // Log d'audit - query(" - INSERT INTO audit_logs (action, details, user_id) - VALUES ('dm_conversation_created', $1, $2) - ") - .bind(json!({ - "conversation_id": conversation.id, - "user1_id": ordered_user1, - "user2_id": ordered_user2 - })) - .bind(user1_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("audit_log", e))?; - - tx.commit().await - .map_err(|e| ChatError::from_sqlx_error("commit_transaction", e))?; - - tracing::info!(conversation_id = %conversation.id, "✅ Conversation DM créée/récupérée"); - Ok(conversation) -} - -/// Bloquer/débloquer une conversation DM -pub async fn block_dm_conversation( - hub: &ChatHub, - conversation_id: Uuid, - user_id: Uuid, - block: bool -) -> Result<()> { - tracing::info!(conversation_id = %conversation_id, user_id = %user_id, block = %block, "🚫 Blocage/déblocage DM"); - - let mut tx = hub.db.begin().await - .map_err(|e| ChatError::from_sqlx_error("begin_transaction", e))?; - - // Vérifier que l'utilisateur fait partie de la conversation - let is_participant: bool = query(" - SELECT EXISTS( - SELECT 1 FROM dm_conversations - WHERE id = $1 AND (user1_id = $2 OR user2_id = $2) - ) - ") - .bind(conversation_id) - .bind(user_id) - .fetch_one(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("check_participant", e))? - .get(0); - - if !is_participant { - return Err(ChatError::unauthorized("block_dm_conversation")); - } - - // Mettre à jour le statut de blocage - query(" - UPDATE dm_conversations - SET is_blocked = $1, blocked_by = $2, updated_at = NOW() - WHERE id = $3 - ") - .bind(block) - .bind(if block { Some(user_id) } else { None }) - .bind(conversation_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("update_block_status", e))?; - - // Log d'audit - query(" - INSERT INTO audit_logs (action, details, user_id) - VALUES ($1, $2, $3) - ") - .bind(if block { "dm_blocked" } else { "dm_unblocked" }) - .bind(json!({"conversation_id": conversation_id})) - .bind(user_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("audit_log", e))?; - - tx.commit().await - .map_err(|e| ChatError::from_sqlx_error("commit_transaction", e))?; - - tracing::info!(conversation_id = %conversation_id, block = %block, "✅ Statut de blocage mis à jour"); - Ok(()) -} - -// ================================================================ -// GESTION DES MESSAGES ENRICHIS -// ================================================================ - -/// Envoyer un message DM enrichi -pub async fn send_dm_message( - hub: &ChatHub, - conversation_id: Uuid, - author_id: Uuid, - username: &str, - content: &str, - parent_message_id: Option, - metadata: Option -) -> Result { - tracing::info!(author_id = %author_id, conversation_id = %conversation_id, "📝 Envoi d'un message DM enrichi"); - - // validate_message_content(content, hub.config.limits.max_message_length)?; - - // Vérification du rate limiting - if !hub.check_rate_limit(author_id).await { - return Err(ChatError::rate_limit_exceeded_simple("send_dm_message")); - } - - let mut tx = hub.db.begin().await - .map_err(|e| ChatError::from_sqlx_error("begin_transaction", e))?; - - // Vérifier que l'utilisateur fait partie de la conversation et qu'elle n'est pas bloquée - let conversation_info = query(" - SELECT is_blocked, blocked_by, user1_id, user2_id - FROM dm_conversations - WHERE id = $1 AND (user1_id = $2 OR user2_id = $2) - ") - .bind(conversation_id) - .bind(author_id) - .fetch_optional(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("check_dm_conversation", e))?; - - let (is_blocked, _blocked_by, user1_id, user2_id) = match conversation_info { - Some(row) => ( - row.get::("is_blocked"), - row.get::, _>("blocked_by"), - row.get::("user1_id"), - row.get::("user2_id") - ), - None => return Err(ChatError::not_found("conversation", &conversation_id.to_string())) - }; - - if is_blocked { - return Err(ChatError::configuration_error("Conversation bloquée")); - } - - // Insérer le message - let message_uuid = Uuid::new_v4(); - let message_metadata = metadata.unwrap_or_else(|| json!({})); - - let message = query(" - INSERT INTO messages (id, sender_id, conversation_id, content, parent_message_id, status) - VALUES ($1, $2, $3, $4, $5, 'sent') - RETURNING id, created_at - ") - .bind(message_uuid) - .bind(author_id) - .bind(conversation_id) - .bind(content) - .bind(parent_message_id) - .fetch_one(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("insert_dm_message", e))?; - - let message_id: Uuid = message.get("id"); - let timestamp: DateTime = message.get("created_at"); - - // Si c'est une réponse, incrémenter le compteur de thread - if let Some(parent_id) = parent_message_id { - query(" - UPDATE messages - SET thread_count = thread_count + 1 - WHERE id = $1 - ") - .bind(parent_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("update_thread_count", e))?; - } - - // Traiter les mentions (@username) - process_dm_mentions(&mut tx, message_id, content).await?; - - // Mettre à jour la conversation - query(" - UPDATE dm_conversations - SET updated_at = NOW() - WHERE id = $1 - ") - .bind(conversation_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("update_dm_conversation", e))?; - - tx.commit().await - .map_err(|e| ChatError::from_sqlx_error("commit_transaction", e))?; - - // Incrémentation des statistiques - hub.increment_message_count().await; - - // Diffusion en temps réel - let other_user_id = if author_id == user1_id { user2_id } else { user1_id }; - broadcast_dm_message(hub, conversation_id, message_id, author_id, other_user_id, username, content, timestamp, parent_message_id).await?; - - tracing::info!(message_id = %message_id, conversation_id = %conversation_id, "✅ Message DM enrichi envoyé"); - Ok(message_id) -} - -/// Épingler/désépingler un message DM -pub async fn pin_dm_message( - hub: &ChatHub, - conversation_id: Uuid, - message_id: Uuid, - user_id: Uuid, - pin: bool -) -> Result<()> { - tracing::info!(user_id = %user_id, conversation_id = %conversation_id, message_id = %message_id, pin = %pin, "📌 Épinglage de message DM"); - - let mut tx = hub.db.begin().await - .map_err(|e| ChatError::from_sqlx_error("begin_transaction", e))?; - - // Vérifier que l'utilisateur fait partie de la conversation - let is_participant: bool = query(" - SELECT EXISTS( - SELECT 1 FROM dm_conversations - WHERE id = $1 AND (user1_id = $2 OR user2_id = $2) - ) - ") - .bind(conversation_id) - .bind(user_id) - .fetch_one(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("check_participant", e))? - .get(0); - - if !is_participant { - return Err(ChatError::unauthorized("pin_dm_message")); - } - - // Mettre à jour le statut d'épinglage - let rows_affected = query(" - UPDATE messages - SET is_pinned = $1, updated_at = NOW() - WHERE id = $2 AND conversation_id = $3 - ") - .bind(pin) - .bind(message_id) - .bind(conversation_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("update_pin_status", e))? - .rows_affected(); - - if rows_affected == 0 { - return Err(ChatError::not_found("message", &message_id.to_string())); - } - - // Log d'audit - query(" - INSERT INTO audit_logs (action, details, user_id) - VALUES ($1, $2, $3) - ") - .bind(if pin { "dm_message_pinned" } else { "dm_message_unpinned" }) - .bind(json!({ - "conversation_id": conversation_id, - "message_id": message_id - })) - .bind(user_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("audit_log", e))?; - - tx.commit().await - .map_err(|e| ChatError::from_sqlx_error("commit_transaction", e))?; - - tracing::info!(message_id = %message_id, pin = %pin, "✅ Statut d'épinglage DM mis à jour"); - Ok(()) -} - -/// Éditer un message DM -pub async fn edit_dm_message( - hub: &ChatHub, - message_id: Uuid, - user_id: Uuid, - new_content: &str, - edit_reason: Option<&str> -) -> Result<()> { - tracing::info!(user_id = %user_id, message_id = %message_id, "✏️ Édition de message DM"); - - // validate_message_content(new_content, hub.config.limits.max_message_length)?; - - let mut tx = hub.db.begin().await - .map_err(|e| ChatError::from_sqlx_error("begin_transaction", e))?; - - // Récupérer le message et vérifier les permissions - let message_info = query(" - SELECT m.content, m.sender_id, m.conversation_id, dc.user1_id, dc.user2_id - FROM messages m - JOIN dm_conversations dc ON dc.id = m.conversation_id - WHERE m.id = $1 - ") - .bind(message_id) - .fetch_optional(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("get_message_info", e))?; - - let (old_content, author_id, conversation_id, user1_id, user2_id) = match message_info { - Some(row) => ( - row.get::("content"), - row.get::("sender_id"), - row.get::("conversation_id"), - row.get::("user1_id"), - row.get::("user2_id") - ), - None => return Err(ChatError::not_found("message", &message_id.to_string())) - }; - - // Seul l'auteur peut éditer son message - if author_id != user_id { - return Err(ChatError::unauthorized("edit_dm_message")); - } - - // Mettre à jour le message - query(" - UPDATE messages - SET content = $1, updated_at = NOW() - WHERE id = $2 - ") - .bind(new_content) - .bind(message_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("update_message", e))?; - - // Log d'audit avec ancien et nouveau contenu - query(" - INSERT INTO audit_logs (action, details, user_id) - VALUES ('dm_message_edited', $1, $2) - ") - .bind(json!({ - "message_id": message_id, - "conversation_id": conversation_id, - "old_content": old_content, - "new_content": new_content, - "edit_reason": edit_reason - })) - .bind(user_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("audit_log", e))?; - - tx.commit().await - .map_err(|e| ChatError::from_sqlx_error("commit_transaction", e))?; - - // Notifier l'autre utilisateur - let other_user_id = if user_id == user1_id { user2_id } else { user1_id }; - broadcast_dm_message_edit(hub, conversation_id, message_id, user_id, other_user_id, new_content).await?; - - tracing::info!(message_id = %message_id, "✅ Message DM édité"); - Ok(()) -} - -// ================================================================ -// HISTORIQUE ET RECHERCHE -// ================================================================ - -/// Récupérer l'historique d'une conversation DM -pub async fn fetch_history( - hub: &ChatHub, - conversation_id: Uuid, - user_id: Uuid, - limit: i64, - before_message_id: Option -) -> Result> { - tracing::info!(conversation_id = %conversation_id, user_id = %user_id, limit = %limit, "📚 Récupération de l'historique DM enrichi"); - - // validate_user_id(user_id as i32)?; - let validated_limit = validate_limit(limit)?; - - // Vérifier que l'utilisateur fait partie de la conversation - let is_participant: bool = query(" - SELECT EXISTS( - SELECT 1 FROM dm_conversations - WHERE id = $1 AND (user1_id = $2 OR user2_id = $2) - ) - ") - .bind(conversation_id) - .bind(user_id) - .fetch_one(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("check_participant", e))? - .get(0); - - if !is_participant { - return Err(ChatError::unauthorized("fetch_dm_history")); - } - - // Requête simplifiée pour récupérer les messages DM - let messages = query_as::<_, DmMessage>(" - SELECT - m.id, m.sender_id as author_id, u.username as author_username, - m.conversation_id, m.content, m.parent_message_id, - 0 as thread_count, - m.status, false as is_edited, 0 as edit_count, m.is_pinned, - '{}'::jsonb as metadata, - m.created_at, m.updated_at, NULL::timestamp as edited_at, - '[]'::json as reactions, - 0 as mention_count - FROM messages m - JOIN users u ON u.id = m.sender_id - WHERE m.conversation_id = $1 - AND ($2::uuid IS NULL OR m.id < $2) - ORDER BY m.created_at DESC - LIMIT $3 - ") - .bind(conversation_id) - .bind(before_message_id) - .bind(validated_limit) - .fetch_all(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("fetch_dm_history", e))?; - - tracing::info!(conversation_id = %conversation_id, message_count = %messages.len(), "✅ Historique DM enrichi récupéré"); - Ok(messages) -} - -/// Récupérer les messages épinglés d'une conversation DM -pub async fn fetch_pinned_messages( - hub: &ChatHub, - conversation_id: Uuid, - user_id: Uuid -) -> Result> { - tracing::info!(conversation_id = %conversation_id, user_id = %user_id, "📌 Récupération des messages DM épinglés"); - - // Vérifier que l'utilisateur fait partie de la conversation - let is_participant: bool = query(" - SELECT EXISTS( - SELECT 1 FROM dm_conversations - WHERE id = $1 AND (user1_id = $2 OR user2_id = $2) - ) - ") - .bind(conversation_id) - .bind(user_id) - .fetch_one(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("check_participant", e))? - .get(0); - - if !is_participant { - return Err(ChatError::unauthorized("fetch_pinned_dm_messages")); - } - - let messages = query_as::<_, DmMessage>(" - SELECT - m.id, m.sender_id as author_id, u.username as author_username, - m.conversation_id, m.content, m.parent_message_id, - 0 as thread_count, - m.status, false as is_edited, 0 as edit_count, m.is_pinned, - '{}'::jsonb as metadata, - m.created_at, m.updated_at, NULL::timestamp as edited_at, - '[]'::json as reactions, - 0 as mention_count - FROM messages m - JOIN users u ON u.id = m.sender_id - WHERE m.conversation_id = $1 AND m.is_pinned = TRUE - ORDER BY m.created_at DESC - ") - .bind(conversation_id) - .fetch_all(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("fetch_pinned_dm_messages", e))?; - - tracing::info!(conversation_id = %conversation_id, pinned_count = %messages.len(), "✅ Messages DM épinglés récupérés"); - Ok(messages) -} - -// ================================================================ -// STATISTIQUES ET ADMINISTRATION -// ================================================================ - -/// Obtenir les statistiques d'une conversation DM -pub async fn get_dm_stats( - hub: &ChatHub, - conversation_id: Uuid, - user_id: Uuid -) -> Result { - tracing::info!(conversation_id = %conversation_id, user_id = %user_id, "📊 Récupération des statistiques DM"); - - // Vérifier que l'utilisateur fait partie de la conversation - let is_participant: bool = query(" - SELECT EXISTS( - SELECT 1 FROM dm_conversations - WHERE id = $1 AND (user1_id = $2 OR user2_id = $2) - ) - ") - .bind(conversation_id) - .bind(user_id) - .fetch_one(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("check_participant", e))? - .get(0); - - if !is_participant { - return Err(ChatError::unauthorized("get_dm_stats")); - } - - let stats = query_as::<_, DmStats>(" - SELECT - dc.id as conversation_id, - COUNT(DISTINCT m.id)::bigint as total_messages, - COUNT(DISTINCT m.id) FILTER (WHERE m.is_pinned = TRUE)::bigint as pinned_messages, - COUNT(DISTINCT m.id) FILTER (WHERE m.parent_message_id IS NOT NULL)::bigint as thread_messages, - COUNT(DISTINCT mr.id)::bigint as total_reactions, - MAX(m.created_at) as last_activity, - dc.is_blocked - FROM dm_conversations dc - LEFT JOIN messages m ON m.conversation_id = dc.id - LEFT JOIN message_reactions mr ON mr.message_id = m.id - WHERE dc.id = $1 - GROUP BY dc.id, dc.is_blocked - ") - .bind(conversation_id) - .fetch_one(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("get_dm_stats", e))?; - - tracing::info!(conversation_id = %conversation_id, "✅ Statistiques DM récupérées"); - Ok(stats) -} - -/// Lister les conversations DM d'un utilisateur -pub async fn list_user_dm_conversations( - hub: &ChatHub, - user_id: Uuid, - limit: i64 -) -> Result> { - tracing::info!(user_id = %user_id, limit = %limit, "💬 Liste des conversations DM"); - - // validate_user_id(user_id as i32)?; - let validated_limit = validate_limit(limit)?; - - let conversations = query(" - SELECT - dc.id, dc.user1_id, dc.user2_id, dc.is_blocked, dc.blocked_by, - dc.created_at, dc.updated_at, - u.id as other_user_id, u.username as other_username, - false as is_online, u.last_seen - FROM dm_conversations dc - JOIN users u ON ( - CASE - WHEN dc.user1_id = $1 THEN u.id = dc.user2_id - ELSE u.id = dc.user1_id - END - ) - WHERE dc.user1_id = $1 OR dc.user2_id = $1 - ORDER BY dc.updated_at DESC - LIMIT $2 - ") - .bind(user_id) - .bind(validated_limit) - .fetch_all(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("list_dm_conversations", e))?; - - let mut result = Vec::new(); - - for row in conversations { - let conversation = DmConversation { - id: row.get("id"), - user1_id: row.get("user1_id"), - user2_id: row.get("user2_id"), - is_blocked: row.get("is_blocked"), - blocked_by: row.get("blocked_by"), - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - }; - - let participant = DmParticipant { - user_id: row.get("other_user_id"), - username: row.get("other_username"), - is_online: row.get("is_online"), - last_seen: row.get("last_seen"), - }; - - result.push((conversation, participant)); - } - - tracing::info!(user_id = %user_id, conversation_count = %result.len(), "✅ Conversations DM listées"); - Ok(result) -} - -// ================================================================ -// FONCTIONS UTILITAIRES -// ================================================================ - -/// Traiter les mentions dans un message DM -async fn process_dm_mentions(tx: &mut Transaction<'_, Postgres>, message_id: Uuid, content: &str) -> Result<()> { - for cap in MENTION_REGEX.captures_iter(content) { - let username = &cap[1]; - - // Trouver l'ID de l'utilisateur mentionné - if let Ok(user_row) = query("SELECT id FROM users WHERE username = $1") - .bind(username) - .fetch_one(&mut **tx) - .await { - - let mentioned_user_id: Uuid = user_row.get("id"); - - // Ajouter la mention - query(" - INSERT INTO message_mentions (message_id, mentioned_user_id) - VALUES ($1, $2) - ON CONFLICT (message_id, mentioned_user_id) DO NOTHING - ") - .bind(message_id) - .bind(mentioned_user_id) - .execute(&mut **tx) - .await - .map_err(|e| ChatError::from_sqlx_error("insert_dm_mention", e))?; - } - } - - Ok(()) -} - -/// Diffuser un message DM en temps réel -async fn broadcast_dm_message( - hub: &ChatHub, - conversation_id: Uuid, - message_id: Uuid, - author_id: Uuid, - other_user_id: Uuid, - username: &str, - content: &str, - timestamp: DateTime, - parent_message_id: Option -) -> Result<()> { - let clients = hub.clients.read().await; - - let payload = json!({ - "type": "dm_message", - "data": { - "id": message_id, - "conversationId": conversation_id, - "authorId": author_id, - "username": username, - "content": content, - "timestamp": timestamp, - "parentMessageId": parent_message_id, - "isThread": parent_message_id.is_some() - } - }); - - let mut successful_sends = 0; - - // Envoyer à l'auteur et au destinataire - for user_id in [author_id, other_user_id] { - if let Some(client) = clients.get(&user_id) { - if client.send_text(&payload.to_string()) { - successful_sends += 1; - } - } - } - - tracing::info!( - conversation_id = %conversation_id, - message_id = %message_id, - successful_sends = %successful_sends, - "📡 Message DM diffusé" - ); - - Ok(()) -} - -/// Diffuser une édition de message DM -async fn broadcast_dm_message_edit( - hub: &ChatHub, - conversation_id: Uuid, - message_id: Uuid, - editor_id: Uuid, - other_user_id: Uuid, - new_content: &str -) -> Result<()> { - let clients = hub.clients.read().await; - - let payload = json!({ - "type": "dm_message_edited", - "data": { - "messageId": message_id, - "conversationId": conversation_id, - "editorId": editor_id, - "newContent": new_content, - "timestamp": Utc::now() - } - }); - - let mut successful_sends = 0; - - // Envoyer à l'éditeur et à l'autre utilisateur - for user_id in [editor_id, other_user_id] { - if let Some(client) = clients.get(&user_id) { - if client.send_text(&payload.to_string()) { - successful_sends += 1; - } - } - } - - tracing::info!( - conversation_id = %conversation_id, - message_id = %message_id, - successful_sends = %successful_sends, - "📡 Édition de message DM diffusée" - ); - - Ok(()) -} - -// Fonction temporaire pour validation -fn validate_limit(limit: i64) -> Result { - if limit > 100 { - return Err(ChatError::ValidationError { - field: "limit".to_string(), - reason: "Limit too high".to_string(), - }); - } - Ok(limit) -} \ No newline at end of file diff --git a/veza-chat-server/src/hub/direct_messages_websocket.rs b/veza-chat-server/src/hub/direct_messages_websocket.rs deleted file mode 100644 index 144dd0476..000000000 --- a/veza-chat-server/src/hub/direct_messages_websocket.rs +++ /dev/null @@ -1,678 +0,0 @@ -//! Gestionnaire WebSocket enrichi pour les messages directs (DM) -//! -//! Fonctionnalités équivalentes aux salons : -//! - Gestion complète des conversations DM -//! - Réactions en temps réel -//! - Messages épinglés -//! - Threads et réponses -//! - Édition de messages -//! - Historique paginé - -use uuid::Uuid; -use crate::hub::{ChatHub, direct_messages, reactions, audit}; -use crate::error::{ChatError, Result}; -use serde_json::{json, Value}; -use tracing::{info, warn}; - -// ================================================================ -// TYPES DE MESSAGES WEBSOCKET DM -// ================================================================ - -pub enum DmWebSocketMessage { - // Gestion des conversations - CreateConversation { user1_id: Uuid, user2_id: Uuid }, - BlockConversation { conversation_id: Uuid, user_id: Uuid, block: bool }, - ListConversations { user_id: Uuid, limit: i64 }, - - // Messages - SendMessage { conversation_id: Uuid, user_id: Uuid, username: String, content: String, parent_id: Option }, - EditMessage { message_id: Uuid, user_id: Uuid, new_content: String, edit_reason: Option }, - - // Historique et recherche - GetHistory { conversation_id: Uuid, user_id: Uuid, limit: i64, before_id: Option }, - GetPinnedMessages { conversation_id: Uuid, user_id: Uuid }, - - // Réactions (utilise le même système que les salons) - AddReaction { message_id: Uuid, user_id: Uuid, emoji: String }, - RemoveReaction { message_id: Uuid, user_id: Uuid, emoji: String }, - GetReactions { message_id: Uuid, user_id: Uuid }, - - // Épinglage - PinMessage { conversation_id: Uuid, message_id: Uuid, user_id: Uuid }, - UnpinMessage { conversation_id: Uuid, message_id: Uuid, user_id: Uuid }, - - // Administration - GetDmStats { conversation_id: Uuid, user_id: Uuid }, - GetAuditLogs { conversation_id: Uuid, user_id: Uuid, limit: i64 }, -} - -// ================================================================ -// GESTIONNAIRE PRINCIPAL -// ================================================================ - -pub async fn handle_dm_websocket_message( - hub: &ChatHub, - message: DmWebSocketMessage -) -> Result> { - match message { - // Gestion des conversations - DmWebSocketMessage::CreateConversation { user1_id, user2_id } => { - handle_create_conversation(hub, user1_id, user2_id).await - } - - DmWebSocketMessage::BlockConversation { conversation_id, user_id, block } => { - handle_block_conversation(hub, conversation_id, user_id, block).await - } - - DmWebSocketMessage::ListConversations { user_id, limit } => { - handle_list_conversations(hub, user_id, limit).await - } - - // Messages - DmWebSocketMessage::SendMessage { conversation_id, user_id, username, content, parent_id } => { - handle_send_dm_message(hub, conversation_id, user_id, &username, &content, parent_id).await - } - - DmWebSocketMessage::EditMessage { message_id, user_id, new_content, edit_reason } => { - handle_edit_dm_message(hub, message_id, user_id, &new_content, edit_reason.as_deref()).await - } - - // Historique - DmWebSocketMessage::GetHistory { conversation_id, user_id, limit, before_id } => { - handle_get_dm_history(hub, conversation_id, user_id, limit, before_id).await - } - - DmWebSocketMessage::GetPinnedMessages { conversation_id, user_id } => { - handle_get_pinned_dm_messages(hub, conversation_id, user_id).await - } - - // Réactions (réutilise le système des salons) - DmWebSocketMessage::AddReaction { message_id, user_id, emoji } => { - handle_add_dm_reaction(hub, message_id, user_id, &emoji).await - } - - DmWebSocketMessage::RemoveReaction { message_id, user_id, emoji } => { - handle_remove_dm_reaction(hub, message_id, user_id, &emoji).await - } - - DmWebSocketMessage::GetReactions { message_id, user_id } => { - handle_get_dm_reactions(hub, message_id, user_id).await - } - - // Épinglage - DmWebSocketMessage::PinMessage { conversation_id, message_id, user_id } => { - handle_pin_dm_message(hub, conversation_id, message_id, user_id, true).await - } - - DmWebSocketMessage::UnpinMessage { conversation_id, message_id, user_id } => { - handle_pin_dm_message(hub, conversation_id, message_id, user_id, false).await - } - - // Administration - DmWebSocketMessage::GetDmStats { conversation_id, user_id } => { - handle_get_dm_stats(hub, conversation_id, user_id).await - } - - DmWebSocketMessage::GetAuditLogs { conversation_id, user_id, limit } => { - handle_get_dm_audit_logs(hub, conversation_id, user_id, limit).await - } - } -} - -// ================================================================ -// GESTIONNAIRES SPÉCIFIQUES -// ================================================================ - -async fn handle_create_conversation(hub: &ChatHub, user1_id: Uuid, user2_id: Uuid) -> Result> { - info!(user1_id = %user1_id, user2_id = %user2_id, "💬 Création/récupération de conversation DM"); - - match direct_messages::get_or_create_dm_conversation(hub, user1_id, user2_id).await { - Ok(conversation) => { - info!(conversation_id = %conversation.id, "✅ Conversation DM créée/récupérée"); - Ok(Some(json!({ - "type": "dm_conversation_created", - "data": { - "conversation": conversation, - "success": true - } - }).to_string())) - } - Err(e) => { - warn!(user1_id = %user1_id, user2_id = %user2_id, error = %e, "❌ Échec de création de conversation DM"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "create_conversation", - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_block_conversation(hub: &ChatHub, conversation_id: Uuid, user_id: Uuid, block: bool) -> Result> { - let action_text = if block { "blocage" } else { "déblocage" }; - info!(conversation_id = %conversation_id, user_id = %user_id, block = %block, "🚫 {} de conversation DM", action_text); - - match direct_messages::block_dm_conversation(hub, conversation_id, user_id, block).await { - Ok(()) => { - info!(conversation_id = %conversation_id, block = %block, "✅ Statut de blocage mis à jour"); - Ok(Some(json!({ - "type": if block { "dm_conversation_blocked" } else { "dm_conversation_unblocked" }, - "data": { - "conversationId": conversation_id, - "isBlocked": block, - "success": true - } - }).to_string())) - } - Err(e) => { - warn!(conversation_id = %conversation_id, user_id = %user_id, error = %e, "❌ Échec de {} de conversation", action_text); - Ok(Some(json!({ - "type": "error", - "data": { - "action": if block { "block_conversation" } else { "unblock_conversation" }, - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_list_conversations(hub: &ChatHub, user_id: Uuid, limit: i64) -> Result> { - info!(user_id = %user_id, limit = %limit, "📋 Liste des conversations DM"); - - match direct_messages::list_user_dm_conversations(hub, user_id, limit).await { - Ok(conversations) => { - info!(user_id = %user_id, conversation_count = %conversations.len(), "✅ Conversations DM listées"); - Ok(Some(json!({ - "type": "dm_conversations_list", - "data": { - "conversations": conversations, - "total": conversations.len() - } - }).to_string())) - } - Err(e) => { - warn!(user_id = %user_id, error = %e, "❌ Échec de liste des conversations DM"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "list_conversations", - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_send_dm_message( - hub: &ChatHub, - conversation_id: Uuid, - user_id: Uuid, - username: &str, - content: &str, - parent_id: Option -) -> Result> { - info!(conversation_id = %conversation_id, user_id = %user_id, content_length = %content.len(), "📝 Envoi de message DM enrichi"); - - match direct_messages::send_dm_message(hub, conversation_id, user_id, username, content, parent_id, None).await { - Ok(message_id) => { - info!(conversation_id = %conversation_id, message_id = %message_id, "✅ Message DM enrichi envoyé"); - Ok(Some(json!({ - "type": "dm_message_sent", - "data": { - "messageId": message_id, - "conversationId": conversation_id, - "success": true - } - }).to_string())) - } - Err(e) => { - warn!(conversation_id = %conversation_id, user_id = %user_id, error = %e, "❌ Échec d'envoi de message DM"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "send_dm_message", - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_edit_dm_message( - hub: &ChatHub, - message_id: Uuid, - user_id: Uuid, - new_content: &str, - edit_reason: Option<&str> -) -> Result> { - info!(message_id = %message_id, user_id = %user_id, "✏️ Édition de message DM"); - - match direct_messages::edit_dm_message(hub, message_id, user_id, new_content, edit_reason).await { - Ok(()) => { - info!(message_id = %message_id, "✅ Message DM édité"); - Ok(Some(json!({ - "type": "dm_message_edited", - "data": { - "messageId": message_id, - "newContent": new_content, - "editReason": edit_reason, - "success": true - } - }).to_string())) - } - Err(e) => { - warn!(message_id = %message_id, user_id = %user_id, error = %e, "❌ Échec d'édition de message DM"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "edit_dm_message", - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_get_dm_history( - hub: &ChatHub, - conversation_id: Uuid, - user_id: Uuid, - limit: i64, - before_id: Option -) -> Result> { - info!(conversation_id = %conversation_id, user_id = %user_id, limit = %limit, "📚 Récupération de l'historique DM enrichi"); - - match direct_messages::fetch_history(hub, conversation_id, user_id, limit, before_id).await { - Ok(messages) => { - info!(conversation_id = %conversation_id, message_count = %messages.len(), "✅ Historique DM enrichi récupéré"); - Ok(Some(json!({ - "type": "dm_history", - "data": { - "conversationId": conversation_id, - "messages": messages, - "hasMore": messages.len() as i64 == limit - } - }).to_string())) - } - Err(e) => { - warn!(conversation_id = %conversation_id, user_id = %user_id, error = %e, "❌ Échec de récupération de l'historique DM"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "get_dm_history", - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_get_pinned_dm_messages(hub: &ChatHub, conversation_id: Uuid, user_id: Uuid) -> Result> { - info!(conversation_id = %conversation_id, user_id = %user_id, "📌 Récupération des messages DM épinglés"); - - match direct_messages::fetch_pinned_messages(hub, conversation_id, user_id).await { - Ok(messages) => { - info!(conversation_id = %conversation_id, pinned_count = %messages.len(), "✅ Messages DM épinglés récupérés"); - Ok(Some(json!({ - "type": "dm_pinned_messages", - "data": { - "conversationId": conversation_id, - "messages": messages - } - }).to_string())) - } - Err(e) => { - warn!(conversation_id = %conversation_id, user_id = %user_id, error = %e, "❌ Échec de récupération des messages DM épinglés"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "get_pinned_dm_messages", - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_add_dm_reaction(hub: &ChatHub, message_id: Uuid, user_id: Uuid, emoji: &str) -> Result> { - info!(message_id = %message_id, user_id = %user_id, emoji = %emoji, "😊 Ajout de réaction DM"); - - // Utilise le même système de réactions que les salons - match reactions::add_reaction(hub, message_id, user_id, emoji).await { - Ok(()) => { - info!(message_id = %message_id, emoji = %emoji, "✅ Réaction DM ajoutée"); - Ok(Some(json!({ - "type": "dm_reaction_added", - "data": { - "messageId": message_id, - "userId": user_id, - "emoji": emoji, - "success": true - } - }).to_string())) - } - Err(e) => { - warn!(message_id = %message_id, user_id = %user_id, error = %e, "❌ Échec d'ajout de réaction DM"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "add_dm_reaction", - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_remove_dm_reaction(hub: &ChatHub, message_id: Uuid, user_id: Uuid, emoji: &str) -> Result> { - info!(message_id = %message_id, user_id = %user_id, emoji = %emoji, "🗑️ Suppression de réaction DM"); - - match reactions::remove_reaction(hub, message_id, user_id, emoji).await { - Ok(()) => { - info!(message_id = %message_id, emoji = %emoji, "✅ Réaction DM supprimée"); - Ok(Some(json!({ - "type": "dm_reaction_removed", - "data": { - "messageId": message_id, - "userId": user_id, - "emoji": emoji, - "success": true - } - }).to_string())) - } - Err(e) => { - warn!(message_id = %message_id, user_id = %user_id, error = %e, "❌ Échec de suppression de réaction DM"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "remove_dm_reaction", - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_get_dm_reactions(hub: &ChatHub, message_id: Uuid, user_id: Uuid) -> Result> { - info!(message_id = %message_id, user_id = %user_id, "�� Récupération des réactions DM"); - - match reactions::get_message_reactions(hub, message_id, user_id).await { - Ok(message_reactions) => { - info!(message_id = %message_id, total_reactions = %message_reactions.total_reactions, "✅ Réactions DM récupérées"); - Ok(Some(json!({ - "type": "dm_message_reactions", - "data": message_reactions - }).to_string())) - } - Err(e) => { - warn!(message_id = %message_id, user_id = %user_id, error = %e, "❌ Échec de récupération des réactions DM"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "get_dm_reactions", - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_pin_dm_message(hub: &ChatHub, conversation_id: Uuid, message_id: Uuid, user_id: Uuid, pin: bool) -> Result> { - let action_text = if pin { "épinglage" } else { "désépinglage" }; - info!(conversation_id = %conversation_id, message_id = %message_id, user_id = %user_id, pin = %pin, "📌 {} de message DM", action_text); - - match direct_messages::pin_dm_message(hub, conversation_id, message_id, user_id, pin).await { - Ok(()) => { - info!(message_id = %message_id, pin = %pin, "✅ Statut d'épinglage DM mis à jour"); - Ok(Some(json!({ - "type": if pin { "dm_message_pinned" } else { "dm_message_unpinned" }, - "data": { - "messageId": message_id, - "conversationId": conversation_id, - "isPinned": pin, - "success": true - } - }).to_string())) - } - Err(e) => { - warn!(message_id = %message_id, user_id = %user_id, error = %e, "❌ Échec de {} de message DM", action_text); - Ok(Some(json!({ - "type": "error", - "data": { - "action": if pin { "pin_dm_message" } else { "unpin_dm_message" }, - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_get_dm_stats(hub: &ChatHub, conversation_id: Uuid, user_id: Uuid) -> Result> { - info!(conversation_id = %conversation_id, user_id = %user_id, "📊 Récupération des statistiques DM"); - - match direct_messages::get_dm_stats(hub, conversation_id, user_id).await { - Ok(stats) => { - info!(conversation_id = %conversation_id, "✅ Statistiques DM récupérées"); - Ok(Some(json!({ - "type": "dm_stats", - "data": stats - }).to_string())) - } - Err(e) => { - warn!(conversation_id = %conversation_id, user_id = %user_id, error = %e, "❌ Échec de récupération des statistiques DM"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "get_dm_stats", - "error": e.to_string() - } - }).to_string())) - } - } -} - -async fn handle_get_dm_audit_logs(hub: &ChatHub, conversation_id: Uuid, user_id: Uuid, limit: i64) -> Result> { - info!(conversation_id = %conversation_id, user_id = %user_id, limit = %limit, "📋 Récupération des logs d'audit DM"); - - // Adapter les logs d'audit pour les DM (chercher par conversation_id dans les détails) - match audit::get_room_audit_logs(hub, conversation_id, user_id, limit, None).await { - Ok(logs) => { - info!(conversation_id = %conversation_id, log_count = %logs.len(), "✅ Logs d'audit DM récupérés"); - Ok(Some(json!({ - "type": "dm_audit_logs", - "data": { - "conversationId": conversation_id, - "logs": logs - } - }).to_string())) - } - Err(e) => { - warn!(conversation_id = %conversation_id, user_id = %user_id, error = %e, "❌ Échec de récupération des logs d'audit DM"); - Ok(Some(json!({ - "type": "error", - "data": { - "action": "get_dm_audit_logs", - "error": e.to_string() - } - }).to_string())) - } - } -} - -// ================================================================ -// UTILITAIRES DE PARSING -// ================================================================ - -/// Parser un message JSON WebSocket en DmWebSocketMessage -pub fn parse_dm_websocket_message(message: &str) -> Result { - let value: Value = serde_json::from_str(message) - .map_err(|e| ChatError::configuration_error(&format!("JSON invalide: {}", e)))?; - - let msg_type = value.get("type") - .and_then(|v| v.as_str()) - .ok_or_else(|| ChatError::configuration_error("Type de message manquant"))?; - - let data = value.get("data") - .ok_or_else(|| ChatError::configuration_error("Données du message manquantes"))?; - - // Helper pour parser un UUID depuis une string JSON - fn parse_uuid_from_json(v: &Value) -> Result { - match v { - Value::String(s) => Uuid::parse_str(s) - .map_err(|e| ChatError::validation_error(&format!("UUID invalide: {}", e))), - _ => Err(ChatError::validation_error("UUID doit être une string")), - } - } - - match msg_type { - "create_dm_conversation" => Ok(DmWebSocketMessage::CreateConversation { - user1_id: data.get("user1Id") - .ok_or_else(|| ChatError::validation_error("user1Id manquant")) - .and_then(parse_uuid_from_json)?, - user2_id: data.get("user2Id") - .ok_or_else(|| ChatError::validation_error("user2Id manquant")) - .and_then(parse_uuid_from_json)?, - }), - - "block_dm_conversation" => Ok(DmWebSocketMessage::BlockConversation { - conversation_id: data.get("conversationId") - .ok_or_else(|| ChatError::validation_error("conversationId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - block: data.get("block").and_then(|v| v.as_bool()).unwrap_or(true), - }), - - "list_dm_conversations" => Ok(DmWebSocketMessage::ListConversations { - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - limit: data.get("limit").and_then(|v| v.as_i64()).unwrap_or(50), - }), - - "send_dm_message" => Ok(DmWebSocketMessage::SendMessage { - conversation_id: data.get("conversationId") - .ok_or_else(|| ChatError::validation_error("conversationId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - username: data.get("username").and_then(|v| v.as_str()).unwrap_or("").to_string(), - content: data.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string(), - parent_id: data.get("parentId") - .map(|v| parse_uuid_from_json(v)) - .transpose()?, - }), - - "edit_dm_message" => Ok(DmWebSocketMessage::EditMessage { - message_id: data.get("messageId") - .ok_or_else(|| ChatError::validation_error("messageId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - new_content: data.get("newContent").and_then(|v| v.as_str()).unwrap_or("").to_string(), - edit_reason: data.get("editReason").and_then(|v| v.as_str()).map(|s| s.to_string()), - }), - - "get_dm_history" => Ok(DmWebSocketMessage::GetHistory { - conversation_id: data.get("conversationId") - .ok_or_else(|| ChatError::validation_error("conversationId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - limit: data.get("limit").and_then(|v| v.as_i64()).unwrap_or(50), - before_id: data.get("beforeId") - .map(|v| parse_uuid_from_json(v)) - .transpose()?, - }), - - "get_pinned_dm_messages" => Ok(DmWebSocketMessage::GetPinnedMessages { - conversation_id: data.get("conversationId") - .ok_or_else(|| ChatError::validation_error("conversationId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - }), - - "add_dm_reaction" => Ok(DmWebSocketMessage::AddReaction { - message_id: data.get("messageId") - .ok_or_else(|| ChatError::validation_error("messageId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - emoji: data.get("emoji").and_then(|v| v.as_str()).unwrap_or("").to_string(), - }), - - "remove_dm_reaction" => Ok(DmWebSocketMessage::RemoveReaction { - message_id: data.get("messageId") - .ok_or_else(|| ChatError::validation_error("messageId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - emoji: data.get("emoji").and_then(|v| v.as_str()).unwrap_or("").to_string(), - }), - - "get_dm_reactions" => Ok(DmWebSocketMessage::GetReactions { - message_id: data.get("messageId") - .ok_or_else(|| ChatError::validation_error("messageId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - }), - - "pin_dm_message" => Ok(DmWebSocketMessage::PinMessage { - conversation_id: data.get("conversationId") - .ok_or_else(|| ChatError::validation_error("conversationId manquant")) - .and_then(parse_uuid_from_json)?, - message_id: data.get("messageId") - .ok_or_else(|| ChatError::validation_error("messageId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - }), - - "unpin_dm_message" => Ok(DmWebSocketMessage::UnpinMessage { - conversation_id: data.get("conversationId") - .ok_or_else(|| ChatError::validation_error("conversationId manquant")) - .and_then(parse_uuid_from_json)?, - message_id: data.get("messageId") - .ok_or_else(|| ChatError::validation_error("messageId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - }), - - "get_dm_stats" => Ok(DmWebSocketMessage::GetDmStats { - conversation_id: data.get("conversationId") - .ok_or_else(|| ChatError::validation_error("conversationId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - }), - - "get_dm_audit_logs" => Ok(DmWebSocketMessage::GetAuditLogs { - conversation_id: data.get("conversationId") - .ok_or_else(|| ChatError::validation_error("conversationId manquant")) - .and_then(parse_uuid_from_json)?, - user_id: data.get("userId") - .ok_or_else(|| ChatError::validation_error("userId manquant")) - .and_then(parse_uuid_from_json)?, - limit: data.get("limit").and_then(|v| v.as_i64()).unwrap_or(50), - }), - - _ => Err(ChatError::configuration_error(&format!("Type de message DM non supporté: {}", msg_type))) - } -} \ No newline at end of file diff --git a/veza-chat-server/src/hub/mod.rs b/veza-chat-server/src/hub/mod.rs deleted file mode 100644 index 8638be83e..000000000 --- a/veza-chat-server/src/hub/mod.rs +++ /dev/null @@ -1,93 +0,0 @@ -//file: backend/modules/chat_server/src/hub/mod.rs - -//! Module Hub - Gestion centralisée du chat en temps réel -//! -//! Ce module contient tous les composants pour la gestion du hub de chat, -//! incluant les salons, messages directs, réactions, audit et WebSocket. - -// ================================================================ -// MODULES CORE -// ================================================================ - -/// Structures communes et hub principal -pub mod common; - -/// Gestion des salons de chat (anciennement room_enhanced) -pub mod channels; - -/// Module room_enhanced pour la compatibilité -pub mod room_enhanced; - -/// Gestion des messages directs (anciennement dm_enhanced) -pub mod direct_messages; - -/// Système de réactions aux messages -pub mod reactions; - -/// Système d'audit et de logs de sécurité -pub mod audit; - -// ================================================================ -// MODULES WEBSOCKET -// ================================================================ - -/// WebSocket pour les salons de chat -pub mod channel_websocket; - -/// WebSocket pour les messages directs -pub mod direct_messages_websocket; - -// ================================================================ -// RÉEXPORTS PRINCIPAUX -// ================================================================ - -// Types et fonctions du hub principal -pub use common::{ChatHub, HubStats}; - -// Types et fonctions pour les salons de chat -pub use channels::{ - create_room, join_room, leave_room, send_room_message, - Room, RoomMember, RoomMessage, RoomStats, - pin_message as pin_room_message, - fetch_room_history, fetch_pinned_messages, - get_room_stats, list_room_members, -}; - -// Types et fonctions pour les messages directs -pub use direct_messages::{ - get_or_create_dm_conversation, - block_dm_conversation, - send_dm_message, - pin_dm_message, - edit_dm_message, - fetch_history as fetch_dm_history, - fetch_pinned_messages as fetch_pinned_dm_messages, - get_dm_stats, - list_user_dm_conversations -}; - -// Système de réactions -pub use reactions::{ - MessageReaction, ReactionSummary, MessageReactions, - add_reaction, remove_reaction, toggle_reaction, - get_message_reactions, get_user_reactions, get_popular_emojis -}; - -// Système d'audit -pub use audit::{ - AuditLog, SecurityEvent, ActivityReport, UserActivity, RoomAuditSummary, - log_action, log_security_event, - log_room_created, log_member_change, log_message_modified, log_moderation_action, - get_room_audit_logs, get_room_security_events, - generate_room_activity_report, get_room_audit_summary, - detect_suspicious_patterns -}; - -// Handlers WebSocket -pub use channel_websocket::{ - RoomWebSocketMessage, handle_room_websocket_message, parse_websocket_message as parse_room_websocket_message -}; - -pub use direct_messages_websocket::{ - DmWebSocketMessage, handle_dm_websocket_message, parse_dm_websocket_message -}; diff --git a/veza-chat-server/src/hub/reactions.rs b/veza-chat-server/src/hub/reactions.rs deleted file mode 100644 index 053c0f4d8..000000000 --- a/veza-chat-server/src/hub/reactions.rs +++ /dev/null @@ -1,488 +0,0 @@ -//! Module de gestion des réactions aux messages -//! -//! Fonctionnalités : -//! - Ajouter/supprimer des réactions emoji -//! - Compter les réactions par type -//! - Historique des réactions -//! - Limitations et validation -//! - Support pour DM et salons - -use uuid::Uuid; -use sqlx::{query, query_as, FromRow, Row}; -use serde::{Serialize, Deserialize}; -use serde_json::json; -use chrono::{DateTime, Utc}; - -use crate::hub::common::ChatHub; -use crate::error::{ChatError, Result}; - -// ================================================================ -// STRUCTURES DE DONNÉES -// ================================================================ - -#[derive(Debug, FromRow, Serialize, Deserialize)] -pub struct MessageReaction { - pub id: Uuid, - pub message_id: Uuid, - pub user_id: Uuid, - pub emoji: String, - pub created_at: DateTime, -} - -#[derive(Debug, Serialize)] -pub struct ReactionSummary { - pub emoji: String, - pub count: i64, - pub users: Vec, -} - -#[derive(Debug, FromRow, Serialize)] -pub struct ReactionUser { - pub user_id: Uuid, - pub username: String, - pub created_at: DateTime, -} - -#[derive(Debug, Serialize)] -pub struct MessageReactions { - pub message_id: Uuid, - pub total_reactions: i64, - pub reactions: Vec, -} - -// ================================================================ -// GESTION DES RÉACTIONS -// ================================================================ - -/// Ajouter une réaction à un message -pub async fn add_reaction( - hub: &ChatHub, - message_id: Uuid, - user_id: Uuid, - emoji: &str -) -> Result<()> { - tracing::info!(user_id = %user_id, message_id = %message_id, emoji = %emoji, "😊 Ajout d'une réaction"); - - // validate_user_id(user_id as i32)?; - validate_emoji(emoji)?; - - let mut tx = hub.db.begin().await - .map_err(|e| ChatError::from_sqlx_error("begin_transaction", e))?; - - // Vérifier que le message existe et que l'utilisateur a accès - let message_access = check_message_access(&mut tx, message_id, user_id).await?; - if !message_access { - return Err(ChatError::unauthorized("add_reaction")); - } - - // Vérifier la limite de réactions par utilisateur par message (max 10) - let user_reaction_count: i64 = query(" - SELECT COUNT(*) - FROM message_reactions - WHERE message_id = $1 AND user_id = $2 - ") - .bind(message_id) - .bind(user_id) - .fetch_one(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("count_user_reactions", e))? - .get(0); - - if user_reaction_count >= 10 { - return Err(ChatError::configuration_error("Limite de réactions par message atteinte")); - } - - // Ajouter la réaction (ou ne rien faire si elle existe déjà) - let rows_affected = query(" - INSERT INTO message_reactions (message_id, user_id, emoji) - VALUES ($1, $2, $3) - ON CONFLICT (message_id, user_id, emoji) DO NOTHING - ") - .bind(message_id) - .bind(user_id) - .bind(emoji) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("insert_reaction", e))? - .rows_affected(); - - if rows_affected == 0 { - return Err(ChatError::configuration_error("Réaction déjà présente")); - } - - // Log d'audit - query(" - INSERT INTO audit_logs (action, details, user_id) - VALUES ('reaction_added', $1, $2) - ") - .bind(json!({ - "message_id": message_id, - "emoji": emoji - })) - .bind(user_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("audit_log", e))?; - - tx.commit().await - .map_err(|e| ChatError::from_sqlx_error("commit_transaction", e))?; - - // Notifier en temps réel - broadcast_reaction_update(hub, message_id, "added", user_id, emoji).await?; - - tracing::info!(user_id = %user_id, message_id = %message_id, emoji = %emoji, "✅ Réaction ajoutée"); - Ok(()) -} - -/// Supprimer une réaction d'un message -pub async fn remove_reaction( - hub: &ChatHub, - message_id: Uuid, - user_id: Uuid, - emoji: &str -) -> Result<()> { - tracing::info!(user_id = %user_id, message_id = %message_id, emoji = %emoji, "🗑️ Suppression d'une réaction"); - - // validate_user_id(user_id as i32)?; - validate_emoji(emoji)?; - - let mut tx = hub.db.begin().await - .map_err(|e| ChatError::from_sqlx_error("begin_transaction", e))?; - - // Vérifier l'accès au message - let message_access = check_message_access(&mut tx, message_id, user_id).await?; - if !message_access { - return Err(ChatError::unauthorized("remove_reaction")); - } - - // Supprimer la réaction - let rows_affected = query(" - DELETE FROM message_reactions - WHERE message_id = $1 AND user_id = $2 AND emoji = $3 - ") - .bind(message_id) - .bind(user_id) - .bind(emoji) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("delete_reaction", e))? - .rows_affected(); - - if rows_affected == 0 { - return Err(ChatError::not_found("réaction", &format!("{}:{}", message_id, emoji))); - } - - // Log d'audit - query(" - INSERT INTO audit_logs (action, details, user_id) - VALUES ('reaction_removed', $1, $2) - ") - .bind(json!({ - "message_id": message_id, - "emoji": emoji - })) - .bind(user_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("audit_log", e))?; - - tx.commit().await - .map_err(|e| ChatError::from_sqlx_error("commit_transaction", e))?; - - // Notifier en temps réel - broadcast_reaction_update(hub, message_id, "removed", user_id, emoji).await?; - - tracing::info!(user_id = %user_id, message_id = %message_id, emoji = %emoji, "✅ Réaction supprimée"); - Ok(()) -} - -/// Basculer une réaction (ajouter si absente, supprimer si présente) -pub async fn toggle_reaction( - hub: &ChatHub, - message_id: Uuid, - user_id: Uuid, - emoji: &str -) -> Result { - tracing::info!(user_id = %user_id, message_id = %message_id, emoji = %emoji, "🔄 Basculement de réaction"); - - // validate_user_id(user_id as i32)?; - validate_emoji(emoji)?; - - // Vérifier si la réaction existe déjà - let reaction_exists: bool = query(" - SELECT EXISTS( - SELECT 1 FROM message_reactions - WHERE message_id = $1 AND user_id = $2 AND emoji = $3 - ) - ") - .bind(message_id) - .bind(user_id) - .bind(emoji) - .fetch_one(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("check_reaction_exists", e))? - .get(0); - - if reaction_exists { - remove_reaction(hub, message_id, user_id, emoji).await?; - Ok(false) // Réaction supprimée - } else { - add_reaction(hub, message_id, user_id, emoji).await?; - Ok(true) // Réaction ajoutée - } -} - -// ================================================================ -// CONSULTATION DES RÉACTIONS -// ================================================================ - -/// Obtenir toutes les réactions d'un message -pub async fn get_message_reactions( - hub: &ChatHub, - message_id: Uuid, - requesting_user_id: Uuid -) -> Result { - tracing::info!(message_id = %message_id, user_id = %requesting_user_id, "📊 Récupération des réactions du message"); - - // validate_user_id(requesting_user_id as i32)?; - - // Vérifier l'accès au message - let mut tx = hub.db.begin().await - .map_err(|e| ChatError::from_sqlx_error("begin_transaction", e))?; - - let message_access = check_message_access(&mut tx, message_id, requesting_user_id).await?; - if !message_access { - return Err(ChatError::unauthorized("get_message_reactions")); - } - - tx.commit().await - .map_err(|e| ChatError::from_sqlx_error("commit_transaction", e))?; - - // Récupérer les réactions groupées par emoji - let reactions = query_as::<_, (String, i64)>(" - SELECT emoji, COUNT(*) as count - FROM message_reactions - WHERE message_id = $1 - GROUP BY emoji - ORDER BY count DESC, emoji - ") - .bind(message_id) - .fetch_all(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("get_reaction_counts", e))?; - - let total_reactions: i64 = reactions.iter().map(|(_, count)| count).sum(); - - // Pour chaque emoji, récupérer les détails des utilisateurs - let mut reaction_summaries = Vec::new(); - - for (emoji, count) in reactions { - let users = query_as::<_, ReactionUser>(" - SELECT mr.user_id, u.username, mr.created_at - FROM message_reactions mr - JOIN users u ON u.id = mr.user_id - WHERE mr.message_id = $1 AND mr.emoji = $2 - ORDER BY mr.created_at ASC - ") - .bind(message_id) - .bind(&emoji) - .fetch_all(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("get_reaction_users", e))?; - - reaction_summaries.push(ReactionSummary { - emoji, - count, - users, - }); - } - - let message_reactions = MessageReactions { - message_id, - total_reactions, - reactions: reaction_summaries, - }; - - tracing::info!(message_id = %message_id, total_reactions = %total_reactions, "✅ Réactions du message récupérées"); - Ok(message_reactions) -} - -/// Obtenir les réactions d'un utilisateur -pub async fn get_user_reactions( - hub: &ChatHub, - user_id: Uuid, - limit: i64 -) -> Result> { - tracing::info!(user_id = %user_id, limit = %limit, "👤 Récupération des réactions de l'utilisateur"); - - // validate_user_id(user_id as i32)?; - - let reactions = query_as::<_, MessageReaction>(" - SELECT id, message_id, user_id, emoji, created_at - FROM message_reactions - WHERE user_id = $1 - ORDER BY created_at DESC - LIMIT $2 - ") - .bind(user_id) - .bind(limit) - .fetch_all(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("get_user_reactions", e))?; - - tracing::info!(user_id = %user_id, reaction_count = %reactions.len(), "✅ Réactions de l'utilisateur récupérées"); - Ok(reactions) -} - -/// Obtenir les emojis les plus utilisés -pub async fn get_popular_emojis(hub: &ChatHub, limit: i64) -> Result> { - tracing::info!(limit = %limit, "📈 Récupération des emojis populaires"); - - let popular_emojis = query_as::<_, (String, i64)>(" - SELECT emoji, COUNT(*) as usage_count - FROM message_reactions - WHERE created_at > NOW() - INTERVAL '30 days' - GROUP BY emoji - ORDER BY usage_count DESC - LIMIT $1 - ") - .bind(limit) - .fetch_all(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("get_popular_emojis", e))?; - - tracing::info!(emoji_count = %popular_emojis.len(), "✅ Emojis populaires récupérés"); - Ok(popular_emojis) -} - -// ================================================================ -// GESTION DES ÉVÉNEMENTS EN TEMPS RÉEL -// ================================================================ - -/// Diffuser une mise à jour de réaction en temps réel -async fn broadcast_reaction_update( - hub: &ChatHub, - message_id: Uuid, - action: &str, // "added" ou "removed" - user_id: Uuid, - emoji: &str -) -> Result<()> { - // Récupérer les utilisateurs qui ont accès au message - let users_with_access = get_message_access_users(hub, message_id).await?; - - let payload = json!({ - "type": "reaction_update", - "data": { - "messageId": message_id, - "action": action, - "userId": user_id, - "emoji": emoji, - "timestamp": Utc::now() - } - }); - - let clients = hub.clients.read().await; - let mut successful_sends = 0; - - for access_user_id in users_with_access { - if let Some(client) = clients.get(&access_user_id) { - if client.send_text(&payload.to_string()) { - successful_sends += 1; - } - } - } - - tracing::info!( - message_id = %message_id, - action = %action, - successful_sends = %successful_sends, - "📡 Mise à jour de réaction diffusée" - ); - - Ok(()) -} - -// ================================================================ -// FONCTIONS UTILITAIRES -// ================================================================ - -/// Valider un emoji (caractères autorisés et longueur) -fn validate_emoji(emoji: &str) -> Result<()> { - if emoji.is_empty() || emoji.len() > 20 { - return Err(ChatError::configuration_error("Emoji invalide")); - } - - // Vérifier que l'emoji ne contient que des caractères autorisés - // (émojis Unicode, lettres, chiffres, quelques symboles) - let allowed = emoji.chars().all(|c| { - c.is_alphanumeric() || - c.is_ascii_punctuation() || - (c as u32 >= 0x1F600 && c as u32 <= 0x1F64F) || // Émojis visages - (c as u32 >= 0x1F300 && c as u32 <= 0x1F5FF) || // Émojis divers - (c as u32 >= 0x1F680 && c as u32 <= 0x1F6FF) || // Émojis transport - (c as u32 >= 0x2600 && c as u32 <= 0x26FF) || // Émojis divers - (c as u32 >= 0x2700 && c as u32 <= 0x27BF) // Dingbats - }); - - if !allowed { - return Err(ChatError::configuration_error("Caractères non autorisés dans l'emoji")); - } - - Ok(()) -} - -/// Vérifier si un utilisateur a accès à un message -async fn check_message_access( - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - message_id: Uuid, - user_id: Uuid -) -> Result { - // Vérifier si c'est un message dans une conversation où l'utilisateur est membre - let has_access: bool = query(" - SELECT EXISTS( - SELECT 1 FROM messages m - JOIN conversations c ON c.id = m.conversation_id - LEFT JOIN conversation_members cm ON cm.conversation_id = c.id AND cm.user_id = $2 AND cm.left_at IS NULL - WHERE m.id = $1 - AND ( - c.is_private = FALSE OR - cm.user_id IS NOT NULL OR - m.sender_id = $2 - ) - ) - ") - .bind(message_id) - .bind(user_id) - .fetch_one(&mut **tx) - .await - .map_err(|e| ChatError::from_sqlx_error("check_message_access", e))? - .get(0); - - Ok(has_access) -} - -/// Obtenir la liste des utilisateurs qui ont accès à un message -async fn get_message_access_users(hub: &ChatHub, message_id: Uuid) -> Result> { - let users = query(" - SELECT DISTINCT cm.user_id - FROM messages m - JOIN conversations c ON c.id = m.conversation_id - JOIN conversation_members cm ON cm.conversation_id = c.id AND cm.left_at IS NULL - WHERE m.id = $1 - - UNION - - SELECT m.sender_id as user_id - FROM messages m - WHERE m.id = $1 - ") - .bind(message_id) - .fetch_all(&hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("get_message_access_users", e))? - .into_iter() - .map(|row| row.get::("user_id")) - .collect(); - - Ok(users) -} \ No newline at end of file diff --git a/veza-chat-server/src/hub/room_enhanced.rs b/veza-chat-server/src/hub/room_enhanced.rs deleted file mode 100644 index c4f1d0cfc..000000000 --- a/veza-chat-server/src/hub/room_enhanced.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! Module de compatibilité pour room_enhanced -//! -//! Ce module fait le pont avec le nouveau module channels pour maintenir -//! la compatibilité avec l'ancien code qui référençait room_enhanced - -use uuid::Uuid; -use crate::error::Result; -use crate::hub::{ChatHub, channels}; - - -/// Fonction de compatibilité pour envoyer un message dans un salon -pub async fn send_room_message( - hub: &ChatHub, - room_id: Uuid, - user_id: Uuid, - username: &str, - content: &str, - parent_id: Option, -) -> Result { - // Délégation vers le nouveau module channels - let result = channels::send_room_message(hub, room_id, user_id, username, content, parent_id, None).await?; - Ok(result.to_string()) -} - -/// Fonction de compatibilité pour récupérer l'historique d'un salon -pub async fn fetch_room_history( - hub: &ChatHub, - room_id: Uuid, - user_id: Uuid, - limit: i32, - before_id: Option, -) -> Result> { - channels::fetch_room_history(hub, room_id, user_id, limit as i64, before_id).await -} - -/// Fonction de compatibilité pour récupérer les messages épinglés -pub async fn fetch_pinned_messages( - hub: &ChatHub, - room_id: Uuid, - user_id: Uuid, -) -> Result> { - channels::fetch_pinned_messages(hub, room_id, user_id).await -} - -/// Fonction de compatibilité pour créer un salon -pub async fn create_room( - hub: &ChatHub, - room_name: &str, - creator_id: Uuid, - description: Option<&str>, -) -> Result { - let result = channels::create_room(hub, creator_id, room_name, description, true, None).await?; - Ok(result.id) -} - -/// Fonction de compatibilité pour rejoindre un salon -pub async fn join_room( - hub: &ChatHub, - room_id: Uuid, - user_id: Uuid, -) -> Result<()> { - channels::join_room(hub, room_id, user_id).await -} - -/// Fonction de compatibilité pour quitter un salon -pub async fn leave_room( - hub: &ChatHub, - room_id: Uuid, - user_id: Uuid, -) -> Result<()> { - channels::leave_room(hub, room_id, user_id).await -} - -/// Fonction de compatibilité pour obtenir les statistiques d'un salon -pub async fn get_room_stats( - hub: &ChatHub, - room_id: Uuid, -) -> Result { - channels::get_room_stats(hub, room_id).await -} - -/// Fonction de compatibilité pour lister les membres d'un salon -pub async fn list_room_members( - hub: &ChatHub, - room_id: Uuid, - requesting_user_id: Uuid, -) -> Result> { - channels::list_room_members(hub, room_id, requesting_user_id).await -} \ No newline at end of file diff --git a/veza-chat-server/src/jwt_manager.rs b/veza-chat-server/src/jwt_manager.rs deleted file mode 100644 index 74eb032a0..000000000 --- a/veza-chat-server/src/jwt_manager.rs +++ /dev/null @@ -1,683 +0,0 @@ -//! Gestionnaire JWT avancé avec refresh tokens et rotation -//! -//! Ce module fournit une gestion complète des tokens JWT avec: -//! - Access tokens (courte durée) -//! - Refresh tokens (longue durée) -//! - Rotation automatique des tokens -//! - Blacklist des tokens révoqués -//! - Validation robuste avec métriques - -use crate::config::SecurityConfig; -use crate::error::{ChatError, Result}; -use crate::jwt_revocation_store::{InMemoryRevocationStore, JwtRevocationStore}; -use chrono::{DateTime, Duration, Utc}; -use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; -use serde::{Deserialize, Serialize}; -use sqlx::PgPool; -use std::collections::HashSet; -use std::sync::Arc; -use tokio::sync::RwLock; -use uuid::Uuid; - -/// Claims pour les access tokens -/// MIGRATION UUID: user_id est maintenant String (UUID serialisé) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AccessTokenClaims { - /// ID de l'utilisateur (UUID en string) - #[serde(rename = "sub")] - pub user_id: String, - /// Nom d'utilisateur - pub username: String, - /// Rôle de l'utilisateur - pub role: String, - /// Type de token - pub token_type: String, - /// Audience - #[serde(deserialize_with = "deserialize_audience")] - pub aud: Vec, - /// Issuer - pub iss: String, - /// Expiration - pub exp: usize, - /// Émis à - pub iat: usize, - /// JTI (JWT ID) pour la révocation - pub jti: String, -} - -/// Claims pour les refresh tokens -/// MIGRATION UUID: user_id est maintenant String (UUID serialisé) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RefreshTokenClaims { - /// ID de l'utilisateur (UUID en string) - #[serde(rename = "sub")] - pub user_id: String, - /// Type de token - pub token_type: String, - /// Audience - #[serde(deserialize_with = "deserialize_audience")] - pub aud: Vec, - /// Issuer - pub iss: String, - /// Expiration - pub exp: usize, - /// Émis à - pub iat: usize, - /// JTI (JWT ID) pour la révocation - pub jti: String, - /// Version de la famille de tokens - pub token_family: String, -} - -fn deserialize_audience<'de, D>(deserializer: D) -> std::result::Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - struct AudienceVisitor; - - impl<'de> serde::de::Visitor<'de> for AudienceVisitor { - type Value = Vec; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a string or an array of strings") - } - - fn visit_str(self, v: &str) -> std::result::Result - where - E: serde::de::Error, - { - Ok(vec![v.to_owned()]) - } - - fn visit_seq(self, mut seq: A) -> std::result::Result - where - A: serde::de::SeqAccess<'de>, - { - let mut res = Vec::new(); - while let Some(el) = seq.next_element()? { - res.push(el); - } - Ok(res) - } - } - - deserializer.deserialize_any(AudienceVisitor) -} - -/// Paire de tokens (access + refresh) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TokenPair { - pub access_token: String, - pub refresh_token: String, - pub expires_in: u64, - pub token_type: String, -} - -/// Informations sur un token révoqué -/// MIGRATION UUID: user_id est maintenant String -#[derive(Debug, Clone)] -pub struct RevokedToken { - pub jti: String, - pub user_id: String, // UUID as string - pub revoked_at: DateTime, - pub reason: RevocationReason, -} - -/// Raison de révocation d'un token -#[derive(Debug, Clone)] -pub enum RevocationReason { - UserLogout, - TokenRefresh, - SecurityViolation, - AdminRevocation, - Expired, -} - -/// Gestionnaire JWT avec rotation et blacklist -pub struct JwtManager { - config: SecurityConfig, - encoding_key: EncodingKey, - decoding_key: DecodingKey, - validation: Validation, - /// Store de révocation (in-memory ou Redis) - revocation_store: Arc, - /// Cache des familles de tokens actives - active_token_families: Arc>>, - /// Pool de base de données optionnel pour récupérer les infos utilisateur - db_pool: Option, -} - -impl JwtManager { - /// Parse algorithm, create keys and validation from config. - /// Shared by all constructors to avoid duplication. - fn build_keys_and_validation( - config: &SecurityConfig, - ) -> Result<(EncodingKey, DecodingKey, Validation)> { - let algorithm = match config.jwt_algorithm.as_str() { - "HS256" => Algorithm::HS256, - "HS384" => Algorithm::HS384, - "HS512" => Algorithm::HS512, - "RS256" => Algorithm::RS256, - "RS384" => Algorithm::RS384, - "RS512" => Algorithm::RS512, - _ => return Err(ChatError::configuration_error("Algorithme JWT invalide")), - }; - - let encoding_key = EncodingKey::from_secret(config.jwt_secret.as_bytes()); - let decoding_key = DecodingKey::from_secret(config.jwt_secret.as_bytes()); - - let mut validation = Validation::new(algorithm); - validation.set_audience(&[&config.jwt_audience]); - validation.set_issuer(&[&config.jwt_issuer]); - validation.set_required_spec_claims(&["exp", "iat", "sub", "aud", "iss", "jti"]); - - Ok((encoding_key, decoding_key, validation)) - } - - /// Crée un nouveau gestionnaire JWT - pub fn new(config: SecurityConfig) -> Result { - let (encoding_key, decoding_key, validation) = - Self::build_keys_and_validation(&config)?; - - Ok(Self { - config, - encoding_key, - decoding_key, - validation, - revocation_store: Arc::new(InMemoryRevocationStore::new()), - active_token_families: Arc::new(RwLock::new(HashSet::new())), - db_pool: None, - }) - } - - /// Crée un nouveau gestionnaire JWT avec un pool de base de données - pub fn with_pool(config: SecurityConfig, pool: PgPool) -> Result { - let mut manager = Self::new(config)?; - manager.db_pool = Some(pool); - Ok(manager) - } - - /// Crée un gestionnaire JWT avec un store de révocation personnalisé (ex: Redis) - pub fn with_revocation_store( - config: SecurityConfig, - store: Arc, - ) -> Result { - let (encoding_key, decoding_key, validation) = - Self::build_keys_and_validation(&config)?; - - Ok(Self { - config, - encoding_key, - decoding_key, - validation, - revocation_store: store, - active_token_families: Arc::new(RwLock::new(HashSet::new())), - db_pool: None, - }) - } - - /// Crée un gestionnaire JWT avec pool DB et store de révocation - pub fn with_pool_and_store( - config: SecurityConfig, - pool: PgPool, - store: Arc, - ) -> Result { - let mut manager = Self::with_revocation_store(config, store)?; - manager.db_pool = Some(pool); - Ok(manager) - } - - /// Génère une paire de tokens (access + refresh) - /// MIGRATION UUID: user_id est maintenant String (UUID) - pub async fn generate_token_pair( - &self, - user_id: String, // UUID as string - username: String, - role: String, - ) -> Result { - let now = Utc::now(); - let access_exp = now + Duration::seconds(self.config.jwt_access_duration.as_secs() as i64); - let refresh_exp = - now + Duration::seconds(self.config.jwt_refresh_duration.as_secs() as i64); - - // Générer des JTI uniques - let access_jti = Uuid::new_v4().to_string(); - let refresh_jti = Uuid::new_v4().to_string(); - let token_family = Uuid::new_v4().to_string(); - - // Claims pour access token - let access_claims = AccessTokenClaims { - user_id: user_id.clone(), - username: username.clone(), - role: role.clone(), - token_type: "access".to_string(), - aud: vec![self.config.jwt_audience.clone()], - iss: self.config.jwt_issuer.clone(), - exp: access_exp.timestamp() as usize, - iat: now.timestamp() as usize, - jti: access_jti.clone(), - }; - - // Claims pour refresh token - let refresh_claims = RefreshTokenClaims { - user_id: user_id.clone(), - token_type: "refresh".to_string(), - aud: vec![self.config.jwt_audience.clone()], - iss: self.config.jwt_issuer.clone(), - exp: refresh_exp.timestamp() as usize, - iat: now.timestamp() as usize, - jti: refresh_jti.clone(), - token_family: token_family.clone(), - }; - - // Encoder les tokens - let access_token = - encode(&Header::default(), &access_claims, &self.encoding_key).map_err(|e| { - ChatError::validation_error(&format!("Erreur encodage access token: {e}")) - })?; - - let refresh_token = encode(&Header::default(), &refresh_claims, &self.encoding_key) - .map_err(|e| { - ChatError::validation_error(&format!("Erreur encodage refresh token: {e}")) - })?; - - // Enregistrer la famille de tokens comme active - { - let mut families = self.active_token_families.write().await; - families.insert(token_family); - } - - tracing::info!( - user_id = %user_id, - username = %username, - role = %role, - access_jti = %access_jti, - refresh_jti = %refresh_jti, - "🔐 Paire de tokens générée" - ); - - Ok(TokenPair { - access_token, - refresh_token, - expires_in: self.config.jwt_access_duration.as_secs(), - token_type: "Bearer".to_string(), - }) - } - - /// Valide un access token - pub async fn validate_access_token(&self, token: &str) -> Result { - // Vérifier si le token est dans la blacklist (in-memory ou Redis) - if self.revocation_store.is_revoked(token).await? { - return Err(ChatError::unauthorized("Token révoqué")); - } - - // Décoder et valider le token - let token_data = decode::(token, &self.decoding_key, &self.validation) - .map_err(|e| { - tracing::warn!(error = %e, "❌ Échec validation access token"); - ChatError::unauthorized("Token invalide") - })?; - - let claims = token_data.claims; - - // Vérifier le type de token - if claims.token_type != "access" { - return Err(ChatError::unauthorized("Type de token invalide")); - } - - // Vérifier l'expiration - let now = Utc::now().timestamp() as usize; - if claims.exp < now { - return Err(ChatError::unauthorized("Token expiré")); - } - - // Si un pool DB est disponible, vérifier que l'utilisateur existe encore (pas de fallback) - if let Some(ref pool) = self.db_pool { - let user_uuid = Uuid::parse_str(&claims.user_id).map_err(|e| { - ChatError::validation_error(&format!("Invalid user UUID in token: {}", e)) - })?; - let exists: Option<(bool,)> = sqlx::query_as( - "SELECT true FROM users WHERE id = $1", - ) - .bind(user_uuid) - .fetch_optional(pool) - .await - .map_err(|e| ChatError::from_sqlx_error("validate_access_token_user_exists", e))?; - if exists.is_none() { - tracing::warn!(user_id = %claims.user_id, "User no longer in DB — rejecting connection"); - return Err(ChatError::unauthorized("User no longer exists — connection denied")); - } - } - - tracing::debug!( - user_id = %claims.user_id, - username = %claims.username, - jti = %claims.jti, - "✅ Access token validé" - ); - - Ok(claims) - } - - /// Valide un refresh token et génère une nouvelle paire - pub async fn refresh_tokens(&self, refresh_token: &str) -> Result { - // Vérifier si le token est dans la blacklist - if self.revocation_store.is_revoked(refresh_token).await? { - return Err(ChatError::unauthorized("Refresh token révoqué")); - } - - // Décoder et valider le refresh token - let token_data = - decode::(refresh_token, &self.decoding_key, &self.validation) - .map_err(|e| { - tracing::warn!(error = %e, "❌ Échec validation refresh token"); - ChatError::unauthorized("Refresh token invalide") - })?; - - let claims = token_data.claims; - - // Vérifier le type de token - if claims.token_type != "refresh" { - return Err(ChatError::unauthorized("Type de token invalide")); - } - - // Vérifier l'expiration - let now = Utc::now().timestamp() as usize; - if claims.exp < now { - return Err(ChatError::unauthorized("Refresh token expiré")); - } - - // Vérifier que la famille de tokens est toujours active - { - let families = self.active_token_families.read().await; - if !families.contains(&claims.token_family) { - return Err(ChatError::unauthorized("Famille de tokens révoquée")); - } - } - - // Révocation de l'ancien refresh token - self.revoke_token(refresh_token, RevocationReason::TokenRefresh) - .await?; - - // Récupérer les informations utilisateur depuis la DB - let (username, role) = if let Some(ref pool) = self.db_pool { - // Parser user_id depuis String vers Uuid - let user_uuid = Uuid::parse_str(&claims.user_id).map_err(|e| { - ChatError::validation_error(&format!("Invalid user UUID in token: {}", e)) - })?; - - // Récupérer username et role depuis la DB - let user_info: Option<(String, Option)> = sqlx::query_as( - r#" - SELECT username, role FROM users - WHERE id = $1 - "#, - ) - .bind(user_uuid) - .fetch_optional(pool) - .await - .map_err(|e| ChatError::from_sqlx_error("get_user_info_for_refresh", e))? - .map(|row: (String, Option)| row); - - match user_info { - Some((username, role_opt)) => { - let role = role_opt.unwrap_or_else(|| "user".to_string()); - (username, role) - } - None => { - tracing::error!( - user_id = %claims.user_id, - "User not found in DB during token refresh — rejecting token" - ); - return Err(ChatError::InvalidToken { - reason: "User no longer exists — token refresh denied".to_string(), - }); - } - } - } else { - tracing::error!( - user_id = %claims.user_id, - "No DB pool available for token refresh — rejecting token" - ); - return Err(ChatError::ServiceUnavailable { - service: "database".to_string(), - reason: "Database unavailable — token refresh denied".to_string(), - }); - }; - - // MIGRATION UUID: Cloner user_id avant de le move - let user_id_clone = claims.user_id.clone(); - - // Générer une nouvelle paire de tokens - let new_tokens = self - .generate_token_pair(claims.user_id, username, role) - .await?; - - tracing::info!( - user_id = %user_id_clone, - old_jti = %claims.jti, - "🔄 Tokens rafraîchis" - ); - - Ok(new_tokens) - } - - /// Révoque un token - pub async fn revoke_token(&self, token: &str, reason: RevocationReason) -> Result<()> { - // Extraire le JTI du token (sans validation complète pour la révocation) - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 3 { - return Err(ChatError::validation_error("Format de token invalide")); - } - - // Décoder le payload pour obtenir le JTI - let payload = parts[1]; - let decoded = - base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, payload) - .map_err(|e| { - ChatError::validation_error(&format!("Erreur décodage payload: {e}")) - })?; - - let claims: serde_json::Value = serde_json::from_slice(&decoded) - .map_err(|e| ChatError::validation_error(&format!("Erreur parsing claims: {e}")))?; - - let jti = claims["jti"] - .as_str() - .ok_or_else(|| ChatError::validation_error("JTI manquant"))?; - - let user_id = claims["sub"].as_str().unwrap_or("unknown").to_string(); - - let exp = claims["exp"].as_u64().unwrap_or(0) as usize; - - // Ajouter à la blacklist (in-memory ou Redis) - self.revocation_store.revoke(token, exp).await?; - - // Si c'est un refresh token, révoquer toute la famille - if let Some(token_type) = claims["token_type"].as_str() { - if token_type == "refresh" { - if let Some(family) = claims["token_family"].as_str() { - let mut families = self.active_token_families.write().await; - families.remove(family); - } - } - } - - tracing::info!( - jti = %jti, - user_id = %user_id, - reason = ?reason, - "🚫 Token révoqué" - ); - - Ok(()) - } - - /// Révoque tous les tokens d'un utilisateur - /// MIGRATION UUID: user_id est String - pub async fn revoke_user_tokens(&self, user_id: String) -> Result<()> { - // En production, on devrait maintenir une liste des familles de tokens par utilisateur - // Pour l'instant, on nettoie toutes les familles actives - let mut families = self.active_token_families.write().await; - families.clear(); - - tracing::info!(user_id = %user_id, "🚫 Tous les tokens de l'utilisateur révoqués"); - - Ok(()) - } - - /// Nettoie les tokens expirés de la blacklist - /// Note: Redis gère le TTL automatiquement. In-memory store ne supporte pas le nettoyage fin. - pub async fn cleanup_expired_tokens(&self) -> Result { - // Redis: TTL géré automatiquement, rien à faire - // In-memory: pas de nettoyage granulaire sans itération (coût) - Ok(0) - } - - /// Vérifie si un token est révoqué - pub async fn is_token_revoked(&self, token: &str) -> bool { - self.revocation_store.is_revoked(token).await.unwrap_or(false) - } - - /// Obtient les statistiques des tokens - pub async fn get_token_stats(&self) -> TokenStats { - let active_families = self.active_token_families.read().await.len(); - - TokenStats { - revoked_tokens: 0, // Non disponible pour Redis store - active_token_families: active_families, - } - } -} - -/// Statistiques des tokens -#[derive(Debug, Clone, Serialize)] -pub struct TokenStats { - pub revoked_tokens: usize, - pub active_token_families: usize, -} - -/// Fonction utilitaire pour extraire le token du header Authorization -pub fn extract_token_from_header(auth_header: &str) -> Result<&str> { - if !auth_header.starts_with("Bearer ") { - return Err(ChatError::unauthorized("Format d'autorisation invalide")); - } - - Ok(&auth_header[7..]) // Retirer "Bearer " -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - - fn create_test_config() -> SecurityConfig { - let jwt_secret = std::env::var("TEST_JWT_SECRET") - .unwrap_or_else(|_| format!("test_{}_{}", Uuid::new_v4(), "x".repeat(20))); - SecurityConfig { - jwt_secret, - jwt_access_duration: Duration::from_secs(3600), // 1 heure - jwt_refresh_duration: Duration::from_secs(86400), // 24 heures - jwt_algorithm: "HS256".to_string(), - jwt_audience: "test".to_string(), - jwt_issuer: "test".to_string(), - enable_2fa: false, - totp_window: 1, - content_filtering: false, - password_min_length: 8, - bcrypt_cost: 12, - } - } - - #[tokio::test] - async fn test_generate_and_validate_tokens() { - let config = create_test_config(); - let manager = JwtManager::new(config).unwrap(); - - // UUID de test - let test_user_id = Uuid::new_v4().to_string(); - - // Générer une paire de tokens - let tokens = manager - .generate_token_pair( - test_user_id.clone(), - "testuser".to_string(), - "user".to_string(), - ) - .await - .unwrap(); - - // Valider l'access token - let claims = manager - .validate_access_token(&tokens.access_token) - .await - .unwrap(); - assert_eq!(claims.user_id, test_user_id); - assert_eq!(claims.username, "testuser"); - assert_eq!(claims.role, "user"); - assert_eq!(claims.token_type, "access"); - } - - #[tokio::test] - async fn test_token_revocation() { - let config = create_test_config(); - let manager = JwtManager::new(config).unwrap(); - - let test_user_id = Uuid::new_v4().to_string(); - - // Générer des tokens - let tokens = manager - .generate_token_pair(test_user_id, "testuser".to_string(), "user".to_string()) - .await - .unwrap(); - - // Valider avant révocation - assert!(manager - .validate_access_token(&tokens.access_token) - .await - .is_ok()); - - // Révoquer le token - manager - .revoke_token(&tokens.access_token, RevocationReason::UserLogout) - .await - .unwrap(); - - // Vérifier que le token est révoqué - assert!(manager - .validate_access_token(&tokens.access_token) - .await - .is_err()); - } - - #[tokio::test] - #[ignore = "requires DATABASE_URL with veza_chat schema for token revocation storage"] - async fn test_token_refresh() { - let config = create_test_config(); - let manager = JwtManager::new(config).unwrap(); - - let test_user_id = Uuid::new_v4().to_string(); - - // Générer des tokens - let tokens = manager - .generate_token_pair( - test_user_id.clone(), - "testuser".to_string(), - "user".to_string(), - ) - .await - .unwrap(); - - // Rafraîchir les tokens - let new_tokens = manager.refresh_tokens(&tokens.refresh_token).await.unwrap(); - - // Vérifier que les nouveaux tokens fonctionnent - let claims = manager - .validate_access_token(&new_tokens.access_token) - .await - .unwrap(); - assert_eq!(claims.user_id, test_user_id); - - // Vérifier que l'ancien refresh token est révoqué - assert!(manager.refresh_tokens(&tokens.refresh_token).await.is_err()); - } -} diff --git a/veza-chat-server/src/jwt_revocation_store.rs b/veza-chat-server/src/jwt_revocation_store.rs deleted file mode 100644 index 02b58a521..000000000 --- a/veza-chat-server/src/jwt_revocation_store.rs +++ /dev/null @@ -1,129 +0,0 @@ -//! Store de révocation JWT (in-memory ou Redis) -//! -//! Permet de persister la blacklist des tokens révoqués pour survivre aux redémarrages. - -use crate::error::{ChatError, Result}; -use std::sync::Arc; -use tokio::sync::RwLock; - -/// Trait pour le stockage des tokens révoqués -#[async_trait::async_trait] -pub trait JwtRevocationStore: Send + Sync { - /// Vérifie si un token est révoqué - async fn is_revoked(&self, token: &str) -> Result; - - /// Marque un token comme révoqué - /// `exp` = expiration du token (timestamp Unix) pour TTL Redis - async fn revoke(&self, token: &str, exp: usize) -> Result<()>; -} - -/// Store en mémoire (comportement actuel, fallback si Redis indisponible) -pub struct InMemoryRevocationStore { - revoked: Arc>>, -} - -impl InMemoryRevocationStore { - pub fn new() -> Self { - Self { - revoked: Arc::new(RwLock::new(std::collections::HashSet::new())), - } - } -} - -impl Default for InMemoryRevocationStore { - fn default() -> Self { - Self::new() - } -} - -#[async_trait::async_trait] -impl JwtRevocationStore for InMemoryRevocationStore { - async fn is_revoked(&self, token: &str) -> Result { - let revoked = self.revoked.read().await; - Ok(revoked.contains(token)) - } - - async fn revoke(&self, token: &str, _exp: usize) -> Result<()> { - let mut revoked = self.revoked.write().await; - revoked.insert(token.to_string()); - Ok(()) - } -} - -/// Store Redis (persistant, survit aux redémarrages) -#[cfg(feature = "redis-cache")] -pub struct RedisRevocationStore { - client: redis::Client, - key_prefix: String, -} - -#[cfg(feature = "redis-cache")] -impl RedisRevocationStore { - pub async fn new(redis_url: &str) -> Result { - let client = redis::Client::open(redis_url) - .map_err(|e| ChatError::configuration_error(&format!("Redis connection: {}", e)))?; - - // Test de connexion - let mut conn = client - .get_multiplexed_async_connection() - .await - .map_err(|e| ChatError::configuration_error(&format!("Redis connect: {}", e)))?; - - redis::cmd("PING") - .query_async::(&mut conn) - .await - .map_err(|e| ChatError::configuration_error(&format!("Redis ping: {}", e)))?; - - Ok(Self { - client, - key_prefix: "jwt:revoked:".to_string(), - }) - } - - fn key(&self, token: &str) -> String { - format!("{}{}", self.key_prefix, token) - } -} - -#[cfg(feature = "redis-cache")] -#[async_trait::async_trait] -impl JwtRevocationStore for RedisRevocationStore { - async fn is_revoked(&self, token: &str) -> Result { - let mut conn = self - .client - .get_multiplexed_async_connection() - .await - .map_err(|e| ChatError::internal_error(format!("Redis connection: {}", e)))?; - - let key = self.key(token); - let exists: bool = redis::cmd("EXISTS") - .arg(&key) - .query_async(&mut conn) - .await - .map_err(|e| ChatError::internal_error(format!("Redis EXISTS: {}", e)))?; - - Ok(exists) - } - - async fn revoke(&self, token: &str, exp: usize) -> Result<()> { - let mut conn = self - .client - .get_multiplexed_async_connection() - .await - .map_err(|e| ChatError::internal_error(format!("Redis connection: {}", e)))?; - - let key = self.key(token); - let now = chrono::Utc::now().timestamp() as usize; - let ttl_secs = if exp > now { (exp - now) as i64 } else { 86400 }; // min 1 jour si exp passé - - redis::cmd("SETEX") - .arg(&key) - .arg(ttl_secs.max(60)) // Au moins 1 min - .arg("1") - .query_async::<()>(&mut conn) - .await - .map_err(|e| ChatError::internal_error(format!("Redis SETEX: {}", e)))?; - - Ok(()) - } -} diff --git a/veza-chat-server/src/lib.rs b/veza-chat-server/src/lib.rs deleted file mode 100644 index b58cf7352..000000000 --- a/veza-chat-server/src/lib.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! Chat Server pour Veza -//! -//! Serveur de chat temps réel avec WebSocket - -pub mod config; -pub mod database; -pub mod message_store; -pub mod delivered_status; -pub mod reactions; -pub mod env; -pub mod error; -pub mod event_bus; -pub mod jwt_manager; -pub mod jwt_revocation_store; -pub mod middleware; -pub mod models; -pub mod monitoring; -pub mod permissions; -pub mod read_receipts; -pub mod repository; -pub mod security; -pub mod services; -pub mod simple_message_store; -pub mod typing_indicator; -pub mod utils; -pub mod websocket; // ORIGIN Architecture: Event-driven via RabbitMQ // Metrics and monitoring - -// Ré-exporter types principaux -pub use error::{ChatError, Result}; -pub use repository::{MessageRepository, Room, RoomMember, RoomRepository}; -pub use services::RoomService; -pub use simple_message_store::SimpleMessageStore; -pub use websocket::{IncomingMessage, OutgoingMessage, WebSocketManager}; - -#[cfg(test)] -mod tests { - use super::*; - use error::ChatError; - use simple_message_store::SimpleMessage; - - #[tokio::test] - async fn test_simple_message_store() { - let store = SimpleMessageStore::new(); - - let msg_id = store - .send_simple_message("Test message", "test_user", None, false) - .await - .unwrap(); - - assert!(msg_id > 0); - } -} diff --git a/veza-chat-server/src/main.rs b/veza-chat-server/src/main.rs deleted file mode 100644 index a23a90fc0..000000000 --- a/veza-chat-server/src/main.rs +++ /dev/null @@ -1,566 +0,0 @@ -use axum::{ - extract::{Extension, Query, State, WebSocketUpgrade}, - http::StatusCode, - middleware, - routing::{get, post}, - Json, Router, -}; -use chat_server::{ - config::SecurityConfig, - delivered_status::DeliveredStatusManager, - error::ChatError, - event_bus::RabbitMQEventBus, - jwt_manager::{AccessTokenClaims, JwtManager}, - jwt_revocation_store::{InMemoryRevocationStore, JwtRevocationStore}, - models::message::Message, - monitoring::ChatMetrics, - read_receipts::ReadReceiptManager, - repository::MessageRepository, - security::permission::PermissionService, - services::MessageEditService, - typing_indicator::TypingIndicatorManager, - reactions::ReactionsManager, - websocket::{ - handler::{websocket_handler, WebSocketState}, - OutgoingMessage, WebSocketManager, - }, -}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; -use tokio::net::TcpListener; -use tracing::{info, warn}; -use uuid::Uuid; - -/// État global de l'application -#[derive(Clone)] -struct AppState { - message_repo: Arc, - _ws_manager: Arc, - database_pool: Option, - event_bus: Option>, - config: chat_server::config::Config, - jwt_manager: Arc, - metrics: Arc, - permission_service: Arc, -} - -/// Requête d'envoi de message -#[derive(Deserialize)] -struct SendMessageRequest { - conversation_id: Uuid, - content: String, -} - -/// Paramètres de récupération de messages -#[derive(Deserialize)] -#[allow(dead_code)] -struct GetMessagesQuery { - conversation_id: Uuid, - limit: Option, -} - -/// Réponse API standard -#[derive(Serialize)] -struct ApiResponse { - success: bool, - data: T, - message: Option, -} - -impl ApiResponse { - fn success(data: T) -> Self { - Self { - success: true, - data, - message: None, - } - } -} - -use metrics_exporter_prometheus::PrometheusBuilder; - -/// Construit le store de révocation JWT (Redis si REDIS_URL défini, sinon in-memory) -async fn build_revocation_store() -> Arc { - #[cfg(feature = "redis-cache")] - { - if let Some(redis_url) = std::env::var("REDIS_URL").ok().filter(|u| !u.is_empty()) { - match chat_server::jwt_revocation_store::RedisRevocationStore::new(&redis_url).await { - Ok(store) => { - tracing::info!("✅ JWT revocation store: Redis (persistant)"); - return Arc::new(store); - } - Err(e) => { - tracing::warn!( - "⚠️ Redis non disponible pour revocation JWT: {}. Fallback in-memory.", - e - ); - } - } - } - } - Arc::new(InMemoryRevocationStore::new()) -} - -#[tokio::main] -async fn main() -> Result<(), ChatError> { - // FIX #12, #24: Utiliser veza-common::logging pour configuration unifiée - // FIX #24: LOG_LEVEL est maintenant lu automatiquement par veza-common::logging - let is_prod = std::env::var("APP_ENV").unwrap_or_default() == "production"; - - // Configuration des fichiers de logs vers /var/log/veza/ - let log_dir = std::env::var("LOG_DIR").unwrap_or_else(|_| "/var/log/veza".to_string()); - let log_file = format!("{}/chat.log", log_dir); - - let log_config = veza_common::logging::LoggingConfig { - // FIX #24: Laisser veza-common::logging normaliser LOG_LEVEL automatiquement - // Si LOG_LEVEL n'est pas défini, veza-common utilisera "INFO" par défaut - level: String::new(), // Vide = utiliser LOG_LEVEL ou RUST_LOG automatiquement - format: if is_prod { "json".to_string() } else { "text".to_string() }, - file: Some(log_file), - max_size: 100 * 1024 * 1024, // 100MB - max_files: 5, - compress: true, - }; - - veza_common::logging::init_with_config(log_config) - .map_err(|e| ChatError::configuration_error(&format!("Failed to initialize logging: {}", e)))?; - - // Initialisation des métriques Prometheus - let builder = PrometheusBuilder::new(); - let prometheus_handle = builder.install_recorder().map_err(|e| { - ChatError::configuration_error(&format!("Failed to install Prometheus recorder: {}", e)) - })?; - - info!("🚀 Démarrage du serveur de chat Veza..."); - - let app_config = - chat_server::config::Config::from_env().map_err(|e| ChatError::Configuration { - message: e.to_string(), - })?; - - // Initialisation du pool de connexions à la base de données - let database_pool = match chat_server::database::pool::create_pool(&app_config.database_url).await { - Ok(pool) => { - info!("✅ Pool de connexions PostgreSQL initialisé avec succès"); - Some(pool) - } - Err(e) => { - warn!("⚠️ Échec d'initialisation du pool de connexions: {}. Le serveur continuera sans base de données.", e); - None - } - }; - - // Database pool est requis pour les managers - let pool_ref = database_pool.as_ref().ok_or_else(|| { - ChatError::configuration_error("Database pool is required but not initialized") - })?; - let message_repo = Arc::new(MessageRepository::new(pool_ref.clone())); - let read_receipt_manager = Arc::new(ReadReceiptManager::new(pool_ref.clone())); - let delivered_status_manager = Arc::new(DeliveredStatusManager::new(pool_ref.clone())); - let typing_indicator_manager = Arc::new(TypingIndicatorManager::new()); - let permission_service = Arc::new(PermissionService::new(pool_ref.clone())); - let message_edit_service = Arc::new(MessageEditService::new(pool_ref.clone())); - let reactions_manager = Arc::new(ReactionsManager::new(pool_ref.clone())); - - // Metrics - let metrics = Arc::new(ChatMetrics::new()); - - // Initialisation de l'Event Bus RabbitMQ - let event_bus = match RabbitMQEventBus::new_with_retry(app_config.rabbit_mq.clone()).await { - Ok(eb) => { - info!("✅ Event Bus RabbitMQ initialisé avec succès"); - Some(eb) - } - Err(e) => { - warn!("⚠️ Échec d'initialisation de l'Event Bus RabbitMQ: {}. Le serveur démarrera en mode dégradé (sans Event Bus).", e); - None - } - }; - - // Initialisation du gestionnaire WebSocket - let ws_manager = Arc::new(WebSocketManager::new()); - - // Initialisation du gestionnaire JWT - let jwt_secret = chat_server::env::require_env_min_length("JWT_SECRET", 32); - - let security_config = SecurityConfig { - jwt_secret, - jwt_access_duration: Duration::from_secs(900), // 15 min - jwt_refresh_duration: Duration::from_secs(86400 * 30), // 30 days - jwt_algorithm: "HS256".to_string(), - jwt_audience: "veza-chat".to_string(), - jwt_issuer: "veza-backend".to_string(), - enable_2fa: false, - totp_window: 1, - content_filtering: false, - password_min_length: 8, - bcrypt_cost: 12, - }; - - // Créer le store de révocation (Redis si REDIS_URL défini, sinon in-memory) - let revocation_store = build_revocation_store().await; - - // Créer JwtManager avec pool DB et store de révocation - let jwt_manager = Arc::new(if let Some(ref pool) = database_pool { - JwtManager::with_pool_and_store( - security_config, - pool.clone(), - revocation_store, - ) - .map_err(|e| ChatError::configuration_error(&format!("JWT Manager error: {}", e)))? - } else { - JwtManager::with_revocation_store(security_config, revocation_store) - .map_err(|e| ChatError::configuration_error(&format!("JWT Manager error: {}", e)))? - }); - - // Définir l'adresse d'écoute - let bind_addr = format!("{}:{}", app_config.host, app_config.port); - - // État pour les routes HTTP (AppState reste pour compatibilité) - let state = AppState { - message_repo: message_repo.clone(), - _ws_manager: ws_manager.clone(), - database_pool: database_pool.clone(), - event_bus: event_bus.map(Arc::new), - config: app_config.clone(), - jwt_manager: jwt_manager.clone(), - metrics: metrics.clone(), - permission_service: permission_service.clone(), - }; - - // Rate limiter (Redis-backed with in-memory fallback) - let rate_limiter = Arc::new(chat_server::security::RateLimiter::new( - std::env::var("REDIS_URL").ok().as_deref(), - )); - - let keepalive_timeout_secs: u64 = std::env::var("CHAT_KEEPALIVE_TIMEOUT_SECS") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(60); - - // État pour le handler WebSocket - let ws_state = WebSocketState { - keepalive_timeout_secs, - message_repo: message_repo.clone(), - read_receipt_manager: read_receipt_manager.clone(), - delivered_status_manager: delivered_status_manager.clone(), - typing_indicator_manager: typing_indicator_manager.clone(), - message_edit_service: message_edit_service.clone(), - reactions_manager: reactions_manager.clone(), - ws_manager: ws_manager.clone(), - jwt_manager: jwt_manager.clone(), - permission_service: permission_service.clone(), - metrics: metrics.clone(), - rate_limiter, - }; - - // Démarrer le task de monitoring des typing indicators - let typing_manager_monitor = typing_indicator_manager.clone(); - let ws_manager_monitor = ws_manager.clone(); - tokio::spawn(async move { - let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(500)); - loop { - interval.tick().await; - - let expired_changes = typing_manager_monitor.monitor_timeouts().await; - - for change in expired_changes { - let typing_message = OutgoingMessage::UserTyping { - conversation_id: change.conversation_id, - user_id: change.user_id, - is_typing: false, - }; - - if let Err(e) = ws_manager_monitor - .broadcast_to_conversation(change.conversation_id, typing_message) - .await - { - warn!( - conversation_id = %change.conversation_id, - user_id = %change.user_id, - error = %e, - "Erreur lors du broadcast de typing timeout" - ); - } - } - } - }); - - info!("✅ Task de monitoring des typing indicators démarré"); - - // FIX #23: Importer le middleware de request_id - use chat_server::middleware::request_id_middleware; - - // Configuration des routes - let app = Router::new() - .route("/health", get(health_check)) - .route("/healthz", get(health_check)) - .route("/readyz", get(readiness_check)) - .route( - "/metrics", - get(move || std::future::ready(prometheus_handle.render())), - ) - .route("/api/messages/stats", get(get_stats)) - // FIX #23: Appliquer le middleware de request_id globalement - .layer(middleware::from_fn(request_id_middleware)); - - let api_routes = Router::new() - .route("/api/messages/{conversation_id}", get(get_messages)) - .route("/api/messages", post(send_message)) - .route_layer(middleware::from_fn_with_state( - state.clone(), - auth_middleware, - )); - - let app = app - .merge(api_routes) - .route( - "/ws", - get({ - let ws_state_clone = ws_state.clone(); - move |ws: WebSocketUpgrade, - query: Query>, - headers: axum::http::HeaderMap, - request_id: Option>| - async move { - websocket_handler(ws, query, headers, State(ws_state_clone), request_id).await - } - }), - ) - .with_state(state); - - // Démarrage du serveur - let listener = TcpListener::bind(&bind_addr) - .await - .map_err(|e| ChatError::configuration_error(&format!("Bind error on {bind_addr}: {e}")))?; - - info!("✅ Serveur démarré sur http://{}", bind_addr); - info!("📊 Endpoints disponibles:"); - info!(" - GET /health - Vérification de santé"); - info!(" - GET /api/messages/:conversation_id - Récupération des messages"); - info!(" - POST /api/messages - Envoi de message"); - info!(" - GET /api/messages/stats - Statistiques"); - info!(" - GET /ws - WebSocket Chat (🆕)"); - - axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal()) - .await - .map_err(|e| ChatError::configuration_error(&format!("Server error: {e}")))?; - - Ok(()) -} - -/// Endpoint de readiness (DB check) -async fn readiness_check( - State(state): State, -) -> Result>>, StatusCode> { - let mut info = HashMap::new(); - - // Check Database - if let Some(pool) = &state.database_pool { - if let Err(e) = sqlx::query("SELECT 1").execute(pool).await { - warn!("Readiness check failed (DB): {}", e); - return Err(StatusCode::SERVICE_UNAVAILABLE); - } - } else { - warn!("Readiness check failed (No DB pool)"); - return Err(StatusCode::SERVICE_UNAVAILABLE); - } - - // Check RabbitMQ Event Bus - if state.config.rabbit_mq.enable { - if let Some(ref event_bus) = state.event_bus { - if !event_bus.is_enabled { - warn!("Readiness check failed (RabbitMQ EventBus not enabled)"); - return Err(StatusCode::SERVICE_UNAVAILABLE); - } - } else { - warn!( - "Readiness check failed (RabbitMQ EventBus not initialized but enabled in config)" - ); - return Err(StatusCode::SERVICE_UNAVAILABLE); - } - } - - info.insert("status".to_string(), "ready".to_string()); - Ok(Json(ApiResponse::success(info))) -} - -/// Endpoint de vérification de santé -#[tracing::instrument(skip(state))] -async fn health_check(State(state): State) -> Json>> { - let mut info = HashMap::new(); - info.insert("status".to_string(), "healthy".to_string()); - info.insert("service".to_string(), "veza-chat-server".to_string()); - info.insert("version".to_string(), "0.3.0".to_string()); - info.insert("websocket".to_string(), "enabled".to_string()); - - if let Some(pool) = &state.database_pool { - match sqlx::query("SELECT 1").execute(pool).await { - Ok(_) => { - info.insert("database".to_string(), "connected".to_string()); - } - Err(e) => { - info.insert("database".to_string(), format!("error: {}", e)); - } - } - } else { - info.insert("database".to_string(), "not_configured".to_string()); - } - - if let Some(event_bus) = &state.event_bus { - if event_bus.is_enabled { - info.insert("rabbitmq".to_string(), "connected".to_string()); - } else { - info.insert("rabbitmq".to_string(), "disabled".to_string()); - } - } else { - if state.config.rabbit_mq.enable { - info.insert("rabbitmq".to_string(), "disconnected".to_string()); - } else { - info.insert("rabbitmq".to_string(), "not_configured".to_string()); - } - } - - Json(ApiResponse::success(info)) -} - -/// Récupération des messages -#[tracing::instrument(skip(state, params))] -async fn get_messages( - State(state): State, - Extension(claims): Extension, - axum::extract::Path(conversation_id): axum::extract::Path, - Query(params): Query, -) -> Result>>, StatusCode> { - let user_uuid = Uuid::parse_str(&claims.user_id).map_err(|_| StatusCode::UNAUTHORIZED)?; - - state - .permission_service - .can_read_conversation(user_uuid, conversation_id) - .await - .map_err(|_| StatusCode::FORBIDDEN)?; - - let limit = params.limit.unwrap_or(50).min(100); - - let messages = state - .message_repo - .get_conversation_messages(conversation_id, limit) - .await - .map_err(|e| { - warn!("Erreur récupération messages conversation: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - Ok(Json(ApiResponse::success(messages))) -} - -/// Envoi de message -#[tracing::instrument(skip(state, payload))] -async fn send_message( - State(state): State, - Extension(claims): Extension, - Json(payload): Json, -) -> Result>, StatusCode> { - let user_uuid = Uuid::parse_str(&claims.user_id).map_err(|_| StatusCode::UNAUTHORIZED)?; - - state - .permission_service - .can_send_message(user_uuid, payload.conversation_id) - .await - .map_err(|_| StatusCode::FORBIDDEN)?; - - let message = state - .message_repo - .create(payload.conversation_id, user_uuid, &payload.content) - .await - .map_err(|e| { - warn!("Erreur envoi message: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - info!( - "✅ Message envoyé - ID: {:?}, sender: {:?}", - message.id, message.sender_id - ); - - Ok(Json(ApiResponse::success(message.id))) -} - -/// Statistiques avec métriques réelles (Memory/CPU) -#[tracing::instrument(skip(state))] -async fn get_stats( - State(state): State, -) -> Json>> { - let mut stats = HashMap::new(); - - // Récupérer les métriques système via metrics - let (memory_mb, cpu) = state.metrics.get_system_metrics().await; - - stats.insert("active_users".to_string(), serde_json::json!(0)); // Placeholder for active users - stats.insert("server_memory_mb".to_string(), serde_json::json!(memory_mb)); - stats.insert("server_cpu_percent".to_string(), serde_json::json!(cpu)); - stats.insert("websocket_enabled".to_string(), serde_json::json!(true)); - - Json(ApiResponse::success(stats)) -} - -/// Middleware d'authentification -async fn auth_middleware( - State(state): State, - mut req: axum::extract::Request, - next: axum::middleware::Next, -) -> Result { - let auth_header = req - .headers() - .get(axum::http::header::AUTHORIZATION) - .and_then(|header| header.to_str().ok()); - - let auth_header = if let Some(auth_header) = auth_header { - auth_header - } else { - return Err(StatusCode::UNAUTHORIZED); - }; - - if !auth_header.starts_with("Bearer ") { - return Err(StatusCode::UNAUTHORIZED); - } - - let token = &auth_header[7..]; - - match state.jwt_manager.validate_access_token(token).await { - Ok(claims) => { - req.extensions_mut().insert(claims); - Ok(next.run(req).await) - } - Err(_) => Err(StatusCode::UNAUTHORIZED), - } -} - -async fn shutdown_signal() { - let ctrl_c = async { - tokio::signal::ctrl_c() - .await - .expect("failed to install Ctrl+C handler"); - }; - - #[cfg(unix)] - let terminate = async { - tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .expect("failed to install signal handler") - .recv() - .await; - }; - - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - - tokio::select! { - _ = ctrl_c => {}, - _ = terminate => {}, - } - - info!("🛑 Signal d'arrêt reçu, fermeture gracieuse..."); -} diff --git a/veza-chat-server/src/message_handler.rs b/veza-chat-server/src/message_handler.rs deleted file mode 100644 index 6ba602400..000000000 --- a/veza-chat-server/src/message_handler.rs +++ /dev/null @@ -1,328 +0,0 @@ -//! Module de gestion des messages avec filtrage de contenu et sécurité -//! -//! Ce module fournit une couche de haut niveau pour traiter les messages entrants, -//! appliquer les filtres de sécurité et déléguer aux modules métier appropriés. - -use std::sync::Arc; -use tracing::{info, warn}; -use uuid::Uuid; -use crate::error::{ChatError, Result}; -use crate::hub::common::ChatHub; -use crate::permissions::Role; -use crate::security::{EnhancedSecurity, SecurityAction, ContentFilter}; -use crate::security::permission::PermissionService; - -/// Gestionnaire centralisé pour tous les types de messages -pub struct MessageHandler { - security: EnhancedSecurity, - content_filter: ContentFilter, - hub: Arc, - permission_service: Arc, -} - -impl MessageHandler { - pub fn new(hub: Arc, permission_service: Arc) -> Result { - Ok(Self { - security: EnhancedSecurity::new()?, - content_filter: ContentFilter::new()?, - hub, - permission_service, - }) - } - - /// Gère les messages de salon avec permissions - pub async fn handle_room_message( - &mut self, - user_id: Uuid, - username: &str, - room: &str, - content: &str, - session_token: &str, - user_ip: &str, - parent_id: Option, - ) -> Result<()> { - // Validation de sécurité - self.security.validate_request( - user_id, - user_ip, - session_token, - &SecurityAction::SendMessage, - Some(content) - ).await?; - - // Filtrage du contenu - let clean_room = self.content_filter.validate_content(room)?; - let clean_content = self.content_filter.validate_content(content)?; - - info!( - user_id = %user_id, - username = %username, - room = %clean_room, - content_length = %clean_content.len(), - "📝 Message de salon filtré et validé" - ); - - // Délégation à la logique métier - let room_id = self.get_room_id_by_name(&clean_room).await?; - - // Vérifier les permissions avant d'envoyer le message - self.permission_service - .can_send_message(user_id, room_id) - .await - .map_err(|e| { - warn!( - user_id = %user_id, - room_id = %room_id, - error = %e, - "Permission refusée pour l'envoi de message dans le salon" - ); - e - })?; - - crate::hub::channels::send_room_message(&self.hub, room_id, user_id, username, &clean_content, parent_id, None).await?; - Ok(()) - } - - /// Gère les messages directs avec permissions - pub async fn handle_direct_message( - &mut self, - from_user: Uuid, - from_username: &str, - to_user: Uuid, - content: &str, - session_token: &str, - user_ip: &str, - parent_id: Option, - ) -> Result<()> { - // Validation de sécurité - self.security.validate_request( - from_user, - user_ip, - session_token, - &SecurityAction::SendDM, - Some(content) - ).await?; - - // Filtrage du contenu - let clean_content = self.content_filter.validate_content(content)?; - - info!( - from_user = %from_user, - from_username = %from_username, - to_user = %to_user, - content_length = %clean_content.len(), - "💬 Message direct filtré et validé" - ); - - // Délégation à la logique métier - let conversation_id = self.get_or_create_conversation(from_user, to_user).await?; - - // Vérifier les permissions avant d'envoyer le message - self.permission_service - .can_send_message(from_user, conversation_id) - .await - .map_err(|e| { - warn!( - from_user = %from_user, - conversation_id = %conversation_id, - error = %e, - "Permission refusée pour l'envoi de message direct" - ); - e - })?; - - crate::hub::direct_messages::send_dm_message(&self.hub, conversation_id, from_user, from_username, &clean_content, parent_id, None).await?; - Ok(()) - } - - /// Gère la jointure d'un salon avec permissions - pub async fn handle_join_room( - &mut self, - user_id: Uuid, - username: &str, - room: &str, - session_token: &str, - user_ip: &str, - ) -> Result<()> { - // Validation de sécurité - self.security.validate_request( - user_id, - user_ip, - session_token, - &SecurityAction::JoinRoom, - None - ).await?; - - // Validation du nom de salon - let clean_room = self.content_filter.validate_content(room)?; - - // Vérification que le salon existe ou peut être créé - // Pour l'instant, on suppose que le salon existe - let room_exists = true; - - if !room_exists { - return Err(ChatError::not_found("Salon", &clean_room)); - } - - info!( - user_id = %user_id, - username = %username, - room = %clean_room, - "🚪 Jointure de salon validée" - ); - - // Délégation à la logique métier - let room_id = self.get_room_id_by_name(&clean_room).await?; - crate::hub::channels::join_room(&self.hub, room_id, user_id).await?; - - // Envoi de confirmation - Ok(()) - } - - /// Gère la récupération d'historique avec permissions - pub async fn handle_room_history( - &mut self, - user_id: Uuid, - user_role: &Role, - room: &str, - limit: Option, - session_token: &str, - user_ip: &str, - ) -> Result> { - // Validation de sécurité pour la lecture - self.security.validate_request( - user_id, - user_ip, - session_token, - &SecurityAction::SendMessage, // Approximation - None - ).await?; - - // Validation du nom de salon - let clean_room = self.content_filter.validate_content(room)?; - let limit = limit.unwrap_or(50).min(100); // Limiter à 100 messages max - - // Vérification des permissions de lecture - if !self.can_read_room_history(user_id, user_role, &clean_room).await? { - return Err(ChatError::unauthorized("Lecture de l'historique du salon")); - } - - // Délégation à la logique métier - let room_id = self.get_room_id_by_name(&clean_room).await?; - let messages = crate::hub::channels::fetch_room_history(&self.hub, room_id, user_id, limit as i64, None).await?; - - // Envoi de la réponse - info!( - user_id = %user_id, - room = %clean_room, - message_count = %messages.len(), - "📚 Historique de salon récupéré" - ); - - Ok(messages) - } - - /// Gère la récupération d'historique DM avec permissions - pub async fn handle_dm_history( - &mut self, - user_id: Uuid, - with_user: Uuid, - limit: Option, - session_token: &str, - user_ip: &str, - ) -> Result> { - // Validation de sécurité - self.security.validate_request( - user_id, - user_ip, - session_token, - &SecurityAction::SendDM, - None - ).await?; - - let limit = limit.unwrap_or(50).min(100); - - // Vérification que l'utilisateur peut lire cette conversation - if !self.can_read_dm_conversation(user_id, with_user).await? { - return Err(ChatError::unauthorized("Lecture de conversation privée")); - } - - // Délégation à la logique métier - let conversation_id = self.get_or_create_conversation(user_id, with_user).await?; - let messages = crate::hub::direct_messages::fetch_history(&self.hub, conversation_id, user_id, limit as i64, None).await?; - - // Envoi de la réponse - info!( - user_id = %user_id, - with_user = %with_user, - message_count = %messages.len(), - "💬 Historique DM récupéré" - ); - - Ok(messages) - } - - /// Vérifie si un utilisateur peut lire l'historique d'un salon - async fn can_read_room_history(&self, user_id: Uuid, user_role: &Role, room: &str) -> Result { - // Logique simple : les admins et modérateurs peuvent tout lire - match user_role { - Role::Admin | Role::Moderator | Role::SuperAdmin => Ok(true), - Role::User => { - // Les utilisateurs normaux peuvent lire les salons dont ils sont membres - let room_id = self.get_room_id_by_name(room).await?; - self.permission_service - .can_read_conversation(user_id, room_id) - .await - .map(|_| true) - .or_else(|e| { - warn!( - user_id = %user_id, - room = %room, - error = %e, - "Permission refusée pour la lecture de l'historique" - ); - Ok(false) - }) - } - _ => Ok(false), - } - } - - /// Vérifie si un utilisateur peut lire une conversation DM - async fn can_read_dm_conversation(&self, user_id: Uuid, with_user: Uuid) -> Result { - // Un utilisateur peut lire ses propres conversations - if user_id == with_user { - return Ok(true); - } - - // Récupérer ou créer la conversation entre les deux utilisateurs - let conversation_id = self.get_or_create_conversation(user_id, with_user).await?; - - // Vérifier les permissions - self.permission_service - .can_read_conversation(user_id, conversation_id) - .await - .map(|_| true) - .or_else(|e| { - warn!( - user_id = %user_id, - with_user = %with_user, - conversation_id = %conversation_id, - error = %e, - "Permission refusée pour la lecture de la conversation DM" - ); - Ok(false) - }) - } - - /// Récupère ou crée une conversation entre deux utilisateurs - async fn get_or_create_conversation(&self, user1_id: Uuid, user2_id: Uuid) -> Result { - let conversation = crate::hub::direct_messages::get_or_create_dm_conversation(&self.hub, user1_id, user2_id).await?; - Ok(conversation.id) - } - - /// Récupère l'ID d'un salon par son nom - async fn get_room_id_by_name(&self, room_name: &str) -> Result { - crate::hub::channels::get_room_id_by_name(&self.hub, room_name).await - } -} \ No newline at end of file diff --git a/veza-chat-server/src/message_store.rs b/veza-chat-server/src/message_store.rs deleted file mode 100644 index 038c657bf..000000000 --- a/veza-chat-server/src/message_store.rs +++ /dev/null @@ -1,288 +0,0 @@ -use sqlx::{PgPool, Row}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use tokio::sync::RwLock; -use tokio::time::{Duration, Instant}; -use tracing::{debug, info}; -use uuid::Uuid; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Message { - pub id: Uuid, - pub conversation_id: Uuid, - pub user_id: Uuid, - pub content: String, - pub message_type: String, // "text", "audio", "image", etc. - pub metadata: Option, - pub created_at: chrono::DateTime, - pub updated_at: chrono::DateTime, -} - -#[derive(Debug, Clone)] -pub struct PendingMessage { - pub conversation_id: Uuid, - pub user_id: Uuid, - pub content: String, - pub message_type: String, - pub metadata: Option, - pub created_at: chrono::DateTime, -} - -#[derive(Debug)] -pub struct MessageStore { - pool: PgPool, - batch_buffer: Arc>>, - batch_size: usize, - flush_interval: Duration, - last_flush: Arc>, -} - -impl MessageStore { - pub fn new(pool: PgPool) -> Self { - Self { - pool, - batch_buffer: Arc::new(RwLock::new(Vec::new())), - batch_size: 50, // Flush après 50 messages - flush_interval: Duration::from_millis(100), // Ou après 100ms - last_flush: Arc::new(RwLock::new(Instant::now())), - } - } - - /// Ajoute un message au buffer et flush si nécessaire - pub async fn add_message(&self, message: PendingMessage) -> Result<(), sqlx::Error> { - let mut buffer = self.batch_buffer.write().await; - buffer.push(message); - - // Vérifier si on doit flush - let should_flush = buffer.len() >= self.batch_size; - let time_since_last_flush = self.last_flush.read().await.elapsed(); - - if should_flush || time_since_last_flush >= self.flush_interval { - drop(buffer); // Libérer le lock avant le flush - self.flush_buffer().await?; - } - - Ok(()) - } - - /// Flush le buffer vers la base de données - async fn flush_buffer(&self) -> Result<(), sqlx::Error> { - let mut buffer = self.batch_buffer.write().await; - if buffer.is_empty() { - return Ok(()); - } - - let messages = buffer.clone(); - buffer.clear(); - drop(buffer); - - // Mettre à jour le timestamp du dernier flush - *self.last_flush.write().await = Instant::now(); - - // Utiliser COPY pour un insert bulk optimisé - self.bulk_insert_messages(messages).await - } - - /// Insert bulk optimisé avec COPY - async fn bulk_insert_messages(&self, messages: Vec) -> Result<(), sqlx::Error> { - if messages.is_empty() { - return Ok(()); - } - - debug!("Bulk inserting {} messages", messages.len()); - - // Utiliser une transaction pour l'atomicité - let mut tx = self.pool.begin().await?; - - // Construire la requête COPY - let mut copy_data = String::new(); - for msg in &messages { - copy_data.push_str(&format!( - "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n", - Uuid::new_v4(), // id - msg.conversation_id, - msg.user_id, - msg.content - .replace('\\', "\\\\") - .replace('\t', "\\t") - .replace('\n', "\\n"), - msg.message_type - .replace('\\', "\\\\") - .replace('\t', "\\t") - .replace('\n', "\\n"), - msg.metadata - .as_ref() - .map(|m| { - m.to_string() - .replace('\\', "\\\\") - .replace('\t', "\\t") - .replace('\n', "\\n") - }) - .unwrap_or_else(|| "\\N".to_string()), - msg.created_at.format("%Y-%m-%d %H:%M:%S%.f %z"), - msg.created_at.format("%Y-%m-%d %H:%M:%S%.f %z"), // updated_at - )); - } - - // Exécuter le COPY - sqlx::query( - "COPY messages (id, conversation_id, user_id, content, message_type, metadata, created_at, updated_at) FROM STDIN WITH (FORMAT text)", - ) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - info!("Successfully bulk inserted {} messages", messages.len()); - Ok(()) - } - - /// Récupère les messages d'une conversation avec pagination - pub async fn get_messages( - &self, - conversation_id: Uuid, - limit: i64, - offset: i64, - ) -> Result, sqlx::Error> { - let rows = sqlx::query( - r#" - SELECT id, conversation_id, user_id, content, message_type, metadata, created_at, updated_at - FROM messages - WHERE conversation_id = $1 - ORDER BY created_at DESC - LIMIT $2 OFFSET $3 - "#, - ) - .bind(conversation_id) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await?; - - let mut messages = Vec::new(); - for row in rows { - let message = Message { - id: row.get("id"), - conversation_id: row.get("conversation_id"), - user_id: row.get("user_id"), - content: row.get("content"), - message_type: row.get("message_type"), - metadata: row.get("metadata"), - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - }; - messages.push(message); - } - - Ok(messages) - } - - /// Récupère les messages récents d'une conversation (pour le cache) - pub async fn get_recent_messages( - &self, - conversation_id: Uuid, - limit: i64, - ) -> Result, sqlx::Error> { - self.get_messages(conversation_id, limit, 0).await - } - - /// Supprime les messages anciens (nettoyage) - pub async fn cleanup_old_messages(&self, older_than_days: i32) -> Result { - // Rejeter les valeurs invalides (évite DoS et edge cases) - if !(1..=3650).contains(&older_than_days) { - return Err(sqlx::Error::Configuration( - "older_than_days must be between 1 and 3650".into(), - )); - } - - let result = sqlx::query( - "DELETE FROM messages WHERE created_at < NOW() - make_interval(days => $1)", - ) - .bind(older_than_days) - .execute(&self.pool) - .await?; - - let deleted_count = result.rows_affected(); - info!("Cleaned up {} old messages", deleted_count); - Ok(deleted_count) - } - - /// Force le flush du buffer (utile pour les tests ou l'arrêt) - pub async fn force_flush(&self) -> Result<(), sqlx::Error> { - self.flush_buffer().await - } - - /// Obtient les statistiques du buffer - pub async fn get_buffer_stats(&self) -> (usize, Duration) { - let buffer_len = self.batch_buffer.read().await.len(); - let time_since_last_flush = self.last_flush.read().await.elapsed(); - (buffer_len, time_since_last_flush) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::Utc; - - /// Vérifie que cleanup_old_messages rejette les valeurs hors bornes (validation anti-injection) - #[tokio::test] - async fn test_cleanup_old_messages_validation_bounds() { - let pool = match PgPool::connect("postgresql://test:test@localhost/test").await { - Ok(p) => p, - Err(_) => return, // Skip si pas de DB - }; - let store = MessageStore::new(pool); - - // Valeurs invalides doivent retourner Err - assert!(store.cleanup_old_messages(0).await.is_err()); - assert!(store.cleanup_old_messages(-1).await.is_err()); - assert!(store.cleanup_old_messages(3651).await.is_err()); - assert!(store.cleanup_old_messages(99999).await.is_err()); - - // Valeur valide doit passer (ou Err si autre problème DB) - let result = store.cleanup_old_messages(30).await; - assert!(result.is_ok() || matches!(result, Err(_))); - } - - /// Vérifie que la requête utilise des paramètres préparés (pas d'injection SQL) - #[tokio::test] - #[ignore] // Nécessite DB - exécuter avec: cargo test --ignored test_cleanup_old_messages_sql_injection_resistant - async fn test_cleanup_old_messages_sql_injection_resistant() { - let pool = PgPool::connect("postgresql://test:test@localhost/test") - .await - .expect("DB required for integration test"); - let store = MessageStore::new(pool); - - // Payload valide - doit être traité comme entier, pas d'injection - let result = store.cleanup_old_messages(30).await; - assert!(result.is_ok() || matches!(result, Err(_))); - } - - #[tokio::test] - #[ignore] // Nécessite DB - exécuter avec: cargo test --ignored test_message_store_batch_insert - async fn test_message_store_batch_insert() { - // Test avec une base de données en mémoire ou mock - // Vérifier que les messages sont bien batchés - let pool = PgPool::connect("postgresql://test:test@localhost/test").await.unwrap(); - let store = MessageStore::new(pool); - - let message = PendingMessage { - conversation_id: Uuid::new_v4(), - user_id: Uuid::new_v4(), - content: "Test message".to_string(), - message_type: "text".to_string(), - metadata: None, - created_at: Utc::now(), - }; - - // Ajouter plusieurs messages - for _ in 0..60 { - store.add_message(message.clone()).await.unwrap(); - } - - // Vérifier que le buffer a été flushé - let (buffer_len, _) = store.get_buffer_stats().await; - assert!(buffer_len < 50); // Buffer devrait être flushé - } -} \ No newline at end of file diff --git a/veza-chat-server/src/message_store_simple.rs b/veza-chat-server/src/message_store_simple.rs deleted file mode 100644 index eb6e6a247..000000000 --- a/veza-chat-server/src/message_store_simple.rs +++ /dev/null @@ -1,329 +0,0 @@ -use std::collections::HashMap; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::{PgPool, FromRow}; -use crate::error::{ChatError, Result}; - -// Modèle de message simplifié correspondant exactement au schéma unifié -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -pub struct SimpleMessage { - pub id: i32, - pub author_id: Option, - pub author_username: Option, - pub recipient_id: Option, - pub recipient_username: Option, - pub room: Option, - pub room_id: Option, - pub content: String, - pub created_at: Option, - pub message_type: Option, - pub is_pinned: Option, - pub is_edited: Option, - pub original_content: Option, - pub status: Option, - pub parent_message_id: Option, - pub updated_at: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageStats { - pub total_messages: u64, - pub room_messages: u64, - pub direct_messages: u64, - pub today_messages: u64, -} - -/// Store de messages simple et fonctionnel -pub struct SimpleMessageStore { - db: PgPool, -} - -impl SimpleMessageStore { - pub fn new(db: PgPool) -> Self { - Self { db } - } - - /// Envoie un message dans un salon - pub async fn send_room_message( - &self, - room_name: &str, - user_id: i32, - username: &str, - content: &str, - ) -> Result { - let result = sqlx::query!( - r#"INSERT INTO messages (author_id, author_username, room, content, message_type) - VALUES ($1, $2, $3, $4, 'text') RETURNING id"#, - user_id, - username, - room_name, - content - ) - .fetch_one(&self.db) - .await - .map_err(|e| ChatError::database(&format!("Erreur lors de l'envoi du message: {}", e)))?; - - Ok(result.id) - } - - /// Envoie un message direct - pub async fn send_direct_message( - &self, - from_user_id: i32, - from_username: &str, - to_user_id: i32, - to_username: &str, - content: &str, - ) -> Result { - let result = sqlx::query!( - r#"INSERT INTO messages (author_id, author_username, recipient_id, recipient_username, content, message_type) - VALUES ($1, $2, $3, $4, $5, 'text') RETURNING id"#, - from_user_id, - from_username, - to_user_id, - to_username, - content - ) - .fetch_one(&self.db) - .await - .map_err(|e| ChatError::database(&format!("Erreur lors de l'envoi du message direct: {}", e)))?; - - Ok(result.id) - } - - /// Récupère les messages d'un salon - pub async fn get_room_messages(&self, room_name: &str, limit: i32) -> Result> { - let messages = sqlx::query_as!( - SimpleMessage, - r#"SELECT id, author_id, author_username, recipient_id, recipient_username, - room, room_id, content, created_at, message_type, is_pinned, - is_edited, original_content, status, parent_message_id, updated_at - FROM messages - WHERE room = $1 AND status != 'deleted' - ORDER BY created_at DESC - LIMIT $2"#, - room_name, - limit as i64 - ) - .fetch_all(&self.db) - .await - .map_err(|e| ChatError::database(&format!("Erreur lors de la récupération des messages: {}", e)))?; - - Ok(messages) - } - - /// Récupère les messages directs entre deux utilisateurs - pub async fn get_direct_messages(&self, user1_id: i32, user2_id: i32, limit: i32) -> Result> { - let messages = sqlx::query_as!( - SimpleMessage, - r#"SELECT id, author_id, author_username, recipient_id, recipient_username, - room, room_id, content, created_at, message_type, is_pinned, - is_edited, original_content, status, parent_message_id, updated_at - FROM messages - WHERE ((author_id = $1 AND recipient_id = $2) OR (author_id = $2 AND recipient_id = $1)) - AND room IS NULL - AND status != 'deleted' - ORDER BY created_at DESC - LIMIT $3"#, - user1_id, - user2_id, - limit as i64 - ) - .fetch_all(&self.db) - .await - .map_err(|e| ChatError::database(&format!("Erreur lors de la récupération des messages directs: {}", e)))?; - - Ok(messages) - } - - /// Épingle/désépingle un message - pub async fn toggle_pin_message(&self, message_id: i32, pinned: bool) -> Result<()> { - sqlx::query!( - "UPDATE messages SET is_pinned = $1, updated_at = NOW() WHERE id = $2", - pinned, - message_id - ) - .execute(&self.db) - .await - .map_err(|e| ChatError::database(&format!("Erreur lors de l'épinglage: {}", e)))?; - - Ok(()) - } - - /// Supprime un message (soft delete) - pub async fn delete_message(&self, message_id: i32, user_id: i32) -> Result<()> { - // Vérifier que l'utilisateur peut supprimer ce message - let message = sqlx::query!( - "SELECT author_id FROM messages WHERE id = $1", - message_id - ) - .fetch_optional(&self.db) - .await - .map_err(|e| ChatError::database(&format!("Erreur lors de la vérification: {}", e)))?; - - if let Some(msg) = message { - if msg.author_id != Some(user_id) { - return Err(ChatError::unauthorized("Vous ne pouvez pas supprimer ce message")); - } - } else { - return Err(ChatError::not_found("Message non trouvé")); - } - - sqlx::query!( - "UPDATE messages SET status = 'deleted', updated_at = NOW() WHERE id = $1", - message_id - ) - .execute(&self.db) - .await - .map_err(|e| ChatError::database(&format!("Erreur lors de la suppression: {}", e)))?; - - Ok(()) - } - - /// Modifie un message - pub async fn edit_message(&self, message_id: i32, new_content: &str, user_id: i32) -> Result<()> { - // Récupérer le message avec son contenu original - let message = sqlx::query!( - "SELECT author_id, content FROM messages WHERE id = $1", - message_id - ) - .fetch_optional(&self.db) - .await - .map_err(|e| ChatError::database(&format!("Erreur lors de la vérification: {}", e)))?; - - if let Some(msg) = message { - if msg.author_id != Some(user_id) { - return Err(ChatError::unauthorized("Vous ne pouvez pas modifier ce message")); - } - - // Sauvegarder le contenu original si c'est la première modification - sqlx::query!( - r#"UPDATE messages - SET content = $1, - is_edited = true, - original_content = COALESCE(original_content, $2), - updated_at = NOW() - WHERE id = $3"#, - new_content, - msg.content, - message_id - ) - .execute(&self.db) - .await - .map_err(|e| ChatError::database(&format!("Erreur lors de la modification: {}", e)))?; - - Ok(()) - } else { - Err(ChatError::not_found("Message non trouvé")) - } - } - - /// Ajoute une réaction à un message - pub async fn add_reaction(&self, message_id: i32, user_id: i32, emoji: &str) -> Result<()> { - // Vérifier si la réaction existe déjà - let existing = sqlx::query!( - "SELECT id FROM message_reactions WHERE message_id = $1 AND user_id = $2 AND emoji = $3", - message_id, - user_id, - emoji - ) - .fetch_optional(&self.db) - .await - .map_err(|e| ChatError::database(&format!("Erreur lors de la vérification: {}", e)))?; - - if existing.is_some() { - return Err(ChatError::bad_request("Réaction déjà ajoutée")); - } - - sqlx::query!( - "INSERT INTO message_reactions (message_id, user_id, emoji) VALUES ($1, $2, $3)", - message_id, - user_id, - emoji - ) - .execute(&self.db) - .await - .map_err(|e| ChatError::database(&format!("Erreur lors de l'ajout de la réaction: {}", e)))?; - - Ok(()) - } - - /// Retire une réaction d'un message - pub async fn remove_reaction(&self, message_id: i32, user_id: i32, emoji: &str) -> Result<()> { - let result = sqlx::query!( - "DELETE FROM message_reactions WHERE message_id = $1 AND user_id = $2 AND emoji = $3", - message_id, - user_id, - emoji - ) - .execute(&self.db) - .await - .map_err(|e| ChatError::database(&format!("Erreur lors de la suppression: {}", e)))?; - - if result.rows_affected() == 0 { - return Err(ChatError::not_found("Réaction non trouvée")); - } - - Ok(()) - } - - /// Marque les messages d'un salon comme lus - pub async fn mark_room_as_read(&self, room_name: &str, user_id: i32) -> Result<()> { - sqlx::query!( - r#"INSERT INTO message_read_status (user_id, message_id) - SELECT $1, m.id - FROM messages m - WHERE m.room = $2 AND m.status != 'deleted' - ON CONFLICT (user_id, message_id) DO NOTHING"#, - user_id, - room_name - ) - .execute(&self.db) - .await - .map_err(|e| ChatError::database(&format!("Erreur lors du marquage comme lu: {}", e)))?; - - Ok(()) - } - - /// Récupère les statistiques des messages - pub async fn get_message_stats(&self) -> Result { - let total_messages = sqlx::query_scalar!( - "SELECT COUNT(*) FROM messages WHERE status != 'deleted'" - ) - .fetch_one(&self.db) - .await - .map_err(|e| ChatError::database(&format!("Erreur lors du comptage: {}", e)))? - .unwrap_or(0) as u64; - - let room_messages = sqlx::query_scalar!( - "SELECT COUNT(*) FROM messages WHERE room IS NOT NULL AND status != 'deleted'" - ) - .fetch_one(&self.db) - .await - .map_err(|e| ChatError::database(&format!("Erreur lors du comptage: {}", e)))? - .unwrap_or(0) as u64; - - let direct_messages = sqlx::query_scalar!( - "SELECT COUNT(*) FROM messages WHERE room IS NULL AND recipient_id IS NOT NULL AND status != 'deleted'" - ) - .fetch_one(&self.db) - .await - .map_err(|e| ChatError::database(&format!("Erreur lors du comptage: {}", e)))? - .unwrap_or(0) as u64; - - let today_messages = sqlx::query_scalar!( - "SELECT COUNT(*) FROM messages WHERE created_at >= CURRENT_DATE AND status != 'deleted'" - ) - .fetch_one(&self.db) - .await - .map_err(|e| ChatError::database(&format!("Erreur lors du comptage: {}", e)))? - .unwrap_or(0) as u64; - - Ok(MessageStats { - total_messages, - room_messages, - direct_messages, - today_messages, - }) - } -} \ No newline at end of file diff --git a/veza-chat-server/src/messages.rs b/veza-chat-server/src/messages.rs deleted file mode 100644 index f4eca4077..000000000 --- a/veza-chat-server/src/messages.rs +++ /dev/null @@ -1,74 +0,0 @@ -//file: backend/modules/chat_server/src/messages.rs - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Deserialize)] -#[serde(tag = "type")] -pub enum WsInbound { - #[serde(rename = "join_room")] - Join { - room: String, - }, - - #[serde(rename = "room_message")] - Message { - room: String, - content: String, - }, - - #[serde(rename = "direct_message")] - DirectMessage { - to_user_id: String, // UUID string depuis le frontend - content: String, - }, - - #[serde(rename = "room_history")] - RoomHistory { - room: String, - limit: i64, - }, - - #[serde(rename = "dm_history")] - DmHistory { - with: String, // UUID string depuis le frontend - limit: i64, - } -} - -impl WsInbound { - #[allow(dead_code)] - pub fn log_received(&self) { - match self { - WsInbound::Join { room } => { - tracing::debug!(message_type = "join_room", room = %room, "📥 Message join_room reçu"); - } - WsInbound::Message { room, content } => { - tracing::debug!(message_type = "room_message", room = %room, content_length = %content.len(), "📥 Message room_message reçu"); - } - WsInbound::DirectMessage { to_user_id, content } => { - tracing::debug!(message_type = "direct_message", to_user_id = %to_user_id, content_length = %content.len(), "📥 Message direct_message reçu"); - } - WsInbound::RoomHistory { room, limit } => { - tracing::debug!(message_type = "room_history", room = %room, limit = %limit, "📥 Message room_history reçu"); - } - WsInbound::DmHistory { with, limit } => { - tracing::debug!(message_type = "dm_history", with_user = %with, limit = %limit, "📥 Message dm_history reçu"); - } - } - } -} - -// Types temporaires pour la compilation -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageContent { - pub text: String, - pub metadata: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum MessageType { - Text, - Image, - File, - System, -} diff --git a/veza-chat-server/src/middleware/mod.rs b/veza-chat-server/src/middleware/mod.rs deleted file mode 100644 index aab232310..000000000 --- a/veza-chat-server/src/middleware/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Middlewares pour le serveur de chat - -pub mod request_id; - -pub use request_id::request_id_middleware; - diff --git a/veza-chat-server/src/middleware/request_id.rs b/veza-chat-server/src/middleware/request_id.rs deleted file mode 100644 index e80c22f89..000000000 --- a/veza-chat-server/src/middleware/request_id.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! Middleware pour extraire et propager le request_id -//! FIX #23: Corrélation avec le backend Go - -use axum::{ - extract::Request, - http::{HeaderMap, HeaderName, HeaderValue}, - middleware::Next, - response::Response, -}; -use tracing::{info_span, Instrument}; -use uuid::Uuid; - -/// Middleware pour extraire le request_id depuis les headers et l'utiliser dans les spans -pub async fn request_id_middleware( - mut request: Request, - next: Next, -) -> Response { - let headers = request.headers().clone(); - - // FIX #23: Extraire le request_id depuis les headers (propagé depuis le backend Go) - // Si X-Request-ID est présent, l'utiliser, sinon générer un nouveau UUID - let request_id = extract_request_id_from_headers(&headers); - - // Extraire aussi le trace_id si présent - let trace_id = extract_trace_id_from_headers(&headers); - - // FIX #13: Ajouter le request_id aux extensions pour utilisation dans les handlers (y compris WebSocket) - request.extensions_mut().insert(request_id.clone()); - if let Some(ref tid) = trace_id { - request.extensions_mut().insert(tid.clone()); - } - - // Ajouter le request_id aux headers de réponse - request.headers_mut().insert( - HeaderName::from_static("x-request-id"), - request_id - .to_string() - .parse() - .unwrap_or_else(|_| HeaderValue::from_static("unknown")), - ); - - // Créer un span avec le request_id pour la corrélation - let span = info_span!( - "request", - request_id = %request_id, - trace_id = ?trace_id, - ); - - // Exécuter la requête dans le span - next.run(request).instrument(span).await -} - -/// Extraire le request_id depuis les headers HTTP -fn extract_request_id_from_headers(headers: &HeaderMap) -> Uuid { - // Chercher X-Request-ID dans les headers - if let Some(request_id_header) = headers.get("x-request-id") { - if let Ok(request_id_str) = request_id_header.to_str() { - if let Ok(uuid) = Uuid::parse_str(request_id_str) { - return uuid; - } - } - } - - // Si aucun request_id valide n'est trouvé, générer un nouveau UUID - Uuid::new_v4() -} - -/// Extraire le trace_id depuis les headers HTTP -fn extract_trace_id_from_headers(headers: &HeaderMap) -> Option { - // Chercher X-Trace-ID dans les headers - if let Some(trace_id_header) = headers.get("x-trace-id") { - if let Ok(trace_id_str) = trace_id_header.to_str() { - return Some(trace_id_str.to_string()); - } - } - - None -} - diff --git a/veza-chat-server/src/models/message.rs b/veza-chat-server/src/models/message.rs deleted file mode 100644 index 56a9e040a..000000000 --- a/veza-chat-server/src/models/message.rs +++ /dev/null @@ -1,50 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use std::convert::TryFrom; -use uuid::Uuid; - -#[derive(Debug, Clone, Serialize, Deserialize)] -// Note: Le schéma utilise VARCHAR(50) pour message_type, pas un enum PostgreSQL -// On garde l'enum Rust mais sans Type derive pour sqlx -pub enum MessageType { - Text, - Image, - Audio, - Video, - File, -} - -impl TryFrom for MessageType { - type Error = String; - - fn try_from(value: String) -> Result { - match value.as_str() { - "text" => Ok(MessageType::Text), - "image" => Ok(MessageType::Image), - "audio" => Ok(MessageType::Audio), - "video" => Ok(MessageType::Video), - "file" => Ok(MessageType::File), - _ => Err(format!("Invalid message type: {}", value)), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Message { - pub id: Uuid, - pub conversation_id: Uuid, // Aligné avec schéma DB (renommé de room_id) - pub sender_id: Uuid, - pub content: String, - pub message_type: MessageType, - pub parent_message_id: Option, // Ajouté depuis schéma DB - pub reply_to_id: Option, // Ajouté depuis migration 002 - pub is_pinned: bool, // Ajouté depuis schéma DB - pub is_edited: bool, // Ajouté depuis migration 002 - pub is_deleted: bool, // Aligné avec schéma DB - pub edited_at: Option>, // Ajouté depuis migration 002 - pub deleted_at: Option>, // Ajouté depuis migration 005 - pub status: String, // Ajouté depuis schéma DB - pub metadata: Option, // Ajouté depuis migration 002 (JSONB) - pub created_at: DateTime, - pub updated_at: DateTime, -} diff --git a/veza-chat-server/src/models/mod.rs b/veza-chat-server/src/models/mod.rs deleted file mode 100644 index ce835b963..000000000 --- a/veza-chat-server/src/models/mod.rs +++ /dev/null @@ -1,115 +0,0 @@ -pub mod message; - -// Ré-exporter les modèles existants -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; -use std::fmt; -use uuid::Uuid; - -/// Utilisateur du système -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -pub struct User { - pub id: Uuid, - pub username: String, - pub email: String, - pub display_name: Option, - pub avatar_url: Option, - pub is_active: bool, - pub last_seen: Option>, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// Conversation (DM ou Room) -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -pub struct Conversation { - pub id: Uuid, - pub name: Option, - pub description: Option, - pub conversation_type: ConversationType, - pub is_private: bool, - pub created_by: Uuid, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// Type de conversation -#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)] -#[sqlx(type_name = "conversation_type")] -pub enum ConversationType { - #[sqlx(rename = "dm")] - DirectMessage, - #[sqlx(rename = "room")] - Room, -} - -impl fmt::Display for ConversationType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ConversationType::DirectMessage => write!(f, "dm"), - ConversationType::Room => write!(f, "room"), - } - } -} - -/// Membre d'une conversation -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -pub struct ConversationMember { - pub conversation_id: Uuid, - pub user_id: Uuid, - pub role: MemberRole, - pub joined_at: DateTime, - pub last_read_at: Option>, -} - -/// Rôle d'un membre -#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)] -#[sqlx(type_name = "member_role")] -pub enum MemberRole { - #[sqlx(rename = "member")] - Member, - #[sqlx(rename = "moderator")] - Moderator, - #[sqlx(rename = "admin")] - Admin, - #[sqlx(rename = "owner")] - Owner, -} - -impl fmt::Display for MemberRole { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - MemberRole::Member => write!(f, "member"), - MemberRole::Moderator => write!(f, "moderator"), - MemberRole::Admin => write!(f, "admin"), - MemberRole::Owner => write!(f, "owner"), - } - } -} - -/// Réaction à un message -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -pub struct MessageReaction { - pub id: Uuid, - pub message_id: Uuid, - pub user_id: Uuid, - pub emoji: String, - pub created_at: DateTime, -} - -/// Session utilisateur -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -pub struct UserSession { - pub id: Uuid, - pub user_id: Uuid, - pub token_hash: String, - pub expires_at: DateTime, - pub created_at: DateTime, - pub last_used_at: Option>, - pub user_agent: Option, - pub ip_address: Option, -} - -// Ré-exporter le nouveau modèle Message -pub use message::{Message, MessageType}; diff --git a/veza-chat-server/src/moderation.rs b/veza-chat-server/src/moderation.rs deleted file mode 100644 index 245dee6ea..000000000 --- a/veza-chat-server/src/moderation.rs +++ /dev/null @@ -1,407 +0,0 @@ -use std::time::{Duration}; -use serde::{Serialize, Deserialize}; -use crate::error::{ChatError, Result}; -use crate::hub::common::ChatHub; -use crate::permissions::Role; -use sqlx::Row; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum SanctionType { - Warning, - Mute, - Kick, - TempBan, - PermaBan, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum SanctionReason { - Spam, - Harassment, - Inappropriate, - Toxicity, - RuleViolation, - Abuse, - Other(String), -} - -#[derive(Debug, Clone, Serialize)] -pub struct Sanction { - pub id: i32, - pub user_id: i32, - pub moderator_id: i32, - pub sanction_type: SanctionType, - pub reason: SanctionReason, - pub message: Option, - pub duration: Option, - pub created_at: chrono::DateTime, - pub expires_at: Option>, - pub is_active: bool, -} - -#[derive(Debug, Clone, Serialize)] -pub struct UserModerationRecord { - pub user_id: i32, - pub username: String, - pub sanctions: Vec, - pub total_warnings: u32, - pub total_mutes: u32, - pub total_bans: u32, - pub reputation_score: i32, - pub is_currently_banned: bool, - pub is_currently_muted: bool, -} - -/// Système de modération automatique et manuelle -pub struct ModerationSystem { - hub: std::sync::Arc, -} - -impl ModerationSystem { - pub fn new(hub: std::sync::Arc) -> Self { - Self { - hub, - } - } - - /// Applique une sanction manuelle par un modérateur - pub async fn apply_sanction( - &self, - moderator_id: i32, - moderator_role: &Role, - target_user_id: i32, - sanction_type: SanctionType, - reason: SanctionReason, - message: Option, - duration: Option, - ) -> Result<()> { - // Vérifier les permissions du modérateur - self.check_moderator_permissions(moderator_role, &sanction_type)?; - - // Vérifier que l'utilisateur cible existe - if !self.user_exists(target_user_id).await? { - return Err(ChatError::configuration_error("Utilisateur cible introuvable")); - } - - // Calculer l'expiration si durée spécifiée - let expires_at = duration.map(|d| { - chrono::Utc::now() + chrono::Duration::from_std(d).unwrap_or(chrono::Duration::zero()) - }); - - // Insérer la sanction en base - let sanction_id = self.insert_sanction( - target_user_id, - moderator_id, - &sanction_type, - &reason, - message.as_deref(), - expires_at, - ).await?; - - // Appliquer les effets de la sanction - self.enforce_sanction(target_user_id, &sanction_type, duration).await?; - - // Notifier l'utilisateur sanctionné - self.notify_user_sanctioned(target_user_id, &sanction_type, &reason, message.as_deref()).await?; - - // Audit log - tracing::warn!( - sanction_id = %sanction_id, - moderator_id = %moderator_id, - target_user_id = %target_user_id, - sanction_type = ?sanction_type, - reason = ?reason, - duration = ?duration, - "⚖️ Sanction appliquée" - ); - - Ok(()) - } - - /// Vérification automatique d'un message pour détecter les violations - pub async fn check_message_auto_moderation( - &self, - user_id: i32, - content: &str, - ) -> Result> { - // Détection de spam basique - if self.detect_spam(user_id, content).await? { - let user_record = self.get_user_moderation_record(user_id).await?; - let sanction_type = self.determine_auto_sanction(&user_record).await?; - - if let Some(sanction) = &sanction_type { - self.apply_sanction( - 0, // ID système - &Role::Admin, - user_id, - sanction.clone(), - SanctionReason::Spam, - Some("Sanction automatique - spam détecté".to_string()), - Some(Duration::from_secs(3600)), // 1 heure - ).await?; - } - - return Ok(sanction_type); - } - - Ok(None) - } - - /// Lève une sanction (unban, unmute, etc.) - pub async fn lift_sanction( - &self, - moderator_id: i32, - moderator_role: &Role, - target_user_id: i32, - sanction_type: SanctionType, - ) -> Result<()> { - // Vérifier les permissions - self.check_moderator_permissions(moderator_role, &sanction_type)?; - - // Désactiver la sanction en base - sqlx::query( - "UPDATE sanctions SET is_active = false WHERE user_id = $1 AND sanction_type = $2 AND is_active = true" - ) - .bind(target_user_id) - .bind(serde_json::to_string(&sanction_type).map_err(|e| ChatError::from_json_error(e))?) - .execute(&self.hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("database_operation", e))?; - - // Retirer les effets de la sanction - self.remove_sanction_effects(target_user_id, &sanction_type).await?; - - tracing::info!( - moderator_id = %moderator_id, - target_user_id = %target_user_id, - sanction_type = ?sanction_type, - "✅ Sanction levée" - ); - - Ok(()) - } - - /// Obtient l'historique de modération d'un utilisateur - pub async fn get_user_moderation_record(&self, user_id: i32) -> Result { - let rows = sqlx::query( - r#" - SELECT s.*, u.username - FROM sanctions s - JOIN users u ON u.id = s.moderator_id - WHERE s.user_id = $1 - ORDER BY s.created_at DESC - "# - ) - .bind(user_id) - .fetch_all(&self.hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("database_operation", e))?; - - let mut sanctions = Vec::new(); - let mut warning_count = 0; - let mut mute_count = 0; - let mut ban_count = 0; - let mut is_currently_banned = false; - let mut is_currently_muted = false; - - for row in rows { - let sanction_type_str: String = row.get("sanction_type"); - let sanction_type: SanctionType = serde_json::from_str(&sanction_type_str) - .map_err(|e| ChatError::from_json_error(e))?; - - let reason_str: String = row.get("reason"); - let reason: SanctionReason = serde_json::from_str(&reason_str) - .map_err(|e| ChatError::from_json_error(e))?; - - let is_active: bool = row.get("is_active"); - - // Compter les types de sanctions - match sanction_type { - SanctionType::Warning => warning_count += 1, - SanctionType::Mute => { - mute_count += 1; - if is_active { - is_currently_muted = true; - } - }, - SanctionType::TempBan | SanctionType::PermaBan => { - ban_count += 1; - if is_active { - is_currently_banned = true; - } - }, - _ => {} - } - - sanctions.push(Sanction { - id: row.get("id"), - user_id: row.get("user_id"), - moderator_id: row.get("moderator_id"), - sanction_type, - reason, - message: row.get("message"), - duration: None, // Simplifié pour cet exemple - created_at: row.get("created_at"), - expires_at: row.get("expires_at"), - is_active, - }); - } - - // Calculer un score de réputation basique - let reputation_score = 100 - (warning_count as i32 * 5) - (mute_count as i32 * 15) - (ban_count as i32 * 50); - - // Obtenir le nom d'utilisateur - let username = self.get_username(user_id).await?; - - Ok(UserModerationRecord { - user_id, - username, - sanctions, - total_warnings: warning_count, - total_mutes: mute_count, - total_bans: ban_count, - reputation_score, - is_currently_banned, - is_currently_muted, - }) - } - - /// Détection de spam basique - async fn detect_spam(&self, user_id: i32, content: &str) -> Result { - // Vérifier les messages répétitifs - let recent_messages = sqlx::query( - "SELECT content FROM messages WHERE user_id = $1 AND created_at > NOW() - INTERVAL '5 minutes' ORDER BY created_at DESC LIMIT 5" - ) - .bind(user_id) - .fetch_all(&self.hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("database_operation", e))?; - - let similar_count = recent_messages.iter() - .filter(|row| { - let msg_content: String = row.get("content"); - self.calculate_similarity(content, &msg_content) > 0.8 - }) - .count(); - - Ok(similar_count >= 3) - } - - - - /// Calcule la similarité entre deux strings (simplifiée) - fn calculate_similarity(&self, a: &str, b: &str) -> f32 { - if a == b { - return 1.0; - } - - let a_len = a.len() as f32; - let b_len = b.len() as f32; - let max_len = a_len.max(b_len); - - if max_len == 0.0 { - return 1.0; - } - - // Distance de Levenshtein simplifiée - let common_chars = a.chars().filter(|c| b.contains(*c)).count() as f32; - common_chars / max_len - } - - /// Détermine la sanction automatique appropriée - async fn determine_auto_sanction(&self, user_record: &UserModerationRecord) -> Result> { - match user_record.total_warnings { - 0 => Ok(Some(SanctionType::Warning)), - 1..=2 => Ok(Some(SanctionType::Mute)), - _ => Ok(Some(SanctionType::TempBan)), - } - } - - /// Implémentations helpers simplifiées - fn check_moderator_permissions(&self, role: &Role, sanction: &SanctionType) -> Result<()> { - match sanction { - SanctionType::Warning | SanctionType::Mute => { - if matches!(role, Role::Admin | Role::Moderator) { - Ok(()) - } else { - Err(ChatError::unauthorized_simple("unauthorized_action")) - } - }, - SanctionType::Kick | SanctionType::TempBan => { - if matches!(role, Role::Admin | Role::Moderator) { - Ok(()) - } else { - Err(ChatError::unauthorized_simple("unauthorized_action")) - } - }, - SanctionType::PermaBan => { - if matches!(role, Role::Admin) { - Ok(()) - } else { - Err(ChatError::unauthorized_simple("unauthorized_action")) - } - }, - } - } - - async fn user_exists(&self, user_id: i32) -> Result { - let row = sqlx::query("SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)") - .bind(user_id) - .fetch_one(&self.hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("database_operation", e))?; - Ok(row.get(0)) - } - - async fn get_username(&self, user_id: i32) -> Result { - let row = sqlx::query("SELECT username FROM users WHERE id = $1") - .bind(user_id) - .fetch_one(&self.hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("database_operation", e))?; - Ok(row.get(0)) - } - - async fn insert_sanction( - &self, - user_id: i32, - moderator_id: i32, - sanction_type: &SanctionType, - reason: &SanctionReason, - message: Option<&str>, - expires_at: Option>, - ) -> Result { - let row = sqlx::query( - "INSERT INTO sanctions (user_id, moderator_id, sanction_type, reason, message, expires_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id" - ) - .bind(user_id) - .bind(moderator_id) - .bind(serde_json::to_string(sanction_type).map_err(|e| ChatError::from_json_error(e))?) - .bind(serde_json::to_string(reason).map_err(|e| ChatError::from_json_error(e))?) - .bind(message) - .bind(expires_at) - .fetch_one(&self.hub.db) - .await - .map_err(|e| ChatError::from_sqlx_error("database_operation", e))?; - - Ok(row.get(0)) - } - - async fn enforce_sanction(&self, user_id: i32, sanction_type: &SanctionType, _duration: Option) -> Result<()> { - // Ici on appliquerait les effets réels (déconnecter, bloquer messages, etc.) - tracing::info!(user_id = %user_id, sanction_type = ?sanction_type, "⚖️ Sanction appliquée"); - Ok(()) - } - - async fn remove_sanction_effects(&self, user_id: i32, sanction_type: &SanctionType) -> Result<()> { - // Ici on retirerait les effets (débloquer, etc.) - tracing::info!(user_id = %user_id, sanction_type = ?sanction_type, "✅ Effets de sanction retirés"); - Ok(()) - } - - async fn notify_user_sanctioned(&self, user_id: i32, sanction_type: &SanctionType, _reason: &SanctionReason, _message: Option<&str>) -> Result<()> { - // Ici on notifierait l'utilisateur - tracing::info!(user_id = %user_id, sanction_type = ?sanction_type, "📢 Utilisateur notifié de la sanction"); - Ok(()) - } -} \ No newline at end of file diff --git a/veza-chat-server/src/monitoring.rs b/veza-chat-server/src/monitoring.rs deleted file mode 100644 index a1d963960..000000000 --- a/veza-chat-server/src/monitoring.rs +++ /dev/null @@ -1,431 +0,0 @@ -use serde::Serialize; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::RwLock; - -use crate::utils::unix_timestamp_secs; - -/// Métrique individuelle avec historique -#[derive(Debug, Clone, Serialize)] -pub struct Metric { - pub name: String, - pub value: f64, - pub timestamp: u64, - pub labels: HashMap, -} - -/// Agrégation de métriques par type -#[derive(Debug, Clone, Serialize)] -pub struct MetricSummary { - pub name: String, - pub count: u64, - pub avg: f64, - pub min: f64, - pub max: f64, - pub sum: f64, - pub labels: HashMap, -} - -/// Types de métriques supportées -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum MetricType { - Counter, - Gauge, - Histogram, - Timer, -} - -/// Gestionnaire de métriques en temps réel -#[derive(Debug)] -pub struct MetricsCollector { - metrics: Arc>>>, - counters: Arc>>, - gauges: Arc>>, - histograms: Arc>>>, - retention_duration: Duration, -} - -impl MetricsCollector { - pub fn new(retention_duration: Duration) -> Self { - Self { - metrics: Arc::new(RwLock::new(HashMap::new())), - counters: Arc::new(RwLock::new(HashMap::new())), - gauges: Arc::new(RwLock::new(HashMap::new())), - histograms: Arc::new(RwLock::new(HashMap::new())), - retention_duration, - } - } - - /// Incrémente un compteur - pub async fn increment_counter(&self, name: &str, labels: HashMap) { - let key = self.create_key(name, &labels); - let mut counters = self.counters.write().await; - *counters.entry(key.clone()).or_insert(0) += 1; - - self.record_metric(name, *counters.get(&key).unwrap_or(&0) as f64, labels) - .await; - - tracing::debug!(metric_name = %name, key = %key, "📊 Counter incrémenté"); - } - - /// Met à jour une jauge - pub async fn set_gauge(&self, name: &str, value: f64, labels: HashMap) { - let key = self.create_key(name, &labels); - let mut gauges = self.gauges.write().await; - gauges.insert(key, value); - - self.record_metric(name, value, labels).await; - - tracing::debug!(metric_name = %name, value = %value, "📊 Gauge mise à jour"); - } - - /// Ajoute une valeur à un histogramme - pub async fn record_histogram(&self, name: &str, value: f64, labels: HashMap) { - let key = self.create_key(name, &labels); - let mut histograms = self.histograms.write().await; - histograms.entry(key).or_default().push(value); - - self.record_metric(name, value, labels).await; - - tracing::debug!(metric_name = %name, value = %value, "📊 Valeur ajoutée à l'histogramme"); - } - - /// Mesure le temps d'exécution d'une opération - pub async fn time_operation( - &self, - name: &str, - labels: HashMap, - operation: F, - ) -> T - where - F: std::future::Future, - { - let start = Instant::now(); - let result = operation.await; - let duration = start.elapsed().as_secs_f64(); - - self.record_histogram(name, duration, labels).await; - - result - } - - /// Enregistre une métrique brute - async fn record_metric(&self, name: &str, value: f64, labels: HashMap) { - let timestamp = unix_timestamp_secs(); - - let metric = Metric { - name: name.to_string(), - value, - timestamp, - labels, - }; - - let mut metrics = self.metrics.write().await; - metrics.entry(name.to_string()).or_default().push(metric); - } - - /// Crée une clé unique pour une métrique avec ses labels - fn create_key(&self, name: &str, labels: &HashMap) -> String { - let mut key = name.to_string(); - let mut label_pairs: Vec<_> = labels.iter().collect(); - label_pairs.sort_by_key(|(k, _)| *k); - - for (k, v) in label_pairs { - key.push_str(&format!("{}={}", k, v)); - } - - key - } - - /// Obtient un résumé d'une métrique - pub async fn get_metric_summary(&self, name: &str) -> Option { - let metrics = self.metrics.read().await; - let metric_values = metrics.get(name)?; - - if metric_values.is_empty() { - return None; - } - - let values: Vec = metric_values.iter().map(|m| m.value).collect(); - let count = values.len() as u64; - let sum: f64 = values.iter().sum(); - let avg = sum / count as f64; - let min = values.iter().fold(f64::INFINITY, |a, &b| a.min(b)); - let max = values.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b)); - - // Prendre les labels de la dernière métrique - let labels = metric_values.last()?.labels.clone(); - - Some(MetricSummary { - name: name.to_string(), - count, - avg, - min, - max, - sum, - labels, - }) - } - - /// Obtient toutes les métriques actives - pub async fn get_all_metrics(&self) -> HashMap> { - let metrics = self.metrics.read().await; - metrics.clone() - } - - /// Nettoie les métriques anciennes - pub async fn cleanup_old_metrics(&self) { - let cutoff_time = unix_timestamp_secs().saturating_sub(self.retention_duration.as_secs()); - - let mut metrics = self.metrics.write().await; - for values in metrics.values_mut() { - values.retain(|m| m.timestamp > cutoff_time); - } - - // Supprimer les entrées vides - metrics.retain(|_, values| !values.is_empty()); - - tracing::debug!("🧹 Nettoyage des métriques anciennes effectué"); - } -} - -use sysinfo::{Pid, ProcessesToUpdate, System}; - -/// Métriques spécifiques au chat -#[derive(Debug)] -pub struct ChatMetrics { - collector: MetricsCollector, - system: Arc>, -} - -impl Default for ChatMetrics { - fn default() -> Self { - Self::new() - } -} - -impl ChatMetrics { - pub fn new() -> Self { - let mut sys = System::new_all(); - sys.refresh_all(); - - Self { - collector: MetricsCollector::new(Duration::from_secs(24 * 3600)), - system: Arc::new(RwLock::new(sys)), - } - } - - /// Connexion WebSocket établie - pub async fn websocket_connected(&self, user_id: String) { - let labels = HashMap::from([("user_id".to_string(), user_id)]); - self.collector - .increment_counter("websocket_connections_total", labels) - .await; - } - - /// Connexion WebSocket fermée - pub async fn websocket_disconnected(&self, user_id: String) { - let labels = HashMap::from([("user_id".to_string(), user_id)]); - self.collector - .increment_counter("websocket_disconnections_total", labels) - .await; - } - - /// Message envoyé (salon ou DM) - pub async fn message_sent(&self, message_type: &str, room: Option<&str>) { - let labels = HashMap::from([ - ("message_type".to_string(), message_type.to_string()), - ("room".to_string(), room.unwrap_or("dm").to_string()), - ]); - self.collector - .increment_counter("messages_sent_total", labels) - .await; - } - - /// Erreur survenue - pub async fn error_occurred(&self, error_type: &str, context: &str) { - let labels = HashMap::from([ - ("error_type".to_string(), error_type.to_string()), - ("context".to_string(), context.to_string()), - ]); - self.collector - .increment_counter("errors_total", labels) - .await; - } - - /// Rate limit déclenché - pub async fn rate_limit_triggered(&self, user_id: String) { - let labels = HashMap::from([("user_id".to_string(), user_id)]); - self.collector - .increment_counter("rate_limits_triggered_total", labels) - .await; - } - - /// Utilisateurs actifs - pub async fn active_users(&self, count: u64) { - let labels = HashMap::new(); - self.collector - .set_gauge("active_users", count as f64, labels) - .await; - } - - /// Salons actifs - pub async fn active_rooms(&self, count: u64) { - let labels = HashMap::new(); - self.collector - .set_gauge("active_rooms", count as f64, labels) - .await; - } - - /// Temps de traitement d'un message - pub async fn message_processing_time(&self, duration: Duration, message_type: &str) { - let labels = HashMap::from([("message_type".to_string(), message_type.to_string())]); - self.collector - .record_histogram( - "message_processing_duration", - duration.as_secs_f64(), - labels, - ) - .await; - } - - /// Taille d'un message - pub async fn message_size(&self, size_bytes: usize, message_type: &str) { - let labels = HashMap::from([("message_type".to_string(), message_type.to_string())]); - self.collector - .record_histogram("message_size_bytes", size_bytes as f64, labels) - .await; - } - - /// Obtient toutes les métriques pour l'API de monitoring - pub async fn get_all_metrics(&self) -> HashMap> { - self.collector.get_all_metrics().await - } - - /// Nettoie les anciennes métriques - pub async fn cleanup(&self) { - self.collector.cleanup_old_metrics().await; - } - - /// Mesure le temps d'une opération de base de données - pub async fn time_db_operation( - &self, - operation_type: &str, - future: impl std::future::Future, - ) -> T { - let labels = HashMap::from([("operation".to_string(), operation_type.to_string())]); - - self.collector - .time_operation("database_operation_duration_seconds", labels, future) - .await - } - - /// Mesure le temps d'authentification - pub async fn time_auth_operation(&self, future: impl std::future::Future) -> T { - let labels = HashMap::new(); - self.collector - .time_operation("auth_operation_duration_seconds", labels, future) - .await - } - - /// Rafraîchit et retourne les métriques système (CPU, RAM) - pub async fn get_system_metrics(&self) -> (u64, f64) { - let mut sys = self.system.write().await; - - // Refresh specific info - sys.refresh_cpu_usage(); - sys.refresh_memory(); - - // Refresh specific process - let pid = Pid::from(std::process::id() as usize); - sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), false); - - // Mémoire utilisée en MB - let memory = if let Some(process) = sys.process(pid) { - process.memory() / 1024 / 1024 - } else { - sys.used_memory() / 1024 / 1024 - }; - - // CPU global usage - let cpu = sys.global_cpu_usage() as f64; - - (memory, cpu) - } -} - -/// Point d'API pour exposer les métriques (format Prometheus ou JSON) -#[derive(Serialize)] -pub struct MetricsExport { - pub timestamp: u64, - pub metrics: HashMap>, - pub system_info: SystemInfo, -} - -#[derive(Serialize)] -pub struct SystemInfo { - pub uptime_seconds: u64, - pub memory_usage_mb: u64, - pub cpu_usage_percent: f64, -} - -impl MetricsExport { - pub async fn new(metrics: &ChatMetrics, start_time: Instant) -> Self { - let timestamp = unix_timestamp_secs(); - - let metrics_data = metrics.get_all_metrics().await; - - // Récupérer les vraies métriques système - let (memory_mb, cpu_percent) = metrics.get_system_metrics().await; - - let system_info = SystemInfo { - uptime_seconds: start_time.elapsed().as_secs(), - memory_usage_mb: memory_mb, - cpu_usage_percent: cpu_percent, - }; - - Self { - timestamp, - metrics: metrics_data, - system_info, - } - } - - /// Exporte au format Prometheus - pub fn to_prometheus_format(&self) -> String { - let mut output = String::new(); - - for (name, metrics) in &self.metrics { - if !metrics.is_empty() { - output.push_str(&format!("# HELP {} Auto-generated metric\n", name)); - output.push_str(&format!("# TYPE {} gauge\n", name)); - - // Calculs basiques sur les métriques - let count = metrics.len(); - let sum: f64 = metrics.iter().map(|m| m.value).sum(); - let avg = sum / count as f64; - - output.push_str(&format!("{}_count {}\n", name, count)); - output.push_str(&format!("{}_sum {}\n", name, sum)); - output.push_str(&format!("{}_avg {}\n", name, avg)); - } - } - - // Métriques système - output.push_str(&format!( - "chat_server_uptime_seconds {}\n", - self.system_info.uptime_seconds - )); - output.push_str(&format!( - "chat_server_memory_usage_mb {}\n", - self.system_info.memory_usage_mb - )); - output.push_str(&format!( - "chat_server_cpu_usage_percent {}\n", - self.system_info.cpu_usage_percent - )); - - output - } -} diff --git a/veza-chat-server/src/optimized_persistence.rs b/veza-chat-server/src/optimized_persistence.rs deleted file mode 100644 index 617a0e855..000000000 --- a/veza-chat-server/src/optimized_persistence.rs +++ /dev/null @@ -1,928 +0,0 @@ -//! Module Optimized Persistence - Système de persistance ultra-rapide < 5ms -//! -//! Ce module implémente un système de persistance haute performance avec : -//! - Cache multi-niveaux (L1: In-Memory, L2: Redis, L3: PostgreSQL) -//! - Write-through et Write-back strategies -//! - Batch operations pour optimiser les écritures -//! - Compression des données -//! - Réplication asynchrone -//! - Indexation intelligente - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::{PgPool, Row}; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::{mpsc, Mutex, RwLock}; -use tokio::time::{interval, timeout}; -use uuid::Uuid; -use redis::AsyncCommands; -use dashmap::DashMap; -use lz4::block::{compress, decompress}; - -use crate::error::{ChatError, Result}; -use crate::monitoring::ChatMetrics; - -/// Configuration de la persistance optimisée -#[derive(Debug, Clone)] -pub struct OptimizedPersistenceConfig { - /// Taille du cache L1 (en mémoire) - pub l1_cache_size: usize, - /// TTL du cache L1 - pub l1_cache_ttl: Duration, - /// Taille du cache L2 (Redis) - pub l2_cache_size: usize, - /// TTL du cache L2 - pub l2_cache_ttl: Duration, - /// Taille des batches pour l'écriture - pub batch_size: usize, - /// Intervalle de flush des batches - pub batch_flush_interval: Duration, - /// Timeout pour les opérations de cache - pub cache_timeout: Duration, - /// Compression activée - pub compression_enabled: bool, - /// Seuil de compression (en bytes) - pub compression_threshold: usize, - /// Nombre de répliques asynchrones - pub async_replica_count: u32, -} - -impl Default for OptimizedPersistenceConfig { - fn default() -> Self { - Self { - l1_cache_size: 10000, // 10k messages en mémoire - l1_cache_ttl: Duration::from_secs(300), // 5 minutes - l2_cache_size: 100000, // 100k messages dans Redis - l2_cache_ttl: Duration::from_secs(3600), // 1 heure - batch_size: 100, // 100 messages par batch - batch_flush_interval: Duration::from_millis(100), // 100ms - cache_timeout: Duration::from_millis(50), // 50ms timeout - compression_enabled: true, - compression_threshold: 1024, // 1KB - async_replica_count: 2, - } - } -} - -/// Message optimisé pour la performance -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OptimizedMessage { - pub id: Uuid, - pub room_id: String, - pub user_id: i32, - pub username: String, - pub content: String, - pub message_type: MessageType, - pub created_at: DateTime, - pub updated_at: Option>, - pub metadata: MessageMetadata, - - // Optimisations - pub content_hash: String, // Hash pour déduplication - pub compressed_content: Option>, // Contenu compressé - pub parent_id: Option, // Pour les réponses - pub thread_id: Option, // Pour les fils de discussion -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum MessageType { - Text, - Image, - File, - Audio, - Video, - System, - Edit, - Delete, - Reaction, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageMetadata { - pub edited: bool, - pub edit_count: u32, - pub reactions: HashMap>, // emoji -> user_ids - pub mentions: Vec, - pub attachments: Vec, - pub reply_count: u32, - pub last_reply_at: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AttachmentInfo { - pub id: Uuid, - pub filename: String, - pub content_type: String, - pub size: u64, - pub url: String, -} - -/// Entrée de cache avec métadonnées -#[derive(Debug, Clone)] -struct CacheEntry { - message: OptimizedMessage, - inserted_at: Instant, - access_count: u64, - last_access: Instant, -} - -impl CacheEntry { - fn new(message: OptimizedMessage) -> Self { - let now = Instant::now(); - Self { - message, - inserted_at: now, - access_count: 1, - last_access: now, - } - } - - fn access(&mut self) -> &OptimizedMessage { - self.access_count += 1; - self.last_access = Instant::now(); - &self.message - } - - fn is_expired(&self, ttl: Duration) -> bool { - self.inserted_at.elapsed() > ttl - } -} - -/// Batch d'opérations en attente -#[derive(Debug)] -struct PendingBatch { - messages: Vec, -} - -impl PendingBatch { - fn new() -> Self { - Self { - messages: Vec::new(), - } - } -} - -/// Statistiques de performance -#[derive(Debug, Clone, Serialize)] -pub struct PersistenceStats { - pub l1_cache_hits: u64, - pub l1_cache_misses: u64, - pub l2_cache_hits: u64, - pub l2_cache_misses: u64, - pub db_reads: u64, - pub db_writes: u64, - pub batch_writes: u64, - pub compression_ratio: f32, - pub avg_write_latency_ms: f32, - pub avg_read_latency_ms: f32, - pub total_messages: u64, - pub cache_evictions: u64, -} - -/// Système de persistance optimisée -pub struct OptimizedPersistenceEngine { - config: OptimizedPersistenceConfig, - - // Stockages - pg_pool: PgPool, - redis_client: redis::Client, - - // Caches - l1_cache: Arc>, // Cache en mémoire - l2_cache_keys: Arc>, // Clés Redis - - // Batching - pending_writes: Arc>, - batch_sender: mpsc::UnboundedSender>, - batch_receiver: Arc>>>, - - // Métriques - stats: Arc>, - metrics: Arc, - - // Runtime - is_running: Arc, -} - -impl OptimizedPersistenceEngine { - /// Crée un nouveau moteur de persistance optimisée - pub async fn new( - config: OptimizedPersistenceConfig, - pg_pool: PgPool, - redis_url: &str, - metrics: Arc, - ) -> Result { - // Connexion Redis - let redis_client = redis::Client::open(redis_url) - .map_err(|e| ChatError::configuration_error(&format!("Redis connection: {}", e)))?; - - // Test de connexion Redis - let mut redis_conn = redis_client.get_multiplexed_async_connection().await - .map_err(|e| ChatError::Cache { operation: format!("redis connection: {}", e) })?; - let _: String = redis::cmd("PING").query_async(&mut redis_conn).await - .map_err(|e| ChatError::Cache { operation: format!("redis ping: {}", e) })?; - - // Batch processing channel - let (batch_sender, batch_receiver) = mpsc::unbounded_channel(); - - let engine = Self { - config, - pg_pool, - redis_client, - l1_cache: Arc::new(DashMap::new()), - l2_cache_keys: Arc::new(DashMap::new()), - pending_writes: Arc::new(Mutex::new(PendingBatch::new())), - batch_sender, - batch_receiver: Arc::new(Mutex::new(batch_receiver)), - stats: Arc::new(RwLock::new(PersistenceStats { - l1_cache_hits: 0, - l1_cache_misses: 0, - l2_cache_hits: 0, - l2_cache_misses: 0, - db_reads: 0, - db_writes: 0, - batch_writes: 0, - compression_ratio: 1.0, - avg_write_latency_ms: 0.0, - avg_read_latency_ms: 0.0, - total_messages: 0, - cache_evictions: 0, - })), - metrics, - is_running: Arc::new(std::sync::atomic::AtomicBool::new(true)), - }; - - Ok(engine) - } - - /// Démarre les tâches de maintenance - pub async fn start_background_tasks(&self) { - // Tâche de traitement des batches - let engine_clone = self.clone(); - tokio::spawn(async move { - engine_clone.batch_processing_loop().await; - }); - - // Tâche de nettoyage du cache L1 - let engine_clone = self.clone(); - tokio::spawn(async move { - engine_clone.l1_cache_cleanup_loop().await; - }); - - // Tâche de flush périodique - let engine_clone = self.clone(); - tokio::spawn(async move { - engine_clone.periodic_flush_loop().await; - }); - - // Tâche de mise à jour des stats - let engine_clone = self.clone(); - tokio::spawn(async move { - engine_clone.stats_update_loop().await; - }); - } - - /// Stocke un message avec optimisations - pub async fn store_message(&self, mut message: OptimizedMessage) -> Result<()> { - let start_time = Instant::now(); - - // Compression si nécessaire - if self.config.compression_enabled && message.content.len() > self.config.compression_threshold { - message.compressed_content = Some(self.compress_content(&message.content)?); - } - - // Calcul du hash pour déduplication - message.content_hash = self.calculate_content_hash(&message.content); - - // Stockage L1 (immédiat) - self.store_in_l1_cache(message.clone()).await; - - // Stockage L2 asynchrone (Redis) - let engine_clone = self.clone(); - let message_clone = message.clone(); - tokio::spawn(async move { - if let Err(e) = engine_clone.store_in_l2_cache(message_clone).await { - tracing::warn!(error = %e, "❌ Erreur stockage L2"); - } - }); - - // Stockage L3 asynchrone (PostgreSQL) - let engine_clone = self.clone(); - let message_clone = message.clone(); - tokio::spawn(async move { - let _ = engine_clone.store_in_database(message_clone).await; - }); - - // Métriques - let latency = start_time.elapsed(); - self.metrics.message_processing_time(latency, "store_message").await; - - let mut stats = self.stats.write().await; - stats.total_messages += 1; - stats.avg_write_latency_ms = (stats.avg_write_latency_ms + latency.as_millis() as f32) / 2.0; - - tracing::debug!( - message_id = %message.id, - latency_ms = %latency.as_millis(), - "💾 Message stocké" - ); - - Ok(()) - } - - /// Récupère un message avec cache multi-niveaux - pub async fn get_message(&self, message_id: Uuid) -> Result> { - let start_time = Instant::now(); - - // 1. Vérifier L1 cache (en mémoire) - if let Some(mut entry) = self.l1_cache.get_mut(&message_id) { - let mut stats = self.stats.write().await; - stats.l1_cache_hits += 1; - stats.avg_read_latency_ms = (stats.avg_read_latency_ms + start_time.elapsed().as_millis() as f32) / 2.0; - - let message = entry.access().clone(); - return Ok(Some(message)); - } - - // 2. Vérifier L2 cache (Redis) - if let Ok(Some(message)) = self.get_from_l2_cache(message_id).await { - // Remettre en L1 - self.store_in_l1_cache(message.clone()).await; - - let mut stats = self.stats.write().await; - stats.l2_cache_hits += 1; - stats.avg_read_latency_ms = (stats.avg_read_latency_ms + start_time.elapsed().as_millis() as f32) / 2.0; - - return Ok(Some(message)); - } - - // 3. Récupérer de la base de données - match self.get_from_database(message_id).await { - Ok(Some(message)) => { - // Stocker dans les caches - self.store_in_l1_cache(message.clone()).await; - let engine_clone = self.clone(); - let message_clone = message.clone(); - tokio::spawn(async move { - let _ = engine_clone.store_in_l2_cache(message_clone).await; - }); - - let mut stats = self.stats.write().await; - stats.db_reads += 1; - stats.avg_read_latency_ms = (stats.avg_read_latency_ms + start_time.elapsed().as_millis() as f32) / 2.0; - - Ok(Some(message)) - } - Ok(None) => Ok(None), - Err(e) => Err(e), - } - } - - /// Récupère les messages d'une salle avec pagination optimisée - pub async fn get_room_messages( - &self, - room_id: &str, - limit: usize, - before: Option>, - ) -> Result> { - let start_time = Instant::now(); - - // Construire la requête avec index optimisé - let query = if let Some(before_time) = before { - sqlx::query( - "SELECT id, room_id, user_id, username, content, message_type, created_at, updated_at, metadata, content_hash, parent_id, thread_id - FROM messages - WHERE room_id = $1 AND created_at < $2 - ORDER BY created_at DESC - LIMIT $3" - ) - .bind(room_id) - .bind(before_time) - .bind(limit as i32) - } else { - sqlx::query( - "SELECT id, room_id, user_id, username, content, message_type, created_at, updated_at, metadata, content_hash, parent_id, thread_id - FROM messages - WHERE room_id = $1 - ORDER BY created_at DESC - LIMIT $2" - ) - .bind(room_id) - .bind(limit as i32) - }; - - let rows = query.fetch_all(&self.pg_pool).await - .map_err(|e| ChatError::from_sqlx_error("get_room_messages", e))?; - - let mut messages = Vec::new(); - for row in rows { - let message = self.row_to_message(row)?; - - // Stocker dans les caches pour les prochaines requêtes - self.store_in_l1_cache(message.clone()).await; - let engine_clone = self.clone(); - let message_clone = message.clone(); - tokio::spawn(async move { - let _ = engine_clone.store_in_l2_cache(message_clone).await; - }); - - messages.push(message); - } - - // Métriques - let latency = start_time.elapsed(); - self.metrics.time_db_operation("get_room_messages", async {}).await; - - let mut stats = self.stats.write().await; - stats.db_reads += 1; - - tracing::debug!( - room_id = %room_id, - message_count = %messages.len(), - latency_ms = %latency.as_millis(), - "📖 Messages récupérés" - ); - - Ok(messages) - } - - /// Met à jour un message avec invalidation de cache - pub async fn update_message(&self, message_id: Uuid, new_content: String) -> Result<()> { - let start_time = Instant::now(); - - // Mettre à jour en base - sqlx::query( - "UPDATE messages SET content = $1, updated_at = $2, metadata = jsonb_set(metadata, '{edited}', 'true') - WHERE id = $3" - ) - .bind(&new_content) - .bind(Utc::now()) - .bind(message_id) - .execute(&self.pg_pool) - .await - .map_err(|e| ChatError::from_sqlx_error("update_message", e))?; - - // Invalider les caches - self.invalidate_caches(message_id).await?; - - // Métriques - let latency = start_time.elapsed(); - self.metrics.message_processing_time(latency, "update_message").await; - - tracing::info!( - message_id = %message_id, - latency_ms = %latency.as_millis(), - "✏️ Message mis à jour" - ); - - Ok(()) - } - - /// Supprime un message avec nettoyage des caches - pub async fn delete_message(&self, message_id: Uuid) -> Result<()> { - let start_time = Instant::now(); - - // Soft delete en base - sqlx::query( - "UPDATE messages SET message_type = 'Delete', content = '[Message supprimé]', updated_at = $1 - WHERE id = $2" - ) - .bind(Utc::now()) - .bind(message_id) - .execute(&self.pg_pool) - .await - .map_err(|e| ChatError::from_sqlx_error("delete_message", e))?; - - // Nettoyer les caches - self.invalidate_caches(message_id).await?; - - // Métriques - let latency = start_time.elapsed(); - self.metrics.message_processing_time(latency, "delete_message").await; - - tracing::info!( - message_id = %message_id, - latency_ms = %latency.as_millis(), - "🗑️ Message supprimé" - ); - - Ok(()) - } - - /// Stockage dans le cache L1 (mémoire) - async fn store_in_l1_cache(&self, message: OptimizedMessage) { - // Vérifier la limite de taille - if self.l1_cache.len() >= self.config.l1_cache_size { - self.evict_l1_cache().await; - } - - let entry = CacheEntry::new(message.clone()); - self.l1_cache.insert(message.id, entry); - - tracing::trace!(message_id = %message.id, "💾 Message stocké en L1"); - } - - /// Stockage dans le cache L2 (Redis) - async fn store_in_l2_cache(&self, message: OptimizedMessage) -> Result<()> { - let mut conn = timeout( - self.config.cache_timeout, - self.redis_client.get_multiplexed_async_connection() - ).await - .map_err(|_| ChatError::configuration_error("Redis connection timeout"))? - .map_err(|e| ChatError::configuration_error(&format!("Redis connection: {}", e)))?; - - // Sérialiser le message - let serialized = serde_json::to_vec(&message) - .map_err(|e| ChatError::configuration_error(&format!("Serialization: {}", e)))?; - - // Compresser si activé - let data = if self.config.compression_enabled && serialized.len() > self.config.compression_threshold { - compress(&serialized, None, false) - .map_err(|e| ChatError::configuration_error(&format!("Compression: {}", e)))? - } else { - serialized - }; - - let key = format!("msg:{}", message.id); - let ttl = self.config.l2_cache_ttl.as_secs() as usize; - - let _: () = conn.set_ex(&key, data, ttl as u64).await - .map_err(|e| ChatError::configuration_error(&format!("Redis setex: {}", e)))?; - - self.l2_cache_keys.insert(message.id, key); - - tracing::trace!(message_id = %message.id, "💾 Message stocké en L2"); - Ok(()) - } - - /// Récupération depuis le cache L2 (Redis) - async fn get_from_l2_cache(&self, message_id: Uuid) -> Result> { - let key = format!("msg:{}", message_id); - - let mut conn = timeout( - self.config.cache_timeout, - self.redis_client.get_multiplexed_async_connection() - ).await - .map_err(|_| ChatError::configuration_error("Redis connection timeout"))? - .map_err(|e| ChatError::configuration_error(&format!("Redis connection: {}", e)))?; - - let data: Option> = conn.get(&key).await - .map_err(|e| ChatError::configuration_error(&format!("Redis get: {}", e)))?; - - if let Some(compressed_data) = data { - // Décompresser si nécessaire - let serialized = if self.config.compression_enabled { - match decompress(&compressed_data, None) { - Ok(decompressed) => decompressed, - Err(_) => compressed_data, // Pas compressé - } - } else { - compressed_data - }; - - let message: OptimizedMessage = serde_json::from_slice(&serialized) - .map_err(|e| ChatError::configuration_error(&format!("Deserialization: {}", e)))?; - - let mut stats = self.stats.write().await; - stats.l2_cache_hits += 1; - - tracing::trace!(message_id = %message_id, "📖 Message récupéré depuis L2"); - Ok(Some(message)) - } else { - let mut stats = self.stats.write().await; - stats.l2_cache_misses += 1; - Ok(None) - } - } - - /// Récupération depuis la base de données - async fn get_from_database(&self, message_id: Uuid) -> Result> { - let row = sqlx::query( - "SELECT id, room_id, user_id, username, content, message_type, created_at, updated_at, metadata, content_hash, parent_id, thread_id - FROM messages WHERE id = $1" - ) - .bind(message_id) - .fetch_optional(&self.pg_pool) - .await - .map_err(|e| ChatError::from_sqlx_error("get_from_database", e))?; - - if let Some(row) = row { - let message = self.row_to_message(row)?; - tracing::trace!(message_id = %message_id, "📖 Message récupéré depuis DB"); - Ok(Some(message)) - } else { - Ok(None) - } - } - - /// Stockage en base de données - async fn store_in_database(&self, message: OptimizedMessage) -> Result<()> { - sqlx::query( - "INSERT INTO messages (id, room_id, user_id, username, content, message_type, created_at, updated_at, metadata, content_hash, parent_id, thread_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - ON CONFLICT (id) DO NOTHING" - ) - .bind(message.id) - .bind(&message.room_id) - .bind(message.user_id) - .bind(&message.username) - .bind(&message.content) - .bind(serde_json::to_string(&message.message_type).unwrap_or_default()) - .bind(message.created_at) - .bind(message.updated_at) - .bind(serde_json::to_value(&message.metadata).unwrap_or_default()) - .bind(&message.content_hash) - .bind(message.parent_id) - .bind(message.thread_id) - .execute(&self.pg_pool) - .await - .map_err(|e| ChatError::from_sqlx_error("store_in_database", e))?; - - let mut stats = self.stats.write().await; - stats.db_writes += 1; - - Ok(()) - } - - /// Boucle de traitement des batches - async fn batch_processing_loop(&self) { - let mut receiver = self.batch_receiver.lock().await; - - while self.is_running.load(std::sync::atomic::Ordering::Relaxed) { - if let Some(messages) = receiver.recv().await { - if let Err(e) = self.process_batch(messages).await { - tracing::error!(error = %e, "❌ Erreur traitement batch"); - } - } - } - } - - /// Traite un batch de messages - async fn process_batch(&self, messages: Vec) -> Result<()> { - let start_time = Instant::now(); - let batch_size = messages.len(); - - if messages.is_empty() { - return Ok(()); - } - - // Transaction pour l'insertion en lot - let mut tx = self.pg_pool.begin().await - .map_err(|e| ChatError::from_sqlx_error("begin_transaction", e))?; - - for message in &messages { - sqlx::query( - "INSERT INTO messages (id, room_id, user_id, username, content, message_type, created_at, updated_at, metadata, content_hash, parent_id, thread_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - ON CONFLICT (id) DO NOTHING" - ) - .bind(message.id) - .bind(&message.room_id) - .bind(message.user_id) - .bind(&message.username) - .bind(&message.content) - .bind(serde_json::to_string(&message.message_type).unwrap_or_default()) - .bind(message.created_at) - .bind(message.updated_at) - .bind(serde_json::to_value(&message.metadata).unwrap_or_default()) - .bind(&message.content_hash) - .bind(message.parent_id) - .bind(message.thread_id) - .execute(&mut *tx) - .await - .map_err(|e| ChatError::from_sqlx_error("insert_message", e))?; - } - - tx.commit().await - .map_err(|e| ChatError::from_sqlx_error("commit_transaction", e))?; - - // Métriques - let latency = start_time.elapsed(); - - let mut stats = self.stats.write().await; - stats.batch_writes += 1; - stats.db_writes += batch_size as u64; - - tracing::info!( - batch_size = %batch_size, - latency_ms = %latency.as_millis(), - "📦 Batch traité" - ); - - Ok(()) - } - - /// Éviction du cache L1 - async fn evict_l1_cache(&self) { - let eviction_count = self.config.l1_cache_size / 4; // Évict 25% - let mut entries_to_remove = Vec::new(); - - // Trouver les entrées les moins récemment utilisées - for entry in self.l1_cache.iter() { - entries_to_remove.push(( - *entry.key(), - entry.value().last_access, - entry.value().access_count, - )); - } - - // Trier par dernier accès et fréquence - entries_to_remove.sort_by(|a, b| { - a.1.cmp(&b.1).then(a.2.cmp(&b.2)) - }); - - // Supprimer les plus anciens - for (id, _, _) in entries_to_remove.iter().take(eviction_count) { - self.l1_cache.remove(id); - } - - let mut stats = self.stats.write().await; - stats.cache_evictions += eviction_count as u64; - - tracing::debug!(evicted_count = %eviction_count, "🧹 Cache L1 éviction"); - } - - /// Nettoyage périodique du cache L1 - async fn l1_cache_cleanup_loop(&self) { - let mut interval = interval(Duration::from_secs(60)); // Toutes les minutes - - while self.is_running.load(std::sync::atomic::Ordering::Relaxed) { - interval.tick().await; - - let mut expired_keys = Vec::new(); - for entry in self.l1_cache.iter() { - if entry.value().is_expired(self.config.l1_cache_ttl) { - expired_keys.push(*entry.key()); - } - } - - for key in expired_keys { - self.l1_cache.remove(&key); - } - } - } - - /// Flush périodique des batches - async fn periodic_flush_loop(&self) { - let mut interval = interval(self.config.batch_flush_interval); - - while self.is_running.load(std::sync::atomic::Ordering::Relaxed) { - interval.tick().await; - - let mut batch = self.pending_writes.lock().await; - if !batch.messages.is_empty() { - let messages = std::mem::take(&mut batch.messages); - *batch = PendingBatch::new(); - drop(batch); - - if let Err(e) = self.batch_sender.send(messages) { - tracing::error!(error = %e, "❌ Erreur flush périodique"); - } - } - } - } - - /// Mise à jour périodique des statistiques - async fn stats_update_loop(&self) { - let mut interval = interval(Duration::from_secs(30)); - - while self.is_running.load(std::sync::atomic::Ordering::Relaxed) { - interval.tick().await; - - // Calculer le ratio de compression - let total_original = self.l1_cache.len() as f32; - let total_compressed = self.l1_cache.iter() - .filter(|entry| entry.value().message.compressed_content.is_some()) - .count() as f32; - - let mut stats = self.stats.write().await; - if total_original > 0.0 { - stats.compression_ratio = total_compressed / total_original; - } - - // Métriques globales - self.metrics.active_users(self.l1_cache.len() as u64).await; - } - } - - /// Invalide les caches pour un message - async fn invalidate_caches(&self, message_id: Uuid) -> Result<()> { - // Supprimer du L1 - self.l1_cache.remove(&message_id); - - // Supprimer du L2 - if let Some((_, key)) = self.l2_cache_keys.remove(&message_id) { - let mut conn = self.redis_client.get_multiplexed_async_connection().await - .map_err(|e| ChatError::configuration_error(&format!("Redis invalidation: {}", e)))?; - - let _: () = conn.del(key).await - .map_err(|e| ChatError::configuration_error(&format!("Redis del: {}", e)))?; - } - - tracing::debug!(message_id = %message_id, "🧹 Caches invalidés"); - Ok(()) - } - - /// Calcule le hash du contenu - fn calculate_content_hash(&self, content: &str) -> String { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - - let mut hasher = DefaultHasher::new(); - content.hash(&mut hasher); - format!("{:x}", hasher.finish()) - } - - /// Compresse le contenu - fn compress_content(&self, content: &str) -> Result> { - compress(content.as_bytes(), None, false) - .map_err(|e| ChatError::configuration_error(&format!("Compression: {}", e))) - } - - /// Convertit une ligne SQL en message optimisé - fn row_to_message(&self, row: sqlx::postgres::PgRow) -> Result { - let metadata_json: serde_json::Value = row.try_get("metadata") - .map_err(|e| ChatError::from_sqlx_error("parse_metadata", e))?; - - let metadata: MessageMetadata = serde_json::from_value(metadata_json) - .map_err(|e| ChatError::configuration_error(&format!("Parse metadata: {}", e)))?; - - let message_type_str: String = row.try_get("message_type") - .map_err(|e| ChatError::from_sqlx_error("parse_message_type", e))?; - - let message_type: MessageType = serde_json::from_str(&format!("\"{}\"", message_type_str)) - .unwrap_or(MessageType::Text); - - Ok(OptimizedMessage { - id: row.try_get("id").map_err(|e| ChatError::from_sqlx_error("parse_id", e))?, - room_id: row.try_get("room_id").map_err(|e| ChatError::from_sqlx_error("parse_room_id", e))?, - user_id: row.try_get("user_id").map_err(|e| ChatError::from_sqlx_error("parse_user_id", e))?, - username: row.try_get("username").map_err(|e| ChatError::from_sqlx_error("parse_username", e))?, - content: row.try_get("content").map_err(|e| ChatError::from_sqlx_error("parse_content", e))?, - message_type, - created_at: row.try_get("created_at").map_err(|e| ChatError::from_sqlx_error("parse_created_at", e))?, - updated_at: row.try_get("updated_at").map_err(|e| ChatError::from_sqlx_error("parse_updated_at", e))?, - metadata, - content_hash: row.try_get("content_hash").map_err(|e| ChatError::from_sqlx_error("parse_content_hash", e))?, - compressed_content: None, - parent_id: row.try_get("parent_id").map_err(|e| ChatError::from_sqlx_error("parse_parent_id", e))?, - thread_id: row.try_get("thread_id").map_err(|e| ChatError::from_sqlx_error("parse_thread_id", e))?, - }) - } - - /// Obtient les statistiques de performance - pub async fn get_stats(&self) -> PersistenceStats { - self.stats.read().await.clone() - } - - /// Arrête le moteur de persistance - pub async fn shutdown(&self) { - self.is_running.store(false, std::sync::atomic::Ordering::Relaxed); - - // Flush final - let batch = self.pending_writes.lock().await; - if !batch.messages.is_empty() { - let messages = batch.messages.clone(); - drop(batch); - - if let Err(e) = self.process_batch(messages).await { - tracing::error!(error = %e, "❌ Erreur flush final"); - } - } - - tracing::info!("🛑 Moteur de persistance arrêté"); - } - - /// Nettoie les anciennes métriques (maintenance) - pub async fn cleanup_old_metrics(&self) -> Result<()> { - // Implémenter la logique de nettoyage - // Supprimer les métriques plus anciennes que X jours - Ok(()) - } - - /// Démarre les tâches de maintenance en arrière-plan - pub async fn start_maintenance_tasks(&self) -> Result<()> { - // ... existing code ... - Ok(()) - } -} - -impl Clone for OptimizedPersistenceEngine { - fn clone(&self) -> Self { - Self { - config: self.config.clone(), - pg_pool: self.pg_pool.clone(), - redis_client: self.redis_client.clone(), - l1_cache: self.l1_cache.clone(), - l2_cache_keys: self.l2_cache_keys.clone(), - pending_writes: self.pending_writes.clone(), - batch_sender: self.batch_sender.clone(), - batch_receiver: self.batch_receiver.clone(), - stats: self.stats.clone(), - metrics: self.metrics.clone(), - is_running: self.is_running.clone(), - } - } -} \ No newline at end of file diff --git a/veza-chat-server/src/permissions.rs b/veza-chat-server/src/permissions.rs deleted file mode 100644 index 7bc856516..000000000 --- a/veza-chat-server/src/permissions.rs +++ /dev/null @@ -1,251 +0,0 @@ -use crate::error::{ChatError, Result}; -use serde::{Deserialize, Serialize}; -use std::collections::HashSet; - -/// Rôles disponibles dans le système de chat -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum Role { - /// Utilisateur standard - User, - /// Modérateur avec permissions étendues - Moderator, - /// Administrateur avec tous les pouvoirs - Admin, - /// Super administrateur - SuperAdmin, -} - -/// Permissions granulaires dans le système -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum Permission { - // Messages - SendMessage, - EditMessage, - DeleteMessage, - PinMessage, - - // Modération - ModerateMessages, - BanUsers, - KickUsers, - MuteUsers, - - // Administration - ManageRoles, - ManageChannels, - ManageServer, - ViewAuditLog, - - // Avancé - ManageWebhooks, - BypassRateLimit, -} - -impl Role { - /// Retourne les permissions par défaut pour un rôle - pub fn default_permissions(&self) -> HashSet { - match self { - Role::User => [Permission::SendMessage, Permission::EditMessage] - .into_iter() - .collect(), - - Role::Moderator => [ - Permission::SendMessage, - Permission::EditMessage, - Permission::DeleteMessage, - Permission::PinMessage, - Permission::ModerateMessages, - Permission::KickUsers, - Permission::MuteUsers, - ] - .into_iter() - .collect(), - - Role::Admin => [ - Permission::SendMessage, - Permission::EditMessage, - Permission::DeleteMessage, - Permission::PinMessage, - Permission::ModerateMessages, - Permission::BanUsers, - Permission::KickUsers, - Permission::MuteUsers, - Permission::ManageRoles, - Permission::ManageChannels, - Permission::ViewAuditLog, - ] - .into_iter() - .collect(), - - Role::SuperAdmin => { - // Toutes les permissions - [ - Permission::SendMessage, - Permission::EditMessage, - Permission::DeleteMessage, - Permission::PinMessage, - Permission::ModerateMessages, - Permission::BanUsers, - Permission::KickUsers, - Permission::MuteUsers, - Permission::ManageRoles, - Permission::ManageChannels, - Permission::ManageServer, - Permission::ViewAuditLog, - Permission::ManageWebhooks, - Permission::BypassRateLimit, - ] - .into_iter() - .collect() - } - } - } - - pub fn get_permissions(&self) -> Vec { - self.default_permissions().into_iter().collect() - } - - pub fn has_permission(&self, permission: &Permission) -> bool { - self.default_permissions().contains(permission) - } - - pub fn from_string(role_str: &str) -> Result { - match role_str.to_lowercase().as_str() { - "admin" => Ok(Role::Admin), - "moderator" | "mod" => Ok(Role::Moderator), - "user" => Ok(Role::User), - "superadmin" => Ok(Role::SuperAdmin), - _ => Err(ChatError::configuration_error(&format!( - "Rôle invalide: {}", - role_str - ))), - } - } -} - -/// Structure représentant les permissions d'un utilisateur -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UserPermissions { - pub user_id: i64, - pub roles: HashSet, - pub custom_permissions: HashSet, -} - -impl UserPermissions { - /// Crée une nouvelle instance avec des permissions utilisateur de base - pub fn new_user(user_id: i64) -> Self { - Self { - user_id, - roles: [Role::User].into_iter().collect(), - custom_permissions: HashSet::new(), - } - } - - /// Vérifie si l'utilisateur possède une permission spécifique - pub fn has_permission(&self, permission: &Permission) -> bool { - // Vérifier les permissions custom - if self.custom_permissions.contains(permission) { - return true; - } - - // Vérifier les permissions des rôles - self.roles - .iter() - .any(|role| role.default_permissions().contains(permission)) - } - - /// Ajoute un rôle à l'utilisateur - pub fn add_role(&mut self, role: Role) { - self.roles.insert(role); - } - - /// Retire un rôle de l'utilisateur - pub fn remove_role(&mut self, role: &Role) { - self.roles.remove(role); - } - - /// Ajoute une permission custom - pub fn grant_permission(&mut self, permission: Permission) { - self.custom_permissions.insert(permission); - } - - /// Retire une permission custom - pub fn revoke_permission(&mut self, permission: &Permission) { - self.custom_permissions.remove(permission); - } -} - -/// Fonction utilitaire pour vérifier les permissions -pub fn check_permission( - user_permissions: &UserPermissions, - required_permission: &Permission, -) -> bool { - user_permissions.has_permission(required_permission) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_user_permissions() { - let mut perms = UserPermissions::new_user(123); - - // Utilisateur de base peut envoyer des messages - assert!(perms.has_permission(&Permission::SendMessage)); - - // Mais ne peut pas bannir - assert!(!perms.has_permission(&Permission::BanUsers)); - - // Ajouter le rôle modérateur - perms.add_role(Role::Moderator); - assert!(perms.has_permission(&Permission::KickUsers)); - - // Ajouter permission custom - perms.grant_permission(Permission::ManageServer); - assert!(perms.has_permission(&Permission::ManageServer)); - } - - #[test] - fn test_role_permissions() { - let admin_perms = Role::Admin.default_permissions(); - assert!(admin_perms.contains(&Permission::ManageRoles)); - assert!(admin_perms.contains(&Permission::BanUsers)); - - let user_perms = Role::User.default_permissions(); - assert!(!user_perms.contains(&Permission::BanUsers)); - assert!(user_perms.contains(&Permission::SendMessage)); - } - - #[test] - fn test_role_from_string() { - assert!(Role::from_string("admin").is_ok()); - assert!(Role::from_string("moderator").is_ok()); - assert!(Role::from_string("mod").is_ok()); - assert!(Role::from_string("user").is_ok()); - assert!(Role::from_string("superadmin").is_ok()); - assert!(Role::from_string("invalid").is_err()); - } - - #[test] - fn test_role_has_permission() { - assert!(Role::Admin.has_permission(&Permission::ManageChannels)); - assert!(!Role::User.has_permission(&Permission::ManageChannels)); - } - - #[test] - fn test_check_permission_helper() { - let perms = UserPermissions::new_user(1); - assert!(check_permission(&perms, &Permission::SendMessage)); - assert!(!check_permission(&perms, &Permission::BanUsers)); - } - - #[test] - fn test_user_permissions_revoke() { - let mut perms = UserPermissions::new_user(1); - perms.grant_permission(Permission::BypassRateLimit); - assert!(perms.has_permission(&Permission::BypassRateLimit)); - perms.revoke_permission(&Permission::BypassRateLimit); - assert!(!perms.has_permission(&Permission::BypassRateLimit)); - } -} diff --git a/veza-chat-server/src/presence.rs b/veza-chat-server/src/presence.rs deleted file mode 100644 index 04d2ee3ea..000000000 --- a/veza-chat-server/src/presence.rs +++ /dev/null @@ -1,265 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::RwLock; -use serde::{Serialize, Deserialize}; -use serde_json::json; -use crate::error::Result; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum UserStatus { - Online, - Away, - Busy, - Invisible, - Offline, -} - -#[derive(Debug, Clone, Serialize)] -pub struct UserPresence { - pub user_id: i32, - pub username: String, - pub status: UserStatus, - #[serde(skip)] // Skip Instant car non sérialisable - pub last_seen: Instant, - pub status_message: Option, - pub current_room: Option, -} - -impl UserPresence { - pub fn new(user_id: i32, username: String) -> Self { - Self { - user_id, - username, - status: UserStatus::Online, - last_seen: Instant::now(), - status_message: None, - current_room: None, - } - } - - pub fn update_activity(&mut self) { - self.last_seen = Instant::now(); - if self.status == UserStatus::Away { - self.status = UserStatus::Online; - } - } - - pub fn set_away_if_inactive(&mut self, away_threshold: Duration) { - if self.status == UserStatus::Online && self.last_seen.elapsed() > away_threshold { - self.status = UserStatus::Away; - } - } -} - -/// Gestionnaire de présence des utilisateurs -pub struct PresenceManager { - users: Arc>>, - away_threshold: Duration, -} - -impl Default for PresenceManager { - fn default() -> Self { - Self::new() - } -} - -impl PresenceManager { - pub fn new() -> Self { - Self { - users: Arc::new(RwLock::new(HashMap::new())), - away_threshold: Duration::from_secs(300), // 5 minutes - } - } - - /// Enregistre un utilisateur comme en ligne - pub async fn user_online(&self, user_id: i32, username: String) { - let mut users = self.users.write().await; - let presence = UserPresence::new(user_id, username.clone()); - - tracing::info!(user_id = %user_id, username = %username, "👋 Utilisateur en ligne"); - users.insert(user_id, presence); - } - - /// Marque un utilisateur comme hors ligne - pub async fn user_offline(&self, user_id: i32) { - let mut users = self.users.write().await; - if let Some(mut presence) = users.remove(&user_id) { - presence.status = UserStatus::Offline; - tracing::info!(user_id = %user_id, username = %presence.username, "👋 Utilisateur hors ligne"); - } - } - - /// Met à jour l'activité d'un utilisateur - pub async fn update_user_activity(&self, user_id: i32) { - let mut users = self.users.write().await; - if let Some(presence) = users.get_mut(&user_id) { - presence.update_activity(); - } - } - - /// Change le statut d'un utilisateur - pub async fn set_user_status(&self, user_id: i32, status: UserStatus, message: Option) -> Result<()> { - let mut users = self.users.write().await; - if let Some(presence) = users.get_mut(&user_id) { - presence.status = status.clone(); - presence.status_message = message.clone(); - - tracing::info!( - user_id = %user_id, - username = %presence.username, - status = ?status, - message = ?message, - "📊 Statut utilisateur mis à jour" - ); - } - Ok(()) - } - - /// Met à jour le salon actuel d'un utilisateur - pub async fn set_user_room(&self, user_id: i32, room: Option) { - let mut users = self.users.write().await; - if let Some(presence) = users.get_mut(&user_id) { - presence.current_room = room.clone(); - tracing::debug!(user_id = %user_id, room = ?room, "🏠 Salon actuel mis à jour"); - } - } - - /// Obtient la présence d'un utilisateur - pub async fn get_user_presence(&self, user_id: i32) -> Option { - let users = self.users.read().await; - users.get(&user_id).cloned() - } - - /// Obtient la liste des utilisateurs en ligne dans un salon - pub async fn get_room_users(&self, room: &str) -> Vec { - let users = self.users.read().await; - users.values() - .filter(|presence| { - presence.current_room.as_deref() == Some(room) && - presence.status != UserStatus::Offline && - presence.status != UserStatus::Invisible - }) - .cloned() - .collect() - } - - /// Obtient tous les utilisateurs en ligne - pub async fn get_online_users(&self) -> Vec { - let users = self.users.read().await; - users.values() - .filter(|presence| { - presence.status != UserStatus::Offline && - presence.status != UserStatus::Invisible - }) - .cloned() - .collect() - } - - /// Nettoie les utilisateurs inactifs (les marque comme "away") - pub async fn cleanup_inactive_users(&self) { - let mut users = self.users.write().await; - let mut updated_users = Vec::new(); - - for presence in users.values_mut() { - let old_status = presence.status.clone(); - presence.set_away_if_inactive(self.away_threshold); - - if old_status != presence.status { - updated_users.push(presence.clone()); - } - } - - if !updated_users.is_empty() { - tracing::info!(count = %updated_users.len(), "😴 Utilisateurs marqués comme inactifs"); - } - } - - /// Génère un événement de présence pour diffusion - pub fn create_presence_event(&self, presence: &UserPresence, event_type: &str) -> serde_json::Value { - json!({ - "type": "presence_update", - "data": { - "event": event_type, - "user_id": presence.user_id, - "username": presence.username, - "status": presence.status, - "status_message": presence.status_message, - "current_room": presence.current_room - } - }) - } -} - -/// Système de notifications push -pub struct NotificationManager { - // Ici on pourrait intégrer avec des services comme Firebase, Apple Push, etc. -} - -impl Default for NotificationManager { - fn default() -> Self { - Self::new() - } -} - -impl NotificationManager { - pub fn new() -> Self { - Self {} - } - - /// Envoie une notification push à un utilisateur - pub async fn send_push_notification( - &self, - user_id: i32, - title: &str, - body: &str, - _data: Option - ) -> Result<()> { - // Implémentation des notifications push - tracing::info!( - user_id = %user_id, - title = %title, - body = %body, - "📱 Notification push envoyée" - ); - - // TODO: Intégrer avec Firebase Cloud Messaging, Apple Push Notification, etc. - Ok(()) - } - - /// Notification pour un nouveau message direct - pub async fn notify_new_dm(&self, to_user: i32, from_username: &str, preview: &str) -> Result<()> { - let title = format!("Nouveau message de {}", from_username); - let body = if preview.len() > 50 { - format!("{}...", &preview[..47]) - } else { - preview.to_string() - }; - - self.send_push_notification( - to_user, - &title, - &body, - Some(json!({"type": "dm", "from": from_username})) - ).await - } - - /// Notification pour mention dans un salon - pub async fn notify_room_mention(&self, user_id: i32, room: &str, from_username: &str, message: &str) -> Result<()> { - let title = format!("Mention dans #{}", room); - let body = format!("{}: {}", from_username, - if message.len() > 50 { - format!("{}...", &message[..47]) - } else { - message.to_string() - } - ); - - self.send_push_notification( - user_id, - &title, - &body, - Some(json!({"type": "mention", "room": room, "from": from_username})) - ).await - } -} \ No newline at end of file diff --git a/veza-chat-server/src/prometheus_metrics.rs b/veza-chat-server/src/prometheus_metrics.rs deleted file mode 100644 index c968fe2f9..000000000 --- a/veza-chat-server/src/prometheus_metrics.rs +++ /dev/null @@ -1,367 +0,0 @@ -//! Métriques Prometheus pour le serveur de chat -//! Version corrigée avec la nouvelle API metrics - -use crate::config::PrometheusConfig; -use crate::error::Result; -use axum::{extract::State, http::StatusCode, response::Response, routing::get, Router}; -use metrics::{counter, gauge, histogram, describe_counter, describe_gauge, describe_histogram, Unit, Counter, Gauge, Histogram}; -use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle}; -use std::sync::Arc; -use std::time::Duration; -use tracing::{debug, info}; - -/// Gestionnaire des métriques Prometheus -pub struct PrometheusMetrics { - - /// Métriques de connexions WebSocket - pub websocket_connections: Gauge, - pub websocket_connections_total: Counter, - pub websocket_disconnections_total: Counter, - - /// Métriques de messages - pub messages_sent_total: Counter, - pub messages_received_total: Counter, - pub message_processing_duration: Histogram, - pub message_size_bytes: Histogram, - - /// Métriques de salons - pub active_rooms: Gauge, - pub rooms_created_total: Counter, - pub rooms_deleted_total: Counter, - - /// Métriques d'erreurs - pub errors_total: Counter, - pub authentication_failures_total: Counter, - pub rate_limits_triggered_total: Counter, - - /// Métriques de performance - pub request_duration_seconds: Histogram, - pub database_operation_duration_seconds: Histogram, - pub cache_hit_ratio: Gauge, - - /// Métriques système - pub memory_usage_bytes: Gauge, - pub cpu_usage_percent: Gauge, - pub uptime_seconds: Gauge, - - /// Métriques de sécurité - pub security_events_total: Counter, - pub jwt_tokens_issued_total: Counter, - pub jwt_tokens_revoked_total: Counter, - - /// Métriques de cache - pub cache_operations_total: Counter, - pub cache_hits_total: Counter, - pub cache_misses_total: Counter, - - /// Métriques de base de données - pub database_connections_active: Gauge, - pub database_operations_total: Counter, - pub database_errors_total: Counter, - - /// Métriques de modération - pub moderation_actions_total: Counter, - pub content_filtered_total: Counter, - pub users_banned_total: Counter, - - /// Métriques d'utilisateurs - pub users_online: Gauge, - pub users_away: Gauge, - pub users_busy: Gauge, - pub users_offline: Gauge, -} - -impl PrometheusMetrics { - /// Initialise les métriques avec les descriptions - pub fn init_metrics() -> Result<()> { - // Descriptions des compteurs - describe_counter!("websocket_connections_total", Unit::Count, "Total des connexions WebSocket"); - describe_counter!("websocket_disconnections_total", Unit::Count, "Total des déconnexions WebSocket"); - describe_counter!("messages_sent_total", Unit::Count, "Total des messages envoyés"); - describe_counter!("messages_received_total", Unit::Count, "Total des messages reçus"); - describe_counter!("rooms_created_total", Unit::Count, "Total des salons créés"); - describe_counter!("rooms_deleted_total", Unit::Count, "Total des salons supprimés"); - describe_counter!("errors_total", Unit::Count, "Total des erreurs"); - describe_counter!("authentication_failures_total", Unit::Count, "Total des échecs d'authentification"); - describe_counter!("rate_limits_triggered_total", Unit::Count, "Total des rate limits déclenchés"); - describe_counter!("security_events_total", Unit::Count, "Total des événements de sécurité"); - describe_counter!("jwt_tokens_issued_total", Unit::Count, "Total des tokens JWT émis"); - describe_counter!("jwt_tokens_revoked_total", Unit::Count, "Total des tokens JWT révoqués"); - describe_counter!("cache_operations_total", Unit::Count, "Total des opérations de cache"); - describe_counter!("cache_hits_total", Unit::Count, "Total des hits de cache"); - describe_counter!("cache_misses_total", Unit::Count, "Total des misses de cache"); - describe_counter!("database_operations_total", Unit::Count, "Total des opérations de base de données"); - describe_counter!("database_errors_total", Unit::Count, "Total des erreurs de base de données"); - describe_counter!("moderation_actions_total", Unit::Count, "Total des actions de modération"); - describe_counter!("content_filtered_total", Unit::Count, "Total du contenu filtré"); - describe_counter!("users_banned_total", Unit::Count, "Total des utilisateurs bannis"); - - // Descriptions des jauges - describe_gauge!("websocket_connections", Unit::Count, "Nombre de connexions WebSocket actives"); - describe_gauge!("active_rooms", Unit::Count, "Nombre de salons actifs"); - describe_gauge!("cache_hit_ratio", Unit::Percent, "Ratio de hits du cache"); - describe_gauge!("memory_usage_bytes", Unit::Bytes, "Utilisation mémoire en bytes"); - describe_gauge!("cpu_usage_percent", Unit::Percent, "Utilisation CPU en pourcentage"); - describe_gauge!("uptime_seconds", Unit::Seconds, "Temps de fonctionnement en secondes"); - describe_gauge!("database_connections_active", Unit::Count, "Connexions de base de données actives"); - describe_gauge!("users_online", Unit::Count, "Utilisateurs en ligne"); - describe_gauge!("users_away", Unit::Count, "Utilisateurs absents"); - describe_gauge!("users_busy", Unit::Count, "Utilisateurs occupés"); - describe_gauge!("users_offline", Unit::Count, "Utilisateurs hors ligne"); - - // Descriptions des histogrammes - describe_histogram!("message_processing_duration_seconds", Unit::Seconds, "Durée de traitement des messages"); - describe_histogram!("message_size_bytes", Unit::Bytes, "Taille des messages en bytes"); - describe_histogram!("request_duration_seconds", Unit::Seconds, "Durée des requêtes HTTP"); - describe_histogram!("database_operation_duration_seconds", Unit::Seconds, "Durée des opérations de base de données"); - - Ok(()) - } - - /// Crée une nouvelle instance des métriques - pub fn new(config: &PrometheusConfig) -> Result { - // Initialiser les descriptions - Self::init_metrics()?; - - // Configurer l'export Prometheus - PrometheusBuilder::new() - .with_http_listener(config.bind_addr) - .install() - .map_err(|e| crate::error::ChatError::internal_error(format!("Failed to install Prometheus exporter: {e}")))?; - - info!("📊 Métriques Prometheus configurées"); - - // Enregistrement des métriques - let websocket_connections = gauge!("websocket_connections"); - let websocket_connections_total = counter!("websocket_connections_total"); - let websocket_disconnections_total = counter!("websocket_disconnections_total"); - - let messages_sent_total = counter!("messages_sent_total"); - let messages_received_total = counter!("messages_received_total"); - let message_processing_duration = histogram!("message_processing_duration_seconds"); - let message_size_bytes = histogram!("message_size_bytes"); - - let active_rooms = gauge!("active_rooms"); - let rooms_created_total = counter!("rooms_created_total"); - let rooms_deleted_total = counter!("rooms_deleted_total"); - - let errors_total = counter!("errors_total"); - let authentication_failures_total = counter!("authentication_failures_total"); - let rate_limits_triggered_total = counter!("rate_limits_triggered_total"); - - let request_duration_seconds = histogram!("request_duration_seconds"); - let database_operation_duration_seconds = histogram!("database_operation_duration_seconds"); - let cache_hit_ratio = gauge!("cache_hit_ratio"); - - let memory_usage_bytes = gauge!("memory_usage_bytes"); - let cpu_usage_percent = gauge!("cpu_usage_percent"); - let uptime_seconds = gauge!("uptime_seconds"); - - let security_events_total = counter!("security_events_total"); - let jwt_tokens_issued_total = counter!("jwt_tokens_issued_total"); - let jwt_tokens_revoked_total = counter!("jwt_tokens_revoked_total"); - - let cache_operations_total = counter!("cache_operations_total"); - let cache_hits_total = counter!("cache_hits_total"); - let cache_misses_total = counter!("cache_misses_total"); - - let database_connections_active = gauge!("database_connections_active"); - let database_operations_total = counter!("database_operations_total"); - let database_errors_total = counter!("database_errors_total"); - - let moderation_actions_total = counter!("moderation_actions_total"); - let content_filtered_total = counter!("content_filtered_total"); - let users_banned_total = counter!("users_banned_total"); - - let users_online = gauge!("users_online"); - let users_away = gauge!("users_away"); - let users_busy = gauge!("users_busy"); - let users_offline = gauge!("users_offline"); - - Ok(Self { - websocket_connections, - websocket_connections_total, - websocket_disconnections_total, - messages_sent_total, - messages_received_total, - message_processing_duration, - message_size_bytes, - active_rooms, - rooms_created_total, - rooms_deleted_total, - errors_total, - authentication_failures_total, - rate_limits_triggered_total, - request_duration_seconds, - database_operation_duration_seconds, - cache_hit_ratio, - memory_usage_bytes, - cpu_usage_percent, - uptime_seconds, - security_events_total, - jwt_tokens_issued_total, - jwt_tokens_revoked_total, - cache_operations_total, - cache_hits_total, - cache_misses_total, - database_connections_active, - database_operations_total, - database_errors_total, - moderation_actions_total, - content_filtered_total, - users_banned_total, - users_online, - users_away, - users_busy, - users_offline, - }) - } - - /// Enregistre une connexion WebSocket - pub fn record_websocket_connection(&self) { - self.websocket_connections.increment(1.0); - self.websocket_connections_total.increment(1); - } - - /// Enregistre une déconnexion WebSocket - pub fn record_websocket_disconnection(&self) { - self.websocket_connections.decrement(1.0); - self.websocket_disconnections_total.increment(1); - } - - /// Enregistre un message envoyé - pub fn record_message_sent(&self, size_bytes: u64) { - self.messages_sent_total.increment(1); - self.message_size_bytes.record(size_bytes as f64); - } - - /// Enregistre un message reçu - pub fn record_message_received(&self, size_bytes: u64) { - self.messages_received_total.increment(1); - self.message_size_bytes.record(size_bytes as f64); - } - - /// Enregistre la durée de traitement d'un message - pub fn record_message_processing_duration(&self, duration: Duration) { - self.message_processing_duration.record(duration.as_secs_f64()); - } - - /// Enregistre la création d'un salon - pub fn record_room_created(&self, room_type: &str) { - self.rooms_created_total.increment(1); - self.update_active_rooms(1); - } - - /// Enregistre la suppression d'un salon - pub fn record_room_deleted(&self, room_type: &str) { - self.rooms_deleted_total.increment(1); - self.update_active_rooms(-1); - } - - /// Met à jour le nombre de salons actifs - pub fn update_active_rooms(&self, delta: i32) { - if delta > 0 { - self.active_rooms.increment(delta as f64); - } else { - self.active_rooms.decrement((-delta) as f64); - } - } - - /// Enregistre une erreur - pub fn record_error(&self, error_type: &str) { - self.errors_total.increment(1); - } - - /// Enregistre un échec d'authentification - pub fn record_authentication_failure(&self) { - self.authentication_failures_total.increment(1); - } - - /// Enregistre un rate limit déclenché - pub fn record_rate_limit_triggered(&self) { - self.rate_limits_triggered_total.increment(1); - } - - /// Enregistre la durée d'une requête HTTP - pub fn record_request_duration(&self, duration: Duration, method: &str, endpoint: &str, status_code: u16) { - self.request_duration_seconds.record(duration.as_secs_f64()); - } - - /// Enregistre la durée d'une opération de base de données - pub fn record_database_operation_duration(&self, duration: Duration, operation: &str, table: &str) { - self.database_operation_duration_seconds.record(duration.as_secs_f64()); - } - - /// Enregistre une opération de cache - pub fn record_cache_operation(&self, operation_type: &str) { - self.cache_operations_total.increment(1); - } - - /// Enregistre une opération de base de données - pub fn record_database_operation(&self, operation_type: &str) { - self.database_operations_total.increment(1); - } - - /// Enregistre une erreur de base de données - pub fn record_database_error(&self, error_type: &str) { - self.database_errors_total.increment(1); - } - - /// Enregistre une action de modération - pub fn record_moderation_action(&self, action_type: &str) { - self.moderation_actions_total.increment(1); - } - - /// Enregistre du contenu filtré - pub fn record_content_filtered(&self, filter_type: &str) { - self.content_filtered_total.increment(1); - } - - /// Enregistre un utilisateur banni - pub fn record_user_banned(&self, reason: &str) { - self.users_banned_total.increment(1); - } - - /// Enregistre un upload de fichier - pub fn record_file_upload(&self, file_type: &str, size_bytes: u64) { - // Implementation pour les uploads de fichiers - debug!("File upload recorded: {} bytes", size_bytes); - } - - /// Enregistre un échec d'upload de fichier - pub fn record_file_upload_failed(&self, error_type: &str) { - // Implementation pour les échecs d'upload - debug!("File upload failed: {}", error_type); - } - - /// Enregistre un appel webhook - pub fn record_webhook_call(&self, webhook_type: &str, status_code: u16) { - // Implementation pour les webhooks - debug!("Webhook call recorded: {} - {}", webhook_type, status_code); - } - - /// Met à jour les métriques système - pub fn update_system_metrics(&self) { - // Implementation pour les métriques système - debug!("System metrics updated"); - } -} - -/// Handler pour l'endpoint Prometheus -pub async fn prometheus_handler(State(_metrics): State>) -> Response { - // Le handle Prometheus gère automatiquement l'export - Response::builder() - .status(StatusCode::OK) - .header("Content-Type", "text/plain; version=0.0.4; charset=utf-8") - .body("Metrics available at /metrics".to_string()) - .unwrap_or_else(|_| { - Response::new("Internal error building metrics response".to_string()) - }) -} - -/// Crée le routeur Prometheus -pub fn create_prometheus_router(metrics: Arc) -> Router { - Router::new() - .route("/metrics", get(prometheus_handler)) - .with_state(metrics) -} diff --git a/veza-chat-server/src/rate_limiter.rs b/veza-chat-server/src/rate_limiter.rs deleted file mode 100644 index 90146c834..000000000 --- a/veza-chat-server/src/rate_limiter.rs +++ /dev/null @@ -1,453 +0,0 @@ -//! Module de protection DoS pour WebSocket -//! -//! Ce module implémente la protection contre les attaques par déni de service -//! pour les connexions WebSocket, incluant la limitation des connexions simultanées, -//! la taille des messages, et la détection de flood. - -use crate::error::{ChatError, Result}; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use tokio::sync::RwLock; -use uuid::Uuid; - -/// Configuration de protection DoS -#[derive(Debug, Clone)] -pub struct DosProtectionConfig { - pub max_connections_per_user: usize, - pub max_message_size: usize, - pub inactivity_timeout: Duration, - pub flood_detection_window: Duration, - pub max_messages_per_window: u32, - pub connection_rate_limit: u32, - pub connection_rate_window: Duration, -} - -impl Default for DosProtectionConfig { - fn default() -> Self { - Self { - max_connections_per_user: 5, - max_message_size: 64 * 1024, // 64KB - inactivity_timeout: Duration::from_secs(300), // 5 minutes - flood_detection_window: Duration::from_secs(10), - max_messages_per_window: 10, - connection_rate_limit: 10, - connection_rate_window: Duration::from_secs(60), - } - } -} - -/// État de protection DoS pour un utilisateur -#[derive(Debug, Clone)] -pub struct UserDosState { - pub user_id: Uuid, - pub active_connections: Vec, - pub message_timestamps: Vec, - pub connection_timestamps: Vec, - pub last_activity: SystemTime, - pub is_blocked: bool, - pub block_until: Option, -} - -/// Gestionnaire de protection DoS -pub struct DosProtectionManager { - config: DosProtectionConfig, - user_states: Arc>>, - ip_states: Arc>>, -} - -/// État de protection DoS pour une IP -#[derive(Debug, Clone)] -pub struct IpDosState { - pub connection_timestamps: Vec, - pub is_blocked: bool, - pub block_until: Option, -} - -impl DosProtectionManager { - /// Crée un nouveau gestionnaire de protection DoS - pub fn new(config: DosProtectionConfig) -> Self { - Self { - config, - user_states: Arc::new(RwLock::new(HashMap::new())), - ip_states: Arc::new(RwLock::new(HashMap::new())), - } - } - - /// Vérifie si une nouvelle connexion est autorisée pour un utilisateur - pub async fn check_connection_allowed( - &self, - user_id: Uuid, - connection_id: Uuid, - ip_address: &str, - ) -> Result { - // Vérifier la limitation par IP - if !self.check_ip_rate_limit(ip_address).await? { - return Ok(false); - } - - // Vérifier la limitation par utilisateur - if !self.check_user_connection_limit(user_id, connection_id).await? { - return Ok(false); - } - - // Enregistrer la connexion - self.record_connection(user_id, connection_id, ip_address).await?; - - Ok(true) - } - - /// Vérifie la limitation de taux par IP - async fn check_ip_rate_limit(&self, ip_address: &str) -> Result { - let mut ip_states = self.ip_states.write().await; - let now = SystemTime::now(); - - // Nettoyer les timestamps expirés - if let Some(ip_state) = ip_states.get_mut(ip_address) { - // Vérifier si l'IP est bloquée - if let Some(block_until) = ip_state.block_until { - if now < block_until { - return Ok(false); - } else { - ip_state.is_blocked = false; - ip_state.block_until = None; - } - } - - // Nettoyer les timestamps expirés - ip_state.connection_timestamps.retain(|×tamp| { - now.duration_since(timestamp) - .map(|d| d < self.config.connection_rate_window) - .unwrap_or(false) - }); - - // Vérifier la limite de taux - if ip_state.connection_timestamps.len() >= self.config.connection_rate_limit as usize { - // Bloquer l'IP temporairement - ip_state.is_blocked = true; - ip_state.block_until = Some(now + Duration::from_secs(300)); // 5 minutes - return Ok(false); - } - - ip_state.connection_timestamps.push(now); - } else { - // Première connexion depuis cette IP - let mut ip_state = IpDosState { - connection_timestamps: vec![now], - is_blocked: false, - block_until: None, - }; - ip_states.insert(ip_address.to_string(), ip_state); - } - - Ok(true) - } - - /// Vérifie la limitation de connexions par utilisateur - async fn check_user_connection_limit(&self, user_id: Uuid, connection_id: Uuid) -> Result { - let mut user_states = self.user_states.write().await; - let now = SystemTime::now(); - - if let Some(user_state) = user_states.get_mut(&user_id) { - // Vérifier si l'utilisateur est bloqué - if let Some(block_until) = user_state.block_until { - if now < block_until { - return Ok(false); - } else { - user_state.is_blocked = false; - user_state.block_until = None; - } - } - - // Nettoyer les connexions inactives - user_state.active_connections.retain(|&conn_id| { - // Dans une implémentation complète, on vérifierait l'état réel de la connexion - conn_id != connection_id // Garder toutes les connexions sauf celle qu'on veut ajouter - }); - - // Vérifier la limite de connexions - if user_state.active_connections.len() >= self.config.max_connections_per_user { - return Ok(false); - } - - user_state.active_connections.push(connection_id); - user_state.last_activity = now; - } else { - // Premier utilisateur - let user_state = UserDosState { - user_id, - active_connections: vec![connection_id], - message_timestamps: Vec::new(), - connection_timestamps: vec![now], - last_activity: now, - is_blocked: false, - block_until: None, - }; - user_states.insert(user_id, user_state); - } - - Ok(true) - } - - /// Enregistre une nouvelle connexion - async fn record_connection( - &self, - user_id: Uuid, - connection_id: Uuid, - ip_address: &str, - ) -> Result<()> { - // L'enregistrement est déjà fait dans check_user_connection_limit - // Cette méthode peut être étendue pour des logs ou métriques supplémentaires - Ok(()) - } - - /// Vérifie si un message est autorisé (protection contre le flood) - pub async fn check_message_allowed(&self, user_id: Uuid) -> Result { - let mut user_states = self.user_states.write().await; - let now = SystemTime::now(); - - if let Some(user_state) = user_states.get_mut(&user_id) { - // Vérifier si l'utilisateur est bloqué - if let Some(block_until) = user_state.block_until { - if now < block_until { - return Ok(false); - } else { - user_state.is_blocked = false; - user_state.block_until = None; - } - } - - // Nettoyer les timestamps expirés - user_state.message_timestamps.retain(|×tamp| { - now.duration_since(timestamp) - .map(|d| d < self.config.flood_detection_window) - .unwrap_or(false) - }); - - // Vérifier la limite de messages - if user_state.message_timestamps.len() >= self.config.max_messages_per_window as usize { - // Bloquer l'utilisateur temporairement - user_state.is_blocked = true; - user_state.block_until = Some(now + Duration::from_secs(60)); // 1 minute - return Ok(false); - } - - user_state.message_timestamps.push(now); - user_state.last_activity = now; - } - - Ok(true) - } - - /// Valide la taille d'un message - pub fn validate_message_size(&self, message_size: usize) -> Result { - if message_size > self.config.max_message_size { - Err(ChatError::rate_limit_error(&format!( - "Message too large: {} bytes (max: {} bytes)", - message_size, self.config.max_message_size - ))) - } else { - Ok(true) - } - } - - /// Déconnecte un utilisateur - pub async fn disconnect_user(&self, user_id: Uuid, connection_id: Uuid) -> Result<()> { - let mut user_states = self.user_states.write().await; - - if let Some(user_state) = user_states.get_mut(&user_id) { - user_state.active_connections.retain(|&id| id != connection_id); - - // Supprimer l'état utilisateur s'il n'y a plus de connexions - if user_state.active_connections.is_empty() { - user_states.remove(&user_id); - } - } - - Ok(()) - } - - /// Nettoie les états expirés - pub async fn cleanup_expired_states(&self) -> Result<()> { - let now = SystemTime::now(); - - // Nettoyer les états utilisateur - { - let mut user_states = self.user_states.write().await; - user_states.retain(|_, user_state| { - // Garder les utilisateurs avec des connexions actives ou récentes - !user_state.active_connections.is_empty() || - now.duration_since(user_state.last_activity) - .map(|d| d < Duration::from_secs(3600)) // 1 heure - .unwrap_or(false) - }); - } - - // Nettoyer les états IP - { - let mut ip_states = self.ip_states.write().await; - ip_states.retain(|_, ip_state| { - // Garder les IPs avec des connexions récentes - !ip_state.connection_timestamps.is_empty() || - ip_state.is_blocked - }); - } - - Ok(()) - } - - /// Obtient les statistiques de protection DoS - pub async fn get_dos_stats(&self) -> Result { - let user_states = self.user_states.read().await; - let ip_states = self.ip_states.read().await; - - let total_users = user_states.len(); - let blocked_users = user_states.values().filter(|s| s.is_blocked).count(); - let total_connections: usize = user_states.values().map(|s| s.active_connections.len()).sum(); - let blocked_ips = ip_states.values().filter(|s| s.is_blocked).count(); - - Ok(DosStats { - total_users, - blocked_users, - total_connections, - blocked_ips, - max_connections_per_user: self.config.max_connections_per_user, - max_message_size: self.config.max_message_size, - }) - } - - /// Force le déblocage d'un utilisateur (pour les administrateurs) - pub async fn unblock_user(&self, user_id: Uuid) -> Result<()> { - let mut user_states = self.user_states.write().await; - - if let Some(user_state) = user_states.get_mut(&user_id) { - user_state.is_blocked = false; - user_state.block_until = None; - } - - Ok(()) - } - - /// Force le déblocage d'une IP (pour les administrateurs) - pub async fn unblock_ip(&self, ip_address: &str) -> Result<()> { - let mut ip_states = self.ip_states.write().await; - - if let Some(ip_state) = ip_states.get_mut(ip_address) { - ip_state.is_blocked = false; - ip_state.block_until = None; - } - - Ok(()) - } -} - -/// Statistiques de protection DoS -#[derive(Debug, Serialize)] -pub struct DosStats { - pub total_users: usize, - pub blocked_users: usize, - pub total_connections: usize, - pub blocked_ips: usize, - pub max_connections_per_user: usize, - pub max_message_size: usize, -} - -impl Default for DosProtectionManager { - fn default() -> Self { - Self::new(DosProtectionConfig::default()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - - #[tokio::test] - async fn test_connection_limit() { - let config = DosProtectionConfig { - max_connections_per_user: 2, - max_message_size: 1024, - inactivity_timeout: Duration::from_secs(300), - flood_detection_window: Duration::from_secs(10), - max_messages_per_window: 10, - connection_rate_limit: 5, - connection_rate_window: Duration::from_secs(60), - }; - - let manager = DosProtectionManager::new(config); - let user_id = Uuid::new_v4(); - let ip_address = "192.168.1.1"; - - // Première connexion - devrait être autorisée - let conn1 = Uuid::new_v4(); - assert!(manager.check_connection_allowed(user_id, conn1, ip_address).await.unwrap()); - - // Deuxième connexion - devrait être autorisée - let conn2 = Uuid::new_v4(); - assert!(manager.check_connection_allowed(user_id, conn2, ip_address).await.unwrap()); - - // Troisième connexion - devrait être refusée - let conn3 = Uuid::new_v4(); - assert!(!manager.check_connection_allowed(user_id, conn3, ip_address).await.unwrap()); - } - - #[tokio::test] - async fn test_message_flood_protection() { - let manager = DosProtectionManager::default(); - let user_id = Uuid::new_v4(); - - // Envoyer des messages rapidement - for i in 0..12 { - let allowed = manager.check_message_allowed(user_id).await.unwrap(); - if i < 10 { - assert!(allowed, "Message {} should be allowed", i); - } else { - assert!(!allowed, "Message {} should be blocked", i); - } - } - } - - #[test] - fn test_message_size_validation() { - let manager = DosProtectionManager::default(); - - // Message de taille normale - assert!(manager.validate_message_size(512).is_ok()); - - // Message trop grand - assert!(manager.validate_message_size(128 * 1024).is_err()); - } - - #[test] - fn test_dos_config_default() { - let config = DosProtectionConfig::default(); - assert_eq!(config.max_connections_per_user, 5); - assert_eq!(config.max_message_size, 64 * 1024); - assert_eq!(config.max_messages_per_window, 10); - assert_eq!(config.connection_rate_limit, 10); - } - - #[test] - fn test_validate_message_size_at_limit() { - let config = DosProtectionConfig { - max_message_size: 1024, - ..DosProtectionConfig::default() - }; - let manager = DosProtectionManager::new(config); - assert!(manager.validate_message_size(1024).is_ok()); - } - - #[test] - fn test_validate_message_size_zero() { - let manager = DosProtectionManager::default(); - assert!(manager.validate_message_size(0).is_ok()); - } - - #[test] - fn test_dos_stats_structure() { - let config = DosProtectionConfig::default(); - assert!(config.max_connections_per_user > 0); - assert!(config.flood_detection_window.as_secs() > 0); - } -} \ No newline at end of file diff --git a/veza-chat-server/src/reactions.rs b/veza-chat-server/src/reactions.rs deleted file mode 100644 index 95827853a..000000000 --- a/veza-chat-server/src/reactions.rs +++ /dev/null @@ -1,223 +0,0 @@ -use serde::{Deserialize, Serialize}; -use sqlx::types::chrono::{DateTime, Utc}; -use sqlx::PgPool; -use std::collections::HashMap; -use tracing::{debug, info, instrument}; -use uuid::Uuid; - -/// Émoji de réaction supporté -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum ReactionEmoji { - Like, - Love, - Haha, - Wow, - Sad, - Angry, -} - -impl ReactionEmoji { - pub fn as_str(&self) -> &'static str { - match self { - ReactionEmoji::Like => "👍", - ReactionEmoji::Love => "❤️", - ReactionEmoji::Haha => "😂", - ReactionEmoji::Wow => "😮", - ReactionEmoji::Sad => "😢", - ReactionEmoji::Angry => "😠", - } - } - - pub fn from_str(emoji: &str) -> Option { - match emoji { - "👍" => Some(ReactionEmoji::Like), - "❤️" => Some(ReactionEmoji::Love), - "😂" => Some(ReactionEmoji::Haha), - "😮" => Some(ReactionEmoji::Wow), - "😢" => Some(ReactionEmoji::Sad), - "😠" => Some(ReactionEmoji::Angry), - _ => None, - } - } -} - -/// Représente une réaction sur un message -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageReaction { - pub message_id: Uuid, - pub user_id: Uuid, - pub emoji: ReactionEmoji, - pub created_at: DateTime, -} - -/// Manager pour gérer les réactions sur les messages -pub struct ReactionsManager { - pool: PgPool, -} - -impl ReactionsManager { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - /// Ajouter une réaction à un message - #[instrument(skip(self))] - pub async fn add_reaction( - &self, - message_id: Uuid, - user_id: Uuid, - emoji: ReactionEmoji, - ) -> Result<(), sqlx::Error> { - // Vérifier si l'utilisateur a déjà réagi à ce message - let existing: Option<(i32,)> = sqlx::query_as( - "SELECT id FROM message_reactions - WHERE message_id = $1 AND user_id = $2" - ) - .bind(message_id) - .bind(user_id) - .fetch_optional(&self.pool) - .await?; - - if let Some(_) = existing { - // L'utilisateur a déjà réagi, supprimer la réaction existante - sqlx::query( - "DELETE FROM message_reactions WHERE message_id = $1 AND user_id = $2" - ) - .bind(message_id) - .bind(user_id) - .execute(&self.pool) - .await?; - - debug!( - message_id = %message_id, - user_id = %user_id, - "Existing reaction removed" - ); - } - - // Ajouter la nouvelle réaction - sqlx::query( - "INSERT INTO message_reactions (message_id, user_id, reaction_type, created_at) - VALUES ($1, $2, $3, NOW())" - ) - .bind(message_id) - .bind(user_id) - .bind(emoji.as_str()) - .execute(&self.pool) - .await?; - - info!( - message_id = %message_id, - user_id = %user_id, - emoji = %emoji.as_str(), - "Reaction added to message" - ); - - Ok(()) - } - - /// Retirer une réaction d'un message - #[instrument(skip(self))] - pub async fn remove_reaction( - &self, - message_id: Uuid, - user_id: Uuid, - ) -> Result<(), sqlx::Error> { - sqlx::query( - "DELETE FROM message_reactions WHERE message_id = $1 AND user_id = $2" - ) - .bind(message_id) - .bind(user_id) - .execute(&self.pool) - .await?; - - info!( - message_id = %message_id, - user_id = %user_id, - "Reaction removed from message" - ); - - Ok(()) - } - - /// Obtenir toutes les réactions d'un message - #[instrument(skip(self))] - pub async fn get_message_reactions( - &self, - message_id: Uuid, - ) -> Result>, sqlx::Error> { - let reactions: Vec<(String, Uuid)> = sqlx::query_as( - "SELECT reaction_type, user_id FROM message_reactions WHERE message_id = $1" - ) - .bind(message_id) - .fetch_all(&self.pool) - .await?; - - let mut result = HashMap::new(); - - for (emoji_str, user_id) in reactions { - if let Some(emoji) = ReactionEmoji::from_str(&emoji_str) { - result.entry(emoji).or_insert_with(Vec::new).push(user_id); - } - } - - Ok(result) - } - - /// Obtenir le nombre de réactions par émoji pour un message - #[instrument(skip(self))] - pub async fn get_reaction_counts( - &self, - message_id: Uuid, - ) -> Result, sqlx::Error> { - let reactions = self.get_message_reactions(message_id).await?; - - let counts: HashMap = reactions - .iter() - .map(|(emoji, users)| (emoji.clone(), users.len())) - .collect(); - - Ok(counts) - } - - /// Obtenir les réactions d'un user pour tous les messages d'une conversation - #[instrument(skip(self))] - pub async fn get_user_reactions_in_conversation( - &self, - conversation_id: Uuid, - user_id: Uuid, - ) -> Result, sqlx::Error> { - let reactions: Vec<(Uuid, String)> = sqlx::query_as( - "SELECT mr.message_id, mr.reaction_type - FROM message_reactions mr - JOIN messages m ON m.id = mr.message_id - WHERE m.conversation_id = $1 AND mr.user_id = $2" - ) - .bind(conversation_id) - .bind(user_id) - .fetch_all(&self.pool) - .await?; - - let mut result = HashMap::new(); - - for (message_id, emoji_str) in reactions { - if let Some(emoji) = ReactionEmoji::from_str(&emoji_str) { - result.insert(message_id, emoji); - } - } - - Ok(result) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_reaction_emoji_conversion() { - assert_eq!(ReactionEmoji::Like.as_str(), "👍"); - assert_eq!(ReactionEmoji::from_str("👍"), Some(ReactionEmoji::Like)); - assert_eq!(ReactionEmoji::from_str("invalid"), None); - } -} diff --git a/veza-chat-server/src/read_receipts.rs b/veza-chat-server/src/read_receipts.rs deleted file mode 100644 index e4201c234..000000000 --- a/veza-chat-server/src/read_receipts.rs +++ /dev/null @@ -1,495 +0,0 @@ -//! Module de gestion des read receipts (marquage de messages comme lus) -//! -//! Ce module fournit un système complet pour tracker quels messages -//! ont été lus par quels utilisateurs dans quelles conversations. - -use serde::{Deserialize, Serialize}; -use sqlx::types::chrono::{DateTime, Utc}; -use sqlx::{FromRow, Pool, Postgres}; -use tracing::{debug, info, instrument}; -use uuid::Uuid; - -/// Représente un read receipt pour un message -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -pub struct ReadReceipt { - pub id: Uuid, - pub message_id: Uuid, - pub user_id: Uuid, - pub conversation_id: Uuid, - pub read_at: DateTime, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// État de lecture d'un message -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum MessageReadStatus { - Sent, - Delivered, - Read, -} - -/// Manager pour gérer les read receipts -pub struct ReadReceiptManager { - pool: Pool, -} - -impl ReadReceiptManager { - /// Crée un nouveau ReadReceiptManager - pub fn new(pool: Pool) -> Self { - Self { pool } - } - - /// Vérifie si un utilisateur est membre d'une conversation - #[instrument(skip(self))] - pub async fn is_user_in_conversation( - &self, - user_id: Uuid, - conversation_id: Uuid, - ) -> Result { - let exists: bool = sqlx::query_scalar( - "SELECT EXISTS( - SELECT 1 FROM conversation_members - WHERE conversation_id = $1 AND user_id = $2 - )", - ) - .bind(conversation_id) - .bind(user_id) - .fetch_one(&self.pool) - .await?; - - Ok(exists) - } - - /// Marquer un message comme lu par un utilisateur - /// - /// Si le read receipt existe déjà, met à jour le timestamp `read_at`. - /// Retourne le read receipt créé ou mis à jour. - #[instrument(skip(self))] - pub async fn mark_as_read( - &self, - user_id: Uuid, - message_id: Uuid, - conversation_id: Uuid, - ) -> Result { - // Vérifier si le read receipt existe déjà - let existing: Option = sqlx::query_as::<_, ReadReceipt>( - "SELECT id, message_id, user_id, conversation_id, read_at, created_at, updated_at - FROM read_receipts - WHERE message_id = $1 AND user_id = $2", - ) - .bind(message_id) - .bind(user_id) - .fetch_optional(&self.pool) - .await?; - - if let Some(receipt) = existing { - // Mettre à jour le timestamp de lecture - let updated = sqlx::query_as::<_, ReadReceipt>( - "UPDATE read_receipts - SET read_at = NOW(), updated_at = NOW() - WHERE id = $1 - RETURNING id, message_id, user_id, conversation_id, read_at, created_at, updated_at" - ) - .bind(receipt.id) - .fetch_one(&self.pool) - .await?; - - debug!( - message_id = %message_id, - user_id = %user_id, - conversation_id = %conversation_id, - "Read receipt updated" - ); - - return Ok(updated); - } - - // Créer un nouveau read receipt - let receipt = sqlx::query_as::<_, ReadReceipt>( - "INSERT INTO read_receipts (message_id, user_id, conversation_id, read_at, created_at, updated_at) - VALUES ($1, $2, $3, NOW(), NOW(), NOW()) - RETURNING id, message_id, user_id, conversation_id, read_at, created_at, updated_at" - ) - .bind(message_id) - .bind(user_id) - .bind(conversation_id) - .fetch_one(&self.pool) - .await?; - - info!( - message_id = %message_id, - user_id = %user_id, - conversation_id = %conversation_id, - "Message marked as read" - ); - - Ok(receipt) - } - - /// Marquer plusieurs messages comme lus (batch operation pour performance) - #[instrument(skip(self, message_ids))] - pub async fn mark_multiple_as_read( - &self, - message_ids: &[Uuid], - user_id: Uuid, - conversation_id: Uuid, - ) -> Result, sqlx::Error> { - if message_ids.is_empty() { - return Ok(Vec::new()); - } - - let mut tx = self.pool.begin().await?; - - // Récupérer les read receipts déjà existants pour éviter les doublons - let existing: Vec = sqlx::query_scalar( - "SELECT message_id FROM read_receipts - WHERE message_id = ANY($1) AND user_id = $2", - ) - .bind(message_ids) - .bind(user_id) - .fetch_all(&mut *tx) - .await?; - - let to_insert: Vec = message_ids - .iter() - .filter(|id| !existing.contains(id)) - .copied() - .collect(); - - let mut receipts = Vec::new(); - - // Mettre à jour les existants - if !existing.is_empty() { - let updated: Vec = sqlx::query_as::<_, ReadReceipt>( - "UPDATE read_receipts - SET read_at = NOW(), updated_at = NOW() - WHERE message_id = ANY($1) AND user_id = $2 - RETURNING id, message_id, user_id, conversation_id, read_at, created_at, updated_at" - ) - .bind(&existing) - .bind(user_id) - .fetch_all(&mut *tx) - .await?; - - receipts.extend(updated); - } - - // Insérer les nouveaux - if !to_insert.is_empty() { - // Pour UUID, on doit utiliser une approche différente de UNNEST - for message_id in &to_insert { - let receipt: ReadReceipt = sqlx::query_as::<_, ReadReceipt>( - "INSERT INTO read_receipts (message_id, user_id, conversation_id, read_at, created_at, updated_at) - VALUES ($1, $2, $3, NOW(), NOW(), NOW()) - RETURNING id, message_id, user_id, conversation_id, read_at, created_at, updated_at" - ) - .bind(message_id) - .bind(user_id) - .bind(conversation_id) - .fetch_one(&mut *tx) - .await?; - - receipts.push(receipt); - } - } - - tx.commit().await?; - - info!( - count = receipts.len(), - user_id = %user_id, - conversation_id = %conversation_id, - "Multiple messages marked as read" - ); - - Ok(receipts) - } - - /// Obtenir le statut de lecture d'un message pour un utilisateur - #[instrument(skip(self))] - pub async fn get_message_status( - &self, - message_id: Uuid, - user_id: Uuid, - ) -> Result { - // Vérifier si le message a un read receipt - let read_at: Option> = sqlx::query_scalar( - "SELECT read_at FROM read_receipts WHERE message_id = $1 AND user_id = $2", - ) - .bind(message_id) - .bind(user_id) - .fetch_optional(&self.pool) - .await?; - - if read_at.is_some() { - Ok(MessageReadStatus::Read) - } else { - // Vérifier si le message a été délivré (delivered_status) - let delivered: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM delivered_status WHERE message_id = $1 AND user_id = $2)", - ) - .bind(message_id) - .bind(user_id) - .fetch_one(&self.pool) - .await?; - - if delivered { - Ok(MessageReadStatus::Delivered) - } else { - Ok(MessageReadStatus::Sent) - } - } - } - - /// Obtenir tous les read receipts pour un message - #[instrument(skip(self))] - pub async fn get_receipts_for_message( - &self, - message_id: Uuid, - ) -> Result, sqlx::Error> { - let receipts = sqlx::query_as::<_, ReadReceipt>( - "SELECT id, message_id, user_id, conversation_id, read_at, created_at, updated_at - FROM read_receipts - WHERE message_id = $1 - ORDER BY read_at ASC", - ) - .bind(message_id) - .fetch_all(&self.pool) - .await?; - - Ok(receipts) - } - - /// Obtenir la dernière lecture d'un utilisateur dans une conversation - #[instrument(skip(self))] - pub async fn get_last_read_message( - &self, - conversation_id: Uuid, - user_id: Uuid, - ) -> Result, sqlx::Error> { - let last_message_id: Option = sqlx::query_scalar( - "SELECT message_id FROM read_receipts - WHERE conversation_id = $1 AND user_id = $2 - ORDER BY read_at DESC LIMIT 1", - ) - .bind(conversation_id) - .bind(user_id) - .fetch_optional(&self.pool) - .await?; - - Ok(last_message_id) - } - - /// Obtenir le nombre de messages non lus pour un utilisateur dans une conversation - #[instrument(skip(self))] - pub async fn get_unread_count( - &self, - conversation_id: Uuid, - user_id: Uuid, - last_read_message_id: Option, - ) -> Result { - let count: Option = if let Some(last_id) = last_read_message_id { - // Compter les messages après le dernier lu (qui ne sont pas de l'utilisateur) - sqlx::query_scalar( - "SELECT COUNT(*) FROM messages - WHERE conversation_id = $1 AND id > $2 AND sender_id != $3 AND is_deleted = false", - ) - .bind(conversation_id) - .bind(last_id) - .bind(user_id) - .fetch_optional(&self.pool) - .await? - } else { - // Pas de dernière lecture, compter tous les messages de la conversation - // (qui ne sont pas de l'utilisateur) - sqlx::query_scalar( - "SELECT COUNT(*) FROM messages - WHERE conversation_id = $1 AND sender_id != $2 AND is_deleted = false", - ) - .bind(conversation_id) - .bind(user_id) - .fetch_optional(&self.pool) - .await? - }; - - Ok(count.unwrap_or(0)) - } - - /// Obtenir un read receipt spécifique - #[instrument(skip(self))] - pub async fn get_receipt( - &self, - message_id: Uuid, - user_id: Uuid, - ) -> Result, sqlx::Error> { - let receipt = sqlx::query_as::<_, ReadReceipt>( - "SELECT id, message_id, user_id, conversation_id, read_at, created_at, updated_at - FROM read_receipts - WHERE message_id = $1 AND user_id = $2", - ) - .bind(message_id) - .bind(user_id) - .fetch_optional(&self.pool) - .await?; - - Ok(receipt) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use sqlx::PgPool; - - /// Setup une base de données de test - async fn setup_test_db() -> PgPool { - let database_url = - std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for tests"); - - sqlx::PgPool::connect(&database_url) - .await - .expect("Failed to connect to test database") - } - - #[tokio::test] - #[ignore] // Nécessite une base de données de test - async fn test_mark_as_read_creates_receipt() { - let pool = setup_test_db().await; - let manager = ReadReceiptManager::new(pool); - - // Créer des UUIDs de test - let user_id = Uuid::new_v4(); - let message_id = Uuid::new_v4(); - let conversation_id = Uuid::new_v4(); - - // Marquer comme lu - let receipt = manager - .mark_as_read(user_id, message_id, conversation_id) - .await - .expect("Should mark message as read"); - - assert_eq!(receipt.message_id, message_id); - assert_eq!(receipt.user_id, user_id); - assert_eq!(receipt.conversation_id, conversation_id); - } - - #[tokio::test] - #[ignore] // Nécessite une base de données de test - async fn test_mark_as_read_updates_existing() { - let pool = setup_test_db().await; - let manager = ReadReceiptManager::new(pool); - - let user_id = Uuid::new_v4(); - let message_id = Uuid::new_v4(); - let conversation_id = Uuid::new_v4(); - - // Première lecture - let receipt1 = manager - .mark_as_read(user_id, message_id, conversation_id) - .await - .expect("Should mark message as read"); - - // Attendre un peu pour que le timestamp change - tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; - - // Deuxième lecture (devrait mettre à jour) - let receipt2 = manager - .mark_as_read(user_id, message_id, conversation_id) - .await - .expect("Should update existing receipt"); - - // Le read_at devrait être mis à jour - assert!(receipt2.read_at >= receipt1.read_at); - assert_eq!(receipt1.id, receipt2.id); // Même ID - } - - #[tokio::test] - #[ignore] // Nécessite une base de données de test - async fn test_get_receipt() { - let pool = setup_test_db().await; - let manager = ReadReceiptManager::new(pool); - - let user_id = Uuid::new_v4(); - let message_id = Uuid::new_v4(); - let conversation_id = Uuid::new_v4(); - - // Créer un read receipt - manager - .mark_as_read(user_id, message_id, conversation_id) - .await - .expect("Should mark message as read"); - - // Récupérer le read receipt - let receipt = manager - .get_receipt(message_id, user_id) - .await - .expect("Should get receipt") - .expect("Receipt should exist"); - - assert_eq!(receipt.message_id, message_id); - assert_eq!(receipt.user_id, user_id); - } - - #[tokio::test] - #[ignore] // Nécessite une base de données de test - async fn test_get_message_status() { - let pool = setup_test_db().await; - let manager = ReadReceiptManager::new(pool); - - let user_id = Uuid::new_v4(); - let message_id = Uuid::new_v4(); - let conversation_id = Uuid::new_v4(); - - // Avant le marquage - let status_before = manager - .get_message_status(message_id, user_id) - .await - .expect("Should get status"); - assert_eq!(status_before, MessageReadStatus::Sent); - - // Après le marquage - manager - .mark_as_read(user_id, message_id, conversation_id) - .await - .expect("Should mark message as read"); - - let status_after = manager - .get_message_status(message_id, user_id) - .await - .expect("Should get status"); - assert_eq!(status_after, MessageReadStatus::Read); - } - - #[tokio::test] - #[ignore] // Nécessite une base de données de test - async fn test_get_receipts_for_message() { - let pool = setup_test_db().await; - let manager = ReadReceiptManager::new(pool); - - let message_id = Uuid::new_v4(); - let conversation_id = Uuid::new_v4(); - let user1 = Uuid::new_v4(); - let user2 = Uuid::new_v4(); - - // Marquer comme lu par deux utilisateurs - manager - .mark_as_read(user1, message_id, conversation_id) - .await - .expect("Should mark as read"); - manager - .mark_as_read(user2, message_id, conversation_id) - .await - .expect("Should mark as read"); - - // Récupérer tous les read receipts - let receipts = manager - .get_receipts_for_message(message_id) - .await - .expect("Should get receipts"); - - assert_eq!(receipts.len(), 2); - assert!(receipts.iter().any(|r| r.user_id == user1)); - assert!(receipts.iter().any(|r| r.user_id == user2)); - } -} diff --git a/veza-chat-server/src/repository/message_repository.rs b/veza-chat-server/src/repository/message_repository.rs deleted file mode 100644 index b65c9f348..000000000 --- a/veza-chat-server/src/repository/message_repository.rs +++ /dev/null @@ -1,677 +0,0 @@ -use crate::models::message::{Message, MessageType}; -use chrono::{DateTime, Utc}; -use sqlx::{PgPool, Result, Row}; -use uuid::Uuid; - -pub struct MessageRepository { - pool: PgPool, -} - -impl MessageRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub fn pool(&self) -> &PgPool { - &self.pool - } - - pub async fn create( - &self, - conversation_id: Uuid, - sender_id: Uuid, - content: &str, - ) -> Result { - // Aligné avec le schéma PostgreSQL complet (migrations 001 et 002) - let row = sqlx::query( - r#" - INSERT INTO messages ( - conversation_id, sender_id, content, message_type, - is_pinned, is_deleted, is_edited, status, - created_at, updated_at - ) - VALUES ($1, $2, $3, 'text', false, false, false, 'sent', NOW(), NOW()) - RETURNING - id, - conversation_id, - sender_id, - content, - message_type, - parent_message_id, - reply_to_id, - is_pinned, - is_edited, - is_deleted, - edited_at, - deleted_at, - status, - metadata, - created_at, - updated_at - "#, - ) - .bind(conversation_id) - .bind(sender_id) - .bind(content) - .fetch_one(&self.pool) - .await?; - - let message = Message { - id: row.get("id"), - conversation_id: row.get("conversation_id"), - sender_id: row.get("sender_id"), - content: row.get("content"), - message_type: MessageType::try_from(row.get::("message_type")) - .unwrap_or(MessageType::Text), - parent_message_id: row.get("parent_message_id"), - reply_to_id: row.get("reply_to_id"), - is_pinned: row.get("is_pinned"), - is_edited: row.get("is_edited"), - is_deleted: row.get("is_deleted"), - edited_at: row.get("edited_at"), - deleted_at: row.get("deleted_at"), - status: row.get("status"), - metadata: row.get("metadata"), - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - }; - - Ok(message) - } - - pub async fn get_conversation_messages( - &self, - conversation_id: Uuid, - limit: i64, - ) -> Result> { - // Aligné avec le schéma PostgreSQL complet - let rows = sqlx::query( - r#" - SELECT - id, - conversation_id, - sender_id, - content, - message_type, - parent_message_id, - reply_to_id, - is_pinned, - is_edited, - is_deleted, - edited_at, - deleted_at, - status, - metadata, - created_at, - updated_at - FROM messages - WHERE conversation_id = $1 AND is_deleted = false - ORDER BY created_at DESC - LIMIT $2 - "#, - ) - .bind(conversation_id) - .bind(limit) - .fetch_all(&self.pool) - .await?; - - let messages: Result, sqlx::Error> = rows - .into_iter() - .map(|row| { - Ok(Message { - id: row.get("id"), - conversation_id: row.get("conversation_id"), - sender_id: row.get("sender_id"), - content: row.get("content"), - message_type: MessageType::try_from(row.get::("message_type")) - .unwrap_or(MessageType::Text), - parent_message_id: row.get("parent_message_id"), - reply_to_id: row.get("reply_to_id"), - is_pinned: row.get("is_pinned"), - is_edited: row.get("is_edited"), - is_deleted: row.get("is_deleted"), - edited_at: row.get("edited_at"), - deleted_at: row.get("deleted_at"), - status: row.get("status"), - metadata: row.get("metadata"), - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - }) - }) - .collect(); - - messages - } - - // Alias pour compatibilité avec code existant - pub async fn get_room_messages( - &self, - conversation_id: Uuid, - limit: i64, - ) -> Result> { - self.get_conversation_messages(conversation_id, limit).await - } - - pub async fn get_by_id(&self, id: Uuid) -> Result> { - let row = sqlx::query( - r#" - SELECT - id, - conversation_id, - sender_id, - content, - message_type, - parent_message_id, - reply_to_id, - is_pinned, - is_edited, - is_deleted, - edited_at, - deleted_at, - status, - metadata, - created_at, - updated_at - FROM messages - WHERE id = $1 AND is_deleted = false - "#, - ) - .bind(id) - .fetch_optional(&self.pool) - .await?; - - if let Some(row) = row { - Ok(Some(Message { - id: row.get("id"), - conversation_id: row.get("conversation_id"), - sender_id: row.get("sender_id"), - content: row.get("content"), - message_type: MessageType::try_from(row.get::("message_type")) - .unwrap_or(MessageType::Text), - parent_message_id: row.get("parent_message_id"), - reply_to_id: row.get("reply_to_id"), - is_pinned: row.get("is_pinned"), - is_edited: row.get("is_edited"), - is_deleted: row.get("is_deleted"), - edited_at: row.get("edited_at"), - deleted_at: row.get("deleted_at"), - status: row.get("status"), - metadata: row.get("metadata"), - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - })) - } else { - Ok(None) - } - } - - pub async fn update(&self, id: Uuid, new_content: &str) -> Result { - // Mettre à jour le message avec le nouveau contenu - let row = sqlx::query( - r#" - UPDATE messages - SET - content = $1, - is_edited = true, - edited_at = NOW(), - updated_at = NOW() - WHERE id = $2 AND is_deleted = false - RETURNING - id, - conversation_id, - sender_id, - content, - message_type, - parent_message_id, - reply_to_id, - is_pinned, - is_edited, - is_deleted, - edited_at, - deleted_at, - status, - metadata, - created_at, - updated_at - "#, - ) - .bind(new_content) - .bind(id) - .fetch_optional(&self.pool) - .await?; - - let row = row.ok_or_else(|| sqlx::Error::RowNotFound)?; - - Ok(Message { - id: row.get("id"), - conversation_id: row.get("conversation_id"), - sender_id: row.get("sender_id"), - content: row.get("content"), - message_type: MessageType::try_from(row.get::("message_type")) - .unwrap_or(MessageType::Text), - parent_message_id: row.get("parent_message_id"), - reply_to_id: row.get("reply_to_id"), - is_pinned: row.get("is_pinned"), - is_edited: row.get("is_edited"), - is_deleted: row.get("is_deleted"), - edited_at: row.get("edited_at"), - deleted_at: row.get("deleted_at"), - status: row.get("status"), - metadata: row.get("metadata"), - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - }) - } - - pub async fn delete(&self, id: Uuid) -> Result<()> { - // Soft delete : mettre à jour is_deleted et deleted_at - sqlx::query( - r#" - UPDATE messages - SET - is_deleted = true, - deleted_at = NOW(), - updated_at = NOW() - WHERE id = $1 - "#, - ) - .bind(id) - .execute(&self.pool) - .await?; - - Ok(()) - } - - /// Récupère un message même s'il est supprimé (pour les admins) - pub async fn get_by_id_including_deleted(&self, id: Uuid) -> Result> { - let row = sqlx::query( - r#" - SELECT - id, - conversation_id, - sender_id, - content, - message_type, - parent_message_id, - reply_to_id, - is_pinned, - is_edited, - is_deleted, - edited_at, - deleted_at, - status, - metadata, - created_at, - updated_at - FROM messages - WHERE id = $1 - "#, - ) - .bind(id) - .fetch_optional(&self.pool) - .await?; - - if let Some(row) = row { - Ok(Some(Message { - id: row.get("id"), - conversation_id: row.get("conversation_id"), - sender_id: row.get("sender_id"), - content: row.get("content"), - message_type: MessageType::try_from(row.get::("message_type")) - .unwrap_or(MessageType::Text), - parent_message_id: row.get("parent_message_id"), - reply_to_id: row.get("reply_to_id"), - is_pinned: row.get("is_pinned"), - is_edited: row.get("is_edited"), - is_deleted: row.get("is_deleted"), - edited_at: row.get("edited_at"), - deleted_at: row.get("deleted_at"), - status: row.get("status"), - metadata: row.get("metadata"), - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - })) - } else { - Ok(None) - } - } - - /// Helper pour mapper une row SQL en Message - fn row_to_message(&self, row: &sqlx::postgres::PgRow) -> Result { - Ok(Message { - id: row.get("id"), - conversation_id: row.get("conversation_id"), - sender_id: row.get("sender_id"), - content: row.get("content"), - message_type: MessageType::try_from(row.get::("message_type")) - .unwrap_or(MessageType::Text), - parent_message_id: row.get("parent_message_id"), - reply_to_id: row.get("reply_to_id"), - is_pinned: row.get("is_pinned"), - is_edited: row.get("is_edited"), - is_deleted: row.get("is_deleted"), - edited_at: row.get("edited_at"), - deleted_at: row.get("deleted_at"), - status: row.get("status"), - metadata: row.get("metadata"), - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - }) - } - - /// Récupère l'historique avec pagination par cursors (before/after) - /// - /// - `before`: Récupère les messages avant ce timestamp (tri DESC) - /// - `after`: Récupère les messages après ce timestamp (tri ASC) - /// - Les résultats sont toujours retournés en ordre ASC (du plus ancien au plus récent) - pub async fn fetch_history( - &self, - conversation_id: Uuid, - before: Option>, - after: Option>, - limit: usize, - include_deleted: bool, - ) -> Result<(Vec, bool, bool)> { - let limit = limit.min(100); // Limiter à 100 messages max - let limit_i64 = limit as i64; - - // Construire la requête selon before/after - let (rows, needs_reverse) = match (before, after) { - (Some(before_ts), None) => { - // Récupérer les messages avant before_ts (plus anciens, tri DESC) - let deleted_filter = if include_deleted { - "" - } else { - " AND is_deleted = false" - }; - let query = format!( - r#" - SELECT - id, conversation_id, sender_id, content, message_type, - parent_message_id, reply_to_id, is_pinned, is_edited, is_deleted, - edited_at, deleted_at, status, metadata, created_at, updated_at - FROM messages - WHERE conversation_id = $1 AND created_at < $2{} - ORDER BY created_at DESC - LIMIT $3 - "#, - deleted_filter - ); - let rows = sqlx::query(&query) - .bind(conversation_id) - .bind(before_ts) - .bind(limit_i64) - .fetch_all(&self.pool) - .await?; - (rows, true) // Besoin de reverse car tri DESC - } - (None, Some(after_ts)) => { - // Récupérer les messages après after_ts (plus récents, tri ASC) - let deleted_filter = if include_deleted { - "" - } else { - " AND is_deleted = false" - }; - let query = format!( - r#" - SELECT - id, conversation_id, sender_id, content, message_type, - parent_message_id, reply_to_id, is_pinned, is_edited, is_deleted, - edited_at, deleted_at, status, metadata, created_at, updated_at - FROM messages - WHERE conversation_id = $1 AND created_at > $2{} - ORDER BY created_at ASC - LIMIT $3 - "#, - deleted_filter - ); - let rows = sqlx::query(&query) - .bind(conversation_id) - .bind(after_ts) - .bind(limit_i64) - .fetch_all(&self.pool) - .await?; - (rows, false) // Pas besoin de reverse car tri ASC - } - (Some(before_ts), Some(after_ts)) => { - // Récupérer les messages entre after_ts et before_ts (tri ASC) - let deleted_filter = if include_deleted { - "" - } else { - " AND is_deleted = false" - }; - let query = format!( - r#" - SELECT - id, conversation_id, sender_id, content, message_type, - parent_message_id, reply_to_id, is_pinned, is_edited, is_deleted, - edited_at, deleted_at, status, metadata, created_at, updated_at - FROM messages - WHERE conversation_id = $1 AND created_at > $2 AND created_at < $3{} - ORDER BY created_at ASC - LIMIT $4 - "#, - deleted_filter - ); - let rows = sqlx::query(&query) - .bind(conversation_id) - .bind(after_ts) - .bind(before_ts) - .bind(limit_i64) - .fetch_all(&self.pool) - .await?; - (rows, false) // Pas besoin de reverse car tri ASC - } - (None, None) => { - // Récupérer les messages les plus récents (tri DESC) - let deleted_filter = if include_deleted { - "" - } else { - " AND is_deleted = false" - }; - let query = format!( - r#" - SELECT - id, conversation_id, sender_id, content, message_type, - parent_message_id, reply_to_id, is_pinned, is_edited, is_deleted, - edited_at, deleted_at, status, metadata, created_at, updated_at - FROM messages - WHERE conversation_id = $1{} - ORDER BY created_at DESC - LIMIT $2 - "#, - deleted_filter - ); - let rows = sqlx::query(&query) - .bind(conversation_id) - .bind(limit_i64) - .fetch_all(&self.pool) - .await?; - (rows, true) // Besoin de reverse car tri DESC - } - }; - - // Mapper les rows en messages - let mut messages: Vec = rows - .iter() - .map(|row| self.row_to_message(row)) - .collect::>>()?; - - // Toujours retourner en ordre ASC (du plus ancien au plus récent) - if needs_reverse { - messages.reverse(); - } - - // Vérifier s'il y a plus de messages avant/après - let has_more_before = if let Some(first_msg) = messages.first() { - let deleted_filter = if include_deleted { - "" - } else { - " AND is_deleted = false" - }; - let count_query = format!( - "SELECT COUNT(*) FROM messages WHERE conversation_id = $1 AND created_at < $2{}", - deleted_filter - ); - let count: i64 = sqlx::query_scalar(&count_query) - .bind(conversation_id) - .bind(first_msg.created_at) - .fetch_one(&self.pool) - .await?; - count > 0 - } else { - false - }; - - let has_more_after = if let Some(last_msg) = messages.last() { - let deleted_filter = if include_deleted { - "" - } else { - " AND is_deleted = false" - }; - let count_query = format!( - "SELECT COUNT(*) FROM messages WHERE conversation_id = $1 AND created_at > $2{}", - deleted_filter - ); - let count: i64 = sqlx::query_scalar(&count_query) - .bind(conversation_id) - .bind(last_msg.created_at) - .fetch_one(&self.pool) - .await?; - count > 0 - } else { - false - }; - - Ok((messages, has_more_before, has_more_after)) - } - - /// Recherche de messages par texte (recherche ILIKE avec index trigram) - pub async fn search_messages( - &self, - conversation_id: Uuid, - query: &str, - limit: usize, - offset: usize, - include_deleted: bool, - ) -> Result<(Vec, i64)> { - let limit = limit.min(100); // Limiter à 100 messages max - let limit_i64 = limit as i64; - let offset_i64 = offset as i64; - - // Requête de recherche avec ILIKE (utilise l'index trigram) - let search_pattern = format!("%{}%", query); - - let rows = sqlx::query( - r#" - SELECT - id, - conversation_id, - sender_id, - content, - message_type, - parent_message_id, - reply_to_id, - is_pinned, - is_edited, - is_deleted, - edited_at, - deleted_at, - status, - metadata, - created_at, - updated_at - FROM messages - WHERE conversation_id = $1 - AND content ILIKE $2 - AND ($3 = true OR is_deleted = false) - ORDER BY created_at DESC - LIMIT $4 OFFSET $5 - "#, - ) - .bind(conversation_id) - .bind(&search_pattern) - .bind(include_deleted) - .bind(limit_i64) - .bind(offset_i64) - .fetch_all(&self.pool) - .await?; - - // Mapper les rows en messages - let messages: Vec = rows - .iter() - .map(|row| self.row_to_message(row)) - .collect::>>()?; - - // Compter le total de résultats - let total: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM messages - WHERE conversation_id = $1 - AND content ILIKE $2 - AND ($3 = true OR is_deleted = false) - "#, - ) - .bind(conversation_id) - .bind(&search_pattern) - .bind(include_deleted) - .fetch_one(&self.pool) - .await?; - - Ok((messages, total)) - } - - /// Récupère tous les messages depuis un timestamp (pour sync offline) - /// - /// Inclut : - /// - Messages créés depuis `since` - /// - Messages édités depuis `since` (même si créés avant) - /// - Messages supprimés depuis `since` (même si créés avant) - pub async fn fetch_since( - &self, - conversation_id: Uuid, - since: DateTime, - ) -> Result> { - // Récupérer tous les messages créés ou modifiés depuis since - let rows = sqlx::query( - r#" - SELECT - id, - conversation_id, - sender_id, - content, - message_type, - parent_message_id, - reply_to_id, - is_pinned, - is_edited, - is_deleted, - edited_at, - deleted_at, - status, - metadata, - created_at, - updated_at - FROM messages - WHERE conversation_id = $1 - AND ( - created_at > $2 - OR updated_at > $2 - ) - ORDER BY created_at ASC - "#, - ) - .bind(conversation_id) - .bind(since) - .fetch_all(&self.pool) - .await?; - - // Mapper les rows en messages - let messages: Vec = rows - .iter() - .map(|row| self.row_to_message(row)) - .collect::>>()?; - - Ok(messages) - } -} diff --git a/veza-chat-server/src/repository/mod.rs b/veza-chat-server/src/repository/mod.rs deleted file mode 100644 index 7da864009..000000000 --- a/veza-chat-server/src/repository/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub mod message_repository; -pub mod room_repository; - -#[cfg(test)] -mod tests; - -pub use message_repository::MessageRepository; -pub use room_repository::{Room, RoomMember, RoomRepository}; diff --git a/veza-chat-server/src/repository/room_repository.rs b/veza-chat-server/src/repository/room_repository.rs deleted file mode 100644 index 9ff5815e5..000000000 --- a/veza-chat-server/src/repository/room_repository.rs +++ /dev/null @@ -1,224 +0,0 @@ -use chrono::{DateTime, Utc}; -use sqlx::{PgPool, Result, Row}; -use uuid::Uuid; - -/// Room représente une conversation/room de chat -/// Aligné avec la table conversations de la migration 001 -#[derive(Debug, Clone)] -pub struct Room { - pub id: Uuid, - pub name: Option, - pub description: Option, - pub room_type: String, // conversation_type dans DB - pub is_private: bool, - pub creator_id: Uuid, // created_by dans DB - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// RoomMember représente un membre d'une room -#[derive(Debug, Clone)] -pub struct RoomMember { - pub id: Uuid, - pub room_id: Uuid, - pub user_id: Uuid, - pub role: String, - pub is_banned: bool, - pub is_muted: bool, - pub last_read_at: Option>, - pub joined_at: DateTime, -} - -pub struct RoomRepository { - pool: PgPool, -} - -impl RoomRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub fn pool(&self) -> &PgPool { - &self.pool - } - - pub async fn create( - &self, - name: Option<&str>, - description: Option<&str>, - room_type: &str, - is_private: bool, - creator_id: Uuid, - ) -> Result { - // Aligné avec la table conversations de la migration 001 - let row = sqlx::query( - r#" - INSERT INTO conversations (name, description, conversation_type, is_private, created_by, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) - RETURNING - id, - name, - description, - conversation_type, - is_private, - created_by, - created_at, - updated_at - "# - ) - .bind(name) - .bind(description) - .bind(room_type) - .bind(is_private) - .bind(creator_id) - .fetch_one(&self.pool) - .await?; - - Ok(Room { - id: row.get("id"), - name: row.get("name"), - description: row.get("description"), - room_type: row.get("conversation_type"), // Mapper conversation_type vers room_type - is_private: row.get("is_private"), - creator_id: row.get("created_by"), // Mapper created_by vers creator_id - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - }) - } - - pub async fn get_by_id(&self, room_id: Uuid) -> Result> { - // Aligné avec la table conversations de la migration 001 - let row_opt = sqlx::query( - r#" - SELECT - id, - name, - description, - conversation_type, - is_private, - created_by, - created_at, - updated_at - FROM conversations - WHERE id = $1 - "#, - ) - .bind(room_id) - .fetch_optional(&self.pool) - .await?; - - if let Some(row) = row_opt { - Ok(Some(Room { - id: row.get("id"), - name: row.get("name"), - description: row.get("description"), - room_type: row.get("conversation_type"), // Mapper conversation_type vers room_type - is_private: row.get("is_private"), - creator_id: row.get("created_by"), // Mapper created_by vers creator_id - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - })) - } else { - Ok(None) - } - } - - pub async fn add_member(&self, room_id: Uuid, user_id: Uuid, role: &str) -> Result { - // Aligné avec la table conversation_members de la migration 001 - // Note: conversation_members n'a pas de colonne id, utilise (conversation_id, user_id) comme PK - sqlx::query( - r#" - INSERT INTO conversation_members (conversation_id, user_id, role, joined_at) - VALUES ($1, $2, $3, NOW()) - ON CONFLICT (conversation_id, user_id) DO UPDATE - SET role = EXCLUDED.role, - joined_at = CASE - WHEN conversation_members.joined_at IS NULL THEN NOW() - ELSE conversation_members.joined_at - END - "#, - ) - .bind(room_id) - .bind(user_id) - .bind(role) - .execute(&self.pool) - .await?; - - // Récupérer le membre créé/mis à jour - let row = sqlx::query( - r#" - SELECT - conversation_id, - user_id, - role, - joined_at - FROM conversation_members - WHERE conversation_id = $1 AND user_id = $2 - "#, - ) - .bind(room_id) - .bind(user_id) - .fetch_one(&self.pool) - .await?; - - Ok(RoomMember { - id: room_id, // Utiliser room_id comme id temporaire (pas de colonne id dans conversation_members) - room_id: row.get("conversation_id"), - user_id: row.get("user_id"), - role: row.get("role"), - is_banned: false, // Pas de colonne is_banned dans conversation_members - is_muted: false, // Pas de colonne is_muted dans conversation_members - last_read_at: None, // Pas de colonne last_read_at dans conversation_members - joined_at: row.get("joined_at"), - }) - } - - pub async fn remove_member(&self, room_id: Uuid, user_id: Uuid) -> Result<()> { - // Aligné avec la table conversation_members de la migration 001 - sqlx::query("DELETE FROM conversation_members WHERE conversation_id = $1 AND user_id = $2") - .bind(room_id) - .bind(user_id) - .execute(&self.pool) - .await?; - - Ok(()) - } - - pub async fn get_members(&self, room_id: Uuid) -> Result> { - // Aligné avec la table conversation_members de la migration 001 - let rows = sqlx::query( - r#" - SELECT - conversation_id, - user_id, - role, - joined_at - FROM conversation_members - WHERE conversation_id = $1 - ORDER BY joined_at ASC - "#, - ) - .bind(room_id) - .fetch_all(&self.pool) - .await?; - - let members: Result, sqlx::Error> = rows - .into_iter() - .map(|row| { - let conv_id: Uuid = row.get("conversation_id"); - Ok(RoomMember { - id: conv_id, // Utiliser conversation_id comme id temporaire - room_id: conv_id, - user_id: row.get("user_id"), - role: row.get("role"), - is_banned: false, // Pas de colonne is_banned dans conversation_members - is_muted: false, // Pas de colonne is_muted dans conversation_members - last_read_at: None, // Pas de colonne last_read_at dans conversation_members - joined_at: row.get("joined_at"), - }) - }) - .collect(); - - members - } -} diff --git a/veza-chat-server/src/repository/tests.rs b/veza-chat-server/src/repository/tests.rs deleted file mode 100644 index bc9ef903f..000000000 --- a/veza-chat-server/src/repository/tests.rs +++ /dev/null @@ -1,75 +0,0 @@ -#[cfg(test)] -mod tests { - use super::*; - use crate::repository::{MessageRepository, RoomRepository}; - use sqlx::PgPool; - use uuid::Uuid; - - async fn setup_test_db() -> PgPool { - // Pour les tests, utiliser une base de données de test - // Cette fonction doit être implémentée selon votre configuration de test - let database_url = - std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for tests"); - - sqlx::PgPool::connect(&database_url) - .await - .expect("Failed to connect to test database") - } - - #[tokio::test] - #[ignore] // Ignorer par défaut car nécessite une DB de test - async fn test_create_message() { - let pool = setup_test_db().await; - let repo = MessageRepository::new(pool.clone()); - - // Créer d'abord une room de test - let room_repo = RoomRepository::new(pool.clone()); - let creator_id = Uuid::new_v4(); - - // Créer la room - let room = room_repo - .create( - Some("Test Room"), - Some("Test Description"), - "public", - false, - creator_id, - ) - .await - .expect("Failed to create room"); - - let sender_id = creator_id; - let message = repo - .create(room.id, sender_id, "Hello world") - .await - .unwrap(); - - assert_eq!(message.content, "Hello world"); - assert!(matches!( - message.message_type, - crate::models::MessageType::Text - )); - } - - #[tokio::test] - #[ignore] // Ignorer par défaut car nécessite une DB de test - async fn test_get_room_messages() { - let pool = setup_test_db().await; - let repo = MessageRepository::new(pool.clone()); - - // Créer une room d'abord - let room_repo = RoomRepository::new(pool.clone()); - let creator_id = Uuid::new_v4(); - let room = room_repo - .create(Some("Test Room"), None, "public", false, creator_id) - .await - .expect("Failed to create room"); - - let sender_id = creator_id; - repo.create(room.id, sender_id, "Message 1").await.unwrap(); - repo.create(room.id, sender_id, "Message 2").await.unwrap(); - - let messages = repo.get_room_messages(room.id, 10).await.unwrap(); - assert_eq!(messages.len(), 2); - } -} diff --git a/veza-chat-server/src/security/csrf.rs b/veza-chat-server/src/security/csrf.rs deleted file mode 100644 index 450162c13..000000000 --- a/veza-chat-server/src/security/csrf.rs +++ /dev/null @@ -1,265 +0,0 @@ -//! Protection CSRF (Cross-Site Request Forgery) -//! -//! Ce module implémente la protection CSRF avec: -//! - Génération de tokens CSRF sécurisés -//! - Validation des tokens CSRF -//! - Rotation automatique des tokens -//! - Protection contre les attaques CSRF - -use crate::error::{ChatError, Result}; -use chrono::{Duration, Utc}; -use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::RwLock; -use uuid::Uuid; - -/// Claims pour les tokens CSRF -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CsrfTokenClaims { - /// ID unique du token - pub jti: String, - /// Type de token - pub token_type: String, - /// Audience - pub aud: String, - /// Issuer - pub iss: String, - /// Sujet (session_id) - pub sub: String, - /// Expiration - pub exp: usize, - /// Émis à - pub iat: usize, - /// Nonce pour la protection contre les attaques de rejeu - pub nonce: String, -} - -/// Gestionnaire CSRF -pub struct CsrfManager { - /// Clé de signature - encoding_key: EncodingKey, - /// Clé de vérification - decoding_key: DecodingKey, - /// Configuration de validation - validation: Validation, - /// Tokens CSRF actifs (jti -> session_id) - active_tokens: Arc>>, - /// Configuration - _secret: String, - /// Durée de vie des tokens CSRF (en secondes) - token_lifetime: i64, -} - -impl CsrfManager { - /// Crée un nouveau gestionnaire CSRF - pub fn new(secret: String, token_lifetime: i64) -> Result { - let algorithm = Algorithm::HS256; - let encoding_key = EncodingKey::from_secret(secret.as_bytes()); - let decoding_key = DecodingKey::from_secret(secret.as_bytes()); - - let mut validation = Validation::new(algorithm); - validation.set_audience(&["veza-csrf"]); - validation.set_issuer(&["veza-platform"]); - validation.set_required_spec_claims(&["exp", "iat", "sub", "aud", "iss", "jti", "nonce"]); - - Ok(Self { - encoding_key, - decoding_key, - validation, - active_tokens: Arc::new(RwLock::new(HashMap::new())), - _secret: secret, - token_lifetime, - }) - } - - /// Génère un nouveau token CSRF - pub async fn generate_token(&self, session_id: &str) -> Result { - let now = Utc::now(); - let exp = now + Duration::seconds(self.token_lifetime); - let jti = Uuid::new_v4().to_string(); - let nonce = Uuid::new_v4().to_string(); - - let claims = CsrfTokenClaims { - jti: jti.clone(), - token_type: "csrf".to_string(), - aud: "veza-csrf".to_string(), - iss: "veza-platform".to_string(), - sub: session_id.to_string(), - exp: exp.timestamp() as usize, - iat: now.timestamp() as usize, - nonce, - }; - - let algorithm = Algorithm::HS256; - let header = Header::new(algorithm); - let token = encode(&header, &claims, &self.encoding_key) - .map_err(|e| ChatError::internal_error(format!("Erreur génération token CSRF: {e}")))?; - - // Enregistrer le token comme actif - { - let mut active_tokens = self.active_tokens.write().await; - active_tokens.insert(jti.clone(), session_id.to_string()); - } - - tracing::debug!( - session_id = %session_id, - jti = %jti, - exp = %exp.timestamp(), - "🔐 Token CSRF généré" - ); - - Ok(token) - } - - /// Valide un token CSRF - pub async fn validate_token(&self, token: &str, session_id: &str) -> Result<()> { - // Décoder le token - let token_data = decode::(token, &self.decoding_key, &self.validation) - .map_err(|e| { - tracing::warn!(error = %e, "❌ Token CSRF invalide"); - ChatError::unauthorized("Token CSRF invalide") - })?; - - let claims = &token_data.claims; - - // Vérifier que le token est actif - { - let active_tokens = self.active_tokens.read().await; - if !active_tokens.contains_key(&claims.jti) { - tracing::warn!(jti = %claims.jti, "❌ Token CSRF révoqué"); - return Err(ChatError::unauthorized("Token CSRF révoqué")); - } - } - - // Vérifier que le token correspond à la session - if claims.sub != session_id { - tracing::warn!( - token_session = %claims.sub, - expected_session = %session_id, - "❌ Token CSRF ne correspond pas à la session" - ); - return Err(ChatError::unauthorized( - "Token CSRF ne correspond pas à la session", - )); - } - - // Vérifier l'expiration - let now = Utc::now().timestamp() as usize; - if claims.exp < now { - tracing::warn!(exp = %claims.exp, now = %now, "❌ Token CSRF expiré"); - return Err(ChatError::unauthorized("Token CSRF expiré")); - } - - tracing::debug!( - session_id = %session_id, - jti = %claims.jti, - "✅ Token CSRF validé" - ); - - Ok(()) - } - - /// Révoque un token CSRF - pub async fn revoke_token(&self, token: &str) -> Result<()> { - let token_data = decode::(token, &self.decoding_key, &self.validation) - .map_err(|e| { - tracing::warn!(error = %e, "❌ Token CSRF invalide lors de la révocation"); - ChatError::unauthorized("Token CSRF invalide") - })?; - - let jti = &token_data.claims.jti; - { - let mut active_tokens = self.active_tokens.write().await; - active_tokens.remove(jti); - } - - tracing::debug!(jti = %jti, "🔐 Token CSRF révoqué"); - - Ok(()) - } - - /// Révoque tous les tokens d'une session - pub async fn revoke_session_tokens(&self, session_id: &str) -> Result<()> { - let mut active_tokens = self.active_tokens.write().await; - active_tokens.retain(|_, session| session != session_id); - - tracing::debug!(session_id = %session_id, "🔐 Tous les tokens CSRF de la session révoqués"); - - Ok(()) - } - - /// Nettoie les tokens expirés - pub async fn cleanup_expired_tokens(&self) -> Result<()> { - let _now = Utc::now().timestamp() as usize; - let mut active_tokens = self.active_tokens.write().await; - let initial_count = active_tokens.len(); - - // Note: Dans une implémentation réelle, on devrait vérifier l'expiration - // des tokens stockés. Ici, on se contente de nettoyer périodiquement. - active_tokens.retain(|_, _| true); // Placeholder pour la logique de nettoyage - - let final_count = active_tokens.len(); - let cleaned = initial_count - final_count; - - if cleaned > 0 { - tracing::debug!(cleaned = %cleaned, "🧹 Tokens CSRF expirés nettoyés"); - } - - Ok(()) - } - - /// Obtient le nombre de tokens actifs - pub async fn active_token_count(&self) -> usize { - let active_tokens = self.active_tokens.read().await; - active_tokens.len() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Test-only secret - never used in production. Isolated in #[cfg(test)]. - const TEST_CSRF_SECRET: &str = "test-csrf-secret-unit-tests-only"; - - #[tokio::test] - async fn test_csrf_token_generation_and_validation() { - let manager = CsrfManager::new(TEST_CSRF_SECRET.to_string(), 3600).unwrap(); - let session_id = "test-session-123"; - - // Générer un token - let token = manager.generate_token(session_id).await.unwrap(); - assert!(!token.is_empty()); - - // Valider le token - let result = manager.validate_token(&token, session_id).await; - assert!(result.is_ok()); - - // Valider avec un mauvais session_id - let result = manager.validate_token(&token, "wrong-session").await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_csrf_token_revocation() { - let manager = CsrfManager::new(TEST_CSRF_SECRET.to_string(), 3600).unwrap(); - let session_id = "test-session-123"; - - // Générer un token - let token = manager.generate_token(session_id).await.unwrap(); - - // Valider le token - let result = manager.validate_token(&token, session_id).await; - assert!(result.is_ok()); - - // Révoquer le token - let result = manager.revoke_token(&token).await; - assert!(result.is_ok()); - - // Valider le token révoqué - let result = manager.validate_token(&token, session_id).await; - assert!(result.is_err()); - } -} diff --git a/veza-chat-server/src/security/mod.rs b/veza-chat-server/src/security/mod.rs deleted file mode 100644 index 41deeadc3..000000000 --- a/veza-chat-server/src/security/mod.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! Module de sécurité pour le serveur de chat -//! -//! Ce module contient toutes les fonctionnalités de sécurité: -//! - Gestion des cookies sécurisés -//! - Protection CSRF -//! - Validation des tokens JWT -//! - Middleware de sécurité -//! - Gestion des permissions - -pub mod csrf; -pub mod permission; -pub mod rate_limiter; - -pub use csrf::CsrfManager; -pub use permission::{PermissionError, PermissionService}; -pub use rate_limiter::{RateLimitAction, RateLimiter}; - -use once_cell::sync::Lazy; -use regex::Regex; - -/// Dangerous content patterns compiled once at startup. -static DANGEROUS_PATTERNS: Lazy> = Lazy::new(|| { - let raw = [ - // XSS vectors - r"(?i)]*>", - r"(?i)javascript:", - r"(?i)on\w+\s*=", - r"(?i)data:text/html", - r"(?i)]*>", - r"(?i)]*>", - r"(?i)]*>", - // SQL injection fragments - r"(?i)UNION\s+SELECT", - r"(?i)DROP\s+TABLE", - r"(?i)OR\s+1\s*=\s*1", - r"(?i)'\s*OR\s*'", - // Command injection - r"(?i)eval\s*\(", - r"(?i)exec\s*\(", - ]; - raw.iter().filter_map(|p| Regex::new(p).ok()).collect() -}); - -/// Filtre de contenu pour détecter du contenu inapproprié -#[derive(Debug, Clone)] -pub struct ContentFilter { - pub enabled: bool, -} - -impl ContentFilter { - pub fn new() -> Result { - Ok(Self { enabled: true }) - } - - /// Returns `true` if content is safe, `false` if dangerous patterns detected. - pub fn filter_content(&self, content: &str) -> bool { - if !self.enabled { - return true; - } - if content.trim().is_empty() || content.len() > 4096 { - return false; - } - // Reject content matching any dangerous pattern - !DANGEROUS_PATTERNS.iter().any(|re| re.is_match(content)) - } - - pub fn validate_content(&self, content: &str) -> Result { - if content.trim().is_empty() { - return Err(crate::error::ChatError::validation_error( - "Le message ne peut pas être vide", - )); - } - - if content.len() > 4096 { - return Err(crate::error::ChatError::validation_error( - "Message trop long (max 4096 caractères)", - )); - } - - // Check for dangerous patterns - if DANGEROUS_PATTERNS.iter().any(|re| re.is_match(content)) { - return Err(crate::error::ChatError::validation_error( - "Contenu potentiellement dangereux détecté", - )); - } - - // Clean control characters but keep whitespace - let cleaned: String = content - .chars() - .filter(|c| !c.is_control() || c.is_whitespace()) - .collect(); - - Ok(cleaned) - } -} - -/// Sécurité avancée -#[derive(Debug, Clone)] -pub struct EnhancedSecurity { - pub rate_limiting: bool, -} - -impl EnhancedSecurity { - pub fn new() -> Result { - Ok(Self { - rate_limiting: true, - }) - } - - pub async fn validate_request( - &self, - _user_id: uuid::Uuid, - _user_ip: &str, - _session_token: &str, - action: &SecurityAction, - content: Option<&str>, - ) -> Result<(), crate::error::ChatError> { - // Validation basique des actions - match action { - SecurityAction::SendMessage => { - if let Some(msg) = content { - if msg.trim().is_empty() { - return Err(crate::error::ChatError::validation_error( - "Message vide interdit", - )); - } - } - } - SecurityAction::UploadFile => { - // Placeholder pour vérification type mime/taille si on avait les métadonnées ici - } - _ => {} - } - - // Rate limiting is now implemented in security::rate_limiter module - // and integrated directly in the WebSocket handler (handle_incoming_message). - - Ok(()) - } -} - -/// Actions de sécurité -#[derive(Debug, Clone)] -pub enum SecurityAction { - SendMessage, - CreateRoom, - JoinRoom, - SendDM, - UploadFile, - ChangeSettings, - AdminAction, - Block, - Warn, - Log, -} diff --git a/veza-chat-server/src/security/permission.rs b/veza-chat-server/src/security/permission.rs deleted file mode 100644 index 30489d8dc..000000000 --- a/veza-chat-server/src/security/permission.rs +++ /dev/null @@ -1,584 +0,0 @@ -//! Module de gestion des permissions pour le chat server -//! -//! Ce module fournit un système centralisé de vérification des permissions -//! pour les conversations, avec support des rôles (admin, moderator, member). -//! -//! # Exemple -//! -//! ```rust,no_run -//! use chat_server::security::permission::PermissionService; -//! use uuid::Uuid; -//! -//! # async fn example() -> Result<(), Box> { -//! let pool = sqlx::PgPool::connect("postgresql://...").await?; -//! let permission_service = PermissionService::new(pool); -//! -//! let user_id = Uuid::new_v4(); -//! let conversation_id = Uuid::new_v4(); -//! -//! // Vérifier si l'utilisateur peut envoyer un message -//! permission_service.can_send_message(user_id, conversation_id).await?; -//! # Ok(()) -//! # } -//! ``` - -use crate::error::{ChatError, Result}; -use crate::permissions::Role; -use sqlx::PgPool; -use tracing::{debug, warn}; -use uuid::Uuid; - -/// Erreur spécifique aux permissions -#[derive(Debug, thiserror::Error)] -pub enum PermissionError { - #[error("Utilisateur {user_id} n'est pas membre de la conversation {conversation_id}")] - NotMember { - user_id: Uuid, - conversation_id: Uuid, - }, - #[error("Permissions insuffisantes pour {action} dans la conversation {conversation_id}")] - InsufficientPermissions { - action: String, - conversation_id: Uuid, - }, - #[error("Rôle invalide: {role}")] - InvalidRole { role: String }, - #[error("Erreur base de données: {0}")] - Database(#[from] sqlx::Error), -} - -impl From for ChatError { - fn from(err: PermissionError) -> Self { - match err { - PermissionError::NotMember { - user_id: _, - conversation_id, - } => ChatError::NotMember { - conversation_id: conversation_id.to_string(), - }, - PermissionError::InsufficientPermissions { - action, - conversation_id, - } => ChatError::InsufficientPermissions { - action, - conversation_id: conversation_id.to_string(), - }, - PermissionError::InvalidRole { role } => { - ChatError::configuration_error(&format!("Rôle invalide: {}", role)) - } - PermissionError::Database(e) => ChatError::from_sqlx_error("permission_check", e), - } - } -} - -/// Service centralisé de gestion des permissions -pub struct PermissionService { - pool: PgPool, -} - -impl PermissionService { - /// Crée un nouveau service de permissions - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - /// Vérifie si un utilisateur est membre d'une conversation - /// - /// # Arguments - /// - /// * `user_id` - ID de l'utilisateur - /// * `conversation_id` - ID de la conversation - /// - /// # Returns - /// - /// `Ok(true)` si l'utilisateur est membre, `Ok(false)` sinon - pub async fn user_in_conversation(&self, user_id: Uuid, conversation_id: Uuid) -> Result { - let exists: bool = sqlx::query_scalar( - r#" - SELECT EXISTS( - SELECT 1 FROM conversation_members - WHERE conversation_id = $1 AND user_id = $2 - ) - "#, - ) - .bind(conversation_id) - .bind(user_id) - .fetch_one(&self.pool) - .await - .map_err(|e| ChatError::from_sqlx_error("check_conversation_membership", e))?; - - debug!( - user_id = %user_id, - conversation_id = %conversation_id, - is_member = %exists, - "Vérification d'appartenance à la conversation" - ); - - Ok(exists) - } - - /// Récupère le rôle d'un utilisateur dans une conversation - /// - /// # Arguments - /// - /// * `user_id` - ID de l'utilisateur - /// * `conversation_id` - ID de la conversation - /// - /// # Returns - /// - /// Le rôle de l'utilisateur dans la conversation, ou une erreur si non membre - pub async fn user_role_in_conversation( - &self, - user_id: Uuid, - conversation_id: Uuid, - ) -> Result { - let role_str: Option = sqlx::query_scalar( - r#" - SELECT role FROM conversation_members - WHERE conversation_id = $1 AND user_id = $2 - "#, - ) - .bind(conversation_id) - .bind(user_id) - .fetch_optional(&self.pool) - .await - .map_err(|e| ChatError::from_sqlx_error("get_conversation_role", e))?; - - let role_str = role_str.ok_or_else(|| PermissionError::NotMember { - user_id, - conversation_id, - })?; - - let role = Role::from_string(&role_str)?; - - debug!( - user_id = %user_id, - conversation_id = %conversation_id, - role = ?role, - "Rôle récupéré pour la conversation" - ); - - Ok(role) - } - - /// Récupère le rôle global d'un utilisateur depuis la table users - /// - /// # Arguments - /// - /// * `user_id` - ID de l'utilisateur - /// - /// # Returns - /// - /// Le rôle global de l'utilisateur, ou User par défaut - pub async fn user_global_role(&self, user_id: Uuid) -> Result { - let role_str: Option = sqlx::query_scalar( - r#" - SELECT role FROM users - WHERE id = $1 - "#, - ) - .bind(user_id) - .fetch_optional(&self.pool) - .await - .map_err(|e| ChatError::from_sqlx_error("get_user_role", e))?; - - // Si pas de rôle défini ou colonne inexistante, retourner User par défaut - let role = match role_str { - Some(r) => Role::from_string(&r).unwrap_or(Role::User), - None => Role::User, - }; - - debug!( - user_id = %user_id, - role = ?role, - "Rôle global récupéré" - ); - - Ok(role) - } - - /// Vérifie si un utilisateur peut envoyer un message dans une conversation - /// - /// # Arguments - /// - /// * `user_id` - ID de l'utilisateur - /// * `conversation_id` - ID de la conversation - /// - /// # Returns - /// - /// `Ok(())` si autorisé, erreur sinon - pub async fn can_send_message(&self, user_id: Uuid, conversation_id: Uuid) -> Result<()> { - // Vérifier d'abord si l'utilisateur est membre - let is_member = self.user_in_conversation(user_id, conversation_id).await?; - - if !is_member { - // Vérifier si l'utilisateur est admin global (peut envoyer partout) - let global_role = self.user_global_role(user_id).await?; - match global_role { - Role::Admin | Role::SuperAdmin => { - debug!( - user_id = %user_id, - conversation_id = %conversation_id, - "Admin autorisé à envoyer un message sans être membre" - ); - return Ok(()); - } - _ => { - warn!( - user_id = %user_id, - conversation_id = %conversation_id, - "Tentative d'envoi de message par un non-membre" - ); - return Err(PermissionError::NotMember { - user_id, - conversation_id, - } - .into()); - } - } - } - - // Récupérer le rôle dans la conversation - let role = self - .user_role_in_conversation(user_id, conversation_id) - .await?; - - // Tous les membres peuvent envoyer des messages - // Les admins et modérateurs ont des permissions supplémentaires - match role { - Role::Admin | Role::Moderator | Role::User => Ok(()), - _ => { - warn!( - user_id = %user_id, - conversation_id = %conversation_id, - role = ?role, - "Rôle invalide pour envoyer un message" - ); - Err(PermissionError::InsufficientPermissions { - action: "send_message".to_string(), - conversation_id, - } - .into()) - } - } - } - - /// Vérifie si un utilisateur peut lire une conversation - /// - /// # Arguments - /// - /// * `user_id` - ID de l'utilisateur - /// * `conversation_id` - ID de la conversation - /// - /// # Returns - /// - /// `Ok(())` si autorisé, erreur sinon - pub async fn can_read_conversation(&self, user_id: Uuid, conversation_id: Uuid) -> Result<()> { - // Vérifier d'abord si l'utilisateur est membre - let is_member = self.user_in_conversation(user_id, conversation_id).await?; - - if !is_member { - // Vérifier si l'utilisateur est admin global (peut lire partout) - let global_role = self.user_global_role(user_id).await?; - match global_role { - Role::Admin | Role::SuperAdmin => { - debug!( - user_id = %user_id, - conversation_id = %conversation_id, - "Admin autorisé à lire la conversation sans être membre" - ); - return Ok(()); - } - _ => { - warn!( - user_id = %user_id, - conversation_id = %conversation_id, - "Tentative de lecture d'une conversation par un non-membre" - ); - return Err(PermissionError::NotMember { - user_id, - conversation_id, - } - .into()); - } - } - } - - // Tous les membres peuvent lire - Ok(()) - } - - /// Vérifie si un utilisateur peut marquer un message comme lu - /// - /// # Arguments - /// - /// * `user_id` - ID de l'utilisateur - /// * `conversation_id` - ID de la conversation - /// - /// # Returns - /// - /// `Ok(())` si autorisé, erreur sinon - pub async fn can_mark_read(&self, user_id: Uuid, conversation_id: Uuid) -> Result<()> { - // Même logique que can_read_conversation - self.can_read_conversation(user_id, conversation_id).await - } - - /// Vérifie si un utilisateur peut rejoindre une conversation - /// - /// # Arguments - /// - /// * `user_id` - ID de l'utilisateur - /// * `conversation_id` - ID de la conversation - /// - /// # Returns - /// - /// `Ok(())` si autorisé, erreur sinon - pub async fn can_join_conversation(&self, user_id: Uuid, conversation_id: Uuid) -> Result<()> { - // Vérifier si la conversation est privée - let is_private: Option = sqlx::query_scalar( - r#" - SELECT is_private FROM conversations - WHERE id = $1 - "#, - ) - .bind(conversation_id) - .fetch_optional(&self.pool) - .await - .map_err(|e| ChatError::from_sqlx_error("check_conversation_privacy", e))?; - - let is_private = is_private.unwrap_or(true); - - // Si la conversation est publique, tout le monde peut rejoindre - if !is_private { - return Ok(()); - } - - // Si privée, vérifier si l'utilisateur est déjà membre ou admin - let is_member = self.user_in_conversation(user_id, conversation_id).await?; - if is_member { - return Ok(()); - } - - let global_role = self.user_global_role(user_id).await?; - match global_role { - Role::Admin | Role::SuperAdmin => { - debug!( - user_id = %user_id, - conversation_id = %conversation_id, - "Admin autorisé à rejoindre une conversation privée" - ); - Ok(()) - } - _ => { - warn!( - user_id = %user_id, - conversation_id = %conversation_id, - "Tentative de rejoindre une conversation privée par un non-membre" - ); - Err(PermissionError::NotMember { - user_id, - conversation_id, - } - .into()) - } - } - } - - /// Vérifie si un utilisateur peut éditer un message - /// - /// # Arguments - /// - /// * `user_id` - ID de l'utilisateur - /// * `message_id` - ID du message - /// - /// # Returns - /// - /// `Ok(())` si autorisé, erreur sinon - /// - /// # Règles - /// - /// * L'auteur du message peut toujours éditer son message - /// * Un admin ou modérateur de la conversation peut éditer n'importe quel message - /// * Un message supprimé ne peut pas être édité - pub async fn can_edit_message(&self, user_id: Uuid, message_id: Uuid) -> Result<()> { - // Récupérer le message pour vérifier l'auteur et l'état - let message_row: Option<(Uuid, Uuid, bool)> = sqlx::query_as( - r#" - SELECT sender_id, conversation_id, is_deleted - FROM messages - WHERE id = $1 - "#, - ) - .bind(message_id) - .fetch_optional(&self.pool) - .await - .map_err(|e| ChatError::from_sqlx_error("get_message_for_edit", e))?; - - let (sender_id, conversation_id, is_deleted) = - message_row.ok_or_else(|| ChatError::not_found("Message", &message_id.to_string()))?; - - // Un message supprimé ne peut pas être édité - if is_deleted { - return Err(ChatError::validation_error( - "Un message supprimé ne peut pas être édité", - )); - } - - // L'auteur peut toujours éditer son message - if sender_id == user_id { - debug!( - user_id = %user_id, - message_id = %message_id, - "Auteur autorisé à éditer son message" - ); - return Ok(()); - } - - // Vérifier si l'utilisateur est admin ou modérateur de la conversation - let role = self - .user_role_in_conversation(user_id, conversation_id) - .await?; - match role { - Role::Admin | Role::Moderator | Role::SuperAdmin => { - debug!( - user_id = %user_id, - message_id = %message_id, - conversation_id = %conversation_id, - role = ?role, - "Admin/Modérateur autorisé à éditer le message" - ); - Ok(()) - } - _ => { - warn!( - user_id = %user_id, - message_id = %message_id, - conversation_id = %conversation_id, - "Tentative d'édition d'un message par un non-auteur sans permissions" - ); - Err(PermissionError::InsufficientPermissions { - action: "edit_message".to_string(), - conversation_id, - } - .into()) - } - } - } - - /// Vérifie si un utilisateur peut supprimer un message - /// - /// # Arguments - /// - /// * `user_id` - ID de l'utilisateur - /// * `message_id` - ID du message - /// - /// # Returns - /// - /// `Ok(())` si autorisé, erreur sinon - /// - /// # Règles - /// - /// * L'auteur du message peut toujours supprimer son message - /// * Un admin ou modérateur de la conversation peut supprimer n'importe quel message - pub async fn can_delete_message(&self, user_id: Uuid, message_id: Uuid) -> Result<()> { - // Récupérer le message pour vérifier l'auteur - let message_row: Option<(Uuid, Uuid)> = sqlx::query_as( - r#" - SELECT sender_id, conversation_id - FROM messages - WHERE id = $1 - "#, - ) - .bind(message_id) - .fetch_optional(&self.pool) - .await - .map_err(|e| ChatError::from_sqlx_error("get_message_for_delete", e))?; - - let (sender_id, conversation_id) = - message_row.ok_or_else(|| ChatError::not_found("Message", &message_id.to_string()))?; - - // L'auteur peut toujours supprimer son message - if sender_id == user_id { - debug!( - user_id = %user_id, - message_id = %message_id, - "Auteur autorisé à supprimer son message" - ); - return Ok(()); - } - - // Vérifier si l'utilisateur est admin ou modérateur de la conversation - let role = self - .user_role_in_conversation(user_id, conversation_id) - .await?; - match role { - Role::Admin | Role::Moderator | Role::SuperAdmin => { - debug!( - user_id = %user_id, - message_id = %message_id, - conversation_id = %conversation_id, - role = ?role, - "Admin/Modérateur autorisé à supprimer le message" - ); - Ok(()) - } - _ => { - warn!( - user_id = %user_id, - message_id = %message_id, - conversation_id = %conversation_id, - "Tentative de suppression d'un message par un non-auteur sans permissions" - ); - Err(PermissionError::InsufficientPermissions { - action: "delete_message".to_string(), - conversation_id, - } - .into()) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // Note: Les tests nécessitent une base de données de test - // Ils sont marqués avec #[ignore] car ils nécessitent une configuration spécifique - - #[tokio::test] - #[ignore] // Nécessite une base de données de test - async fn test_user_in_conversation() { - // Ce test nécessite un pool de test et des données de test - // let pool = create_test_pool().await; - // let service = PermissionService::new(pool); - // let user_id = Uuid::new_v4(); - // let conversation_id = Uuid::new_v4(); - // - // // Ajouter l'utilisateur à la conversation - // sqlx::query("INSERT INTO conversation_members (conversation_id, user_id, role) VALUES ($1, $2, 'member')") - // .bind(conversation_id) - // .bind(user_id) - // .execute(&pool) - // .await - // .unwrap(); - // - // // Vérifier - // let is_member = service.user_in_conversation(user_id, conversation_id).await.unwrap(); - // assert!(is_member); - } - - #[tokio::test] - #[ignore] // Nécessite une base de données de test - async fn test_can_send_message_non_member() { - // Ce test nécessite un pool de test - // let pool = create_test_pool().await; - // let service = PermissionService::new(pool); - // let user_id = Uuid::new_v4(); - // let conversation_id = Uuid::new_v4(); - // - // // Un non-membre ne peut pas envoyer de message - // let result = service.can_send_message(user_id, conversation_id).await; - // assert!(result.is_err()); - } -} diff --git a/veza-chat-server/src/security/rate_limiter.rs b/veza-chat-server/src/security/rate_limiter.rs deleted file mode 100644 index eaf3e3c84..000000000 --- a/veza-chat-server/src/security/rate_limiter.rs +++ /dev/null @@ -1,320 +0,0 @@ -//! Redis-backed rate limiter for WebSocket message actions. -//! -//! Uses a sliding-window counter via Redis INCR + EXPIRE. -//! Falls back to an in-memory HashMap when Redis is unavailable. - -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::RwLock; - -/// Per-action rate limit configuration. -#[derive(Debug, Clone)] -pub struct RateLimitConfig { - /// Maximum number of actions allowed within the window. - pub max_requests: u64, - /// Duration of the sliding window. - pub window: Duration, -} - -impl Default for RateLimitConfig { - fn default() -> Self { - Self { - max_requests: 30, - window: Duration::from_secs(60), - } - } -} - -/// Predefined action categories with their limits. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum RateLimitAction { - SendMessage, - AddReaction, - EditMessage, - DeleteMessage, - Typing, - JoinConversation, - SearchMessages, - CallSignaling, -} - -impl RateLimitAction { - /// Returns the default rate limit for each action category. - pub fn default_config(&self) -> RateLimitConfig { - match self { - Self::SendMessage => RateLimitConfig { - max_requests: 30, - window: Duration::from_secs(60), - }, - Self::AddReaction => RateLimitConfig { - max_requests: 60, - window: Duration::from_secs(60), - }, - Self::EditMessage => RateLimitConfig { - max_requests: 20, - window: Duration::from_secs(60), - }, - Self::DeleteMessage => RateLimitConfig { - max_requests: 10, - window: Duration::from_secs(60), - }, - Self::Typing => RateLimitConfig { - max_requests: 120, - window: Duration::from_secs(60), - }, - Self::JoinConversation => RateLimitConfig { - max_requests: 10, - window: Duration::from_secs(60), - }, - Self::SearchMessages => RateLimitConfig { - max_requests: 15, - window: Duration::from_secs(60), - }, - Self::CallSignaling => RateLimitConfig { - max_requests: 60, - window: Duration::from_secs(60), - }, - } - } - - fn redis_key_suffix(&self) -> &'static str { - match self { - Self::SendMessage => "msg", - Self::AddReaction => "react", - Self::EditMessage => "edit", - Self::DeleteMessage => "del", - Self::Typing => "typing", - Self::JoinConversation => "join", - Self::SearchMessages => "search", - Self::CallSignaling => "callsig", - } - } -} - -/// Entry in the in-memory fallback store. -#[derive(Debug)] -struct InMemoryEntry { - count: u64, - window_start: Instant, - window_duration: Duration, -} - -/// Rate limiter with Redis primary and in-memory fallback. -#[derive(Clone)] -pub struct RateLimiter { - #[cfg(feature = "redis-cache")] - redis_client: Option>, - /// In-memory fallback: key = "user_id:action" -> entry. - fallback: Arc>>, -} - -impl RateLimiter { - /// Create a new rate limiter with optional Redis URL. - pub fn new(redis_url: Option<&str>) -> Self { - #[cfg(feature = "redis-cache")] - let redis_client = redis_url.and_then(|url| { - redis::Client::open(url) - .map(|c| { - tracing::info!("Rate limiter connected to Redis: {}", url); - Arc::new(c) - }) - .map_err(|e| { - tracing::warn!("Rate limiter Redis unavailable, using in-memory fallback: {}", e); - e - }) - .ok() - }); - - Self { - #[cfg(feature = "redis-cache")] - redis_client, - fallback: Arc::new(RwLock::new(HashMap::new())), - } - } - - /// Check if the user is within their rate limit for the given action. - /// - /// Returns `Ok(true)` if allowed, `Ok(false)` if rate-limited. - /// Returns `Err` only on unexpected internal errors. - pub async fn check_rate_limit( - &self, - user_id: &str, - action: RateLimitAction, - ) -> Result { - let config = action.default_config(); - - // Try Redis first - #[cfg(feature = "redis-cache")] - if let Some(ref client) = self.redis_client { - match self.check_redis(client, user_id, &action, &config).await { - Ok(allowed) => return Ok(allowed), - Err(e) => { - tracing::warn!( - "Redis rate limit check failed, falling back to in-memory: {}", - e - ); - // Fall through to in-memory - } - } - } - - // In-memory fallback - self.check_in_memory(user_id, &action, &config).await - } - - #[cfg(feature = "redis-cache")] - async fn check_redis( - &self, - client: &redis::Client, - user_id: &str, - action: &RateLimitAction, - config: &RateLimitConfig, - ) -> Result { - let key = format!("rl:chat:{}:{}", user_id, action.redis_key_suffix()); - let window_secs = config.window.as_secs().max(1); - - let mut conn = client - .get_multiplexed_async_connection() - .await - .map_err(|e| format!("Redis connection error: {}", e))?; - - let count: u64 = redis::cmd("INCR") - .arg(&key) - .query_async(&mut conn) - .await - .map_err(|e| format!("Redis INCR error: {}", e))?; - - // Set expiry on first increment - if count == 1 { - let _: () = redis::cmd("EXPIRE") - .arg(&key) - .arg(window_secs as i64) - .query_async(&mut conn) - .await - .map_err(|e| format!("Redis EXPIRE error: {}", e))?; - } - - Ok(count <= config.max_requests) - } - - async fn check_in_memory( - &self, - user_id: &str, - action: &RateLimitAction, - config: &RateLimitConfig, - ) -> Result { - let key = format!("{}:{}", user_id, action.redis_key_suffix()); - let now = Instant::now(); - - let mut store = self.fallback.write().await; - - let entry = store.entry(key).or_insert_with(|| InMemoryEntry { - count: 0, - window_start: now, - window_duration: config.window, - }); - - // Reset window if expired - if now.duration_since(entry.window_start) >= entry.window_duration { - entry.count = 0; - entry.window_start = now; - } - - entry.count += 1; - Ok(entry.count <= config.max_requests) - } - - /// Periodically clean up expired entries from the in-memory fallback. - pub async fn cleanup_expired(&self) { - let now = Instant::now(); - let mut store = self.fallback.write().await; - store.retain(|_, entry| now.duration_since(entry.window_start) < entry.window_duration); - } -} - -impl std::fmt::Debug for RateLimiter { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("RateLimiter") - .field("has_redis", &{ - #[cfg(feature = "redis-cache")] - { - self.redis_client.is_some() - } - #[cfg(not(feature = "redis-cache"))] - { - false - } - }) - .finish() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_in_memory_rate_limit_allows_within_limit() { - let limiter = RateLimiter::new(None); - for _ in 0..30 { - let allowed = limiter - .check_rate_limit("user1", RateLimitAction::SendMessage) - .await - .unwrap(); - assert!(allowed); - } - } - - #[tokio::test] - async fn test_in_memory_rate_limit_blocks_over_limit() { - let limiter = RateLimiter::new(None); - // Exhaust the limit (30 messages/min) - for _ in 0..30 { - limiter - .check_rate_limit("user1", RateLimitAction::SendMessage) - .await - .unwrap(); - } - // 31st should be blocked - let allowed = limiter - .check_rate_limit("user1", RateLimitAction::SendMessage) - .await - .unwrap(); - assert!(!allowed); - } - - #[tokio::test] - async fn test_rate_limit_per_user_isolation() { - let limiter = RateLimiter::new(None); - // Exhaust user1's limit - for _ in 0..30 { - limiter - .check_rate_limit("user1", RateLimitAction::SendMessage) - .await - .unwrap(); - } - // user2 should still be allowed - let allowed = limiter - .check_rate_limit("user2", RateLimitAction::SendMessage) - .await - .unwrap(); - assert!(allowed); - } - - #[tokio::test] - async fn test_cleanup_expired() { - let limiter = RateLimiter::new(None); - limiter - .check_rate_limit("user1", RateLimitAction::SendMessage) - .await - .unwrap(); - - // Entries should exist - assert!(!limiter.fallback.read().await.is_empty()); - - // Cleanup should NOT remove non-expired entries - limiter.cleanup_expired().await; - assert!(!limiter.fallback.read().await.is_empty()); - } -} diff --git a/veza-chat-server/src/services/message_edit_service.rs b/veza-chat-server/src/services/message_edit_service.rs deleted file mode 100644 index a738090bc..000000000 --- a/veza-chat-server/src/services/message_edit_service.rs +++ /dev/null @@ -1,257 +0,0 @@ -//! Service pour l'édition et la suppression de messages -//! -//! Ce module fournit un service centralisé pour gérer l'édition et la suppression -//! de messages avec validation des permissions et mise à jour de la base de données. - -use crate::error::{ChatError, Result}; -use crate::repository::MessageRepository; -use crate::security::permission::PermissionService; -use sqlx::PgPool; -use tracing::{debug, info, warn}; -use uuid::Uuid; - -/// Service pour l'édition et la suppression de messages -pub struct MessageEditService { - message_repo: MessageRepository, - permission_service: PermissionService, -} - -impl MessageEditService { - /// Crée un nouveau service d'édition de messages - pub fn new(pool: PgPool) -> Self { - Self { - message_repo: MessageRepository::new(pool.clone()), - permission_service: PermissionService::new(pool), - } - } - - /// Édite un message - /// - /// # Arguments - /// - /// * `user_id` - ID de l'utilisateur qui édite - /// * `message_id` - ID du message à éditer - /// * `new_content` - Nouveau contenu du message - /// - /// # Returns - /// - /// Le message mis à jour - /// - /// # Erreurs - /// - /// * `ChatError::NotFound` - Message introuvable - /// * `ChatError::ValidationError` - Message supprimé ou contenu invalide - /// * `ChatError::InsufficientPermissions` - Permissions insuffisantes - pub async fn edit_message( - &self, - user_id: Uuid, - message_id: Uuid, - new_content: &str, - ) -> Result { - // Validation du contenu - if new_content.trim().is_empty() { - return Err(ChatError::validation_error( - "Le contenu du message ne peut pas être vide", - )); - } - - // Limite de longueur (configurable, 4000 caractères par défaut) - const MAX_CONTENT_LENGTH: usize = 4000; - if new_content.len() > MAX_CONTENT_LENGTH { - return Err(ChatError::validation_error(&format!( - "Le contenu du message ne peut pas dépasser {} caractères", - MAX_CONTENT_LENGTH - ))); - } - - // Vérifier que le message existe et n'est pas supprimé - let message = self.message_repo.get_by_id(message_id).await.map_err(|e| { - ChatError::internal_error(format!("Erreur lors de la récupération du message: {}", e)) - })?; - - let message = - message.ok_or_else(|| ChatError::not_found("Message", &message_id.to_string()))?; - - // Vérifier que le contenu a changé - if message.content == new_content { - return Err(ChatError::validation_error( - "Le nouveau contenu doit être différent de l'ancien", - )); - } - - // Vérifier les permissions - self.permission_service - .can_edit_message(user_id, message_id) - .await - .map_err(|e| { - warn!( - user_id = %user_id, - message_id = %message_id, - error = %e, - "Permission refusée pour l'édition du message" - ); - e - })?; - - // Mettre à jour le message - let updated_message = self - .message_repo - .update(message_id, new_content) - .await - .map_err(|e| { - ChatError::internal_error(format!( - "Erreur lors de la mise à jour du message: {}", - e - )) - })?; - - info!( - user_id = %user_id, - message_id = %message_id, - conversation_id = %updated_message.conversation_id, - "Message édité avec succès" - ); - - Ok(updated_message) - } - - /// Supprime un message (soft delete) - /// - /// # Arguments - /// - /// * `user_id` - ID de l'utilisateur qui supprime - /// * `message_id` - ID du message à supprimer - /// - /// # Returns - /// - /// Le message supprimé (avec is_deleted = true) - /// - /// # Erreurs - /// - /// * `ChatError::NotFound` - Message introuvable - /// * `ChatError::InsufficientPermissions` - Permissions insuffisantes - /// - /// # Note - /// - /// Cette méthode est idempotente : supprimer un message déjà supprimé - /// retourne OK sans erreur. - pub async fn delete_message( - &self, - user_id: Uuid, - message_id: Uuid, - ) -> Result { - // Vérifier que le message existe (même s'il est déjà supprimé) - let message = self - .message_repo - .get_by_id_including_deleted(message_id) - .await - .map_err(|e| { - ChatError::internal_error(format!( - "Erreur lors de la récupération du message: {}", - e - )) - })?; - - let message = - message.ok_or_else(|| ChatError::not_found("Message", &message_id.to_string()))?; - - // Si déjà supprimé, retourner le message tel quel (idempotent) - if message.is_deleted { - debug!( - user_id = %user_id, - message_id = %message_id, - "Message déjà supprimé, opération idempotente" - ); - return Ok(message); - } - - // Vérifier les permissions - self.permission_service - .can_delete_message(user_id, message_id) - .await - .map_err(|e| { - warn!( - user_id = %user_id, - message_id = %message_id, - error = %e, - "Permission refusée pour la suppression du message" - ); - e - })?; - - // Supprimer le message (soft delete) - self.message_repo.delete(message_id).await.map_err(|e| { - ChatError::internal_error(format!("Erreur lors de la suppression du message: {}", e)) - })?; - - // Récupérer le message supprimé pour le retourner - let deleted_message = self - .message_repo - .get_by_id_including_deleted(message_id) - .await - .map_err(|e| { - ChatError::internal_error(format!( - "Erreur lors de la récupération du message supprimé: {}", - e - )) - })?; - - let deleted_message = deleted_message.ok_or_else(|| { - ChatError::internal_error( - "Message supprimé mais introuvable après suppression".to_string(), - ) - })?; - - info!( - user_id = %user_id, - message_id = %message_id, - conversation_id = %deleted_message.conversation_id, - "Message supprimé avec succès" - ); - - Ok(deleted_message) - } -} - -#[cfg(test)] -mod tests { - - // Note: Les tests nécessitent une base de données de test - // Ils sont marqués avec #[ignore] car ils nécessitent une configuration spécifique - - #[tokio::test] - #[ignore] // Nécessite une base de données de test - async fn test_edit_message_author() { - // Ce test nécessite un pool de test et des données de test - // let pool = create_test_pool().await; - // let service = MessageEditService::new(pool); - // let user_id = Uuid::new_v4(); - // let message_id = Uuid::new_v4(); - // - // // Créer un message - // let message = service.message_repo.create(...).await.unwrap(); - // - // // L'auteur peut éditer son message - // let edited = service.edit_message(user_id, message_id, "Nouveau contenu").await.unwrap(); - // assert_eq!(edited.content, "Nouveau contenu"); - // assert!(edited.is_edited); - } - - #[tokio::test] - #[ignore] // Nécessite une base de données de test - async fn test_delete_message_idempotent() { - // Ce test nécessite un pool de test - // let pool = create_test_pool().await; - // let service = MessageEditService::new(pool); - // let user_id = Uuid::new_v4(); - // let message_id = Uuid::new_v4(); - // - // // Supprimer le message - // let deleted1 = service.delete_message(user_id, message_id).await.unwrap(); - // assert!(deleted1.is_deleted); - // - // // Supprimer à nouveau (idempotent) - // let deleted2 = service.delete_message(user_id, message_id).await.unwrap(); - // assert!(deleted2.is_deleted); - } -} diff --git a/veza-chat-server/src/services/mod.rs b/veza-chat-server/src/services/mod.rs deleted file mode 100644 index cc77a18a3..000000000 --- a/veza-chat-server/src/services/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Services pour le chat server -//! -//! Ce module contient les services de haut niveau qui encapsulent -//! la logique métier et utilisent les repositories pour accéder aux données. - -pub mod message_edit_service; -pub mod room_service; - -pub use message_edit_service::MessageEditService; -pub use room_service::RoomService; diff --git a/veza-chat-server/src/services/room_service.rs b/veza-chat-server/src/services/room_service.rs deleted file mode 100644 index 8fdfc1d09..000000000 --- a/veza-chat-server/src/services/room_service.rs +++ /dev/null @@ -1,365 +0,0 @@ -//! Service de gestion des rooms pour le chat server -//! -//! Ce module fournit un service de haut niveau pour gérer les rooms: -//! - Création et suppression de rooms -//! - Ajout et retrait d'utilisateurs -//! - Liste des utilisateurs d'une room - -use crate::error::ChatError; -use crate::repository::{Room, RoomMember, RoomRepository}; -use sqlx::PgPool; -use tracing::{debug, info, warn}; -use uuid::Uuid; - -/// Service de gestion des rooms -pub struct RoomService { - repo: RoomRepository, -} - -impl RoomService { - /// Crée un nouveau service de gestion des rooms - /// - /// # Arguments - /// - /// * `pool` - Pool de connexions PostgreSQL - pub fn new(pool: PgPool) -> Self { - Self { - repo: RoomRepository::new(pool), - } - } - - /// Crée une nouvelle room - /// - /// # Arguments - /// - /// * `name` - Nom de la room (optionnel) - /// * `description` - Description de la room (optionnel) - /// * `room_type` - Type de room (ex: "public", "private", "direct") - /// * `is_private` - Si la room est privée - /// * `creator_id` - ID du créateur de la room - /// - /// # Returns - /// - /// L'ID de la room créée - /// - /// # Errors - /// - /// Retourne une erreur si la création échoue - pub async fn create_room( - &self, - name: Option<&str>, - description: Option<&str>, - room_type: &str, - is_private: bool, - creator_id: Uuid, - ) -> Result { - info!( - "Création d'une room: name={:?}, type={}, private={}, creator={}", - name, room_type, is_private, creator_id - ); - - let room = self - .repo - .create(name, description, room_type, is_private, creator_id) - .await - .map_err(|e| { - ChatError::internal_error(format!("Erreur lors de la création de la room: {}", e)) - })?; - - info!("✅ Room créée avec succès: {}", room.id); - - // Ajouter le créateur comme membre de la room avec le rôle "owner" - let _ = self - .add_user(room.id, creator_id, "owner") - .await - .map_err(|e| { - warn!("⚠️ Impossible d'ajouter le créateur comme membre: {}", e); - // On continue même si l'ajout échoue - }); - - Ok(room.id) - } - - /// Supprime une room - /// - /// # Arguments - /// - /// * `room_id` - ID de la room à supprimer - /// - /// # Returns - /// - /// Ok(()) si la suppression réussit - /// - /// # Errors - /// - /// Retourne une erreur si la room n'existe pas ou si la suppression échoue - pub async fn delete_room(&self, room_id: Uuid) -> Result<(), ChatError> { - info!("Suppression de la room: {}", room_id); - - // Vérifier que la room existe - let room = self.repo.get_by_id(room_id).await.map_err(|e| { - ChatError::internal_error(format!("Erreur lors de la vérification de la room: {}", e)) - })?; - - if room.is_none() { - return Err(ChatError::not_found("Room", &room_id.to_string())); - } - - // Supprimer tous les membres de la room - let members = self.repo.get_members(room_id).await.map_err(|e| { - ChatError::internal_error(format!("Erreur lors de la récupération des membres: {}", e)) - })?; - - for member in members { - let _ = self - .repo - .remove_member(room_id, member.user_id) - .await - .map_err(|e| { - warn!( - "⚠️ Erreur lors de la suppression du membre {}: {}", - member.user_id, e - ); - // Continue même en cas d'erreur - }); - } - - // Supprimer la room (nécessite une méthode delete dans le repository) - // Pour l'instant, on utilise une requête SQL directe - sqlx::query("DELETE FROM conversations WHERE id = $1") - .bind(room_id) - .execute(self.repo.pool()) - .await - .map_err(|e| { - ChatError::internal_error(format!( - "Erreur lors de la suppression de la room: {}", - e - )) - })?; - - info!("✅ Room {} supprimée avec succès", room_id); - Ok(()) - } - - /// Ajoute un utilisateur à une room - /// - /// # Arguments - /// - /// * `room_id` - ID de la room - /// * `user_id` - ID de l'utilisateur à ajouter - /// * `role` - Rôle de l'utilisateur dans la room (ex: "member", "admin", "owner") - /// - /// # Returns - /// - /// Le membre créé - /// - /// # Errors - /// - /// Retourne une erreur si la room n'existe pas ou si l'ajout échoue - pub async fn add_user( - &self, - room_id: Uuid, - user_id: Uuid, - role: &str, - ) -> Result { - debug!( - "Ajout de l'utilisateur {} à la room {} avec le rôle {}", - user_id, room_id, role - ); - - // Vérifier que la room existe - let room = self.repo.get_by_id(room_id).await.map_err(|e| { - ChatError::internal_error(format!("Erreur lors de la vérification de la room: {}", e)) - })?; - - if room.is_none() { - return Err(ChatError::not_found("Room", &room_id.to_string())); - } - - // Ajouter l'utilisateur à la room - let member = self - .repo - .add_member(room_id, user_id, role) - .await - .map_err(|e| { - ChatError::internal_error(format!( - "Erreur lors de l'ajout de l'utilisateur à la room: {}", - e - )) - })?; - - info!( - "✅ Utilisateur {} ajouté à la room {} avec le rôle {}", - user_id, room_id, role - ); - Ok(member) - } - - /// Retire un utilisateur d'une room - /// - /// # Arguments - /// - /// * `room_id` - ID de la room - /// * `user_id` - ID de l'utilisateur à retirer - /// - /// # Returns - /// - /// Ok(()) si la suppression réussit - /// - /// # Errors - /// - /// Retourne une erreur si la room ou l'utilisateur n'existe pas, ou si la suppression échoue - pub async fn remove_user(&self, room_id: Uuid, user_id: Uuid) -> Result<(), ChatError> { - info!( - "Retrait de l'utilisateur {} de la room {}", - user_id, room_id - ); - - // Vérifier que la room existe - let room = self.repo.get_by_id(room_id).await.map_err(|e| { - ChatError::internal_error(format!("Erreur lors de la vérification de la room: {}", e)) - })?; - - if room.is_none() { - return Err(ChatError::not_found("Room", &room_id.to_string())); - } - - // Retirer l'utilisateur de la room - self.repo - .remove_member(room_id, user_id) - .await - .map_err(|e| { - ChatError::internal_error(format!( - "Erreur lors du retrait de l'utilisateur de la room: {}", - e - )) - })?; - - info!("✅ Utilisateur {} retiré de la room {}", user_id, room_id); - Ok(()) - } - - /// Liste tous les utilisateurs d'une room - /// - /// # Arguments - /// - /// * `room_id` - ID de la room - /// - /// # Returns - /// - /// Un vecteur de membres de la room - /// - /// # Errors - /// - /// Retourne une erreur si la room n'existe pas ou si la récupération échoue - pub async fn list_users(&self, room_id: Uuid) -> Result, ChatError> { - debug!( - "Récupération de la liste des utilisateurs de la room {}", - room_id - ); - - // Vérifier que la room existe - let room = self.repo.get_by_id(room_id).await.map_err(|e| { - ChatError::internal_error(format!("Erreur lors de la vérification de la room: {}", e)) - })?; - - if room.is_none() { - return Err(ChatError::not_found("Room", &room_id.to_string())); - } - - // Récupérer les membres de la room - let members = self.repo.get_members(room_id).await.map_err(|e| { - ChatError::internal_error(format!("Erreur lors de la récupération des membres: {}", e)) - })?; - - debug!( - "✅ {} membres trouvés dans la room {}", - members.len(), - room_id - ); - Ok(members) - } - - /// Obtient une room par son ID - /// - /// # Arguments - /// - /// * `room_id` - ID de la room - /// - /// # Returns - /// - /// La room si elle existe - /// - /// # Errors - /// - /// Retourne une erreur si la room n'existe pas - pub async fn get_room(&self, room_id: Uuid) -> Result { - self.repo - .get_by_id(room_id) - .await - .map_err(|e| { - ChatError::internal_error(format!( - "Erreur lors de la récupération de la room: {}", - e - )) - })? - .ok_or_else(|| ChatError::not_found("Room", &room_id.to_string())) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // Note: Les tests nécessitent une base de données de test - // Ils sont marqués avec #[ignore] car ils nécessitent une configuration spécifique - - #[tokio::test] - #[ignore] // Nécessite une base de données de test - async fn test_create_room() { - // Ce test nécessite un pool de test - // let pool = create_test_pool().await; - // let service = RoomService::new(pool); - // let creator_id = Uuid::new_v4(); - // - // let room_id = service - // .create_room(Some("Test Room"), None, "public", false, creator_id) - // .await - // .unwrap(); - // - // assert!(!room_id.is_nil()); - } - - #[tokio::test] - #[ignore] // Nécessite une base de données de test - async fn test_add_and_remove_user() { - // Ce test nécessite un pool de test et une room existante - // let pool = create_test_pool().await; - // let service = RoomService::new(pool); - // let room_id = Uuid::new_v4(); - // let user_id = Uuid::new_v4(); - // - // // Ajouter l'utilisateur - // let member = service.add_user(room_id, user_id, "member").await.unwrap(); - // assert_eq!(member.user_id, user_id); - // - // // Retirer l'utilisateur - // service.remove_user(room_id, user_id).await.unwrap(); - // - // // Vérifier que l'utilisateur n'est plus dans la room - // let members = service.list_users(room_id).await.unwrap(); - // assert!(!members.iter().any(|m| m.user_id == user_id)); - } - - #[tokio::test] - #[ignore] // Nécessite une base de données de test - async fn test_list_users() { - // Ce test nécessite un pool de test et une room avec des membres - // let pool = create_test_pool().await; - // let service = RoomService::new(pool); - // let room_id = Uuid::new_v4(); - // - // let members = service.list_users(room_id).await.unwrap(); - // assert!(members.is_empty() || !members.is_empty()); // Au moins vérifier que ça ne panique pas - } -} diff --git a/veza-chat-server/src/simple_message_store.rs b/veza-chat-server/src/simple_message_store.rs deleted file mode 100644 index 7805149b8..000000000 --- a/veza-chat-server/src/simple_message_store.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! Store de messages simple et fonctionnel - -use crate::error::{ChatError, Result}; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use tokio::sync::RwLock; - -/// Message simple pour test -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SimpleMessage { - pub id: i32, - pub content: String, - pub author: String, - pub timestamp: DateTime, - pub room: Option, - pub is_direct: bool, -} - -/// Store en mémoire pour les tests -pub struct SimpleMessageStore { - messages: RwLock>, - next_id: RwLock, -} - -impl Default for SimpleMessageStore { - fn default() -> Self { - Self::new() - } -} - -impl SimpleMessageStore { - pub fn new() -> Self { - Self { - messages: RwLock::new(Vec::new()), - next_id: RwLock::new(1), - } - } - - /// Envoi d'un message simple - pub async fn send_simple_message( - &self, - content: &str, - author: &str, - room: Option<&str>, - is_direct: bool, - ) -> Result { - let mut next_id = self.next_id.write().await; - let id = *next_id; - *next_id += 1; - - let message = SimpleMessage { - id, - content: content.to_string(), - author: author.to_string(), - timestamp: Utc::now(), - room: room.map(|s| s.to_string()), - is_direct, - }; - - let mut messages = self.messages.write().await; - messages.push(message); - - Ok(id) - } - - /// Récupération des messages d'un salon - pub async fn get_room_messages( - &self, - room_name: &str, - limit: i32, - ) -> Result> { - let messages = self.messages.read().await; - let filtered: Vec = messages - .iter() - .filter(|msg| { - if let Some(ref msg_room) = msg.room { - msg_room == room_name && !msg.is_direct - } else { - false - } - }) - .take(limit as usize) - .cloned() - .collect(); - - Ok(filtered) - } - - /// Récupération des messages directs - pub async fn get_direct_messages( - &self, - user1: &str, - user2: &str, - limit: i32, - ) -> Result> { - let messages = self.messages.read().await; - let filtered: Vec = messages - .iter() - .filter(|msg| msg.is_direct && ((msg.author == user1) || (msg.author == user2))) - .take(limit as usize) - .cloned() - .collect(); - - Ok(filtered) - } - - /// Autres méthodes simplifiées - pub async fn pin_message(&self, _message_id: i32) -> Result<()> { - Ok(()) - } - pub async fn message_exists(&self, message_id: i32) -> Result { - let messages = self.messages.read().await; - Ok(messages.iter().any(|msg| msg.id == message_id)) - } - pub async fn delete_message(&self, message_id: i32) -> Result<()> { - let mut messages = self.messages.write().await; - messages.retain(|msg| msg.id != message_id); - Ok(()) - } - pub async fn edit_message(&self, message_id: i32, new_content: &str) -> Result<()> { - let mut messages = self.messages.write().await; - if let Some(msg) = messages.iter_mut().find(|msg| msg.id == message_id) { - msg.content = new_content.to_string(); - Ok(()) - } else { - Err(ChatError::not_found("message", &message_id.to_string())) - } - } - pub async fn add_reaction(&self, _message_id: i32, _user_id: i32, _emoji: &str) -> Result<()> { - Ok(()) - } - pub async fn remove_reaction( - &self, - _message_id: i32, - _user_id: i32, - _emoji: &str, - ) -> Result<()> { - Ok(()) - } - pub async fn mark_as_read(&self, _user_id: i32, _conversation_id: &str) -> Result<()> { - Ok(()) - } - pub async fn count_unread(&self, _user_id: i32) -> Result { - Ok(0) - } - pub async fn count_unread_dms(&self, _user_id: i32) -> Result { - Ok(0) - } - pub async fn count_unread_mentions(&self, _user_id: i32) -> Result { - Ok(0) - } - pub async fn count_reactions(&self, _message_id: i32) -> Result { - Ok(0) - } -} diff --git a/veza-chat-server/src/structured_logging.rs b/veza-chat-server/src/structured_logging.rs deleted file mode 100644 index 0fc0300f5..000000000 --- a/veza-chat-server/src/structured_logging.rs +++ /dev/null @@ -1,534 +0,0 @@ -//! Logging structuré avec tracing pour le serveur de chat -//! -//! Ce module fournit un système de logging avancé avec: -//! - Logs structurés avec champs contextuels -//! - Rotation des logs -//! - Filtrage par niveau et module -//! - Export vers différents formats (JSON, Pretty, Compact) -//! - Intégration avec les métriques - -use crate::config::{LogFormat, LogRotation, LoggingConfig, ServerConfig}; -use crate::error::{ChatError, Result}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::PathBuf; -use std::time::Duration; -use tracing::{debug, error, info, trace, warn}; -use tracing_appender::{ - non_blocking::{NonBlocking, WorkerGuard}, - rolling::{RollingFileAppender, Rotation}, -}; -use tracing_subscriber::{ - fmt::{self, format::Writer, time::ChronoUtc}, - layer::SubscriberExt, - util::SubscriberInitExt, - EnvFilter, Layer, Registry, -}; - -/// Configuration du logging structuré -#[derive(Debug)] -pub struct StructuredLogging { - config: LoggingConfig, - _guard: Option, -} - -impl StructuredLogging { - /// Initialise le système de logging structuré - pub fn new(config: LoggingConfig) -> Result { - Ok(Self { - config, - _guard: None, - }) - } - - /// Configure et initialise le subscriber tracing - pub fn setup(&self) -> Result<()> { - // Filtre d'environnement - let env_filter = EnvFilter::try_from_default_env() - .or_else(|_| EnvFilter::try_new(&self.config.level)) - .map_err(|e| { - ChatError::configuration_error(&format!("Erreur configuration filtre: {e}")) - })?; - - // Configuration du format - let format_layer = match self.config.format { - LogFormat::Json => fmt::layer() - .json() - .with_timer(ChronoUtc::rfc_3339()) - .with_target(true) - .with_file(true) - .with_line_number(true) - .boxed(), - LogFormat::Pretty => fmt::layer() - .pretty() - .with_timer(ChronoUtc::rfc_3339()) - .with_target(true) - .with_file(true) - .with_line_number(true) - .boxed(), - LogFormat::Compact => fmt::layer() - .compact() - .with_timer(ChronoUtc::rfc_3339()) - .with_target(true) - .boxed(), - }; - - // Configuration de la sortie - let registry = Registry::default().with(env_filter).with(format_layer); - registry.init(); - - info!( - level = %self.config.level, - format = ?self.config.format, - file = ?self.config.file, - "🔧 Système de logging structuré initialisé" - ); - - Ok(()) - } -} - -/// Macros de logging contextuel pour le chat -pub mod chat_logs { - use super::*; - use std::collections::HashMap; - use tracing::{debug, error, info, trace, warn}; - use uuid::Uuid; - - /// Log d'authentification - pub fn auth_success(user_id: i32, username: &str, ip: &str) { - info!( - event = "auth_success", - user_id = %user_id, - username = %username, - ip_address = %ip, - "🔐 Utilisateur authentifié" - ); - } - - /// Log d'échec d'authentification - pub fn auth_failure(username: &str, ip: &str, reason: &str) { - warn!( - event = "auth_failure", - username = %username, - ip_address = %ip, - reason = %reason, - "❌ Échec d'authentification" - ); - } - - /// Log de connexion WebSocket - pub fn websocket_connected(connection_id: &str, user_id: i32, username: &str) { - info!( - event = "websocket_connected", - connection_id = %connection_id, - user_id = %user_id, - username = %username, - "🔌 Connexion WebSocket établie" - ); - } - - /// Log de déconnexion WebSocket - pub fn websocket_disconnected(connection_id: &str, user_id: i32, username: &str, reason: &str) { - info!( - event = "websocket_disconnected", - connection_id = %connection_id, - user_id = %user_id, - username = %username, - reason = %reason, - "🔌 Connexion WebSocket fermée" - ); - } - - /// Log d'envoi de message - pub fn message_sent( - message_id: String, - user_id: i32, - username: &str, - room_id: &str, - message_type: &str, - content_length: usize, - ) { - info!( - event = "message_sent", - message_id = %message_id, - user_id = %user_id, - username = %username, - room_id = %room_id, - message_type = %message_type, - content_length = %content_length, - "💬 Message envoyé" - ); - } - - /// Log de réception de message - pub fn message_received( - message_id: i32, - user_id: i32, - username: &str, - room_id: &str, - message_type: &str, - ) { - debug!( - event = "message_received", - message_id = %message_id, - user_id = %user_id, - username = %username, - room_id = %room_id, - message_type = %message_type, - "📨 Message reçu" - ); - } - - /// Log de création de salon - pub fn room_created(room_id: &str, room_name: &str, creator_id: i32, creator_username: &str) { - info!( - event = "room_created", - room_id = %room_id, - room_name = %room_name, - creator_id = %creator_id, - creator_username = %creator_username, - "🏠 Salon créé" - ); - } - - /// Log de suppression de salon - pub fn room_deleted(room_id: &str, room_name: &str, deleter_id: i32, deleter_username: &str) { - warn!( - event = "room_deleted", - room_id = %room_id, - room_name = %room_name, - deleter_id = %deleter_id, - deleter_username = %deleter_username, - "🗑️ Salon supprimé" - ); - } - - /// Log d'erreur système - pub fn system_error(error_type: &str, context: &str, error: &str) { - error!( - event = "system_error", - error_type = %error_type, - context = %context, - error = %error, - "💥 Erreur système" - ); - } - - /// Log d'erreur de validation - pub fn validation_error(field: &str, value: &str, reason: &str) { - warn!( - event = "validation_error", - field = %field, - value = %value, - reason = %reason, - "⚠️ Erreur de validation" - ); - } - - /// Log de rate limiting - pub fn rate_limit_triggered( - user_id: i32, - username: &str, - limit_type: &str, - current_count: u32, - ) { - warn!( - event = "rate_limit_triggered", - user_id = %user_id, - username = %username, - limit_type = %limit_type, - current_count = %current_count, - "🚫 Rate limit déclenché" - ); - } - - /// Log de métriques - pub fn metrics_updated(metric_name: &str, value: f64, labels: &HashMap) { - trace!( - event = "metrics_updated", - metric_name = %metric_name, - value = %value, - labels = ?labels, - "📊 Métrique mise à jour" - ); - } - - /// Log de performance - pub fn performance_measurement( - operation: &str, - duration_ms: f64, - success: bool, - additional_data: Option<&HashMap>, - ) { - let level = if success { "info" } else { "warn" }; - let message = if success { - "⚡ Opération terminée" - } else { - "🐌 Opération lente" - }; - - match level { - "info" => { - info!( - event = "performance_measurement", - operation = %operation, - duration_ms = %duration_ms, - success = %success, - additional_data = ?additional_data, - "{}", message - ); - } - _ => { - warn!( - event = "performance_measurement", - operation = %operation, - duration_ms = %duration_ms, - success = %success, - additional_data = ?additional_data, - "{}", message - ); - } - } - } - - /// Log de démarrage du serveur - pub fn server_started(bind_addr: &str, environment: &str, version: &str) { - info!( - event = "server_started", - bind_addr = %bind_addr, - environment = %environment, - version = %version, - "🚀 Serveur de chat démarré" - ); - } - - /// Log d'arrêt du serveur - pub fn server_stopped(reason: &str, uptime_seconds: u64) { - info!( - event = "server_stopped", - reason = %reason, - uptime_seconds = %uptime_seconds, - "🛑 Serveur de chat arrêté" - ); - } - - /// Log de configuration - pub fn config_loaded(config_source: &str, config_path: Option<&str>) { - info!( - event = "config_loaded", - config_source = %config_source, - config_path = ?config_path, - "⚙️ Configuration chargée" - ); - } - - /// Log de base de données - pub fn database_operation(operation: &str, table: &str, duration_ms: f64, success: bool) { - let level = if success { "debug" } else { "error" }; - let message = if success { - "🗄️ Opération DB réussie" - } else { - "💥 Erreur DB" - }; - - match level { - "debug" => { - debug!( - event = "database_operation", - operation = %operation, - table = %table, - duration_ms = %duration_ms, - success = %success, - "{}", message - ); - } - _ => { - error!( - event = "database_operation", - operation = %operation, - table = %table, - duration_ms = %duration_ms, - success = %success, - "{}", message - ); - } - } - } - - /// Log de cache - pub fn cache_operation(operation: &str, key: &str, hit: bool, duration_ms: f64) { - debug!( - event = "cache_operation", - operation = %operation, - key = %key, - hit = %hit, - duration_ms = %duration_ms, - "💾 Opération de cache" - ); - } - - /// Log de sécurité - pub fn security_event(event_type: &str, user_id: Option, ip: &str, details: &str) { - warn!( - event = "security_event", - event_type = %event_type, - user_id = ?user_id, - ip_address = %ip, - details = %details, - "🔒 Événement de sécurité" - ); - } -} - -/// Wrapper pour les logs avec contexte utilisateur -pub struct UserContextLogger { - user_id: i32, - username: String, - ip_address: String, -} - -impl UserContextLogger { - pub fn new(user_id: i32, username: String, ip_address: String) -> Self { - Self { - user_id, - username, - ip_address, - } - } - - pub fn info(&self, message: &str, fields: Option<&HashMap>) { - info!( - user_id = %self.user_id, - username = %self.username, - ip_address = %self.ip_address, - additional_fields = ?fields, - "{}", message - ); - } - - pub fn warn(&self, message: &str, fields: Option<&HashMap>) { - warn!( - user_id = %self.user_id, - username = %self.username, - ip_address = %self.ip_address, - additional_fields = ?fields, - "{}", message - ); - } - - pub fn error(&self, message: &str, fields: Option<&HashMap>) { - error!( - user_id = %self.user_id, - username = %self.username, - ip_address = %self.ip_address, - additional_fields = ?fields, - "{}", message - ); - } -} - -/// Initialise le système de logging depuis la configuration du serveur -pub fn init_logging_from_config(config: &ServerConfig) -> Result { - let logging_config = config.logging.clone(); - let structured_logging = StructuredLogging::new(logging_config)?; - structured_logging.setup()?; - - chat_logs::config_loaded("server_config", None); - chat_logs::server_started( - &config.server.bind_addr.to_string(), - &config.server.environment.to_string(), - env!("CARGO_PKG_VERSION"), - ); - - Ok(structured_logging) -} - -/// Configuration par défaut pour les tests -pub fn init_test_logging() -> Result<()> { - let config = LoggingConfig { - level: "debug".to_string(), - format: LogFormat::Compact, - file: None, - rotation: None, - filters: vec!["chat_server=debug".to_string()], - }; - - let structured_logging = StructuredLogging::new(config)?; - structured_logging.setup()?; - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashMap; - - #[test] - fn test_user_context_logger() { - let logger = UserContextLogger::new(1, "testuser".to_string(), "127.0.0.1".to_string()); - - // Test que le logger peut être créé sans erreur - assert_eq!(logger.user_id, 1); - assert_eq!(logger.username, "testuser"); - assert_eq!(logger.ip_address, "127.0.0.1"); - } - - #[test] - fn test_chat_logs_macros() { - // Test que les macros de logging peuvent être appelées - // (elles ne feront rien en mode test sans subscriber) - chat_logs::auth_success(1, "testuser", "127.0.0.1"); - chat_logs::message_sent(1, 1, "testuser", "room1", "text", 10); - chat_logs::system_error("test", "context", "error"); - } - - #[test] - fn test_structured_logging_creation() { - let config = LoggingConfig { - level: "info".to_string(), - format: LogFormat::Pretty, - file: None, - rotation: None, - filters: vec![], - }; - - let result = StructuredLogging::new(config); - assert!(result.is_ok()); - } - - #[test] - fn test_user_context_logger_fields() { - let logger = UserContextLogger::new(42, "admin".to_string(), "10.0.0.1".to_string()); - assert_eq!(logger.user_id, 42); - assert_eq!(logger.username, "admin"); - assert_eq!(logger.ip_address, "10.0.0.1"); - } - - #[test] - fn test_structured_logging_json_format() { - let config = LoggingConfig { - level: "warn".to_string(), - format: LogFormat::Json, - file: None, - rotation: None, - filters: vec![], - }; - let result = StructuredLogging::new(config); - assert!(result.is_ok()); - } - - #[test] - fn test_structured_logging_compact_format() { - let config = LoggingConfig { - level: "debug".to_string(), - format: LogFormat::Compact, - file: None, - rotation: None, - filters: vec!["chat=debug".to_string()], - }; - let result = StructuredLogging::new(config); - assert!(result.is_ok()); - } -} diff --git a/veza-chat-server/src/test_simple_store.rs b/veza-chat-server/src/test_simple_store.rs deleted file mode 100644 index 9ecbf1827..000000000 --- a/veza-chat-server/src/test_simple_store.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! Tests pour le store simple - -use crate::error::{ChatError, Result}; -use crate::simple_message_store::SimpleMessageStore; - -pub async fn test_simple_store(_database_url: &str) -> Result<()> { - let store = SimpleMessageStore::new(); - - // Test d'envoi de message - let msg_id = store.send_simple_message( - "Hello World!", - "test_user", - Some("general"), - false - ).await?; - - println!("✅ Message envoyé avec ID: {}", msg_id); - - // Test de récupération - let messages = store.get_room_messages("general", 10).await?; - println!("✅ Messages récupérés: {}", messages.len()); - - // Test de message direct - let dm_id = store.send_simple_message( - "Private message", - "user1", - None, - true - ).await?; - - println!("✅ Message direct envoyé avec ID: {}", dm_id); - - // Test de récupération DM - let dms = store.get_direct_messages("user1", "user2", 10).await?; - println!("✅ Messages directs récupérés: {}", dms.len()); - - // Test d'édition - store.edit_message(msg_id, "Hello World Updated!").await?; - println!("✅ Message édité"); - - // Test de suppression - store.delete_message(msg_id).await?; - println!("✅ Message supprimé"); - - // Tests d'autres fonctions - store.pin_message(1).await?; - let exists = store.message_exists(999).await?; - println!("✅ Message 999 existe: {}", exists); - - store.add_reaction(1, 1, "👍").await?; - store.remove_reaction(1, 1, "👍").await?; - store.mark_as_read(1, "conversation1").await?; - - let unread = store.count_unread(1).await?; - let unread_dms = store.count_unread_dms(1).await?; - let unread_mentions = store.count_unread_mentions(1).await?; - let reactions = store.count_reactions(1).await?; - - println!("✅ Compteurs - Non lus: {}, DMs: {}, Mentions: {}, Réactions: {}", - unread, unread_dms, unread_mentions, reactions); - - println!("🎉 Tous les tests passés!"); - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_store_integration() { - let database_url = std::env::var("DATABASE_URL") - .unwrap_or_else(|_| "postgresql://veza_user:veza_password@10.5.191.134:5432/veza_db".to_string()); - - if let Err(e) = test_simple_store(&database_url).await { - panic!("Test failed: {}", e); - } - } -} \ No newline at end of file diff --git a/veza-chat-server/src/typing_indicator.rs b/veza-chat-server/src/typing_indicator.rs deleted file mode 100644 index 0440b16a0..000000000 --- a/veza-chat-server/src/typing_indicator.rs +++ /dev/null @@ -1,179 +0,0 @@ -use chrono::{Duration, Utc}; -use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::RwLock; -use tracing::{debug, info, instrument}; -use uuid::Uuid; - -/// Représente un changement de statut typing pour un utilisateur -#[derive(Debug, Clone)] -pub struct TypingStatusChange { - pub user_id: Uuid, - pub conversation_id: Uuid, - pub is_typing: bool, -} - -/// Manager pour gérer les typing indicators -pub struct TypingIndicatorManager { - /// Map de conversation ID vers map de user ID vers timestamp de dernière activité - typing_users: Arc>>>>, - /// Durée après laquelle un user n'est plus considéré comme "en train de taper" - timeout_duration: Duration, -} - -impl TypingIndicatorManager { - pub fn new() -> Self { - Self { - typing_users: Arc::new(RwLock::new(HashMap::new())), - timeout_duration: Duration::seconds(3), - } - } - - /// Marquer qu'un user est en train de taper dans une conversation - #[instrument(skip(self))] - pub async fn user_started_typing(&self, user_id: Uuid, conversation_id: Uuid) { - let mut typing = self.typing_users.write().await; - - let conversation_typing = typing.entry(conversation_id).or_insert_with(HashMap::new); - - conversation_typing.insert(user_id, Utc::now()); - - info!( - user_id = %user_id, - conversation_id = %conversation_id, - "User started typing" - ); - } - - /// Retirer un user de la liste des users en train de taper - #[instrument(skip(self))] - pub async fn user_stopped_typing(&self, user_id: Uuid, conversation_id: Uuid) { - let mut typing = self.typing_users.write().await; - - if let Some(conversation_typing) = typing.get_mut(&conversation_id) { - conversation_typing.remove(&user_id); - - info!( - user_id = %user_id, - conversation_id = %conversation_id, - "User stopped typing" - ); - } - } - - /// Obtenir la liste des users en train de taper dans une conversation - pub async fn get_typing_users(&self, conversation_id: Uuid) -> Vec { - let typing = self.typing_users.read().await; - - if let Some(conversation_typing) = typing.get(&conversation_id) { - let now = Utc::now(); - let mut active_users = Vec::new(); - - for (user_id, last_activity) in conversation_typing.iter() { - let elapsed = now.signed_duration_since(*last_activity); - - if elapsed < self.timeout_duration { - active_users.push(*user_id); - } - } - - active_users - } else { - Vec::new() - } - } - - /// Détecter les utilisateurs dont le timeout a expiré et les retirer - /// Retourne la liste des changements de statut (is_typing = false) - #[instrument(skip(self))] - pub async fn monitor_timeouts(&self) -> Vec { - let mut typing = self.typing_users.write().await; - let now = Utc::now(); - let mut expired_changes = Vec::new(); - - for (conversation_id, conversation_typing) in typing.iter_mut() { - let mut expired_users = Vec::new(); - - for (user_id, last_activity) in conversation_typing.iter() { - let elapsed = now.signed_duration_since(*last_activity); - - if elapsed >= self.timeout_duration { - expired_users.push(*user_id); - } - } - - // Retirer les utilisateurs expirés et créer les changements de statut - for user_id in expired_users { - conversation_typing.remove(&user_id); - expired_changes.push(TypingStatusChange { - user_id, - conversation_id: *conversation_id, - is_typing: false, - }); - - debug!( - user_id = %user_id, - conversation_id = %conversation_id, - "User typing timeout expired" - ); - } - } - - // Retirer les conversations vides - typing.retain(|_conversation_id, users| !users.is_empty()); - - if !expired_changes.is_empty() { - debug!( - count = expired_changes.len(), - "Detected expired typing indicators" - ); - } - - expired_changes - } - - /// Nettoyer les users expirés de manière périodique (méthode legacy, utiliser monitor_timeouts) - #[deprecated(note = "Use monitor_timeouts() instead")] - pub async fn cleanup_expired(&self) { - let _ = self.monitor_timeouts().await; - } -} - -impl Default for TypingIndicatorManager { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_typing_indicator_manager() { - let manager = TypingIndicatorManager::new(); - - let conv1 = Uuid::new_v4(); - let user1 = Uuid::new_v4(); - let user2 = Uuid::new_v4(); - - // Test user_started_typing - manager.user_started_typing(user1, conv1).await; - manager.user_started_typing(user2, conv1).await; - - let typing_users = manager.get_typing_users(conv1).await; - assert!(typing_users.contains(&user1)); - assert!(typing_users.contains(&user2)); - - // Test user_stopped_typing - manager.user_stopped_typing(user1, conv1).await; - - let typing_users = manager.get_typing_users(conv1).await; - assert!(!typing_users.contains(&user1)); - assert!(typing_users.contains(&user2)); - - // Test monitor_timeouts - let expired = manager.monitor_timeouts().await; - assert!(expired.is_empty()); // Pas encore expiré - } -} diff --git a/veza-chat-server/src/utils.rs b/veza-chat-server/src/utils.rs deleted file mode 100644 index cfd97575c..000000000 --- a/veza-chat-server/src/utils.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! Utilitaires généraux - -use chrono::{DateTime, Utc}; -use std::time::{SystemTime, UNIX_EPOCH}; -use uuid::Uuid; - -/// Timestamp Unix actuel en secondes. Ne panique jamais (retourne 0 si système avant 1970). -pub fn unix_timestamp_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) -} - -/// Génère un nouvel UUID v4 -pub fn generate_id() -> Uuid { - Uuid::new_v4() -} - -/// Retourne le timestamp UTC actuel -pub fn now() -> DateTime { - Utc::now() -} - -/// Valide un nom d'utilisateur -pub fn validate_username(username: &str) -> bool { - !username.is_empty() - && username.len() >= 3 - && username.len() <= 32 - && username.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') -} - -/// Valide une adresse email basique -pub fn validate_email(email: &str) -> bool { - email.contains('@') && email.len() >= 5 && email.len() <= 255 -} - -/// Nettoie et normalise le contenu d'un message -pub fn sanitize_message_content(content: &str) -> String { - content.trim().to_string() -} - -/// Tronque un texte à une longueur donnée -pub fn truncate_text(text: &str, max_len: usize) -> String { - if text.len() <= max_len { - text.to_string() - } else { - format!("{}...", &text[..max_len.saturating_sub(3)]) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_validate_username() { - assert!(validate_username("test_user")); - assert!(validate_username("user-123")); - assert!(!validate_username("")); - assert!(!validate_username("us")); - assert!(!validate_username("user@domain")); - } - - #[test] - fn test_validate_email() { - assert!(validate_email("test@example.com")); - assert!(!validate_email("invalid")); - assert!(!validate_email("")); - } - - #[test] - fn test_truncate_text() { - assert_eq!(truncate_text("hello", 10), "hello"); - assert_eq!(truncate_text("hello world test", 10), "hello w..."); - } - - #[test] - fn test_validate_username_length_boundaries() { - assert!(validate_username("abc")); - assert!(validate_username("a".repeat(32).as_str())); - assert!(!validate_username("ab")); - assert!(!validate_username("a".repeat(33).as_str())); - } - - #[test] - fn test_validate_username_allowed_chars() { - assert!(validate_username("user_123")); - assert!(validate_username("user-name")); - assert!(!validate_username("user name")); - assert!(!validate_username("user@domain")); - } - - #[test] - fn test_sanitize_message_content() { - assert_eq!(sanitize_message_content(" hello "), "hello"); - assert_eq!(sanitize_message_content("no change"), "no change"); - } - - #[test] - fn test_truncate_text_edge_cases() { - assert_eq!(truncate_text("hi", 5), "hi"); - assert_eq!(truncate_text("hello", 5), "he..."); - } -} \ No newline at end of file diff --git a/veza-chat-server/src/validation.rs b/veza-chat-server/src/validation.rs deleted file mode 100644 index 7db270615..000000000 --- a/veza-chat-server/src/validation.rs +++ /dev/null @@ -1,54 +0,0 @@ -use crate::error::{ChatError, Result}; - -pub fn validate_message_content(content: &str, max_size: usize) -> Result<()> { - if content.is_empty() { - return Err(ChatError::configuration_error("Le message ne peut pas être vide")); - } - - if content.len() > max_size { - return Err(ChatError::message_too_long(content.len(), max_size)); - } - - // Vérifier les caractères de contrôle dangereux - if content.chars().any(|c| c.is_control() && c != '\n' && c != '\r' && c != '\t') { - return Err(ChatError::configuration_error("Caractères de contrôle non autorisés")); - } - - Ok(()) -} - -pub fn validate_room_name(room: &str) -> Result<()> { - if room.is_empty() { - return Err(ChatError::configuration_error("Le nom du salon ne peut pas être vide")); - } - - if room.len() > 100 { - return Err(ChatError::configuration_error("Le nom du salon est trop long (max 100 caractères)")); - } - - // Vérifier que le nom ne contient que des caractères alphanumériques, tirets et underscores - if !room.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') { - return Err(ChatError::configuration_error("Le nom du salon ne peut contenir que des lettres, chiffres, tirets et underscores")); - } - - Ok(()) -} - -pub fn validate_user_id(user_id: i32) -> Result<()> { - if user_id <= 0 { - return Err(ChatError::configuration_error("L'ID utilisateur doit être positif")); - } - Ok(()) -} - -pub fn validate_limit(limit: i64) -> Result { - if limit <= 0 { - return Err(ChatError::configuration_error("La limite doit être positive")); - } - - if limit > 1000 { - return Err(ChatError::configuration_error("La limite ne peut pas dépasser 1000")); - } - - Ok(limit) -} \ No newline at end of file diff --git a/veza-chat-server/src/websocket/broadcast.rs b/veza-chat-server/src/websocket/broadcast.rs deleted file mode 100644 index 29916a1b6..000000000 --- a/veza-chat-server/src/websocket/broadcast.rs +++ /dev/null @@ -1,302 +0,0 @@ -//! Système de broadcast de messages pour les rooms -//! -//! Ce module fournit un gestionnaire de broadcast utilisant `tokio::sync::broadcast` -//! pour diffuser efficacement des messages à tous les clients abonnés à une room. - -use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::{broadcast, RwLock}; -use tracing::{debug, error, info}; -use uuid::Uuid; - -use crate::websocket::OutgoingMessage; - -/// Gestionnaire de broadcast pour les messages de chat -/// -/// Utilise `tokio::sync::broadcast` pour diffuser des messages efficacement -/// à tous les clients abonnés à une room donnée. -pub struct BroadcastManager { - /// Map des rooms avec leur canal de broadcast - /// Chaque room a un `broadcast::Sender` pour diffuser les messages JSON - rooms: Arc>>>, - /// Capacité du canal de broadcast pour chaque room (nombre de messages en attente) - channel_capacity: usize, -} - -impl BroadcastManager { - /// Crée un nouveau gestionnaire de broadcast - /// - /// # Arguments - /// - /// * `channel_capacity` - Capacité du canal de broadcast pour chaque room (défaut: 128) - pub fn new(channel_capacity: Option) -> Self { - Self { - rooms: Arc::new(RwLock::new(HashMap::new())), - channel_capacity: channel_capacity.unwrap_or(128), - } - } - - /// Crée ou récupère le canal de broadcast pour une room - /// - /// Retourne un receiver pour s'abonner aux messages de la room - async fn get_or_create_room_channel(&self, room_id: Uuid) -> broadcast::Receiver { - let mut rooms = self.rooms.write().await; - - // Récupérer ou créer le canal pour cette room - let sender = rooms - .entry(room_id) - .or_insert_with(|| { - let (tx, _) = broadcast::channel(self.channel_capacity); - info!("📡 Canal de broadcast créé pour la room {}", room_id); - tx - }) - .clone(); - - // Créer un receiver pour cette room - sender.subscribe() - } - - /// S'abonne aux messages d'une room - /// - /// # Arguments - /// - /// * `room_id` - ID de la room à laquelle s'abonner - /// - /// # Returns - /// - /// Un `broadcast::Receiver` pour recevoir les messages de la room - pub async fn subscribe_to_room(&self, room_id: Uuid) -> broadcast::Receiver { - debug!("🔗 Subscription à la room {}", room_id); - self.get_or_create_room_channel(room_id).await - } - - /// Diffuse un message à tous les clients d'une room - /// - /// # Arguments - /// - /// * `room_id` - ID de la room dans laquelle diffuser - /// * `message` - Message à diffuser - /// - /// # Returns - /// - /// Le nombre de clients qui ont reçu le message (ou 0 si la room n'existe pas) - pub async fn broadcast_to_room(&self, room_id: Uuid, message: OutgoingMessage) -> usize { - // Sérialiser le message en JSON - let json = match serde_json::to_string(&message) { - Ok(json) => json, - Err(e) => { - error!( - "❌ Erreur de sérialisation du message pour la room {}: {}", - room_id, e - ); - return 0; - } - }; - - let rooms = self.rooms.read().await; - - // Chercher le canal de broadcast pour cette room - if let Some(sender) = rooms.get(&room_id) { - match sender.send(json) { - Ok(count) => { - debug!( - "📢 Message diffusé à {} clients dans la room {}", - count, room_id - ); - count - } - Err(e) => { - // Aucun receiver actif (la room est vide) - debug!("⚠️ Aucun receiver actif pour la room {}: {}", room_id, e); - 0 - } - } - } else { - // La room n'existe pas encore (aucun subscriber) - debug!( - "⚠️ Tentative de broadcast dans une room inexistante: {}", - room_id - ); - 0 - } - } - - /// Retire une room du gestionnaire si elle n'a plus de subscribers - /// - /// Cette méthode nettoie les rooms vides pour libérer la mémoire. - /// Appelée automatiquement lors des désinscriptions si nécessaire. - /// - /// # Arguments - /// - /// * `room_id` - ID de la room à nettoyer - pub async fn cleanup_empty_room(&self, room_id: Uuid) { - let mut rooms = self.rooms.write().await; - - if let Some(sender) = rooms.get(&room_id) { - // Si le sender n'a plus de receivers actifs, on retire la room - if sender.receiver_count() == 0 { - rooms.remove(&room_id); - info!("🧹 Room {} nettoyée (plus de subscribers)", room_id); - } - } - } - - /// Obtient le nombre de subscribers actifs pour une room - /// - /// # Arguments - /// - /// * `room_id` - ID de la room - /// - /// # Returns - /// - /// Le nombre de subscribers actifs (ou 0 si la room n'existe pas) - pub async fn subscriber_count(&self, room_id: Uuid) -> usize { - let rooms = self.rooms.read().await; - rooms - .get(&room_id) - .map(|sender| sender.receiver_count()) - .unwrap_or(0) - } - - /// Obtient la liste de toutes les rooms actives - /// - /// # Returns - /// - /// Un vecteur d'IDs de rooms actives (avec au moins un subscriber) - pub async fn active_rooms(&self) -> Vec { - let rooms = self.rooms.read().await; - rooms - .iter() - .filter(|(_, sender)| sender.receiver_count() > 0) - .map(|(room_id, _)| *room_id) - .collect() - } - - /// Nettoie toutes les rooms vides - /// - /// Utile pour libérer la mémoire périodiquement - pub async fn cleanup_all_empty_rooms(&self) { - let mut rooms = self.rooms.write().await; - let mut to_remove = Vec::new(); - - for (room_id, sender) in rooms.iter() { - if sender.receiver_count() == 0 { - to_remove.push(*room_id); - } - } - - for room_id in to_remove { - rooms.remove(&room_id); - info!("🧹 Room {} nettoyée (plus de subscribers)", room_id); - } - } -} - -impl Default for BroadcastManager { - fn default() -> Self { - Self::new(None) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tokio::time::{sleep, Duration}; - - #[tokio::test] - async fn test_broadcast_manager_creation() { - let manager = BroadcastManager::new(None); - assert_eq!(manager.subscriber_count(Uuid::new_v4()).await, 0); - } - - #[tokio::test] - async fn test_subscribe_and_broadcast() { - let manager = BroadcastManager::new(None); - let room_id = Uuid::new_v4(); - - // S'abonner à la room - let mut receiver = manager.subscribe_to_room(room_id).await; - assert_eq!(manager.subscriber_count(room_id).await, 1); - - // Créer un message de test - let message = OutgoingMessage::ActionConfirmed { - action: "test".to_string(), - success: true, - }; - - // Diffuser le message - let count = manager.broadcast_to_room(room_id, message).await; - assert_eq!(count, 1); - - // Vérifier que le message a été reçu - let received = tokio::time::timeout(Duration::from_millis(100), receiver.recv()).await; - assert!(received.is_ok()); - if let Ok(Ok(json)) = received { - assert!(!json.is_empty()); - } - } - - #[tokio::test] - async fn test_multiple_subscribers() { - let manager = BroadcastManager::new(None); - let room_id = Uuid::new_v4(); - - // Créer plusieurs subscribers - let mut receiver1 = manager.subscribe_to_room(room_id).await; - let mut receiver2 = manager.subscribe_to_room(room_id).await; - let mut receiver3 = manager.subscribe_to_room(room_id).await; - - assert_eq!(manager.subscriber_count(room_id).await, 3); - - // Diffuser un message - let message = OutgoingMessage::ActionConfirmed { - action: "broadcast".to_string(), - success: true, - }; - - let count = manager.broadcast_to_room(room_id, message).await; - assert_eq!(count, 3); - - // Vérifier que tous les receivers ont reçu le message - let received1 = tokio::time::timeout(Duration::from_millis(100), receiver1.recv()).await; - let received2 = tokio::time::timeout(Duration::from_millis(100), receiver2.recv()).await; - let received3 = tokio::time::timeout(Duration::from_millis(100), receiver3.recv()).await; - - assert!(received1.is_ok()); - assert!(received2.is_ok()); - assert!(received3.is_ok()); - } - - #[tokio::test] - async fn test_broadcast_to_nonexistent_room() { - let manager = BroadcastManager::new(None); - let room_id = Uuid::new_v4(); - - let message = OutgoingMessage::ActionConfirmed { - action: "test".to_string(), - success: true, - }; - - // Diffuser dans une room sans subscribers - let count = manager.broadcast_to_room(room_id, message).await; - assert_eq!(count, 0); - } - - #[tokio::test] - async fn test_cleanup_empty_room() { - let manager = BroadcastManager::new(None); - let room_id = Uuid::new_v4(); - - // S'abonner puis se désabonner - let receiver = manager.subscribe_to_room(room_id).await; - assert_eq!(manager.subscriber_count(room_id).await, 1); - - // Drop le receiver (simule une désinscription) - drop(receiver); - sleep(Duration::from_millis(10)).await; // Laisser le temps au cleanup - - // Nettoyer la room - manager.cleanup_empty_room(room_id).await; - assert_eq!(manager.subscriber_count(room_id).await, 0); - } -} diff --git a/veza-chat-server/src/websocket/handler.rs b/veza-chat-server/src/websocket/handler.rs deleted file mode 100644 index c3f723c34..000000000 --- a/veza-chat-server/src/websocket/handler.rs +++ /dev/null @@ -1,1231 +0,0 @@ -//! Handler WebSocket pour le chat server -//! -//! Ce module fournit un handler WebSocket utilisant Axum pour gérer -//! les connexions, déconnexions et le routage des messages de chat. - -use axum::extract::ws::{Message, WebSocket}; -use axum::extract::{Query, State, WebSocketUpgrade}; -use axum::http::HeaderMap; -use axum::http::StatusCode; -use axum::response::{IntoResponse, Response}; -use futures_util::StreamExt; -use serde_json; -use std::collections::HashMap; -use std::sync::Arc; -use tracing::{debug, error, info, info_span, warn, Instrument}; -use uuid::Uuid; - -use crate::delivered_status::DeliveredStatusManager; -use crate::error::ChatError; -use crate::jwt_manager::{AccessTokenClaims, JwtManager}; -use crate::monitoring::ChatMetrics; -use crate::reactions::ReactionsManager; -use crate::read_receipts::ReadReceiptManager; -use crate::repository::MessageRepository; -use crate::security::permission::PermissionService; -use crate::services::MessageEditService; -use crate::typing_indicator::TypingIndicatorManager; -use crate::websocket::{IncomingMessage, OutgoingMessage, WebSocketClient, WebSocketManager}; - -/// État partagé pour le handler WebSocket -#[derive(Clone)] -pub struct WebSocketState { - /// Timeout d'inactivité (heartbeat) en secondes, configurable via CHAT_KEEPALIVE_TIMEOUT_SECS - pub keepalive_timeout_secs: u64, - // pub store: Arc, // Remove SimpleMessageStore - pub message_repo: Arc, // Add MessageRepository - pub read_receipt_manager: Arc, // Add ReadReceiptManager - pub delivered_status_manager: Arc, // Add DeliveredStatusManager - pub typing_indicator_manager: Arc, // Add TypingIndicatorManager - pub message_edit_service: Arc, // Add MessageEditService - pub reactions_manager: Arc, // Add ReactionsManager - pub ws_manager: Arc, - pub jwt_manager: Arc, - pub permission_service: Arc, // Add PermissionService - pub metrics: Arc, - pub rate_limiter: Arc, -} - -/// Extract access token from query param (?token=) or Cookie header (access_token) -fn extract_token(params: &HashMap, headers: &HeaderMap) -> Option { - if let Some(t) = params.get("token") { - return Some(t.clone()); - } - if let Some(cookie_header) = headers.get(axum::http::header::COOKIE) { - if let Ok(cookie_str) = cookie_header.to_str() { - for part in cookie_str.split(';') { - let part = part.trim(); - if part.starts_with("access_token=") { - let value = part.trim_start_matches("access_token=").trim(); - if !value.is_empty() { - return Some(value.to_string()); - } - } - } - } - } - None -} - -/// Handler principal pour les connexions WebSocket -/// -/// Cette fonction gère la mise à niveau de la connexion HTTP vers WebSocket -/// et délègue la gestion de la connexion à `handle_socket`. -/// FIX #13: Extrait le request_id depuis les extensions Axum (ajouté par le middleware) -pub async fn websocket_handler( - ws: WebSocketUpgrade, - Query(params): Query>, - headers: HeaderMap, - State(state): State, - request_id: Option>, -) -> Response { - // FIX #13: Extraire le request_id pour la corrélation - let request_id = request_id.map(|ext| *ext).unwrap_or_else(Uuid::new_v4); - - // Créer un span avec le request_id pour la corrélation - let span = info_span!( - "websocket_upgrade", - request_id = %request_id, - ); - - async move { - info!(request_id = %request_id, "🔌 Nouvelle connexion WebSocket demandée"); - - let token = match extract_token(¶ms, &headers) { - Some(t) => t, - None => { - error!(request_id = %request_id, "❌ Token manquant (query ?token= ou cookie access_token)"); - return (StatusCode::UNAUTHORIZED, "Missing token").into_response(); - } - }; - - match state.jwt_manager.validate_access_token(&token).await { - Ok(claims) => { - info!( - request_id = %request_id, - username = %claims.username, - user_id = %claims.user_id, - "✅ Connexion autorisée" - ); - // FIX #13: Passer le request_id au handler de socket - ws.on_upgrade(move |socket| handle_socket(socket, state, claims, request_id)) - } - Err(e) => { - error!(request_id = %request_id, error = %e, "❌ Token invalide"); - (StatusCode::UNAUTHORIZED, "Invalid token").into_response() - } - } - } - .instrument(span) - .await -} - -/// Gère une connexion WebSocket individuelle -/// -/// Note: Toutes les erreurs sont gérées explicitement pour éviter les panics. -/// Tokio capture automatiquement les panics dans les handlers, mais nous -/// nous assurons que toutes les erreurs sont gérées explicitement avec `?` ou `match`. -/// FIX #13: Accepte le request_id pour la corrélation -async fn handle_socket( - socket: WebSocket, - state: WebSocketState, - claims: AccessTokenClaims, - request_id: Uuid, -) { - // FIX #13: Créer un span avec le request_id pour toute la durée de la connexion WebSocket - let span = info_span!( - "websocket_connection", - request_id = %request_id, - user_id = %claims.user_id, - username = %claims.username, - ); - - async move { - let (sender, mut receiver) = socket.split(); - - // ID du client (généré localement pour cette connexion) - let client_id = Uuid::new_v4(); - // MIGRATION UUID: user_id est déjà String (UUID) - let client = Arc::new(WebSocketClient::new( - client_id, - claims.user_id.clone(), - sender, - )); - - state.ws_manager.add_client(client.clone()).await; - - info!( - request_id = %request_id, - client_id = %client_id, - username = %claims.username, - "✅ Connexion WebSocket établie" - ); - - // Metrics: connection - state - .metrics - .websocket_connected(claims.user_id.clone()) - .await; - - // Envoyer un message de bienvenue - let welcome_msg = OutgoingMessage::ActionConfirmed { - action: "connected".to_string(), - success: true, - }; - - if client.send_message(welcome_msg).await.is_err() { - error!("❌ Impossible d'envoyer le message de bienvenue"); - state.ws_manager.remove_client(client_id).await; - return; - } - - let keepalive_timeout = std::time::Duration::from_secs(state.keepalive_timeout_secs); - - // Boucle principale de gestion des messages avec timeout - loop { - match tokio::time::timeout(keepalive_timeout, receiver.next()).await { - Ok(Some(msg)) => { - match msg { - Ok(Message::Text(text)) => { - debug!("📨 Message WebSocket reçu: {}", text); - - match handle_incoming_message(&text, &state, client.clone(), &claims).await - { - Ok(should_continue) => { - if !should_continue { - break; - } - } - Err(e) => { - error!("❌ Erreur lors du traitement du message: {}", e); - - // Envoyer un message d'erreur au client - let error_msg = OutgoingMessage::Error { - message: format!("Erreur: {}", e), - }; - if client.send_message(error_msg).await.is_err() { - error!("❌ Impossible d'envoyer le message d'erreur au client"); - break; // Fermer la connexion si on ne peut même pas envoyer d'erreur - } - } - } - } - Ok(Message::Close(_)) => { - info!("👋 Connexion WebSocket fermée par le client"); - break; - } - Ok(Message::Ping(_)) => { - debug!("🏓 Ping WebSocket reçu"); - if client.send_message(OutgoingMessage::Pong).await.is_err() { - error!("❌ Erreur lors de l'envoi du Pong"); - break; - } - } - Ok(Message::Pong(_)) => { - debug!("🏓 Pong WebSocket reçu"); - } - Ok(_) => { - debug!("⚠️ Type de message WebSocket non géré"); - } - Err(e) => { - error!("❌ Erreur WebSocket: {}", e); - break; - } - } - } - Ok(None) => { - // Fin du stream - break; - } - Err(_) => { - info!( - "💤 Timeout inactivité ({}s) pour client {}, fermeture", - keepalive_timeout.as_secs(), - client_id - ); - break; - } - } - } - - info!( - request_id = %request_id, - client_id = %client_id, - username = %claims.username, - "🔌 Connexion WebSocket terminée" - ); - state.ws_manager.remove_client(client_id).await; - - // Metrics: disconnection - state.metrics.websocket_disconnected(claims.user_id).await; - } - .instrument(span) - .await; -} - -/// Traite un message entrant et route selon le type -/// -/// Retourne `Ok(true)` si la connexion doit continuer, `Ok(false)` pour fermer -async fn handle_incoming_message( - text: &str, - state: &WebSocketState, - client: Arc, - claims: &AccessTokenClaims, -) -> Result { - // Parser le message JSON - let incoming: IncomingMessage = serde_json::from_str(text) - .map_err(|e| ChatError::serialization_error("IncomingMessage", text, e))?; - - // Determine the rate limit action category for this message - let rate_action = match &incoming { - IncomingMessage::SendMessage { .. } => Some(crate::security::RateLimitAction::SendMessage), - IncomingMessage::AddReaction { .. } | IncomingMessage::RemoveReaction { .. } => { - Some(crate::security::RateLimitAction::AddReaction) - } - IncomingMessage::EditMessage { .. } => Some(crate::security::RateLimitAction::EditMessage), - IncomingMessage::DeleteMessage { .. } => { - Some(crate::security::RateLimitAction::DeleteMessage) - } - IncomingMessage::Typing { .. } => Some(crate::security::RateLimitAction::Typing), - IncomingMessage::JoinConversation { .. } => { - Some(crate::security::RateLimitAction::JoinConversation) - } - IncomingMessage::SearchMessages { .. } => { - Some(crate::security::RateLimitAction::SearchMessages) - } - IncomingMessage::CallOffer { .. } - | IncomingMessage::CallAnswer { .. } - | IncomingMessage::ICECandidate { .. } - | IncomingMessage::CallHangup { .. } - | IncomingMessage::CallReject { .. } => { - Some(crate::security::RateLimitAction::CallSignaling) - } - // Ping, MarkAsRead, Delivered, LeaveConversation, FetchHistory, SyncMessages - // are not rate-limited - _ => None, - }; - - // Check rate limit if applicable - if let Some(action) = rate_action { - match state.rate_limiter.check_rate_limit(&claims.user_id, action).await { - Ok(false) => { - tracing::warn!( - user_id = %claims.user_id, - action = ?action, - "Rate limit exceeded for WebSocket action" - ); - // Send error to client via WebSocket and continue connection - let _ = client - .send_message(crate::websocket::OutgoingMessage::Error { - message: "Too many requests. Please slow down.".to_string(), - }) - .await; - return Ok(true); // Continue connection, just skip this message - } - Ok(true) => { /* Allowed, proceed */ } - Err(e) => { - tracing::error!("Rate limiter error: {}", e); - // Fail open — don't block on rate limiter errors - } - } - } - - match incoming { - IncomingMessage::SendMessage { - conversation_id, - content, - parent_message_id: _, - attachments, - } => { - info!( - "💬 Envoi de message via WebSocket par {} (conversation: {})", - claims.username, conversation_id - ); - - // MIGRATION UUID: user_id est déjà String (UUID), on le parse directement - let sender_uuid = Uuid::parse_str(&claims.user_id) - .map_err(|e| ChatError::validation_error(&format!("Invalid user UUID: {}", e)))?; - - // Vérifier les permissions avant d'envoyer le message - state - .permission_service - .can_send_message(sender_uuid, conversation_id) - .await - .map_err(|e| { - warn!( - user_id = %sender_uuid, - conversation_id = %conversation_id, - error = %e, - "Permission refusée pour l'envoi de message" - ); - e - })?; - - // Préparer les métadonnées pour les pièces jointes - let _metadata = attachments.as_ref().map(|a| serde_json::to_value(a).unwrap_or(serde_json::Value::Null)); - - // Enregistrer le message dans le store - // Note: On pourrait étendre MessageRepository::create pour accepter metadata et parent_message_id - let message = state - .message_repo - .create(conversation_id, sender_uuid, &content) - .await - .map_err(|e| { - ChatError::internal_error(format!( - "Erreur lors de l'enregistrement du message: {}", - e - )) - })?; - - // Diffuser le message à tous les clients de la conversation - let outgoing_message = OutgoingMessage::NewMessage { - conversation_id, - message_id: message.id, - sender_id: message.sender_id, - content: message.content.clone(), - created_at: message.created_at, - attachments, - }; - state - .ws_manager - .broadcast_to_conversation(conversation_id, outgoing_message) - .await?; - - // Envoyer confirmation au sender - let confirmation = OutgoingMessage::ActionConfirmed { - action: "message_sent".to_string(), - success: true, - }; - client.send_message(confirmation).await?; - - info!( - "✅ Message WebSocket envoyé et diffusé - ID: {}", - message.id - ); - } - IncomingMessage::AddReaction { - message_id, - conversation_id, - emoji, - } => { - info!( - "❤️ Ajout de réaction {} au message {} par {}", - emoji, message_id, claims.username - ); - - let user_uuid = Uuid::parse_str(&claims.user_id) - .map_err(|e| ChatError::validation_error(&format!("Invalid user UUID: {}", e)))?; - - // Vérifier les permissions - state - .permission_service - .can_read_conversation(user_uuid, conversation_id) - .await - .map_err(|e| { - warn!( - user_id = %user_uuid, - conversation_id = %conversation_id, - error = %e, - "Permission refusée pour ajouter une réaction" - ); - e - })?; - - // Convertir l'emoji string en enum (optionnel, on peut aussi stocker le string directement) - if let Some(reaction_emoji) = crate::reactions::ReactionEmoji::from_str(&emoji) { - state - .reactions_manager - .add_reaction(message_id, user_uuid, reaction_emoji) - .await - .map_err(|e| ChatError::internal_error(format!("Erreur DB réaction: {}", e)))?; - - // Diffuser la réaction - let reaction_msg = OutgoingMessage::ReactionAdded { - message_id, - conversation_id, - user_id: user_uuid, - emoji, - }; - state - .ws_manager - .broadcast_to_conversation(conversation_id, reaction_msg) - .await?; - - client.send_message(OutgoingMessage::ActionConfirmed { - action: "reaction_added".to_string(), - success: true, - }).await?; - } else { - return Err(ChatError::validation_error("Emoji non supporté")); - } - } - IncomingMessage::RemoveReaction { - message_id, - conversation_id, - } => { - info!( - "💔 Retrait de réaction du message {} par {}", - message_id, claims.username - ); - - let user_uuid = Uuid::parse_str(&claims.user_id) - .map_err(|e| ChatError::validation_error(&format!("Invalid user UUID: {}", e)))?; - - state - .reactions_manager - .remove_reaction(message_id, user_uuid) - .await - .map_err(|e| ChatError::internal_error(format!("Erreur DB réaction: {}", e)))?; - - // Diffuser le retrait - let reaction_msg = OutgoingMessage::ReactionRemoved { - message_id, - conversation_id, - user_id: user_uuid, - }; - state - .ws_manager - .broadcast_to_conversation(conversation_id, reaction_msg) - .await?; - - client.send_message(OutgoingMessage::ActionConfirmed { - action: "reaction_removed".to_string(), - success: true, - }).await?; - } - IncomingMessage::JoinConversation { conversation_id } => { - info!( - "🔗 Client {} ({}) rejoint la conversation {}", - client.id, claims.username, conversation_id - ); - - // MIGRATION UUID: user_id est déjà String (UUID), on le parse directement - let user_uuid = Uuid::parse_str(&claims.user_id) - .map_err(|e| ChatError::validation_error(&format!("Invalid user UUID: {}", e)))?; - - // Vérifier les permissions avant de rejoindre - state - .permission_service - .can_join_conversation(user_uuid, conversation_id) - .await - .map_err(|e| { - warn!( - user_id = %user_uuid, - conversation_id = %conversation_id, - error = %e, - "Permission refusée pour rejoindre la conversation" - ); - e - })?; - - client.add_conversation(conversation_id).await; - - let outgoing = OutgoingMessage::ActionConfirmed { - action: "joined_conversation".to_string(), - success: true, - }; - client.send_message(outgoing).await?; - } - IncomingMessage::LeaveConversation { conversation_id } => { - info!( - "🔚 Client {} ({}) quitte la conversation {}", - client.id, claims.username, conversation_id - ); - - client.remove_conversation(conversation_id).await; - - let outgoing = OutgoingMessage::ActionConfirmed { - action: "left_conversation".to_string(), - success: true, - }; - client.send_message(outgoing).await?; - } - IncomingMessage::MarkAsRead { - conversation_id, - message_id, - } => { - info!( - "👁️ Client {} marque le message {} comme lu dans {}", - client.id, message_id, conversation_id - ); - - // Parser l'user_id depuis les claims JWT - let user_uuid = Uuid::parse_str(&claims.user_id) - .map_err(|e| ChatError::validation_error(&format!("Invalid user UUID: {}", e)))?; - - // Vérifier que le message existe - let message = state - .message_repo - .get_by_id(message_id) - .await - .map_err(|e| { - ChatError::internal_error(format!( - "Erreur lors de la récupération du message: {}", - e - )) - })?; - - let message = - message.ok_or_else(|| ChatError::not_found("Message", &message_id.to_string()))?; - - // Vérifier que le message appartient à la conversation indiquée - if message.conversation_id != conversation_id { - return Err(ChatError::validation_error( - "Le message n'appartient pas à la conversation indiquée", - )); - } - - // Vérifier les permissions pour marquer comme lu - state - .permission_service - .can_mark_read(user_uuid, conversation_id) - .await - .map_err(|e| { - warn!( - user_id = %user_uuid, - conversation_id = %conversation_id, - error = %e, - "Permission refusée pour marquer comme lu" - ); - e - })?; - - // Marquer le message comme lu - let receipt = state - .read_receipt_manager - .mark_as_read(user_uuid, message_id, conversation_id) - .await - .map_err(|e| { - ChatError::internal_error(format!("Erreur lors du marquage comme lu: {}", e)) - })?; - - // Créer le message outbound pour notifier les autres participants - let message_read = OutgoingMessage::MessageRead { - message_id, - user_id: user_uuid, - conversation_id, - read_at: receipt.read_at, - }; - - // Broadcast aux autres participants de la conversation - state - .ws_manager - .broadcast_to_conversation(conversation_id, message_read.clone()) - .await?; - - // Envoyer confirmation au client qui a initié l'action - let confirmation = OutgoingMessage::ActionConfirmed { - action: "marked_as_read".to_string(), - success: true, - }; - client.send_message(confirmation).await?; - - info!( - "✅ Message {} marqué comme lu par {} dans la conversation {}", - message_id, user_uuid, conversation_id - ); - } - IncomingMessage::Typing { - conversation_id, - is_typing, - } => { - info!( - "⌨️ Client {} ({}) typing indicator: {} dans conversation {}", - client.id, claims.username, is_typing, conversation_id - ); - - // Parser l'user_id depuis les claims JWT - let user_uuid = Uuid::parse_str(&claims.user_id) - .map_err(|e| ChatError::validation_error(&format!("Invalid user UUID: {}", e)))?; - - // Vérifier les permissions avant d'envoyer le signal typing - state - .permission_service - .can_send_message(user_uuid, conversation_id) - .await - .map_err(|e| { - warn!( - user_id = %user_uuid, - conversation_id = %conversation_id, - error = %e, - "Permission refusée pour typing indicator" - ); - e - })?; - - if is_typing { - // User a commencé à taper - state - .typing_indicator_manager - .user_started_typing(user_uuid, conversation_id) - .await; - } else { - // User a arrêté de taper - state - .typing_indicator_manager - .user_stopped_typing(user_uuid, conversation_id) - .await; - } - - // Broadcast aux autres participants de la conversation - let typing_message = OutgoingMessage::UserTyping { - conversation_id, - user_id: user_uuid, - is_typing, - }; - state - .ws_manager - .broadcast_to_conversation(conversation_id, typing_message.clone()) - .await?; - - // Envoyer confirmation au client qui a initié l'action - let confirmation = OutgoingMessage::ActionConfirmed { - action: "typing_indicator".to_string(), - success: true, - }; - client.send_message(confirmation).await?; - - info!( - "✅ Typing indicator {} diffusé pour {} dans la conversation {}", - if is_typing { "activé" } else { "désactivé" }, - user_uuid, - conversation_id - ); - } - IncomingMessage::Delivered { - conversation_id, - message_id, - } => { - info!( - "📬 Client {} ({}) marque le message {} comme délivré dans {}", - client.id, message_id, conversation_id, claims.username - ); - - // Parser l'user_id depuis les claims JWT - let user_uuid = Uuid::parse_str(&claims.user_id) - .map_err(|e| ChatError::validation_error(&format!("Invalid user UUID: {}", e)))?; - - // Vérifier les permissions pour marquer comme délivré - state - .permission_service - .can_read_conversation(user_uuid, conversation_id) - .await - .map_err(|e| { - warn!( - user_id = %user_uuid, - conversation_id = %conversation_id, - error = %e, - "Permission refusée pour marquer comme délivré" - ); - e - })?; - - // Vérifier que le message existe et appartient à la conversation - let message = state - .message_repo - .get_by_id(message_id) - .await - .map_err(|e| { - ChatError::internal_error(format!( - "Erreur lors de la récupération du message: {}", - e - )) - })?; - - let message = - message.ok_or_else(|| ChatError::not_found("Message", &message_id.to_string()))?; - - // Vérifier que le message appartient à la conversation indiquée - if message.conversation_id != conversation_id { - return Err(ChatError::validation_error( - "Le message n'appartient pas à la conversation indiquée", - )); - } - - // Vérifier que le message appartient bien à la conversation (double vérification) - let belongs = state - .delivered_status_manager - .verify_message_belongs_to_conversation(message_id, conversation_id) - .await - .map_err(|e| { - ChatError::internal_error(format!( - "Erreur lors de la vérification du message: {}", - e - )) - })?; - - if !belongs { - return Err(ChatError::validation_error( - "Le message n'appartient pas à la conversation indiquée", - )); - } - - // Marquer le message comme délivré - let status = state - .delivered_status_manager - .mark_delivered(user_uuid, message_id, conversation_id) - .await - .map_err(|e| { - ChatError::internal_error(format!( - "Erreur lors du marquage comme délivré: {}", - e - )) - })?; - - // Créer le message outbound pour notifier les autres participants - let message_delivered = OutgoingMessage::MessageDelivered { - message_id, - user_id: user_uuid, - conversation_id, - delivered_at: status.delivered_at, - }; - - // Broadcast aux autres participants de la conversation - state - .ws_manager - .broadcast_to_conversation(conversation_id, message_delivered.clone()) - .await?; - - // Envoyer confirmation au client qui a initié l'action - let confirmation = OutgoingMessage::ActionConfirmed { - action: "marked_as_delivered".to_string(), - success: true, - }; - client.send_message(confirmation).await?; - - info!( - "✅ Message {} marqué comme délivré par {} dans la conversation {}", - message_id, user_uuid, conversation_id - ); - } - IncomingMessage::EditMessage { - message_id, - conversation_id, - new_content, - } => { - info!( - "✏️ Client {} ({}) édite le message {} dans {}", - client.id, claims.username, message_id, conversation_id - ); - - // Parser l'user_id depuis les claims JWT - let user_uuid = Uuid::parse_str(&claims.user_id) - .map_err(|e| ChatError::validation_error(&format!("Invalid user UUID: {}", e)))?; - - // Éditer le message via le service - let updated_message = state - .message_edit_service - .edit_message(user_uuid, message_id, &new_content) - .await - .map_err(|e| { - warn!( - user_id = %user_uuid, - message_id = %message_id, - error = %e, - "Erreur lors de l'édition du message" - ); - e - })?; - - // Vérifier que le message appartient à la conversation indiquée - if updated_message.conversation_id != conversation_id { - return Err(ChatError::validation_error( - "Le message n'appartient pas à la conversation indiquée", - )); - } - - // Créer le message outbound pour notifier les autres participants - let message_edited = OutgoingMessage::MessageEdited { - message_id, - conversation_id, - editor_id: user_uuid, - edited_at: updated_message - .edited_at - .unwrap_or(updated_message.updated_at), - new_content: updated_message.content.clone(), - }; - - // Broadcast aux autres participants de la conversation - state - .ws_manager - .broadcast_to_conversation(conversation_id, message_edited.clone()) - .await?; - - // Envoyer confirmation au client qui a initié l'action - let confirmation = OutgoingMessage::ActionConfirmed { - action: "message_edited".to_string(), - success: true, - }; - client.send_message(confirmation).await?; - - info!( - "✅ Message {} édité par {} dans la conversation {}", - message_id, user_uuid, conversation_id - ); - } - IncomingMessage::DeleteMessage { - message_id, - conversation_id, - } => { - info!( - "🗑️ Client {} ({}) supprime le message {} dans {}", - client.id, claims.username, message_id, conversation_id - ); - - // Parser l'user_id depuis les claims JWT - let user_uuid = Uuid::parse_str(&claims.user_id) - .map_err(|e| ChatError::validation_error(&format!("Invalid user UUID: {}", e)))?; - - // Supprimer le message via le service - let deleted_message = state - .message_edit_service - .delete_message(user_uuid, message_id) - .await - .map_err(|e| { - warn!( - user_id = %user_uuid, - message_id = %message_id, - error = %e, - "Erreur lors de la suppression du message" - ); - e - })?; - - // Vérifier que le message appartient à la conversation indiquée - if deleted_message.conversation_id != conversation_id { - return Err(ChatError::validation_error( - "Le message n'appartient pas à la conversation indiquée", - )); - } - - // Créer le message outbound pour notifier les autres participants - let message_deleted = OutgoingMessage::MessageDeleted { - message_id, - conversation_id, - deleter_id: user_uuid, - deleted_at: deleted_message - .deleted_at - .unwrap_or(deleted_message.updated_at), - }; - - // Broadcast aux autres participants de la conversation - state - .ws_manager - .broadcast_to_conversation(conversation_id, message_deleted.clone()) - .await?; - - // Envoyer confirmation au client qui a initié l'action - let confirmation = OutgoingMessage::ActionConfirmed { - action: "message_deleted".to_string(), - success: true, - }; - client.send_message(confirmation).await?; - - info!( - "✅ Message {} supprimé par {} dans la conversation {}", - message_id, user_uuid, conversation_id - ); - } - IncomingMessage::FetchHistory { - conversation_id, - before, - after, - limit, - } => { - info!( - "📜 Client {} ({}) demande l'historique de la conversation {}", - client.id, claims.username, conversation_id - ); - - // Parser l'user_id depuis les claims JWT - let user_uuid = Uuid::parse_str(&claims.user_id) - .map_err(|e| ChatError::validation_error(&format!("Invalid user UUID: {}", e)))?; - - // Vérifier les permissions pour lire l'historique - state - .permission_service - .can_read_conversation(user_uuid, conversation_id) - .await - .map_err(|e| { - warn!( - user_id = %user_uuid, - conversation_id = %conversation_id, - error = %e, - "Permission refusée pour lire l'historique" - ); - e - })?; - - // Récupérer l'historique - let limit = limit.unwrap_or(50).min(100); - let (messages, has_more_before, has_more_after): ( - Vec, - bool, - bool, - ) = state - .message_repo - .fetch_history(conversation_id, before, after, limit, false) - .await - .map_err(|e| { - ChatError::internal_error(format!( - "Erreur lors de la récupération de l'historique: {}", - e - )) - })?; - - // Envoyer le chunk d'historique - let message_count = messages.len(); - let history_chunk = OutgoingMessage::HistoryChunk { - conversation_id, - messages, - has_more_before, - has_more_after, - }; - client.send_message(history_chunk).await?; - - info!( - "✅ Historique envoyé pour la conversation {} ({} messages)", - conversation_id, message_count - ); - } - IncomingMessage::SearchMessages { - conversation_id, - query, - limit, - offset, - } => { - info!( - "🔍 Client {} ({}) recherche dans la conversation {}: '{}'", - client.id, claims.username, conversation_id, query - ); - - // Parser l'user_id depuis les claims JWT - let user_uuid = Uuid::parse_str(&claims.user_id) - .map_err(|e| ChatError::validation_error(&format!("Invalid user UUID: {}", e)))?; - - // Vérifier les permissions pour rechercher - state - .permission_service - .can_read_conversation(user_uuid, conversation_id) - .await - .map_err(|e| { - warn!( - user_id = %user_uuid, - conversation_id = %conversation_id, - error = %e, - "Permission refusée pour rechercher" - ); - e - })?; - - // Valider la query (ne pas être vide) - if query.trim().is_empty() { - return Err(ChatError::validation_error( - "La requête de recherche ne peut pas être vide", - )); - } - - // Rechercher les messages - let limit = limit.unwrap_or(50).min(100); - let offset = offset.unwrap_or(0); - let (messages, total) = state - .message_repo - .search_messages(conversation_id, &query, limit, offset, false) - .await - .map_err(|e| { - ChatError::internal_error(format!("Erreur lors de la recherche: {}", e)) - })?; - - // Envoyer les résultats - let search_results = OutgoingMessage::SearchResults { - conversation_id, - messages, - query: query.clone(), - total, - }; - client.send_message(search_results).await?; - - info!( - "✅ Recherche terminée pour '{}' dans {} ({} résultats)", - query, conversation_id, total - ); - } - IncomingMessage::SyncMessages { - conversation_id, - since, - } => { - info!( - "🔄 Client {} ({}) synchronise la conversation {} depuis {}", - client.id, claims.username, conversation_id, since - ); - - // Parser l'user_id depuis les claims JWT - let user_uuid = Uuid::parse_str(&claims.user_id) - .map_err(|e| ChatError::validation_error(&format!("Invalid user UUID: {}", e)))?; - - // Vérifier les permissions pour synchroniser - state - .permission_service - .can_read_conversation(user_uuid, conversation_id) - .await - .map_err(|e| { - warn!( - user_id = %user_uuid, - conversation_id = %conversation_id, - error = %e, - "Permission refusée pour synchroniser" - ); - e - })?; - - // Récupérer les messages depuis since - let messages = state - .message_repo - .fetch_since(conversation_id, since) - .await - .map_err(|e| { - ChatError::internal_error(format!("Erreur lors de la synchronisation: {}", e)) - })?; - - // Calculer le dernier timestamp de sync (maintenant) - let last_sync = chrono::Utc::now(); - - // Envoyer le chunk de synchronisation - let message_count = messages.len(); - let sync_chunk = OutgoingMessage::SyncChunk { - conversation_id, - messages, - last_sync, - }; - client.send_message(sync_chunk).await?; - - info!( - "✅ Synchronisation terminée pour {} ({} messages)", - conversation_id, message_count - ); - } - IncomingMessage::CallOffer { - conversation_id, - target_user_id, - sdp, - call_type, - } => { - let sender_uuid = Uuid::parse_str(&claims.user_id) - .map_err(|e| ChatError::validation_error(&format!("Invalid user UUID: {}", e)))?; - state - .permission_service - .can_send_message(sender_uuid, conversation_id) - .await - .map_err(|e| { - warn!( - user_id = %sender_uuid, - conversation_id = %conversation_id, - error = %e, - "Permission refusée pour CallOffer" - ); - e - })?; - let msg = OutgoingMessage::CallOffer { - conversation_id, - caller_user_id: sender_uuid, - sdp: sdp.clone(), - call_type: call_type.clone(), - }; - state - .ws_manager - .send_to_user(&target_user_id.to_string(), msg, Some(client.id)) - .await?; - info!( - "📞 CallOffer relayé de {} vers {} (conversation: {})", - claims.username, target_user_id, conversation_id - ); - } - IncomingMessage::CallAnswer { - conversation_id, - caller_user_id, - sdp, - } => { - let callee_uuid = Uuid::parse_str(&claims.user_id) - .map_err(|e| ChatError::validation_error(&format!("Invalid user UUID: {}", e)))?; - let msg = OutgoingMessage::CallAnswer { - conversation_id, - target_user_id: caller_user_id, - from_user_id: callee_uuid, - sdp: sdp.clone(), - }; - state - .ws_manager - .send_to_user(&caller_user_id.to_string(), msg, Some(client.id)) - .await?; - info!( - "📞 CallAnswer relayé vers {} (conversation: {})", - caller_user_id, conversation_id - ); - } - IncomingMessage::ICECandidate { - conversation_id, - target_user_id, - candidate, - } => { - let sender_uuid = Uuid::parse_str(&claims.user_id) - .map_err(|e| ChatError::validation_error(&format!("Invalid user UUID: {}", e)))?; - let msg = OutgoingMessage::ICECandidate { - conversation_id, - from_user_id: sender_uuid, - candidate: candidate.clone(), - }; - state - .ws_manager - .send_to_user(&target_user_id.to_string(), msg, Some(client.id)) - .await?; - debug!( - "📞 ICECandidate relayé de {} vers {} (conversation: {})", - claims.username, target_user_id, conversation_id - ); - } - IncomingMessage::CallHangup { - conversation_id, - target_user_id, - } => { - let sender_uuid = Uuid::parse_str(&claims.user_id) - .map_err(|e| ChatError::validation_error(&format!("Invalid user UUID: {}", e)))?; - let msg = OutgoingMessage::CallHangup { - conversation_id, - user_id: sender_uuid, - }; - state - .ws_manager - .send_to_user(&target_user_id.to_string(), msg, Some(client.id)) - .await?; - info!( - "📞 CallHangup relayé de {} vers {} (conversation: {})", - claims.username, target_user_id, conversation_id - ); - } - IncomingMessage::CallReject { - conversation_id, - caller_user_id, - } => { - let sender_uuid = Uuid::parse_str(&claims.user_id) - .map_err(|e| ChatError::validation_error(&format!("Invalid user UUID: {}", e)))?; - let msg = OutgoingMessage::CallRejected { - conversation_id, - user_id: sender_uuid, - }; - state - .ws_manager - .send_to_user(&caller_user_id.to_string(), msg, Some(client.id)) - .await?; - info!( - "📞 CallReject relayé vers {} (conversation: {})", - caller_user_id, conversation_id - ); - } - IncomingMessage::Ping => { - debug!("🏓 Ping WebSocket reçu"); - client.send_message(OutgoingMessage::Pong).await?; - } - } - - Ok(true) // Continuer la connexion -} diff --git a/veza-chat-server/src/websocket/mod.rs b/veza-chat-server/src/websocket/mod.rs deleted file mode 100644 index 75bd6ccbb..000000000 --- a/veza-chat-server/src/websocket/mod.rs +++ /dev/null @@ -1,363 +0,0 @@ -//! Module WebSocket pour le chat server -//! -//! Ce module contient: -//! - Les types de messages WebSocket (IncomingMessage, OutgoingMessage) -//! - Le gestionnaire de connexions (WebSocketManager) -//! - Le handler Axum (handler.rs) -//! - Le système de broadcast (broadcast.rs) - -pub mod broadcast; -pub mod handler; - -use crate::error::Result; -use axum::extract::ws::{Message, WebSocket}; -use futures_util::{stream::SplitSink, SinkExt}; -use serde::{Deserialize, Serialize}; -use std::collections::HashSet; -use std::sync::Arc; -use tokio::sync::RwLock; -use uuid::Uuid; - -/// Message WebSocket entrant -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum IncomingMessage { - /// Envoi d'un message de chat - SendMessage { - conversation_id: Uuid, - content: String, - parent_message_id: Option, - attachments: Option>, - }, - /// Rejoindre une conversation - JoinConversation { conversation_id: Uuid }, - /// Quitter une conversation - LeaveConversation { conversation_id: Uuid }, - /// Marquer des messages comme lus - MarkAsRead { - conversation_id: Uuid, - message_id: Uuid, - }, - /// Indicateur de frappe (typing indicator) - Typing { - conversation_id: Uuid, - is_typing: bool, - }, - /// Marquer un message comme délivré (reçu par le client) - Delivered { - conversation_id: Uuid, - message_id: Uuid, - }, - /// Éditer un message - EditMessage { - message_id: Uuid, - conversation_id: Uuid, - new_content: String, - }, - /// Supprimer un message - DeleteMessage { - message_id: Uuid, - conversation_id: Uuid, - }, - /// Ajouter une réaction - AddReaction { - message_id: Uuid, - conversation_id: Uuid, - emoji: String, // String representation from ReactionEmoji - }, - /// Retirer une réaction - RemoveReaction { - message_id: Uuid, - conversation_id: Uuid, - }, - /// Récupérer l'historique avec pagination - FetchHistory { - conversation_id: Uuid, - before: Option>, - after: Option>, - limit: Option, - }, - /// Rechercher des messages - SearchMessages { - conversation_id: Uuid, - query: String, - limit: Option, - offset: Option, - }, - /// Synchroniser les messages depuis un timestamp (offline sync) - SyncMessages { - conversation_id: Uuid, - since: chrono::DateTime, - }, - /// Initier un appel WebRTC - CallOffer { - conversation_id: Uuid, - target_user_id: Uuid, - sdp: String, - call_type: String, // "audio" | "video" - }, - /// Accepter un appel - CallAnswer { - conversation_id: Uuid, - caller_user_id: Uuid, - sdp: String, - }, - /// Échanger un candidat ICE - ICECandidate { - conversation_id: Uuid, - target_user_id: Uuid, - candidate: String, - }, - /// Raccrocher - CallHangup { - conversation_id: Uuid, - target_user_id: Uuid, - }, - /// Refuser un appel - CallReject { - conversation_id: Uuid, - caller_user_id: Uuid, - }, - /// Ping de connexion - Ping, -} - -/// Pièce jointe à un message -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageAttachment { - pub file_name: String, - pub file_type: String, // 'image', 'audio', 'video', 'file' - pub file_url: String, - pub file_size: Option, -} - -/// Message WebSocket sortant -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "type")] -pub enum OutgoingMessage { - /// Nouveau message reçu - NewMessage { - conversation_id: Uuid, - message_id: Uuid, - sender_id: Uuid, - content: String, - created_at: chrono::DateTime, - attachments: Option>, - }, - /// Message marqué comme lu - MessageRead { - message_id: Uuid, - user_id: Uuid, - conversation_id: Uuid, - read_at: chrono::DateTime, - }, - /// Message délivré (reçu par le client) - MessageDelivered { - message_id: Uuid, - user_id: Uuid, - conversation_id: Uuid, - delivered_at: chrono::DateTime, - }, - /// Indicateur de frappe (typing indicator) - UserTyping { - conversation_id: Uuid, - user_id: Uuid, - is_typing: bool, - }, - /// Message édité - MessageEdited { - message_id: Uuid, - conversation_id: Uuid, - editor_id: Uuid, - edited_at: chrono::DateTime, - new_content: String, - }, - /// Message supprimé - MessageDeleted { - message_id: Uuid, - conversation_id: Uuid, - deleter_id: Uuid, - deleted_at: chrono::DateTime, - }, - /// Réaction ajoutée - ReactionAdded { - message_id: Uuid, - conversation_id: Uuid, - user_id: Uuid, - emoji: String, - }, - /// Réaction retirée - ReactionRemoved { - message_id: Uuid, - conversation_id: Uuid, - user_id: Uuid, - }, - /// Chunk d'historique (pagination) - HistoryChunk { - conversation_id: Uuid, - messages: Vec, - has_more_before: bool, - has_more_after: bool, - }, - /// Résultats de recherche - SearchResults { - conversation_id: Uuid, - messages: Vec, - query: String, - total: i64, - }, - /// Chunk de synchronisation (offline sync) - SyncChunk { - conversation_id: Uuid, - messages: Vec, - last_sync: chrono::DateTime, - }, - /// Confirmation d'action - ActionConfirmed { action: String, success: bool }, - /// Erreur - Error { message: String }, - /// Appel WebRTC — offre - CallOffer { - conversation_id: Uuid, - caller_user_id: Uuid, - sdp: String, - call_type: String, - }, - /// Appel WebRTC — réponse - CallAnswer { - conversation_id: Uuid, - target_user_id: Uuid, - from_user_id: Uuid, - sdp: String, - }, - /// Appel WebRTC — candidat ICE - ICECandidate { - conversation_id: Uuid, - from_user_id: Uuid, - candidate: String, - }, - /// Appel WebRTC — raccrocher - CallHangup { - conversation_id: Uuid, - user_id: Uuid, - }, - /// Appel WebRTC — refusé - CallRejected { - conversation_id: Uuid, - user_id: Uuid, - }, - /// Pong de connexion - Pong, -} - -/// Client WebSocket connecté -pub struct WebSocketClient { - pub id: Uuid, - pub user_id: String, - pub stream: Arc>>, - pub conversations: Arc>>, -} - -impl WebSocketClient { - pub fn new(id: Uuid, user_id: String, stream: SplitSink) -> Self { - Self { - id, - user_id, - stream: Arc::new(RwLock::new(stream)), - conversations: Arc::new(RwLock::new(HashSet::new())), - } - } - - /// Envoie un message au client - pub async fn send_message(&self, message: OutgoingMessage) -> Result<()> { - let json = serde_json::to_string(&message) - .map_err(|e| crate::error::ChatError::serialization_error("OutgoingMessage", "", e))?; - - let mut stream = self.stream.write().await; - stream.send(Message::Text(json.into())).await.map_err(|e| { - crate::error::ChatError::internal_error(format!("WebSocket send error: {}", e)) - })?; - - Ok(()) - } - - /// Ajoute une conversation à la liste du client - pub async fn add_conversation(&self, conversation_id: Uuid) { - let mut conversations = self.conversations.write().await; - conversations.insert(conversation_id); - } - - /// Supprime une conversation de la liste du client - pub async fn remove_conversation(&self, conversation_id: Uuid) { - let mut conversations = self.conversations.write().await; - conversations.remove(&conversation_id); - } -} - -/// Gestionnaire de connexions WebSocket -pub struct WebSocketManager { - clients: Arc>>>, -} - -impl WebSocketManager { - pub fn new() -> Self { - Self { - clients: Arc::new(RwLock::new(Vec::new())), - } - } - - /// Ajoute un nouveau client - pub async fn add_client(&self, client: Arc) { - let mut clients = self.clients.write().await; - clients.push(client); - } - - /// Supprime un client - pub async fn remove_client(&self, client_id: Uuid) { - let mut clients = self.clients.write().await; - clients.retain(|c| c.id != client_id); - } - - /// Diffuse un message à tous les clients d'une conversation - pub async fn broadcast_to_conversation( - &self, - conversation_id: Uuid, - message: OutgoingMessage, - ) -> Result<()> { - let clients = self.clients.read().await; - - for client in clients.iter() { - let conversations = client.conversations.read().await; - if conversations.contains(&conversation_id) { - // Ignore send errors during broadcast to avoid stopping - let _ = client.send_message(message.clone()).await; - } - } - - Ok(()) - } - - /// Récupère un client par son ID - pub async fn get_client(&self, client_id: Uuid) -> Option> { - let clients = self.clients.read().await; - clients.iter().find(|c| c.id == client_id).cloned() - } - - /// Envoie un message à un utilisateur spécifique (1-to-1, pour appels) - /// exclude_client_id : ne pas envoyer au client qui a initié (éviter echo) - pub async fn send_to_user( - &self, - user_id: &str, - message: OutgoingMessage, - exclude_client_id: Option, - ) -> Result<()> { - let clients = self.clients.read().await; - for client in clients.iter() { - if client.user_id == user_id && Some(client.id) != exclude_client_id { - let _ = client.send_message(message.clone()).await; - return Ok(()); - } - } - Ok(()) - } -}