Add source

This commit is contained in:
2025-12-04 00:12:56 +03:00
parent b75875df5e
commit 0cb7045e7a
75 changed files with 9055 additions and 0 deletions

859
web/admin/routes.py Normal file
View File

@@ -0,0 +1,859 @@
"""
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
)