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

4
web/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""
Web application for administrative management
"""

4
web/admin/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""
Web site admin panel
"""

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
)

View File

@@ -0,0 +1,2 @@
# Директория для статических файлов (CSS, JS, изображения)

View File

@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}TGLoader Admin Panel{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
.sidebar {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.sidebar .nav-link {
color: rgba(255, 255, 255, 0.8);
padding: 0.75rem 1rem;
margin: 0.25rem 0;
border-radius: 0.5rem;
transition: all 0.3s;
}
.sidebar .nav-link:hover, .sidebar .nav-link.active {
background-color: rgba(255, 255, 255, 0.2);
color: white;
}
.main-content {
background-color: #f8f9fa;
min-height: 100vh;
}
.stat-card {
border: none;
border-radius: 1rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
transition: transform 0.3s;
}
.stat-card:hover {
transform: translateY(-5px);
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<div class="container-fluid">
<div class="row">
<!-- Sidebar -->
<nav class="col-md-3 col-lg-2 sidebar p-3">
<div class="d-flex flex-column">
<h4 class="text-white mb-4">
<i class="bi bi-robot"></i> TGLoader
</h4>
<ul class="nav nav-pills flex-column">
<li class="nav-item">
<a class="nav-link {% if request.url.path == '/admin/' %}active{% endif %}" href="/admin/">
<i class="bi bi-speedometer2 me-2"></i> Дашборд
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if '/admin/users' in request.url.path %}active{% endif %}" href="/admin/users">
<i class="bi bi-people me-2"></i> Пользователи
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if '/admin/tasks' in request.url.path %}active{% endif %}" href="/admin/tasks">
<i class="bi bi-list-task me-2"></i> Задачи
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if '/admin/create-task' in request.url.path %}active{% endif %}" href="/admin/create-task">
<i class="bi bi-plus-circle me-2"></i> Создать задачу
</a>
</li>
</ul>
<hr class="text-white">
<div class="mt-auto">
<a class="nav-link" href="/admin/logout">
<i class="bi bi-box-arrow-right me-2"></i> Выход
</a>
</div>
</div>
</nav>
<!-- Main Content -->
<main class="col-md-9 col-lg-10 main-content p-4">
{% block content %}{% endblock %}
</main>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
<script>
// Глобальная функция для получения CSRF токена для AJAX запросов
function getCSRFToken() {
const tokenInput = document.querySelector('input[name="csrf_token"]');
return tokenInput ? tokenInput.value : '';
}
// Перехватываем все fetch запросы и добавляем CSRF токен
const originalFetch = window.fetch;
window.fetch = function(url, options = {}) {
if (options.method && options.method.toUpperCase() === 'POST') {
const token = getCSRFToken();
if (token) {
if (!options.headers) {
options.headers = {};
}
if (!(options.headers instanceof Headers)) {
options.headers['X-CSRF-Token'] = token;
}
}
}
return originalFetch(url, options);
};
</script>
</body>
</html>

View File

@@ -0,0 +1,142 @@
{% extends "base.html" %}
{% block title %}Создать задачу - TGLoader Admin{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-plus-circle me-2"></i>Создать задачу на загрузку</h1>
<a href="/admin/tasks" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Назад к задачам
</a>
</div>
<!-- Информация о лимитах -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h6 class="text-muted">Активные задачи</h6>
<h3>{{ active_tasks_count }} / {{ max_concurrent_tasks }}</h3>
</div>
</div>
</div>
{% if max_file_size %}
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h6 class="text-muted">Макс. размер файла</h6>
<h3>{{ (max_file_size / (1024*1024))|round(1) }} MB</h3>
</div>
</div>
</div>
{% endif %}
{% if max_duration %}
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h6 class="text-muted">Макс. длительность</h6>
<h3>{{ max_duration }} мин</h3>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Форма создания задачи -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Новая задача</h5>
</div>
<div class="card-body">
<form id="createTaskForm">
{% if csrf_token %}
<input type="hidden" name="csrf_token" id="csrf_token" value="{{ csrf_token }}">
{% endif %}
<div class="mb-3">
<label for="url" class="form-label">URL для загрузки</label>
<input type="url" class="form-control" id="url" name="url"
placeholder="https://www.youtube.com/watch?v=..." required>
<small class="form-text text-muted">
Поддерживаются YouTube, Instagram, прямые ссылки и другие платформы
</small>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-cloud-download me-2"></i>Создать задачу
</button>
</div>
</form>
</div>
</div>
{% block extra_js %}
<script>
function showAlert(message, type = 'success') {
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type} alert-dismissible fade show`;
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
const cardBody = document.querySelector('.card-body');
cardBody.insertBefore(alertDiv, cardBody.firstChild);
setTimeout(() => alertDiv.remove(), 5000);
}
let isSubmitting = false; // Флаг для предотвращения двойной отправки
document.getElementById('createTaskForm').addEventListener('submit', async (e) => {
e.preventDefault();
// Защита от двойной отправки
if (isSubmitting) {
return;
}
const form = e.target;
const formData = new FormData(form);
const submitBtn = form.querySelector('button[type="submit"]');
// Получаем CSRF токен из формы или мета-тега
const csrfTokenInput = document.getElementById('csrf_token');
const csrfToken = csrfTokenInput ? csrfTokenInput.value :
(document.querySelector('meta[name="csrf-token"]')?.content || '');
isSubmitting = true;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Создание...';
try {
const response = await fetch('/admin/api/tasks/create', {
method: 'POST',
body: formData,
headers: {
'X-CSRF-Token': csrfToken || ''
}
});
const data = await response.json();
if (data.success) {
showAlert(data.message, 'success');
setTimeout(() => {
window.location.href = '/admin/tasks';
}, 1500);
} else {
showAlert(data.message, 'danger');
isSubmitting = false;
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="bi bi-cloud-download me-2"></i>Создать задачу';
}
} catch (error) {
showAlert('Ошибка при создании задачи', 'danger');
isSubmitting = false;
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="bi bi-cloud-download me-2"></i>Создать задачу';
}
});
</script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,227 @@
{% extends "base.html" %}
{% block title %}Дашборд - TGLoader Admin{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-speedometer2 me-2"></i>Дашборд</h1>
<div>
<span class="badge bg-secondary">User ID: {{ current_user.user_id }}</span>
{% if is_owner %}
<span class="badge bg-danger ms-2">Owner</span>
{% else %}
<span class="badge bg-primary ms-2">Admin</span>
{% endif %}
</div>
</div>
<!-- Статистика пользователей -->
<div class="row mb-4">
<div class="col-md-12">
<h5 class="mb-3"><i class="bi bi-people me-2"></i>Пользователи</h5>
</div>
<div class="col-md-3 mb-3">
<div class="card stat-card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="stat-icon bg-primary text-white me-3">
<i class="bi bi-people"></i>
</div>
<div>
<h6 class="text-muted mb-0">Всего</h6>
<h3 class="mb-0">{{ users_stats.total }}</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card stat-card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="stat-icon bg-success text-white me-3">
<i class="bi bi-shield-check"></i>
</div>
<div>
<h6 class="text-muted mb-0">Администраторы</h6>
<h3 class="mb-0">{{ users_stats.admins }}</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card stat-card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="stat-icon bg-danger text-white me-3">
<i class="bi bi-ban"></i>
</div>
<div>
<h6 class="text-muted mb-0">Заблокировано</h6>
<h3 class="mb-0">{{ users_stats.blocked }}</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card stat-card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="stat-icon bg-info text-white me-3">
<i class="bi bi-person-plus"></i>
</div>
<div>
<h6 class="text-muted mb-0">Новых (7 дней)</h6>
<h3 class="mb-0">{{ users_stats.new_week }}</h3>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Статистика задач -->
<div class="row mb-4">
<div class="col-md-12">
<h5 class="mb-3"><i class="bi bi-list-task me-2"></i>Задачи</h5>
</div>
<div class="col-md-3 mb-3">
<div class="card stat-card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="stat-icon bg-primary text-white me-3">
<i class="bi bi-list-ul"></i>
</div>
<div>
<h6 class="text-muted mb-0">Всего</h6>
<h3 class="mb-0">{{ tasks_stats.total }}</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card stat-card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="stat-icon bg-warning text-white me-3">
<i class="bi bi-hourglass-split"></i>
</div>
<div>
<h6 class="text-muted mb-0">Активные</h6>
<h3 class="mb-0">{{ tasks_stats.active }}</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card stat-card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="stat-icon bg-success text-white me-3">
<i class="bi bi-check-circle"></i>
</div>
<div>
<h6 class="text-muted mb-0">Завершено</h6>
<h3 class="mb-0">{{ tasks_stats.completed }}</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card stat-card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="stat-icon bg-danger text-white me-3">
<i class="bi bi-x-circle"></i>
</div>
<div>
<h6 class="text-muted mb-0">Ошибки</h6>
<h3 class="mb-0">{{ tasks_stats.failed }}</h3>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Последние задачи -->
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Последние задачи</h5>
</div>
<div class="card-body">
{% if recent_tasks %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>Пользователь</th>
<th>URL</th>
<th>Статус</th>
<th>Прогресс</th>
<th>Создано</th>
</tr>
</thead>
<tbody>
{% for task in recent_tasks %}
<tr>
<td>{{ task.id }}</td>
<td>
{% if task.user %}
{{ task.user.username or task.user.user_id }}
{% else %}
{{ task.user_id }}
{% endif %}
</td>
<td>
<small class="text-muted">
{{ task.url[:50] + '...' if task.url and task.url|length > 50 else (task.url or '-') }}
</small>
</td>
<td>
{% if task.status == 'completed' %}
<span class="badge bg-success">Завершено</span>
{% elif task.status == 'processing' %}
<span class="badge bg-warning">В процессе</span>
{% elif task.status == 'pending' %}
<span class="badge bg-info">Ожидание</span>
{% elif task.status == 'failed' %}
<span class="badge bg-danger">Ошибка</span>
{% elif task.status == 'cancelled' %}
<span class="badge bg-secondary">Отменено</span>
{% else %}
<span class="badge bg-secondary">{{ task.status }}</span>
{% endif %}
</td>
<td>
<div class="progress" style="height: 20px; width: 100px;">
<div class="progress-bar" role="progressbar"
style="width: {{ task.progress }}%"
aria-valuenow="{{ task.progress }}"
aria-valuemin="0"
aria-valuemax="100">
{{ task.progress }}%
</div>
</div>
</td>
<td><small>{{ task.created_at.strftime('%Y-%m-%d %H:%M') if task.created_at else '-' }}</small></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted text-center">Нет задач</p>
{% endif %}
<div class="text-center mt-3">
<a href="/admin/tasks" class="btn btn-outline-primary">Все задачи</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,220 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Вход - TGLoader Admin</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
border: none;
border-radius: 1rem;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
max-width: 450px;
width: 100%;
}
.login-tabs {
border-bottom: 2px solid #dee2e6;
}
.nav-link {
color: #6c757d;
}
.nav-link.active {
color: #667eea;
border-bottom-color: #667eea;
}
</style>
</head>
<body>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card login-card">
<div class="card-body p-5">
<div class="text-center mb-4">
<i class="bi bi-robot" style="font-size: 3rem; color: #667eea;"></i>
<h2 class="mt-3">TGLoader</h2>
<p class="text-muted">Вход в веб-интерфейс</p>
</div>
{% if error %}
<div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
</div>
{% endif %}
<!-- Вкладки -->
<ul class="nav nav-tabs login-tabs mb-4" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="otp-tab" data-bs-toggle="tab"
data-bs-target="#otp-pane" type="button" role="tab">
<i class="bi bi-key me-2"></i>По коду
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-tab" data-bs-toggle="tab"
data-bs-target="#admin-pane" type="button" role="tab">
<i class="bi bi-shield-check me-2"></i>Админ (по ID)
</button>
</li>
</ul>
<div class="tab-content">
<!-- Вход по OTP коду -->
<div class="tab-pane fade show active" id="otp-pane" role="tabpanel">
<div class="mb-3">
<label for="identifier" class="form-label">Telegram User ID или Username</label>
<input type="text" class="form-control" id="identifier"
placeholder="Введите ваш User ID или @username">
<small class="form-text text-muted">
Код будет отправлен вам в Telegram
</small>
</div>
<button type="button" class="btn btn-outline-primary w-100 mb-3"
onclick="requestOTP()">
<i class="bi bi-send me-2"></i>Получить код
</button>
<div id="otp-input-group" style="display: none;">
<div class="mb-3">
<label for="otp_code" class="form-label">Одноразовый код</label>
<input type="text" class="form-control" id="otp_code" name="otp_code"
placeholder="Введите код из Telegram" maxlength="6">
<small class="form-text text-muted">
Код действителен 10 минут
</small>
</div>
<form method="POST" action="/admin/login" id="otp-form">
{% if csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% endif %}
<input type="hidden" name="otp_code" id="otp_code_hidden">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-box-arrow-in-right me-2"></i>Войти
</button>
</form>
</div>
<div class="mt-3 text-center">
<small class="text-muted">
Или используйте команду <code>/login</code> в боте для получения кода
</small>
</div>
</div>
<!-- Вход по User ID (альтернативный способ) -->
<div class="tab-pane fade" id="admin-pane" role="tabpanel">
<form method="POST" action="/admin/login">
{% if csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% endif %}
<input type="hidden" name="user_id" id="admin_user_id">
<div class="mb-3">
<label for="admin_user_id_input" class="form-label">Telegram User ID</label>
<input type="number" class="form-control" id="admin_user_id_input"
placeholder="Введите ваш Telegram User ID" required>
<small class="form-text text-muted">
Альтернативный способ входа (без OTP кода)
</small>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-box-arrow-in-right me-2"></i>Войти
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
function showAlert(message, type = 'success') {
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type} alert-dismissible fade show`;
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
const cardBody = document.querySelector('.card-body');
cardBody.insertBefore(alertDiv, cardBody.firstChild);
setTimeout(() => alertDiv.remove(), 5000);
}
async function requestOTP() {
const identifier = document.getElementById('identifier').value.trim();
if (!identifier) {
showAlert('Введите User ID или username', 'danger');
return;
}
const btn = event.target;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Отправка...';
try {
const formData = new FormData();
formData.append('identifier', identifier);
const response = await fetch('/admin/api/otp/request', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
showAlert(data.message, 'success');
document.getElementById('otp-input-group').style.display = 'block';
document.getElementById('otp_code').focus();
// Если код возвращен в ответе (не удалось отправить в Telegram)
if (data.code) {
document.getElementById('otp_code').value = data.code;
showAlert(`Код не был отправлен в Telegram. Используйте код: ${data.code}`, 'warning');
}
} else {
showAlert(data.message, 'danger');
}
} catch (error) {
showAlert('Ошибка при запросе кода', 'danger');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-send me-2"></i>Получить код';
}
}
// Обработка формы OTP
document.getElementById('otp-form').addEventListener('submit', function(e) {
const code = document.getElementById('otp_code').value.trim();
if (!code) {
e.preventDefault();
showAlert('Введите код', 'danger');
return;
}
document.getElementById('otp_code_hidden').value = code;
});
// Обработка формы админа
document.getElementById('admin_user_id_input').addEventListener('input', function(e) {
document.getElementById('admin_user_id').value = e.target.value;
});
// Enter для запроса OTP
document.getElementById('identifier').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
requestOTP();
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,153 @@
{% extends "base.html" %}
{% block title %}Задачи - TGLoader Admin{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-list-task me-2"></i>Управление задачами</h1>
</div>
<!-- Фильтры -->
<div class="card mb-4">
<div class="card-body">
<form method="GET" action="/admin/tasks" class="row g-3">
<div class="col-md-4">
<label class="form-label">Статус</label>
<select class="form-select" name="status_filter">
<option value="">Все</option>
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>Ожидание</option>
<option value="processing" {% if status_filter == 'processing' %}selected{% endif %}>В процессе</option>
<option value="completed" {% if status_filter == 'completed' %}selected{% endif %}>Завершено</option>
<option value="failed" {% if status_filter == 'failed' %}selected{% endif %}>Ошибка</option>
<option value="cancelled" {% if status_filter == 'cancelled' %}selected{% endif %}>Отменено</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">User ID</label>
<input type="number" class="form-control" name="user_id"
placeholder="Фильтр по User ID"
value="{{ user_id_filter or '' }}">
</div>
<div class="col-md-4">
<label class="form-label">&nbsp;</label>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-funnel me-2"></i>Применить фильтры
</button>
</div>
</form>
</div>
</div>
<!-- Таблица задач -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Список задач</h5>
</div>
<div class="card-body">
{% if tasks %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>Пользователь</th>
<th>Тип</th>
<th>URL</th>
<th>Статус</th>
<th>Прогресс</th>
<th>Создано</th>
<th>Обновлено</th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr>
<td><code>{{ task.id }}</code></td>
<td>
{% if task.user %}
<strong>{{ task.user.username or task.user.user_id }}</strong>
<br><small class="text-muted">ID: {{ task.user_id }}</small>
{% else %}
{{ task.user_id }}
{% endif %}
</td>
<td><span class="badge bg-info">{{ task.task_type }}</span></td>
<td>
{% if task.url %}
<a href="{{ task.url }}" target="_blank" class="text-decoration-none">
{{ task.url[:60] + '...' if task.url|length > 60 else task.url }}
<i class="bi bi-box-arrow-up-right ms-1"></i>
</a>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if task.status == 'completed' %}
<span class="badge bg-success">Завершено</span>
{% elif task.status == 'processing' %}
<span class="badge bg-warning">В процессе</span>
{% elif task.status == 'pending' %}
<span class="badge bg-info">Ожидание</span>
{% elif task.status == 'failed' %}
<span class="badge bg-danger">Ошибка</span>
{% elif task.status == 'cancelled' %}
<span class="badge bg-secondary">Отменено</span>
{% else %}
<span class="badge bg-secondary">{{ task.status }}</span>
{% endif %}
</td>
<td>
<div class="d-flex align-items-center">
<div class="progress me-2" style="height: 20px; width: 100px;">
<div class="progress-bar
{% if task.status == 'completed' %}bg-success
{% elif task.status == 'failed' %}bg-danger
{% elif task.status == 'processing' %}bg-warning
{% else %}bg-info{% endif %}"
role="progressbar"
style="width: {{ task.progress }}%"
aria-valuenow="{{ task.progress }}"
aria-valuemin="0"
aria-valuemax="100">
{{ task.progress }}%
</div>
</div>
</div>
</td>
<td><small>{{ task.created_at.strftime('%Y-%m-%d %H:%M') if task.created_at else '-' }}</small></td>
<td><small>{{ task.updated_at.strftime('%Y-%m-%d %H:%M') if task.updated_at else '-' }}</small></td>
</tr>
{% if task.error_message %}
<tr>
<td colspan="8">
<div class="alert alert-danger mb-0 py-2">
<small><strong>Ошибка:</strong> {{ task.error_message }}</small>
</div>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted text-center">Задачи не найдены</p>
{% endif %}
</div>
</div>
{% block extra_js %}
<script>
// Автообновление каждые 5 секунд для активных задач
setInterval(() => {
const hasActiveTasks = Array.from(document.querySelectorAll('.badge')).some(
badge => badge.textContent.includes('В процессе') || badge.textContent.includes('Ожидание')
);
if (hasActiveTasks) {
location.reload();
}
}, 5000);
</script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,228 @@
{% extends "base.html" %}
{% block title %}Пользователи - TGLoader Admin{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-people me-2"></i>Управление пользователями</h1>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addUserModal">
<i class="bi bi-person-plus me-2"></i>Добавить пользователя
</button>
</div>
<!-- Поиск -->
<div class="card mb-4">
<div class="card-body">
<form method="GET" action="/admin/users" class="row g-3">
<div class="col-md-10">
<input type="text" class="form-control" name="search"
placeholder="Поиск по User ID или username..."
value="{{ search }}">
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-outline-primary w-100">
<i class="bi bi-search me-2"></i>Поиск
</button>
</div>
</form>
</div>
</div>
<!-- Таблица пользователей -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Список пользователей</h5>
</div>
<div class="card-body">
{% if users %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>User ID</th>
<th>Username</th>
<th>Имя</th>
<th>Роль</th>
<th>Статус</th>
<th>Создан</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td><code>{{ user.user_id }}</code></td>
<td>@{{ user.username or '-' }}</td>
<td>{{ user.first_name or '-' }} {{ user.last_name or '' }}</td>
<td>
{% if user.is_admin %}
<span class="badge bg-primary">Администратор</span>
{% else %}
<span class="badge bg-secondary">Пользователь</span>
{% endif %}
</td>
<td>
{% if user.is_blocked %}
<span class="badge bg-danger">Заблокирован</span>
{% else %}
<span class="badge bg-success">Активен</span>
{% endif %}
</td>
<td><small>{{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else '-' }}</small></td>
<td>
<div class="btn-group btn-group-sm">
{% if not user.is_blocked %}
<button class="btn btn-outline-danger"
onclick="blockUser({{ user.user_id }})"
title="Заблокировать">
<i class="bi bi-ban"></i>
</button>
{% else %}
<button class="btn btn-outline-success"
onclick="unblockUser({{ user.user_id }})"
title="Разблокировать">
<i class="bi bi-check-circle"></i>
</button>
{% endif %}
{% if is_owner %}
{% if user.is_admin %}
<button class="btn btn-outline-warning"
onclick="removeAdmin({{ user.user_id }})"
title="Удалить из администраторов">
<i class="bi bi-shield-x"></i>
</button>
{% else %}
<button class="btn btn-outline-primary"
onclick="addAdmin({{ user.user_id }})"
title="Сделать администратором">
<i class="bi bi-shield-check"></i>
</button>
{% endif %}
{% endif %}
<button class="btn btn-outline-danger"
onclick="removeUser({{ user.user_id }})"
title="Удалить">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted text-center">Пользователи не найдены</p>
{% endif %}
</div>
</div>
<!-- Модальное окно добавления пользователя -->
<div class="modal fade" id="addUserModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Добавить пользователя</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="addUserForm">
{% if csrf_token %}
<input type="hidden" name="csrf_token" id="csrf_token_users" value="{{ csrf_token }}">
{% endif %}
<div class="modal-body">
<div class="mb-3">
<label for="new_user_id" class="form-label">Telegram User ID</label>
<input type="number" class="form-control" id="new_user_id" name="user_id" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-primary">Добавить</button>
</div>
</form>
</div>
</div>
</div>
{% block extra_js %}
<script>
function showAlert(message, type = 'success') {
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type} alert-dismissible fade show`;
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.insertBefore(alertDiv, document.body.firstChild);
setTimeout(() => alertDiv.remove(), 5000);
}
async function apiCall(url, formData) {
// Добавляем CSRF токен
const csrfToken = document.getElementById('csrf_token_users')?.value ||
document.querySelector('input[name="csrf_token"]')?.value;
if (csrfToken) {
formData.append('csrf_token', csrfToken);
}
const response = await fetch(url, {
method: 'POST',
body: formData,
headers: {
'X-CSRF-Token': csrfToken || ''
}
});
const data = await response.json();
if (data.success) {
showAlert(data.message, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showAlert(data.message, 'danger');
}
}
document.getElementById('addUserForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
await apiCall('/admin/api/users/add', formData);
bootstrap.Modal.getInstance(document.getElementById('addUserModal')).hide();
});
function removeUser(userId) {
if (!confirm('Вы уверены, что хотите удалить этого пользователя?')) return;
const formData = new FormData();
formData.append('user_id', userId);
apiCall('/admin/api/users/remove', formData);
}
function blockUser(userId) {
if (!confirm('Вы уверены, что хотите заблокировать этого пользователя?')) return;
const formData = new FormData();
formData.append('user_id', userId);
apiCall('/admin/api/users/block', formData);
}
function unblockUser(userId) {
const formData = new FormData();
formData.append('user_id', userId);
apiCall('/admin/api/users/unblock', formData);
}
function addAdmin(userId) {
if (!confirm('Вы уверены, что хотите сделать этого пользователя администратором?')) return;
const formData = new FormData();
formData.append('user_id', userId);
apiCall('/admin/api/users/add-admin', formData);
}
function removeAdmin(userId) {
if (!confirm('Вы уверены, что хотите удалить этого пользователя из администраторов?')) return;
const formData = new FormData();
formData.append('user_id', userId);
apiCall('/admin/api/users/remove-admin', formData);
}
</script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,253 @@
"""
Wrappers for user management in web interface
"""
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from shared.database.models import User
from shared.config import settings
import logging
logger = logging.getLogger(__name__)
async def add_user_web(user_id: int, db: AsyncSession) -> dict:
"""
Add user (for web interface)
Args:
user_id: User ID
db: Database session
Returns:
Dictionary with operation result
"""
try:
# Check existence
existing_user = await db.get(User, user_id)
if existing_user:
return {
"success": False,
"message": f"Пользователь {user_id} уже существует"
}
# Create new user
user = User(
user_id=user_id,
is_admin=False,
is_blocked=False
)
db.add(user)
await db.commit()
logger.info(f"User {user_id} added via web interface")
return {
"success": True,
"message": f"Пользователь {user_id} успешно добавлен"
}
except Exception as e:
logger.error(f"Error adding user: {e}")
await db.rollback()
return {
"success": False,
"message": f"Ошибка при добавлении пользователя: {str(e)}"
}
async def remove_user_web(user_id: int, db: AsyncSession) -> dict:
"""
Remove user (for web interface)
Args:
user_id: User ID
db: Database session
Returns:
Dictionary with operation result
"""
try:
user = await db.get(User, user_id)
if not user:
return {
"success": False,
"message": f"Пользователь {user_id} не найден"
}
# Cannot delete Owner
if user_id == settings.OWNER_ID:
return {
"success": False,
"message": "Нельзя удалить Owner"
}
await db.delete(user)
await db.commit()
logger.info(f"User {user_id} removed via web interface")
return {
"success": True,
"message": f"Пользователь {user_id} успешно удален"
}
except Exception as e:
logger.error(f"Error removing user: {e}")
await db.rollback()
return {
"success": False,
"message": f"Ошибка при удалении пользователя: {str(e)}"
}
async def block_user_web(user_id: int, db: AsyncSession) -> dict:
"""
Block user (for web interface)
Args:
user_id: User ID
db: Database session
Returns:
Dictionary with operation result
"""
try:
user = await db.get(User, user_id)
if not user:
return {
"success": False,
"message": f"Пользователь {user_id} не найден"
}
# Cannot block Owner
if user_id == settings.OWNER_ID:
return {
"success": False,
"message": "Нельзя заблокировать Owner"
}
user.is_blocked = True
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"User {user_id} blocked via web interface")
return {
"success": True,
"message": f"Пользователь {user_id} успешно заблокирован"
}
except Exception as e:
logger.error(f"Error blocking user: {e}")
await db.rollback()
return {
"success": False,
"message": f"Ошибка при блокировке пользователя: {str(e)}"
}
async def unblock_user_web(user_id: int, db: AsyncSession) -> dict:
"""
Unblock user (for web interface)
Args:
user_id: User ID
db: Database session
Returns:
Dictionary with operation result
"""
try:
user = await db.get(User, user_id)
if not user:
return {
"success": False,
"message": f"Пользователь {user_id} не найден"
}
user.is_blocked = False
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"User {user_id} unblocked via web interface")
return {
"success": True,
"message": f"Пользователь {user_id} успешно разблокирован"
}
except Exception as e:
logger.error(f"Error unblocking user: {e}")
await db.rollback()
return {
"success": False,
"message": f"Ошибка при разблокировке пользователя: {str(e)}"
}
async def add_admin_web(user_id: int, db: AsyncSession) -> dict:
"""
Add administrator (for web interface, Owner only)
Args:
user_id: User ID
db: Database session
Returns:
Dictionary with operation result
"""
try:
user = await db.get(User, user_id)
if not user:
# Create user if doesn't exist
user = User(user_id=user_id, is_admin=True, is_blocked=False)
db.add(user)
else:
user.is_admin = True
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"User {user_id} assigned as administrator via web interface")
return {
"success": True,
"message": f"Пользователь {user_id} успешно назначен администратором"
}
except Exception as e:
logger.error(f"Error assigning administrator: {e}")
await db.rollback()
return {
"success": False,
"message": f"Ошибка при назначении администратора: {str(e)}"
}
async def remove_admin_web(user_id: int, db: AsyncSession) -> dict:
"""
Remove administrator (for web interface, Owner only)
Args:
user_id: User ID
db: Database session
Returns:
Dictionary with operation result
"""
try:
# Cannot remove privileges from Owner
if user_id == settings.OWNER_ID:
return {
"success": False,
"message": "Нельзя снять права администратора у Owner"
}
user = await db.get(User, user_id)
if not user or not user.is_admin:
return {
"success": False,
"message": f"Пользователь {user_id} не является администратором"
}
user.is_admin = False
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"Administrator privileges removed from user {user_id} via web interface")
return {
"success": True,
"message": f"Права администратора успешно сняты у пользователя {user_id}"
}
except Exception as e:
logger.error(f"Error removing administrator privileges: {e}")
await db.rollback()
return {
"success": False,
"message": f"Ошибка при снятии прав администратора: {str(e)}"
}

176
web/admin/views.py Normal file
View File

@@ -0,0 +1,176 @@
"""
Admin panel views - data retrieval logic
"""
from typing import List, Optional
from sqlalchemy import select, func, desc
from sqlalchemy.ext.asyncio import AsyncSession
from shared.database.models import User, Task
from datetime import datetime, timedelta
import logging
logger = logging.getLogger(__name__)
async def get_users_stats(session: AsyncSession) -> dict:
"""Get user statistics"""
try:
# Total user count
total_users_result = await session.execute(
select(func.count(User.user_id))
)
total_users = total_users_result.scalar() or 0
# Administrator count
admins_result = await session.execute(
select(func.count(User.user_id)).where(User.is_admin == True)
)
total_admins = admins_result.scalar() or 0
# Blocked user count
blocked_result = await session.execute(
select(func.count(User.user_id)).where(User.is_blocked == True)
)
blocked_users = blocked_result.scalar() or 0
# New users in last 7 days
week_ago = datetime.utcnow() - timedelta(days=7)
new_users_result = await session.execute(
select(func.count(User.user_id)).where(User.created_at >= week_ago)
)
new_users = new_users_result.scalar() or 0
return {
"total": total_users,
"admins": total_admins,
"blocked": blocked_users,
"new_week": new_users
}
except Exception as e:
logger.error(f"Error getting user statistics: {e}")
return {"total": 0, "admins": 0, "blocked": 0, "new_week": 0}
async def get_tasks_stats(session: AsyncSession) -> dict:
"""Get task statistics"""
try:
# Total task count
total_tasks_result = await session.execute(
select(func.count(Task.id))
)
total_tasks = total_tasks_result.scalar() or 0
# Active tasks (pending, processing)
active_tasks_result = await session.execute(
select(func.count(Task.id)).where(
Task.status.in_(["pending", "processing"])
)
)
active_tasks = active_tasks_result.scalar() or 0
# Completed tasks
completed_tasks_result = await session.execute(
select(func.count(Task.id)).where(Task.status == "completed")
)
completed_tasks = completed_tasks_result.scalar() or 0
# Failed tasks
failed_tasks_result = await session.execute(
select(func.count(Task.id)).where(Task.status == "failed")
)
failed_tasks = failed_tasks_result.scalar() or 0
# Tasks in last 24 hours
day_ago = datetime.utcnow() - timedelta(hours=24)
recent_tasks_result = await session.execute(
select(func.count(Task.id)).where(Task.created_at >= day_ago)
)
recent_tasks = recent_tasks_result.scalar() or 0
return {
"total": total_tasks,
"active": active_tasks,
"completed": completed_tasks,
"failed": failed_tasks,
"recent_24h": recent_tasks
}
except Exception as e:
logger.error(f"Error getting task statistics: {e}")
return {"total": 0, "active": 0, "completed": 0, "failed": 0, "recent_24h": 0}
async def get_all_users(
session: AsyncSession,
limit: int = 100,
offset: int = 0,
search: Optional[str] = None
) -> List[User]:
"""Get user list"""
try:
query = select(User)
if search:
# Search by username or user_id
try:
user_id = int(search)
query = query.where(User.user_id == user_id)
except ValueError:
query = query.where(User.username.ilike(f"%{search}%"))
query = query.order_by(desc(User.created_at)).limit(limit).offset(offset)
result = await session.execute(query)
return list(result.scalars().all())
except Exception as e:
logger.error(f"Error getting users: {e}")
return []
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
"""Get user by ID"""
try:
return await session.get(User, user_id)
except Exception as e:
logger.error(f"Error getting user: {e}")
return None
async def get_all_tasks(
session: AsyncSession,
limit: int = 100,
offset: int = 0,
status_filter: Optional[str] = None,
user_id_filter: Optional[int] = None
) -> List[Task]:
"""Get task list"""
try:
query = select(Task)
if status_filter:
query = query.where(Task.status == status_filter)
if user_id_filter:
query = query.where(Task.user_id == user_id_filter)
query = query.order_by(desc(Task.created_at)).limit(limit).offset(offset)
result = await session.execute(query)
tasks = list(result.scalars().all())
# Load related users
for task in tasks:
await session.refresh(task, ["user"])
return tasks
except Exception as e:
logger.error(f"Error getting tasks: {e}")
return []
async def get_task_by_id(session: AsyncSession, task_id: int) -> Optional[Task]:
"""Get task by ID"""
try:
task = await session.get(Task, task_id)
if task:
await session.refresh(task, ["user"])
return task
except Exception as e:
logger.error(f"Error getting task: {e}")
return None

112
web/app.py Normal file
View File

@@ -0,0 +1,112 @@
"""
Web application entry point
"""
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
from pathlib import Path
from web.admin.routes import router as admin_router
from shared.config import settings
from web.utils.auth import start_session_cleanup_task
import logging
import secrets
logger = logging.getLogger(__name__)
app = FastAPI(
title="TGLoader Admin Panel",
description="Web interface for managing TGLoader Telegram bot",
version="1.0.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json"
)
# Check and generate SECRET_KEY
if not settings.WEB_SECRET_KEY or settings.WEB_SECRET_KEY == "your-secret-key-change-in-production":
logger.warning(
"⚠️ WEB_SECRET_KEY not set or using default value!\n"
"⚠️ This is unsafe for production!\n"
"⚠️ Generate a random key and add it to .env file:\n"
f" WEB_SECRET_KEY={secrets.token_urlsafe(32)}"
)
# Generate temporary key for development (in production this should be an error)
secret_key = secrets.token_urlsafe(32)
logger.warning(f"⚠️ Using temporary key (DO NOT USE IN PRODUCTION!): {secret_key[:20]}...")
else:
secret_key = settings.WEB_SECRET_KEY
# Session middleware
app.add_middleware(
SessionMiddleware,
secret_key=secret_key,
max_age=86400 * 7 # 7 days
)
# Mount static files and templates
static_dir = Path("web/admin/static")
static_dir.mkdir(parents=True, exist_ok=True)
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
templates_dir = Path("web/admin/templates")
templates = Jinja2Templates(directory=str(templates_dir))
# Include routers
app.include_router(admin_router, prefix="/admin", tags=["admin"])
@app.on_event("startup")
async def startup_event():
"""Start background tasks on application startup"""
try:
# Initialize database (create tables if they don't exist)
from shared.database.session import init_db
try:
await init_db()
logger.info("Database initialized for web application")
except Exception as db_error:
logger.error(f"Error initializing database: {db_error}", exc_info=True)
# Don't interrupt startup, database might already be initialized
start_session_cleanup_task()
# Start cleanup of old OTP codes on startup
try:
from web.utils.otp import cleanup_expired_otp_codes
from web.utils.database import get_db
async for db in get_db():
await cleanup_expired_otp_codes(db)
break
except Exception as otp_cleanup_error:
logger.warning(f"Failed to cleanup old OTP codes on startup: {otp_cleanup_error}")
# Start background task for updating user information
try:
import asyncio
from bot.utils.user_info_updater import update_users_without_info_periodically
loop = asyncio.get_running_loop()
user_info_task = loop.create_task(update_users_without_info_periodically())
logger.info("User information update task started")
except Exception as e:
logger.warning(f"Failed to start user information update task: {e}")
except Exception as e:
logger.error(f"Error starting background tasks: {e}", exc_info=True)
@app.get("/")
async def root():
"""Redirect to admin panel"""
return RedirectResponse(url="/admin/login")
if __name__ == "__main__":
import uvicorn
from shared.config import settings
uvicorn.run(
app,
host=settings.WEB_HOST,
port=settings.WEB_PORT
)

7
web/config.py Normal file
View File

@@ -0,0 +1,7 @@
"""
Web application configuration
"""
from shared.config import settings
__all__ = ["settings"]

4
web/utils/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""
Web application utilities
"""

310
web/utils/auth.py Normal file
View File

@@ -0,0 +1,310 @@
"""
Authentication for web interface
"""
from typing import Optional
from datetime import datetime, timedelta
from fastapi import Request, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from starlette.middleware.sessions import SessionMiddleware
from shared.config import settings
from shared.database.session import AsyncSessionLocal
from shared.database.models import User
import logging
import asyncio
logger = logging.getLogger(__name__)
# Global variable for storing sessions (use Redis in production)
# Format: {session_id: {"user_id": int, "is_owner": bool, "created_at": datetime}}
_sessions: dict[str, dict] = {}
_sessions_lock = asyncio.Lock() # Use asyncio.Lock for async context
# Constants for time intervals
SECONDS_PER_HOUR = 3600
HOURS_PER_DAY = 24
# TTL for sessions (7 days)
SESSION_LIFETIME_DAYS = 7
SESSION_TTL_DAYS = 7
SESSION_TTL = timedelta(days=SESSION_TTL_DAYS)
# Session cleanup interval (6 hours)
SESSION_CLEANUP_INTERVAL_HOURS = 6
# Flag for background cleanup task
_cleanup_task: Optional[asyncio.Task] = None
async def verify_web_user(user_id: int) -> bool:
"""
Check user access rights to web interface
All authorized users can use web interface
Args:
user_id: Telegram user ID
Returns:
True if user has access, False otherwise
"""
from bot.modules.access_control.auth import is_authorized
# Check user authorization
return await is_authorized(user_id)
async def create_session(user_id: int) -> str:
"""
Create session for user
Args:
user_id: Telegram user ID
Returns:
Session ID
"""
# Use Redis if enabled
if settings.USE_REDIS_SESSIONS:
try:
from web.utils.redis_session import create_redis_session
session_id = await create_redis_session(user_id)
if session_id:
return session_id
else:
logger.warning("Failed to create session in Redis, using in-memory")
return await _create_in_memory_session(user_id)
except Exception as e:
logger.warning(f"Failed to create session in Redis, using in-memory: {e}")
return await _create_in_memory_session(user_id)
else:
return await _create_in_memory_session(user_id)
async def _create_in_memory_session(user_id: int) -> str:
"""Create in-memory session (fallback)"""
import secrets
from web.utils.csrf import generate_csrf_token
session_id = secrets.token_urlsafe(32)
csrf_token = generate_csrf_token()
with _sessions_lock:
_sessions[session_id] = {
"user_id": user_id,
"is_owner": user_id == settings.OWNER_ID,
"created_at": datetime.utcnow(),
"expires_at": datetime.utcnow() + timedelta(days=SESSION_LIFETIME_DAYS),
"csrf_token": csrf_token
}
return session_id
async def get_session(session_id: str) -> Optional[dict]:
"""
Get session data (async version).
Checks Redis first if enabled, then falls back to in-memory sessions.
Args:
session_id: Session ID
Returns:
Session data or None (if session expired)
"""
# Use Redis if enabled
if settings.USE_REDIS_SESSIONS:
try:
from web.utils.redis_session import get_redis_session
session_data = await get_redis_session(session_id)
if session_data:
return session_data
# If not found in Redis, check in-memory (fallback)
except Exception as e:
logger.warning(f"Failed to get session from Redis, using in-memory: {e}")
# Fallback to in-memory sessions
async with _sessions_lock:
session_data = _sessions.get(session_id)
if not session_data:
return None
# Check TTL by expires_at (more reliable)
expires_at = session_data.get("expires_at")
if expires_at:
if isinstance(expires_at, datetime):
if datetime.utcnow() >= expires_at:
# Session expired, delete
del _sessions[session_id]
logger.debug(f"Session {session_id} expired (expires_at: {expires_at})")
return None
else:
# If expires_at in different format, try to parse
try:
if isinstance(expires_at, str):
expires_at_dt = datetime.fromisoformat(expires_at)
if datetime.utcnow() >= expires_at_dt:
del _sessions[session_id]
logger.debug(f"Session {session_id} expired (expires_at: {expires_at})")
return None
except (ValueError, TypeError):
logger.warning(f"Failed to parse expires_at for session {session_id}: {expires_at}")
# Fallback: check by created_at (for backward compatibility)
if not expires_at:
created_at = session_data.get("created_at")
if created_at and isinstance(created_at, datetime):
if datetime.utcnow() - created_at > SESSION_TTL:
# Session expired, delete
del _sessions[session_id]
logger.debug(f"Session {session_id} expired (by created_at)")
return None
return session_data
async def delete_session(session_id: str):
"""
Delete session
Args:
session_id: Session ID
"""
# Use Redis if enabled
if settings.USE_REDIS_SESSIONS:
try:
from web.utils.redis_session import delete_redis_session
await delete_redis_session(session_id)
except Exception as e:
logger.warning(f"Failed to delete session from Redis: {e}")
# Also remove from memory just in case
async with _sessions_lock:
if session_id in _sessions:
del _sessions[session_id]
async def cleanup_expired_sessions():
"""
Cleanup expired sessions
Called periodically in background
"""
now = datetime.utcnow()
expired_sessions = []
async with _sessions_lock:
for session_id, session_data in _sessions.items():
created_at = session_data.get("created_at")
if created_at and isinstance(created_at, datetime):
if now - created_at > SESSION_TTL:
expired_sessions.append(session_id)
for session_id in expired_sessions:
del _sessions[session_id]
if expired_sessions:
logger.info(f"Cleaned up {len(expired_sessions)} expired sessions")
async def cleanup_sessions_periodically():
"""
Periodic cleanup of expired sessions
Runs in background every 6 hours
"""
while True:
try:
await asyncio.sleep(SESSION_CLEANUP_INTERVAL_HOURS * SECONDS_PER_HOUR)
await cleanup_expired_sessions()
except asyncio.CancelledError:
logger.info("Session cleanup task stopped")
break
except Exception as e:
logger.error(f"Error cleaning up sessions: {e}", exc_info=True)
def start_session_cleanup_task():
"""
Start background session cleanup task
"""
global _cleanup_task
if _cleanup_task is None or _cleanup_task.done():
try:
loop = asyncio.get_running_loop()
_cleanup_task = loop.create_task(cleanup_sessions_periodically())
logger.info("Background session cleanup task started")
except RuntimeError:
# If no running loop, try to get current one
try:
loop = asyncio.get_event_loop()
if loop.is_running():
_cleanup_task = loop.create_task(cleanup_sessions_periodically())
logger.info("Background session cleanup task started")
else:
logger.warning("Event loop not running, session cleanup task will be started later")
except RuntimeError:
logger.warning("Failed to start session cleanup task: no event loop")
async def get_current_user(request: Request) -> dict:
"""
Get current user from session
Args:
request: FastAPI Request object
Returns:
Dictionary with user data
Raises:
HTTPException: If user is not authorized
"""
session_id = request.cookies.get("session_id")
if not session_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authorized"
)
# Get session (from Redis or in-memory)
session_data = None
if settings.USE_REDIS_SESSIONS:
try:
from web.utils.redis_session import get_redis_session
session_data = await get_redis_session(session_id)
except Exception as e:
logger.warning(f"Error getting session from Redis: {e}, trying in-memory")
session_data = await get_session(session_id)
else:
session_data = await get_session(session_id)
if not session_data:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired"
)
# Check that user still has access
user_id = session_data.get("user_id")
if not await verify_web_user(user_id):
await delete_session(session_id)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
return session_data
async def is_owner_web(request: Request) -> bool:
"""
Check if current user is Owner
Args:
request: FastAPI Request object
Returns:
True if Owner, False otherwise
"""
try:
user_data = await get_current_user(request)
return user_data.get("is_owner", False)
except HTTPException:
return False

72
web/utils/bot_client.py Normal file
View File

@@ -0,0 +1,72 @@
"""
Utilities for interacting with bot from web interface
"""
import logging
from typing import Optional
from bot.modules.task_scheduler.executor import get_app_client
logger = logging.getLogger(__name__)
async def send_otp_to_user(user_id: int, code: str) -> bool:
"""
Send OTP code to user via Telegram bot
Args:
user_id: User ID
code: OTP code
Returns:
True if successfully sent, False otherwise
"""
try:
app_client = get_app_client()
if not app_client:
logger.warning(f"Bot client not available for sending OTP to user {user_id}")
return False
# Check that client is started and connected
try:
if not hasattr(app_client, 'is_connected') or not app_client.is_connected:
logger.warning(f"Bot client not connected for sending OTP to user {user_id}")
return False
except Exception as check_error:
logger.warning(f"Failed to check bot connection status: {check_error}")
# Continue sending attempt, client might be working
from shared.config import settings
# Form URL for web interface
if settings.WEB_HOST == "0.0.0.0":
login_url = f"localhost:{settings.WEB_PORT}"
else:
login_url = f"{settings.WEB_HOST}:{settings.WEB_PORT}"
message = (
f"🔐 **Ваш код для входа в веб-интерфейс:**\n\n"
f"**`{code}`**\n\n"
f"⏰ Код действителен 10 минут\n\n"
f"🌐 Перейдите на http://{login_url}/admin/login и введите этот код"
)
try:
# Try to send message
result = await app_client.send_message(user_id, message)
logger.info(f"OTP code successfully sent to user {user_id}, message_id: {result.id if result else 'unknown'}")
return True
except Exception as send_error:
error_msg = str(send_error)
logger.error(f"Error sending message to user {user_id}: {error_msg}", exc_info=True)
# Check error type for more informative message
if "chat not found" in error_msg.lower() or "user not found" in error_msg.lower():
logger.error(f"User {user_id} not found or hasn't started dialog with bot")
elif "flood" in error_msg.lower():
logger.error(f"Message sending rate limit exceeded for user {user_id}")
elif "unauthorized" in error_msg.lower():
logger.error(f"Bot not authorized or stopped")
return False
except Exception as e:
logger.error(f"Critical error sending OTP to user {user_id}: {e}", exc_info=True)
return False

123
web/utils/csrf.py Normal file
View File

@@ -0,0 +1,123 @@
"""
CSRF token utilities
"""
import secrets
from typing import Optional
from fastapi import Request, HTTPException, status
from web.utils.auth import get_session
import logging
logger = logging.getLogger(__name__)
# CSRF token length
CSRF_TOKEN_LENGTH = 32
def generate_csrf_token() -> str:
"""
Generate CSRF token
Returns:
Random token
"""
return secrets.token_urlsafe(CSRF_TOKEN_LENGTH)
async def get_csrf_token(request: Request) -> Optional[str]:
"""
Get CSRF token from session.
Args:
request: FastAPI Request object
Returns:
CSRF token or None
"""
session_id = request.cookies.get("session_id")
if not session_id:
return None
session_data = await get_session(session_id)
if not session_data:
return None
return session_data.get("csrf_token")
async def set_csrf_token(request: Request, token: str) -> None:
"""
Set CSRF token in session.
Args:
request: FastAPI Request object
token: CSRF token
"""
session_id = request.cookies.get("session_id")
if not session_id:
return
session_data = await get_session(session_id)
if session_data:
session_data["csrf_token"] = token
# Save session back to Redis if using Redis
from shared.config import settings
if settings.USE_REDIS_SESSIONS:
from web.utils.redis_session import update_redis_session
await update_redis_session(session_id, session_data)
async def validate_csrf_token(request: Request, token: Optional[str] = None) -> bool:
"""
Validate CSRF token
Args:
request: FastAPI Request object
token: Token to validate (if None, taken from form/headers)
Returns:
True if token is valid
"""
# Get token from different sources
if not token:
# Try to get from form
try:
form = await request.form()
token = form.get("csrf_token")
except Exception as e:
logger.debug(f"Failed to get CSRF token from form: {e}")
pass
# If not found in form, try from headers
if not token:
token = request.headers.get("X-CSRF-Token")
if not token:
return False
# Get token from session
session_token = await get_csrf_token(request)
if not session_token:
return False
# Compare tokens
return secrets.compare_digest(token, session_token)
async def verify_csrf(request: Request, token: Optional[str] = None):
"""
Verify CSRF token with exception on error
Args:
request: FastAPI Request object
token: Token to verify
Raises:
HTTPException: If token is invalid
"""
if not await validate_csrf_token(request, token):
logger.warning(f"CSRF token invalid for IP {request.client.host if request.client else 'unknown'}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token"
)

9
web/utils/database.py Normal file
View File

@@ -0,0 +1,9 @@
"""
Database utilities for web interface (wrapper over shared module)
Uses unified module from shared/database/session.py
"""
from shared.database.session import get_db
# Export function for use in FastAPI Depends
__all__ = ['get_db']

288
web/utils/otp.py Normal file
View File

@@ -0,0 +1,288 @@
"""
One-time password (OTP) utilities
"""
import secrets
import string
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from shared.database.models import OTPCode, User
import logging
from collections import defaultdict
import time
logger = logging.getLogger(__name__)
# OTP code lifetime (10 minutes)
OTP_EXPIRY_MINUTES = 10
# Rate limiting for OTP code verification
# Format: {ip_address: [(timestamp, success), ...]}
_otp_attempts: dict[str, list[tuple[float, bool]]] = defaultdict(list)
import threading
_otp_locks: dict[str, threading.Lock] = defaultdict(lambda: threading.Lock())
# Rate limiting settings
MAX_OTP_ATTEMPTS = 5 # Maximum attempts
OTP_ATTEMPT_WINDOW = 60 # Time window in seconds (1 minute)
OTP_CLEANUP_INTERVAL = 3600 # Old attempts cleanup interval (1 hour)
def generate_otp_code() -> str:
"""
Generate 6-digit OTP code
Returns:
6-digit code
"""
return ''.join(secrets.choice(string.digits) for _ in range(6))
async def create_otp_code(user_id: int, db: AsyncSession) -> Optional[str]:
"""
Create new OTP code for user
Args:
user_id: User ID
db: Database session
Returns:
OTP code or None on error
"""
try:
# Invalidate all previous unused codes for user
await invalidate_user_otp_codes(user_id, db)
# Generate new code
code = generate_otp_code()
expires_at = datetime.utcnow() + timedelta(minutes=OTP_EXPIRY_MINUTES)
# Create database record
otp = OTPCode(
user_id=user_id,
code=code,
expires_at=expires_at,
used=False
)
db.add(otp)
await db.commit()
logger.info(f"OTP code created for user {user_id}, expires at {expires_at}")
return code
except Exception as e:
logger.error(f"Error creating OTP code: {e}")
await db.rollback()
return None
def _check_rate_limit(ip_address: str) -> bool:
"""
Check rate limiting for IP address
Args:
ip_address: Client IP address
Returns:
True if can continue, False if limit exceeded
"""
lock = _otp_locks[ip_address]
with lock:
now = time.time()
attempts = _otp_attempts[ip_address]
# Remove old attempts (older than time window)
attempts[:] = [(ts, success) for ts, success in attempts if now - ts < OTP_ATTEMPT_WINDOW]
# Check attempt count
if len(attempts) >= MAX_OTP_ATTEMPTS:
logger.warning(f"OTP verification rate limit exceeded for IP {ip_address}")
return False
return True
def _record_otp_attempt(ip_address: str, success: bool):
"""
Record OTP verification attempt
Args:
ip_address: Client IP address
success: Whether attempt was successful
"""
lock = _otp_locks[ip_address]
with lock:
now = time.time()
_otp_attempts[ip_address].append((now, success))
async def verify_otp_code(code: str, db: AsyncSession, ip_address: Optional[str] = None) -> Optional[int]:
"""
Verify and use OTP code with brute force protection
Args:
code: OTP code to verify
db: Database session
ip_address: Client IP address (for rate limiting)
Returns:
user_id if code is valid, None otherwise
"""
# Check rate limiting
if ip_address and not _check_rate_limit(ip_address):
logger.warning(f"OTP verification rate limit exceeded for IP {ip_address}")
return None
try:
# Search for code
result = await db.execute(
select(OTPCode).where(
and_(
OTPCode.code == code,
OTPCode.used == False,
OTPCode.expires_at > datetime.utcnow()
)
)
)
otp = result.scalar_one_or_none()
if not otp:
logger.warning(f"Invalid or expired OTP code: {code}")
if ip_address:
_record_otp_attempt(ip_address, False)
return None
# Mark code as used
otp.used = True
await db.commit()
if ip_address:
_record_otp_attempt(ip_address, True)
logger.info(f"OTP code used for user {otp.user_id}")
return otp.user_id
except Exception as e:
logger.error(f"Error verifying OTP code: {e}")
await db.rollback()
if ip_address:
_record_otp_attempt(ip_address, False)
return None
async def invalidate_user_otp_codes(user_id: int, db: AsyncSession):
"""
Invalidate all unused OTP codes for user
Args:
user_id: User ID
db: Database session
"""
try:
result = await db.execute(
select(OTPCode).where(
and_(
OTPCode.user_id == user_id,
OTPCode.used == False
)
)
)
otps = result.scalars().all()
for otp in otps:
otp.used = True
if otps:
await db.commit()
logger.info(f"Invalidated {len(otps)} OTP codes for user {user_id}")
except Exception as e:
logger.error(f"Error invalidating OTP codes: {e}")
await db.rollback()
async def cleanup_expired_otp_codes(db: AsyncSession):
"""
Cleanup expired and used OTP codes older than 24 hours
Args:
db: Database session
"""
try:
cutoff_time = datetime.utcnow() - timedelta(hours=24)
result = await db.execute(
select(OTPCode).where(
and_(
OTPCode.created_at < cutoff_time,
OTPCode.used == True
)
)
)
expired_otps = result.scalars().all()
for otp in expired_otps:
await db.delete(otp)
if expired_otps:
await db.commit()
logger.info(f"Cleaned up {len(expired_otps)} old OTP codes")
except Exception as e:
logger.error(f"Error cleaning up old OTP codes: {e}")
await db.rollback()
async def get_user_by_identifier(identifier: str, db: AsyncSession) -> Optional[User]:
"""
Search user by ID or username
Args:
identifier: User ID (number) or username (string with @ or without)
db: Database session
Returns:
User object or None
"""
try:
# Try to find by user_id
try:
user_id = int(identifier)
user = await db.get(User, user_id)
if user:
return user
except ValueError:
pass
# Search by username in database
username = identifier.lstrip('@')
result = await db.execute(
select(User).where(User.username == username)
)
user = result.scalar_one_or_none()
if user:
return user
# If not found in database, try to find by username via Telegram API
# This is needed for cases when user is not yet registered in database
# Bot API doesn't allow searching users by username directly,
# but we can try get_chat if bot has already interacted with user
try:
from bot.modules.task_scheduler.executor import get_app_client
app_client = get_app_client()
if app_client:
try:
# Try to get user information via get_chat
# This only works if bot has already interacted with user
chat = await app_client.get_chat(username)
if chat and hasattr(chat, 'id'):
# Found user, return None to create later
logger.info(f"Found user {chat.id} by username {username} via Telegram API")
return None
except Exception as e:
logger.debug(f"Failed to get user {username} via get_chat: {e}")
# get_chat didn't work, user must use ID for first login
except Exception as e:
logger.debug(f"Failed to get user information via Telegram API: {e}")
return None
except Exception as e:
logger.error(f"Error searching for user: {e}")
return None

138
web/utils/redis_session.py Normal file
View File

@@ -0,0 +1,138 @@
"""
Redis session utilities
"""
import json
import logging
from typing import Optional
from datetime import datetime, timedelta
from shared.config import settings
logger = logging.getLogger(__name__)
_redis_client = None
def get_redis_client():
"""Get Redis client"""
global _redis_client
if _redis_client is None:
try:
import redis.asyncio as redis
_redis_client = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DB,
decode_responses=True
)
logger.info(f"Connected to Redis: {settings.REDIS_HOST}:{settings.REDIS_PORT}")
except ImportError:
logger.error("Redis library not installed. Install: pip install redis")
return None
except Exception as e:
logger.error(f"Error connecting to Redis: {e}")
return None
return _redis_client
async def create_redis_session(user_id: int) -> str:
"""Create session in Redis"""
import secrets
from web.utils.csrf import generate_csrf_token
session_id = secrets.token_urlsafe(32)
csrf_token = generate_csrf_token()
session_data = {
"user_id": user_id,
"is_owner": user_id == settings.OWNER_ID,
"created_at": datetime.utcnow().isoformat(),
"expires_at": (datetime.utcnow() + timedelta(days=7)).isoformat(),
"csrf_token": csrf_token
}
redis_client = get_redis_client()
if not redis_client:
logger.warning("Redis client unavailable, session not created")
return None
try:
# Save session with TTL 7 days
await redis_client.setex(
f"session:{session_id}",
7 * 24 * 3600, # 7 days in seconds
json.dumps(session_data)
)
logger.debug(f"Session created in Redis: {session_id}")
return session_id
except Exception as e:
logger.error(f"Error creating session in Redis: {e}")
return None
async def get_redis_session(session_id: str) -> Optional[dict]:
"""Get session from Redis"""
redis_client = get_redis_client()
if not redis_client:
return None
try:
data = await redis_client.get(f"session:{session_id}")
if data:
session_data = json.loads(data)
# Check expires_at
expires_at_str = session_data.get('expires_at')
if expires_at_str:
try:
expires_at = datetime.fromisoformat(expires_at_str)
if datetime.utcnow() < expires_at:
return session_data
else:
# Session expired, delete
logger.debug(f"Session {session_id} expired in Redis (expires_at: {expires_at})")
await delete_redis_session(session_id)
return None
except (ValueError, TypeError) as e:
logger.warning(f"Failed to parse expires_at for session {session_id}: {e}")
# If parsing failed, consider session valid (Redis TTL will still work)
return session_data
else:
# If expires_at missing, return session (Redis TTL will still work)
return session_data
except Exception as e:
logger.error(f"Error getting session from Redis: {e}")
return None
async def delete_redis_session(session_id: str) -> None:
"""Delete session from Redis"""
redis_client = get_redis_client()
if redis_client:
try:
await redis_client.delete(f"session:{session_id}")
logger.debug(f"Session deleted from Redis: {session_id}")
except Exception as e:
logger.error(f"Error deleting session from Redis: {e}")
async def update_redis_session(session_id: str, session_data: dict) -> bool:
"""Update session in Redis"""
redis_client = get_redis_client()
if not redis_client:
return False
try:
expires_at = datetime.fromisoformat(session_data['expires_at'])
ttl = int((expires_at - datetime.utcnow()).total_seconds())
if ttl > 0:
await redis_client.setex(
f"session:{session_id}",
ttl,
json.dumps(session_data)
)
return True
except Exception as e:
logger.error(f"Error updating session in Redis: {e}")
return False