Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
677 changes: 59 additions & 618 deletions frontend/package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-google-recaptcha": "^3.1.0",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
Expand All @@ -27,6 +28,7 @@
"@types/node": "^25.0.3",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-google-recaptcha": "^2.1.9",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ export async function translateFile(request: TranslateRequest): Promise<Translat
formData.append('sheets', request.sheets.join(','));
}

if (request.recaptchaToken) {
formData.append('recaptcha_token', request.recaptchaToken);
}

try {
const response = await fetch(`${API_URL}/translate`, {
method: 'POST',
Expand Down
69 changes: 65 additions & 4 deletions frontend/src/components/features/translate/TranslateForm.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Languages, Sparkles, Settings2, MessageSquare } from 'lucide-react';
import { Card, CardContent, Button, FileDropzone } from '../../ui';
import { Card, CardContent, Button, FileDropzone, Recaptcha, type RecaptchaRef } from '../../ui';
import { LanguageSelector } from './LanguageSelector';
import { SheetSelector } from './SheetSelector';
import { ResultDisplay } from './ResultDisplay';
Expand All @@ -21,6 +21,8 @@ export function TranslateForm() {
const [selectedSheets, setSelectedSheets] = useState<string[]>([]);
const [showAdvanced, setShowAdvanced] = useState(false);
const [loadingSheets, setLoadingSheets] = useState(false);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
const recaptchaRef = useRef<RecaptchaRef>(null);

const { status, error, filename, translate, downloadResult, reset } = useTranslate();

Expand Down Expand Up @@ -53,11 +55,38 @@ export function TranslateForm() {
if (status !== 'idle') {
reset();
}
}, [status, reset]);
// Reset reCAPTCHA when file changes
if (fileInfo !== selectedFile) {
recaptchaRef.current?.reset();
setRecaptchaToken(null);
}
}, [status, reset, selectedFile]);

const isTranslating = status === 'uploading' || status === 'translating';
const recaptchaSiteKey = import.meta.env.VITE_RECAPTCHA_SITE_KEY || '';
// reCAPTCHA is required only if site key is configured
const recaptchaRequired = !!recaptchaSiteKey;

const handleTranslate = useCallback(async () => {
if (!selectedFile || !targetLanguage) return;

// If reCAPTCHA is configured, execute it first (invisible mode)
if (recaptchaRequired && !recaptchaToken) {
try {
if (recaptchaRef.current) {
recaptchaRef.current.execute();
return; // Wait for onChange callback to proceed
} else {
// reCAPTCHA ref not available, proceed without it (fallback)
console.warn('reCAPTCHA ref not available, proceeding without verification');
}
} catch (error) {
console.error('reCAPTCHA execution error:', error);
// Continue without reCAPTCHA on error
}
}

// Proceed with translation (token already obtained from reCAPTCHA onChange, or no reCAPTCHA)
await translate({
file: selectedFile.file,
targetLanguage,
Expand All @@ -66,14 +95,35 @@ export function TranslateForm() {
sheets: selectedSheets.length > 0 && selectedSheets.length < sheets.length
? selectedSheets
: undefined,
recaptchaToken: recaptchaToken || undefined,
});
}, [selectedFile, targetLanguage, sourceLanguage, context, selectedSheets, sheets.length, recaptchaToken, recaptchaRequired, translate]);

// Handle reCAPTCHA token received (for invisible mode)
const handleRecaptchaChange = useCallback((token: string | null) => {
setRecaptchaToken(token);
// If we have a token and form is ready, proceed with translation
if (token && selectedFile && targetLanguage) {
translate({
file: selectedFile.file,
targetLanguage,
sourceLanguage: sourceLanguage || undefined,
context: context || undefined,
sheets: selectedSheets.length > 0 && selectedSheets.length < sheets.length
? selectedSheets
: undefined,
recaptchaToken: token,
});
}
}, [selectedFile, targetLanguage, sourceLanguage, context, selectedSheets, sheets.length, translate]);

const handleRetry = useCallback(() => {
reset();
recaptchaRef.current?.reset();
setRecaptchaToken(null);
}, [reset]);

const isTranslating = status === 'uploading' || status === 'translating';
// For invisible reCAPTCHA, button is enabled when form is ready (reCAPTCHA executes on click)
const canTranslate = selectedFile && targetLanguage && !isTranslating;

return (
Expand Down Expand Up @@ -180,6 +230,17 @@ export function TranslateForm() {
</div>
)}

{/* Invisible reCAPTCHA - hidden but always rendered when site key is available */}
{recaptchaSiteKey && (
<div className="recaptcha-wrapper recaptcha-invisible-wrapper">
<Recaptcha
ref={recaptchaRef}
siteKey={recaptchaSiteKey}
onChange={handleRecaptchaChange}
/>
</div>
)}

<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/components/ui/Recaptcha.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
.recaptcha-wrapper {
display: flex;
flex-direction: column;
padding: 1rem;
background: rgba(0, 0, 0, 0.02);
border-radius: 0.75rem;
border: 1px solid var(--color-neutral-200);
overflow: hidden;
}

:root.dark .recaptcha-wrapper {
background: rgba(255, 255, 255, 0.02);
border-color: var(--color-neutral-700);
}

/* Invisible reCAPTCHA wrapper - completely hidden */
.recaptcha-invisible-wrapper {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
padding: 0;
border: none;
background: transparent;
}

.recaptcha-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 78px;
}

.recaptcha-container > div {
display: flex;
justify-content: center;
}

/* Hide invisible reCAPTCHA container */
.recaptcha-invisible {
display: none;
}

/* Override Google's reCAPTCHA styling to match theme */
.recaptcha-wrapper iframe {
border-radius: 0.5rem;
}

57 changes: 57 additions & 0 deletions frontend/src/components/ui/Recaptcha.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useRef, useImperativeHandle, forwardRef } from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import type { ReCAPTCHA as ReCAPTCHAType } from 'react-google-recaptcha';
import './Recaptcha.css';

export interface RecaptchaRef {
reset: () => void;
getValue: () => string | null;
execute: () => void;
}

interface RecaptchaProps {
siteKey: string;
onChange: (token: string | null) => void;
}

export const Recaptcha = forwardRef<RecaptchaRef, RecaptchaProps>(
({ siteKey, onChange }, ref) => {
const recaptchaRef = useRef<ReCAPTCHAType>(null);

useImperativeHandle(ref, () => ({
reset: () => {
recaptchaRef.current?.reset();
onChange(null);
},
getValue: () => {
return recaptchaRef.current?.getValue() || null;
},
execute: () => {
recaptchaRef.current?.execute();
},
}));

const handleChange = (token: string | null) => {
onChange(token);
};

const handleExpired = () => {
onChange(null);
};

return (
<div className="recaptcha-container recaptcha-invisible">
<ReCAPTCHA
ref={recaptchaRef}
sitekey={siteKey}
onChange={handleChange}
onExpired={handleExpired}
size="invisible"
/>
</div>
);
}
);

Recaptcha.displayName = 'Recaptcha';

2 changes: 2 additions & 0 deletions frontend/src/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ export { Button } from './Button';
export { Card, CardHeader, CardContent, CardFooter } from './Card';
export { Dropdown } from './Dropdown';
export { FileDropzone } from './FileDropzone';
export { Recaptcha } from './Recaptcha';
export type { RecaptchaRef } from './Recaptcha';
1 change: 1 addition & 0 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface TranslateRequest {
sourceLanguage?: string;
context?: string;
sheets?: string[];
recaptchaToken?: string;
}

export interface TranslateResponse {
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ dev = [
"pytest-cov>=4.1.0",
"pytest-asyncio>=0.24.0",
"httpx>=0.27.0",
"requests>=2.31.0",
"black>=24.0.0",
"ruff>=0.6.0",
"mypy>=1.11.0",
Expand Down
49 changes: 49 additions & 0 deletions src/rosetta/api/app.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""FastAPI application for Rosetta translation service."""

import os
import tempfile
from pathlib import Path
from typing import Optional

import requests
from dotenv import load_dotenv
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
Expand All @@ -15,6 +17,10 @@
# Load environment variables from .env file
load_dotenv()

# reCAPTCHA configuration
RECAPTCHA_SECRET_KEY = os.getenv("RECAPTCHA_SECRET_KEY")
RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"

# Limits
# Keep in sync with frontend validation/copy (50MB).
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
Expand Down Expand Up @@ -89,19 +95,62 @@ async def get_sheets(
input_path.unlink(missing_ok=True)


def verify_recaptcha(token: Optional[str]) -> bool:
"""Verify reCAPTCHA token with Google's API."""
if not RECAPTCHA_SECRET_KEY:
# If no secret key is configured, skip verification (for development)
return True

if not token:
return False

try:
response = requests.post(
RECAPTCHA_VERIFY_URL,
data={
"secret": RECAPTCHA_SECRET_KEY,
"response": token,
},
timeout=5,
)
response.raise_for_status()
result = response.json()
success = result.get("success", False)

# Log error details if verification failed
if not success:
error_codes = result.get("error-codes", [])
print(f"reCAPTCHA verification failed. Error codes: {error_codes}")
print(f"Response: {result}")

return success
except Exception as e:
# If verification fails due to network/API issues, reject the request
print(f"reCAPTCHA verification error: {e}")
return False


@app.post("/translate")
async def translate(
file: UploadFile = File(..., description="Excel file to translate"),
target_lang: str = Form(..., description="Target language (e.g., french, spanish)"),
source_lang: Optional[str] = Form(None, description="Source language (auto-detect if omitted)"),
context: Optional[str] = Form(None, description="Additional context for accurate translations"),
sheets: Optional[str] = Form(None, description="Comma-separated sheet names (all if omitted)"),
recaptcha_token: Optional[str] = Form(None, description="reCAPTCHA token for verification"),
) -> FileResponse:
"""Translate an Excel file.

Upload an Excel file and receive the translated version.
Preserves all formatting, formulas, images, and data validations.
"""
# Verify reCAPTCHA token
if not verify_recaptcha(recaptcha_token):
raise HTTPException(
status_code=400,
detail="reCAPTCHA verification failed. Please complete the reCAPTCHA challenge.",
)

# Validate file type
if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided")
Expand Down