""" Admin panel routes """ from fastapi import APIRouter, Request, Depends, HTTPException, Form, status from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from fastapi.templating import Jinja2Templates from sqlalchemy.ext.asyncio import AsyncSession from typing import Optional from web.utils.auth import get_current_user, verify_web_user, create_session, delete_session, is_owner_web from web.utils.database import get_db from web.utils.otp import verify_otp_code, get_user_by_identifier, create_otp_code from web.utils.csrf import verify_csrf, get_csrf_token from web.admin import views from shared.database.models import User, Task from web.admin.user_manager_web import add_user_web, remove_user_web, block_user_web, unblock_user_web, add_admin_web, remove_admin_web from bot.modules.task_scheduler.queue import task_queue, Task as QueueTask, TaskStatus from bot.modules.media_loader.ytdlp import get_media_info from bot.modules.access_control.auth import is_authorized from web.utils.bot_client import send_otp_to_user from shared.config import settings import logging import time import asyncio logger = logging.getLogger(__name__) router = APIRouter() templates = Jinja2Templates(directory="web/admin/templates") async def get_csrf_token_for_template(request: Request) -> Optional[str]: """ Get CSRF token for template. Args: request: FastAPI Request object Returns: CSRF token or None """ from web.utils.csrf import get_csrf_token, generate_csrf_token from web.utils.auth import get_session token = await get_csrf_token(request) if not token: # Generate new token token = generate_csrf_token() session_id = request.cookies.get("session_id") if session_id: session_data = await get_session(session_id) if session_data: session_data["csrf_token"] = token return token # ==================== Authentication ==================== @router.get("/login", response_class=HTMLResponse) async def login_page(request: Request): """Login page""" csrf_token = await get_csrf_token_for_template(request) return templates.TemplateResponse("login.html", { "request": request, "csrf_token": csrf_token }) @router.post("/api/otp/request") async def request_otp( request: Request, identifier: str = Form(...), csrf_token: Optional[str] = Form(None), db: AsyncSession = Depends(get_db) ): """API: Request OTP code by ID or username""" # CSRF token check (softer for unauthorized users) try: await verify_csrf(request, csrf_token) except HTTPException: # For OTP request allow without CSRF (user not yet authorized) # But check Origin/Referer for basic protection origin = request.headers.get("Origin") referer = request.headers.get("Referer") if not origin and not referer: logger.warning(f"OTP request without Origin/Referer from IP {request.client.host if request.client else 'unknown'}") try: user_id = None # Try to find user by identifier user = await get_user_by_identifier(identifier, db) if user: user_id = user.user_id else: # If user not found in database, try to determine user_id from identifier try: user_id = int(identifier) except ValueError: # This is username, try to find via Telegram API try: from bot.modules.task_scheduler.executor import get_app_client app_client = get_app_client() if app_client: username = identifier.lstrip('@') chat = await app_client.get_chat(username) if chat and hasattr(chat, 'id'): user_id = chat.id logger.info(f"Найден пользователь {user_id} по username {username} через Telegram API") except Exception as e: logger.debug(f"Не удалось найти пользователя по username {identifier}: {e}") if not user_id: # User not found in database or via Telegram API return JSONResponse( {"success": False, "message": "Пользователь не найден. Убедитесь, что вы используете правильный User ID или username. Для первого входа используйте User ID."}, status_code=404 ) # Check authorization (this can work even if user doesn't exist in database) if not await is_authorized(user_id): return JSONResponse( {"success": False, "message": "У вас нет доступа к боту. Обратитесь к администратору."}, status_code=403 ) # If user is authorized but doesn't exist in DB - create them if not user: from shared.database.models import User from datetime import datetime from bot.utils.user_info_updater import update_user_info_from_telegram from sqlalchemy.exc import IntegrityError # Check if user already exists (race condition protection) existing_user = await db.get(User, user_id) if existing_user: user = existing_user else: # Create user with basic information user = User( user_id=user_id, username=None, first_name=None, last_name=None, is_admin=False, is_blocked=False, created_at=datetime.utcnow() ) try: db.add(user) await db.commit() logger.info(f"Automatically created user {user_id} on OTP request") except IntegrityError: # User was created by another request, fetch it await db.rollback() user = await db.get(User, user_id) if not user: logger.error(f"Failed to create or fetch user {user_id}") return JSONResponse( {"success": False, "message": "Ошибка при создании пользователя"}, status_code=500 ) logger.info(f"User {user_id} already exists, using existing record") # Try to get information from Telegram API (async, doesn't block response) try: await update_user_info_from_telegram(user_id, db_session=db) except Exception as e: logger.warning(f"Failed to get user {user_id} information from Telegram: {e}") # Continue working, information will be updated by background task later # Generate OTP code code = await create_otp_code(user_id, db) if not code: return JSONResponse( {"success": False, "message": "Не удалось создать код. Попробуйте позже."}, status_code=500 ) # Send code to user in Telegram sent = await send_otp_to_user(user_id, code) if not sent: # If failed to send, return warning logger.warning(f"Failed to send OTP to user {user_id}, but code created") return JSONResponse({ "success": True, "message": f"Код создан: **{code}**. Не удалось отправить в Telegram (бот может быть не запущен). Используйте этот код для входа.", "code": code # Return code in response for debugging }) return JSONResponse({ "success": True, "message": "Код отправлен вам в Telegram. Проверьте сообщения от бота." }) except Exception as e: logger.error(f"Error requesting OTP: {e}", exc_info=True) return JSONResponse( {"success": False, "message": f"Ошибка при создании кода: {str(e)}"}, status_code=500 ) @router.post("/login") async def login( request: Request, otp_code: str = Form(None), user_id: int = Form(None), db: AsyncSession = Depends(get_db) ): """Handle login via OTP or direct user_id (for admins)""" # Get IP address for rate limiting client_ip = request.client.host if request.client else None # If OTP code provided if otp_code: user_id_from_otp = await verify_otp_code(otp_code, db, ip_address=client_ip) if not user_id_from_otp: return templates.TemplateResponse( "login.html", {"request": request, "error": "Неверный или истекший код"}, status_code=status.HTTP_401_UNAUTHORIZED ) user_id = user_id_from_otp # If user_id provided directly (alternative login method) if user_id: # Check user authorization if not await is_authorized(user_id): return templates.TemplateResponse( "login.html", {"request": request, "error": "У вас нет доступа к боту. Обратитесь к администратору."}, status_code=status.HTTP_403_FORBIDDEN ) # If user is authorized but doesn't exist in DB - create them existing_user = await db.get(User, user_id) if not existing_user: from datetime import datetime from bot.utils.user_info_updater import update_user_info_from_telegram from sqlalchemy.exc import IntegrityError # Create user with basic information new_user = User( user_id=user_id, username=None, first_name=None, last_name=None, is_admin=False, is_blocked=False, created_at=datetime.utcnow() ) try: db.add(new_user) await db.commit() logger.info(f"Automatically created user {user_id} on login") except IntegrityError: # User was created by another request, fetch it await db.rollback() existing_user = await db.get(User, user_id) if not existing_user: logger.error(f"Failed to create or fetch user {user_id}") return templates.TemplateResponse( "login.html", {"request": request, "error": "Ошибка при создании пользователя"}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR ) logger.info(f"User {user_id} already exists, using existing record") # Try to get information from Telegram API (async, doesn't block response) try: await update_user_info_from_telegram(user_id, db_session=db) except Exception as e: logger.warning(f"Failed to get user {user_id} information from Telegram: {e}") # Continue working, information will be updated by background task later else: return templates.TemplateResponse( "login.html", {"request": request, "error": "Необходимо указать код или User ID"}, status_code=status.HTTP_400_BAD_REQUEST ) # Create session session_id = await create_session(user_id) # Set cookie response = RedirectResponse(url="/admin/", status_code=status.HTTP_302_FOUND) response.set_cookie( key="session_id", value=session_id, httponly=True, secure=False, # Set True for HTTPS in production samesite="lax", max_age=86400 * 7 # 7 days ) return response @router.get("/logout") async def logout(request: Request): """Logout""" session_id = request.cookies.get("session_id") if session_id: await delete_session(session_id) response = RedirectResponse(url="/admin/login", status_code=status.HTTP_302_FOUND) response.delete_cookie("session_id") return response # ==================== Protected routes ==================== @router.get("/", response_class=HTMLResponse) async def admin_dashboard( request: Request, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Main admin page (dashboard)""" try: # Get statistics users_stats = await views.get_users_stats(db) tasks_stats = await views.get_tasks_stats(db) # Get recent tasks recent_tasks = await views.get_all_tasks(db, limit=10) return templates.TemplateResponse( "dashboard.html", { "request": request, "current_user": current_user, "users_stats": users_stats, "tasks_stats": tasks_stats, "recent_tasks": recent_tasks, "is_owner": current_user.get("is_owner", False), "csrf_token": await get_csrf_token_for_template(request) } ) except Exception as e: logger.error(f"Ошибка при загрузке дашборда: {e}") raise HTTPException(status_code=500, detail="Ошибка при загрузке данных") @router.get("/users", response_class=HTMLResponse) async def users_page( request: Request, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), search: Optional[str] = None ): """User management page""" try: users = await views.get_all_users(db, search=search) is_owner = current_user.get("is_owner", False) return templates.TemplateResponse( "users.html", { "request": request, "current_user": current_user, "users": users, "is_owner": is_owner, "search": search or "", "csrf_token": await get_csrf_token_for_template(request) } ) except Exception as e: logger.error(f"Ошибка при загрузке пользователей: {e}") raise HTTPException(status_code=500, detail="Ошибка при загрузке данных") @router.get("/tasks", response_class=HTMLResponse) async def tasks_page( request: Request, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), status_filter: Optional[str] = None, user_id: Optional[int] = None ): """Task management page""" try: tasks = await views.get_all_tasks( db, status_filter=status_filter, user_id_filter=user_id ) return templates.TemplateResponse( "tasks.html", { "request": request, "current_user": current_user, "tasks": tasks, "status_filter": status_filter or "", "user_id_filter": user_id, "csrf_token": await get_csrf_token_for_template(request) } ) except Exception as e: logger.error(f"Ошибка при загрузке задач: {e}") raise HTTPException(status_code=500, detail="Ошибка при загрузке данных") @router.get("/create-task", response_class=HTMLResponse) async def create_task_page( request: Request, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Create task page""" try: # Get user tasks to display limits user_tasks = await views.get_all_tasks(db, user_id_filter=current_user["user_id"]) active_tasks = [t for t in user_tasks if t.status in ["pending", "processing"]] # Get CSRF token for form csrf_token = await get_csrf_token_for_template(request) return templates.TemplateResponse( "create_task.html", { "request": request, "current_user": current_user, "active_tasks_count": len(active_tasks), "max_concurrent_tasks": settings.MAX_CONCURRENT_TASKS, "max_file_size": settings.max_file_size_bytes, "max_duration": settings.max_duration_minutes_int, "csrf_token": csrf_token } ) except Exception as e: logger.error(f"Ошибка при загрузке страницы создания задачи: {e}") raise HTTPException(status_code=500, detail="Ошибка при загрузке данных") # ==================== API Endpoints ==================== @router.post("/api/users/add") async def api_add_user( request: Request, user_id: int = Form(...), csrf_token: Optional[str] = Form(None), current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """API: Add user""" # CSRF token check await verify_csrf(request, csrf_token) try: result = await add_user_web(user_id, db) if result["success"]: return JSONResponse({"success": True, "message": result["message"]}) else: return JSONResponse( {"success": False, "message": result["message"]}, status_code=400 ) except Exception as e: logger.error(f"Ошибка при добавлении пользователя: {e}") return JSONResponse( {"success": False, "message": "Ошибка при добавлении пользователя"}, status_code=500 ) @router.post("/api/users/remove") async def api_remove_user( request: Request, user_id: int = Form(...), csrf_token: Optional[str] = Form(None), current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """API: Remove user""" # CSRF token check await verify_csrf(request, csrf_token) try: result = await remove_user_web(user_id, db) if result["success"]: return JSONResponse({"success": True, "message": result["message"]}) else: return JSONResponse( {"success": False, "message": result["message"]}, status_code=400 ) except Exception as e: logger.error(f"Ошибка при удалении пользователя: {e}") return JSONResponse( {"success": False, "message": "Ошибка при удалении пользователя"}, status_code=500 ) @router.post("/api/users/block") async def api_block_user( request: Request, user_id: int = Form(...), csrf_token: Optional[str] = Form(None), current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """API: Block user""" # CSRF token check await verify_csrf(request, csrf_token) try: result = await block_user_web(user_id, db) if result["success"]: return JSONResponse({"success": True, "message": result["message"]}) else: return JSONResponse( {"success": False, "message": result["message"]}, status_code=400 ) except Exception as e: logger.error(f"Ошибка при блокировке пользователя: {e}") return JSONResponse( {"success": False, "message": "Ошибка при блокировке пользователя"}, status_code=500 ) @router.post("/api/users/unblock") async def api_unblock_user( request: Request, user_id: int = Form(...), csrf_token: Optional[str] = Form(None), current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """API: Unblock user""" # CSRF token check await verify_csrf(request, csrf_token) try: result = await unblock_user_web(user_id, db) if result["success"]: return JSONResponse({"success": True, "message": result["message"]}) else: return JSONResponse( {"success": False, "message": result["message"]}, status_code=400 ) except Exception as e: logger.error(f"Ошибка при разблокировке пользователя: {e}") return JSONResponse( {"success": False, "message": "Ошибка при разблокировке пользователя"}, status_code=500 ) @router.post("/api/users/add-admin") async def api_add_admin( request: Request, user_id: int = Form(...), csrf_token: Optional[str] = Form(None), current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """API: Add administrator (Owner only)""" # CSRF token check await verify_csrf(request, csrf_token) if not current_user.get("is_owner", False): return JSONResponse( {"success": False, "message": "Только Owner может добавлять администраторов"}, status_code=403 ) try: result = await add_admin_web(user_id, db) if result["success"]: return JSONResponse({"success": True, "message": result["message"]}) else: return JSONResponse( {"success": False, "message": result["message"]}, status_code=400 ) except Exception as e: logger.error(f"Ошибка при добавлении администратора: {e}") return JSONResponse( {"success": False, "message": "Ошибка при добавлении администратора"}, status_code=500 ) @router.post("/api/users/remove-admin") async def api_remove_admin( request: Request, user_id: int = Form(...), csrf_token: Optional[str] = Form(None), current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """API: Remove administrator (Owner only)""" # CSRF token check await verify_csrf(request, csrf_token) if not current_user.get("is_owner", False): return JSONResponse( {"success": False, "message": "Только Owner может удалять администраторов"}, status_code=403 ) try: result = await remove_admin_web(user_id, db) if result["success"]: return JSONResponse({"success": True, "message": result["message"]}) else: return JSONResponse( {"success": False, "message": result["message"]}, status_code=400 ) except Exception as e: logger.error(f"Ошибка при удалении администратора: {e}") return JSONResponse( {"success": False, "message": "Ошибка при удалении администратора"}, status_code=500 ) @router.post("/api/tasks/create") async def api_create_task( request: Request, url: str = Form(...), csrf_token: Optional[str] = Form(None), current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """API: Create download task""" # CSRF token check try: await verify_csrf(request, csrf_token) except HTTPException as e: logger.warning(f"CSRF check failed for task creation: {e.detail}") return JSONResponse( {"success": False, "message": "Невалидный CSRF токен"}, status_code=403 ) from bot.modules.task_scheduler.executor import task_executor, set_app_client from bot.modules.access_control.auth import is_authorized from bot.utils.helpers import is_valid_url user_id = current_user["user_id"] # URL validation if not is_valid_url(url): return JSONResponse( {"success": False, "message": "Некорректный или небезопасный URL"}, status_code=400 ) # Check authorization if not await is_authorized(user_id): return JSONResponse( {"success": False, "message": "У вас нет доступа к боту"}, status_code=403 ) try: # Check concurrent tasks count user_tasks = await views.get_all_tasks(db, user_id_filter=user_id) active_tasks = [t for t in user_tasks if t.status in ["pending", "processing"]] if len(active_tasks) >= settings.MAX_CONCURRENT_TASKS: return JSONResponse( { "success": False, "message": f"Превышен лимит одновременных задач ({settings.MAX_CONCURRENT_TASKS})" }, status_code=400 ) # Duplicate URL check will be performed atomically in task_queue.add_task() url_normalized = url.strip() # Get media information to check limits # If getting info takes too long, skip check media_info = None try: from shared.config import settings media_info = await asyncio.wait_for( get_media_info(url, cookies_file=settings.COOKIES_FILE), timeout=10.0 ) except asyncio.TimeoutError: logger.warning(f"Timeout getting media info for URL: {url}") media_info = None except Exception as e: logger.warning(f"Failed to get media info: {e}") media_info = None if media_info: # Check duration max_duration = settings.max_duration_minutes_int if max_duration and media_info.get('duration'): duration_minutes = media_info['duration'] / 60 if duration_minutes > max_duration: return JSONResponse( { "success": False, "message": f"Файл слишком длинный ({duration_minutes:.1f} мин). Максимум: {max_duration} мин." }, status_code=400 ) # Generate unique task_id using UUID from bot.utils.helpers import generate_unique_task_id task_id = generate_unique_task_id() # Check that such ID doesn't exist yet (in case of collision, though probability is extremely low) existing_task = await task_queue.get_task_by_id(task_id) max_retries = 10 retries = 0 while existing_task and retries < max_retries: task_id = generate_unique_task_id() existing_task = await task_queue.get_task_by_id(task_id) retries += 1 if existing_task: # If after 10 attempts still collision (extremely unlikely), return error logger.error(f"Failed to generate unique task_id after {max_retries} attempts") return JSONResponse( {"success": False, "message": "Ошибка при создании задачи. Попробуйте позже."}, status_code=500 ) task = QueueTask( id=task_id, user_id=user_id, task_type="download", url=url_normalized, status=TaskStatus.PENDING ) # Save task to database BEFORE adding to queue (race condition fix) try: from shared.database.models import Task as DBTask from shared.database.user_helpers import ensure_user_exists from datetime import datetime from sqlalchemy.exc import IntegrityError # Ensure user exists before creating task await ensure_user_exists(user_id, db) db_task = DBTask( id=task_id, user_id=user_id, task_type=task.task_type, status=task.status.value, url=task.url, progress=0, created_at=datetime.utcnow(), updated_at=datetime.utcnow() ) db.add(db_task) await db.commit() logger.info(f"Task {task_id} saved to database") except IntegrityError as e: await db.rollback() logger.error(f"IntegrityError saving task {task_id} to database (possibly duplicate ID): {e}", exc_info=True) # Generate new task_id and retry from bot.utils.helpers import generate_unique_task_id task_id = generate_unique_task_id() task.id = task_id try: from shared.database.user_helpers import ensure_user_exists # Ensure user exists before creating task await ensure_user_exists(user_id, db) db_task = DBTask( id=task_id, user_id=user_id, task_type=task.task_type, status=task.status.value, url=task.url, progress=0, created_at=datetime.utcnow(), updated_at=datetime.utcnow() ) db.add(db_task) await db.commit() logger.info(f"Task {task_id} saved to database with new ID") except Exception as e2: await db.rollback() logger.error(f"Error saving task {task_id} to database again: {e2}", exc_info=True) return JSONResponse( {"success": False, "message": f"Ошибка при создании задачи: {str(e2)}"}, status_code=500 ) except Exception as e: await db.rollback() logger.error(f"Error saving task {task_id} to database: {e}", exc_info=True) return JSONResponse( {"success": False, "message": f"Ошибка при создании задачи: {str(e)}"}, status_code=500 ) # Add to queue (with duplicate URL check) AFTER saving to database success = await task_queue.add_task(task, check_duplicate_url=True) if not success: # If failed to add to queue, remove from database try: await db.delete(db_task) await db.commit() except Exception as e: await db.rollback() logger.error(f"Error deleting task {task_id} from database after failed queue addition: {e}") return JSONResponse( { "success": False, "message": "Задача с этим URL уже обрабатывается. Дождитесь завершения или отмените предыдущую задачу." }, status_code=400 ) # Start executor if needed from bot.modules.task_scheduler.executor import get_app_client app_client = get_app_client() if app_client: set_app_client(app_client) if not task_executor._running: await task_executor.start() return JSONResponse({ "success": True, "message": "Задача успешно создана и добавлена в очередь", "task_id": task_id }) except Exception as e: logger.error(f"Error creating task: {e}", exc_info=True) return JSONResponse( {"success": False, "message": f"Ошибка при создании задачи: {str(e)}"}, status_code=500 ) @router.get("/api/stats") async def api_stats( request: Request, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """API: Get statistics""" try: users_stats = await views.get_users_stats(db) tasks_stats = await views.get_tasks_stats(db) return JSONResponse({ "users": users_stats, "tasks": tasks_stats }) except Exception as e: logger.error(f"Ошибка при получении статистики: {e}") return JSONResponse( {"error": "Ошибка при получении статистики"}, status_code=500 )