""" Raw Download Routes - Sharing Link Mode Handles Synology sharing link downloads (với Selenium + OTP). """ from fastapi import APIRouter, HTTPException, Request, Response from pydantic import BaseModel from typing import List, Dict, Optional import logging import os import uuid import time from collections import defaultdict from ..services import mongodb_service, nas_service, nas_sharing_service, supabase_service, downloads_service from ..common import get_download_destination_path logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/sharing-link", tags=["Raw Sharing"]) # ==================== RATE LIMITING ==================== # Track last request time per sharing_id to prevent NAS API rate limit errors _last_request_time: Dict[str, float] = defaultdict(float) _rate_limit_window_ms = 200 # Minimum 200ms between requests for same sharing_id # ==================== REQUEST MODELS ==================== class SharingLinkRequest(BaseModel): url: str class SharingLinkDownloadRequest(BaseModel): sharing_id: str files: List[Dict] # List of file objects with name, path, isdir ge_id: Optional[str] = None # Optional: for organizing files by project lang: Optional[str] = None # Optional: for organizing files by project class SharingLinkFromDbRequest(BaseModel): ge_id: str lang: str class SharingOtpSubmit(BaseModel): otp_code: str # ==================== SHARING LINK PROCESSING ==================== @router.post('/get-from-db') def get_sharing_link_from_db(payload: SharingLinkFromDbRequest): """ Query MongoDB titles_data collection to get linkRaw field. Returns sharing link from database or throws error with record details. """ try: # Query MongoDB collection = mongodb_service.get_titles_collection() query = { "geId": str(payload.ge_id).strip(), "lang": str(payload.lang).strip().upper() } # Find all matching records documents = list(collection.find(query)) # Validation 1: No records found if len(documents) == 0: raise HTTPException( status_code=404, detail={ "error": "Không tìm thấy record", "query": {"geId": payload.ge_id, "lang": payload.lang.upper()} } ) # Validation 2: Multiple records (should not happen with unique constraint) if len(documents) > 1: record_info = [ { "geId": doc.get("geId"), "lang": doc.get("lang"), "linkRaw": doc.get("linkRaw"), "path": doc.get("path") } for doc in documents ] raise HTTPException( status_code=400, detail={ "error": "Tìm thấy nhiều hơn 1 record", "records": record_info } ) # Get single record document = documents[0] link_raw = document.get("linkRaw") # Validation 3: linkRaw is empty or null if not link_raw or not isinstance(link_raw, str) or link_raw.strip() == "": record_info = { "geId": document.get("geId"), "lang": document.get("lang"), "linkRaw": link_raw, "path": document.get("path") } raise HTTPException( status_code=400, detail={ "error": "Trường linkRaw trống hoặc null", "record": record_info } ) # Validation 4: linkRaw is not a valid link link_raw_stripped = link_raw.strip() if not (link_raw_stripped.startswith("http://") or link_raw_stripped.startswith("https://")): record_info = { "geId": document.get("geId"), "lang": document.get("lang"), "linkRaw": link_raw, "path": document.get("path") } raise HTTPException( status_code=400, detail={ "error": "linkRaw không phải là liên kết hợp lệ (phải bắt đầu bằng http:// hoặc https://)", "record": record_info } ) # Success - return sharing link return { "success": True, "sharing_link": link_raw_stripped, "record": { "geId": document.get("geId"), "lang": document.get("lang"), "path": document.get("path") } } except HTTPException: raise except Exception as e: logger.error(f"Error querying MongoDB for sharing link: {e}") raise HTTPException(status_code=500, detail=f"Lỗi hệ thống: {e}") @router.post('/process') def process_sharing_link(payload: SharingLinkRequest): """ Process sharing link to extract file list. Returns: request_id for polling result """ try: # Validate URL format if not payload.url or 'sharing' not in payload.url: raise HTTPException(status_code=400, detail="URL không hợp lệ") # Submit to worker queue result = nas_sharing_service.process_sharing_link(payload.url) return { "success": True, "request_id": result['request_id'], "status": result['status'], "message": "Đang xử lý sharing link..." } except Exception as e: logger.error(f"Error processing sharing link: {e}") raise HTTPException(status_code=500, detail=f"Lỗi hệ thống: {e}") @router.get('/related-projects') def get_related_projects(link_raw: str): """ Tìm tất cả GE projects có cùng sharing link (linkRaw). Query params: link_raw: Sharing link URL Returns: { "success": true, "projects": [ {"ge_id": "1000", "lang": "DE"}, {"ge_id": "2000", "lang": "KO"} ], "total": 2 } """ try: if not link_raw or not link_raw.strip(): raise HTTPException( status_code=400, detail="link_raw không được rỗng") link_raw_normalized = link_raw.strip() # Query MongoDB titles_data - tìm tất cả records có cùng linkRaw collection = mongodb_service.get_titles_collection() documents = list(collection.find( {"linkRaw": link_raw_normalized}, {"geId": 1, "lang": 1, "_id": 0} # Chỉ lấy geId và lang )) # Format kết quả projects = [ { "ge_id": doc.get("geId"), "lang": doc.get("lang") } for doc in documents ] logger.debug( f"Found {len(projects)} projects with linkRaw: {link_raw_normalized}") return { "success": True, "projects": projects, "total": len(projects) } except HTTPException: raise except Exception as e: logger.error(f"Error querying related projects: {e}") raise HTTPException(status_code=500, detail=f"Lỗi hệ thống: {e}") @router.get('/related-projects-by-ge') def get_related_projects_by_ge(ge_id: str, lang: str): """ Tìm tất cả GE projects có cùng sharing link dựa trên ge_id và lang. Query params: ge_id: GE ID lang: Language code Returns: { "success": true, "projects": [ {"ge_id": "1000", "lang": "DE"}, {"ge_id": "2000", "lang": "KO"} ], "total": 2 } """ try: if not ge_id or not ge_id.strip(): raise HTTPException( status_code=400, detail="ge_id không được rỗng") if not lang or not lang.strip(): raise HTTPException(status_code=400, detail="lang không được rỗng") # Step 1: Get linkRaw from titles_data link_raw = mongodb_service.get_sharing_link_from_tms_data(ge_id, lang) if not link_raw: return { "success": True, "projects": [], "total": 0 } # Step 2: Find all projects with same linkRaw collection = mongodb_service.get_titles_collection() documents = list(collection.find( {"linkRaw": link_raw}, {"geId": 1, "lang": 1, "_id": 0} )) # Format kết quả projects = [ { "ge_id": doc.get("geId"), "lang": doc.get("lang") } for doc in documents ] logger.debug( f"Found {len(projects)} related projects for GE {ge_id} {lang}") return { "success": True, "projects": projects, "total": len(projects) } except HTTPException: raise except Exception as e: logger.error(f"Error querying related projects by GE: {e}") raise HTTPException(status_code=500, detail=f"Lỗi hệ thống: {e}") @router.get('/project-note') def get_project_note(ge_id: str, lang: str): """ Lấy note từ collection titlelist_data cho GE project cụ thể. Query params: ge_id: GE ID lang: Language code Returns: { "success": true, "note": "Content of note field" } """ try: if not ge_id or not ge_id.strip(): raise HTTPException( status_code=400, detail="ge_id không được rỗng") if not lang or not lang.strip(): raise HTTPException(status_code=400, detail="lang không được rỗng") # Query MongoDB titlelist_data collection db = mongodb_service.get_db_connection() collection = db['titlelist_data'] document = collection.find_one( { "geId": str(ge_id).strip(), "lang": str(lang).strip().upper() }, {"note": 1, "_id": 0} ) if not document: return { "success": True, "note": None } note_content = document.get("note") logger.debug(f"Found note for {ge_id} {lang}: {bool(note_content)}") return { "success": True, "note": note_content if note_content else None } except HTTPException: raise except Exception as e: logger.error(f"Error querying project note: {e}") raise HTTPException(status_code=500, detail=f"Lỗi hệ thống: {e}") @router.get('/result/{request_id}') def get_sharing_result(request_id: str): """ Poll result of sharing link processing. Returns: - status: "pending" | "success" | "error" - If success: sharing_id, path, files, total_files - If error: message """ try: result = nas_sharing_service.get_sharing_result(request_id) if not result: raise HTTPException( status_code=404, detail="Request không tồn tại") return result except HTTPException: raise except Exception as e: logger.error(f"Error getting sharing result: {e}") raise HTTPException(status_code=500, detail=f"Lỗi hệ thống: {e}") # ==================== FOLDER NAVIGATION ==================== @router.post('/list-folder') def list_sharing_folder(payload: dict): """ List contents of a subfolder in sharing link. Used when user double-clicks a folder in sharing link file list. Payload: - sharing_id: Sharing ID from initial process - folder_path: Path to folder to list (e.g., "/subfolder_name") Returns: - status: "success" | "error" - files: List of files/folders - path: Current folder path """ try: sharing_id = payload.get('sharing_id') folder_path = payload.get('folder_path', '/') if not sharing_id: raise HTTPException( status_code=400, detail="sharing_id là bắt buộc") # CRITICAL FIX: Rate limiting to prevent Error 407 from rapid requests current_time = time.time() * 1000 # Convert to milliseconds last_time = _last_request_time[sharing_id] time_since_last = current_time - last_time if time_since_last < _rate_limit_window_ms: wait_time = (_rate_limit_window_ms - time_since_last) / 1000 logger.warning( f"Rate limit hit for {sharing_id}, rejecting request (wait {wait_time:.2f}s)") raise HTTPException( status_code=429, detail=f"Vui lòng chậm lại, đợi {wait_time:.1f}s trước khi thao tác tiếp" ) # Update last request time _last_request_time[sharing_id] = current_time # Get sharing worker instance worker = nas_sharing_service.get_sharing_worker() if not worker or not worker.driver: raise HTTPException( status_code=503, detail="Sharing worker không sẵn sàng") # CRITICAL FIX: Lock driver to prevent race conditions # Without lock, multiple users can navigate simultaneously and conflict with worker.driver_lock: # List folder using nas_sharing_api package from ..services.nas_sharing_api import get_file_list logger.debug( f"📂 [Navigation] Lấy danh sách subfolder: {folder_path}") files = get_file_list( driver=worker.driver, sharing_id=sharing_id, folder_path=folder_path ) logger.debug( f"✅ [Navigation] Tìm thấy {len(files)} items trong: {folder_path}") return { "status": "success", "files": files, "path": folder_path, "message": f"Tìm thấy {len(files)} item(s)" } except HTTPException: raise except Exception as e: logger.error(f"Error listing sharing folder: {e}") import traceback traceback.print_exc() raise HTTPException(status_code=500, detail=f"Lỗi hệ thống: {e}") # ==================== DOWNLOAD ENDPOINTS ==================== @router.post('/download') def download_sharing_files(payload: SharingLinkDownloadRequest): """ Download files from sharing link to NAS raw folder. If ge_id and lang provided, files go to \\NAS_PATH\\{ge_id}_{lang}\\ (same as API download). Otherwise, files go directly to \\NAS_PATH\\ root. """ try: logger.debug( f"Creating sharing link download job: {payload.sharing_id}, {len(payload.files)} files") if not payload.files: return { "status": "error", "message": "Không có file nào được chọn để tải xuống" } # Calculate destination path if payload.ge_id and payload.lang: # Use same logic as API download: \\172.16.14.240\\raw\\11_US\\ destination_path = get_download_destination_path( payload.ge_id, payload.lang) ge_id_for_db = payload.ge_id lang_for_db = payload.lang else: # Fallback: direct to root (backward compatible with old logic) destination_path = nas_service.DESTINATION_PATH ge_id_for_db = f"SHARING_{payload.sharing_id}" lang_for_db = "LINK" # ✅ FIX: Use downloads_service (NEW) instead of supabase_service (OLD) result = downloads_service.create_downloads_batch( files=payload.files, ge_id=ge_id_for_db, lang=lang_for_db, mode='sharing', # Sharing link mode sharing_id=payload.sharing_id, mongodb_path=None, # No MongoDB path for sharing links destination_path=destination_path ) if not result['success']: return { "status": "error", "message": result.get('message', 'Không thể tạo batch downloads') } logger.debug( f"Created sharing batch: {result['batch_id']} ({result['file_count']} files)") return { "status": "pending", "message": "Batch đã được tạo và đang chờ xử lý", "batch_id": result['batch_id'], "download_ids": result['download_ids'], "file_count": result['file_count'], "destination_path": destination_path, "sharing_id": payload.sharing_id } except Exception as e: logger.error(f"Error creating sharing download job: {e}") raise HTTPException(status_code=500, detail=f"Lỗi hệ thống: {e}") # ==================== OTP HANDLING ==================== @router.get('/download-status/{batch_id}') def get_sharing_download_status(batch_id: str): """ Get the status of a sharing link download batch. Returns batch summary with all files' status. """ try: summary = downloads_service.get_batch_summary(batch_id) if not summary: raise HTTPException(status_code=404, detail="Batch không tồn tại") return { "success": True, "batch": summary } except HTTPException: raise except Exception as e: logger.error(f"Error getting sharing download job status: {e}") raise HTTPException(status_code=500, detail=f"Lỗi hệ thống: {e}") # ==================== OTP HANDLING ==================== @router.get('/otp-status') def check_sharing_otp_status(): """ Check if sharing worker is waiting for OTP Returns: - otp_required: bool """ try: otp_required = nas_sharing_service.is_otp_required() return { "otp_required": otp_required, "message": "Vui lòng nhập mã OTP" if otp_required else "Không cần OTP" } except Exception as e: logger.error(f"Error checking OTP status: {e}") raise HTTPException(status_code=500, detail=f"Lỗi hệ thống: {e}") @router.post('/submit-otp') def submit_sharing_otp(payload: SharingOtpSubmit): """ Submit OTP code for sharing link login Args: otp_code: OTP code from user Returns: - status: "success" | "error" - message: Status message """ try: status, message = nas_sharing_service.submit_otp(payload.otp_code) if status == "success": return { "success": True, "message": message } else: raise HTTPException(status_code=400, detail=message) except HTTPException: raise except Exception as e: logger.error(f"Error submitting OTP: {e}") raise HTTPException(status_code=500, detail=f"Lỗi hệ thống: {e}")