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

@@ -72,10 +72,11 @@ logger = logging.getLogger(__name__)
router_v1 = APIRouter(prefix="/api/v1", tags=["Translation v1"])
security = HTTPBearer(auto_error=False)
MAX_FILE_SIZE_MB = 50
# Reference config for file constraints (avoids duplicating values)
MAX_FILE_SIZE_MB = config.MAX_FILE_SIZE_MB
OFFICE_MAGIC_BYTES = b"PK\x03\x04"
ACCEPTED_EXTENSIONS = {".xlsx", ".docx", ".pptx"}
PDF_MAGIC_BYTES = b"%PDF"
ACCEPTED_EXTENSIONS = config.SUPPORTED_EXTENSIONS
class TranslateEndpointError(Exception):
@@ -92,22 +93,22 @@ class TranslateEndpointError(Exception):
PRO_FEATURE_REQUIRED = "PRO_FEATURE_REQUIRED"
ERROR_MESSAGES = {
INVALID_FORMAT: "Format de fichier non supporte. Formats acceptes : .xlsx, .docx, .pptx",
CORRUPTED_FILE: "Le fichier semble corrompu ou n'est pas un document Office valide.",
FILE_TOO_LARGE: f"Le fichier est trop volumineux (max {MAX_FILE_SIZE_MB} Mo).",
QUOTA_EXCEEDED: "Limite quotidienne atteinte.",
URL_DOWNLOAD_FAILED: "Impossible de telecharger le fichier depuis l'URL.",
URL_UNREACHABLE: "URL inaccessible.",
UNAUTHORIZED: "Authentification requise.",
MISSING_FILE: "Fichier ou URL requis.",
PRO_FEATURE_REQUIRED: "Cette fonctionnalite necessite un abonnement Pro.",
INVALID_FORMAT: "Unsupported file format. Accepted formats: .xlsx, .docx, .pptx",
CORRUPTED_FILE: "The file appears corrupted or is not a valid Office document.",
FILE_TOO_LARGE: f"File is too large (max {MAX_FILE_SIZE_MB} MB).",
QUOTA_EXCEEDED: "Monthly translation limit reached.",
URL_DOWNLOAD_FAILED: "Failed to download file from URL.",
URL_UNREACHABLE: "URL unreachable.",
UNAUTHORIZED: "Authentication required.",
MISSING_FILE: "File or URL required.",
PRO_FEATURE_REQUIRED: "This feature requires a Pro subscription.",
}
def __init__(
self, code: str, message: Optional[str] = None, details: Optional[dict] = None
):
self.code = code
self.message = message or self.ERROR_MESSAGES.get(code, "Erreur inconnue")
self.message = message or self.ERROR_MESSAGES.get(code, "Unknown error")
self.details = details or {}
super().__init__(self.message)
@@ -159,18 +160,29 @@ async def validate_file_content(content: bytes, extension: str) -> None:
if len(content) < 4:
raise TranslateEndpointError(
code=TranslateEndpointError.CORRUPTED_FILE,
message="Le fichier est trop petit pour etre un document Office valide.",
message="File is too small to be a valid document.",
details={"reason": "File is too small"},
)
header = content[:4]
if header != OFFICE_MAGIC_BYTES:
header = content[:5]
# PDF files start with %PDF
if extension.lower() == ".pdf":
if not header[:4] == PDF_MAGIC_BYTES:
raise TranslateEndpointError(
code=TranslateEndpointError.CORRUPTED_FILE,
message="File is not a valid PDF.",
details={"reason": "Invalid PDF header"},
)
return
# Office files (xlsx, docx, pptx) are ZIP archives
if header[:4] != OFFICE_MAGIC_BYTES:
raise TranslateEndpointError(
code=TranslateEndpointError.CORRUPTED_FILE,
message="Le fichier n'est pas un document Office valide ou est corrompu.",
message="File is not a valid Office document.",
details={
"accepted_formats": list(ACCEPTED_EXTENSIONS),
"hint": "Les fichiers .xlsx, .docx, .pptx doivent etre des archives ZIP valides.",
"hint": "Office files (.xlsx, .docx, .pptx) must be valid ZIP archives.",
},
)
@@ -227,7 +239,7 @@ async def download_from_url(url: str, timeout: int = 30) -> tuple[Path, str]:
if parsed_url.scheme not in ("http", "https"):
raise TranslateEndpointError(
code=TranslateEndpointError.URL_UNREACHABLE,
message="Seules les URLs HTTP/HTTPS sont acceptees.",
message="Only HTTP/HTTPS URLs are accepted.",
details={"scheme": parsed_url.scheme or "none"},
)
@@ -235,7 +247,7 @@ async def download_from_url(url: str, timeout: int = 30) -> tuple[Path, str]:
if not hostname or _is_ssrf_risk(hostname):
raise TranslateEndpointError(
code=TranslateEndpointError.URL_UNREACHABLE,
message="L'URL pointe vers une adresse interdite (adresse privee ou interne).",
message="The URL points to a blocked address (private or internal network).",
details={"reason": "ssrf_blocked"},
)
@@ -247,7 +259,7 @@ async def download_from_url(url: str, timeout: int = 30) -> tuple[Path, str]:
if response.status_code != 200:
raise TranslateEndpointError(
code=TranslateEndpointError.URL_UNREACHABLE,
message=f"URL inaccessible (HTTP {response.status_code})",
message=f"URL unreachable (HTTP {response.status_code})",
details={"status_code": response.status_code, "url": url[:100]},
)
@@ -259,7 +271,7 @@ async def download_from_url(url: str, timeout: int = 30) -> tuple[Path, str]:
if file_size > max_size_bytes:
raise TranslateEndpointError(
code=TranslateEndpointError.FILE_TOO_LARGE,
message=f"Le fichier est trop volumineux ({round(file_size / (1024 * 1024), 2)} Mo, max {MAX_FILE_SIZE_MB} Mo).",
message=f"File is too large ({round(file_size / (1024 * 1024), 2)} MB, max {MAX_FILE_SIZE_MB} MB).",
details={
"size_mb": round(file_size / (1024 * 1024), 2),
"max_mb": MAX_FILE_SIZE_MB,
@@ -326,7 +338,7 @@ async def download_from_url(url: str, timeout: int = 30) -> tuple[Path, str]:
temp_path.unlink()
raise TranslateEndpointError(
code=TranslateEndpointError.URL_UNREACHABLE,
message="Timeout lors du telechargement.",
message="Download timed out.",
details={"timeout_seconds": timeout},
)
except httpx.RequestError as e:
@@ -334,7 +346,7 @@ async def download_from_url(url: str, timeout: int = 30) -> tuple[Path, str]:
temp_path.unlink()
raise TranslateEndpointError(
code=TranslateEndpointError.URL_DOWNLOAD_FAILED,
message=f"Erreur de telechargement: {str(e)}",
message=f"Download error: {str(e)}",
details={"error": str(e)},
)
except TranslateEndpointError:
@@ -346,7 +358,7 @@ async def download_from_url(url: str, timeout: int = 30) -> tuple[Path, str]:
temp_path.unlink()
raise TranslateEndpointError(
code=TranslateEndpointError.URL_DOWNLOAD_FAILED,
message=f"Erreur inattendue lors du telechargement: {str(e)}",
message=f"Unexpected download error: {str(e)}",
details={"error": str(e), "error_type": type(e).__name__},
)
@@ -423,6 +435,9 @@ async def translate_document_v1(
glossary_id: Optional[str] = Form(None, description="Glossary ID (Pro only)"),
custom_prompt: Optional[str] = Form(None, description="Custom prompt (Pro only)"),
prompt_id: Optional[str] = Form(None, description="Prompt ID from saved prompts (Pro only)"),
pdf_mode: Optional[Literal["layout", "text_only"]] = Form(
default=None, description="PDF translation mode: 'layout' (preserve layout) or 'text_only' (clean text output). PDF only."
),
current_user: Optional[Any] = Depends(get_authenticated_user),
):
"""
@@ -514,7 +529,7 @@ async def translate_document_v1(
if tier == "free":
raise TranslateEndpointError(
code=TranslateEndpointError.PRO_FEATURE_REQUIRED,
message="L'ingestion par URL est reservee aux utilisateurs Pro.",
message="URL ingestion is reserved for Pro users.",
details={"feature": "file_url", "tier": tier},
)
@@ -522,7 +537,7 @@ async def translate_document_v1(
if (glossary_id or custom_prompt or prompt_id) and tier == "free":
raise TranslateEndpointError(
code=TranslateEndpointError.PRO_FEATURE_REQUIRED,
message="Les glossaires et prompts personnalises sont reserves aux utilisateurs Pro.",
message="Glossaries and custom prompts are reserved for Pro users.",
details={"feature": "glossary_id, custom_prompt, or prompt_id", "tier": tier},
)
@@ -560,12 +575,12 @@ async def translate_document_v1(
if current_user:
quota = await tier_quota_service.check_quota(user_id, tier)
if not quota.allowed:
retry_after = _seconds_until_midnight_utc()
retry_after = tier_quota_service.seconds_until_reset()
raise HTTPException(
status_code=429,
detail={
"error": "QUOTA_EXCEEDED",
"message": f"Limite quotidienne atteinte ({quota.current_usage}/{quota.limit} fichiers). Reessayez apres minuit UTC.",
"message": f"Monthly limit reached ({quota.current_usage}/{quota.limit} documents). Upgrade your plan for more.",
"details": {
"current_usage": quota.current_usage,
"limit": quota.limit,
@@ -584,7 +599,7 @@ async def translate_document_v1(
except ValidationError as e:
raise TranslateEndpointError(
code="INVALID_FORMAT",
message=f"Code langue cible invalide: {target_lang}",
message=f"Invalid target language code: {target_lang}",
details={"field": "target_lang"},
)
@@ -594,7 +609,7 @@ async def translate_document_v1(
except ValidationError:
raise TranslateEndpointError(
code="INVALID_FORMAT",
message=f"Code langue source invalide: {source_lang}",
message=f"Invalid source language code: {source_lang}",
details={"field": "source_lang"},
)
@@ -647,7 +662,7 @@ async def translate_document_v1(
file_handler_util.cleanup_file(input_path)
raise TranslateEndpointError(
code=TranslateEndpointError.CORRUPTED_FILE,
message="Impossible de calculer le hash du fichier. Fichier potentiellement corrompu.",
message="Failed to calculate file hash. File may be corrupted.",
details={"error": "sha256_calculation_failed"},
)
@@ -660,7 +675,7 @@ async def translate_document_v1(
file_handler_util.cleanup_file(input_path)
raise TranslateEndpointError(
code=TranslateEndpointError.CORRUPTED_FILE,
message="Impossible de calculer le hash du fichier telecharge.",
message="Failed to calculate downloaded file hash.",
details={"error": "sha256_calculation_failed"},
)
@@ -701,6 +716,7 @@ async def translate_document_v1(
"custom_prompt": custom_prompt,
"glossary_id": glossary_id,
"prompt_id": prompt_id, # Story 3.12: Store prompt_id
"pdf_mode": pdf_mode, # PDF translation mode
}
await set_job_status_async(job_id, _translation_jobs[job_id])
@@ -730,8 +746,10 @@ async def translate_document_v1(
user_id=user_id,
custom_prompt=custom_prompt,
glossary_id=glossary_id,
prompt_id=prompt_id, # Story 3.12: Pass prompt_id
prompt_id=prompt_id,
webhook_url=webhook_url,
user_plan=str(current_user.plan) if current_user else "free",
pdf_mode=pdf_mode,
)
)
@@ -777,7 +795,7 @@ async def translate_document_v1(
status_code=400,
content={
"error": "PROCESSING_ERROR",
"message": "Erreur lors du traitement de la requete.",
"message": "Error processing the request.",
"details": {"error_type": type(e).__name__},
},
)
@@ -825,6 +843,8 @@ async def _run_translation_job(
glossary_id: Optional[str],
prompt_id: Optional[str] = None, # Story 3.12: Add prompt_id parameter
webhook_url: Optional[str] = None,
user_plan: Optional[str] = None, # Plan name for watermark decision
pdf_mode: Optional[str] = None, # PDF translation mode: "layout" or "text_only"
) -> None:
"""
Run translation job in background with progress tracking.
@@ -1065,6 +1085,21 @@ async def _run_translation_job(
source_lang,
progress_callback=progress_callback,
)
elif file_extension == ".pdf":
from translators.pdf_translator import PDFTranslator
job_translator = PDFTranslator(provider=translation_provider)
actual_output = await asyncio.to_thread(
job_translator.translate_file,
input_path,
output_path,
target_lang,
source_lang,
progress_callback=progress_callback,
pdf_mode=pdf_mode or "layout",
)
# PDF translation may output .docx (if no LibreOffice); use actual path
if actual_output and Path(actual_output).exists():
output_path = Path(actual_output)
else:
raise ValueError(f"Unsupported file type: {file_extension}")
@@ -1077,6 +1112,17 @@ async def _run_translation_job(
await asyncio.to_thread(record_usage, user_id, pages)
logger.info(f"Job {job_id}: usage recorded — {pages} page(s)")
# Apply watermark for Free-tier users
plan_name = (user_plan or "free").lower()
if plan_name in ("free", "plantype.free"):
try:
from translators.watermark import add_watermark
actual_ext = output_path.suffix.lower()
await asyncio.to_thread(add_watermark, output_path, actual_ext)
logger.info(f"Job {job_id}: watermark applied (free plan)")
except Exception as wm_err:
logger.warning(f"Job {job_id}: watermark failed: {wm_err}")
tracker.set_completed(str(output_path))
logger.info(f"Job {job_id}: Completed successfully")
@@ -1193,7 +1239,7 @@ async def get_translation_status(
status_code=404,
content={
"error": "NOT_FOUND",
"message": "Job de traduction non trouve.",
"message": "Translation job not found.",
"details": {"job_id": job_id},
},
)
@@ -1249,6 +1295,7 @@ MIME_TYPES = {
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".pdf": "application/pdf",
}
@@ -1317,7 +1364,7 @@ async def download_translated_file(
status_code=400,
content={
"error": "INVALID_JOB_ID",
"message": "Format d'identifiant de travail invalide.",
"message": "Invalid job ID format.",
"details": {"job_id": job_id, "expected_format": "tr_xxxxxxxxxxxx"},
},
)
@@ -1331,7 +1378,7 @@ async def download_translated_file(
status_code=404,
content={
"error": "FILE_EXPIRED",
"message": "Le fichier traduit n'est plus disponible ou a expire.",
"message": "The translated file is no longer available or has expired.",
"details": {"job_id": job_id, "status": "not_found"},
},
)
@@ -1342,7 +1389,7 @@ async def download_translated_file(
status_code=403,
content={
"error": "ACCESS_DENIED",
"message": "Vous n'avez pas acces a ce fichier.",
"message": "You do not have access to this file.",
"details": {"job_id": job_id},
},
)
@@ -1352,7 +1399,7 @@ async def download_translated_file(
status_code=404,
content={
"error": "NOT_READY",
"message": "La traduction est encore en cours.",
"message": "Translation is still in progress.",
"details": {
"job_id": job_id,
"status": job.get("status"),
@@ -1367,7 +1414,7 @@ async def download_translated_file(
status_code=404,
content={
"error": "FILE_EXPIRED",
"message": "Le fichier traduit n'est plus disponible ou a expire.",
"message": "The translated file is no longer available or has expired.",
"details": {"job_id": job_id, "status": "no_output_path"},
},
)
@@ -1378,22 +1425,21 @@ async def download_translated_file(
status_code=404,
content={
"error": "FILE_EXPIRED",
"message": "Le fichier traduit n'est plus disponible ou a expire.",
"message": "The translated file is no longer available or has expired.",
"details": {"job_id": job_id, "status": "file_deleted"},
},
)
original_filename = job.get("file_name", "document")
# Use the actual output file extension (PDF→DOCX conversion changes extension)
actual_extension = output_path.suffix.lower()
if original_filename:
name_without_ext = Path(original_filename).stem
extension = Path(original_filename).suffix.lower()
download_filename = f"{name_without_ext}_translated{extension}"
download_filename = f"{name_without_ext}_translated{actual_extension}"
else:
file_extension = job.get("file_extension", ".xlsx")
download_filename = f"document_translated{file_extension}"
extension = file_extension
download_filename = f"document_translated{actual_extension}"
mime_type = MIME_TYPES.get(extension, "application/octet-stream")
mime_type = MIME_TYPES.get(actual_extension, "application/octet-stream")
input_path_str = job.get("input_path")