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:
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user