feat: homelab deployment - NPM + IONOS DNS + monitoring + NAS backup

- Restructured docker-compose for Nginx Proxy Manager (no custom nginx)
- Added domain wordly.art configuration
- Added Prometheus + Grafana monitoring stack with pre-configured dashboards
- Added PostgreSQL backup script to NAS (daily/weekly/monthly rotation)
- Added alert rules for backend, system, and Docker metrics
- Updated deployment guide for NPM + IONOS DNS homelab setup
- Added marketing plan document
- PDF translator and watermark support
- Enhanced middleware, routes, and translator modules

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 11:43:28 +02:00
parent 16ac7ca2b9
commit ce8e150a61
110 changed files with 6935 additions and 4301 deletions

View File

@@ -161,7 +161,7 @@ Créer un nouveau compte utilisateur.
- Le `tier` par défaut est "free"
**Codes d'erreur:**
- `INVALID_EMAIL`: Format d'email invalide
- `INVALID_EMAIL`: Invalid email format
- `EMAIL_EXISTS`: Un compte existe déjà avec cet email
- `INVALID_REQUEST`: Données d'inscription invalides
""",
@@ -187,17 +187,17 @@ Créer un nouveau compte utilisateur.
"application/json": {
"examples": {
"INVALID_EMAIL": {
"summary": "Format d'email invalide",
"summary": "Invalid email format",
"value": {
"error": "INVALID_EMAIL",
"message": "Format d'email invalide",
"message": "Invalid email format",
},
},
"EMAIL_EXISTS": {
"summary": "Email déjà utilisé",
"summary": "Email already used",
"value": {
"error": "EMAIL_EXISTS",
"message": "Un compte existe déjà avec cette adresse email",
"message": "An account already exists with this email address",
},
},
}
@@ -215,7 +215,7 @@ async def register_v1(request: Request):
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Corps de requete JSON invalide",
"message": "Invalid JSON request body",
},
)
@@ -227,7 +227,7 @@ async def register_v1(request: Request):
status_code=400,
content={
"error": "INVALID_EMAIL",
"message": "Format d'email invalide",
"message": "Invalid email format",
},
)
# Check if it's a password validation error
@@ -241,7 +241,7 @@ async def register_v1(request: Request):
# Otherwise it's a weak password
# Translate common pydantic messages to French
if "at least 8 characters" in msg.lower() or "8 caractères" in msg:
msg = "Le mot de passe doit contenir au moins 8 caractères"
msg = "Password must be at least 8 characters long"
return JSONResponse(
status_code=400,
content={
@@ -253,7 +253,7 @@ async def register_v1(request: Request):
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Données d'inscription invalides",
"message": "Invalid registration data",
},
)
@@ -266,7 +266,7 @@ async def register_v1(request: Request):
status_code=503,
content={
"error": "AUTH_HASHING_UNAVAILABLE",
"message": "Service d'inscription temporairement indisponible",
"message": "Registration service temporarily unavailable",
},
)
@@ -279,14 +279,14 @@ async def register_v1(request: Request):
status_code=400,
content={
"error": "EMAIL_EXISTS",
"message": "Un compte existe déjà avec cette adresse email",
"message": "An account already exists with this email address",
},
)
return JSONResponse(
status_code=400,
content={
"error": "REGISTRATION_FAILED",
"message": "Impossible de créer le compte avec les données fournies",
"message": "Unable to create account with the provided data",
},
)
@@ -318,7 +318,7 @@ Déconnecte l'utilisateur en révoquant son token d'accès.
- HTTP 200 avec message de confirmation
**Codes d'erreur:**
- `TOKEN_MISSING`: Token d'authentification manquant
- `TOKEN_MISSING`: Authentication token missing
- `TOKEN_INVALID`: Token invalide ou expiré
""",
responses={
@@ -326,7 +326,7 @@ Déconnecte l'utilisateur en révoquant son token d'accès.
"description": "Déconnexion réussie",
"content": {
"application/json": {
"example": {"data": {"message": "Déconnexion réussie"}, "meta": {}}
"example": {"data": {"message": "Logged out successfully"}, "meta": {}}
}
},
},
@@ -336,17 +336,17 @@ Déconnecte l'utilisateur en révoquant son token d'accès.
"application/json": {
"examples": {
"TOKEN_MISSING": {
"summary": "Token manquant",
"summary": "Missing token",
"value": {
"error": "TOKEN_MISSING",
"message": "Token d'authentification requis",
"message": "Authentication token required",
},
},
"TOKEN_INVALID": {
"summary": "Token invalide",
"summary": "Invalid token",
"value": {
"error": "TOKEN_INVALID",
"message": "Token invalide ou expiré",
"message": "Invalid or expired token",
},
},
}
@@ -363,7 +363,7 @@ async def logout_v1(request: Request):
status_code=401,
content={
"error": "TOKEN_MISSING",
"message": "Token d'authentification requis",
"message": "Authentication token required",
},
)
access_token = auth_header[7:]
@@ -374,7 +374,7 @@ async def logout_v1(request: Request):
status_code=401,
content={
"error": "TOKEN_INVALID",
"message": "Token invalide ou expiré",
"message": "Invalid or expired token",
},
)
@@ -397,7 +397,7 @@ async def logout_v1(request: Request):
return JSONResponse(
status_code=200,
content={"data": {"message": "Déconnexion réussie"}, "meta": {}},
content={"data": {"message": "Logged out successfully"}, "meta": {}},
)
@@ -416,7 +416,7 @@ Authentifie un utilisateur et retourne les tokens JWT.
**Codes d'erreur:**
- `INVALID_REQUEST`: Corps de requête JSON invalide
- `INVALID_EMAIL`: Format d'email invalide
- `INVALID_EMAIL`: Invalid email format
- `INVALID_CREDENTIALS`: Email ou mot de passe incorrect
""",
responses={
@@ -441,17 +441,17 @@ Authentifie un utilisateur et retourne les tokens JWT.
"application/json": {
"examples": {
"INVALID_REQUEST": {
"summary": "Corps invalide",
"summary": "Invalid body",
"value": {
"error": "INVALID_REQUEST",
"message": "Corps de requete JSON invalide",
"message": "Invalid JSON request body",
},
},
"INVALID_EMAIL": {
"summary": "Email invalide",
"summary": "Invalid email",
"value": {
"error": "INVALID_EMAIL",
"message": "Format d'email invalide",
"message": "Invalid email format",
},
},
}
@@ -464,7 +464,7 @@ Authentifie un utilisateur et retourne les tokens JWT.
"application/json": {
"example": {
"error": "INVALID_CREDENTIALS",
"message": "Email ou mot de passe incorrect",
"message": "Incorrect email or password",
}
}
},
@@ -480,7 +480,7 @@ async def login_v1(request: Request):
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Corps de requete JSON invalide",
"message": "Invalid JSON request body",
},
)
@@ -492,14 +492,14 @@ async def login_v1(request: Request):
status_code=400,
content={
"error": "INVALID_EMAIL",
"message": "Format d'email invalide",
"message": "Invalid email format",
},
)
return JSONResponse(
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Données d'inscription invalides",
"message": "Invalid registration data",
},
)
@@ -511,7 +511,7 @@ async def login_v1(request: Request):
status_code=401,
content={
"error": "INVALID_CREDENTIALS",
"message": "Email ou mot de passe incorrect",
"message": "Incorrect email or password",
},
)
@@ -520,7 +520,7 @@ async def login_v1(request: Request):
status_code=401,
content={
"error": "INVALID_CREDENTIALS",
"message": "Email ou mot de passe incorrect",
"message": "Incorrect email or password",
},
)
@@ -552,7 +552,7 @@ async def google_auth_v1(body: GoogleAuthRequest):
if not body.credential and not body.access_token:
return JSONResponse(
status_code=400,
content={"error": "MISSING_TOKEN", "message": "credential ou access_token requis."},
content={"error": "MISSING_TOKEN", "message": "credential or access_token required."},
)
google_client_id = os.getenv("GOOGLE_CLIENT_ID", "")
@@ -574,13 +574,13 @@ async def google_auth_v1(body: GoogleAuthRequest):
except Exception:
return JSONResponse(
status_code=503,
content={"error": "GOOGLE_UNREACHABLE", "message": "Impossible de contacter les serveurs Google."},
content={"error": "GOOGLE_UNREACHABLE", "message": "Unable to contact Google servers."},
)
if resp.status_code != 200:
return JSONResponse(
status_code=401,
content={"error": "INVALID_GOOGLE_TOKEN", "message": "Token Google invalide ou expiré."},
content={"error": "INVALID_GOOGLE_TOKEN", "message": "Invalid or expired Google token."},
)
google_data = resp.json()
@@ -589,14 +589,14 @@ async def google_auth_v1(body: GoogleAuthRequest):
if body.credential and google_client_id and google_data.get("aud") != google_client_id:
return JSONResponse(
status_code=401,
content={"error": "INVALID_TOKEN_AUDIENCE", "message": "Token Google non destiné à cette application."},
content={"error": "INVALID_TOKEN_AUDIENCE", "message": "Google token not intended for this application."},
)
email = google_data.get("email")
if not email:
return JSONResponse(
status_code=400,
content={"error": "MISSING_EMAIL", "message": "Email non fourni par Google."},
content={"error": "MISSING_EMAIL", "message": "Email not provided by Google."},
)
name = google_data.get("name") or google_data.get("given_name") or email.split("@")[0]
@@ -636,7 +636,7 @@ async def refresh_v1(request: Request):
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Corps de requete JSON invalide",
"message": "Invalid JSON request body",
},
)
@@ -645,7 +645,7 @@ async def refresh_v1(request: Request):
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Corps de requete JSON invalide",
"message": "Invalid JSON request body",
},
)
@@ -659,7 +659,7 @@ async def refresh_v1(request: Request):
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Refresh token requis",
"message": "Refresh token required",
},
)
@@ -669,7 +669,7 @@ async def refresh_v1(request: Request):
status_code=401,
content={
"error": "TOKEN_EXPIRED",
"message": "Token invalide ou expiré",
"message": "Invalid or expired token",
},
)
@@ -678,7 +678,7 @@ async def refresh_v1(request: Request):
status_code=401,
content={
"error": "TOKEN_EXPIRED",
"message": "Token invalide ou expiré",
"message": "Invalid or expired token",
},
)
@@ -688,7 +688,7 @@ async def refresh_v1(request: Request):
status_code=401,
content={
"error": "TOKEN_EXPIRED",
"message": "Token invalide ou expiré",
"message": "Invalid or expired token",
},
)
@@ -893,7 +893,7 @@ async def get_billing_portal_v1(user=Depends(require_user)):
status_code=400,
content={
"error": "BILLING_UNAVAILABLE",
"message": "Portail de facturation non disponible",
"message": "Billing portal unavailable",
},
)
return JSONResponse(status_code=200, content={"data": {"url": url}, "meta": {}})
@@ -928,7 +928,7 @@ async def forgot_password(request: Request):
status_code=503,
content={
"error": "EMAIL_SERVICE_UNAVAILABLE",
"message": "Service email non configure",
"message": "Email service not configured",
},
)
@@ -939,7 +939,7 @@ async def forgot_password(request: Request):
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Corps de requete JSON invalide",
"message": "Invalid JSON request body",
},
)
@@ -949,7 +949,7 @@ async def forgot_password(request: Request):
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Email requis",
"message": "Email required",
},
)
@@ -1015,7 +1015,7 @@ async def forgot_password(request: Request):
status_code=200,
content={
"data": {
"message": "Si un compte existe avec cette adresse, un email de reinitialisation a ete envoye."
"message": "If an account exists with this email, a reset email has been sent."
},
"meta": {},
},
@@ -1041,7 +1041,7 @@ async def reset_password(request: Request):
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Corps de requete JSON invalide",
"message": "Invalid JSON request body",
},
)
@@ -1053,7 +1053,7 @@ async def reset_password(request: Request):
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Token et mot de passe requis",
"message": "Token and password required",
},
)
@@ -1063,7 +1063,7 @@ async def reset_password(request: Request):
status_code=400,
content={
"error": "WEAK_PASSWORD",
"message": "Le mot de passe doit contenir au moins 8 caracteres, une majuscule, une minuscule et un chiffre",
"message": "Password must be at least 8 characters with at least one uppercase letter, one lowercase letter, and one digit",
},
)
@@ -1085,7 +1085,7 @@ async def reset_password(request: Request):
status_code=400,
content={
"error": "INVALID_TOKEN",
"message": "Token invalide ou expire",
"message": "Invalid or expired token",
},
)
@@ -1099,7 +1099,7 @@ async def reset_password(request: Request):
status_code=400,
content={
"error": "TOKEN_EXPIRED",
"message": "Token expire, veuillez redemander une reinitialisation",
"message": "Token expired, please request a new reset",
},
)
@@ -1116,14 +1116,14 @@ async def reset_password(request: Request):
status_code=500,
content={
"error": "RESET_FAILED",
"message": "Erreur lors de la reinitialisation",
"message": "Error during password reset",
},
)
return JSONResponse(
status_code=200,
content={
"data": {"message": "Mot de passe reinitialise avec succes"},
"data": {"message": "Password reset successfully"},
"meta": {},
},
)