first
This commit is contained in:
31
server/.env.example
Normal file
31
server/.env.example
Normal file
@@ -0,0 +1,31 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-01
|
||||
# Purpose: Environment variables template for Mesh Server
|
||||
# Refs: deployment.md
|
||||
|
||||
# Server Configuration
|
||||
MESH_PUBLIC_URL=https://mesh.example.com
|
||||
MESH_HOST=10.0.0.50
|
||||
MESH_PORT=8065
|
||||
|
||||
# Security
|
||||
MESH_JWT_SECRET=your-secret-key-change-this-in-production
|
||||
MESH_JWT_ALGORITHM=HS256
|
||||
MESH_JWT_ACCESS_TOKEN_EXPIRE_MINUTES=120
|
||||
|
||||
# Gotify Integration
|
||||
GOTIFY_URL=https://gotify.example.com
|
||||
GOTIFY_TOKEN=your-gotify-token
|
||||
|
||||
# WebRTC / ICE
|
||||
STUN_URL=stun:stun.l.google.com:19302
|
||||
TURN_HOST=turn.example.com
|
||||
TURN_PORT=3478
|
||||
TURN_USER=mesh
|
||||
TURN_PASS=change-this-in-production
|
||||
|
||||
# Database (optional)
|
||||
DATABASE_URL=sqlite:///./mesh.db
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
262
server/CLAUDE.md
Normal file
262
server/CLAUDE.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# CLAUDE.md — Mesh Server
|
||||
|
||||
This file provides server-specific guidance for the Mesh control plane implementation.
|
||||
|
||||
## Server Role
|
||||
|
||||
The Mesh Server is the **control plane only**. It:
|
||||
- Authenticates users and agents
|
||||
- Manages rooms and ACL
|
||||
- Issues capability tokens (60-180s TTL)
|
||||
- Provides WebRTC signaling
|
||||
- Orchestrates P2P sessions (QUIC)
|
||||
- Sends Gotify notifications
|
||||
|
||||
**The server NEVER transports media or heavy data.** This is a fundamental architectural constraint.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Python 3.12+** (IMPORTANT: Utiliser `python3` pour les commandes, pas `python`)
|
||||
- **FastAPI** for REST API
|
||||
- **WebSocket** for real-time events
|
||||
- **JWT** for authentication
|
||||
- **SQLAlchemy** for database (SQLite initially, PostgreSQL for production)
|
||||
- **httpx** for Gotify integration
|
||||
- **Docker** recommandé pour éviter les problèmes de compatibilité de versions
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
server/
|
||||
├── src/
|
||||
│ ├── main.py # FastAPI app entry point
|
||||
│ ├── config.py # Configuration management
|
||||
│ ├── auth/
|
||||
│ │ ├── jwt.py # JWT token management
|
||||
│ │ ├── capabilities.py # Capability token generation
|
||||
│ │ └── models.py # Auth data models
|
||||
│ ├── rooms/
|
||||
│ │ ├── manager.py # Room management
|
||||
│ │ ├── acl.py # Access control lists
|
||||
│ │ └── models.py # Room data models
|
||||
│ ├── websocket/
|
||||
│ │ ├── connection.py # WebSocket connection manager
|
||||
│ │ ├── events.py # Event handlers
|
||||
│ │ └── router.py # WebSocket routing
|
||||
│ ├── signaling/
|
||||
│ │ ├── webrtc.py # WebRTC signaling
|
||||
│ │ └── p2p.py # QUIC P2P session orchestration
|
||||
│ ├── notifications/
|
||||
│ │ └── gotify.py # Gotify client
|
||||
│ └── db/
|
||||
│ ├── models.py # Database models
|
||||
│ └── session.py # Database session management
|
||||
├── tests/
|
||||
├── requirements.txt
|
||||
├── Dockerfile
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Setup Local (avec virtualenv)
|
||||
```bash
|
||||
cd server
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # or venv\Scripts\activate on Windows
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
# Éditer .env avec votre configuration
|
||||
```
|
||||
|
||||
### Setup avec Docker (Recommandé)
|
||||
```bash
|
||||
cd server
|
||||
cp .env.example .env
|
||||
# Éditer .env avec votre configuration
|
||||
|
||||
# Construire l'image
|
||||
docker build -t mesh-server:dev .
|
||||
|
||||
# Lancer le serveur
|
||||
docker run -d --name mesh-server -p 8000:8000 --env-file .env mesh-server:dev
|
||||
|
||||
# Voir les logs
|
||||
docker logs mesh-server -f
|
||||
|
||||
# Arrêter et supprimer
|
||||
docker rm -f mesh-server
|
||||
```
|
||||
|
||||
### Run Development Server (Local)
|
||||
```bash
|
||||
python3 -m uvicorn src.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### Run Tests
|
||||
```bash
|
||||
# Tester l'API (avec le serveur lancé)
|
||||
python3 test_api.py
|
||||
|
||||
# Tests unitaires
|
||||
pytest tests/
|
||||
```
|
||||
|
||||
### Type Checking
|
||||
```bash
|
||||
mypy src/
|
||||
```
|
||||
|
||||
## Event Protocol
|
||||
|
||||
All WebSocket events follow this structure (see [protocol_events_v_2.md](../protocol_events_v_2.md)):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "event.type",
|
||||
"id": "uuid",
|
||||
"timestamp": "ISO-8601",
|
||||
"from": "peer_id|device_id|server",
|
||||
"to": "peer_id|device_id|room_id|server",
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
## Capability Tokens
|
||||
|
||||
Capability tokens are **short-lived JWTs** (60-180s) that authorize specific P2P actions:
|
||||
|
||||
```python
|
||||
{
|
||||
"sub": "peer_id or device_id",
|
||||
"room_id": "room_id",
|
||||
"caps": ["call", "screen", "share:file", "terminal:view"],
|
||||
"exp": timestamp,
|
||||
"target_peer_id": "optional",
|
||||
"max_size": "optional",
|
||||
"max_rate": "optional"
|
||||
}
|
||||
```
|
||||
|
||||
**Critical rules**:
|
||||
- Never accept WebRTC offers without valid capability tokens
|
||||
- Never create P2P sessions without valid capability tokens
|
||||
- Validate token expiration on every use
|
||||
- Terminal control requires explicit `terminal:control` capability
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] All WebSocket connections authenticated with JWT
|
||||
- [ ] Capability tokens have short TTL (60-180s)
|
||||
- [ ] Room ACL enforced server-side
|
||||
- [ ] No sensitive data in logs
|
||||
- [ ] TURN credentials are temporary (when implemented)
|
||||
- [ ] Rate limiting on auth endpoints
|
||||
- [ ] Input validation on all endpoints
|
||||
|
||||
## Database Schema
|
||||
|
||||
Key entities:
|
||||
- **User**: user_id, username, hashed_password, created_at
|
||||
- **Device**: device_id, user_id, name, last_seen
|
||||
- **Room**: room_id, name, owner_user_id, created_at
|
||||
- **RoomMember**: room_id, user_id, role (owner/member/guest)
|
||||
- **Session**: session_id, peer_id, device_id, room_id, expires_at
|
||||
|
||||
## WebSocket Events to Implement
|
||||
|
||||
### System
|
||||
- `system.hello` (client → server)
|
||||
- `system.welcome` (server → client)
|
||||
|
||||
### Rooms
|
||||
- `room.join`, `room.joined`
|
||||
- `room.leave`, `room.left`
|
||||
- `presence.update`
|
||||
|
||||
### Chat
|
||||
- `chat.message.send`, `chat.message.created`
|
||||
|
||||
### WebRTC Signaling
|
||||
- `rtc.offer`, `rtc.answer`, `rtc.ice`
|
||||
|
||||
### P2P Sessions (QUIC)
|
||||
- `p2p.session.request`, `p2p.session.created`
|
||||
- `p2p.session.closed`
|
||||
|
||||
### Terminal Control
|
||||
- `terminal.control.take`, `terminal.control.granted`
|
||||
- `terminal.control.release`
|
||||
|
||||
## Gotify Integration
|
||||
|
||||
Send notifications for:
|
||||
- New chat messages (when user offline)
|
||||
- Missed calls
|
||||
- Share completed
|
||||
- Terminal share started
|
||||
- Agent offline
|
||||
|
||||
**Never include secrets in notification text.**
|
||||
|
||||
## Error Handling
|
||||
|
||||
Return structured errors:
|
||||
```json
|
||||
{
|
||||
"type": "error",
|
||||
"code": "CAP_EXPIRED",
|
||||
"message": "Capability token expired"
|
||||
}
|
||||
```
|
||||
|
||||
Error codes:
|
||||
- `CAP_REQUIRED`: Missing capability token
|
||||
- `CAP_EXPIRED`: Token expired
|
||||
- `CAP_INVALID`: Token invalid or tampered
|
||||
- `ROOM_NOT_FOUND`: Room doesn't exist
|
||||
- `ACCESS_DENIED`: User not in room or insufficient permissions
|
||||
- `P2P_SESSION_DENIED`: P2P session creation failed
|
||||
- `UNROUTABLE`: Peer not connected
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit tests**: Individual functions (JWT, capabilities, validation)
|
||||
2. **Integration tests**: WebSocket event flows
|
||||
3. **E2E tests**: Complete user journeys (join room, send message, start call)
|
||||
|
||||
## Notes sur Python et Dépendances
|
||||
|
||||
### Python 3.13 Compatibility
|
||||
⚠️ **Important**: Les dépendances actuelles (`pydantic==2.5.3` avec `pydantic-core==2.14.6`) ne sont pas compatibles avec Python 3.13.
|
||||
|
||||
**Solutions**:
|
||||
1. **Docker (Recommandé)**: Utiliser l'image Docker qui utilise Python 3.12
|
||||
2. **Mise à jour**: Upgrader vers `pydantic>=2.10` pour Python 3.13 (nécessite tests de régression)
|
||||
3. **Downgrade**: Installer Python 3.12 localement
|
||||
|
||||
### Dépendances Importantes
|
||||
- `pydantic[email]==2.5.3` - Inclut `email-validator` pour la validation des emails
|
||||
- `passlib[bcrypt]==1.7.4` + `bcrypt==4.1.2` - Hash des mots de passe
|
||||
- `python-jose[cryptography]==3.3.0` + `pyjwt[crypto]==2.8.0` - JWT tokens
|
||||
- `sqlalchemy==2.0.25` + `alembic==1.13.1` - ORM et migrations
|
||||
|
||||
### Commandes Python
|
||||
Toujours utiliser `python3` explicitement sur les systèmes Unix/Linux:
|
||||
```bash
|
||||
python3 -m pip install -r requirements.txt
|
||||
python3 -m uvicorn src.main:app --reload
|
||||
python3 test_api.py
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Keep WebSocket connections lightweight
|
||||
- Use connection pooling for database
|
||||
- Cache room membership lookups
|
||||
- Implement backpressure for high-frequency events
|
||||
- Monitor active connections count
|
||||
|
||||
---
|
||||
|
||||
**Remember**: The server is control plane only. Any attempt to route media or large data through the server violates the architecture.
|
||||
21
server/Dockerfile
Normal file
21
server/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-01
|
||||
# Purpose: Dockerfile for Mesh Server
|
||||
# Refs: deployment.md
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run server
|
||||
CMD ["python", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
273
server/README.md
Normal file
273
server/README.md
Normal file
@@ -0,0 +1,273 @@
|
||||
<!--
|
||||
Created by: Claude
|
||||
Date: 2026-01-02
|
||||
Purpose: Documentation du serveur Mesh
|
||||
Refs: server/CLAUDE.md
|
||||
-->
|
||||
|
||||
# Mesh Server
|
||||
|
||||
Serveur de control plane pour la plateforme Mesh.
|
||||
|
||||
## Installation
|
||||
|
||||
### Option 1: Docker (Recommandé)
|
||||
|
||||
```bash
|
||||
# Copier et configurer l'environnement
|
||||
cp .env.example .env
|
||||
# Éditer .env avec vos valeurs (notamment MESH_JWT_SECRET)
|
||||
|
||||
# Construire l'image Docker
|
||||
docker build -t mesh-server .
|
||||
|
||||
# Lancer le serveur
|
||||
docker run -d --name mesh-server -p 8000:8000 --env-file .env mesh-server
|
||||
|
||||
# Voir les logs
|
||||
docker logs mesh-server -f
|
||||
```
|
||||
|
||||
### Option 2: Installation Locale
|
||||
|
||||
**Note**: Nécessite Python 3.12+ (Python 3.13 non supporté actuellement)
|
||||
|
||||
```bash
|
||||
# Créer un environnement virtuel
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
|
||||
# Installer les dépendances
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Copier et configurer l'environnement
|
||||
cp .env.example .env
|
||||
# Éditer .env avec vos valeurs
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Éditer le fichier `.env`:
|
||||
|
||||
```bash
|
||||
# Serveur
|
||||
MESH_PUBLIC_URL=http://localhost:8000
|
||||
MESH_JWT_SECRET=your-secret-key-min-32-chars
|
||||
|
||||
# Gotify
|
||||
GOTIFY_URL=http://gotify:8080
|
||||
GOTIFY_TOKEN=your-gotify-token
|
||||
|
||||
# TURN/STUN
|
||||
STUN_URL=stun:stun.l.google.com:19302
|
||||
TURN_HOST=turn.example.com
|
||||
|
||||
# Base de données
|
||||
DATABASE_URL=sqlite:///./mesh.db
|
||||
```
|
||||
|
||||
## Démarrage
|
||||
|
||||
### Avec Docker
|
||||
```bash
|
||||
# Le serveur est déjà lancé après docker run
|
||||
# Pour voir les logs:
|
||||
docker logs mesh-server -f
|
||||
|
||||
# Pour arrêter:
|
||||
docker stop mesh-server
|
||||
|
||||
# Pour redémarrer:
|
||||
docker start mesh-server
|
||||
```
|
||||
|
||||
### En local
|
||||
```bash
|
||||
# Lancer le serveur en mode développement
|
||||
python3 -m uvicorn src.main:app --reload
|
||||
|
||||
# Ou avec le script
|
||||
python3 -m src.main
|
||||
```
|
||||
|
||||
Le serveur démarre sur http://localhost:8000
|
||||
|
||||
## API Documentation
|
||||
|
||||
Documentation interactive disponible sur:
|
||||
- Swagger UI: http://localhost:8000/docs
|
||||
- ReDoc: http://localhost:8000/redoc
|
||||
|
||||
## Endpoints Principaux
|
||||
|
||||
### Authentification
|
||||
- `POST /api/auth/register` - Créer un compte
|
||||
- `POST /api/auth/login` - Se connecter
|
||||
- `GET /api/auth/me` - Informations utilisateur
|
||||
- `POST /api/auth/capability` - Demander un capability token
|
||||
|
||||
### Rooms
|
||||
- `POST /api/rooms/` - Créer une room
|
||||
- `GET /api/rooms/` - Lister mes rooms
|
||||
- `GET /api/rooms/{room_id}` - Détails d'une room
|
||||
- `GET /api/rooms/{room_id}/members` - Membres d'une room
|
||||
|
||||
### WebSocket
|
||||
- `WS /ws?token=JWT_TOKEN` - Connexion temps réel
|
||||
|
||||
## Structure du Projet
|
||||
|
||||
```
|
||||
server/
|
||||
├── src/
|
||||
│ ├── main.py # Point d'entrée
|
||||
│ ├── config.py # Configuration
|
||||
│ ├── api/ # Routes API REST
|
||||
│ │ ├── auth.py # Authentification
|
||||
│ │ └── rooms.py # Gestion des rooms
|
||||
│ ├── auth/ # Authentification & sécurité
|
||||
│ │ ├── security.py # JWT, hashing, capability tokens
|
||||
│ │ ├── schemas.py # Schémas Pydantic
|
||||
│ │ └── dependencies.py # Dépendances FastAPI
|
||||
│ ├── db/ # Base de données
|
||||
│ │ ├── base.py # Configuration SQLAlchemy
|
||||
│ │ └── models.py # Modèles ORM
|
||||
│ └── websocket/ # WebSocket temps réel
|
||||
│ ├── manager.py # Gestionnaire de connexions
|
||||
│ ├── events.py # Types d'événements
|
||||
│ └── handlers.py # Handlers d'événements
|
||||
├── alembic/ # Migrations
|
||||
├── tests/ # Tests
|
||||
├── requirements.txt
|
||||
├── .env.example
|
||||
└── Dockerfile
|
||||
```
|
||||
|
||||
## Développement
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
# Test de l'API (avec serveur lancé)
|
||||
python3 test_api.py
|
||||
|
||||
# Tests unitaires
|
||||
pytest tests/
|
||||
```
|
||||
|
||||
### Migrations
|
||||
|
||||
```bash
|
||||
# Créer une migration
|
||||
alembic revision --autogenerate -m "Description"
|
||||
|
||||
# Appliquer les migrations
|
||||
alembic upgrade head
|
||||
|
||||
# Revenir en arrière
|
||||
alembic downgrade -1
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
```bash
|
||||
# Type checking
|
||||
mypy src/
|
||||
|
||||
# Formatage
|
||||
black src/
|
||||
|
||||
# Linting
|
||||
flake8 src/
|
||||
```
|
||||
|
||||
## Événements WebSocket
|
||||
|
||||
Voir [docs/protocol_events_v_2.md](../docs/protocol_events_v_2.md) pour le protocole complet.
|
||||
|
||||
### Exemples
|
||||
|
||||
**system.hello**:
|
||||
```json
|
||||
{
|
||||
"type": "system.hello",
|
||||
"from": "peer_123",
|
||||
"to": "server",
|
||||
"payload": {
|
||||
"peer_type": "client",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**room.join**:
|
||||
```json
|
||||
{
|
||||
"type": "room.join",
|
||||
"from": "peer_123",
|
||||
"to": "server",
|
||||
"payload": {
|
||||
"room_id": "room_uuid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**chat.message.send**:
|
||||
```json
|
||||
{
|
||||
"type": "chat.message.send",
|
||||
"from": "peer_123",
|
||||
"to": "server",
|
||||
"payload": {
|
||||
"room_id": "room_uuid",
|
||||
"content": "Hello world"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Sécurité
|
||||
|
||||
- Tous les endpoints protégés nécessitent un JWT Bearer token
|
||||
- Les capability tokens ont un TTL court (60-180s)
|
||||
- Les mots de passe sont hashés avec bcrypt
|
||||
- Les WebSocket nécessitent un token JWT valide
|
||||
|
||||
Voir [docs/security.md](../docs/security.md) pour plus de détails.
|
||||
|
||||
## Variables d'Environnement
|
||||
|
||||
| Variable | Description | Défaut |
|
||||
|----------|-------------|--------|
|
||||
| MESH_PUBLIC_URL | URL publique du serveur | http://localhost:8000 |
|
||||
| MESH_HOST | Host d'écoute | 0.0.0.0 |
|
||||
| MESH_PORT | Port d'écoute | 8000 |
|
||||
| MESH_JWT_SECRET | Secret pour JWT | (requis) |
|
||||
| MESH_JWT_ALGORITHM | Algorithme JWT | HS256 |
|
||||
| MESH_JWT_ACCESS_TOKEN_EXPIRE_MINUTES | Expiration token | 120 |
|
||||
| GOTIFY_URL | URL Gotify | (requis) |
|
||||
| GOTIFY_TOKEN | Token Gotify | (requis) |
|
||||
| STUN_URL | URL STUN | stun:stun.l.google.com:19302 |
|
||||
| TURN_HOST | Host TURN | (optionnel) |
|
||||
| TURN_PORT | Port TURN | 3478 |
|
||||
| DATABASE_URL | URL base de données | sqlite:///./mesh.db |
|
||||
| LOG_LEVEL | Niveau de log | INFO |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Erreur de connexion à la DB
|
||||
|
||||
```bash
|
||||
# Vérifier que la DB existe
|
||||
ls -la mesh.db
|
||||
|
||||
# Créer les tables manuellement
|
||||
python -c "from src.db.base import Base, engine; Base.metadata.create_all(engine)"
|
||||
```
|
||||
|
||||
### Token JWT invalide
|
||||
|
||||
Vérifier que `MESH_JWT_SECRET` est défini et identique entre serveur et client.
|
||||
|
||||
### WebSocket déconnecté immédiatement
|
||||
|
||||
Vérifier que le token JWT est passé en query param: `/ws?token=YOUR_TOKEN`
|
||||
53
server/alembic.ini
Normal file
53
server/alembic.ini
Normal file
@@ -0,0 +1,53 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: Configuration Alembic pour migrations de base de données
|
||||
# Refs: server/CLAUDE.md
|
||||
|
||||
[alembic]
|
||||
# Chemin vers le dossier des migrations
|
||||
script_location = alembic
|
||||
|
||||
# Template pour les noms de fichiers de migration
|
||||
file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# Fuseau horaire
|
||||
timezone = UTC
|
||||
|
||||
# Autres configurations
|
||||
truncate_slug_length = 40
|
||||
revision_environment = false
|
||||
sqlalchemy.url = sqlite:///./mesh.db
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
74
server/alembic/env.py
Normal file
74
server/alembic/env.py
Normal file
@@ -0,0 +1,74 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: Configuration de l'environnement Alembic
|
||||
# Refs: server/CLAUDE.md
|
||||
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
from alembic import context
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ajouter le dossier src au path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.db.base import Base
|
||||
from src.db.models import User, Device, Room, RoomMember, Message, P2PSession
|
||||
from src.config import settings
|
||||
|
||||
# Alembic Config object
|
||||
config = context.config
|
||||
|
||||
# Interpréter le fichier de configuration pour les loggers
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Métadonnées des modèles pour autogenerate
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# Overrider l'URL de la DB depuis les settings
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""
|
||||
Exécuter les migrations en mode 'offline'.
|
||||
Configure le contexte avec juste une URL, sans créer d'Engine.
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""
|
||||
Exécuter les migrations en mode 'online'.
|
||||
Crée un Engine et associe une connexion au contexte.
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
24
server/alembic/script.py.mako
Normal file
24
server/alembic/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
36
server/requirements.txt
Normal file
36
server/requirements.txt
Normal file
@@ -0,0 +1,36 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: Python dependencies for Mesh Server
|
||||
# Refs: CLAUDE.md
|
||||
|
||||
# Web Framework
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
python-multipart==0.0.6
|
||||
|
||||
# WebSocket
|
||||
websockets==12.0
|
||||
|
||||
# Authentication & Security
|
||||
pyjwt[crypto]==2.8.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-jose[cryptography]==3.3.0
|
||||
bcrypt==4.1.2
|
||||
|
||||
# HTTP Client (for Gotify)
|
||||
httpx==0.26.0
|
||||
|
||||
# Data Validation
|
||||
pydantic[email]==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
|
||||
# Database (SQLite/PostgreSQL)
|
||||
sqlalchemy==2.0.25
|
||||
alembic==1.13.1
|
||||
|
||||
# Utils
|
||||
python-dotenv==1.0.0
|
||||
|
||||
# Development
|
||||
pytest==7.4.4
|
||||
pytest-asyncio==0.23.3
|
||||
4
server/src/__init__.py
Normal file
4
server/src/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-01
|
||||
# Purpose: Server package initialization
|
||||
# Refs: CLAUDE.md
|
||||
4
server/src/api/__init__.py
Normal file
4
server/src/api/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: Package des routes API
|
||||
# Refs: server/CLAUDE.md
|
||||
206
server/src/api/auth.py
Normal file
206
server/src/api/auth.py
Normal file
@@ -0,0 +1,206 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: Routes API pour l'authentification
|
||||
# Refs: server/CLAUDE.md, docs/security.md
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from ..db.base import get_db
|
||||
from ..db.models import User
|
||||
from ..auth.schemas import UserCreate, UserLogin, Token, CapabilityTokenRequest, CapabilityTokenResponse
|
||||
from ..auth.security import (
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
create_access_token,
|
||||
create_capability_token
|
||||
)
|
||||
from ..auth.dependencies import get_current_active_user
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["authentication"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=Token, status_code=status.HTTP_201_CREATED)
|
||||
async def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Enregistrer un nouvel utilisateur.
|
||||
|
||||
Args:
|
||||
user_data: Données de l'utilisateur (username, email, password)
|
||||
db: Session de base de données
|
||||
|
||||
Returns:
|
||||
Token JWT avec informations utilisateur
|
||||
|
||||
Raises:
|
||||
HTTPException: Si le username existe déjà
|
||||
"""
|
||||
# Vérifier si l'utilisateur existe déjà
|
||||
existing_user = db.query(User).filter(
|
||||
(User.username == user_data.username) |
|
||||
(User.email == user_data.email if user_data.email else False)
|
||||
).first()
|
||||
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username or email already registered"
|
||||
)
|
||||
|
||||
# Créer le nouvel utilisateur
|
||||
user_id = str(uuid.uuid4())
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
|
||||
new_user = User(
|
||||
user_id=user_id,
|
||||
username=user_data.username,
|
||||
email=user_data.email,
|
||||
hashed_password=hashed_password,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
# Créer le token d'accès
|
||||
access_token = create_access_token(data={"sub": user_id})
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
user_id=user_id,
|
||||
username=user_data.username
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(credentials: UserLogin, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Connecter un utilisateur existant.
|
||||
|
||||
Args:
|
||||
credentials: Identifiants (username, password)
|
||||
db: Session de base de données
|
||||
|
||||
Returns:
|
||||
Token JWT avec informations utilisateur
|
||||
|
||||
Raises:
|
||||
HTTPException: Si les identifiants sont invalides
|
||||
"""
|
||||
# Rechercher l'utilisateur par username
|
||||
user = db.query(User).filter(User.username == credentials.username).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Vérifier le mot de passe
|
||||
if not verify_password(credentials.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Vérifier que l'utilisateur est actif
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
# Créer le token d'accès
|
||||
access_token = create_access_token(data={"sub": user.user_id})
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
user_id=user.user_id,
|
||||
username=user.username
|
||||
)
|
||||
|
||||
|
||||
@router.post("/capability", response_model=CapabilityTokenResponse)
|
||||
async def request_capability_token(
|
||||
request: CapabilityTokenRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Demander un capability token pour une action P2P.
|
||||
|
||||
Les capability tokens ont un TTL court (60-180s) et autorisent
|
||||
des actions spécifiques comme les appels ou les transferts de fichiers.
|
||||
|
||||
Args:
|
||||
request: Détails de la capability demandée
|
||||
current_user: Utilisateur authentifié
|
||||
db: Session de base de données
|
||||
|
||||
Returns:
|
||||
Capability token JWT
|
||||
|
||||
Raises:
|
||||
HTTPException: Si l'utilisateur n'a pas accès à la room
|
||||
"""
|
||||
# TODO: Vérifier que l'utilisateur a accès à la room
|
||||
# Pour l'instant, on autorise toutes les demandes
|
||||
|
||||
# Déterminer le TTL selon le type de capability
|
||||
ttl_seconds = 120 # 2 minutes par défaut
|
||||
|
||||
# Terminal control a un TTL plus long
|
||||
if "terminal:control" in request.capabilities:
|
||||
ttl_seconds = 180 # 3 minutes
|
||||
|
||||
# Créer les claims additionnels
|
||||
extra_claims = {}
|
||||
if request.target_peer_id:
|
||||
extra_claims["target_peer_id"] = request.target_peer_id
|
||||
if request.target_device_id:
|
||||
extra_claims["target_device_id"] = request.target_device_id
|
||||
if request.max_size:
|
||||
extra_claims["max_size"] = request.max_size
|
||||
if request.max_rate:
|
||||
extra_claims["max_rate"] = request.max_rate
|
||||
|
||||
# Créer le capability token
|
||||
cap_token = create_capability_token(
|
||||
subject=current_user.user_id,
|
||||
room_id=request.room_id,
|
||||
capabilities=request.capabilities,
|
||||
expires_delta=timedelta(seconds=ttl_seconds),
|
||||
**extra_claims
|
||||
)
|
||||
|
||||
return CapabilityTokenResponse(
|
||||
cap_token=cap_token,
|
||||
expires_in=ttl_seconds
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_current_user_info(current_user: User = Depends(get_current_active_user)):
|
||||
"""
|
||||
Obtenir les informations de l'utilisateur connecté.
|
||||
|
||||
Args:
|
||||
current_user: Utilisateur authentifié
|
||||
|
||||
Returns:
|
||||
Informations de l'utilisateur
|
||||
"""
|
||||
return {
|
||||
"user_id": current_user.user_id,
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"is_active": current_user.is_active,
|
||||
"created_at": current_user.created_at.isoformat()
|
||||
}
|
||||
227
server/src/api/p2p.py
Normal file
227
server/src/api/p2p.py
Normal file
@@ -0,0 +1,227 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: API endpoints pour l'orchestration des sessions P2P (QUIC)
|
||||
# Refs: server/CLAUDE.md, docs/signaling_v_2.md
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..db.base import get_db
|
||||
from ..db.models import P2PSession, P2PSessionKind, Room, RoomMember, User
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..auth.security import create_capability_token
|
||||
from ..config import settings
|
||||
|
||||
router = APIRouter(prefix="/api/p2p", tags=["p2p"])
|
||||
|
||||
|
||||
class P2PSessionRequest(BaseModel):
|
||||
"""
|
||||
Requête de création de session P2P.
|
||||
|
||||
Le client demande une session P2P avec un peer cible pour un type d'action spécifique.
|
||||
"""
|
||||
room_id: str
|
||||
target_peer_id: str
|
||||
kind: str # 'file', 'folder', 'terminal'
|
||||
capabilities: List[str] # Liste des capacités demandées
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"room_id": "room_123",
|
||||
"target_peer_id": "peer_456",
|
||||
"kind": "file",
|
||||
"capabilities": ["share:file"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class P2PSessionResponse(BaseModel):
|
||||
"""Réponse avec les détails de la session P2P créée."""
|
||||
session_id: str
|
||||
session_token: str
|
||||
expires_at: str
|
||||
kind: str
|
||||
initiator_peer_id: str
|
||||
target_peer_id: str
|
||||
|
||||
|
||||
@router.post("/session", response_model=P2PSessionResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_p2p_session(
|
||||
request: P2PSessionRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Créer une session P2P entre deux peers.
|
||||
|
||||
Cette fonction:
|
||||
1. Valide que l'utilisateur est membre de la room
|
||||
2. Génère un session_id et un session_token
|
||||
3. Crée l'enregistrement dans la DB
|
||||
4. Retourne les informations de session
|
||||
|
||||
Note: Le serveur ne distribue PAS les endpoints QUIC ici.
|
||||
Les agents s'échangent leurs endpoints via WebSocket (p2p.session.created event).
|
||||
"""
|
||||
# Vérifier que la room existe
|
||||
room = db.query(Room).filter(Room.room_id == request.room_id).first()
|
||||
if not room:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Room not found"
|
||||
)
|
||||
|
||||
# Vérifier que l'utilisateur est membre de la room
|
||||
membership = db.query(RoomMember).filter(
|
||||
RoomMember.room_id == room.id,
|
||||
RoomMember.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not membership:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not a member of this room"
|
||||
)
|
||||
|
||||
# Valider le kind
|
||||
valid_kinds = ["file", "folder", "terminal"]
|
||||
if request.kind not in valid_kinds:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid session kind. Must be one of: {', '.join(valid_kinds)}"
|
||||
)
|
||||
|
||||
# Mapper string -> enum
|
||||
kind_enum_map = {
|
||||
"file": P2PSessionKind.FILE,
|
||||
"folder": P2PSessionKind.FOLDER,
|
||||
"terminal": P2PSessionKind.TERMINAL
|
||||
}
|
||||
|
||||
# Générer session_id et session_token
|
||||
session_id = str(uuid.uuid4())
|
||||
|
||||
# Le session_token est un capability token avec les permissions demandées
|
||||
# TTL: 180 secondes (3 minutes) pour laisser le temps d'établir la connexion QUIC
|
||||
session_token = create_capability_token(
|
||||
subject=current_user.user_id,
|
||||
room_id=request.room_id,
|
||||
capabilities=request.capabilities,
|
||||
expires_delta=timedelta(seconds=180),
|
||||
session_id=session_id,
|
||||
target_peer_id=request.target_peer_id,
|
||||
kind=request.kind
|
||||
)
|
||||
|
||||
# Calculer expires_at
|
||||
expires_at = datetime.utcnow() + timedelta(seconds=180)
|
||||
|
||||
# Créer la session en base de données
|
||||
# Note: initiator_device_id et target_device_id sont optionnels pour l'instant
|
||||
# Ils seront remplis via WebSocket quand les peers se connectent
|
||||
new_session = P2PSession(
|
||||
session_id=session_id,
|
||||
kind=kind_enum_map[request.kind],
|
||||
session_token=session_token,
|
||||
room_id=room.id,
|
||||
expires_at=expires_at
|
||||
)
|
||||
|
||||
db.add(new_session)
|
||||
db.commit()
|
||||
db.refresh(new_session)
|
||||
|
||||
return P2PSessionResponse(
|
||||
session_id=session_id,
|
||||
session_token=session_token,
|
||||
expires_at=expires_at.isoformat(),
|
||||
kind=request.kind,
|
||||
initiator_peer_id="", # Sera rempli via WebSocket
|
||||
target_peer_id=request.target_peer_id
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions")
|
||||
async def list_active_sessions(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Lister les sessions P2P actives de l'utilisateur.
|
||||
|
||||
Retourne les sessions qui n'ont pas encore expiré.
|
||||
"""
|
||||
# Récupérer les sessions actives (non expirées)
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Trouver toutes les rooms dont l'utilisateur est membre
|
||||
room_memberships = db.query(RoomMember).filter(
|
||||
RoomMember.user_id == current_user.id
|
||||
).all()
|
||||
|
||||
room_ids = [m.room_id for m in room_memberships]
|
||||
|
||||
# Récupérer les sessions actives dans ces rooms
|
||||
sessions = db.query(P2PSession).filter(
|
||||
P2PSession.room_id.in_(room_ids),
|
||||
P2PSession.expires_at > now
|
||||
).all()
|
||||
|
||||
return {
|
||||
"sessions": [
|
||||
{
|
||||
"session_id": s.session_id,
|
||||
"kind": s.kind.value,
|
||||
"created_at": s.created_at.isoformat(),
|
||||
"expires_at": s.expires_at.isoformat()
|
||||
}
|
||||
for s in sessions
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/session/{session_id}")
|
||||
async def close_p2p_session(
|
||||
session_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Fermer une session P2P.
|
||||
|
||||
Permet à un utilisateur de terminer une session P2P active.
|
||||
"""
|
||||
# Récupérer la session
|
||||
session = db.query(P2PSession).filter(
|
||||
P2PSession.session_id == session_id
|
||||
).first()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
# Vérifier que l'utilisateur est membre de la room de cette session
|
||||
membership = db.query(RoomMember).filter(
|
||||
RoomMember.room_id == session.room_id,
|
||||
RoomMember.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not membership:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to close this session"
|
||||
)
|
||||
|
||||
# Supprimer la session
|
||||
db.delete(session)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Session closed successfully"}
|
||||
339
server/src/api/rooms.py
Normal file
339
server/src/api/rooms.py
Normal file
@@ -0,0 +1,339 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: Routes API pour les rooms
|
||||
# Refs: server/CLAUDE.md
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
import uuid
|
||||
from typing import List
|
||||
|
||||
from ..db.base import get_db
|
||||
from ..db.models import Room, RoomMember, User, UserRole, PresenceStatus
|
||||
from ..auth.dependencies import get_current_active_user
|
||||
|
||||
router = APIRouter(prefix="/api/rooms", tags=["rooms"])
|
||||
|
||||
|
||||
class RoomCreate(BaseModel):
|
||||
"""Schéma pour créer une room."""
|
||||
name: str
|
||||
|
||||
|
||||
class RoomResponse(BaseModel):
|
||||
"""Schéma de réponse pour une room."""
|
||||
room_id: str
|
||||
name: str
|
||||
owner_id: str
|
||||
created_at: str
|
||||
member_count: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RoomMemberResponse(BaseModel):
|
||||
"""Schéma de réponse pour un membre de room."""
|
||||
user_id: str
|
||||
username: str
|
||||
role: str
|
||||
presence_status: str
|
||||
joined_at: str
|
||||
|
||||
|
||||
class AddMemberRequest(BaseModel):
|
||||
"""Schéma pour ajouter un membre à une room."""
|
||||
username: str
|
||||
|
||||
|
||||
@router.post("/", response_model=RoomResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_room(
|
||||
room_data: RoomCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Créer une nouvelle room.
|
||||
|
||||
L'utilisateur qui crée la room en devient automatiquement owner.
|
||||
|
||||
Args:
|
||||
room_data: Données de la room (nom)
|
||||
current_user: Utilisateur authentifié
|
||||
db: Session de base de données
|
||||
|
||||
Returns:
|
||||
Room créée
|
||||
"""
|
||||
room_id = str(uuid.uuid4())
|
||||
|
||||
# Créer la room
|
||||
new_room = Room(
|
||||
room_id=room_id,
|
||||
name=room_data.name,
|
||||
owner_id=current_user.id,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
db.add(new_room)
|
||||
db.flush() # Pour obtenir l'ID
|
||||
|
||||
# Ajouter le créateur comme owner
|
||||
owner_membership = RoomMember(
|
||||
room_id=new_room.id,
|
||||
user_id=current_user.id,
|
||||
role=UserRole.OWNER,
|
||||
presence_status=PresenceStatus.ONLINE
|
||||
)
|
||||
|
||||
db.add(owner_membership)
|
||||
db.commit()
|
||||
db.refresh(new_room)
|
||||
|
||||
return RoomResponse(
|
||||
room_id=new_room.room_id,
|
||||
name=new_room.name,
|
||||
owner_id=current_user.user_id,
|
||||
created_at=new_room.created_at.isoformat(),
|
||||
member_count=1
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[RoomResponse])
|
||||
async def list_my_rooms(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Lister les rooms de l'utilisateur connecté.
|
||||
|
||||
Args:
|
||||
current_user: Utilisateur authentifié
|
||||
db: Session de base de données
|
||||
|
||||
Returns:
|
||||
Liste des rooms dont l'utilisateur est membre
|
||||
"""
|
||||
# Récupérer les memberships de l'utilisateur
|
||||
memberships = db.query(RoomMember).filter(
|
||||
RoomMember.user_id == current_user.id
|
||||
).all()
|
||||
|
||||
rooms = []
|
||||
for membership in memberships:
|
||||
room = db.query(Room).filter(Room.id == membership.room_id).first()
|
||||
if room and room.is_active:
|
||||
member_count = db.query(RoomMember).filter(
|
||||
RoomMember.room_id == room.id
|
||||
).count()
|
||||
|
||||
rooms.append(RoomResponse(
|
||||
room_id=room.room_id,
|
||||
name=room.name,
|
||||
owner_id=room.owner.user_id,
|
||||
created_at=room.created_at.isoformat(),
|
||||
member_count=member_count
|
||||
))
|
||||
|
||||
return rooms
|
||||
|
||||
|
||||
@router.get("/{room_id}", response_model=RoomResponse)
|
||||
async def get_room(
|
||||
room_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtenir les détails d'une room.
|
||||
|
||||
Args:
|
||||
room_id: ID de la room
|
||||
current_user: Utilisateur authentifié
|
||||
db: Session de base de données
|
||||
|
||||
Returns:
|
||||
Détails de la room
|
||||
|
||||
Raises:
|
||||
HTTPException: Si la room n'existe pas ou l'utilisateur n'y a pas accès
|
||||
"""
|
||||
room = db.query(Room).filter(Room.room_id == room_id).first()
|
||||
|
||||
if not room:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Room not found"
|
||||
)
|
||||
|
||||
# Vérifier que l'utilisateur est membre
|
||||
membership = db.query(RoomMember).filter(
|
||||
RoomMember.room_id == room.id,
|
||||
RoomMember.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not membership:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not a member of this room"
|
||||
)
|
||||
|
||||
member_count = db.query(RoomMember).filter(
|
||||
RoomMember.room_id == room.id
|
||||
).count()
|
||||
|
||||
return RoomResponse(
|
||||
room_id=room.room_id,
|
||||
name=room.name,
|
||||
owner_id=room.owner.user_id,
|
||||
created_at=room.created_at.isoformat(),
|
||||
member_count=member_count
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{room_id}/members", response_model=List[RoomMemberResponse])
|
||||
async def get_room_members(
|
||||
room_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Obtenir la liste des membres d'une room.
|
||||
|
||||
Args:
|
||||
room_id: ID de la room
|
||||
current_user: Utilisateur authentifié
|
||||
db: Session de base de données
|
||||
|
||||
Returns:
|
||||
Liste des membres
|
||||
|
||||
Raises:
|
||||
HTTPException: Si la room n'existe pas ou l'utilisateur n'y a pas accès
|
||||
"""
|
||||
room = db.query(Room).filter(Room.room_id == room_id).first()
|
||||
|
||||
if not room:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Room not found"
|
||||
)
|
||||
|
||||
# Vérifier que l'utilisateur est membre
|
||||
is_member = db.query(RoomMember).filter(
|
||||
RoomMember.room_id == room.id,
|
||||
RoomMember.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not is_member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not a member of this room"
|
||||
)
|
||||
|
||||
# Récupérer tous les membres
|
||||
members = db.query(RoomMember).filter(
|
||||
RoomMember.room_id == room.id
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for member in members:
|
||||
user = db.query(User).filter(User.id == member.user_id).first()
|
||||
if user:
|
||||
result.append(RoomMemberResponse(
|
||||
user_id=user.user_id,
|
||||
username=user.username,
|
||||
role=member.role.value,
|
||||
presence_status=member.presence_status.value,
|
||||
joined_at=member.joined_at.isoformat()
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{room_id}/members", response_model=RoomMemberResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def add_member_to_room(
|
||||
room_id: str,
|
||||
member_data: AddMemberRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Ajouter un membre à une room.
|
||||
|
||||
Seul l'owner de la room peut ajouter des membres.
|
||||
|
||||
Args:
|
||||
room_id: ID de la room
|
||||
member_data: Données du membre à ajouter (username)
|
||||
current_user: Utilisateur authentifié
|
||||
db: Session de base de données
|
||||
|
||||
Returns:
|
||||
Membre ajouté
|
||||
|
||||
Raises:
|
||||
HTTPException: Si la room n'existe pas, l'utilisateur n'est pas owner,
|
||||
ou l'utilisateur à ajouter n'existe pas
|
||||
"""
|
||||
room = db.query(Room).filter(Room.room_id == room_id).first()
|
||||
|
||||
if not room:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Room not found"
|
||||
)
|
||||
|
||||
# Vérifier que l'utilisateur est owner
|
||||
membership = db.query(RoomMember).filter(
|
||||
RoomMember.room_id == room.id,
|
||||
RoomMember.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not membership or membership.role != UserRole.OWNER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only room owner can add members"
|
||||
)
|
||||
|
||||
# Trouver l'utilisateur à ajouter
|
||||
user_to_add = db.query(User).filter(User.username == member_data.username).first()
|
||||
|
||||
if not user_to_add:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User '{member_data.username}' not found"
|
||||
)
|
||||
|
||||
# Vérifier si déjà membre
|
||||
existing_membership = db.query(RoomMember).filter(
|
||||
RoomMember.room_id == room.id,
|
||||
RoomMember.user_id == user_to_add.id
|
||||
).first()
|
||||
|
||||
if existing_membership:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="User is already a member of this room"
|
||||
)
|
||||
|
||||
# Ajouter le membre
|
||||
new_membership = RoomMember(
|
||||
room_id=room.id,
|
||||
user_id=user_to_add.id,
|
||||
role=UserRole.MEMBER,
|
||||
presence_status=PresenceStatus.OFFLINE
|
||||
)
|
||||
|
||||
db.add(new_membership)
|
||||
db.commit()
|
||||
db.refresh(new_membership)
|
||||
|
||||
return RoomMemberResponse(
|
||||
user_id=user_to_add.user_id,
|
||||
username=user_to_add.username,
|
||||
role=new_membership.role.value,
|
||||
presence_status=new_membership.presence_status.value,
|
||||
joined_at=new_membership.joined_at.isoformat()
|
||||
)
|
||||
4
server/src/auth/__init__.py
Normal file
4
server/src/auth/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: Package d'authentification et autorisation
|
||||
# Refs: server/CLAUDE.md
|
||||
90
server/src/auth/dependencies.py
Normal file
90
server/src/auth/dependencies.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: Dépendances FastAPI pour l'authentification
|
||||
# Refs: server/CLAUDE.md
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from ..db.base import get_db
|
||||
from ..db.models import User
|
||||
from .security import decode_access_token
|
||||
from .schemas import TokenData
|
||||
|
||||
# Schéma de sécurité Bearer
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
"""
|
||||
Dépendance pour obtenir l'utilisateur courant depuis le token JWT.
|
||||
|
||||
Args:
|
||||
credentials: Credentials HTTP Bearer
|
||||
db: Session de base de données
|
||||
|
||||
Returns:
|
||||
Utilisateur authentifié
|
||||
|
||||
Raises:
|
||||
HTTPException: Si le token est invalide ou l'utilisateur n'existe pas
|
||||
"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Décoder le token
|
||||
token = credentials.credentials
|
||||
payload = decode_access_token(token)
|
||||
|
||||
if payload is None:
|
||||
raise credentials_exception
|
||||
|
||||
# Extraire l'user_id
|
||||
user_id: Optional[str] = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
|
||||
# Récupérer l'utilisateur depuis la DB
|
||||
user = db.query(User).filter(User.user_id == user_id).first()
|
||||
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> User:
|
||||
"""
|
||||
Dépendance pour obtenir l'utilisateur courant actif.
|
||||
|
||||
Args:
|
||||
current_user: Utilisateur courant
|
||||
|
||||
Returns:
|
||||
Utilisateur actif
|
||||
|
||||
Raises:
|
||||
HTTPException: Si l'utilisateur est inactif
|
||||
"""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Inactive user"
|
||||
)
|
||||
return current_user
|
||||
49
server/src/auth/schemas.py
Normal file
49
server/src/auth/schemas.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: Schémas Pydantic pour l'authentification
|
||||
# Refs: server/CLAUDE.md
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""Schéma pour la création d'un utilisateur."""
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
email: Optional[EmailStr] = None
|
||||
password: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
"""Schéma pour la connexion d'un utilisateur."""
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Schéma pour la réponse avec token JWT."""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user_id: str
|
||||
username: str
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
"""Schéma pour les données extraites d'un token."""
|
||||
user_id: Optional[str] = None
|
||||
|
||||
|
||||
class CapabilityTokenRequest(BaseModel):
|
||||
"""Schéma pour demander un capability token."""
|
||||
room_id: str
|
||||
capabilities: list[str]
|
||||
target_peer_id: Optional[str] = None
|
||||
target_device_id: Optional[str] = None
|
||||
max_size: Optional[int] = None
|
||||
max_rate: Optional[int] = None
|
||||
|
||||
|
||||
class CapabilityTokenResponse(BaseModel):
|
||||
"""Schéma pour la réponse avec capability token."""
|
||||
cap_token: str
|
||||
expires_in: int # Secondes
|
||||
178
server/src/auth/security.py
Normal file
178
server/src/auth/security.py
Normal file
@@ -0,0 +1,178 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: Utilitaires de sécurité (hashing, JWT)
|
||||
# Refs: server/CLAUDE.md, docs/security.md
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from passlib.context import CryptContext
|
||||
from jose import JWTError, jwt
|
||||
import uuid
|
||||
|
||||
from ..config import settings
|
||||
|
||||
# Configuration du hashing de mots de passe
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
Vérifie qu'un mot de passe en clair correspond au hash.
|
||||
|
||||
Args:
|
||||
plain_password: Mot de passe en clair
|
||||
hashed_password: Hash bcrypt du mot de passe
|
||||
|
||||
Returns:
|
||||
True si le mot de passe correspond
|
||||
"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""
|
||||
Hash un mot de passe avec bcrypt.
|
||||
|
||||
Args:
|
||||
password: Mot de passe en clair
|
||||
|
||||
Returns:
|
||||
Hash bcrypt du mot de passe
|
||||
"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""
|
||||
Crée un JWT access token.
|
||||
|
||||
Args:
|
||||
data: Données à encoder dans le token (ex: {"sub": user_id})
|
||||
expires_delta: Durée de validité optionnelle
|
||||
|
||||
Returns:
|
||||
JWT token encodé
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.mesh_jwt_access_token_expire_minutes)
|
||||
|
||||
to_encode.update({
|
||||
"exp": expire,
|
||||
"iat": datetime.utcnow(),
|
||||
"jti": str(uuid.uuid4()) # JWT ID unique
|
||||
})
|
||||
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode,
|
||||
settings.mesh_jwt_secret,
|
||||
algorithm=settings.mesh_jwt_algorithm
|
||||
)
|
||||
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Décode et valide un JWT access token.
|
||||
|
||||
Args:
|
||||
token: JWT token à décoder
|
||||
|
||||
Returns:
|
||||
Payload du token si valide, None sinon
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.mesh_jwt_secret,
|
||||
algorithms=[settings.mesh_jwt_algorithm]
|
||||
)
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def create_capability_token(
|
||||
subject: str,
|
||||
room_id: str,
|
||||
capabilities: list[str],
|
||||
expires_delta: Optional[timedelta] = None,
|
||||
**extra_claims
|
||||
) -> str:
|
||||
"""
|
||||
Crée un capability token pour autoriser des actions P2P.
|
||||
|
||||
Les capability tokens ont un TTL court (60-180s) et autorisent
|
||||
des actions spécifiques comme les appels WebRTC ou les transferts QUIC.
|
||||
|
||||
Args:
|
||||
subject: Identifiant du sujet (peer_id ou device_id)
|
||||
room_id: ID de la room
|
||||
capabilities: Liste de capabilities (ex: ["call", "share:file"])
|
||||
expires_delta: Durée de validité (défaut: 120s)
|
||||
**extra_claims: Claims additionnels (target_peer_id, max_size, etc.)
|
||||
|
||||
Returns:
|
||||
Capability token JWT
|
||||
"""
|
||||
if not expires_delta:
|
||||
expires_delta = timedelta(seconds=120) # 2 minutes par défaut
|
||||
|
||||
to_encode = {
|
||||
"sub": subject,
|
||||
"room_id": room_id,
|
||||
"caps": capabilities,
|
||||
"exp": datetime.utcnow() + expires_delta,
|
||||
"iat": datetime.utcnow(),
|
||||
"jti": str(uuid.uuid4()),
|
||||
"type": "capability"
|
||||
}
|
||||
|
||||
# Ajouter les claims additionnels
|
||||
to_encode.update(extra_claims)
|
||||
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode,
|
||||
settings.mesh_jwt_secret,
|
||||
algorithm=settings.mesh_jwt_algorithm
|
||||
)
|
||||
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def validate_capability_token(token: str, required_cap: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Valide un capability token et optionnellement vérifie une capability spécifique.
|
||||
|
||||
Args:
|
||||
token: Capability token JWT
|
||||
required_cap: Capability requise optionnelle (ex: "terminal:control")
|
||||
|
||||
Returns:
|
||||
Payload du token si valide, None sinon
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.mesh_jwt_secret,
|
||||
algorithms=[settings.mesh_jwt_algorithm]
|
||||
)
|
||||
|
||||
# Vérifier que c'est bien un capability token
|
||||
if payload.get("type") != "capability":
|
||||
return None
|
||||
|
||||
# Vérifier la capability si demandée
|
||||
if required_cap:
|
||||
caps = payload.get("caps", [])
|
||||
if required_cap not in caps:
|
||||
return None
|
||||
|
||||
return payload
|
||||
|
||||
except JWTError:
|
||||
return None
|
||||
52
server/src/config.py
Normal file
52
server/src/config.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-01
|
||||
# Purpose: Configuration management for Mesh Server
|
||||
# Refs: CLAUDE.md
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Mesh Server configuration settings."""
|
||||
|
||||
# Server
|
||||
mesh_public_url: str = "http://localhost:8000"
|
||||
mesh_host: str = "0.0.0.0"
|
||||
mesh_port: int = 8000
|
||||
|
||||
# Security
|
||||
mesh_jwt_secret: str
|
||||
mesh_jwt_algorithm: str = "HS256"
|
||||
mesh_jwt_access_token_expire_minutes: int = 120
|
||||
|
||||
# Gotify (optionnel)
|
||||
gotify_url: Optional[str] = None
|
||||
gotify_token: Optional[str] = None
|
||||
|
||||
# Alias en majuscules pour compatibilité
|
||||
GOTIFY_URL: Optional[str] = None
|
||||
GOTIFY_TOKEN: Optional[str] = None
|
||||
|
||||
# WebRTC/ICE
|
||||
stun_url: str = "stun:stun.l.google.com:19302"
|
||||
turn_host: Optional[str] = None
|
||||
turn_port: int = 3478
|
||||
turn_user: Optional[str] = None
|
||||
turn_pass: Optional[str] = None
|
||||
turn_external_ip: Optional[str] = None
|
||||
turn_realm: Optional[str] = None
|
||||
|
||||
# Database
|
||||
database_url: str = Field(default="sqlite:///./mesh.db", env="DATABASE_URL")
|
||||
|
||||
# Logging
|
||||
log_level: str = "INFO"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
4
server/src/db/__init__.py
Normal file
4
server/src/db/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: Package de gestion de la base de données
|
||||
# Refs: server/CLAUDE.md
|
||||
35
server/src/db/base.py
Normal file
35
server/src/db/base.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: Configuration de base pour SQLAlchemy
|
||||
# Refs: server/CLAUDE.md
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from ..config import settings
|
||||
|
||||
# Créer l'engine de base de données
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
connect_args={"check_same_thread": False} if "sqlite" in settings.database_url else {},
|
||||
echo=settings.log_level == "DEBUG"
|
||||
)
|
||||
|
||||
# Session factory
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Base class pour les modèles
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""
|
||||
Générateur de session de base de données.
|
||||
Utilisé comme dépendance FastAPI.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
155
server/src/db/models.py
Normal file
155
server/src/db/models.py
Normal file
@@ -0,0 +1,155 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: Modèles SQLAlchemy pour Mesh
|
||||
# Refs: server/CLAUDE.md, docs/protocol_events_v_2.md
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
"""Rôles utilisateur dans une room."""
|
||||
OWNER = "owner"
|
||||
MEMBER = "member"
|
||||
GUEST = "guest"
|
||||
|
||||
|
||||
class PresenceStatus(str, enum.Enum):
|
||||
"""Statuts de présence."""
|
||||
ONLINE = "online"
|
||||
BUSY = "busy"
|
||||
OFFLINE = "offline"
|
||||
|
||||
|
||||
class P2PSessionKind(str, enum.Enum):
|
||||
"""Types de sessions P2P."""
|
||||
FILE = "file"
|
||||
FOLDER = "folder"
|
||||
TERMINAL = "terminal"
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""
|
||||
Modèle utilisateur.
|
||||
Représente un utilisateur du système Mesh.
|
||||
"""
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(String, unique=True, index=True, nullable=False) # UUID
|
||||
username = Column(String, unique=True, index=True, nullable=False)
|
||||
email = Column(String, unique=True, index=True, nullable=True)
|
||||
hashed_password = Column(String, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Relations
|
||||
devices = relationship("Device", back_populates="user", cascade="all, delete-orphan")
|
||||
room_memberships = relationship("RoomMember", back_populates="user", cascade="all, delete-orphan")
|
||||
owned_rooms = relationship("Room", back_populates="owner", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Device(Base):
|
||||
"""
|
||||
Modèle device (agent desktop).
|
||||
Représente une instance d'agent Mesh sur une machine.
|
||||
"""
|
||||
__tablename__ = "devices"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
device_id = Column(String, unique=True, index=True, nullable=False) # UUID
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
name = Column(String, nullable=False) # Ex: "Laptop-Linux", "Desktop-Windows"
|
||||
last_seen = Column(DateTime, default=datetime.utcnow)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Relations
|
||||
user = relationship("User", back_populates="devices")
|
||||
|
||||
|
||||
class Room(Base):
|
||||
"""
|
||||
Modèle room (salon de communication).
|
||||
Représente un espace de communication pour 2-4 personnes.
|
||||
"""
|
||||
__tablename__ = "rooms"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
room_id = Column(String, unique=True, index=True, nullable=False) # UUID
|
||||
name = Column(String, nullable=False)
|
||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Relations
|
||||
owner = relationship("User", back_populates="owned_rooms")
|
||||
members = relationship("RoomMember", back_populates="room", cascade="all, delete-orphan")
|
||||
messages = relationship("Message", back_populates="room", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class RoomMember(Base):
|
||||
"""
|
||||
Modèle d'appartenance à une room.
|
||||
Représente la relation entre un utilisateur et une room avec son rôle.
|
||||
"""
|
||||
__tablename__ = "room_members"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
room_id = Column(Integer, ForeignKey("rooms.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
role = Column(Enum(UserRole), default=UserRole.MEMBER, nullable=False)
|
||||
joined_at = Column(DateTime, default=datetime.utcnow)
|
||||
presence_status = Column(Enum(PresenceStatus), default=PresenceStatus.OFFLINE)
|
||||
|
||||
# Relations
|
||||
room = relationship("Room", back_populates="members")
|
||||
user = relationship("User", back_populates="room_memberships")
|
||||
|
||||
|
||||
class Message(Base):
|
||||
"""
|
||||
Modèle message de chat.
|
||||
Représente un message dans une room.
|
||||
"""
|
||||
__tablename__ = "messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(String, unique=True, index=True, nullable=False) # UUID
|
||||
room_id = Column(Integer, ForeignKey("rooms.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
content = Column(String, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relations
|
||||
room = relationship("Room", back_populates="messages")
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class P2PSession(Base):
|
||||
"""
|
||||
Modèle session P2P.
|
||||
Représente une session QUIC entre deux agents.
|
||||
"""
|
||||
__tablename__ = "p2p_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
session_id = Column(String, unique=True, index=True, nullable=False) # UUID
|
||||
room_id = Column(Integer, ForeignKey("rooms.id"), nullable=False)
|
||||
initiator_device_id = Column(String, nullable=True) # Optionnel, rempli via WebSocket
|
||||
target_device_id = Column(String, nullable=True) # Optionnel, rempli via WebSocket
|
||||
kind = Column(Enum(P2PSessionKind), nullable=False)
|
||||
session_token = Column(String, nullable=False) # JWT pour validation
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
closed_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relations
|
||||
room = relationship("Room")
|
||||
147
server/src/main.py
Normal file
147
server/src/main.py
Normal file
@@ -0,0 +1,147 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: Main FastAPI application entry point for Mesh Server
|
||||
# Refs: CLAUDE.md, protocol_events_v_2.md
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy.orm import Session
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from .config import settings
|
||||
from .db.base import get_db, engine, Base
|
||||
from .api import auth, rooms, p2p
|
||||
from .websocket.manager import manager
|
||||
from .websocket.handlers import EventHandler
|
||||
from .auth.security import decode_access_token
|
||||
|
||||
# Configurer le logging
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.log_level.upper()),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Créer les tables de la base de données
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(
|
||||
title="Mesh Server",
|
||||
description="Control plane for Mesh P2P communication platform",
|
||||
version="0.1.0"
|
||||
)
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # À configurer en production
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Inclure les routers API
|
||||
app.include_router(auth.router)
|
||||
app.include_router(rooms.router)
|
||||
app.include_router(p2p.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint."""
|
||||
return {
|
||||
"service": "mesh-server",
|
||||
"version": "0.1.0",
|
||||
"status": "operational"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
token: str = Query(...),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Endpoint WebSocket principal pour les connexions temps réel.
|
||||
|
||||
Args:
|
||||
websocket: Connexion WebSocket
|
||||
token: JWT token pour authentification (query param)
|
||||
db: Session de base de données
|
||||
|
||||
Le client doit se connecter avec: ws://server/ws?token=JWT_TOKEN
|
||||
"""
|
||||
# Vérifier le token JWT
|
||||
payload = decode_access_token(token)
|
||||
|
||||
if not payload:
|
||||
await websocket.close(code=1008, reason="Invalid token")
|
||||
logger.warning("WebSocket connection rejected: invalid token")
|
||||
return
|
||||
|
||||
user_id = payload.get("sub")
|
||||
|
||||
if not user_id:
|
||||
await websocket.close(code=1008, reason="Invalid token payload")
|
||||
logger.warning("WebSocket connection rejected: invalid payload")
|
||||
return
|
||||
|
||||
# Générer un peer_id unique pour cette connexion
|
||||
peer_id = str(uuid.uuid4())
|
||||
|
||||
# Enregistrer la connexion
|
||||
await manager.connect(peer_id, user_id, websocket)
|
||||
|
||||
# Créer le handler d'événements
|
||||
event_handler = EventHandler(db)
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Recevoir les messages
|
||||
data = await websocket.receive_json()
|
||||
|
||||
# Traiter l'événement
|
||||
await event_handler.handle_event(data, peer_id, websocket)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
# Déconnexion normale
|
||||
manager.disconnect(peer_id)
|
||||
logger.info(f"WebSocket disconnected normally: peer_id={peer_id}")
|
||||
|
||||
except Exception as e:
|
||||
# Erreur inattendue
|
||||
logger.error(f"WebSocket error for peer_id={peer_id}: {str(e)}")
|
||||
manager.disconnect(peer_id)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Tâches au démarrage de l'application."""
|
||||
logger.info(f"Mesh Server starting on {settings.mesh_host}:{settings.mesh_port}")
|
||||
logger.info(f"Public URL: {settings.mesh_public_url}")
|
||||
logger.info(f"Database: {settings.database_url}")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""Tâches à l'arrêt de l'application."""
|
||||
logger.info("Mesh Server shutting down")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"src.main:app",
|
||||
host=settings.mesh_host,
|
||||
port=settings.mesh_port,
|
||||
reload=True
|
||||
)
|
||||
4
server/src/notifications/__init__.py
Normal file
4
server/src/notifications/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-03
|
||||
# Purpose: Package pour les notifications (Gotify)
|
||||
# Refs: server/CLAUDE.md
|
||||
207
server/src/notifications/gotify.py
Normal file
207
server/src/notifications/gotify.py
Normal file
@@ -0,0 +1,207 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-03
|
||||
# Purpose: Client Gotify pour les notifications push
|
||||
# Refs: server/CLAUDE.md, https://gotify.net/docs/msgextras
|
||||
|
||||
import httpx
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GotifyClient:
|
||||
"""Client pour envoyer des notifications via Gotify."""
|
||||
|
||||
def __init__(self):
|
||||
self.url = settings.GOTIFY_URL
|
||||
self.token = settings.GOTIFY_TOKEN
|
||||
self.enabled = bool(self.url and self.token)
|
||||
|
||||
if not self.enabled:
|
||||
logger.warning("Gotify non configuré - notifications désactivées")
|
||||
else:
|
||||
logger.info(f"Gotify configuré: {self.url}")
|
||||
|
||||
async def send_notification(
|
||||
self,
|
||||
title: str,
|
||||
message: str,
|
||||
priority: int = 5,
|
||||
extras: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Envoyer une notification Gotify.
|
||||
|
||||
Args:
|
||||
title: Titre de la notification
|
||||
message: Message de la notification
|
||||
priority: Priorité (0=min, 10=max, défaut=5)
|
||||
extras: Données supplémentaires (ex: actions, images)
|
||||
|
||||
Returns:
|
||||
True si envoyé avec succès, False sinon
|
||||
"""
|
||||
if not self.enabled:
|
||||
logger.debug(f"Gotify disabled - Would send: {title}")
|
||||
return False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
payload = {
|
||||
"title": title,
|
||||
"message": message,
|
||||
"priority": priority,
|
||||
}
|
||||
|
||||
if extras:
|
||||
payload["extras"] = extras
|
||||
|
||||
response = await client.post(
|
||||
f"{self.url}/message",
|
||||
params={"token": self.token},
|
||||
json=payload,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
logger.info(f"Notification Gotify envoyée: {title}")
|
||||
return True
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Erreur envoi Gotify: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur inattendue Gotify: {e}")
|
||||
return False
|
||||
|
||||
async def send_chat_notification(
|
||||
self,
|
||||
from_username: str,
|
||||
room_name: str,
|
||||
message: str,
|
||||
room_id: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Notification pour un nouveau message de chat.
|
||||
|
||||
Args:
|
||||
from_username: Nom de l'expéditeur
|
||||
room_name: Nom de la room
|
||||
message: Contenu du message
|
||||
room_id: ID de la room
|
||||
|
||||
Returns:
|
||||
True si envoyé
|
||||
"""
|
||||
title = f"💬 {from_username} dans {room_name}"
|
||||
|
||||
# Tronquer le message si trop long
|
||||
preview = message[:100] + "..." if len(message) > 100 else message
|
||||
|
||||
# Extras avec actions Gotify
|
||||
extras = {
|
||||
"client::display": {
|
||||
"contentType": "text/markdown"
|
||||
},
|
||||
"client::notification": {
|
||||
"click": {
|
||||
"url": f"mesh://room/{room_id}"
|
||||
}
|
||||
},
|
||||
"android::action": {
|
||||
"onReceive": {
|
||||
"intentUrl": f"mesh://room/{room_id}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await self.send_notification(
|
||||
title=title,
|
||||
message=preview,
|
||||
priority=6, # Priorité normale-haute pour chat
|
||||
extras=extras,
|
||||
)
|
||||
|
||||
async def send_call_notification(
|
||||
self,
|
||||
from_username: str,
|
||||
room_name: str,
|
||||
room_id: str,
|
||||
call_type: str = "audio/vidéo",
|
||||
) -> bool:
|
||||
"""
|
||||
Notification pour un appel entrant.
|
||||
|
||||
Args:
|
||||
from_username: Nom de l'appelant
|
||||
room_name: Nom de la room
|
||||
room_id: ID de la room
|
||||
call_type: Type d'appel (audio, vidéo, audio/vidéo)
|
||||
|
||||
Returns:
|
||||
True si envoyé
|
||||
"""
|
||||
title = f"📞 Appel {call_type} de {from_username}"
|
||||
message = f"Appel entrant dans {room_name}"
|
||||
|
||||
extras = {
|
||||
"client::notification": {
|
||||
"click": {
|
||||
"url": f"mesh://room/{room_id}"
|
||||
}
|
||||
},
|
||||
"android::action": {
|
||||
"onReceive": {
|
||||
"intentUrl": f"mesh://room/{room_id}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await self.send_notification(
|
||||
title=title,
|
||||
message=message,
|
||||
priority=8, # Haute priorité pour appels
|
||||
extras=extras,
|
||||
)
|
||||
|
||||
async def send_file_notification(
|
||||
self,
|
||||
from_username: str,
|
||||
room_name: str,
|
||||
filename: str,
|
||||
room_id: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Notification pour un fichier partagé.
|
||||
|
||||
Args:
|
||||
from_username: Nom de l'expéditeur
|
||||
room_name: Nom de la room
|
||||
filename: Nom du fichier
|
||||
room_id: ID de la room
|
||||
|
||||
Returns:
|
||||
True si envoyé
|
||||
"""
|
||||
title = f"📁 {from_username} a partagé un fichier"
|
||||
message = f"Fichier: {filename}\nDans: {room_name}"
|
||||
|
||||
extras = {
|
||||
"client::notification": {
|
||||
"click": {
|
||||
"url": f"mesh://room/{room_id}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await self.send_notification(
|
||||
title=title,
|
||||
message=message,
|
||||
priority=5,
|
||||
extras=extras,
|
||||
)
|
||||
|
||||
|
||||
# Instance globale du client Gotify
|
||||
gotify_client = GotifyClient()
|
||||
4
server/src/websocket/__init__.py
Normal file
4
server/src/websocket/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: Package WebSocket pour connexions temps réel
|
||||
# Refs: server/CLAUDE.md, docs/protocol_events_v_2.md
|
||||
182
server/src/websocket/events.py
Normal file
182
server/src/websocket/events.py
Normal file
@@ -0,0 +1,182 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: Types d'événements WebSocket
|
||||
# Refs: docs/protocol_events_v_2.md
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import Any, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class EventType:
|
||||
"""Constantes pour les types d'événements."""
|
||||
|
||||
# Système
|
||||
SYSTEM_HELLO = "system.hello"
|
||||
SYSTEM_WELCOME = "system.welcome"
|
||||
|
||||
# Rooms
|
||||
ROOM_JOIN = "room.join"
|
||||
ROOM_JOINED = "room.joined"
|
||||
ROOM_LEFT = "room.left"
|
||||
|
||||
# Présence
|
||||
PRESENCE_UPDATE = "presence.update"
|
||||
|
||||
# Chat
|
||||
CHAT_MESSAGE_SEND = "chat.message.send"
|
||||
CHAT_MESSAGE_CREATED = "chat.message.created"
|
||||
|
||||
# WebRTC Signaling
|
||||
RTC_OFFER = "rtc.offer"
|
||||
RTC_ANSWER = "rtc.answer"
|
||||
RTC_ICE = "rtc.ice"
|
||||
|
||||
# P2P Sessions (QUIC)
|
||||
P2P_SESSION_REQUEST = "p2p.session.request"
|
||||
P2P_SESSION_CREATED = "p2p.session.created"
|
||||
P2P_SESSION_CLOSED = "p2p.session.closed"
|
||||
|
||||
# Terminal Control
|
||||
TERMINAL_CONTROL_TAKE = "terminal.control.take"
|
||||
TERMINAL_CONTROL_GRANTED = "terminal.control.granted"
|
||||
TERMINAL_CONTROL_RELEASE = "terminal.control.release"
|
||||
|
||||
# Erreurs
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class WebSocketEvent(BaseModel):
|
||||
"""
|
||||
Modèle de base pour tous les événements WebSocket.
|
||||
|
||||
Structure selon protocol_events_v_2.md:
|
||||
{
|
||||
"type": "event.type",
|
||||
"id": "uuid",
|
||||
"timestamp": "ISO-8601",
|
||||
"from": "peer_id|device_id|server",
|
||||
"to": "peer_id|device_id|room_id|server",
|
||||
"payload": {}
|
||||
}
|
||||
"""
|
||||
type: str
|
||||
id: str = None
|
||||
timestamp: str = None
|
||||
from_: str = "server" # Alias pour "from"
|
||||
to: str
|
||||
payload: dict = {}
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
fields = {"from_": "from"}
|
||||
|
||||
def __init__(self, **data):
|
||||
# Générer ID et timestamp si non fournis
|
||||
if "id" not in data or not data["id"]:
|
||||
data["id"] = str(uuid.uuid4())
|
||||
if "timestamp" not in data or not data["timestamp"]:
|
||||
data["timestamp"] = datetime.utcnow().isoformat() + "Z"
|
||||
|
||||
super().__init__(**data)
|
||||
|
||||
def dict(self, **kwargs):
|
||||
"""Override pour utiliser 'from' au lieu de 'from_'."""
|
||||
d = super().dict(**kwargs)
|
||||
if "from_" in d:
|
||||
d["from"] = d.pop("from_")
|
||||
return d
|
||||
|
||||
|
||||
# Schémas de payload spécifiques
|
||||
|
||||
class SystemHelloPayload(BaseModel):
|
||||
"""Payload pour system.hello."""
|
||||
peer_type: str # "client" ou "agent"
|
||||
version: str
|
||||
|
||||
|
||||
class SystemWelcomePayload(BaseModel):
|
||||
"""Payload pour system.welcome."""
|
||||
peer_id: str
|
||||
user_id: str
|
||||
|
||||
|
||||
class RoomJoinPayload(BaseModel):
|
||||
"""Payload pour room.join."""
|
||||
room_id: str
|
||||
|
||||
|
||||
class RoomJoinedPayload(BaseModel):
|
||||
"""Payload pour room.joined."""
|
||||
peer_id: str
|
||||
user_id: str
|
||||
username: str
|
||||
role: str
|
||||
room_id: str
|
||||
|
||||
|
||||
class ChatMessageSendPayload(BaseModel):
|
||||
"""Payload pour chat.message.send."""
|
||||
room_id: str
|
||||
content: str
|
||||
|
||||
|
||||
class ChatMessageCreatedPayload(BaseModel):
|
||||
"""Payload pour chat.message.created."""
|
||||
message_id: str
|
||||
room_id: str
|
||||
from_user_id: str
|
||||
from_username: str
|
||||
content: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class RTCOfferPayload(BaseModel):
|
||||
"""Payload pour rtc.offer."""
|
||||
room_id: str
|
||||
target_peer_id: str
|
||||
sdp: str
|
||||
cap_token: str
|
||||
|
||||
|
||||
class RTCAnswerPayload(BaseModel):
|
||||
"""Payload pour rtc.answer."""
|
||||
room_id: str
|
||||
target_peer_id: str
|
||||
sdp: str
|
||||
cap_token: str
|
||||
|
||||
|
||||
class RTCIcePayload(BaseModel):
|
||||
"""Payload pour rtc.ice."""
|
||||
room_id: str
|
||||
target_peer_id: str
|
||||
candidate: dict
|
||||
cap_token: str
|
||||
|
||||
|
||||
class P2PSessionRequestPayload(BaseModel):
|
||||
"""Payload pour p2p.session.request."""
|
||||
room_id: str
|
||||
target_device_id: str
|
||||
kind: str # "file", "folder", "terminal"
|
||||
cap_token: str
|
||||
meta: Optional[dict] = {}
|
||||
|
||||
|
||||
class P2PSessionCreatedPayload(BaseModel):
|
||||
"""Payload pour p2p.session.created."""
|
||||
session_id: str
|
||||
kind: str
|
||||
expires_in: int
|
||||
auth: dict
|
||||
endpoints: dict
|
||||
|
||||
|
||||
class ErrorPayload(BaseModel):
|
||||
"""Payload pour error."""
|
||||
code: str
|
||||
message: str
|
||||
details: Optional[dict] = {}
|
||||
473
server/src/websocket/handlers.py
Normal file
473
server/src/websocket/handlers.py
Normal file
@@ -0,0 +1,473 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: Handlers pour les événements WebSocket
|
||||
# Refs: server/CLAUDE.md, docs/protocol_events_v_2.md
|
||||
|
||||
from fastapi import WebSocket
|
||||
from sqlalchemy.orm import Session
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from .manager import manager
|
||||
from .events import (
|
||||
EventType,
|
||||
WebSocketEvent,
|
||||
SystemHelloPayload,
|
||||
SystemWelcomePayload,
|
||||
RoomJoinPayload,
|
||||
RoomJoinedPayload,
|
||||
ChatMessageSendPayload,
|
||||
ChatMessageCreatedPayload,
|
||||
ErrorPayload
|
||||
)
|
||||
from ..db.models import Room, RoomMember, Message, User, PresenceStatus
|
||||
from ..notifications.gotify import gotify_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventHandler:
|
||||
"""Gestionnaire des événements WebSocket."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
async def handle_event(self, event_data: dict, peer_id: str, websocket: WebSocket):
|
||||
"""
|
||||
Router principal pour gérer les événements entrants.
|
||||
|
||||
Args:
|
||||
event_data: Données de l'événement
|
||||
peer_id: ID du peer émetteur
|
||||
websocket: Connexion WebSocket
|
||||
"""
|
||||
event_type = event_data.get("type")
|
||||
|
||||
if not event_type:
|
||||
await self.send_error(websocket, "INVALID_EVENT", "Missing event type")
|
||||
return
|
||||
|
||||
# Router vers le handler approprié
|
||||
handlers = {
|
||||
EventType.SYSTEM_HELLO: self.handle_system_hello,
|
||||
EventType.ROOM_JOIN: self.handle_room_join,
|
||||
EventType.ROOM_LEFT: self.handle_room_left,
|
||||
EventType.CHAT_MESSAGE_SEND: self.handle_chat_message_send,
|
||||
EventType.PRESENCE_UPDATE: self.handle_presence_update,
|
||||
EventType.RTC_OFFER: self.handle_rtc_signal,
|
||||
EventType.RTC_ANSWER: self.handle_rtc_signal,
|
||||
EventType.RTC_ICE: self.handle_rtc_signal,
|
||||
EventType.P2P_SESSION_REQUEST: self.handle_p2p_session_request,
|
||||
}
|
||||
|
||||
handler = handlers.get(event_type)
|
||||
|
||||
if handler:
|
||||
try:
|
||||
await handler(event_data, peer_id, websocket)
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling {event_type}: {str(e)}")
|
||||
await self.send_error(websocket, "HANDLER_ERROR", str(e))
|
||||
else:
|
||||
logger.warning(f"Unknown event type: {event_type}")
|
||||
await self.send_error(websocket, "UNKNOWN_EVENT", f"Unknown event type: {event_type}")
|
||||
|
||||
async def handle_system_hello(self, event_data: dict, peer_id: str, websocket: WebSocket):
|
||||
"""
|
||||
Gérer l'événement system.hello.
|
||||
|
||||
Le client/agent s'identifie et reçoit son peer_id en retour.
|
||||
"""
|
||||
payload = event_data.get("payload", {})
|
||||
|
||||
# Envoyer system.welcome
|
||||
welcome_event = WebSocketEvent(
|
||||
type=EventType.SYSTEM_WELCOME,
|
||||
from_="server",
|
||||
to=peer_id,
|
||||
payload=SystemWelcomePayload(
|
||||
peer_id=peer_id,
|
||||
user_id=manager.get_user_id(peer_id)
|
||||
).dict()
|
||||
)
|
||||
|
||||
await websocket.send_json(welcome_event.dict())
|
||||
logger.info(f"Sent system.welcome to {peer_id}")
|
||||
|
||||
async def handle_room_join(self, event_data: dict, peer_id: str, websocket: WebSocket):
|
||||
"""
|
||||
Gérer l'événement room.join.
|
||||
|
||||
Un peer demande à rejoindre une room.
|
||||
"""
|
||||
payload = event_data.get("payload", {})
|
||||
room_id_str = payload.get("room_id")
|
||||
|
||||
if not room_id_str:
|
||||
await self.send_error(websocket, "MISSING_ROOM_ID", "room_id is required")
|
||||
return
|
||||
|
||||
# Vérifier que la room existe
|
||||
room = self.db.query(Room).filter(Room.room_id == room_id_str).first()
|
||||
|
||||
if not room:
|
||||
await self.send_error(websocket, "ROOM_NOT_FOUND", "Room not found")
|
||||
return
|
||||
|
||||
# Vérifier que l'utilisateur est membre
|
||||
user_id = manager.get_user_id(peer_id)
|
||||
user = self.db.query(User).filter(User.user_id == user_id).first()
|
||||
|
||||
if not user:
|
||||
await self.send_error(websocket, "USER_NOT_FOUND", "User not found")
|
||||
return
|
||||
|
||||
membership = self.db.query(RoomMember).filter(
|
||||
RoomMember.room_id == room.id,
|
||||
RoomMember.user_id == user.id
|
||||
).first()
|
||||
|
||||
if not membership:
|
||||
await self.send_error(websocket, "ACCESS_DENIED", "Not a member of this room")
|
||||
return
|
||||
|
||||
# Ajouter le peer à la room
|
||||
manager.join_room(peer_id, room_id_str)
|
||||
|
||||
# Mettre à jour le statut de présence
|
||||
membership.presence_status = PresenceStatus.ONLINE
|
||||
self.db.commit()
|
||||
|
||||
# Envoyer room.joined au peer
|
||||
joined_event = WebSocketEvent(
|
||||
type=EventType.ROOM_JOINED,
|
||||
from_="server",
|
||||
to=peer_id,
|
||||
payload=RoomJoinedPayload(
|
||||
peer_id=peer_id,
|
||||
user_id=user.user_id,
|
||||
username=user.username,
|
||||
role=membership.role.value,
|
||||
room_id=room_id_str
|
||||
).dict()
|
||||
)
|
||||
|
||||
await websocket.send_json(joined_event.dict())
|
||||
|
||||
# Broadcast aux autres membres
|
||||
await manager.broadcast_to_room(joined_event.dict(), room_id_str, exclude=peer_id)
|
||||
|
||||
logger.info(f"Peer {peer_id} joined room {room_id_str}")
|
||||
|
||||
async def handle_room_left(self, event_data: dict, peer_id: str, websocket: WebSocket):
|
||||
"""Gérer l'événement room.left."""
|
||||
payload = event_data.get("payload", {})
|
||||
room_id = payload.get("room_id")
|
||||
|
||||
if room_id:
|
||||
manager.leave_room(peer_id, room_id)
|
||||
logger.info(f"Peer {peer_id} left room {room_id}")
|
||||
|
||||
async def handle_chat_message_send(self, event_data: dict, peer_id: str, websocket: WebSocket):
|
||||
"""
|
||||
Gérer l'envoi d'un message de chat.
|
||||
|
||||
Persister le message et le broadcast à tous les membres de la room.
|
||||
"""
|
||||
payload = event_data.get("payload", {})
|
||||
room_id_str = payload.get("room_id")
|
||||
content = payload.get("content")
|
||||
|
||||
if not room_id_str or not content:
|
||||
await self.send_error(websocket, "INVALID_MESSAGE", "room_id and content are required")
|
||||
return
|
||||
|
||||
# Vérifier la room et le membership
|
||||
room = self.db.query(Room).filter(Room.room_id == room_id_str).first()
|
||||
|
||||
if not room:
|
||||
await self.send_error(websocket, "ROOM_NOT_FOUND", "Room not found")
|
||||
return
|
||||
|
||||
user_id = manager.get_user_id(peer_id)
|
||||
user = self.db.query(User).filter(User.user_id == user_id).first()
|
||||
|
||||
if not user:
|
||||
await self.send_error(websocket, "USER_NOT_FOUND", "User not found")
|
||||
return
|
||||
|
||||
# Créer le message
|
||||
message_id = str(uuid.uuid4())
|
||||
new_message = Message(
|
||||
message_id=message_id,
|
||||
room_id=room.id,
|
||||
user_id=user.id,
|
||||
content=content
|
||||
)
|
||||
|
||||
self.db.add(new_message)
|
||||
self.db.commit()
|
||||
self.db.refresh(new_message)
|
||||
|
||||
# Créer l'événement chat.message.created
|
||||
created_event = WebSocketEvent(
|
||||
type=EventType.CHAT_MESSAGE_CREATED,
|
||||
from_=peer_id,
|
||||
to=room_id_str,
|
||||
payload=ChatMessageCreatedPayload(
|
||||
message_id=message_id,
|
||||
room_id=room_id_str,
|
||||
from_user_id=user.user_id,
|
||||
from_username=user.username,
|
||||
content=content,
|
||||
created_at=new_message.created_at.isoformat()
|
||||
).dict()
|
||||
)
|
||||
|
||||
# Broadcast à tous les membres de la room
|
||||
await manager.broadcast_to_room(created_event.dict(), room_id_str)
|
||||
|
||||
# Envoyer notifications Gotify aux membres absents
|
||||
await self._send_chat_notifications(room, user, content, room_id_str, peer_id)
|
||||
|
||||
logger.info(f"Message created in room {room_id_str} by {user.username}")
|
||||
|
||||
async def handle_presence_update(self, event_data: dict, peer_id: str, websocket: WebSocket):
|
||||
"""Gérer la mise à jour de présence."""
|
||||
# TODO: Implémenter la mise à jour de présence
|
||||
pass
|
||||
|
||||
async def handle_p2p_session_request(self, event_data: dict, peer_id: str, websocket: WebSocket):
|
||||
"""
|
||||
Gérer une requête de session P2P.
|
||||
|
||||
Un peer demande à établir une session P2P QUIC avec un autre peer.
|
||||
Le serveur génère un session_id et un session_token, puis notifie
|
||||
les deux peers via l'événement p2p.session.created.
|
||||
"""
|
||||
payload = event_data.get("payload", {})
|
||||
target_peer_id = payload.get("target_peer_id")
|
||||
kind = payload.get("kind") # 'file', 'folder', 'terminal'
|
||||
room_id = payload.get("room_id")
|
||||
|
||||
if not target_peer_id or not kind or not room_id:
|
||||
await self.send_error(
|
||||
websocket,
|
||||
"INVALID_P2P_REQUEST",
|
||||
"target_peer_id, kind, and room_id are required"
|
||||
)
|
||||
return
|
||||
|
||||
# Vérifier que les deux peers sont membres de la room
|
||||
user_id = manager.get_user_id(peer_id)
|
||||
user = self.db.query(User).filter(User.user_id == user_id).first()
|
||||
|
||||
if not user:
|
||||
await self.send_error(websocket, "USER_NOT_FOUND", "User not found")
|
||||
return
|
||||
|
||||
room = self.db.query(Room).filter(Room.room_id == room_id).first()
|
||||
if not room:
|
||||
await self.send_error(websocket, "ROOM_NOT_FOUND", "Room not found")
|
||||
return
|
||||
|
||||
membership = self.db.query(RoomMember).filter(
|
||||
RoomMember.room_id == room.id,
|
||||
RoomMember.user_id == user.id
|
||||
).first()
|
||||
|
||||
if not membership:
|
||||
await self.send_error(websocket, "ACCESS_DENIED", "Not a member of this room")
|
||||
return
|
||||
|
||||
# Générer session_id et session_token
|
||||
from ..auth.security import create_capability_token
|
||||
from datetime import timedelta
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
|
||||
# Déterminer les capabilities en fonction du kind
|
||||
capabilities_map = {
|
||||
"file": ["share:file"],
|
||||
"folder": ["share:folder"],
|
||||
"terminal": ["terminal:view"]
|
||||
}
|
||||
|
||||
capabilities = capabilities_map.get(kind, [])
|
||||
|
||||
session_token = create_capability_token(
|
||||
subject=user_id,
|
||||
room_id=room_id,
|
||||
capabilities=capabilities,
|
||||
expires_delta=timedelta(seconds=180),
|
||||
session_id=session_id,
|
||||
target_peer_id=target_peer_id,
|
||||
kind=kind
|
||||
)
|
||||
|
||||
# Créer la session en base de données
|
||||
from ..db.models import P2PSession, P2PSessionKind
|
||||
|
||||
kind_enum_map = {
|
||||
"file": P2PSessionKind.FILE,
|
||||
"folder": P2PSessionKind.FOLDER,
|
||||
"terminal": P2PSessionKind.TERMINAL
|
||||
}
|
||||
|
||||
if kind not in kind_enum_map:
|
||||
await self.send_error(websocket, "INVALID_KIND", f"Invalid kind: {kind}")
|
||||
return
|
||||
|
||||
expires_at = datetime.utcnow() + timedelta(seconds=180)
|
||||
|
||||
new_session = P2PSession(
|
||||
session_id=session_id,
|
||||
kind=kind_enum_map[kind],
|
||||
session_token=session_token,
|
||||
room_id=room.id,
|
||||
expires_at=expires_at
|
||||
)
|
||||
|
||||
self.db.add(new_session)
|
||||
self.db.commit()
|
||||
|
||||
# Créer l'événement p2p.session.created
|
||||
from .events import WebSocketEvent
|
||||
|
||||
session_created_event = WebSocketEvent(
|
||||
type=EventType.P2P_SESSION_CREATED,
|
||||
from_="server",
|
||||
to=room_id,
|
||||
payload={
|
||||
"session_id": session_id,
|
||||
"session_token": session_token,
|
||||
"kind": kind,
|
||||
"initiator_peer_id": peer_id,
|
||||
"target_peer_id": target_peer_id,
|
||||
"expires_at": expires_at.isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
# Envoyer à l'initiateur
|
||||
await websocket.send_json(session_created_event.dict())
|
||||
|
||||
# Envoyer au peer cible
|
||||
await manager.send_personal_message(session_created_event.dict(), target_peer_id)
|
||||
|
||||
logger.info(f"P2P session {session_id} created: {peer_id} -> {target_peer_id} ({kind})")
|
||||
|
||||
async def handle_rtc_signal(self, event_data: dict, peer_id: str, websocket: WebSocket):
|
||||
"""
|
||||
Gérer les événements de signalisation WebRTC (offer, answer, ice).
|
||||
|
||||
Relay les messages SDP et ICE candidates entre peers.
|
||||
"""
|
||||
payload = event_data.get("payload", {})
|
||||
target_peer_id = payload.get("target_peer_id")
|
||||
|
||||
if not target_peer_id:
|
||||
await self.send_error(websocket, "MISSING_TARGET", "target_peer_id is required")
|
||||
return
|
||||
|
||||
# TODO: Valider le capability token
|
||||
|
||||
# Ajouter des informations sur l'émetteur pour les offers
|
||||
user_id = manager.get_user_id(peer_id)
|
||||
if user_id and event_data.get("type") == EventType.RTC_OFFER:
|
||||
user = self.db.query(User).filter(User.user_id == user_id).first()
|
||||
if user:
|
||||
event_data["payload"]["from_username"] = user.username
|
||||
|
||||
# Envoyer notification Gotify si le destinataire n'est pas connecté
|
||||
target_user_id = manager.get_user_id(target_peer_id)
|
||||
if target_user_id:
|
||||
target_is_online = manager.is_connected(target_peer_id)
|
||||
|
||||
if not target_is_online:
|
||||
# Trouver la room pour le nom
|
||||
room_id = payload.get("room_id")
|
||||
if room_id:
|
||||
room = self.db.query(Room).filter(Room.room_id == room_id).first()
|
||||
if room:
|
||||
await gotify_client.send_call_notification(
|
||||
from_username=user.username,
|
||||
room_name=room.name,
|
||||
room_id=room_id,
|
||||
call_type="audio/vidéo"
|
||||
)
|
||||
|
||||
# Relay le message au peer cible
|
||||
event_data["from"] = peer_id
|
||||
event_data["payload"]["from_peer_id"] = peer_id
|
||||
await manager.send_personal_message(event_data, target_peer_id)
|
||||
|
||||
logger.debug(f"Relayed {event_data.get('type')} from {peer_id} to {target_peer_id}")
|
||||
|
||||
async def send_error(self, websocket: WebSocket, code: str, message: str):
|
||||
"""
|
||||
Envoyer un événement d'erreur au client.
|
||||
|
||||
Args:
|
||||
websocket: Connexion WebSocket
|
||||
code: Code d'erreur
|
||||
message: Message d'erreur
|
||||
"""
|
||||
error_event = WebSocketEvent(
|
||||
type=EventType.ERROR,
|
||||
from_="server",
|
||||
to="client",
|
||||
payload=ErrorPayload(
|
||||
code=code,
|
||||
message=message
|
||||
).dict()
|
||||
)
|
||||
|
||||
await websocket.send_json(error_event.dict())
|
||||
logger.warning(f"Sent error: {code} - {message}")
|
||||
|
||||
async def _send_chat_notifications(
|
||||
self,
|
||||
room: Room,
|
||||
sender: User,
|
||||
message_content: str,
|
||||
room_id_str: str,
|
||||
sender_peer_id: str
|
||||
):
|
||||
"""
|
||||
Envoyer des notifications Gotify aux membres absents.
|
||||
|
||||
Args:
|
||||
room: Room où le message a été envoyé
|
||||
sender: Utilisateur qui a envoyé le message
|
||||
message_content: Contenu du message
|
||||
room_id_str: ID de la room (UUID string)
|
||||
sender_peer_id: Peer ID de l'expéditeur
|
||||
"""
|
||||
# Récupérer tous les membres de la room
|
||||
members = self.db.query(RoomMember).filter(
|
||||
RoomMember.room_id == room.id
|
||||
).all()
|
||||
|
||||
for member in members:
|
||||
# Ne pas notifier l'expéditeur
|
||||
if member.user_id == sender.id:
|
||||
continue
|
||||
|
||||
# Vérifier si le membre est connecté
|
||||
user = self.db.query(User).filter(User.id == member.user_id).first()
|
||||
if not user:
|
||||
continue
|
||||
|
||||
# Vérifier si l'utilisateur a un peer_id actif dans cette room
|
||||
is_online = manager.is_user_in_room(user.user_id, room_id_str)
|
||||
|
||||
# Envoyer notification Gotify uniquement si l'utilisateur est absent
|
||||
if not is_online:
|
||||
await gotify_client.send_chat_notification(
|
||||
from_username=sender.username,
|
||||
room_name=room.name,
|
||||
message=message_content,
|
||||
room_id=room_id_str,
|
||||
)
|
||||
logger.debug(f"Notification Gotify envoyée à {user.username} pour message dans {room.name}")
|
||||
198
server/src/websocket/manager.py
Normal file
198
server/src/websocket/manager.py
Normal file
@@ -0,0 +1,198 @@
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: Gestionnaire de connexions WebSocket
|
||||
# Refs: server/CLAUDE.md, docs/protocol_events_v_2.md
|
||||
|
||||
from fastapi import WebSocket
|
||||
from typing import Dict, Set
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""
|
||||
Gestionnaire des connexions WebSocket.
|
||||
|
||||
Maintient un mapping entre les peer_id/device_id et leurs WebSocket.
|
||||
Permet d'envoyer des messages à des peers spécifiques ou à tous les membres d'une room.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Connexions actives: peer_id/device_id -> WebSocket
|
||||
self.active_connections: Dict[str, WebSocket] = {}
|
||||
|
||||
# Mapping peer -> user_id pour l'authentification
|
||||
self.peer_to_user: Dict[str, str] = {}
|
||||
|
||||
# Mapping room_id -> Set[peer_id] pour broadcast dans les rooms
|
||||
self.room_members: Dict[str, Set[str]] = {}
|
||||
|
||||
async def connect(self, peer_id: str, user_id: str, websocket: WebSocket):
|
||||
"""
|
||||
Enregistrer une nouvelle connexion WebSocket.
|
||||
|
||||
Args:
|
||||
peer_id: Identifiant du peer (généré côté serveur)
|
||||
user_id: ID de l'utilisateur authentifié
|
||||
websocket: Connexion WebSocket
|
||||
"""
|
||||
await websocket.accept()
|
||||
self.active_connections[peer_id] = websocket
|
||||
self.peer_to_user[peer_id] = user_id
|
||||
logger.info(f"WebSocket connected: peer_id={peer_id}, user_id={user_id}")
|
||||
|
||||
def disconnect(self, peer_id: str):
|
||||
"""
|
||||
Déconnecter un peer et nettoyer les mappings.
|
||||
|
||||
Args:
|
||||
peer_id: Identifiant du peer à déconnecter
|
||||
"""
|
||||
if peer_id in self.active_connections:
|
||||
del self.active_connections[peer_id]
|
||||
logger.info(f"WebSocket disconnected: peer_id={peer_id}")
|
||||
|
||||
if peer_id in self.peer_to_user:
|
||||
del self.peer_to_user[peer_id]
|
||||
|
||||
# Retirer de toutes les rooms
|
||||
for room_id in list(self.room_members.keys()):
|
||||
if peer_id in self.room_members[room_id]:
|
||||
self.room_members[room_id].remove(peer_id)
|
||||
if not self.room_members[room_id]:
|
||||
del self.room_members[room_id]
|
||||
|
||||
def join_room(self, peer_id: str, room_id: str):
|
||||
"""
|
||||
Ajouter un peer à une room.
|
||||
|
||||
Args:
|
||||
peer_id: Identifiant du peer
|
||||
room_id: ID de la room
|
||||
"""
|
||||
if room_id not in self.room_members:
|
||||
self.room_members[room_id] = set()
|
||||
|
||||
self.room_members[room_id].add(peer_id)
|
||||
logger.info(f"Peer {peer_id} joined room {room_id}")
|
||||
|
||||
def leave_room(self, peer_id: str, room_id: str):
|
||||
"""
|
||||
Retirer un peer d'une room.
|
||||
|
||||
Args:
|
||||
peer_id: Identifiant du peer
|
||||
room_id: ID de la room
|
||||
"""
|
||||
if room_id in self.room_members and peer_id in self.room_members[room_id]:
|
||||
self.room_members[room_id].remove(peer_id)
|
||||
if not self.room_members[room_id]:
|
||||
del self.room_members[room_id]
|
||||
logger.info(f"Peer {peer_id} left room {room_id}")
|
||||
|
||||
async def send_personal_message(self, message: dict, peer_id: str):
|
||||
"""
|
||||
Envoyer un message à un peer spécifique.
|
||||
|
||||
Args:
|
||||
message: Dictionnaire du message à envoyer
|
||||
peer_id: Identifiant du peer destinataire
|
||||
"""
|
||||
if peer_id in self.active_connections:
|
||||
websocket = self.active_connections[peer_id]
|
||||
await websocket.send_json(message)
|
||||
logger.debug(f"Sent message to {peer_id}: {message.get('type')}")
|
||||
|
||||
async def broadcast_to_room(self, message: dict, room_id: str, exclude: str = None):
|
||||
"""
|
||||
Envoyer un message à tous les membres d'une room.
|
||||
|
||||
Args:
|
||||
message: Dictionnaire du message à envoyer
|
||||
room_id: ID de la room
|
||||
exclude: Peer ID à exclure (optionnel, pour ne pas envoyer à l'émetteur)
|
||||
"""
|
||||
if room_id not in self.room_members:
|
||||
return
|
||||
|
||||
for peer_id in self.room_members[room_id]:
|
||||
if exclude and peer_id == exclude:
|
||||
continue
|
||||
|
||||
await self.send_personal_message(message, peer_id)
|
||||
|
||||
logger.debug(f"Broadcasted to room {room_id}: {message.get('type')}")
|
||||
|
||||
async def broadcast_to_all(self, message: dict):
|
||||
"""
|
||||
Envoyer un message à tous les peers connectés.
|
||||
|
||||
Args:
|
||||
message: Dictionnaire du message à envoyer
|
||||
"""
|
||||
for peer_id in list(self.active_connections.keys()):
|
||||
await self.send_personal_message(message, peer_id)
|
||||
|
||||
logger.debug(f"Broadcasted to all: {message.get('type')}")
|
||||
|
||||
def get_room_members(self, room_id: str) -> Set[str]:
|
||||
"""
|
||||
Obtenir la liste des peer_id dans une room.
|
||||
|
||||
Args:
|
||||
room_id: ID de la room
|
||||
|
||||
Returns:
|
||||
Set des peer_id dans la room
|
||||
"""
|
||||
return self.room_members.get(room_id, set()).copy()
|
||||
|
||||
def is_connected(self, peer_id: str) -> bool:
|
||||
"""
|
||||
Vérifier si un peer est connecté.
|
||||
|
||||
Args:
|
||||
peer_id: Identifiant du peer
|
||||
|
||||
Returns:
|
||||
True si le peer est connecté
|
||||
"""
|
||||
return peer_id in self.active_connections
|
||||
|
||||
def get_user_id(self, peer_id: str) -> str:
|
||||
"""
|
||||
Obtenir l'user_id associé à un peer_id.
|
||||
|
||||
Args:
|
||||
peer_id: Identifiant du peer
|
||||
|
||||
Returns:
|
||||
user_id ou None si non trouvé
|
||||
"""
|
||||
return self.peer_to_user.get(peer_id)
|
||||
|
||||
def is_user_in_room(self, user_id: str, room_id: str) -> bool:
|
||||
"""
|
||||
Vérifier si un utilisateur est actuellement actif dans une room.
|
||||
|
||||
Args:
|
||||
user_id: ID de l'utilisateur
|
||||
room_id: ID de la room
|
||||
|
||||
Returns:
|
||||
True si l'utilisateur a au moins un peer connecté dans la room
|
||||
"""
|
||||
if room_id not in self.room_members:
|
||||
return False
|
||||
|
||||
# Parcourir tous les peers de la room
|
||||
for peer_id in self.room_members[room_id]:
|
||||
if self.get_user_id(peer_id) == user_id:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# Instance globale du manager
|
||||
manager = ConnectionManager()
|
||||
285
server/test_api.py
Executable file
285
server/test_api.py
Executable file
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env python3
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: Script de test rapide pour l'API Mesh
|
||||
# Refs: server/CLAUDE.md
|
||||
|
||||
"""
|
||||
Script de test pour vérifier le fonctionnement de base de l'API Mesh.
|
||||
|
||||
Usage:
|
||||
python test_api.py
|
||||
|
||||
Le serveur doit être lancé avant d'exécuter ce script.
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
# Configuration
|
||||
BASE_URL = "http://localhost:8000"
|
||||
|
||||
|
||||
class Colors:
|
||||
"""Codes ANSI pour les couleurs."""
|
||||
GREEN = '\033[92m'
|
||||
RED = '\033[91m'
|
||||
YELLOW = '\033[93m'
|
||||
BLUE = '\033[94m'
|
||||
END = '\033[0m'
|
||||
|
||||
|
||||
def print_success(message: str):
|
||||
"""Afficher un message de succès."""
|
||||
print(f"{Colors.GREEN}✓ {message}{Colors.END}")
|
||||
|
||||
|
||||
def print_error(message: str):
|
||||
"""Afficher un message d'erreur."""
|
||||
print(f"{Colors.RED}✗ {message}{Colors.END}")
|
||||
|
||||
|
||||
def print_info(message: str):
|
||||
"""Afficher un message d'information."""
|
||||
print(f"{Colors.BLUE}ℹ {message}{Colors.END}")
|
||||
|
||||
|
||||
def print_section(title: str):
|
||||
"""Afficher un titre de section."""
|
||||
print(f"\n{Colors.YELLOW}{'='*60}{Colors.END}")
|
||||
print(f"{Colors.YELLOW}{title}{Colors.END}")
|
||||
print(f"{Colors.YELLOW}{'='*60}{Colors.END}\n")
|
||||
|
||||
|
||||
def test_health():
|
||||
"""Tester le endpoint de santé."""
|
||||
print_section("Test Health Check")
|
||||
|
||||
try:
|
||||
response = requests.get(f"{BASE_URL}/health")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("status") == "healthy":
|
||||
print_success("Health check passed")
|
||||
return True
|
||||
else:
|
||||
print_error(f"Health check failed: {data}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print_error(f"Health check error: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def test_register(username: str, password: str) -> Optional[str]:
|
||||
"""Tester l'enregistrement d'un utilisateur."""
|
||||
print_section(f"Test Register - {username}")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"password": password,
|
||||
"email": f"{username}@example.com"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 201:
|
||||
data = response.json()
|
||||
print_success(f"User registered: {data['username']}")
|
||||
print_info(f"User ID: {data['user_id']}")
|
||||
print_info(f"Token: {data['access_token'][:20]}...")
|
||||
return data['access_token']
|
||||
elif response.status_code == 400:
|
||||
print_info("User already exists (expected if running multiple times)")
|
||||
# Essayer de se connecter à la place
|
||||
return test_login(username, password)
|
||||
else:
|
||||
print_error(f"Registration failed: {response.status_code} - {response.text}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Registration error: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
def test_login(username: str, password: str) -> Optional[str]:
|
||||
"""Tester la connexion d'un utilisateur."""
|
||||
print_section(f"Test Login - {username}")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/api/auth/login",
|
||||
json={
|
||||
"username": username,
|
||||
"password": password
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
print_success(f"User logged in: {data['username']}")
|
||||
print_info(f"Token: {data['access_token'][:20]}...")
|
||||
return data['access_token']
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Login error: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
def test_get_me(token: str):
|
||||
"""Tester la récupération des infos utilisateur."""
|
||||
print_section("Test Get User Info")
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{BASE_URL}/api/auth/me",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
print_success("User info retrieved")
|
||||
print_info(f"Username: {data['username']}")
|
||||
print_info(f"User ID: {data['user_id']}")
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Get user info error: {str(e)}")
|
||||
|
||||
|
||||
def test_create_room(token: str, room_name: str) -> Optional[str]:
|
||||
"""Tester la création d'une room."""
|
||||
print_section(f"Test Create Room - {room_name}")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/api/rooms/",
|
||||
json={"name": room_name},
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
print_success(f"Room created: {data['name']}")
|
||||
print_info(f"Room ID: {data['room_id']}")
|
||||
return data['room_id']
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Create room error: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
def test_list_rooms(token: str):
|
||||
"""Tester la liste des rooms."""
|
||||
print_section("Test List Rooms")
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{BASE_URL}/api/rooms/",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
print_success(f"Found {len(data)} room(s)")
|
||||
for room in data:
|
||||
print_info(f" - {room['name']} ({room['room_id']})")
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"List rooms error: {str(e)}")
|
||||
|
||||
|
||||
def test_get_room(token: str, room_id: str):
|
||||
"""Tester la récupération d'une room."""
|
||||
print_section("Test Get Room Details")
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{BASE_URL}/api/rooms/{room_id}",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
print_success("Room details retrieved")
|
||||
print_info(f"Name: {data['name']}")
|
||||
print_info(f"Members: {data['member_count']}")
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Get room error: {str(e)}")
|
||||
|
||||
|
||||
def test_request_capability(token: str, room_id: str):
|
||||
"""Tester la demande de capability token."""
|
||||
print_section("Test Request Capability Token")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/api/auth/capability",
|
||||
json={
|
||||
"room_id": room_id,
|
||||
"capabilities": ["call", "share:file"]
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
print_success("Capability token obtained")
|
||||
print_info(f"Token: {data['cap_token'][:20]}...")
|
||||
print_info(f"Expires in: {data['expires_in']}s")
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Request capability error: {str(e)}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Fonction principale du test."""
|
||||
print(f"{Colors.BLUE}")
|
||||
print("╔════════════════════════════════════════════════════════════╗")
|
||||
print("║ MESH SERVER API TEST SUITE ║")
|
||||
print("╚════════════════════════════════════════════════════════════╝")
|
||||
print(f"{Colors.END}")
|
||||
|
||||
# Test de santé
|
||||
if not test_health():
|
||||
print_error("\n❌ Le serveur ne répond pas. Assurez-vous qu'il est démarré.")
|
||||
return
|
||||
|
||||
# Enregistrer deux utilisateurs
|
||||
user1_token = test_register("alice", "password123")
|
||||
user2_token = test_register("bob", "password456")
|
||||
|
||||
if not user1_token or not user2_token:
|
||||
print_error("\n❌ Impossible de créer les utilisateurs de test")
|
||||
return
|
||||
|
||||
# Tester les infos utilisateur
|
||||
test_get_me(user1_token)
|
||||
|
||||
# Créer une room
|
||||
room_id = test_create_room(user1_token, "Test Room")
|
||||
|
||||
if room_id:
|
||||
# Lister les rooms
|
||||
test_list_rooms(user1_token)
|
||||
|
||||
# Détails de la room
|
||||
test_get_room(user1_token, room_id)
|
||||
|
||||
# Demander un capability token
|
||||
test_request_capability(user1_token, room_id)
|
||||
|
||||
print(f"\n{Colors.GREEN}")
|
||||
print("╔════════════════════════════════════════════════════════════╗")
|
||||
print("║ ✓ TESTS TERMINÉS ║")
|
||||
print("╚════════════════════════════════════════════════════════════╝")
|
||||
print(f"{Colors.END}")
|
||||
print_info("Pour tester le WebSocket, utilisez le client web ou un outil comme wscat")
|
||||
print_info("Exemple: wscat -c 'ws://localhost:8000/ws?token=YOUR_TOKEN'")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
208
server/test_gotify.py
Normal file
208
server/test_gotify.py
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-03
|
||||
# Purpose: Script de test pour les notifications Gotify
|
||||
# Refs: server/CLAUDE.md
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
from datetime import datetime
|
||||
|
||||
# Configuration
|
||||
API_URL = "http://localhost:8000"
|
||||
GOTIFY_URL = "http://10.0.0.5:8185"
|
||||
GOTIFY_TOKEN = "AvKcy9o-yvVhyKd"
|
||||
|
||||
# Utilisateurs de test
|
||||
USER1 = {
|
||||
"email": "alice@test.com",
|
||||
"username": "alice",
|
||||
"password": "password123"
|
||||
}
|
||||
|
||||
USER2 = {
|
||||
"email": "bob@test.com",
|
||||
"username": "bob",
|
||||
"password": "password123"
|
||||
}
|
||||
|
||||
|
||||
async def test_gotify_direct():
|
||||
"""Tester l'envoi direct à Gotify."""
|
||||
print("\n" + "="*60)
|
||||
print("TEST 1: Envoi direct à Gotify")
|
||||
print("="*60)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
payload = {
|
||||
"title": "🧪 Test Mesh",
|
||||
"message": "Ceci est un test de notification depuis Mesh",
|
||||
"priority": 5,
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
f"{GOTIFY_URL}/message",
|
||||
params={"token": GOTIFY_TOKEN},
|
||||
json=payload,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✅ Notification envoyée avec succès à Gotify")
|
||||
print(f" Response: {response.json()}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Erreur: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def register_user(user_data):
|
||||
"""Créer un utilisateur de test."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{API_URL}/api/auth/register",
|
||||
json=user_data
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f"✅ Utilisateur {user_data['username']} créé")
|
||||
return data['access_token']
|
||||
elif response.status_code == 400:
|
||||
# Utilisateur existe déjà, essayer de login
|
||||
response = await client.post(
|
||||
f"{API_URL}/api/auth/login",
|
||||
data={
|
||||
"username": user_data["email"],
|
||||
"password": user_data["password"]
|
||||
}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f"ℹ️ Utilisateur {user_data['username']} existe déjà (login)")
|
||||
return data['access_token']
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Erreur création/login {user_data['username']}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def create_room(token, room_name):
|
||||
"""Créer une room de test."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{API_URL}/api/rooms",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"name": room_name}
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
print(f"✅ Room '{room_name}' créée: {data['room_id']}")
|
||||
return data['room_id']
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Erreur création room: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def send_message(token, room_id, message):
|
||||
"""Envoyer un message dans une room (via API REST)."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{API_URL}/api/rooms/{room_id}/messages",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"content": message}
|
||||
)
|
||||
|
||||
if response.status_code == 404:
|
||||
print(f"⚠️ Endpoint POST /api/rooms/{room_id}/messages n'existe pas")
|
||||
print(f" (Normal: envoi de messages via WebSocket uniquement)")
|
||||
return False
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✅ Message envoyé: {message[:50]}...")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Pas d'API REST pour messages: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_chat_notification():
|
||||
"""Tester notification de chat (sans WebSocket)."""
|
||||
print("\n" + "="*60)
|
||||
print("TEST 2: Notification de chat (setup)")
|
||||
print("="*60)
|
||||
|
||||
# Créer/login utilisateurs
|
||||
alice_token = await register_user(USER1)
|
||||
bob_token = await register_user(USER2)
|
||||
|
||||
if not alice_token or not bob_token:
|
||||
print("❌ Impossible de créer les utilisateurs")
|
||||
return False
|
||||
|
||||
# Créer une room
|
||||
room_id = await create_room(alice_token, "Test Gotify Chat")
|
||||
|
||||
if not room_id:
|
||||
print("❌ Impossible de créer la room")
|
||||
return False
|
||||
|
||||
print("\n📝 NOTE:")
|
||||
print(" Les notifications de chat nécessitent une connexion WebSocket.")
|
||||
print(" Pour tester complètement:")
|
||||
print(" 1. Bob doit se déconnecter de la room")
|
||||
print(" 2. Alice envoie un message via WebSocket")
|
||||
print(" 3. Bob devrait recevoir une notification Gotify")
|
||||
print("")
|
||||
print(" Utilisez le client web pour tester end-to-end.")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def main():
|
||||
"""Exécuter tous les tests."""
|
||||
print("\n" + "="*60)
|
||||
print("🧪 TESTS NOTIFICATIONS GOTIFY - MESH")
|
||||
print("="*60)
|
||||
print(f"API URL: {API_URL}")
|
||||
print(f"Gotify URL: {GOTIFY_URL}")
|
||||
print(f"Timestamp: {datetime.now().isoformat()}")
|
||||
|
||||
# Test 1: Envoi direct
|
||||
success1 = await test_gotify_direct()
|
||||
|
||||
# Test 2: Setup pour chat
|
||||
success2 = await test_chat_notification()
|
||||
|
||||
# Résumé
|
||||
print("\n" + "="*60)
|
||||
print("RÉSUMÉ DES TESTS")
|
||||
print("="*60)
|
||||
print(f"Test 1 - Envoi direct Gotify: {'✅ PASS' if success1 else '❌ FAIL'}")
|
||||
print(f"Test 2 - Setup chat: {'✅ PASS' if success2 else '❌ FAIL'}")
|
||||
print("")
|
||||
|
||||
if success1:
|
||||
print("✅ Gotify est correctement configuré et accessible")
|
||||
print("✅ Les notifications peuvent être envoyées")
|
||||
print("")
|
||||
print("📱 Vérifiez votre application Gotify pour voir la notification")
|
||||
else:
|
||||
print("❌ Problème de configuration Gotify")
|
||||
print(" Vérifiez:")
|
||||
print(" - GOTIFY_URL est correct dans .env")
|
||||
print(" - GOTIFY_TOKEN est valide")
|
||||
print(" - Le serveur Gotify est accessible")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
247
server/test_p2p_api.py
Executable file
247
server/test_p2p_api.py
Executable file
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
# Created by: Claude
|
||||
# Date: 2026-01-02
|
||||
# Purpose: Script de test pour les endpoints P2P
|
||||
# Refs: server/CLAUDE.md
|
||||
|
||||
"""
|
||||
Script de test pour vérifier les endpoints P2P du serveur Mesh.
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
# Configuration
|
||||
BASE_URL = "http://localhost:8000"
|
||||
|
||||
|
||||
class Colors:
|
||||
"""Codes ANSI pour les couleurs."""
|
||||
GREEN = '\033[92m'
|
||||
RED = '\033[91m'
|
||||
YELLOW = '\033[93m'
|
||||
BLUE = '\033[94m'
|
||||
END = '\033[0m'
|
||||
|
||||
|
||||
def print_success(message: str):
|
||||
"""Afficher un message de succès."""
|
||||
print(f"{Colors.GREEN}✓ {message}{Colors.END}")
|
||||
|
||||
|
||||
def print_error(message: str):
|
||||
"""Afficher un message d'erreur."""
|
||||
print(f"{Colors.RED}✗ {message}{Colors.END}")
|
||||
|
||||
|
||||
def print_info(message: str):
|
||||
"""Afficher un message d'information."""
|
||||
print(f"{Colors.BLUE}ℹ {message}{Colors.END}")
|
||||
|
||||
|
||||
def print_section(title: str):
|
||||
"""Afficher un titre de section."""
|
||||
print(f"\n{Colors.YELLOW}{'='*60}{Colors.END}")
|
||||
print(f"{Colors.YELLOW}{title}{Colors.END}")
|
||||
print(f"{Colors.YELLOW}{'='*60}{Colors.END}\n")
|
||||
|
||||
|
||||
def register_user(username: str, password: str) -> Optional[str]:
|
||||
"""Enregistrer un utilisateur et retourner le token."""
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"password": password,
|
||||
"email": f"{username}@example.com"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 201:
|
||||
return response.json()['access_token']
|
||||
elif response.status_code == 400:
|
||||
# User exists, login instead
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/api/auth/login",
|
||||
json={"username": username, "password": password}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()['access_token']
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Registration error: {str(e)}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def create_room(token: str, room_name: str) -> Optional[str]:
|
||||
"""Créer une room et retourner le room_id."""
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/api/rooms/",
|
||||
json={"name": room_name},
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()['room_id']
|
||||
except Exception as e:
|
||||
print_error(f"Create room error: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
def test_create_p2p_session(token: str, room_id: str):
|
||||
"""Tester la création d'une session P2P."""
|
||||
print_section("Test Create P2P Session")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/api/p2p/session",
|
||||
json={
|
||||
"room_id": room_id,
|
||||
"target_peer_id": "peer_target_123",
|
||||
"kind": "file",
|
||||
"capabilities": ["share:file"]
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
print_success("P2P session created")
|
||||
print_info(f"Session ID: {data['session_id']}")
|
||||
print_info(f"Session Token: {data['session_token'][:30]}...")
|
||||
print_info(f"Kind: {data['kind']}")
|
||||
print_info(f"Expires at: {data['expires_at']}")
|
||||
|
||||
return data['session_id']
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Create P2P session error: {str(e)}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
print_error(f"Response: {e.response.text}")
|
||||
return None
|
||||
|
||||
|
||||
def test_list_p2p_sessions(token: str):
|
||||
"""Tester la liste des sessions P2P."""
|
||||
print_section("Test List P2P Sessions")
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{BASE_URL}/api/p2p/sessions",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
sessions = data.get('sessions', [])
|
||||
print_success(f"Found {len(sessions)} active session(s)")
|
||||
|
||||
for session in sessions:
|
||||
print_info(f" - {session['session_id']} ({session['kind']})")
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"List P2P sessions error: {str(e)}")
|
||||
|
||||
|
||||
def test_close_p2p_session(token: str, session_id: str):
|
||||
"""Tester la fermeture d'une session P2P."""
|
||||
print_section("Test Close P2P Session")
|
||||
|
||||
try:
|
||||
response = requests.delete(
|
||||
f"{BASE_URL}/api/p2p/session/{session_id}",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
print_success(data['message'])
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Close P2P session error: {str(e)}")
|
||||
|
||||
|
||||
def test_invalid_kind(token: str, room_id: str):
|
||||
"""Tester avec un kind invalide."""
|
||||
print_section("Test Invalid Session Kind")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/api/p2p/session",
|
||||
json={
|
||||
"room_id": room_id,
|
||||
"target_peer_id": "peer_target_123",
|
||||
"kind": "invalid_kind",
|
||||
"capabilities": ["share:file"]
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
if response.status_code == 400:
|
||||
print_success("Invalid kind correctly rejected")
|
||||
print_info(f"Error: {response.json()['detail']}")
|
||||
else:
|
||||
print_error(f"Expected 400, got {response.status_code}")
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Invalid kind test error: {str(e)}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Fonction principale du test."""
|
||||
print(f"{Colors.BLUE}")
|
||||
print("╔════════════════════════════════════════════════════════════╗")
|
||||
print("║ MESH P2P API TEST SUITE ║")
|
||||
print("╚════════════════════════════════════════════════════════════╝")
|
||||
print(f"{Colors.END}")
|
||||
|
||||
# Enregistrer un utilisateur
|
||||
print_section("Setup: Register User & Create Room")
|
||||
token = register_user("alice_p2p", "password123")
|
||||
|
||||
if not token:
|
||||
print_error("\n❌ Impossible de créer l'utilisateur de test")
|
||||
return
|
||||
|
||||
print_success("User registered/logged in")
|
||||
|
||||
# Créer une room
|
||||
room_id = create_room(token, "P2P Test Room")
|
||||
|
||||
if not room_id:
|
||||
print_error("\n❌ Impossible de créer la room de test")
|
||||
return
|
||||
|
||||
print_success(f"Room created: {room_id}")
|
||||
|
||||
# Tester la création de session P2P
|
||||
session_id = test_create_p2p_session(token, room_id)
|
||||
|
||||
if session_id:
|
||||
# Lister les sessions actives
|
||||
test_list_p2p_sessions(token)
|
||||
|
||||
# Fermer la session
|
||||
test_close_p2p_session(token, session_id)
|
||||
|
||||
# Vérifier que la session a été fermée
|
||||
test_list_p2p_sessions(token)
|
||||
|
||||
# Tester avec un kind invalide
|
||||
test_invalid_kind(token, room_id)
|
||||
|
||||
print(f"\n{Colors.GREEN}")
|
||||
print("╔════════════════════════════════════════════════════════════╗")
|
||||
print("║ ✓ TESTS P2P TERMINÉS ║")
|
||||
print("╚════════════════════════════════════════════════════════════╝")
|
||||
print(f"{Colors.END}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user