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>
45 lines
1.5 KiB
Python
45 lines
1.5 KiB
Python
"""Схемы для задач и поиска."""
|
||
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
from pydantic import BaseModel, Field
|
||
|
||
|
||
class TaskCreate(BaseModel):
|
||
"""Создание задачи."""
|
||
|
||
type: str
|
||
input_data: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
class TaskResponse(BaseModel):
|
||
"""Ответ с информацией о задаче.
|
||
|
||
В API возвращается public_id (не внутренний UUID).
|
||
Клиент использует public_id для всех операций с задачей.
|
||
"""
|
||
|
||
# public_id — это то, что видит клиент и использует в URL
|
||
public_id: str
|
||
type: str
|
||
status: str
|
||
queue_position: int | None = None
|
||
eta_seconds: int | None = None
|
||
# input_data не возвращаем (может содержать minio_key и другие внутренние данные)
|
||
result: dict[str, Any] | None = None
|
||
error: str | None = None
|
||
created_at: datetime
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class SearchRequest(BaseModel):
|
||
"""Запрос на семантический поиск источников."""
|
||
|
||
query: str = Field(min_length=3, max_length=1000)
|
||
lang: str | None = Field(default=None, description="Язык: ru, en или None для всех")
|
||
year_from: int | None = Field(default=None, ge=1900, le=2100)
|
||
year_to: int | None = Field(default=None, ge=1900, le=2100)
|
||
category: str | None = Field(default=None, description="Тематическая категория")
|