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>
This commit is contained in:
@@ -1,18 +1,18 @@
|
||||
"""Роутер загрузки документов на проверку плагиата."""
|
||||
"""Загрузка документов на проверку плагиата."""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.rate_limiter import check_and_increment_limit, check_concurrent_limit
|
||||
from app.core.security import get_current_user
|
||||
from app.core.minio_client import get_minio
|
||||
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
|
||||
@@ -22,102 +22,104 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/documents", tags=["documents"])
|
||||
|
||||
# Допустимые форматы
|
||||
ALLOWED_EXTENSIONS = {".pdf", ".docx", ".txt"}
|
||||
MAX_FILE_SIZE_MB = 100
|
||||
MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024 # 100 МБ
|
||||
|
||||
|
||||
def get_minio_client() -> Minio:
|
||||
"""Создать MinIO клиент."""
|
||||
return Minio(
|
||||
settings.MINIO_ENDPOINT,
|
||||
access_key=settings.MINIO_ACCESS_KEY,
|
||||
secret_key=settings.MINIO_SECRET_KEY,
|
||||
secure=False,
|
||||
)
|
||||
CONTENT_TYPE_MAP = {
|
||||
".pdf": "application/pdf",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".txt": "text/plain",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/check", response_model=TaskResponse, status_code=status.HTTP_202_ACCEPTED)
|
||||
async def upload_for_plagiarism_check(
|
||||
file: UploadFile = File(..., description="PDF, DOCX или TXT файл до 100 МБ"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
request: Request,
|
||||
file: UploadFile,
|
||||
current_user: User = Depends(get_current_verified_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TaskResponse:
|
||||
"""
|
||||
Загрузить документ для проверки на плагиат.
|
||||
Загрузить документ на проверку плагиата.
|
||||
|
||||
Файл сохраняется в MinIO, затем диспатчится задача index.extract_and_check.
|
||||
Результат доступен через GET /tasks/{task_id}.
|
||||
Файл сохраняется в MinIO. В пути используется UUID документа,
|
||||
не ID пользователя (пользователь не должен знать свой internal ID).
|
||||
"""
|
||||
# Проверить расширение файла
|
||||
# ── 1. Проверяем заголовки ДО чтения тела ─────────────────────────────────
|
||||
|
||||
if not file.filename:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Имя файла не указано",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Имя файла не указано")
|
||||
|
||||
ext = Path(file.filename).suffix.lower()
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
detail=f"Неподдерживаемый формат файла. Допустимые форматы: {', '.join(ALLOWED_EXTENSIONS)}",
|
||||
detail=f"Неподдерживаемый формат. Допустимые: {', '.join(ALLOWED_EXTENSIONS)}",
|
||||
)
|
||||
|
||||
# Проверить лимит по тарифу
|
||||
limit_check = check_and_increment_limit(current_user.id, "plagiarism", current_user.plan)
|
||||
if not limit_check["allowed"]:
|
||||
# Content-Length позволяет отклонить слишком большой файл до его чтения в память.
|
||||
# Это не 100% защита (заголовок можно не передать), поэтому проверяем ещё раз после чтения.
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length and int(content_length) > MAX_FILE_SIZE_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"Файл слишком большой (максимум {MAX_FILE_SIZE_BYTES // 1024 // 1024} МБ)",
|
||||
)
|
||||
|
||||
# ── 2. Rate limits ─────────────────────────────────────────────────────────
|
||||
|
||||
limit_result = await check_and_increment_limit(current_user.id, "plagiarism", 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_check['current']} из {limit_check['limit']}."
|
||||
f"Превышен месячный лимит проверок (тариф '{current_user.plan}'): "
|
||||
f"{limit_result['current']}/{limit_result['limit']}. "
|
||||
f"Сбросится {limit_result['reset_at']}."
|
||||
),
|
||||
headers={"Retry-After": "86400"},
|
||||
)
|
||||
|
||||
# Проверить лимит одновременных задач
|
||||
if not check_concurrent_limit(current_user.id, current_user.plan):
|
||||
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="Превышен лимит одновременных задач.",
|
||||
)
|
||||
|
||||
# Прочитать файл
|
||||
file_data = await file.read()
|
||||
# ── 3. Читаем файл и проверяем реальный размер ────────────────────────────
|
||||
|
||||
file_data = await file.read()
|
||||
if len(file_data) > MAX_FILE_SIZE_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"Файл слишком большой. Максимальный размер: {MAX_FILE_SIZE_MB} МБ",
|
||||
detail=f"Файл слишком большой (максимум {MAX_FILE_SIZE_BYTES // 1024 // 1024} МБ)",
|
||||
)
|
||||
|
||||
# Загрузить в MinIO
|
||||
minio_key = f"uploads/{current_user.id}/{uuid.uuid4()}{ext}"
|
||||
minio_client = get_minio_client()
|
||||
# ── 4. Сохраняем в MinIO ───────────────────────────────────────────────────
|
||||
# В пути — UUID документа, НЕ user_id. Так не раскрываем внутренний ID пользователя.
|
||||
|
||||
doc_uuid = str(uuid.uuid4())
|
||||
minio_key = f"uploads/{doc_uuid}{ext}"
|
||||
content_type = CONTENT_TYPE_MAP.get(ext, "application/octet-stream")
|
||||
|
||||
try:
|
||||
# Создать бакет если не существует
|
||||
if not minio_client.bucket_exists(settings.MINIO_BUCKET_DOCS):
|
||||
minio_client.make_bucket(settings.MINIO_BUCKET_DOCS)
|
||||
|
||||
import io
|
||||
minio_client.put_object(
|
||||
get_minio().put_object(
|
||||
bucket_name=settings.MINIO_BUCKET_DOCS,
|
||||
object_name=minio_key,
|
||||
data=io.BytesIO(file_data),
|
||||
length=len(file_data),
|
||||
content_type=file.content_type or "application/octet-stream",
|
||||
content_type=content_type,
|
||||
)
|
||||
logger.info(f"Файл загружен в MinIO: {minio_key}")
|
||||
|
||||
except S3Error as e:
|
||||
logger.error(f"Ошибка загрузки в MinIO: {e}")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка загрузки в MinIO: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Ошибка сохранения файла. Попробуйте позже.",
|
||||
)
|
||||
|
||||
# Создать задачу в БД
|
||||
# ── 5. Создаём задачу и диспатчим ─────────────────────────────────────────
|
||||
|
||||
task = Task(
|
||||
user_id=current_user.id,
|
||||
type="plagiarism",
|
||||
@@ -126,30 +128,23 @@ async def upload_for_plagiarism_check(
|
||||
"filename": file.filename,
|
||||
"minio_key": minio_key,
|
||||
"file_size_bytes": len(file_data),
|
||||
"content_type": file.content_type,
|
||||
},
|
||||
)
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
task_id = task.id
|
||||
|
||||
# Диспатч в индексер воркер
|
||||
celery_result = celery_app.send_task(
|
||||
"index.extract_and_check",
|
||||
args=[task_id, minio_key, file.filename],
|
||||
args=[task.id, minio_key, file.filename],
|
||||
queue="queue.index",
|
||||
)
|
||||
|
||||
task.celery_task_id = celery_result.id
|
||||
task.queue_position = 1
|
||||
task.eta_seconds = 120 # ~2 минуты с учётом 4 уровней проверки
|
||||
task.eta_seconds = 120
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
logger.info(
|
||||
f"Задача проверки плагиата {task_id!r} создана для пользователя {current_user.id}, "
|
||||
f"файл: {file.filename!r}"
|
||||
)
|
||||
|
||||
logger.info("Задача плагиата %s создана для пользователя %d", task.public_id, current_user.id)
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
@@ -6,8 +6,8 @@ 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 check_and_increment_limit, check_concurrent_limit
|
||||
from app.core.security import get_current_user
|
||||
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
|
||||
@@ -17,41 +17,43 @@ 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,
|
||||
current_user: User = Depends(get_current_user),
|
||||
# Поиск требует подтверждённого email — защита от массового abuse
|
||||
current_user: User = Depends(get_current_verified_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TaskResponse:
|
||||
"""
|
||||
Создать задачу семантического поиска источников.
|
||||
Создать задачу поиска источников.
|
||||
|
||||
Возвращает TaskResponse с queue_position и eta_seconds сразу.
|
||||
Результат доступен через GET /tasks/{task_id} или WebSocket /ws/tasks/{task_id}.
|
||||
Возвращает сразу с task public_id и позицией в очереди.
|
||||
Результат — через GET /tasks/{public_id} или WS /ws/tasks/{public_id}?token=JWT.
|
||||
"""
|
||||
# Проверить лимит по тарифу
|
||||
limit_check = check_and_increment_limit(current_user.id, "search", current_user.plan)
|
||||
if not limit_check["allowed"]:
|
||||
# Проверяем лимит (атомарно, 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_check['current']} из {limit_check['limit']}."
|
||||
f"Превышен дневной лимит поиска (тариф '{current_user.plan}'): "
|
||||
f"{limit_result['current']}/{limit_result['limit']}. "
|
||||
f"Сбросится {limit_result['reset_at']}."
|
||||
),
|
||||
headers={"Retry-After": "86400"},
|
||||
)
|
||||
|
||||
# Проверить лимит одновременных задач
|
||||
if not check_concurrent_limit(current_user.id, current_user.plan):
|
||||
# Проверяем лимит одновременных задач (атомарно)
|
||||
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",
|
||||
@@ -65,31 +67,21 @@ async def create_search_task(
|
||||
},
|
||||
)
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
task_id = task.id
|
||||
await db.flush() # получаем id и public_id
|
||||
|
||||
# Диспатч в GPU воркер
|
||||
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,
|
||||
},
|
||||
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 # TODO: реальный подсчёт через Redis
|
||||
task.queue_position = 1
|
||||
task.eta_seconds = ETA_PER_POSITION_SECONDS
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
logger.info(
|
||||
f"Задача поиска {task_id!r} создана для пользователя {current_user.id}, "
|
||||
f"запрос: {data.query[:50]!r}"
|
||||
)
|
||||
|
||||
logger.info("Задача поиска %s создана для пользователя %d", task.public_id, current_user.id)
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Роутер для управления задачами пользователя."""
|
||||
"""Управление задачами пользователя.
|
||||
|
||||
В URL используется public_id (не внутренний UUID), чтобы не раскрывать
|
||||
структуру внутренних идентификаторов.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
@@ -24,7 +28,10 @@ async def list_tasks(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[TaskResponse]:
|
||||
"""Получить список задач текущего пользователя (от новых к старым)."""
|
||||
"""Список задач текущего пользователя (новые первыми)."""
|
||||
# Жёсткий потолок limit — не даём вытащить всю таблицу одним запросом
|
||||
limit = min(limit, 100)
|
||||
|
||||
result = await db.execute(
|
||||
select(Task)
|
||||
.where(Task.user_id == current_user.id)
|
||||
@@ -32,52 +39,47 @@ async def list_tasks(
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
tasks = result.scalars().all()
|
||||
return [TaskResponse.model_validate(t) for t in tasks]
|
||||
return [TaskResponse.model_validate(t) for t in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskResponse)
|
||||
@router.get("/{public_id}", response_model=TaskResponse)
|
||||
async def get_task(
|
||||
task_id: str,
|
||||
public_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TaskResponse:
|
||||
"""Получить детали конкретной задачи."""
|
||||
"""Получить задачу по публичному ID. Возвращает 404 если задача чужая — не раскрываем факт существования."""
|
||||
result = await db.execute(
|
||||
select(Task).where(Task.id == task_id, Task.user_id == current_user.id)
|
||||
select(Task).where(
|
||||
Task.public_id == public_id,
|
||||
Task.user_id == current_user.id, # ownership проверяется в одном запросе
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Задача не найдена",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Задача не найдена")
|
||||
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
|
||||
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/{public_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_task(
|
||||
task_id: str,
|
||||
public_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""
|
||||
Удалить задачу.
|
||||
|
||||
Нельзя удалить задачу в статусе 'processing'.
|
||||
"""
|
||||
"""Удалить задачу. Нельзя удалить задачу в статусе 'processing'."""
|
||||
result = await db.execute(
|
||||
select(Task).where(Task.id == task_id, Task.user_id == current_user.id)
|
||||
select(Task).where(
|
||||
Task.public_id == public_id,
|
||||
Task.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Задача не найдена",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Задача не найдена")
|
||||
|
||||
if task.status == "processing":
|
||||
raise HTTPException(
|
||||
|
||||
Reference in New Issue
Block a user