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>
46 lines
1.4 KiB
Python
46 lines
1.4 KiB
Python
"""Добавить public_id в таблицу tasks.
|
|
|
|
Revision ID: 002
|
|
Revises: 001
|
|
Create Date: 2026-05-24
|
|
"""
|
|
|
|
import secrets
|
|
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
from sqlalchemy import text
|
|
|
|
revision = "002"
|
|
down_revision = "001"
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
# Добавляем колонку как nullable — сначала заполним данными, потом сделаем NOT NULL
|
|
op.add_column(
|
|
"tasks",
|
|
sa.Column("public_id", sa.String(32), nullable=True),
|
|
)
|
|
|
|
# Заполняем существующие строки уникальными public_id
|
|
conn = op.get_bind()
|
|
tasks = conn.execute(text("SELECT id FROM tasks")).fetchall()
|
|
for (task_id,) in tasks:
|
|
conn.execute(
|
|
text("UPDATE tasks SET public_id = :pid WHERE id = :id"),
|
|
{"pid": secrets.token_urlsafe(16), "id": task_id},
|
|
)
|
|
|
|
# Делаем NOT NULL и добавляем уникальный индекс
|
|
op.alter_column("tasks", "public_id", nullable=False)
|
|
op.create_unique_constraint("uq_tasks_public_id", "tasks", ["public_id"])
|
|
op.create_index("ix_tasks_public_id", "tasks", ["public_id"], unique=True)
|
|
|
|
|
|
def downgrade() -> None:
|
|
op.drop_index("ix_tasks_public_id", table_name="tasks")
|
|
op.drop_constraint("uq_tasks_public_id", "tasks", type_="unique")
|
|
op.drop_column("tasks", "public_id")
|