Major changes across backend, frontend, infrastructure: - Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud) - Admin panel: user management, pricing, settings - Glossary system with CSV import/export - Subscription and tier quota management - Security hardening (rate limiting, API key auth, path traversal fixes) - Docker compose for dev, prod, and IONOS deployment - Alembic migrations for new tables - Frontend: dashboard, pricing page, landing page, i18n (en/fr) - Test suite and verification scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
24 KiB
Story 3.7: Webhook - Spécification URL
Status: ready-for-dev
Story
En tant qu'utilisateur, Je veux pouvoir spécifier une URL de webhook dans ma requête de traduction, de sorte que je puisse être notifié automatiquement quand la traduction est terminée.
Acceptance Criteria
- Paramètre webhook_url accepté: POST
/api/v1/translateaccepte un paramètrewebhook_urloptionnel. (FR36, FR65) - Validation format URL: Si
webhook_urlest fourni, il doit être une URL HTTP/HTTPS valide. - Erreur INVALID_WEBHOOK_URL: Si le format est invalide, retourne 400 avec error code
INVALID_WEBHOOK_URL. - Stockage avec le job:
webhook_urlest stocké avec le job de traduction pour utilisation ultérieure. - Paramètre optionnel:
webhook_urlest optionnel - la traduction fonctionne sans webhook. (FR65) - Documentation OpenAPI: Le paramètre
webhook_urlest documenté avec description et exemple.
Tasks / Subtasks
-
Task 1: Améliorer la validation du webhook_url (AC: #2, #3)
- 1.1 Créer une fonction
validate_webhook_url()dédiée dansmiddleware/validation.py - 1.2 Valider le format URL (http/https, domaine valide, pas d'IP privée)
- 1.3 Retourner l'erreur
INVALID_WEBHOOK_URLavec détails appropriés - 1.4 Ajouter des tests unitaires pour la validation
- 1.1 Créer une fonction
-
Task 2: Mettre à jour le endpoint translate (AC: #1, #4)
- 2.1 Vérifier que
webhook_urlest déjà accepté comme paramètre Form - 2.2 Intégrer la nouvelle validation dans le flux du endpoint
- 2.3 S'assurer que
webhook_urlest stocké dans_translation_jobs[job_id] - 2.4 Vérifier que le code existant de notification webhook (Story 3.8) utilise bien cette valeur
- 2.1 Vérifier que
-
Task 3: Ajouter le code d'erreur INVALID_WEBHOOK_URL (AC: #3)
- 3.1 Vérifier que
INVALID_WEBHOOK_URLexiste déjà dansschemas/errors.py - 3.2 Ajouter un exemple d'erreur dans
ERROR_EXAMPLESsi manquant - 3.3 Documenter le code d'erreur dans la docstring du endpoint
- 3.1 Vérifier que
-
Task 4: Documenter le paramètre dans OpenAPI (AC: #6)
- 4.1 Ajouter une description complète au paramètre
webhook_urldans le Form - 4.2 Ajouter un exemple de requête avec webhook_url dans la documentation
- 4.3 Documenter le comportement Fire & Forget (Story 3.8)
- 4.1 Ajouter une description complète au paramètre
-
Task 5: Tests d'intégration (AC: Tous)
- 5.1 Tester une requête avec webhook_url valide
- 5.2 Tester une requête avec webhook_url invalide (mauvais format)
- 5.3 Tester une requête avec webhook_url invalide (URL non HTTP/HTTPS)
- 5.4 Tester une requête sans webhook_url (devrait réussir)
- 5.5 Vérifier que le webhook_url est bien stocké dans le job
Dev Notes
État Actuel de l'Implémentation
CE QUI EXISTE DÉJÀ dans routes/translate_routes.py:
# Ligne ~350 - Paramètre déjà accepté
webhook_url: Optional[str] = Form(None, description="Webhook URL for notification"),
# Ligne ~400 - Validation basique existante
if webhook_url:
if not re.match(r"^https?://", webhook_url, re.IGNORECASE):
raise TranslateEndpointError(
code="INVALID_FORMAT", # ⚠️ Devrait être INVALID_WEBHOOK_URL
message="URL webhook invalide. Doit commencer par http:// ou https://",
details={"field": "webhook_url"},
)
# Ligne ~470 - Stockage dans le job
"webhook_url": webhook_url,
# Ligne ~560-575 - Notification webhook existante (Story 3.8)
if webhook_url:
try:
async with httpx.AsyncClient(timeout=10) as client:
await client.post(
webhook_url,
json={
"translation_id": job_id,
"status": job["status"],
"timestamp": datetime.now(timezone.utc).isoformat(),
"file_name": job.get("file_name"),
"error_message": job.get("error_message"),
},
)
except Exception as e:
logger.warning(f"Job {job_id}: Webhook notification failed - {e}")
CE QUI MANQUE:
- ❌ Code d'erreur
INVALID_WEBHOOK_URLau lieu deINVALID_FORMAT - ❌ Validation plus robuste (domaine valide, pas d'IP privée pour sécurité)
- ❌ Documentation OpenAPI complète pour le paramètre
- ❌ Tests unitaires dédiés à la validation webhook
Architecture Patterns
Validation Pattern (depuis middleware/validation.py):
class ValidationError(Exception):
"""Exception for validation errors with structured error codes."""
def __init__(self, code: str, message: str, details: Optional[dict] = None):
self.code = code
self.message = message
self.details = details or {}
class WebhookURLValidator:
"""Validator for webhook URLs."""
def __init__(self, allowed_schemes: tuple = ("http", "https")):
self.allowed_schemes = allowed_schemes
def validate(self, url: str) -> ValidationResult:
"""Validate webhook URL format and security."""
# Implementation
pass
Error Response Format (depuis schemas/errors.py):
class ErrorCode(str, Enum):
# ...
INVALID_WEBHOOK_URL = "INVALID_WEBHOOK_URL"
# ...
ERROR_EXAMPLES = {
# ...
"INVALID_WEBHOOK_URL": {
"summary": "URL webhook invalide",
"value": {
"error": "INVALID_WEBHOOK_URL",
"message": "L'URL du webhook doit être une URL HTTP/HTTPS valide.",
"details": {
"field": "webhook_url",
"hint": "L'URL doit commencer par http:// ou https://"
}
}
}
}
Sécurité - Validation Webhook URL
Risques de sécurité à considérer:
-
SSRF (Server-Side Request Forgery):
- Ne pas permettre les URLs vers des IPs privées (10.x, 172.16-31.x, 192.168.x)
- Ne pas permettre localhost (127.0.0.1, ::1)
- Ne pas permettre les URLs avec credentials intégrés
-
Validation recommandée:
import ipaddress
from urllib.parse import urlparse
def validate_webhook_url(url: str) -> tuple[bool, Optional[str]]:
"""
Validate webhook URL for security.
Returns: (is_valid, error_message)
"""
try:
parsed = urlparse(url)
# Check scheme
if parsed.scheme not in ("http", "https"):
return False, "L'URL doit utiliser http:// ou https://"
# Check for credentials in URL
if parsed.username or parsed.password:
return False, "L'URL ne doit pas contenir de credentials"
# Resolve hostname and check for private IPs
import socket
hostname = parsed.hostname
if hostname:
# Block localhost
if hostname.lower() in ("localhost", "127.0.0.1", "::1"):
return False, "Les URLs localhost ne sont pas autorisées"
# Resolve and check IP
try:
ip_str = socket.gethostbyname(hostname)
ip = ipaddress.ip_address(ip_str)
if ip.is_private or ip.is_loopback or ip.is_link_local:
return False, "Les adresses IP privées ne sont pas autorisées"
except socket.gaierror:
pass # DNS resolution failed, let it through (will fail at webhook time)
return True, None
except Exception as e:
return False, f"Format d'URL invalide: {str(e)}"
Project Structure Notes
- Fichier principal:
routes/translate_routes.py(modifier validation existante) - Validation:
middleware/validation.py(ajouter WebhookURLValidator) - Erreurs:
schemas/errors.py(vérifier INVALID_WEBHOOK_URL) - Tests:
tests/test_webhook_validation.py(nouveau fichier)
Références
- [Source: _bmad-output/planning-artifacts/epics.md#Story 3.7]
- [Source: _bmad-output/planning-artifacts/architecture.md#API & Communication Patterns]
- [Source: _bmad-output/planning-artifacts/architecture.md#Error Handling]
- [Source: FR36, FR65, FR66 - Webhook requirements]
- [Source: routes/translate_routes.py - Implémentation actuelle]
Intelligence des Stories Précédentes (Epic 3)
Story 3.1 (API Key Generation) - Enseignements
- Routeur api_key_routes.py utilise
prefix="/api/v1/api-keys"✅ - Pattern de réponse
{data: {...}, meta: {...}}établi - Tests complets avec fixtures dans
tests/conftest.py
Story 3.2 (API Key Revocation User) - Enseignements
- Soft delete avec
is_active=False - Fonction
get_user_by_api_keydansservices/auth_service.py - Format d'erreur structuré
{error, message, details?}
Story 3.3 (Admin API Key Revocation) - Enseignements
- Routes admin dans main.py - à migrer vers
routes/admin_routes.py - Dépendance
require_adminpour l'authentification admin - Audit logging avec
logger.info()
Story 3.4 (API Auth X-API-Key) - Enseignements
- Coexistence JWT + API Key dans
get_authenticated_user - Middleware d'auth dans
routes/translate_routes.py - Priorité API key sur JWT si les deux présents
Story 3.5 (API Versioning) - Enseignements
- Tous les endpoints sont maintenant sous
/api/v1/✅ - Exceptions documentées:
/health,/ready,/docs,/redoc - Routeur principal
routes/api_v1_router.py
Story 3.6 (Documentation OpenAPI) - Enseignements
- Schémas Pydantic dans
schemas/pour documentation ✅ - Codes d'erreur documentés dans
schemas/errors.pyavecERROR_EXAMPLES✅ INVALID_WEBHOOK_URLexiste déjà dansErrorCodeenum ✅- Descriptions complètes dans les docstrings des endpoints
Contexte Métier
Epic 3: API & Automation (Pro)
Cette story est la septième de l'Epic 3 qui permet aux utilisateurs Pro d'automatiser les traductions:
API Keys - Génération(Story 3.1 ✅)API Keys - Révocation User(Story 3.2 ✅)API Keys - Révocation Admin(Story 3.3 ✅)Authentification X-API-Key(Story 3.4 ✅)API Versioning(Story 3.5 ✅)Documentation OpenAPI(Story 3.6 ✅)- Webhook - Spécification URL (cette story)
- Webhook - Envoi POST Fire & Forget (Story 3.8 - backlog)
- Glossaires (Stories 3.9-3.10 - backlog)
- Custom Prompts (Stories 3.11-3.12 - backlog)
Valeur Business
Le paramètre webhook_url est critique pour:
- Automation: Thomas (Pro user) peut enchaîner les traductions dans ses workflows n8n
- Intégration CI/CD: Notification automatique quand une traduction termine
- Architecture événementielle: Base pour les futures fonctionnalités temps réel
- Expérience Pro: Justification de l'abonnement Pro
Dépendances
- Stories 3.1-3.6 (prérequis): API versionnée, authentifiée, documentée ✅
- Story 3.8 (suivante): Envoi du webhook - utilisera le webhook_url stocké
- Stories futures: Glossaires et Custom Prompts pourront aussi utiliser les webhooks
Guardrails Développeur
❌ À NE PAS FAIRE
- NE PAS utiliser le code d'erreur
INVALID_FORMATpour les webhooks - utiliserINVALID_WEBHOOK_URL - NE PAS permettre les URLs vers des IPs privées (risque SSRF)
- NE PAS permettre les URLs avec credentials intégrés (
http://user:pass@...) - NE PAS bloquer la traduction si le webhook échoue (Fire & Forget)
- NE PAS stocker le webhook_url en base de données - seulement en mémoire avec le job
- NE PAS oublier de documenter le paramètre dans OpenAPI
✅ À FAIRE
- TOUJOURS valider le format URL (http/https, domaine valide)
- TOUJOURS utiliser le code d'erreur
INVALID_WEBHOOK_URLpour les erreurs de validation - TOUJOURS rendre le paramètre optionnel (la traduction fonctionne sans webhook)
- TOUJOURS stocker le webhook_url avec le job pour la Story 3.8
- CRÉER une fonction de validation dédiée dans
middleware/validation.py - AJOUTER des tests unitaires pour la validation webhook
- DOCUMENTER le paramètre avec description et exemple dans OpenAPI
Code Suggéré
Fichier middleware/validation.py - Ajouter WebhookURLValidator
import re
import ipaddress
import socket
from urllib.parse import urlparse
from typing import Optional, Tuple
class WebhookURLValidator:
"""
Validator for webhook URLs with security checks.
Prevents SSRF attacks by blocking private IPs and localhost.
"""
# Allowed URL schemes
ALLOWED_SCHEMES = ("http", "https")
# Blocked hostnames
BLOCKED_HOSTNAMES = {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
def __init__(
self,
allowed_schemes: Tuple[str, ...] = ALLOWED_SCHEMES,
block_private_ips: bool = True
):
self.allowed_schemes = allowed_schemes
self.block_private_ips = block_private_ips
def validate(self, url: str) -> Tuple[bool, Optional[str], Optional[dict]]:
"""
Validate webhook URL format and security.
Args:
url: The webhook URL to validate
Returns:
Tuple of (is_valid, error_message, details)
"""
if not url:
return True, None, None
try:
parsed = urlparse(url)
# Check scheme
if parsed.scheme.lower() not in self.allowed_schemes:
return False, (
f"L'URL doit utiliser {' ou '.join(self.allowed_schemes)}",
{
"field": "webhook_url",
"allowed_schemes": list(self.allowed_schemes),
"detected_scheme": parsed.scheme or "none"
}
)
# Check for credentials in URL
if parsed.username or parsed.password:
return False, (
"L'URL ne doit pas contenir de credentials",
{"field": "webhook_url", "reason": "credentials_in_url"}
)
# Check hostname
hostname = parsed.hostname
if not hostname:
return False, (
"URL invalide: hostname manquant",
{"field": "webhook_url", "reason": "missing_hostname"}
)
# Block localhost and common local addresses
if hostname.lower() in self.BLOCKED_HOSTNAMES:
return False, (
"Les URLs localhost ne sont pas autorisées",
{"field": "webhook_url", "reason": "localhost_blocked"}
)
# Check for private IPs (SSRF protection)
if self.block_private_ips:
try:
# Try to parse as IP directly
try:
ip = ipaddress.ip_address(hostname)
if self._is_blocked_ip(ip):
return False, (
"Les adresses IP privées ne sont pas autorisées",
{"field": "webhook_url", "reason": "private_ip_blocked"}
)
except ValueError:
# Not an IP, try DNS resolution
ip_str = socket.gethostbyname(hostname)
ip = ipaddress.ip_address(ip_str)
if self._is_blocked_ip(ip):
return False, (
"Les adresses IP privées ne sont pas autorisées",
{"field": "webhook_url", "reason": "private_ip_blocked"}
)
except socket.gaierror:
# DNS resolution failed - let it through
# Will fail at webhook send time
pass
except Exception:
pass
return True, None, None
except Exception as e:
return False, (
f"Format d'URL invalide: {str(e)}",
{"field": "webhook_url", "error": str(e)}
)
def _is_blocked_ip(self, ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
"""Check if IP is private, loopback, or link-local."""
return (
ip.is_private or
ip.is_loopback or
ip.is_link_local or
ip.is_reserved or
ip.is_multicast
)
# Singleton instance
webhook_validator = WebhookURLValidator()
Modification de routes/translate_routes.py
# Ajouter l'import
from middleware.validation import webhook_validator
# Remplacer la validation existante (ligne ~400)
# AVANT:
if webhook_url:
if not re.match(r"^https?://", webhook_url, re.IGNORECASE):
raise TranslateEndpointError(
code="INVALID_FORMAT",
message="URL webhook invalide. Doit commencer par http:// ou https://",
details={"field": "webhook_url"},
)
# APRÈS:
if webhook_url:
is_valid, error_msg, error_details = webhook_validator.validate(webhook_url)
if not is_valid:
raise TranslateEndpointError(
code="INVALID_WEBHOOK_URL",
message=error_msg,
details=error_details,
)
Ajout dans schemas/errors.py - ERROR_EXAMPLES
ERROR_EXAMPLES = {
# ... existing examples ...
"INVALID_WEBHOOK_URL": {
"summary": "URL webhook invalide",
"value": {
"error": "INVALID_WEBHOOK_URL",
"message": "L'URL du webhook doit être une URL HTTP/HTTPS valide.",
"details": {
"field": "webhook_url",
"allowed_schemes": ["http", "https"],
"hint": "L'URL doit commencer par http:// ou https://"
}
}
},
"WEBHOOK_PRIVATE_IP": {
"summary": "IP privée non autorisée",
"value": {
"error": "INVALID_WEBHOOK_URL",
"message": "Les adresses IP privées ne sont pas autorisées.",
"details": {
"field": "webhook_url",
"reason": "private_ip_blocked"
}
}
},
"WEBHOOK_LOCALHOST": {
"summary": "Localhost non autorisé",
"value": {
"error": "INVALID_WEBHOOK_URL",
"message": "Les URLs localhost ne sont pas autorisées.",
"details": {
"field": "webhook_url",
"reason": "localhost_blocked"
}
}
}
}
Fichier de tests tests/test_webhook_validation.py
"""
Tests for webhook URL validation.
Story 3.7: Webhook - Spécification URL
"""
import pytest
from middleware.validation import webhook_validator, WebhookURLValidator
class TestWebhookURLValidator:
"""Tests for WebhookURLValidator class."""
def test_valid_https_url(self):
"""Valid HTTPS URL should pass."""
url = "https://example.com/webhook"
is_valid, error, details = webhook_validator.validate(url)
assert is_valid is True
assert error is None
assert details is None
def test_valid_http_url(self):
"""Valid HTTP URL should pass."""
url = "http://example.com/webhook"
is_valid, error, details = webhook_validator.validate(url)
assert is_valid is True
assert error is None
def test_invalid_scheme_ftp(self):
"""FTP URL should be rejected."""
url = "ftp://example.com/webhook"
is_valid, error, details = webhook_validator.validate(url)
assert is_valid is False
assert "http" in error.lower()
def test_invalid_scheme_no_scheme(self):
"""URL without scheme should be rejected."""
url = "example.com/webhook"
is_valid, error, details = webhook_validator.validate(url)
assert is_valid is False
def test_localhost_blocked(self):
"""Localhost should be blocked."""
urls = [
"http://localhost/webhook",
"http://127.0.0.1/webhook",
"http://[::1]/webhook",
"http://0.0.0.0/webhook",
]
for url in urls:
is_valid, error, details = webhook_validator.validate(url)
assert is_valid is False, f"URL {url} should be blocked"
assert "localhost" in error.lower() or "priv" in error.lower()
def test_credentials_in_url_blocked(self):
"""URLs with credentials should be blocked."""
url = "https://user:password@example.com/webhook"
is_valid, error, details = webhook_validator.validate(url)
assert is_valid is False
assert "credentials" in error.lower()
def test_empty_url_valid(self):
"""Empty URL should be valid (optional parameter)."""
is_valid, error, details = webhook_validator.validate("")
assert is_valid is True
def test_none_url_valid(self):
"""None URL should be valid (optional parameter)."""
is_valid, error, details = webhook_validator.validate(None)
# Note: The validator should handle None gracefully
# Implementation may vary
def test_url_with_port(self):
"""URL with port should be valid."""
url = "https://example.com:8080/webhook"
is_valid, error, details = webhook_validator.validate(url)
assert is_valid is True
def test_url_with_query_params(self):
"""URL with query parameters should be valid."""
url = "https://example.com/webhook?token=abc123&source=api"
is_valid, error, details = webhook_validator.validate(url)
assert is_valid is True
def test_url_with_path(self):
"""URL with path should be valid."""
url = "https://example.com/api/v1/notifications/translation-complete"
is_valid, error, details = webhook_validator.validate(url)
assert is_valid is True
class TestWebhookURLIntegration:
"""Integration tests for webhook URL in translate endpoint."""
@pytest.mark.asyncio
async def test_translate_with_valid_webhook(self, client, auth_headers, sample_file):
"""Translation with valid webhook_url should succeed."""
# Implementation
pass
@pytest.mark.asyncio
async def test_translate_with_invalid_webhook(self, client, auth_headers, sample_file):
"""Translation with invalid webhook_url should return 400."""
# Implementation
pass
@pytest.mark.asyncio
async def test_translate_without_webhook(self, client, auth_headers, sample_file):
"""Translation without webhook_url should succeed."""
# Implementation
pass
Dev Agent Record
Agent Model Used
{{agent_model_name_version}}
Debug Log References
(À remplir lors de l'implémentation)
Completion Notes List
- ✅ Analyse exhaustive du contexte terminée
- ✅ Code existant identifié dans
routes/translate_routes.py - ✅ Validation basique existante à améliorer
- ✅ Code d'erreur
INVALID_WEBHOOK_URLdéjà présent dansschemas/errors.py - ✅ Tests à créer dans
tests/test_webhook_validation.py
File List
middleware/validation.py- À MODIFIER - Ajouter WebhookURLValidatorroutes/translate_routes.py- À MODIFIER - Utiliser nouvelle validationschemas/errors.py- À VÉRIFIER - ERROR_EXAMPLES pour INVALID_WEBHOOK_URLtests/test_webhook_validation.py- À CRÉER - Tests unitaires validation webhook
Change Log
- 2026-02-22: Story créée avec contexte complet et analyse exhaustive
Checklist de Validation
Avant de marquer cette story comme terminée, vérifier:
- Le paramètre
webhook_urlest accepté dans POST/api/v1/translate - La validation utilise le code d'erreur
INVALID_WEBHOOK_URL(pasINVALID_FORMAT) - Les URLs localhost et IPs privées sont bloquées (sécurité SSRF)
- Les URLs avec credentials sont bloquées
- Le paramètre est optionnel (la traduction fonctionne sans)
- Le
webhook_urlest stocké avec le job pour la Story 3.8 - Le paramètre est documenté dans OpenAPI
- Les tests unitaires passent
- Les tests d'intégration passent