Files
anti-plagiarism/services/api/app/api/search.py
jze9 c1bfb5f40e fix(api): security, caching, atomic rate limits, url obfuscation
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>
2026-05-24 19:51:51 +05:00

88 lines
3.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Роутер семантического поиска источников."""
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)