Redis:
- Singleton ConnectionPool (redis.asyncio), 50 connections — не создаём
новое TCP-соединение на каждый HTTP-запрос
Rate limiter:
- Полностью переписан на async/await
- Lua-скрипт _LUA_CHECK_AND_INCR — атомарная проверка+инкремент без race condition
- Lua-скрипт _LUA_ACQUIRE_CONCURRENT — атомарный захват слота задачи
- Старый паттерн INCR→check→DECR удалён (race condition при конкурентных запросах)
Security:
- get_current_user кэширует пользователя в Redis на 5 минут (TTL)
Раньше: SELECT users на каждый HTTP-запрос
Теперь: Redis GET (кэш) → SELECT users (только при промахе)
- hashed_password НЕ кладётся в кэш
- invalidate_user_cache() для сброса при смене тарифа/пароля
- get_ws_user() для WebSocket через ?token=JWT (браузеры не могут
передавать Authorization header при WS-handshake)
WebSocket:
- Добавлена аутентификация (Depends(get_ws_user))
- Проверка ownership задачи ДО accept() соединения
- Чужой task_id → закрытие с кодом 4004
URL obfuscation:
- Task.public_id = secrets.token_urlsafe(16) = 22 случайных base64url символа
- Клиент работает только с public_id, внутренний UUID не раскрывается
- Все роутеры переключены на public_id в WHERE условиях
- TaskResponse больше не возвращает input_data (там minio_key и т.д.)
- Миграция 002_add_task_public_id.py
MinIO:
- Singleton клиент (не создаём новый на каждый upload)
- ensure_bucket() вызывается один раз при старте (lifespan), не на каждый запрос
- Путь uploads/{doc_uuid}{ext} — user_id убран из пути
CORS:
- Убраны wildcard allow_methods/allow_headers (несовместимы с credentials=True)
- Явный список: methods=[GET,POST,DELETE,OPTIONS], headers=[Authorization,Content-Type,Accept]
- Swagger/OpenAPI доступны только в ENVIRONMENT=development
Documents:
- Content-Length проверяется ДО чтения тела (ранняя отбивка больших файлов)
- Повторная проверка реального размера после чтения (защита от поддельного заголовка)
- Используем get_current_verified_user вместо get_current_user (требуем подтверждённый email)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
88 lines
3.3 KiB
Python
88 lines
3.3 KiB
Python
"""Роутер семантического поиска источников."""
|
||
|
||
import logging
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.celery_app import celery_app
|
||
from app.core.rate_limiter import acquire_concurrent_slot, check_and_increment_limit
|
||
from app.core.security import get_current_verified_user
|
||
from app.database import get_db
|
||
from app.models.task import Task
|
||
from app.models.user import User
|
||
from app.schemas.tasks import SearchRequest, TaskResponse
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/search", tags=["search"])
|
||
|
||
ETA_PER_POSITION_SECONDS = 15
|
||
|
||
|
||
@router.post("/", response_model=TaskResponse, status_code=status.HTTP_202_ACCEPTED)
|
||
async def create_search_task(
|
||
data: SearchRequest,
|
||
# Поиск требует подтверждённого email — защита от массового abuse
|
||
current_user: User = Depends(get_current_verified_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
) -> TaskResponse:
|
||
"""
|
||
Создать задачу поиска источников.
|
||
|
||
Возвращает сразу с task public_id и позицией в очереди.
|
||
Результат — через GET /tasks/{public_id} или WS /ws/tasks/{public_id}?token=JWT.
|
||
"""
|
||
# Проверяем лимит (атомарно, Lua-скрипт)
|
||
limit_result = await check_and_increment_limit(current_user.id, "search", current_user.plan)
|
||
if not limit_result["allowed"]:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||
detail=(
|
||
f"Превышен дневной лимит поиска (тариф '{current_user.plan}'): "
|
||
f"{limit_result['current']}/{limit_result['limit']}. "
|
||
f"Сбросится {limit_result['reset_at']}."
|
||
),
|
||
headers={"Retry-After": "86400"},
|
||
)
|
||
|
||
# Проверяем лимит одновременных задач (атомарно)
|
||
slot_acquired = await acquire_concurrent_slot(current_user.id, current_user.plan)
|
||
if not slot_acquired:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||
detail="Превышен лимит одновременных задач. Дождитесь завершения текущих.",
|
||
)
|
||
|
||
task = Task(
|
||
user_id=current_user.id,
|
||
type="search",
|
||
status="queued",
|
||
input_data={
|
||
"query": data.query,
|
||
"lang": data.lang,
|
||
"year_from": data.year_from,
|
||
"year_to": data.year_to,
|
||
"category": data.category,
|
||
},
|
||
)
|
||
db.add(task)
|
||
await db.flush() # получаем id и public_id
|
||
|
||
celery_result = celery_app.send_task(
|
||
"gpu.search_semantic",
|
||
args=[task.id, data.query],
|
||
kwargs={"lang": data.lang, "year_from": data.year_from, "year_to": data.year_to},
|
||
queue="queue.gpu",
|
||
)
|
||
|
||
task.celery_task_id = celery_result.id
|
||
task.queue_position = 1
|
||
task.eta_seconds = ETA_PER_POSITION_SECONDS
|
||
|
||
await db.commit()
|
||
await db.refresh(task)
|
||
|
||
logger.info("Задача поиска %s создана для пользователя %d", task.public_id, current_user.id)
|
||
return TaskResponse.model_validate(task)
|