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>
92 lines
3.2 KiB
Python
92 lines
3.2 KiB
Python
"""Управление задачами пользователя.
|
||
|
||
В URL используется public_id (не внутренний UUID), чтобы не раскрывать
|
||
структуру внутренних идентификаторов.
|
||
"""
|
||
|
||
import logging
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.security import get_current_user
|
||
from app.database import get_db
|
||
from app.models.task import Task
|
||
from app.models.user import User
|
||
from app.schemas.tasks import TaskResponse
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||
|
||
|
||
@router.get("/", response_model=list[TaskResponse])
|
||
async def list_tasks(
|
||
limit: int = 20,
|
||
offset: int = 0,
|
||
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)
|
||
.order_by(Task.created_at.desc())
|
||
.limit(limit)
|
||
.offset(offset)
|
||
)
|
||
return [TaskResponse.model_validate(t) for t in result.scalars().all()]
|
||
|
||
|
||
@router.get("/{public_id}", response_model=TaskResponse)
|
||
async def get_task(
|
||
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.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="Задача не найдена")
|
||
|
||
return TaskResponse.model_validate(task)
|
||
|
||
|
||
@router.delete("/{public_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
async def delete_task(
|
||
public_id: str,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
) -> None:
|
||
"""Удалить задачу. Нельзя удалить задачу в статусе 'processing'."""
|
||
result = await db.execute(
|
||
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="Задача не найдена")
|
||
|
||
if task.status == "processing":
|
||
raise HTTPException(
|
||
status_code=status.HTTP_409_CONFLICT,
|
||
detail="Нельзя удалить задачу в процессе выполнения",
|
||
)
|
||
|
||
await db.delete(task)
|
||
await db.commit()
|