Compare commits
1 Commits
dev
...
27b0928e73
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27b0928e73 |
@@ -1,71 +0,0 @@
|
||||
# 🚀 Docker ignore patterns for optimal build performance
|
||||
|
||||
# 📁 Development environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# 🧪 Testing and temporary files
|
||||
tests/
|
||||
test-results/
|
||||
*_test.py
|
||||
test_*.py
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
|
||||
# 📝 Documentation and metadata
|
||||
CHANGELOG*
|
||||
LICENSE*
|
||||
docs/
|
||||
.gitignore
|
||||
.dockerignore
|
||||
|
||||
# 🔧 Development tools
|
||||
.git/
|
||||
.github/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# 🎯 Build artifacts and cache
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
node_modules/
|
||||
.cache/
|
||||
dump/
|
||||
|
||||
# 📊 Logs and databases
|
||||
*.log
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
dev-server.pid
|
||||
|
||||
# 🔐 Environment and secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.key
|
||||
*.pem
|
||||
|
||||
# 🎨 Frontend development
|
||||
panel/node_modules/
|
||||
*.css.map
|
||||
*.js.map
|
||||
|
||||
# 🧹 OS and editor files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.tmp
|
||||
*.temp
|
||||
@@ -1,178 +1,14 @@
|
||||
name: 'Deploy on push'
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Cloning repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
# Try multiple installation methods for uv
|
||||
if curl -LsSf https://astral.sh/uv/install.sh | sh; then
|
||||
echo "uv installed successfully via install script"
|
||||
elif curl -LsSf https://github.com/astral-sh/uv/releases/latest/download/uv-installer.sh | sh; then
|
||||
echo "uv installed successfully via GitHub installer"
|
||||
else
|
||||
echo "uv installation failed, using pip fallback"
|
||||
pip install uv
|
||||
fi
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Prepare Environment
|
||||
run: |
|
||||
uv --version
|
||||
python3 --version
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
uv sync --frozen
|
||||
uv sync --group dev
|
||||
|
||||
|
||||
|
||||
- name: Run linting
|
||||
run: |
|
||||
echo "🔍 Запускаем проверки качества кода..."
|
||||
|
||||
# Ruff linting
|
||||
echo "📝 Проверяем код с помощью Ruff..."
|
||||
uv run ruff check . --fix
|
||||
|
||||
# Ruff formatting check
|
||||
echo "🎨 Проверяем форматирование с помощью Ruff..."
|
||||
uv run ruff format . --line-length 120
|
||||
|
||||
- name: Run type checking
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "🏷️ Проверяем типы с помощью MyPy..."
|
||||
echo "📊 Доступная память:"
|
||||
free -h
|
||||
|
||||
# Проверяем доступную память
|
||||
AVAILABLE_MEM=$(free -m | awk 'NR==2{printf "%.0f", $7}')
|
||||
echo "📊 Доступно памяти: ${AVAILABLE_MEM}MB"
|
||||
|
||||
# Если памяти меньше 1GB, пропускаем mypy
|
||||
if [ "$AVAILABLE_MEM" -lt 1000 ]; then
|
||||
echo "⚠️ Недостаточно памяти для mypy (${AVAILABLE_MEM}MB < 1000MB), пропускаем проверку типов"
|
||||
echo "✅ Проверка типов пропущена из-за нехватки памяти"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Пробуем dmypy сначала, если не работает - fallback на обычный mypy
|
||||
if command -v dmypy >/dev/null 2>&1 && uv run dmypy run -- auth/ cache/ orm/ resolvers/ services/ storage/ utils/ --ignore-missing-imports; then
|
||||
echo "✅ dmypy выполнен успешно"
|
||||
else
|
||||
echo "⚠️ dmypy недоступен, используем обычный mypy"
|
||||
# Запускаем mypy только на самых критичных модулях
|
||||
echo "🔍 Проверяем только критичные модули..."
|
||||
uv run mypy auth/ orm/ resolvers/ --ignore-missing-imports || echo "⚠️ Ошибки в критичных модулях, но продолжаем"
|
||||
echo "✅ Проверка типов завершена"
|
||||
fi
|
||||
|
||||
- name: Install Node.js Dependencies
|
||||
run: |
|
||||
npm ci
|
||||
|
||||
- name: Build Frontend
|
||||
env:
|
||||
CI: "true" # 🚨 Указываем что это CI сборка для codegen
|
||||
run: |
|
||||
echo "🏗️ Начинаем сборку фронтенда..."
|
||||
|
||||
# Запускаем codegen с fallback логикой
|
||||
echo "📝 Запускаем GraphQL codegen..."
|
||||
npm run codegen 2>&1 | tee codegen_output.log
|
||||
if [ ${PIPESTATUS[0]} -ne 0 ]; then
|
||||
echo "❌ GraphQL codegen упал с v3.discours.io!"
|
||||
echo "📋 ПОЛНЫЙ ВЫВОД ОШИБКИ:"
|
||||
cat codegen_output.log
|
||||
echo "📋 КОНЕЦ ВЫВОДА ОШИБКИ"
|
||||
echo ""
|
||||
|
||||
# Проверяем доступность endpoints
|
||||
echo "🌐 Проверяем доступность GraphQL endpoints:"
|
||||
V3_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query":"query{__typename}"}' \
|
||||
https://v3.discours.io/graphql 2>/dev/null || echo "000")
|
||||
echo "v3.discours.io: $V3_STATUS"
|
||||
|
||||
CORETEST_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query":"query{__typename}"}' \
|
||||
https://coretest.discours.io/graphql 2>/dev/null || echo "000")
|
||||
echo "coretest.discours.io: $CORETEST_STATUS"
|
||||
|
||||
# Если coretest доступен, пробуем его
|
||||
if [ "$CORETEST_STATUS" = "200" ]; then
|
||||
echo "🔄 Переключаемся на coretest.discours.io..."
|
||||
# Временно меняем схему в codegen.ts
|
||||
sed -i "s|https://v3.discours.io/graphql|https://coretest.discours.io/graphql|g" codegen.ts
|
||||
npm run codegen 2>&1 | tee fallback_output.log
|
||||
if [ ${PIPESTATUS[0]} -ne 0 ]; then
|
||||
echo "❌ Fallback тоже не сработал!"
|
||||
echo "📋 ПОЛНЫЙ ВЫВОД ОШИБКИ FALLBACK:"
|
||||
cat fallback_output.log
|
||||
echo "📋 КОНЕЦ ВЫВОДА ОШИБКИ FALLBACK"
|
||||
# Восстанавливаем оригинальную схему
|
||||
sed -i "s|https://coretest.discours.io/graphql|https://v3.discours.io/graphql|g" codegen.ts
|
||||
exit 1
|
||||
fi
|
||||
# Восстанавливаем оригинальную схему
|
||||
sed -i "s|https://coretest.discours.io/graphql|https://v3.discours.io/graphql|g" codegen.ts
|
||||
else
|
||||
echo "❌ Оба endpoint недоступны!"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "🔨 Запускаем Vite build..."
|
||||
npx vite build
|
||||
|
||||
- name: Setup Playwright (use pre-installed browsers)
|
||||
env:
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
||||
run: |
|
||||
# Используем предустановленные браузеры в системе
|
||||
npx playwright --version
|
||||
|
||||
- name: Run Tests
|
||||
env:
|
||||
PLAYWRIGHT_HEADLESS: "true"
|
||||
timeout-minutes: 7
|
||||
run: |
|
||||
# Запускаем тесты с таймаутом для предотвращения зависания
|
||||
# continue-on-error: true не работает в Gitea Actions, поэтому используем || true
|
||||
timeout 900 uv run pytest tests/ -v --timeout=300 || echo "⚠️ Тесты завершились с ошибками/таймаутом, но продолжаем деплой"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Restore Git Repository
|
||||
if: always()
|
||||
run: |
|
||||
echo "🔧 Восстанавливаем git репозиторий для деплоя..."
|
||||
# Проверяем состояние git
|
||||
git status || echo "⚠️ Git репозиторий поврежден, восстанавливаем..."
|
||||
|
||||
# Если git поврежден, переинициализируем
|
||||
if [ ! -d ".git" ] || [ ! -f ".git/HEAD" ]; then
|
||||
echo "🔄 Переинициализируем git репозиторий..."
|
||||
git init
|
||||
git remote add origin https://github.com/${{ github.repository }}.git
|
||||
git fetch origin
|
||||
git checkout ${{ github.ref_name }}
|
||||
fi
|
||||
|
||||
# Проверяем финальное состояние
|
||||
git status
|
||||
echo "✅ Git репозиторий готов для деплоя"
|
||||
|
||||
- name: Get Repo Name
|
||||
id: repo_name
|
||||
run: echo "::set-output name=repo::$(echo ${GITHUB_REPOSITORY##*/})"
|
||||
@@ -181,56 +17,28 @@ jobs:
|
||||
id: branch_name
|
||||
run: echo "::set-output name=branch::$(echo ${GITHUB_REF##*/})"
|
||||
|
||||
- name: Verify Git Before Deploy Main
|
||||
- name: Push to dokku for main branch
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
echo "🔍 Проверяем git перед деплоем на main..."
|
||||
git status
|
||||
git log --oneline -5
|
||||
echo "✅ Git репозиторий готов"
|
||||
|
||||
- name: Verify Git Before Deploy
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
run: |
|
||||
echo "🔍 Проверяем git перед деплоем..."
|
||||
git status
|
||||
git log --oneline -5
|
||||
echo "✅ Git репозиторий готов"
|
||||
|
||||
- name: Setup SSH for Dev Deploy
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
run: |
|
||||
echo "🔑 Настраиваем SSH для деплоя..."
|
||||
|
||||
# Создаем SSH директорию
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
|
||||
# Добавляем приватный ключ
|
||||
echo "${{ secrets.STAGING_PRIVATE_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
|
||||
# Добавляем v3.discours.io в known_hosts
|
||||
ssh-keyscan -H v3.discours.io >> ~/.ssh/known_hosts
|
||||
|
||||
# Запускаем ssh-agent
|
||||
eval $(ssh-agent -s)
|
||||
ssh-add ~/.ssh/id_rsa
|
||||
|
||||
echo "✅ SSH настроен для v3.discours.io"
|
||||
uses: dokku/github-action@master
|
||||
with:
|
||||
branch: 'main'
|
||||
git_remote_url: 'ssh://dokku@v2.discours.io:22/discoursio-api'
|
||||
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: Push to dokku for dev branch
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
run: |
|
||||
echo "🚀 Деплоим на v3.discours.io..."
|
||||
|
||||
# Добавляем dokku remote
|
||||
git remote add dokku ssh://dokku@v3.discours.io:22/core || git remote set-url dokku ssh://dokku@v3.discours.io:22/core
|
||||
|
||||
# Проверяем remote
|
||||
git remote -v
|
||||
|
||||
# Деплоим текущую ветку
|
||||
git push dokku dev -f
|
||||
|
||||
echo "✅ Деплой на dev завершен"
|
||||
uses: dokku/github-action@master
|
||||
with:
|
||||
branch: 'main'
|
||||
force: true
|
||||
git_remote_url: 'ssh://dokku@v2.discours.io:22/core'
|
||||
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: Push to dokku for staging branch
|
||||
if: github.ref == 'refs/heads/staging'
|
||||
uses: dokku/github-action@master
|
||||
with:
|
||||
branch: 'dev'
|
||||
git_remote_url: 'ssh://dokku@staging.discours.io:22/core'
|
||||
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
git_push_flags: '--force'
|
||||
241
.github/workflows/deploy.yml
vendored
241
.github/workflows/deploy.yml
vendored
@@ -1,233 +1,28 @@
|
||||
name: CI/CD Pipeline
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, dev, feature/* ]
|
||||
pull_request:
|
||||
branches: [ main, dev ]
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
push_to_target_repository:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Checkout source repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- uses: webfactory/ssh-agent@v0.8.0
|
||||
with:
|
||||
ssh-private-key: ${{ github.action.secrets.SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v1
|
||||
with:
|
||||
version: "1.0.0"
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
.venv
|
||||
.uv_cache
|
||||
key: ${{ runner.os }}-uv-3.13-${{ hashFiles('**/uv.lock') }}
|
||||
restore-keys: ${{ runner.os }}-uv-3.13-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync --group dev
|
||||
cd panel && npm ci && cd ..
|
||||
|
||||
- name: Verify Redis connection
|
||||
run: |
|
||||
echo "Verifying Redis connection..."
|
||||
max_retries=5
|
||||
for attempt in $(seq 1 $max_retries); do
|
||||
if redis-cli ping > /dev/null 2>&1; then
|
||||
echo "✅ Redis is ready!"
|
||||
break
|
||||
else
|
||||
if [ $attempt -eq $max_retries ]; then
|
||||
echo "❌ Redis connection failed after $max_retries attempts"
|
||||
echo "⚠️ Tests may fail due to Redis unavailability"
|
||||
# Не выходим с ошибкой, продолжаем тесты
|
||||
break
|
||||
else
|
||||
echo "⚠️ Redis not ready, retrying in 2 seconds... (attempt $attempt/$max_retries)"
|
||||
sleep 2
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Run linting and type checking
|
||||
run: |
|
||||
echo "🔍 Запускаем проверки качества кода..."
|
||||
|
||||
# Ruff linting
|
||||
echo "📝 Проверяем код с помощью Ruff..."
|
||||
uv run ruff check . --fix
|
||||
|
||||
# Ruff formatting check
|
||||
echo "🎨 Проверяем форматирование с помощью Ruff..."
|
||||
uv run ruff format . --line-length 120
|
||||
|
||||
# MyPy type checking
|
||||
echo "🏷️ Проверяем типы с помощью MyPy..."
|
||||
uv run mypy . --ignore-missing-imports
|
||||
|
||||
- name: Setup test environment
|
||||
run: |
|
||||
echo "Setting up test environment..."
|
||||
# Создаем .env.test для тестов
|
||||
cat > .env.test << EOF
|
||||
DATABASE_URL=sqlite:///database.db
|
||||
REDIS_URL=redis://localhost:6379
|
||||
TEST_MODE=true
|
||||
EOF
|
||||
|
||||
# Проверяем что файл создан
|
||||
echo "Test environment file created:"
|
||||
cat .env.test
|
||||
|
||||
- name: Initialize test database
|
||||
run: |
|
||||
echo "Initializing test database..."
|
||||
touch database.db
|
||||
uv run python -c "
|
||||
import time
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем корневую папку в путь
|
||||
sys.path.insert(0, str(Path.cwd()))
|
||||
|
||||
try:
|
||||
from orm.base import Base
|
||||
from orm.community import Community, CommunityFollower, CommunityAuthor
|
||||
from orm.draft import Draft
|
||||
from orm.invite import Invite
|
||||
from orm.notification import Notification
|
||||
from orm.reaction import Reaction
|
||||
from orm.shout import Shout
|
||||
from orm.topic import Topic
|
||||
from orm.author import Author, AuthorBookmark, AuthorRating, AuthorFollower
|
||||
from storage.db import engine
|
||||
from sqlalchemy import inspect
|
||||
|
||||
print('✅ Engine imported successfully')
|
||||
|
||||
print('Creating all tables...')
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
# Проверяем что таблицы созданы
|
||||
inspector = inspect(engine)
|
||||
tables = inspector.get_table_names()
|
||||
print(f'✅ Created tables: {tables}')
|
||||
|
||||
# Проверяем конкретно community_author
|
||||
if 'community_author' in tables:
|
||||
print('✅ community_author table exists!')
|
||||
else:
|
||||
print('❌ community_author table missing!')
|
||||
print('Available tables:', tables)
|
||||
|
||||
except Exception as e:
|
||||
print(f'❌ Error initializing database: {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
"
|
||||
|
||||
- name: Start servers
|
||||
run: |
|
||||
chmod +x ./ci-server.py
|
||||
timeout 300 python ./ci-server.py &
|
||||
echo $! > ci-server.pid
|
||||
|
||||
echo "Waiting for servers..."
|
||||
timeout 180 bash -c '
|
||||
while ! (curl -f http://localhost:8000/ > /dev/null 2>&1 && \
|
||||
curl -f http://localhost:3000/ > /dev/null 2>&1); do
|
||||
sleep 3
|
||||
done
|
||||
echo "Servers ready!"
|
||||
'
|
||||
|
||||
- name: Run tests with retry
|
||||
run: |
|
||||
# Создаем папку для результатов тестов
|
||||
mkdir -p test-results
|
||||
|
||||
# Сначала проверяем здоровье серверов
|
||||
echo "🏥 Проверяем здоровье серверов..."
|
||||
if uv run pytest tests/test_server_health.py -v; then
|
||||
echo "✅ Серверы здоровы!"
|
||||
else
|
||||
echo "⚠️ Тест здоровья серверов не прошел, но продолжаем..."
|
||||
fi
|
||||
|
||||
for test_type in "not e2e" "integration" "e2e" "browser"; do
|
||||
echo "Running $test_type tests..."
|
||||
max_retries=3 # Увеличиваем количество попыток
|
||||
for attempt in $(seq 1 $max_retries); do
|
||||
echo "Attempt $attempt/$max_retries for $test_type tests..."
|
||||
|
||||
# Добавляем специальные параметры для browser тестов
|
||||
if [ "$test_type" = "browser" ]; then
|
||||
echo "🚀 Запускаем browser тесты с увеличенным таймаутом..."
|
||||
if uv run pytest tests/ -m "$test_type" -v --tb=short --timeout=60; then
|
||||
echo "✅ $test_type tests passed!"
|
||||
break
|
||||
else
|
||||
if [ $attempt -eq $max_retries ]; then
|
||||
echo "⚠️ Browser tests failed after $max_retries attempts (expected in CI) - continuing..."
|
||||
break
|
||||
else
|
||||
echo "⚠️ Browser tests failed, retrying in 15 seconds..."
|
||||
sleep 15
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# Обычные тесты
|
||||
if uv run pytest tests/ -m "$test_type" -v --tb=short; then
|
||||
echo "✅ $test_type tests passed!"
|
||||
break
|
||||
else
|
||||
if [ $attempt -eq $max_retries ]; then
|
||||
echo "❌ $test_type tests failed after $max_retries attempts"
|
||||
exit 1
|
||||
else
|
||||
echo "⚠️ $test_type tests failed, retrying in 10 seconds..."
|
||||
sleep 10
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
- name: Generate coverage
|
||||
run: |
|
||||
uv run pytest tests/ --cov=. --cov-report=xml --cov-report=html
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
[ -f ci-server.pid ] && kill $(cat ci-server.pid) 2>/dev/null || true
|
||||
pkill -f "python dev.py|npm run dev|vite|ci-server.py" || true
|
||||
rm -f backend.pid frontend.pid ci-server.pid
|
||||
- name: Push to dokku
|
||||
env:
|
||||
HOST_KEY: ${{ github.action.secrets.HOST_KEY }}
|
||||
run: |
|
||||
echo $HOST_KEY > ~/.ssh/known_hosts
|
||||
git remote add dokku dokku@v2.discours.io:discoursio-api
|
||||
git push dokku HEAD:main -f
|
||||
|
||||
27
.gitignore
vendored
27
.gitignore
vendored
@@ -128,6 +128,9 @@ dmypy.json
|
||||
.idea
|
||||
temp.*
|
||||
|
||||
# Debug
|
||||
DEBUG.log
|
||||
|
||||
discours.key
|
||||
discours.crt
|
||||
discours.pem
|
||||
@@ -162,26 +165,4 @@ views.json
|
||||
*.crt
|
||||
*cache.json
|
||||
.cursor
|
||||
|
||||
node_modules/
|
||||
panel/graphql/generated/
|
||||
panel/types.gen.ts
|
||||
|
||||
.cursorrules
|
||||
.cursor/
|
||||
|
||||
# YoYo AI version control directory
|
||||
.yoyo/
|
||||
.autopilot.json
|
||||
.cursor
|
||||
tmp
|
||||
test-results
|
||||
page_content.html
|
||||
test_output
|
||||
docs/progress/*
|
||||
|
||||
panel/graphql/generated
|
||||
|
||||
test_e2e.db*
|
||||
|
||||
uv.lock
|
||||
.devcontainer/
|
||||
|
||||
18
.pre-commit-config.yaml
Normal file
18
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
- id: check-yaml
|
||||
- id: check-toml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- id: check-added-large-files
|
||||
- id: detect-private-key
|
||||
- id: check-ast
|
||||
- id: check-merge-conflict
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.4.7
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
2559
CHANGELOG.md
2559
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
134
CONTRIBUTING.md
134
CONTRIBUTING.md
@@ -1,134 +0,0 @@
|
||||
# Contributing to Discours Core
|
||||
|
||||
🎉 Thanks for taking the time to contribute!
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch: `git checkout -b my-new-feature`
|
||||
3. Make your changes
|
||||
4. Add tests for your changes
|
||||
5. Run the test suite: `pytest`
|
||||
6. Run the linter: `ruff check . --fix && ruff format . --line-length=120`
|
||||
7. Commit your changes: `git commit -am 'Add some feature'`
|
||||
8. Push to the branch: `git push origin my-new-feature`
|
||||
9. Create a Pull Request
|
||||
|
||||
## 📋 Development Guidelines
|
||||
|
||||
### Code Style
|
||||
|
||||
- **Python 3.12+** required
|
||||
- **Line length**: 120 characters max
|
||||
- **Type hints**: Required for all functions
|
||||
- **Docstrings**: Required for public methods
|
||||
- **Ruff**: linting and formatting
|
||||
- **MyPy**: typechecks
|
||||
|
||||
### Testing
|
||||
|
||||
- **Pytest** for testing
|
||||
- **85%+ coverage** required
|
||||
- Test both positive and negative cases
|
||||
- Mock external dependencies
|
||||
|
||||
### Commit Messages
|
||||
|
||||
We follow [Conventional Commits](https://conventionalcommits.org/):
|
||||
|
||||
```
|
||||
feat: add user authentication
|
||||
fix: resolve database connection issue
|
||||
docs: update API documentation
|
||||
test: add tests for reaction system
|
||||
refactor: improve GraphQL resolvers
|
||||
```
|
||||
|
||||
### Python Code Standards
|
||||
|
||||
```python
|
||||
# Good example
|
||||
async def create_reaction(
|
||||
session: Session,
|
||||
author_id: int,
|
||||
reaction_data: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create a new reaction.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
author_id: ID of the author creating the reaction
|
||||
reaction_data: Reaction data
|
||||
|
||||
Returns:
|
||||
Created reaction data
|
||||
|
||||
Raises:
|
||||
ValueError: If reaction data is invalid
|
||||
"""
|
||||
if not reaction_data.get("kind"):
|
||||
raise ValueError("Reaction kind is required")
|
||||
|
||||
reaction = Reaction(**reaction_data)
|
||||
session.add(reaction)
|
||||
session.commit()
|
||||
|
||||
return reaction.dict()
|
||||
```
|
||||
|
||||
## 🐛 Bug Reports
|
||||
|
||||
When filing a bug report, please include:
|
||||
|
||||
- **Python version**
|
||||
- **Package versions** (`pip freeze`)
|
||||
- **Error message** and full traceback
|
||||
- **Steps to reproduce**
|
||||
- **Expected vs actual behavior**
|
||||
|
||||
## 💡 Feature Requests
|
||||
|
||||
For feature requests, please include:
|
||||
|
||||
- **Use case** description
|
||||
- **Proposed solution**
|
||||
- **Alternatives considered**
|
||||
- **Breaking changes** (if any)
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- Update documentation for new features
|
||||
- Add examples for complex functionality
|
||||
- Use Russian comments for Russian-speaking team members
|
||||
- Keep README.md up to date
|
||||
|
||||
## 🔍 Code Review Process
|
||||
|
||||
1. **Automated checks** must pass (tests, linting)
|
||||
2. **Manual review** by at least one maintainer
|
||||
3. **Documentation** must be updated if needed
|
||||
4. **Breaking changes** require discussion
|
||||
|
||||
## 🏷️ Release Process
|
||||
|
||||
We follow [Semantic Versioning](https://semver.org/):
|
||||
|
||||
- **MAJOR**: Breaking changes
|
||||
- **MINOR**: New features (backward compatible)
|
||||
- **PATCH**: Bug fixes (backward compatible)
|
||||
|
||||
## 🤝 Community
|
||||
|
||||
- Be respectful and inclusive
|
||||
- Help newcomers get started
|
||||
- Share knowledge and best practices
|
||||
- Follow our [Code of Conduct](CODE_OF_CONDUCT.md)
|
||||
|
||||
## 📞 Getting Help
|
||||
|
||||
- **Issues**: For bugs and feature requests
|
||||
- **Discussions**: For questions and general discussion
|
||||
- **Documentation**: Check `docs/` folder first
|
||||
|
||||
Thank you for contributing! 🙏
|
||||
57
Dockerfile
57
Dockerfile
@@ -1,65 +1,18 @@
|
||||
# 🏗️ Multi-stage build for optimal caching and size
|
||||
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
|
||||
FROM python:slim
|
||||
|
||||
# 🔧 System dependencies layer (cached unless OS changes)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
postgresql-client \
|
||||
git \
|
||||
curl \
|
||||
build-essential \
|
||||
gnupg \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 📦 Install Node.js LTS (cached until Node.js version changes)
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
|
||||
apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& npm upgrade -g npm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 📦 Node.js dependencies layer (cached unless package*.json changes)
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# 🐍 Python dependencies compilation (with Rust/maturin support)
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-install-project
|
||||
|
||||
# 🏗️ Frontend build (build with all dependencies)
|
||||
COPY . .
|
||||
# Install local package in builder stage
|
||||
RUN uv sync --frozen --no-editable
|
||||
RUN npm run build
|
||||
|
||||
# 🚀 Production stage
|
||||
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
|
||||
|
||||
# 🔧 Runtime dependencies only
|
||||
RUN apt-get update && apt-get install -y \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 🧠 ML models cache setup (cached unless HF environment changes)
|
||||
RUN mkdir -p /app/.cache/huggingface && chmod 755 /app/.cache/huggingface
|
||||
ENV TRANSFORMERS_CACHE=/app/.cache/huggingface
|
||||
ENV HF_HOME=/app/.cache/huggingface
|
||||
# Принудительно используем CPU-only версию PyTorch
|
||||
ENV TORCH_CUDA_AVAILABLE=0
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
# 🚀 Application code (rebuilt on any code change)
|
||||
COPY . .
|
||||
|
||||
# 📦 Copy compiled Python environment from builder (includes all dependencies + local package)
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# 📦 Copy built frontend from builder stage
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["python", "-m", "granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["python", "-m", "granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port", "8000"]
|
||||
21
LICENSE
21
LICENSE
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Discours Team
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
272
README.md
272
README.md
@@ -1,212 +1,102 @@
|
||||
# Discours.io Core
|
||||
# GraphQL API Backend
|
||||
|
||||
🚀 **Modern community platform** with GraphQL API, RBAC system, and comprehensive testing infrastructure.
|
||||
Backend service providing GraphQL API for content management system with reactions, ratings and comments.
|
||||
|
||||
## 🎯 Features
|
||||
## Core Features
|
||||
|
||||
- **🔐 Authentication**: JWT + OAuth (Google, GitHub, Facebook)
|
||||
- **🏘️ Communities**: Full community management with roles and permissions
|
||||
- **🔒 RBAC System**: Role-based access control with inheritance
|
||||
- **🌐 GraphQL API**: Modern API with comprehensive schema
|
||||
- **🧪 Testing**: Complete test suite with E2E automation
|
||||
- **🚀 CI/CD**: Automated testing and deployment pipeline
|
||||
### Shouts (Posts)
|
||||
- CRUD operations via GraphQL mutations
|
||||
- Rich filtering and sorting options
|
||||
- Support for multiple authors and topics
|
||||
- Rating system with likes/dislikes
|
||||
- Comments and nested replies
|
||||
- Bookmarks and following
|
||||
|
||||
## 🚀 Quick Start
|
||||
### Reactions System
|
||||
- `ReactionKind` types: LIKE, DISLIKE, COMMENT
|
||||
- Rating calculation for shouts and comments
|
||||
- User-specific reaction tracking
|
||||
- Reaction stats and aggregations
|
||||
- Nested comments support
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.11+
|
||||
- Node.js 18+
|
||||
- Redis
|
||||
- uv (Python package manager)
|
||||
### Authors & Topics
|
||||
- Author profiles with stats
|
||||
- Topic categorization and hierarchy
|
||||
- Following system for authors/topics
|
||||
- Activity tracking and stats
|
||||
- Community features
|
||||
|
||||
### Installation
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone <repository-url>
|
||||
cd core
|
||||
## Tech Stack
|
||||
|
||||
# Install Python dependencies
|
||||
uv sync --group dev
|
||||
- **(Python)[https://www.python.org/]** 3.12+
|
||||
- **GraphQL** with [Ariadne](https://ariadnegraphql.org/)
|
||||
- **(SQLAlchemy)[https://docs.sqlalchemy.org/en/20/orm/]**
|
||||
- **(PostgreSQL)[https://www.postgresql.org/]/(SQLite)[https://www.sqlite.org/]** support
|
||||
- **(Starlette)[https://www.starlette.io/]** for ASGI server
|
||||
- **(Redis)[https://redis.io/]** for caching
|
||||
|
||||
# Install Node.js dependencies
|
||||
cd panel
|
||||
npm ci
|
||||
cd ..
|
||||
## Development
|
||||
|
||||
# Setup environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
### Prepare environment:
|
||||
|
||||
```shell
|
||||
mkdir .venv
|
||||
python3.12 -m venv venv
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
### Development
|
||||
```bash
|
||||
# Start backend server
|
||||
uv run python dev.py
|
||||
### Run server
|
||||
|
||||
# Start frontend (in another terminal)
|
||||
cd panel
|
||||
npm run dev
|
||||
First, certifcates are required to run the server.
|
||||
|
||||
```shell
|
||||
mkcert -install
|
||||
mkcert localhost
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
Then, run the server:
|
||||
|
||||
### Run All Tests
|
||||
```bash
|
||||
uv run pytest tests/ -v
|
||||
```shell
|
||||
python server.py dev
|
||||
```
|
||||
|
||||
### Test Categories
|
||||
### Useful Commands
|
||||
|
||||
#### Run only unit tests
|
||||
```bash
|
||||
uv run pytest tests/ -m "not e2e" -v
|
||||
```shell
|
||||
# Linting and import sorting
|
||||
ruff check . --fix --select I
|
||||
|
||||
# Code formatting
|
||||
ruff format . --line-length=120
|
||||
|
||||
# Run tests
|
||||
pytest
|
||||
|
||||
# Type checking
|
||||
mypy .
|
||||
```
|
||||
|
||||
#### Run only integration tests
|
||||
```bash
|
||||
uv run pytest tests/ -m "integration" -v
|
||||
### Code Style
|
||||
|
||||
We use:
|
||||
- Ruff for linting and import sorting
|
||||
- Line length: 120 characters
|
||||
- Python type hints
|
||||
- Docstrings for public methods
|
||||
|
||||
### GraphQL Development
|
||||
|
||||
Test queries in GraphQL Playground at `http://localhost:8000`:
|
||||
|
||||
```graphql
|
||||
# Example query
|
||||
query GetShout($slug: String) {
|
||||
get_shout(slug: $slug) {
|
||||
id
|
||||
title
|
||||
main_author {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Run only e2e tests
|
||||
```bash
|
||||
uv run pytest tests/ -m "e2e" -v
|
||||
```
|
||||
|
||||
#### Run browser tests
|
||||
```bash
|
||||
uv run pytest tests/ -m "browser" -v
|
||||
```
|
||||
|
||||
#### Run API tests
|
||||
```bash
|
||||
uv run pytest tests/ -m "api" -v
|
||||
```
|
||||
|
||||
#### Skip slow tests
|
||||
```bash
|
||||
uv run pytest tests/ -m "not slow" -v
|
||||
```
|
||||
|
||||
#### Run tests with specific markers
|
||||
```bash
|
||||
uv run pytest tests/ -m "db and not slow" -v
|
||||
```
|
||||
|
||||
### Test Markers
|
||||
- `unit` - Unit tests (fast)
|
||||
- `integration` - Integration tests
|
||||
- `e2e` - End-to-end tests
|
||||
- `browser` - Browser automation tests
|
||||
- `api` - API-based tests
|
||||
- `db` - Database tests
|
||||
- `redis` - Redis tests
|
||||
- `auth` - Authentication tests
|
||||
- `slow` - Slow tests (can be skipped)
|
||||
|
||||
### E2E Testing
|
||||
E2E tests automatically start backend and frontend servers:
|
||||
- Backend: `http://localhost:8000`
|
||||
- Frontend: `http://localhost:3000`
|
||||
|
||||
## 🚀 CI/CD Pipeline
|
||||
|
||||
### GitHub Actions Workflow
|
||||
The project includes a comprehensive CI/CD pipeline that:
|
||||
|
||||
1. **🧪 Testing Phase**
|
||||
- Matrix testing across Python 3.11, 3.12, 3.13
|
||||
- Unit, integration, and E2E tests
|
||||
- Code coverage reporting
|
||||
- Linting and type checking
|
||||
|
||||
2. **🚀 Deployment Phase**
|
||||
- **Staging**: Automatic deployment on `dev` branch
|
||||
- **Production**: Automatic deployment on `main` branch
|
||||
- Dokku integration for seamless deployments
|
||||
|
||||
### Local CI Testing
|
||||
Test the CI pipeline locally:
|
||||
|
||||
```bash
|
||||
# Run local CI simulation
|
||||
chmod +x scripts/test-ci-local.sh
|
||||
./scripts/test-ci-local.sh
|
||||
```
|
||||
|
||||
### CI Server Management
|
||||
The `./ci-server.py` script manages servers for CI:
|
||||
|
||||
```bash
|
||||
# Start servers in CI mode
|
||||
CI_MODE=true python3 ./ci-server.py
|
||||
```
|
||||
|
||||
## 📊 Project Structure
|
||||
|
||||
```
|
||||
core/
|
||||
├── auth/ # Authentication system
|
||||
├── orm/ # Database models
|
||||
├── resolvers/ # GraphQL resolvers
|
||||
├── services/ # Business logic
|
||||
├── panel/ # Frontend (SolidJS)
|
||||
├── tests/ # Test suite
|
||||
├── scripts/ # CI/CD scripts
|
||||
└── docs/ # Documentation
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Environment Variables
|
||||
- `DATABASE_URL` - Database connection string
|
||||
- `REDIS_URL` - Redis connection string
|
||||
- `JWT_SECRET_KEY` - JWT signing secret
|
||||
- `OAUTH_*` - OAuth provider credentials
|
||||
|
||||
### Database
|
||||
- **Development**: SQLite (default)
|
||||
- **Production**: PostgreSQL
|
||||
- **Testing**: In-memory SQLite
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- [API Documentation](docs/api.md)
|
||||
- [Authentication](docs/auth.md)
|
||||
- [RBAC System](docs/rbac-system.md)
|
||||
- [Testing Guide](docs/testing.md)
|
||||
- [Deployment](docs/deployment.md)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests for new functionality
|
||||
5. Ensure all tests pass
|
||||
6. Submit a pull request
|
||||
|
||||
### Development Workflow
|
||||
```bash
|
||||
# Create feature branch
|
||||
git checkout -b feature/your-feature
|
||||
|
||||
# Make changes and test
|
||||
uv run pytest tests/ -v
|
||||
|
||||
# Commit changes
|
||||
git commit -m "feat: add your feature"
|
||||
|
||||
# Push and create PR
|
||||
git push origin feature/your-feature
|
||||
```
|
||||
|
||||
## 📈 Status
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Получаем путь к корневой директории проекта
|
||||
root_path = Path(__file__).parent.parent
|
||||
sys.path.append(str(root_path))
|
||||
root_path = os.path.abspath(os.path.dirname(__file__))
|
||||
sys.path.append(root_path)
|
||||
|
||||
76
alembic/env.py
Normal file
76
alembic/env.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from alembic import context
|
||||
from services.db import Base
|
||||
from settings import DB_URL
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# override DB_URL
|
||||
config.set_section_option(config.config_ini_section, "DB_URL", DB_URL)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = [Base.metadata]
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
129
auth/__init__.py
129
auth/__init__.py
@@ -1,129 +0,0 @@
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, RedirectResponse, Response
|
||||
|
||||
from auth.core import verify_internal_auth
|
||||
from auth.tokens.storage import TokenStorage
|
||||
from auth.utils import extract_token_from_request
|
||||
from orm.author import Author
|
||||
from settings import (
|
||||
SESSION_COOKIE_HTTPONLY,
|
||||
SESSION_COOKIE_MAX_AGE,
|
||||
SESSION_COOKIE_NAME,
|
||||
SESSION_COOKIE_SAMESITE,
|
||||
SESSION_COOKIE_SECURE,
|
||||
)
|
||||
from storage.db import local_session
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
|
||||
async def logout(request: Request) -> Response:
|
||||
"""
|
||||
Выход из системы с удалением сессии и cookie.
|
||||
|
||||
Поддерживает получение токена из:
|
||||
1. HTTP-only cookie
|
||||
2. Заголовка Authorization
|
||||
"""
|
||||
token = await extract_token_from_request(request)
|
||||
|
||||
# Если токен найден, отзываем его
|
||||
if token:
|
||||
try:
|
||||
# Декодируем токен для получения user_id
|
||||
user_id, _, _ = await verify_internal_auth(token)
|
||||
if user_id:
|
||||
# Отзываем сессию
|
||||
await TokenStorage.revoke_session(token)
|
||||
logger.info(f"[auth] logout: Токен успешно отозван для пользователя {user_id}")
|
||||
else:
|
||||
logger.warning("[auth] logout: Не удалось получить user_id из токена")
|
||||
except Exception as e:
|
||||
logger.error(f"[auth] logout: Ошибка при отзыве токена: {e}")
|
||||
else:
|
||||
logger.warning("[auth] logout: Токен не найден в запросе")
|
||||
|
||||
# Создаем ответ с редиректом на страницу входа
|
||||
response = RedirectResponse(url="/")
|
||||
|
||||
# Удаляем cookie с токеном
|
||||
response.delete_cookie(
|
||||
key=SESSION_COOKIE_NAME,
|
||||
secure=SESSION_COOKIE_SECURE,
|
||||
httponly=SESSION_COOKIE_HTTPONLY,
|
||||
samesite=SESSION_COOKIE_SAMESITE if SESSION_COOKIE_SAMESITE in ["strict", "lax", "none"] else "none",
|
||||
)
|
||||
logger.info("[auth] logout: Cookie успешно удалена")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
async def refresh_token(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Обновление токена аутентификации.
|
||||
|
||||
Поддерживает получение токена из:
|
||||
1. HTTP-only cookie
|
||||
2. Заголовка Authorization
|
||||
|
||||
Возвращает новый токен как в HTTP-only cookie, так и в теле ответа.
|
||||
"""
|
||||
token = await extract_token_from_request(request)
|
||||
|
||||
if not token:
|
||||
logger.warning("[auth] refresh_token: Токен не найден в запросе")
|
||||
return JSONResponse({"success": False, "error": "Токен не найден"}, status_code=401)
|
||||
|
||||
try:
|
||||
# Получаем информацию о пользователе из токена
|
||||
user_id, _, _ = await verify_internal_auth(token)
|
||||
if not user_id:
|
||||
logger.warning("[auth] refresh_token: Недействительный токен")
|
||||
return JSONResponse({"success": False, "error": "Недействительный токен"}, status_code=401)
|
||||
|
||||
# Получаем пользователя из базы данных
|
||||
with local_session() as session:
|
||||
author = session.query(Author).where(Author.id == user_id).first()
|
||||
|
||||
if not author:
|
||||
logger.warning(f"[auth] refresh_token: Пользователь с ID {user_id} не найден")
|
||||
return JSONResponse({"success": False, "error": "Пользователь не найден"}, status_code=404)
|
||||
|
||||
# Обновляем сессию (создаем новую и отзываем старую)
|
||||
device_info = {
|
||||
"ip": request.client.host if request.client else "unknown",
|
||||
"user_agent": request.headers.get("user-agent"),
|
||||
}
|
||||
new_token = await TokenStorage.refresh_session(user_id, token, device_info)
|
||||
|
||||
if not new_token:
|
||||
logger.error(f"[auth] refresh_token: Не удалось обновить токен для пользователя {user_id}")
|
||||
return JSONResponse({"success": False, "error": "Не удалось обновить токен"}, status_code=500)
|
||||
|
||||
source = "cookie" if token.startswith("Bearer ") else "header"
|
||||
|
||||
# Создаем ответ
|
||||
response = JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
# Возвращаем токен в теле ответа только если он был получен из заголовка
|
||||
"token": new_token if source == "header" else None,
|
||||
"author": {"id": author.id, "email": author.email, "name": author.name},
|
||||
}
|
||||
)
|
||||
|
||||
# Всегда устанавливаем cookie с новым токеном
|
||||
response.set_cookie(
|
||||
key=SESSION_COOKIE_NAME,
|
||||
value=new_token,
|
||||
httponly=SESSION_COOKIE_HTTPONLY,
|
||||
secure=SESSION_COOKIE_SECURE,
|
||||
samesite=SESSION_COOKIE_SAMESITE if SESSION_COOKIE_SAMESITE in ["strict", "lax", "none"] else "none",
|
||||
max_age=SESSION_COOKIE_MAX_AGE,
|
||||
)
|
||||
|
||||
logger.info(f"[auth] refresh_token: Токен успешно обновлен для пользователя {user_id}")
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[auth] refresh_token: Ошибка при обновлении токена: {e}")
|
||||
return JSONResponse({"success": False, "error": str(e)}, status_code=401)
|
||||
96
auth/authenticate.py
Normal file
96
auth/authenticate.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from functools import wraps
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from graphql.type import GraphQLResolveInfo
|
||||
from sqlalchemy.orm import exc, joinedload
|
||||
from starlette.authentication import AuthenticationBackend
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
from auth.credentials import AuthCredentials, AuthUser
|
||||
from auth.exceptions import OperationNotAllowed
|
||||
from auth.tokenstorage import SessionToken
|
||||
from auth.usermodel import Role, User
|
||||
from services.db import local_session
|
||||
from settings import SESSION_TOKEN_HEADER
|
||||
|
||||
|
||||
class JWTAuthenticate(AuthenticationBackend):
|
||||
async def authenticate(self, request: HTTPConnection) -> Optional[Tuple[AuthCredentials, AuthUser]]:
|
||||
if SESSION_TOKEN_HEADER not in request.headers:
|
||||
return AuthCredentials(scopes={}), AuthUser(user_id=None, username="")
|
||||
|
||||
token = request.headers.get(SESSION_TOKEN_HEADER)
|
||||
if not token:
|
||||
print("[auth.authenticate] no token in header %s" % SESSION_TOKEN_HEADER)
|
||||
return AuthCredentials(scopes={}, error_message=str("no token")), AuthUser(user_id=None, username="")
|
||||
|
||||
if len(token.split(".")) > 1:
|
||||
payload = await SessionToken.verify(token)
|
||||
|
||||
with local_session() as session:
|
||||
try:
|
||||
user = (
|
||||
session.query(User)
|
||||
.options(
|
||||
joinedload(User.roles).options(joinedload(Role.permissions)),
|
||||
joinedload(User.ratings),
|
||||
)
|
||||
.filter(User.id == payload.user_id)
|
||||
.one()
|
||||
)
|
||||
|
||||
scopes = {} # TODO: integrate await user.get_permission()
|
||||
|
||||
return (
|
||||
AuthCredentials(user_id=payload.user_id, scopes=scopes, logged_in=True),
|
||||
AuthUser(user_id=user.id, username=""),
|
||||
)
|
||||
except exc.NoResultFound:
|
||||
pass
|
||||
|
||||
return AuthCredentials(scopes={}, error_message=str("Invalid token")), AuthUser(user_id=None, username="")
|
||||
|
||||
|
||||
def login_required(func):
|
||||
@wraps(func)
|
||||
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
|
||||
auth: AuthCredentials = info.context["request"].auth
|
||||
if not auth or not auth.logged_in:
|
||||
return {"error": "Please login first"}
|
||||
return await func(parent, info, *args, **kwargs)
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
def permission_required(resource, operation, func):
|
||||
@wraps(func)
|
||||
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
|
||||
print("[auth.authenticate] permission_required for %r with info %r" % (func, info)) # debug only
|
||||
auth: AuthCredentials = info.context["request"].auth
|
||||
if not auth.logged_in:
|
||||
raise OperationNotAllowed(auth.error_message or "Please login")
|
||||
|
||||
# TODO: add actual check permission logix here
|
||||
|
||||
return await func(parent, info, *args, **kwargs)
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
def login_accepted(func):
|
||||
@wraps(func)
|
||||
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
|
||||
auth: AuthCredentials = info.context["request"].auth
|
||||
|
||||
# Если есть авторизация, добавляем данные автора в контекст
|
||||
if auth and auth.logged_in:
|
||||
info.context["author"] = auth.author
|
||||
info.context["user_id"] = auth.author.get("id")
|
||||
else:
|
||||
# Очищаем данные автора из контекста если авторизация отсутствует
|
||||
info.context["author"] = None
|
||||
info.context["user_id"] = None
|
||||
|
||||
return await func(parent, info, *args, **kwargs)
|
||||
|
||||
return wrap
|
||||
150
auth/core.py
150
auth/core.py
@@ -1,150 +0,0 @@
|
||||
"""
|
||||
Базовые функции аутентификации и верификации
|
||||
Этот модуль содержит основные функции без циклических зависимостей
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
|
||||
from auth.state import AuthState
|
||||
from auth.tokens.storage import TokenStorage as TokenManager
|
||||
from orm.author import Author
|
||||
from orm.community import CommunityAuthor
|
||||
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
|
||||
from storage.db import local_session
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
|
||||
|
||||
|
||||
async def verify_internal_auth(token: str) -> tuple[int, list, bool]:
|
||||
"""
|
||||
Проверяет локальную авторизацию.
|
||||
Возвращает user_id, список ролей и флаг администратора.
|
||||
|
||||
Args:
|
||||
token: Токен авторизации (может быть как с Bearer, так и без)
|
||||
|
||||
Returns:
|
||||
tuple: (user_id, roles, is_admin)
|
||||
"""
|
||||
logger.debug(f"[verify_internal_auth] Проверка токена: {token[:10]}...")
|
||||
|
||||
# Обработка формата "Bearer <token>" (если токен не был обработан ранее)
|
||||
if token and token.startswith("Bearer "):
|
||||
token = token.replace("Bearer ", "", 1).strip()
|
||||
|
||||
# Проверяем сессию
|
||||
payload = await TokenManager.verify_session(token)
|
||||
if not payload:
|
||||
logger.warning("[verify_internal_auth] Недействительный токен: payload не получен")
|
||||
return 0, [], False
|
||||
|
||||
# payload может быть словарем или объектом, обрабатываем оба случая
|
||||
user_id = payload.user_id if hasattr(payload, "user_id") else payload.get("user_id")
|
||||
if not user_id:
|
||||
logger.warning("[verify_internal_auth] user_id не найден в payload")
|
||||
return 0, [], False
|
||||
|
||||
logger.debug(f"[verify_internal_auth] Токен действителен, user_id={user_id}")
|
||||
|
||||
with local_session() as session:
|
||||
try:
|
||||
# Author уже импортирован в начале файла
|
||||
|
||||
author = session.query(Author).where(Author.id == user_id).one()
|
||||
|
||||
# Получаем роли
|
||||
ca = session.query(CommunityAuthor).filter_by(author_id=author.id, community_id=1).first()
|
||||
roles = ca.role_list if ca else []
|
||||
logger.debug(f"[verify_internal_auth] Роли пользователя: {roles}")
|
||||
|
||||
# Определяем, является ли пользователь администратором
|
||||
is_admin = any(role in ["admin", "super"] for role in roles) or author.email in ADMIN_EMAILS
|
||||
logger.debug(
|
||||
f"[verify_internal_auth] Пользователь {author.id} {'является' if is_admin else 'не является'} администратором"
|
||||
)
|
||||
|
||||
return int(author.id), roles, is_admin
|
||||
except NoResultFound:
|
||||
logger.warning(f"[verify_internal_auth] Пользователь с ID {user_id} не найден в БД или не активен")
|
||||
return 0, [], False
|
||||
|
||||
|
||||
async def create_internal_session(author, device_info: dict | None = None) -> str:
|
||||
"""
|
||||
Создает новую сессию для автора
|
||||
|
||||
Args:
|
||||
author: Объект автора
|
||||
device_info: Информация об устройстве (опционально)
|
||||
|
||||
Returns:
|
||||
str: Токен сессии
|
||||
"""
|
||||
# Сбрасываем счетчик неудачных попыток
|
||||
author.reset_failed_login()
|
||||
|
||||
# Обновляем last_seen
|
||||
author.last_seen = int(time.time())
|
||||
|
||||
# Создаем сессию, используя token для идентификации
|
||||
return await TokenManager.create_session(
|
||||
user_id=str(author.id),
|
||||
username=str(author.slug or author.email or author.phone or ""),
|
||||
device_info=device_info,
|
||||
)
|
||||
|
||||
|
||||
async def get_auth_token_from_request(request) -> str | None:
|
||||
"""
|
||||
Извлекает токен авторизации из запроса.
|
||||
Порядок проверки:
|
||||
1. Проверяет auth из middleware
|
||||
2. Проверяет auth из scope
|
||||
3. Проверяет заголовок Authorization
|
||||
4. Проверяет cookie с именем auth_token
|
||||
|
||||
Args:
|
||||
request: Объект запроса
|
||||
|
||||
Returns:
|
||||
Optional[str]: Токен авторизации или None
|
||||
"""
|
||||
# Отложенный импорт для избежания циклических зависимостей
|
||||
from auth.decorators import get_auth_token
|
||||
|
||||
return await get_auth_token(request)
|
||||
|
||||
|
||||
async def authenticate(request) -> AuthState:
|
||||
"""
|
||||
Получает токен из запроса и проверяет авторизацию.
|
||||
|
||||
Args:
|
||||
request: Объект запроса
|
||||
|
||||
Returns:
|
||||
AuthState: Состояние аутентификации
|
||||
"""
|
||||
logger.debug("[authenticate] Начало аутентификации")
|
||||
|
||||
# Получаем токен из запроса используя безопасный метод
|
||||
token = await get_auth_token_from_request(request)
|
||||
if not token:
|
||||
logger.info("[authenticate] Токен не найден в запросе")
|
||||
return AuthState()
|
||||
|
||||
# Проверяем токен используя internal auth
|
||||
user_id, roles, is_admin = await verify_internal_auth(token)
|
||||
if not user_id:
|
||||
logger.warning("[authenticate] Недействительный токен")
|
||||
return AuthState()
|
||||
|
||||
logger.debug(f"[authenticate] Аутентификация успешна: user_id={user_id}, roles={roles}, is_admin={is_admin}")
|
||||
auth_state = AuthState()
|
||||
auth_state.logged_in = True
|
||||
auth_state.author_id = str(user_id)
|
||||
auth_state.is_admin = is_admin
|
||||
return auth_state
|
||||
@@ -1,95 +1,43 @@
|
||||
from typing import Any
|
||||
from typing import List, Optional, Text
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
|
||||
# from base.exceptions import UnauthorizedError
|
||||
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
|
||||
|
||||
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
|
||||
# from base.exceptions import Unauthorized
|
||||
|
||||
|
||||
class Permission(BaseModel):
|
||||
"""Модель разрешения для RBAC"""
|
||||
|
||||
resource: str
|
||||
operation: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.resource}:{self.operation}"
|
||||
name: Text
|
||||
|
||||
|
||||
class AuthCredentials(BaseModel):
|
||||
"""
|
||||
Модель учетных данных авторизации.
|
||||
Используется как часть механизма аутентификации Starlette.
|
||||
"""
|
||||
|
||||
author_id: int | None = Field(None, description="ID автора")
|
||||
scopes: dict[str, set[str]] = Field(default_factory=dict, description="Разрешения пользователя")
|
||||
logged_in: bool = Field(default=False, description="Флаг, указывающий, авторизован ли пользователь")
|
||||
error_message: str = Field("", description="Сообщение об ошибке аутентификации")
|
||||
email: str | None = Field(None, description="Email пользователя")
|
||||
token: str | None = Field(None, description="JWT токен авторизации")
|
||||
|
||||
def get_permissions(self) -> list[str]:
|
||||
"""
|
||||
Возвращает список строковых представлений разрешений.
|
||||
Например: ["posts:read", "posts:write", "comments:create"].
|
||||
|
||||
Returns:
|
||||
List[str]: Список разрешений
|
||||
"""
|
||||
result = []
|
||||
for resource, operations in self.scopes.items():
|
||||
for operation in operations:
|
||||
result.extend([f"{resource}:{operation}"])
|
||||
return result
|
||||
|
||||
def has_permission(self, resource: str, operation: str) -> bool:
|
||||
"""
|
||||
Проверяет наличие определенного разрешения.
|
||||
|
||||
Args:
|
||||
resource: Ресурс (например, "posts")
|
||||
operation: Операция (например, "read")
|
||||
|
||||
Returns:
|
||||
bool: True, если пользователь имеет указанное разрешение
|
||||
"""
|
||||
if not self.logged_in:
|
||||
return False
|
||||
|
||||
return resource in self.scopes and operation in self.scopes[resource]
|
||||
user_id: Optional[int] = None
|
||||
scopes: Optional[dict] = {}
|
||||
logged_in: bool = False
|
||||
error_message: str = ""
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
"""
|
||||
Проверяет, является ли пользователь администратором.
|
||||
def is_admin(self):
|
||||
# TODO: check admin logix
|
||||
return True
|
||||
|
||||
Returns:
|
||||
bool: True, если email пользователя находится в списке ADMIN_EMAILS
|
||||
"""
|
||||
return self.email in ADMIN_EMAILS if self.email else False
|
||||
async def permissions(self) -> List[Permission]:
|
||||
if self.user_id is None:
|
||||
# raise Unauthorized("Please login first")
|
||||
return {"error": "Please login first"}
|
||||
else:
|
||||
# TODO: implement permissions logix
|
||||
print(self.user_id)
|
||||
return NotImplemented
|
||||
|
||||
async def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Преобразует учетные данные в словарь
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Словарь с данными учетных данных
|
||||
"""
|
||||
permissions = self.get_permissions()
|
||||
return {
|
||||
"author_id": self.author_id,
|
||||
"logged_in": self.logged_in,
|
||||
"is_admin": self.is_admin,
|
||||
"permissions": list(permissions),
|
||||
}
|
||||
class AuthUser(BaseModel):
|
||||
user_id: Optional[int]
|
||||
username: Optional[str]
|
||||
|
||||
async def permissions(self) -> list[Permission]:
|
||||
if self.author_id is None:
|
||||
# raise UnauthorizedError("Please login first")
|
||||
return [] # Возвращаем пустой список вместо dict
|
||||
# TODO: implement permissions logix
|
||||
print(self.author_id)
|
||||
return [] # Возвращаем пустой список вместо NotImplemented
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
return self.user_id is not None
|
||||
|
||||
# @property
|
||||
# def display_id(self) -> int:
|
||||
# return self.user_id
|
||||
|
||||
@@ -1,424 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
|
||||
from graphql import GraphQLError, GraphQLResolveInfo
|
||||
from sqlalchemy import exc
|
||||
|
||||
# Импорт базовых функций из реструктурированных модулей
|
||||
from auth.core import authenticate
|
||||
from auth.credentials import AuthCredentials
|
||||
from auth.exceptions import OperationNotAllowedError
|
||||
from auth.utils import get_auth_token, get_safe_headers
|
||||
from orm.author import Author
|
||||
from orm.community import CommunityAuthor
|
||||
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
|
||||
from storage.db import local_session
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
|
||||
|
||||
|
||||
async def validate_graphql_context(info: GraphQLResolveInfo) -> None:
|
||||
"""
|
||||
Проверяет валидность GraphQL контекста и проверяет авторизацию.
|
||||
|
||||
Args:
|
||||
info: GraphQL информация о контексте
|
||||
|
||||
Raises:
|
||||
GraphQLError: если контекст невалиден или пользователь не авторизован
|
||||
"""
|
||||
# Подробное логирование для диагностики
|
||||
logger.debug("[validate_graphql_context] Начало проверки контекста и авторизации")
|
||||
|
||||
# Проверка базовой структуры контекста
|
||||
if info is None or not hasattr(info, "context"):
|
||||
logger.warning("[validate_graphql_context] Missing GraphQL context information")
|
||||
msg = "Internal server error: missing context"
|
||||
raise GraphQLError(msg)
|
||||
|
||||
request = info.context.get("request")
|
||||
if not request:
|
||||
logger.error("[validate_graphql_context] Missing request in context")
|
||||
msg = "Internal server error: missing request"
|
||||
raise GraphQLError(msg)
|
||||
|
||||
# Логируем детали запроса
|
||||
client_info = {
|
||||
"ip": getattr(request.client, "host", "unknown") if hasattr(request, "client") else "unknown",
|
||||
"headers_keys": list(get_safe_headers(request).keys()),
|
||||
}
|
||||
logger.debug(f"[validate_graphql_context] Детали запроса: {client_info}")
|
||||
|
||||
# Проверяем auth из контекста - если уже авторизован, просто возвращаем
|
||||
auth = getattr(request, "auth", None)
|
||||
if auth and getattr(auth, "logged_in", False):
|
||||
logger.debug(f"[validate_graphql_context] Пользователь уже авторизован через request.auth: {auth.author_id}")
|
||||
return
|
||||
|
||||
# Если аутентификации нет в request.auth, пробуем получить ее из scope
|
||||
token: str | None = None
|
||||
if hasattr(request, "scope") and "auth" in request.scope:
|
||||
auth_cred = request.scope.get("auth")
|
||||
if isinstance(auth_cred, AuthCredentials) and getattr(auth_cred, "logged_in", False):
|
||||
logger.debug(f"[validate_graphql_context] Пользователь авторизован через scope: {auth_cred.author_id}")
|
||||
return
|
||||
|
||||
# Если авторизации нет ни в auth, ни в scope, пробуем получить и проверить токен
|
||||
token = await get_auth_token(request)
|
||||
if not token:
|
||||
# Если токен не найден, логируем как предупреждение, но не бросаем GraphQLError
|
||||
client_info = {
|
||||
"ip": getattr(request.client, "host", "unknown") if hasattr(request, "client") else "unknown",
|
||||
"headers": {k: v for k, v in get_safe_headers(request).items() if k not in ["authorization", "cookie"]},
|
||||
}
|
||||
logger.info(f"[validate_graphql_context] Токен авторизации не найден: {client_info}")
|
||||
|
||||
# Устанавливаем пустые учетные данные вместо выброса исключения
|
||||
if hasattr(request, "scope") and isinstance(request.scope, dict):
|
||||
request.scope["auth"] = AuthCredentials(
|
||||
author_id=None,
|
||||
scopes={},
|
||||
logged_in=False,
|
||||
error_message="No authentication token",
|
||||
email=None,
|
||||
token=None,
|
||||
)
|
||||
return
|
||||
|
||||
# Логируем информацию о найденном токене
|
||||
token_len = len(token) if hasattr(token, "__len__") else 0
|
||||
logger.debug(f"[validate_graphql_context] Токен найден, длина: {token_len}")
|
||||
|
||||
# Используем единый механизм проверки токена из auth.internal
|
||||
auth_state = await authenticate(request)
|
||||
logger.debug(
|
||||
f"[validate_graphql_context] Результат аутентификации: logged_in={auth_state.logged_in}, author_id={auth_state.author_id}, error={auth_state.error}"
|
||||
)
|
||||
|
||||
if not auth_state.logged_in:
|
||||
error_msg = auth_state.error or "Invalid or expired token"
|
||||
logger.warning(f"[validate_graphql_context] Недействительный токен: {error_msg}")
|
||||
msg = f"UnauthorizedError - {error_msg}"
|
||||
raise GraphQLError(msg)
|
||||
|
||||
# Если все проверки пройдены, создаем AuthCredentials и устанавливаем в request.scope
|
||||
with local_session() as session:
|
||||
try:
|
||||
author = session.query(Author).where(Author.id == auth_state.author_id).one()
|
||||
logger.debug(f"[validate_graphql_context] Найден автор: id={author.id}, email={author.email}")
|
||||
|
||||
# Создаем объект авторизации с пустыми разрешениями
|
||||
# Разрешения будут проверяться через RBAC систему по требованию
|
||||
auth_cred = AuthCredentials(
|
||||
author_id=author.id,
|
||||
scopes={}, # Пустой словарь разрешений
|
||||
logged_in=True,
|
||||
error_message="",
|
||||
email=author.email,
|
||||
token=auth_state.token,
|
||||
)
|
||||
|
||||
# Устанавливаем auth в request.scope вместо прямого присваивания к request.auth
|
||||
if hasattr(request, "scope") and isinstance(request.scope, dict):
|
||||
request.scope["auth"] = auth_cred
|
||||
logger.debug(
|
||||
f"[validate_graphql_context] Токен успешно проверен и установлен для пользователя {auth_state.author_id}"
|
||||
)
|
||||
else:
|
||||
logger.warning("[validate_graphql_context] Не удалось установить auth: отсутствует request.scope")
|
||||
msg = "Internal server error: unable to set authentication context"
|
||||
raise GraphQLError(msg)
|
||||
except exc.NoResultFound:
|
||||
logger.warning(
|
||||
f"[validate_graphql_context] Пользователь с ID {auth_state.author_id} не найден в базе данных"
|
||||
)
|
||||
msg = "UnauthorizedError - user not found"
|
||||
raise GraphQLError(msg) from None
|
||||
|
||||
return
|
||||
|
||||
|
||||
def admin_auth_required(resolver: Callable) -> Callable:
|
||||
"""
|
||||
Декоратор для защиты админских эндпоинтов.
|
||||
Проверяет принадлежность к списку разрешенных email-адресов.
|
||||
|
||||
Args:
|
||||
resolver: GraphQL резолвер для защиты
|
||||
|
||||
Returns:
|
||||
Обернутый резолвер, который проверяет права доступа администратора
|
||||
|
||||
Raises:
|
||||
GraphQLError: если пользователь не авторизован или не имеет доступа администратора
|
||||
|
||||
Example:
|
||||
>>> @admin_auth_required
|
||||
... async def admin_resolver(root, info, **kwargs):
|
||||
... return "Admin data"
|
||||
"""
|
||||
|
||||
@wraps(resolver)
|
||||
async def wrapper(root: Any = None, info: GraphQLResolveInfo | None = None, **kwargs: dict[str, Any]) -> Any:
|
||||
# Подробное логирование для диагностики
|
||||
logger.debug(f"[admin_auth_required] Начало проверки авторизации для {resolver.__name__}")
|
||||
|
||||
# Проверяем авторизацию пользователя
|
||||
if info is None:
|
||||
logger.warning("[admin_auth_required] GraphQL info is None")
|
||||
msg = "Invalid GraphQL context"
|
||||
raise GraphQLError(msg)
|
||||
|
||||
# Логируем детали запроса
|
||||
request = info.context.get("request")
|
||||
client_info = {
|
||||
"ip": getattr(request.client, "host", "unknown") if hasattr(request, "client") else "unknown",
|
||||
"headers": {k: v for k, v in get_safe_headers(request).items() if k not in ["authorization", "cookie"]},
|
||||
}
|
||||
logger.debug(f"[admin_auth_required] Детали запроса: {client_info}")
|
||||
|
||||
# Проверяем наличие токена до validate_graphql_context
|
||||
token = await get_auth_token(request)
|
||||
logger.debug(f"[admin_auth_required] Токен найден: {bool(token)}, длина: {len(token) if token else 0}")
|
||||
|
||||
try:
|
||||
# Проверяем авторизацию - НЕ ловим GraphQLError здесь!
|
||||
await validate_graphql_context(info)
|
||||
logger.debug("[admin_auth_required] validate_graphql_context успешно пройден")
|
||||
except GraphQLError:
|
||||
# Пробрасываем GraphQLError дальше - это ошибки авторизации
|
||||
logger.debug("[admin_auth_required] GraphQLError от validate_graphql_context - пробрасываем дальше")
|
||||
raise
|
||||
|
||||
# Получаем объект авторизации
|
||||
auth = None
|
||||
if hasattr(info.context["request"], "scope") and "auth" in info.context["request"].scope:
|
||||
auth = info.context["request"].scope.get("auth")
|
||||
logger.debug(f"[admin_auth_required] Auth из scope: {auth.author_id if auth else None}")
|
||||
elif hasattr(info.context["request"], "auth"):
|
||||
auth = info.context["request"].auth
|
||||
logger.debug(f"[admin_auth_required] Auth из request: {auth.author_id if auth else None}")
|
||||
else:
|
||||
logger.warning("[admin_auth_required] Auth не найден ни в scope, ни в request")
|
||||
|
||||
if not auth or not getattr(auth, "logged_in", False):
|
||||
logger.warning("[admin_auth_required] Пользователь не авторизован после validate_graphql_context")
|
||||
msg = "UnauthorizedError - please login"
|
||||
raise GraphQLError(msg)
|
||||
|
||||
# Проверяем, является ли пользователь администратором
|
||||
try:
|
||||
with local_session() as session:
|
||||
# Преобразуем author_id в int для совместимости с базой данных
|
||||
author_id = int(auth.author_id) if auth and auth.author_id else None
|
||||
if not author_id:
|
||||
logger.warning(f"[admin_auth_required] ID автора не определен: {auth}")
|
||||
msg = "UnauthorizedError - invalid user ID"
|
||||
raise GraphQLError(msg)
|
||||
|
||||
author = session.query(Author).where(Author.id == author_id).one()
|
||||
logger.debug(f"[admin_auth_required] Найден автор: {author.id}, {author.email}")
|
||||
|
||||
# Проверяем, является ли пользователь системным администратором
|
||||
if author.email and author.email in ADMIN_EMAILS:
|
||||
logger.info(f"System admin access granted for {author.email} (ID: {author.id})")
|
||||
return await resolver(root, info, **kwargs)
|
||||
|
||||
# Системный администратор определяется ТОЛЬКО по ADMIN_EMAILS
|
||||
logger.warning(f"System admin access denied for {author.email} (ID: {author.id}). Not in ADMIN_EMAILS.")
|
||||
msg = "UnauthorizedError - system admin access required"
|
||||
raise GraphQLError(msg)
|
||||
|
||||
except exc.NoResultFound:
|
||||
logger.warning(f"[admin_auth_required] Пользователь с ID {auth.author_id} не найден в базе данных")
|
||||
msg = "UnauthorizedError - user not found"
|
||||
raise GraphQLError(msg) from None
|
||||
except GraphQLError:
|
||||
# Пробрасываем GraphQLError дальше
|
||||
raise
|
||||
except Exception as e:
|
||||
# Ловим только неожиданные ошибки, не GraphQLError
|
||||
error_msg = f"Admin access error: {e!s}"
|
||||
logger.error(f"[admin_auth_required] Неожиданная ошибка: {error_msg}")
|
||||
raise GraphQLError(error_msg) from e
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def permission_required(resource: str, operation: str, func: Callable) -> Callable:
|
||||
"""
|
||||
Декоратор для проверки разрешений.
|
||||
|
||||
Args:
|
||||
resource: Ресурс для проверки
|
||||
operation: Операция для проверки
|
||||
func: Декорируемая функция
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
async def wrap(parent: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any) -> Any:
|
||||
# Сначала проверяем авторизацию
|
||||
await validate_graphql_context(info)
|
||||
|
||||
# Получаем объект авторизации
|
||||
logger.debug(f"[permission_required] Контекст: {info.context}")
|
||||
auth = None
|
||||
if hasattr(info.context["request"], "scope") and "auth" in info.context["request"].scope:
|
||||
auth = info.context["request"].scope.get("auth")
|
||||
if not auth or not getattr(auth, "logged_in", False):
|
||||
logger.error("[permission_required] Пользователь не авторизован после validate_graphql_context")
|
||||
msg = "Требуются права доступа"
|
||||
raise OperationNotAllowedError(msg)
|
||||
|
||||
# Проверяем разрешения
|
||||
with local_session() as session:
|
||||
try:
|
||||
author = session.query(Author).where(Author.id == auth.author_id).one()
|
||||
|
||||
# Проверяем базовые условия
|
||||
if author.is_locked():
|
||||
msg = "Account is locked"
|
||||
raise OperationNotAllowedError(msg)
|
||||
|
||||
# Проверяем, является ли пользователь администратором (у них есть все разрешения)
|
||||
if author.email in ADMIN_EMAILS:
|
||||
logger.debug(f"[permission_required] Администратор {author.email} имеет все разрешения")
|
||||
return await func(parent, info, *args, **kwargs)
|
||||
|
||||
# Проверяем роли пользователя
|
||||
admin_roles = ["admin", "super"]
|
||||
ca = session.query(CommunityAuthor).filter_by(author_id=author.id, community_id=1).first()
|
||||
user_roles = ca.role_list if ca else []
|
||||
|
||||
if any(role in admin_roles for role in user_roles):
|
||||
logger.debug(
|
||||
f"[permission_required] Пользователь с ролью администратора {author.email} имеет все разрешения"
|
||||
)
|
||||
return await func(parent, info, *args, **kwargs)
|
||||
|
||||
# Проверяем разрешение
|
||||
ca = session.query(CommunityAuthor).filter_by(author_id=author.id, community_id=1).first()
|
||||
if ca:
|
||||
user_roles = ca.role_list
|
||||
if any(role in admin_roles for role in user_roles):
|
||||
logger.debug(
|
||||
f"[permission_required] Пользователь с ролью администратора {author.email} имеет все разрешения"
|
||||
)
|
||||
return await func(parent, info, *args, **kwargs)
|
||||
if not ca or not ca.has_permission(f"{resource}:{operation}"):
|
||||
logger.warning(
|
||||
f"[permission_required] У пользователя {author.email} нет разрешения {operation} на {resource}"
|
||||
)
|
||||
msg = f"No permission for {operation} on {resource}"
|
||||
raise OperationNotAllowedError(msg)
|
||||
|
||||
logger.debug(
|
||||
f"[permission_required] Пользователь {author.email} имеет разрешение {operation} на {resource}"
|
||||
)
|
||||
return await func(parent, info, *args, **kwargs)
|
||||
except exc.NoResultFound:
|
||||
logger.warning(f"[permission_required] Пользователь с ID {auth.author_id} не найден в базе данных")
|
||||
msg = "User not found"
|
||||
raise OperationNotAllowedError(msg) from None
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
def login_accepted(func: Callable) -> Callable:
|
||||
"""
|
||||
Декоратор для проверки аутентификации пользователя.
|
||||
|
||||
Args:
|
||||
func: функция-резолвер для декорирования
|
||||
|
||||
Returns:
|
||||
Callable: обернутая функция
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
async def wrap(parent: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
await validate_graphql_context(info)
|
||||
return await func(parent, info, *args, **kwargs)
|
||||
except GraphQLError:
|
||||
# Пробрасываем ошибки авторизации далее
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[decorators] Unexpected error in login_accepted: {e}")
|
||||
msg = "Internal server error"
|
||||
raise GraphQLError(msg) from e
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
def editor_or_admin_required(func: Callable) -> Callable:
|
||||
"""
|
||||
Декоратор для проверки, что пользователь имеет роль 'editor' или 'admin'.
|
||||
|
||||
Args:
|
||||
func: функция-резолвер для декорирования
|
||||
|
||||
Returns:
|
||||
Callable: обернутая функция
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
async def wrap(parent: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
# Сначала проверяем авторизацию
|
||||
await validate_graphql_context(info)
|
||||
|
||||
# Получаем информацию о пользователе
|
||||
request = info.context.get("request")
|
||||
author_id = None
|
||||
|
||||
# Пробуем получить author_id из разных источников
|
||||
if hasattr(request, "auth") and request.auth and hasattr(request.auth, "author_id"):
|
||||
author_id = request.auth.author_id
|
||||
elif hasattr(request, "scope") and "auth" in request.scope:
|
||||
auth_info = request.scope.get("auth", {})
|
||||
if isinstance(auth_info, dict):
|
||||
author_id = auth_info.get("author_id")
|
||||
elif hasattr(auth_info, "author_id"):
|
||||
author_id = auth_info.author_id
|
||||
|
||||
if not author_id:
|
||||
logger.warning("[decorators] Не удалось получить author_id для проверки ролей")
|
||||
raise GraphQLError("Ошибка авторизации: не удалось определить пользователя")
|
||||
|
||||
# Проверяем роли пользователя
|
||||
with local_session() as session:
|
||||
author = session.query(Author).where(Author.id == author_id).first()
|
||||
if not author:
|
||||
logger.warning(f"[decorators] Автор с ID {author_id} не найден")
|
||||
raise GraphQLError("Пользователь не найден")
|
||||
|
||||
# Проверяем email админа
|
||||
if author.email in ADMIN_EMAILS:
|
||||
logger.debug(f"[decorators] Пользователь {author.email} является админом по email")
|
||||
return await func(parent, info, *args, **kwargs)
|
||||
|
||||
# Получаем список ролей пользователя
|
||||
ca = session.query(CommunityAuthor).filter_by(author_id=author.id, community_id=1).first()
|
||||
user_roles = ca.role_list if ca else []
|
||||
logger.debug(f"[decorators] Роли пользователя {author_id}: {user_roles}")
|
||||
|
||||
# Проверяем наличие роли admin или editor
|
||||
if "admin" in user_roles or "editor" in user_roles:
|
||||
logger.debug(f"[decorators] Пользователь {author_id} имеет разрешение (роли: {user_roles})")
|
||||
return await func(parent, info, *args, **kwargs)
|
||||
|
||||
# Если нет нужных ролей
|
||||
logger.warning(f"[decorators] Пользователю {author_id} отказано в доступе. Роли: {user_roles}")
|
||||
raise GraphQLError("Доступ запрещен. Требуется роль редактора или администратора.")
|
||||
|
||||
except GraphQLError:
|
||||
# Пробрасываем ошибки авторизации далее
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[decorators] Неожиданная ошибка в editor_or_admin_required: {e}")
|
||||
raise GraphQLError("Внутренняя ошибка сервера") from e
|
||||
|
||||
return wrap
|
||||
@@ -1,5 +1,3 @@
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from settings import MAILGUN_API_KEY, MAILGUN_DOMAIN
|
||||
@@ -9,9 +7,9 @@ noreply = "discours.io <noreply@%s>" % (MAILGUN_DOMAIN or "discours.io")
|
||||
lang_subject = {"ru": "Подтверждение почты", "en": "Confirm email"}
|
||||
|
||||
|
||||
async def send_auth_email(user: Any, token: str, lang: str = "ru", template: str = "email_confirmation") -> None:
|
||||
async def send_auth_email(user, token, lang="ru", template="email_confirmation"):
|
||||
try:
|
||||
to = f"{user.name} <{user.email}>"
|
||||
to = "%s <%s>" % (user.name, user.email)
|
||||
if lang not in ["ru", "en"]:
|
||||
lang = "ru"
|
||||
subject = lang_subject.get(lang, lang_subject["en"])
|
||||
@@ -21,12 +19,12 @@ async def send_auth_email(user: Any, token: str, lang: str = "ru", template: str
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"template": template,
|
||||
"h:X-Mailgun-Variables": f'{{ "token": "{token}" }}',
|
||||
"h:X-Mailgun-Variables": '{ "token": "%s" }' % token,
|
||||
}
|
||||
print(f"[auth.email] payload: {payload!r}")
|
||||
print("[auth.email] payload: %r" % payload)
|
||||
# debug
|
||||
# print('http://localhost:3000/?modal=auth&mode=confirm-email&token=%s' % token)
|
||||
response = requests.post(api_url, auth=("api", MAILGUN_API_KEY), data=payload, timeout=30)
|
||||
response = requests.post(api_url, auth=("api", MAILGUN_API_KEY), data=payload)
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
@@ -3,43 +3,36 @@ from graphql.error import GraphQLError
|
||||
# TODO: remove traceback from logs for defined exceptions
|
||||
|
||||
|
||||
class BaseHttpError(GraphQLError):
|
||||
class BaseHttpException(GraphQLError):
|
||||
code = 500
|
||||
message = "500 Server error"
|
||||
|
||||
|
||||
class ExpiredTokenError(BaseHttpError):
|
||||
class ExpiredToken(BaseHttpException):
|
||||
code = 401
|
||||
message = "401 Expired Token"
|
||||
|
||||
|
||||
class InvalidTokenError(BaseHttpError):
|
||||
class InvalidToken(BaseHttpException):
|
||||
code = 401
|
||||
message = "401 Invalid Token"
|
||||
|
||||
|
||||
class UnauthorizedError(BaseHttpError):
|
||||
class Unauthorized(BaseHttpException):
|
||||
code = 401
|
||||
message = "401 UnauthorizedError"
|
||||
message = "401 Unauthorized"
|
||||
|
||||
|
||||
class ObjectNotExistError(BaseHttpError):
|
||||
class ObjectNotExist(BaseHttpException):
|
||||
code = 404
|
||||
message = "404 Object Does Not Exist"
|
||||
|
||||
|
||||
class OperationNotAllowedError(BaseHttpError):
|
||||
class OperationNotAllowed(BaseHttpException):
|
||||
code = 403
|
||||
message = "403 Operation Is Not Allowed"
|
||||
|
||||
|
||||
class InvalidPasswordError(BaseHttpError):
|
||||
class InvalidPassword(BaseHttpException):
|
||||
code = 403
|
||||
message = "403 Invalid Password"
|
||||
|
||||
|
||||
class AuthorizationError(BaseHttpError):
|
||||
"""Ошибка авторизации - не должна показывать трейсбек в логах"""
|
||||
|
||||
code = 401
|
||||
message = "401 Authorization Required"
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from ariadne.asgi.handlers import GraphQLHTTPHandler
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from auth.middleware import auth_middleware
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
|
||||
class EnhancedGraphQLHTTPHandler(GraphQLHTTPHandler):
|
||||
"""
|
||||
Улучшенный GraphQL HTTP обработчик с поддержкой cookie и авторизации.
|
||||
|
||||
Расширяет стандартный GraphQLHTTPHandler для:
|
||||
1. Создания расширенного контекста запроса с авторизационными данными
|
||||
2. Корректной обработки ответов с cookie и headers
|
||||
3. Интеграции с AuthMiddleware
|
||||
"""
|
||||
|
||||
async def get_context_for_request(self, request: Request, data: dict) -> dict:
|
||||
"""
|
||||
Расширяем контекст для GraphQL запросов.
|
||||
|
||||
Добавляет к стандартному контексту:
|
||||
- Объект response для установки cookie
|
||||
- Интеграцию с AuthMiddleware
|
||||
- Расширения для управления авторизацией
|
||||
|
||||
Args:
|
||||
request: Starlette Request объект
|
||||
data: данные запроса
|
||||
|
||||
Returns:
|
||||
dict: контекст с дополнительными данными для авторизации и cookie
|
||||
"""
|
||||
# Безопасно получаем заголовки для диагностики
|
||||
headers = {}
|
||||
if hasattr(request, "headers"):
|
||||
try:
|
||||
# Используем безопасный способ получения заголовков
|
||||
for key, value in request.headers.items():
|
||||
headers[key.lower()] = value
|
||||
except Exception as e:
|
||||
logger.debug(f"[graphql] Ошибка при получении заголовков: {e}")
|
||||
|
||||
# Получаем стандартный контекст от базового класса
|
||||
context = await super().get_context_for_request(request, data)
|
||||
|
||||
# Создаем объект ответа для установки cookie
|
||||
response = JSONResponse({})
|
||||
context["response"] = response
|
||||
|
||||
# Интегрируем с AuthMiddleware
|
||||
auth_middleware.set_context(context)
|
||||
context["extensions"] = auth_middleware
|
||||
|
||||
# Добавляем данные авторизации только если они доступны
|
||||
# Проверяем наличие данных авторизации в scope
|
||||
if hasattr(request, "scope") and isinstance(request.scope, dict) and "auth" in request.scope:
|
||||
auth_cred: Any | None = request.scope.get("auth")
|
||||
context["auth"] = auth_cred
|
||||
# Безопасно логируем информацию о типе объекта auth
|
||||
|
||||
# Добавляем author_id в контекст для RBAC
|
||||
author_id = None
|
||||
if auth_cred is not None and hasattr(auth_cred, "author_id") and auth_cred.author_id:
|
||||
author_id = auth_cred.author_id
|
||||
elif isinstance(auth_cred, dict) and "author_id" in auth_cred:
|
||||
author_id = auth_cred["author_id"]
|
||||
|
||||
if author_id:
|
||||
# Преобразуем author_id в число для совместимости с RBAC
|
||||
try:
|
||||
author_id_int = int(str(author_id).strip())
|
||||
context["author"] = {"id": author_id_int}
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.error(f"[graphql] Ошибка преобразования author_id {author_id}: {e}")
|
||||
context["author"] = {"id": author_id}
|
||||
|
||||
return context
|
||||
173
auth/identity.py
173
auth/identity.py
@@ -1,114 +1,97 @@
|
||||
from typing import Any, TypeVar
|
||||
from binascii import hexlify
|
||||
from hashlib import sha256
|
||||
|
||||
from auth.exceptions import ExpiredTokenError, InvalidPasswordError, InvalidTokenError
|
||||
from passlib.hash import bcrypt
|
||||
|
||||
from auth.exceptions import ExpiredToken, InvalidToken
|
||||
from auth.jwtcodec import JWTCodec
|
||||
from orm.author import Author
|
||||
from storage.db import local_session
|
||||
from storage.redis import redis
|
||||
from utils.logger import root_logger as logger
|
||||
from utils.password import Password
|
||||
from auth.tokenstorage import TokenStorage
|
||||
from orm.user import User
|
||||
|
||||
AuthorType = TypeVar("AuthorType", bound=Author)
|
||||
# from base.exceptions import InvalidPassword, InvalidToken
|
||||
from services.db import local_session
|
||||
|
||||
|
||||
class Password:
|
||||
@staticmethod
|
||||
def _to_bytes(data: str) -> bytes:
|
||||
return bytes(data.encode())
|
||||
|
||||
@classmethod
|
||||
def _get_sha256(cls, password: str) -> bytes:
|
||||
bytes_password = cls._to_bytes(password)
|
||||
return hexlify(sha256(bytes_password).digest())
|
||||
|
||||
@staticmethod
|
||||
def encode(password: str) -> str:
|
||||
password_sha256 = Password._get_sha256(password)
|
||||
return bcrypt.using(rounds=10).hash(password_sha256)
|
||||
|
||||
@staticmethod
|
||||
def verify(password: str, hashed: str) -> bool:
|
||||
"""
|
||||
Verify that password hash is equal to specified hash. Hash format:
|
||||
|
||||
$2a$10$Ro0CUfOqk6cXEKf3dyaM7OhSCvnwM9s4wIX9JeLapehKK5YdLxKcm
|
||||
\__/\/ \____________________/\_____________________________/ # noqa: W605
|
||||
| | Salt Hash
|
||||
| Cost
|
||||
Version
|
||||
|
||||
More info: https://passlib.readthedocs.io/en/stable/lib/passlib.hash.bcrypt.html
|
||||
|
||||
:param password: clear text password
|
||||
:param hashed: hash of the password
|
||||
:return: True if clear text password matches specified hash
|
||||
"""
|
||||
hashed_bytes = Password._to_bytes(hashed)
|
||||
password_sha256 = Password._get_sha256(password)
|
||||
|
||||
return bcrypt.verify(password_sha256, hashed_bytes)
|
||||
|
||||
|
||||
class Identity:
|
||||
@staticmethod
|
||||
def password(orm_author: AuthorType, password: str) -> AuthorType:
|
||||
"""
|
||||
Проверяет пароль пользователя
|
||||
|
||||
Args:
|
||||
orm_author (Author): Объект пользователя
|
||||
password (str): Пароль пользователя
|
||||
|
||||
Returns:
|
||||
Author: Объект автора при успешной проверке
|
||||
|
||||
Raises:
|
||||
InvalidPasswordError: Если пароль не соответствует хешу или отсутствует
|
||||
"""
|
||||
# Проверим исходный пароль в orm_author
|
||||
if not orm_author.password:
|
||||
logger.warning(f"[auth.identity] Пароль в исходном объекте автора пуст: email={orm_author.email}")
|
||||
msg = "Пароль не установлен для данного пользователя"
|
||||
raise InvalidPasswordError(msg)
|
||||
|
||||
# Проверяем пароль напрямую, не используя dict()
|
||||
password_hash = str(orm_author.password) if orm_author.password else ""
|
||||
if not password_hash or not Password.verify(password, password_hash):
|
||||
logger.warning(f"[auth.identity] Неверный пароль для {orm_author.email}")
|
||||
msg = "Неверный пароль пользователя"
|
||||
raise InvalidPasswordError(msg)
|
||||
|
||||
# Возвращаем исходный объект, чтобы сохранить все связи
|
||||
return orm_author
|
||||
def password(orm_user: User, password: str) -> User:
|
||||
user = User(**orm_user.dict())
|
||||
if not user.password:
|
||||
# raise InvalidPassword("User password is empty")
|
||||
return {"error": "User password is empty"}
|
||||
if not Password.verify(password, user.password):
|
||||
# raise InvalidPassword("Wrong user password")
|
||||
return {"error": "Wrong user password"}
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def oauth(inp: dict[str, Any]) -> Any:
|
||||
"""
|
||||
Создает нового пользователя OAuth, если он не существует
|
||||
|
||||
Args:
|
||||
inp (dict): Данные OAuth пользователя
|
||||
|
||||
Returns:
|
||||
Author: Объект пользователя
|
||||
"""
|
||||
# Author уже импортирован в начале файла
|
||||
|
||||
def oauth(inp) -> User:
|
||||
with local_session() as session:
|
||||
author = session.query(Author).where(Author.email == inp["email"]).first()
|
||||
if not author:
|
||||
author = Author(**inp)
|
||||
author.email_verified = True # type: ignore[assignment]
|
||||
session.add(author)
|
||||
user = session.query(User).filter(User.email == inp["email"]).first()
|
||||
if not user:
|
||||
user = User.create(**inp, emailConfirmed=True)
|
||||
session.commit()
|
||||
|
||||
return author
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
async def onetime(token: str) -> Any:
|
||||
"""
|
||||
Проверяет одноразовый токен
|
||||
|
||||
Args:
|
||||
token (str): Одноразовый токен
|
||||
|
||||
Returns:
|
||||
Author: Объект пользователя
|
||||
"""
|
||||
async def onetime(token: str) -> User:
|
||||
try:
|
||||
print("[auth.identity] using one time token")
|
||||
payload = JWTCodec.decode(token)
|
||||
if payload is None:
|
||||
logger.warning("[Identity.token] Токен не валиден (payload is None)")
|
||||
return {"error": "Invalid token"}
|
||||
|
||||
# Проверяем существование токена в хранилище
|
||||
user_id = payload.get("user_id")
|
||||
username = payload.get("username")
|
||||
if not user_id or not username:
|
||||
logger.warning("[Identity.token] Нет user_id или username в токене")
|
||||
return {"error": "Invalid token"}
|
||||
|
||||
token_key = f"{user_id}-{username}-{token}"
|
||||
if not await redis.exists(token_key):
|
||||
logger.warning(f"[Identity.token] Токен не найден в хранилище: {token_key}")
|
||||
return {"error": "Token not found"}
|
||||
|
||||
# Если все проверки пройдены, ищем автора в базе данных
|
||||
# Author уже импортирован в начале файла
|
||||
with local_session() as session:
|
||||
author = session.query(Author).filter_by(id=user_id).first()
|
||||
if not author:
|
||||
logger.warning(f"[Identity.token] Автор с ID {user_id} не найден")
|
||||
return {"error": "User not found"}
|
||||
|
||||
logger.info(f"[Identity.token] Токен валиден для автора {author.id}")
|
||||
return author
|
||||
except ExpiredTokenError:
|
||||
# raise InvalidTokenError("Login token has expired, please try again")
|
||||
if not await TokenStorage.exist(f"{payload.user_id}-{payload.username}-{token}"):
|
||||
# raise InvalidToken("Login token has expired, please login again")
|
||||
return {"error": "Token has expired"}
|
||||
except ExpiredToken:
|
||||
# raise InvalidToken("Login token has expired, please try again")
|
||||
return {"error": "Token has expired"}
|
||||
except InvalidTokenError:
|
||||
# raise InvalidTokenError("token format error") from e
|
||||
except InvalidToken:
|
||||
# raise InvalidToken("token format error") from e
|
||||
return {"error": "Token format error"}
|
||||
with local_session() as session:
|
||||
user = session.query(User).filter_by(id=payload.user_id).first()
|
||||
if not user:
|
||||
# raise Exception("user not exist")
|
||||
return {"error": "User does not exist"}
|
||||
if not user.emailConfirmed:
|
||||
user.emailConfirmed = True
|
||||
session.commit()
|
||||
return user
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
"""
|
||||
Утилитные функции для внутренней аутентификации
|
||||
Используются в GraphQL резолверах и декораторах
|
||||
|
||||
DEPRECATED: Этот модуль переносится в auth/core.py
|
||||
Импорты оставлены для обратной совместимости
|
||||
"""
|
||||
|
||||
# Импорт базовых функций из core модуля
|
||||
from auth.core import authenticate, create_internal_session, verify_internal_auth
|
||||
|
||||
# Re-export для обратной совместимости
|
||||
__all__ = ["authenticate", "create_internal_session", "verify_internal_auth"]
|
||||
127
auth/jwtcodec.py
127
auth/jwtcodec.py
@@ -1,93 +1,60 @@
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import jwt
|
||||
from pydantic import BaseModel
|
||||
|
||||
from settings import JWT_ALGORITHM, JWT_ISSUER, JWT_REFRESH_TOKEN_EXPIRE_DAYS, JWT_SECRET_KEY
|
||||
from auth.exceptions import ExpiredToken, InvalidToken
|
||||
from settings import JWT_ALGORITHM, JWT_SECRET_KEY
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
user_id: str
|
||||
username: str
|
||||
exp: datetime
|
||||
iat: datetime
|
||||
iss: str
|
||||
|
||||
|
||||
class JWTCodec:
|
||||
"""
|
||||
Кодировщик и декодировщик JWT токенов.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def encode(
|
||||
payload: Dict[str, Any],
|
||||
secret_key: str | None = None,
|
||||
algorithm: str | None = None,
|
||||
expiration: datetime.datetime | None = None,
|
||||
) -> str | bytes:
|
||||
"""
|
||||
Кодирует payload в JWT токен.
|
||||
|
||||
Args:
|
||||
payload (Dict[str, Any]): Полезная нагрузка для кодирования
|
||||
secret_key (Optional[str]): Секретный ключ. По умолчанию используется JWT_SECRET_KEY
|
||||
algorithm (Optional[str]): Алгоритм шифрования. По умолчанию используется JWT_ALGORITHM
|
||||
expiration (Optional[datetime.datetime]): Время истечения токена
|
||||
|
||||
Returns:
|
||||
str: Закодированный JWT токен
|
||||
"""
|
||||
logger = logging.getLogger("root")
|
||||
logger.debug(f"[JWTCodec.encode] Кодирование токена для payload: {payload}")
|
||||
|
||||
# Используем переданные или дефолтные значения
|
||||
secret_key = secret_key or JWT_SECRET_KEY
|
||||
algorithm = algorithm or JWT_ALGORITHM
|
||||
|
||||
# Если время истечения не указано, устанавливаем дефолтное
|
||||
if not expiration:
|
||||
expiration = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=JWT_REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
logger.debug(f"[JWTCodec.encode] Время истечения не указано, устанавливаем срок: {expiration}")
|
||||
|
||||
# Формируем payload с временными метками
|
||||
payload.update(
|
||||
{"exp": int(expiration.timestamp()), "iat": datetime.datetime.now(datetime.UTC), "iss": JWT_ISSUER}
|
||||
)
|
||||
|
||||
logger.debug(f"[JWTCodec.encode] Сформирован payload: {payload}")
|
||||
|
||||
def encode(user, exp: datetime) -> str:
|
||||
payload = {
|
||||
"user_id": user.id,
|
||||
"username": user.email or user.phone,
|
||||
"exp": exp,
|
||||
"iat": datetime.now(tz=timezone.utc),
|
||||
"iss": "discours",
|
||||
}
|
||||
try:
|
||||
# Используем PyJWT для кодирования
|
||||
encoded = jwt.encode(payload, secret_key, algorithm=algorithm)
|
||||
return encoded.decode("utf-8") if isinstance(encoded, bytes) else encoded
|
||||
return jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM)
|
||||
except Exception as e:
|
||||
logger.warning(f"[JWTCodec.encode] Ошибка при кодировании JWT: {e}")
|
||||
raise
|
||||
print("[auth.jwtcodec] JWT encode error %r" % e)
|
||||
|
||||
@staticmethod
|
||||
def decode(
|
||||
token: str,
|
||||
secret_key: str | None = None,
|
||||
algorithms: list | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Декодирует JWT токен.
|
||||
|
||||
Args:
|
||||
token (str): JWT токен
|
||||
secret_key (Optional[str]): Секретный ключ. По умолчанию используется JWT_SECRET_KEY
|
||||
algorithms (Optional[list]): Список алгоритмов. По умолчанию используется [JWT_ALGORITHM]
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Декодированный payload
|
||||
"""
|
||||
logger = logging.getLogger("root")
|
||||
logger.debug("[JWTCodec.decode] Декодирование токена")
|
||||
|
||||
# Используем переданные или дефолтные значения
|
||||
secret_key = secret_key or JWT_SECRET_KEY
|
||||
algorithms = algorithms or [JWT_ALGORITHM]
|
||||
|
||||
def decode(token: str, verify_exp: bool = True):
|
||||
r = None
|
||||
payload = None
|
||||
try:
|
||||
# Используем PyJWT для декодирования
|
||||
return jwt.decode(token, secret_key, algorithms=algorithms)
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
key=JWT_SECRET_KEY,
|
||||
options={
|
||||
"verify_exp": verify_exp,
|
||||
# "verify_signature": False
|
||||
},
|
||||
algorithms=[JWT_ALGORITHM],
|
||||
issuer="discours",
|
||||
)
|
||||
r = TokenPayload(**payload)
|
||||
# print('[auth.jwtcodec] debug token %r' % r)
|
||||
return r
|
||||
except jwt.InvalidIssuedAtError:
|
||||
print("[auth.jwtcodec] invalid issued at: %r" % payload)
|
||||
raise ExpiredToken("check token issued time")
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.warning("[JWTCodec.decode] Токен просрочен")
|
||||
raise
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"[JWTCodec.decode] Ошибка при декодировании JWT: {e}")
|
||||
raise
|
||||
print("[auth.jwtcodec] expired signature %r" % payload)
|
||||
raise ExpiredToken("check token lifetime")
|
||||
except jwt.InvalidTokenError:
|
||||
raise InvalidToken("token is not valid")
|
||||
except jwt.InvalidSignatureError:
|
||||
raise InvalidToken("token is not valid")
|
||||
|
||||
113
auth/logout.py
113
auth/logout.py
@@ -1,113 +0,0 @@
|
||||
"""
|
||||
🔒 OAuth Logout Endpoint - Критически важный для безопасности
|
||||
|
||||
Обеспечивает безопасный выход пользователей с отзывом httpOnly cookies.
|
||||
"""
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, RedirectResponse
|
||||
|
||||
from auth.tokens.storage import TokenStorage
|
||||
from settings import SESSION_COOKIE_NAME
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
|
||||
def _clear_session_cookie(response) -> None:
|
||||
"""🔍 DRY: Единая функция очистки session cookie"""
|
||||
response.delete_cookie(
|
||||
SESSION_COOKIE_NAME,
|
||||
path="/",
|
||||
domain=".discours.io", # Важно: тот же domain что при установке
|
||||
)
|
||||
|
||||
|
||||
async def logout_endpoint(request: Request) -> JSONResponse | RedirectResponse:
|
||||
"""
|
||||
🔒 Безопасный logout с отзывом httpOnly cookie
|
||||
|
||||
Поддерживает как JSON API так и redirect для браузеров.
|
||||
"""
|
||||
try:
|
||||
# 1. Получаем токен из cookie
|
||||
session_token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
|
||||
if session_token:
|
||||
# 2. Отзываем сессию в Redis
|
||||
revoked = await TokenStorage.revoke_session(session_token)
|
||||
if revoked:
|
||||
logger.info("✅ Session revoked successfully")
|
||||
else:
|
||||
logger.warning("⚠️ Session not found or already revoked")
|
||||
|
||||
# 3. Определяем тип ответа
|
||||
accept_header = request.headers.get("accept", "")
|
||||
redirect_url = request.query_params.get("redirect_url", "https://testing.discours.io")
|
||||
|
||||
if "application/json" in accept_header:
|
||||
# JSON API ответ
|
||||
response: JSONResponse | RedirectResponse = JSONResponse(
|
||||
{"success": True, "message": "Logged out successfully"}
|
||||
)
|
||||
else:
|
||||
# Browser redirect
|
||||
response = RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
# 4. Очищаем httpOnly cookie
|
||||
_clear_session_cookie(response)
|
||||
|
||||
logger.info("🚪 User logged out successfully")
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Logout error: {e}", exc_info=True)
|
||||
|
||||
# Даже при ошибке очищаем cookie
|
||||
response = JSONResponse({"success": False, "error": "Logout failed"}, status_code=500)
|
||||
_clear_session_cookie(response)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
async def logout_all_sessions(request: Request) -> JSONResponse:
|
||||
"""
|
||||
🔒 Отзыв всех сессий пользователя (security endpoint)
|
||||
|
||||
Используется при компрометации аккаунта.
|
||||
"""
|
||||
try:
|
||||
# Получаем текущий токен
|
||||
session_token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
|
||||
if not session_token:
|
||||
return JSONResponse({"success": False, "error": "No active session"}, status_code=401)
|
||||
|
||||
# Получаем user_id из токена
|
||||
from auth.tokens.sessions import SessionTokenManager
|
||||
|
||||
session_manager = SessionTokenManager()
|
||||
|
||||
session_data = await session_manager.get_session_data(session_token)
|
||||
if not session_data:
|
||||
return JSONResponse({"success": False, "error": "Invalid session"}, status_code=401)
|
||||
|
||||
user_id = session_data.get("user_id")
|
||||
if not user_id:
|
||||
return JSONResponse({"success": False, "error": "No user ID in session"}, status_code=400)
|
||||
|
||||
# Отзываем ВСЕ сессии пользователя
|
||||
revoked_count = await session_manager.revoke_user_sessions(user_id)
|
||||
|
||||
logger.warning(f"🚨 All sessions revoked for user {user_id}: {revoked_count} sessions")
|
||||
|
||||
# Очищаем cookie
|
||||
response = JSONResponse(
|
||||
{"success": True, "message": f"All sessions revoked: {revoked_count}", "revoked_sessions": revoked_count}
|
||||
)
|
||||
|
||||
_clear_session_cookie(response)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Logout all sessions error: {e}", exc_info=True)
|
||||
return JSONResponse({"success": False, "error": "Failed to revoke sessions"}, status_code=500)
|
||||
@@ -1,381 +0,0 @@
|
||||
"""
|
||||
Единый middleware для обработки авторизации в GraphQL запросах
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections.abc import Awaitable, MutableMapping
|
||||
from typing import Any, Callable
|
||||
|
||||
from graphql import GraphQLResolveInfo
|
||||
from sqlalchemy.orm import exc
|
||||
from starlette.authentication import UnauthenticatedUser
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from auth.credentials import AuthCredentials
|
||||
from auth.tokens.storage import TokenStorage as TokenManager
|
||||
from orm.author import Author
|
||||
from settings import (
|
||||
ADMIN_EMAILS as ADMIN_EMAILS_LIST,
|
||||
)
|
||||
from settings import (
|
||||
SESSION_COOKIE_DOMAIN,
|
||||
SESSION_COOKIE_HTTPONLY,
|
||||
SESSION_COOKIE_NAME,
|
||||
SESSION_COOKIE_SAMESITE,
|
||||
SESSION_COOKIE_SECURE,
|
||||
SESSION_TOKEN_HEADER,
|
||||
)
|
||||
from storage.db import local_session
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
|
||||
|
||||
|
||||
class AuthenticatedUser:
|
||||
"""Аутентифицированный пользователь"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str,
|
||||
username: str = "",
|
||||
roles: list | None = None,
|
||||
permissions: dict | None = None,
|
||||
token: str | None = None,
|
||||
) -> None:
|
||||
self.user_id = user_id
|
||||
self.username = username
|
||||
self.roles = roles or []
|
||||
self.permissions = permissions or {}
|
||||
self.token = token
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return self.username
|
||||
|
||||
@property
|
||||
def identity(self) -> str:
|
||||
return self.user_id
|
||||
|
||||
|
||||
class AuthMiddleware:
|
||||
"""
|
||||
Единый middleware для обработки авторизации и аутентификации.
|
||||
|
||||
Основные функции:
|
||||
1. Извлечение Bearer токена из заголовка Authorization или cookie
|
||||
2. Проверка сессии через TokenStorage
|
||||
3. Создание request.user и request.auth
|
||||
4. Предоставление методов для установки/удаления cookies
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
self._context = None
|
||||
|
||||
async def authenticate_user(self, token: str) -> tuple[AuthCredentials, AuthenticatedUser | UnauthenticatedUser]:
|
||||
"""Аутентифицирует пользователя по токену"""
|
||||
if not token:
|
||||
return AuthCredentials(
|
||||
author_id=None, scopes={}, logged_in=False, error_message="no token", email=None, token=None
|
||||
), UnauthenticatedUser()
|
||||
|
||||
# Проверяем сессию в Redis
|
||||
try:
|
||||
payload = await TokenManager.verify_session(token)
|
||||
if not payload:
|
||||
logger.debug("[auth.authenticate] Недействительный токен или сессия не найдена")
|
||||
return AuthCredentials(
|
||||
author_id=None,
|
||||
scopes={},
|
||||
logged_in=False,
|
||||
error_message="Invalid token or session",
|
||||
email=None,
|
||||
token=None,
|
||||
), UnauthenticatedUser()
|
||||
|
||||
with local_session() as session:
|
||||
try:
|
||||
# payload может быть словарем или объектом, обрабатываем оба случая
|
||||
user_id = payload.user_id if hasattr(payload, "user_id") else payload.get("user_id")
|
||||
if not user_id:
|
||||
logger.debug("[auth.authenticate] user_id не найден в payload")
|
||||
return AuthCredentials(
|
||||
author_id=None,
|
||||
scopes={},
|
||||
logged_in=False,
|
||||
error_message="Invalid token payload",
|
||||
email=None,
|
||||
token=None,
|
||||
), UnauthenticatedUser()
|
||||
|
||||
author = session.query(Author).where(Author.id == user_id).one()
|
||||
|
||||
if author.is_locked():
|
||||
logger.debug(f"[auth.authenticate] Аккаунт заблокирован: {author.id}")
|
||||
return AuthCredentials(
|
||||
author_id=None,
|
||||
scopes={},
|
||||
logged_in=False,
|
||||
error_message="Account is locked",
|
||||
email=None,
|
||||
token=None,
|
||||
), UnauthenticatedUser()
|
||||
|
||||
# Создаем пустой словарь разрешений
|
||||
# Разрешения будут проверяться через RBAC систему по требованию
|
||||
scopes: dict[str, Any] = {}
|
||||
|
||||
# Роли пользователя будут определяться в контексте конкретной операции
|
||||
# через RBAC систему, а не здесь
|
||||
roles: list[str] = []
|
||||
|
||||
# Обновляем last_seen
|
||||
author.last_seen = int(time.time())
|
||||
session.commit()
|
||||
|
||||
# Создаем объекты авторизации с сохранением токена
|
||||
credentials = AuthCredentials(
|
||||
author_id=author.id,
|
||||
scopes=scopes,
|
||||
logged_in=True,
|
||||
error_message="",
|
||||
email=author.email,
|
||||
token=token,
|
||||
)
|
||||
|
||||
user = AuthenticatedUser(
|
||||
user_id=str(author.id),
|
||||
username=author.slug or author.email or "",
|
||||
roles=roles,
|
||||
permissions=scopes,
|
||||
token=token,
|
||||
)
|
||||
|
||||
logger.debug(f"[auth.authenticate] Успешная аутентификация: {author.email}")
|
||||
return credentials, user
|
||||
|
||||
except exc.NoResultFound:
|
||||
logger.debug("[auth.authenticate] Пользователь не найден в базе данных")
|
||||
return AuthCredentials(
|
||||
author_id=None,
|
||||
scopes={},
|
||||
logged_in=False,
|
||||
error_message="User not found",
|
||||
email=None,
|
||||
token=None,
|
||||
), UnauthenticatedUser()
|
||||
except Exception as e:
|
||||
logger.warning(f"[auth.authenticate] Ошибка при работе с базой данных: {e}")
|
||||
return AuthCredentials(
|
||||
author_id=None, scopes={}, logged_in=False, error_message=str(e), email=None, token=None
|
||||
), UnauthenticatedUser()
|
||||
except Exception as e:
|
||||
logger.warning(f"[auth.authenticate] Ошибка при проверке сессии: {e}")
|
||||
return AuthCredentials(
|
||||
author_id=None, scopes={}, logged_in=False, error_message=str(e), email=None, token=None
|
||||
), UnauthenticatedUser()
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
scope: MutableMapping[str, Any],
|
||||
receive: Callable[[], Awaitable[MutableMapping[str, Any]]],
|
||||
send: Callable[[MutableMapping[str, Any]], Awaitable[None]],
|
||||
) -> None:
|
||||
"""Обработка ASGI запроса"""
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
# Извлекаем заголовки используя тот же механизм, что и get_safe_headers
|
||||
headers = {}
|
||||
|
||||
# Первый приоритет: scope из ASGI (самый надежный источник)
|
||||
if "headers" in scope:
|
||||
scope_headers = scope.get("headers", [])
|
||||
if scope_headers:
|
||||
headers.update({k.decode("utf-8").lower(): v.decode("utf-8") for k, v in scope_headers})
|
||||
|
||||
# Используем тот же механизм получения токена, что и в декораторе
|
||||
token = None
|
||||
|
||||
# 0. Проверяем сохраненный токен в scope (приоритет)
|
||||
if "auth_token" in scope:
|
||||
token = scope["auth_token"]
|
||||
|
||||
# 1. Проверяем заголовок Authorization
|
||||
if not token:
|
||||
auth_header = headers.get("authorization", "")
|
||||
if auth_header:
|
||||
token = auth_header[7:].strip() if auth_header.startswith("Bearer ") else auth_header.strip()
|
||||
|
||||
# 2. Проверяем основной заголовок авторизации, если Authorization не найден
|
||||
if not token:
|
||||
auth_header = headers.get(SESSION_TOKEN_HEADER.lower(), "")
|
||||
if auth_header:
|
||||
token = auth_header[7:].strip() if auth_header.startswith("Bearer ") else auth_header.strip()
|
||||
|
||||
# 3. Проверяем cookie
|
||||
if not token:
|
||||
cookies = headers.get("cookie", "")
|
||||
if cookies:
|
||||
cookie_items = cookies.split(";")
|
||||
for item in cookie_items:
|
||||
if "=" in item:
|
||||
name, value = item.split("=", 1)
|
||||
cookie_name = name.strip()
|
||||
if cookie_name == SESSION_COOKIE_NAME:
|
||||
token = value.strip()
|
||||
break
|
||||
|
||||
# Аутентифицируем пользователя
|
||||
auth, user = await self.authenticate_user(token or "")
|
||||
|
||||
# Добавляем в scope данные авторизации и пользователя
|
||||
scope["auth"] = auth
|
||||
scope["user"] = user
|
||||
|
||||
# Сохраняем токен в scope для использования в последующих запросах
|
||||
if token:
|
||||
scope["auth_token"] = token
|
||||
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
def set_context(self, context) -> None:
|
||||
"""Сохраняет ссылку на контекст GraphQL запроса"""
|
||||
self._context = context
|
||||
|
||||
def set_cookie(self, key: str, value: str, **options: Any) -> None:
|
||||
"""
|
||||
Устанавливает cookie в ответе
|
||||
|
||||
Args:
|
||||
key: Имя cookie
|
||||
value: Значение cookie
|
||||
**options: Дополнительные параметры (httponly, secure, max_age, etc.)
|
||||
"""
|
||||
success = False
|
||||
|
||||
# Способ 1: Через response
|
||||
if self._context and "response" in self._context and hasattr(self._context["response"], "set_cookie"):
|
||||
try:
|
||||
self._context["response"].set_cookie(key, value, **options)
|
||||
success = True
|
||||
except Exception as e:
|
||||
logger.error(f"[middleware] Ошибка при установке cookie {key} через response: {e!s}")
|
||||
|
||||
# Способ 2: Через собственный response в контексте
|
||||
if not success and hasattr(self, "_response") and self._response and hasattr(self._response, "set_cookie"):
|
||||
try:
|
||||
self._response.set_cookie(key, value, **options)
|
||||
success = True
|
||||
except Exception as e:
|
||||
logger.error(f"[middleware] Ошибка при установке cookie {key} через _response: {e!s}")
|
||||
|
||||
if not success:
|
||||
logger.error(f"[middleware] Не удалось установить cookie {key}: объекты response недоступны")
|
||||
|
||||
def delete_cookie(self, key: str, **options: Any) -> None:
|
||||
"""
|
||||
Удаляет cookie из ответа
|
||||
"""
|
||||
success = False
|
||||
|
||||
# Способ 1: Через response
|
||||
if self._context and "response" in self._context and hasattr(self._context["response"], "delete_cookie"):
|
||||
try:
|
||||
self._context["response"].delete_cookie(key, **options)
|
||||
success = True
|
||||
except Exception as e:
|
||||
logger.error(f"[middleware] Ошибка при удалении cookie {key} через response: {e!s}")
|
||||
|
||||
# Способ 2: Через собственный response в контексте
|
||||
if not success and hasattr(self, "_response") and self._response and hasattr(self._response, "delete_cookie"):
|
||||
try:
|
||||
self._response.delete_cookie(key, **options)
|
||||
success = True
|
||||
except Exception as e:
|
||||
logger.error(f"[middleware] Ошибка при удалении cookie {key} через _response: {e!s}")
|
||||
|
||||
if not success:
|
||||
logger.error(f"[middleware] Не удалось удалить cookie {key}: объекты response недоступны")
|
||||
|
||||
async def resolve(
|
||||
self, next_resolver: Callable[..., Any], root: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
"""
|
||||
Middleware для обработки запросов GraphQL.
|
||||
Добавляет методы для установки cookie в контекст.
|
||||
"""
|
||||
try:
|
||||
# Получаем доступ к контексту запроса
|
||||
context = info.context
|
||||
|
||||
# Сохраняем ссылку на контекст
|
||||
self.set_context(context)
|
||||
|
||||
# Добавляем себя как объект, содержащий утилитные методы
|
||||
context["extensions"] = self
|
||||
|
||||
# Проверяем наличие response в контексте
|
||||
if "response" not in context or not context["response"]:
|
||||
context["response"] = JSONResponse({})
|
||||
|
||||
return await next_resolver(root, info, *args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"[AuthMiddleware] Ошибка в GraphQL resolve: {e!s}")
|
||||
raise
|
||||
|
||||
async def process_result(self, request: Request, result: Any) -> Response:
|
||||
"""
|
||||
Обрабатывает результат GraphQL запроса, поддерживая установку cookie
|
||||
|
||||
Args:
|
||||
request: Starlette Request объект
|
||||
result: результат GraphQL запроса (dict или Response)
|
||||
|
||||
Returns:
|
||||
Response: HTTP-ответ с результатом и cookie (если необходимо)
|
||||
"""
|
||||
|
||||
# Проверяем, является ли result уже объектом Response
|
||||
response = result if isinstance(result, Response) else JSONResponse(result)
|
||||
|
||||
# Проверяем, был ли токен в запросе или ответе
|
||||
if request.method == "POST":
|
||||
try:
|
||||
data = await request.json()
|
||||
op_name = data.get("operationName", "").lower()
|
||||
|
||||
# Если это операция logout, удаляем cookie
|
||||
if op_name == "logout":
|
||||
response.delete_cookie(
|
||||
key=SESSION_COOKIE_NAME,
|
||||
secure=SESSION_COOKIE_SECURE,
|
||||
httponly=SESSION_COOKIE_HTTPONLY,
|
||||
samesite=SESSION_COOKIE_SAMESITE
|
||||
if SESSION_COOKIE_SAMESITE in ["strict", "lax", "none"]
|
||||
else "none",
|
||||
domain=SESSION_COOKIE_DOMAIN,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[process_result] Ошибка при обработке POST запроса: {e!s}")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# Создаем единый экземпляр AuthMiddleware для использования с GraphQL
|
||||
async def _dummy_app(
|
||||
scope: MutableMapping[str, Any],
|
||||
receive: Callable[[], Awaitable[MutableMapping[str, Any]]],
|
||||
send: Callable[[MutableMapping[str, Any]], Awaitable[None]],
|
||||
) -> None:
|
||||
"""Dummy ASGI app for middleware initialization"""
|
||||
|
||||
|
||||
auth_middleware = AuthMiddleware(_dummy_app)
|
||||
1210
auth/oauth.py
1210
auth/oauth.py
File diff suppressed because it is too large
Load Diff
@@ -1,300 +0,0 @@
|
||||
"""
|
||||
🔒 OAuth Security Enhancements - Критические исправления безопасности
|
||||
|
||||
Исправляет найденные уязвимости в OAuth реализации.
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
from typing import Dict, List
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
|
||||
def _send_security_alert_to_glitchtip(event_type: str, details: Dict) -> None:
|
||||
"""
|
||||
🚨 Отправка алертов безопасности в GlitchTip
|
||||
|
||||
Args:
|
||||
event_type: Тип события безопасности
|
||||
details: Детали события
|
||||
"""
|
||||
try:
|
||||
import sentry_sdk
|
||||
|
||||
# Определяем уровень критичности
|
||||
critical_events = [
|
||||
"open_redirect_attempt",
|
||||
"rate_limit_exceeded",
|
||||
"invalid_provider",
|
||||
"suspicious_redirect_uri",
|
||||
"brute_force_detected",
|
||||
]
|
||||
|
||||
# Создаем контекст для GlitchTip
|
||||
with sentry_sdk.configure_scope() as scope:
|
||||
scope.set_tag("security_event", event_type)
|
||||
scope.set_tag("component", "oauth")
|
||||
scope.set_context("security_details", details)
|
||||
|
||||
# Добавляем дополнительные теги для фильтрации
|
||||
if "ip" in details:
|
||||
scope.set_tag("client_ip", details["ip"])
|
||||
if "provider" in details:
|
||||
scope.set_tag("oauth_provider", details["provider"])
|
||||
if "redirect_uri" in details:
|
||||
scope.set_tag("has_redirect_uri", "true")
|
||||
|
||||
# Отправляем в зависимости от критичности
|
||||
if event_type in critical_events:
|
||||
# Критичные события как ERROR
|
||||
sentry_sdk.capture_message(f"🚨 CRITICAL OAuth Security Event: {event_type}", level="error")
|
||||
logger.error(f"🚨 CRITICAL security alert sent to GlitchTip: {event_type}")
|
||||
else:
|
||||
# Обычные события как WARNING
|
||||
sentry_sdk.capture_message(f"⚠️ OAuth Security Event: {event_type}", level="warning")
|
||||
logger.info(f"⚠️ Security alert sent to GlitchTip: {event_type}")
|
||||
|
||||
except Exception as e:
|
||||
# Не ломаем основную логику если GlitchTip недоступен
|
||||
logger.error(f"❌ Failed to send security alert to GlitchTip: {e}")
|
||||
|
||||
|
||||
def send_rate_limit_alert(client_ip: str, attempts: int) -> None:
|
||||
"""
|
||||
🚨 Специальный алерт для превышения rate limit
|
||||
|
||||
Args:
|
||||
client_ip: IP адрес нарушителя
|
||||
attempts: Количество попыток
|
||||
"""
|
||||
log_oauth_security_event(
|
||||
"rate_limit_exceeded",
|
||||
{
|
||||
"ip": client_ip,
|
||||
"attempts": attempts,
|
||||
"limit": OAUTH_RATE_LIMIT,
|
||||
"window_seconds": OAUTH_RATE_WINDOW,
|
||||
"severity": "high",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def send_open_redirect_alert(malicious_uri: str, client_ip: str = "") -> None:
|
||||
"""
|
||||
🚨 Специальный алерт для попытки open redirect атаки
|
||||
|
||||
Args:
|
||||
malicious_uri: Подозрительный URI
|
||||
client_ip: IP адрес атакующего
|
||||
"""
|
||||
log_oauth_security_event(
|
||||
"open_redirect_attempt",
|
||||
{"malicious_uri": malicious_uri, "ip": client_ip, "severity": "critical", "attack_type": "open_redirect"},
|
||||
)
|
||||
|
||||
|
||||
# 🔒 Whitelist разрешенных redirect URI
|
||||
ALLOWED_REDIRECT_DOMAINS = [
|
||||
"testing.discours.io",
|
||||
"new.discours.io",
|
||||
"discours.io",
|
||||
"localhost", # Только для разработки
|
||||
]
|
||||
|
||||
# 🔒 Rate limiting для OAuth endpoints
|
||||
oauth_rate_limits: Dict[str, List[float]] = {}
|
||||
OAUTH_RATE_LIMIT = 10 # Максимум 10 попыток
|
||||
OAUTH_RATE_WINDOW = 300 # За 5 минут
|
||||
|
||||
|
||||
def validate_redirect_uri(redirect_uri: str) -> bool:
|
||||
"""
|
||||
🔒 Строгая валидация redirect URI против open redirect атак
|
||||
|
||||
Args:
|
||||
redirect_uri: URI для валидации
|
||||
|
||||
Returns:
|
||||
bool: True если URI безопасен
|
||||
"""
|
||||
if not redirect_uri:
|
||||
return False
|
||||
|
||||
try:
|
||||
parsed = urlparse(redirect_uri)
|
||||
|
||||
# 1. Проверяем схему (только HTTPS в продакшене)
|
||||
if parsed.scheme not in ["https", "http"]: # http только для localhost
|
||||
logger.warning(f"🚨 Invalid scheme in redirect_uri: {parsed.scheme}")
|
||||
return False
|
||||
|
||||
# 2. Проверяем домен против whitelist
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
logger.warning(f"🚨 No hostname in redirect_uri: {redirect_uri}")
|
||||
return False
|
||||
|
||||
# 3. Проверяем против разрешенных доменов
|
||||
is_allowed = False
|
||||
for allowed_domain in ALLOWED_REDIRECT_DOMAINS:
|
||||
if hostname == allowed_domain or hostname.endswith(f".{allowed_domain}"):
|
||||
is_allowed = True
|
||||
break
|
||||
|
||||
if not is_allowed:
|
||||
logger.warning(f"🚨 Unauthorized domain in redirect_uri: {hostname}")
|
||||
# 🚨 Отправляем алерт о попытке open redirect атаки
|
||||
send_open_redirect_alert(redirect_uri)
|
||||
return False
|
||||
|
||||
# 4. Дополнительные проверки безопасности
|
||||
if len(redirect_uri) > 2048: # Слишком длинный URL
|
||||
logger.warning(f"🚨 Redirect URI too long: {len(redirect_uri)}")
|
||||
return False
|
||||
|
||||
# 5. Проверяем на подозрительные паттерны
|
||||
suspicious_patterns = [
|
||||
r"javascript:",
|
||||
r"data:",
|
||||
r"vbscript:",
|
||||
r"file:",
|
||||
r"ftp:",
|
||||
]
|
||||
|
||||
for pattern in suspicious_patterns:
|
||||
if re.search(pattern, redirect_uri, re.IGNORECASE):
|
||||
logger.warning(f"🚨 Suspicious pattern in redirect_uri: {pattern}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"🚨 Error validating redirect_uri: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def check_oauth_rate_limit(client_ip: str) -> bool:
|
||||
"""
|
||||
🔒 Rate limiting для OAuth endpoints
|
||||
|
||||
Args:
|
||||
client_ip: IP адрес клиента
|
||||
|
||||
Returns:
|
||||
bool: True если запрос разрешен
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
# Получаем историю запросов для IP
|
||||
if client_ip not in oauth_rate_limits:
|
||||
oauth_rate_limits[client_ip] = []
|
||||
|
||||
requests = oauth_rate_limits[client_ip]
|
||||
|
||||
# Удаляем старые запросы
|
||||
requests[:] = [req_time for req_time in requests if current_time - req_time < OAUTH_RATE_WINDOW]
|
||||
|
||||
# Проверяем лимит
|
||||
if len(requests) >= OAUTH_RATE_LIMIT:
|
||||
logger.warning(f"🚨 OAuth rate limit exceeded for IP: {client_ip}")
|
||||
# 🚨 Отправляем алерт о превышении rate limit
|
||||
send_rate_limit_alert(client_ip, len(requests))
|
||||
return False
|
||||
|
||||
# Добавляем текущий запрос
|
||||
requests.append(current_time)
|
||||
return True
|
||||
|
||||
|
||||
def get_safe_redirect_uri(request, fallback: str = "https://testing.discours.io") -> str:
|
||||
"""
|
||||
🔒 Безопасное получение redirect_uri с валидацией
|
||||
|
||||
Args:
|
||||
request: HTTP запрос
|
||||
fallback: Безопасный fallback URI
|
||||
|
||||
Returns:
|
||||
str: Валидный redirect URI
|
||||
"""
|
||||
# Приоритет источников (БЕЗ Referer header!)
|
||||
candidates = [
|
||||
request.query_params.get("redirect_uri"),
|
||||
request.path_params.get("redirect_uri"),
|
||||
fallback, # Безопасный fallback
|
||||
]
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate and validate_redirect_uri(candidate):
|
||||
logger.info(f"✅ Valid redirect_uri: {candidate}")
|
||||
return candidate
|
||||
|
||||
# Если ничего не подошло - используем безопасный fallback
|
||||
logger.warning(f"🚨 No valid redirect_uri found, using fallback: {fallback}")
|
||||
return fallback
|
||||
|
||||
|
||||
def log_oauth_security_event(event_type: str, details: Dict) -> None:
|
||||
"""
|
||||
🔒 Логирование событий безопасности OAuth
|
||||
|
||||
Args:
|
||||
event_type: Тип события
|
||||
details: Детали события
|
||||
"""
|
||||
logger.warning(f"🚨 OAuth Security Event: {event_type}")
|
||||
logger.warning(f" Details: {details}")
|
||||
|
||||
# 🚨 Отправляем критические события в GlitchTip
|
||||
_send_security_alert_to_glitchtip(event_type, details)
|
||||
|
||||
|
||||
def validate_oauth_provider(provider: str, log_security_events: bool = True) -> bool:
|
||||
"""
|
||||
🔒 Валидация OAuth провайдера
|
||||
|
||||
Args:
|
||||
provider: Название провайдера
|
||||
log_security_events: Логировать события безопасности
|
||||
|
||||
Returns:
|
||||
bool: True если провайдер валидный
|
||||
"""
|
||||
# Импортируем здесь чтобы избежать циклических импортов
|
||||
from auth.oauth import PROVIDER_CONFIGS
|
||||
|
||||
if not provider:
|
||||
return False
|
||||
|
||||
if provider not in PROVIDER_CONFIGS:
|
||||
if log_security_events:
|
||||
log_oauth_security_event(
|
||||
"invalid_provider", {"provider": provider, "available": list(PROVIDER_CONFIGS.keys())}
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def sanitize_oauth_logs(data: Dict) -> Dict:
|
||||
"""
|
||||
🔒 Очистка логов от чувствительной информации
|
||||
|
||||
Args:
|
||||
data: Данные для логирования
|
||||
|
||||
Returns:
|
||||
Dict: Очищенные данные
|
||||
"""
|
||||
sensitive_keys = ["state", "code", "access_token", "refresh_token", "client_secret"]
|
||||
|
||||
sanitized = {}
|
||||
for key, value in data.items():
|
||||
if key.lower() in sensitive_keys:
|
||||
sanitized[key] = f"***{str(value)[-4:]}" if value else None
|
||||
else:
|
||||
sanitized[key] = value
|
||||
|
||||
return sanitized
|
||||
215
auth/resolvers.py
Normal file
215
auth/resolvers.py
Normal file
@@ -0,0 +1,215 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from graphql.type import GraphQLResolveInfo
|
||||
|
||||
from auth.authenticate import login_required
|
||||
from auth.credentials import AuthCredentials
|
||||
from auth.email import send_auth_email
|
||||
from auth.exceptions import InvalidPassword, InvalidToken, ObjectNotExist, Unauthorized
|
||||
from auth.identity import Identity, Password
|
||||
from auth.jwtcodec import JWTCodec
|
||||
from auth.tokenstorage import TokenStorage
|
||||
from orm import Role, User
|
||||
from services.db import local_session
|
||||
from services.schema import mutation, query
|
||||
from settings import SESSION_TOKEN_HEADER
|
||||
|
||||
|
||||
@mutation.field("getSession")
|
||||
@login_required
|
||||
async def get_current_user(_, info):
|
||||
auth: AuthCredentials = info.context["request"].auth
|
||||
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER)
|
||||
|
||||
with local_session() as session:
|
||||
user = session.query(User).where(User.id == auth.user_id).one()
|
||||
user.lastSeen = datetime.now(tz=timezone.utc)
|
||||
session.commit()
|
||||
|
||||
return {"token": token, "user": user}
|
||||
|
||||
|
||||
@mutation.field("confirmEmail")
|
||||
async def confirm_email(_, info, token):
|
||||
"""confirm owning email address"""
|
||||
try:
|
||||
print("[resolvers.auth] confirm email by token")
|
||||
payload = JWTCodec.decode(token)
|
||||
user_id = payload.user_id
|
||||
await TokenStorage.get(f"{user_id}-{payload.username}-{token}")
|
||||
with local_session() as session:
|
||||
user = session.query(User).where(User.id == user_id).first()
|
||||
session_token = await TokenStorage.create_session(user)
|
||||
user.emailConfirmed = True
|
||||
user.lastSeen = datetime.now(tz=timezone.utc)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
return {"token": session_token, "user": user}
|
||||
except InvalidToken as e:
|
||||
raise InvalidToken(e.message)
|
||||
except Exception as e:
|
||||
print(e) # FIXME: debug only
|
||||
return {"error": "email is not confirmed"}
|
||||
|
||||
|
||||
def create_user(user_dict):
|
||||
user = User(**user_dict)
|
||||
with local_session() as session:
|
||||
user.roles.append(session.query(Role).first())
|
||||
session.add(user)
|
||||
session.commit()
|
||||
return user
|
||||
|
||||
|
||||
def replace_translit(src):
|
||||
ruchars = "абвгдеёжзийклмнопрстуфхцчшщъыьэюя."
|
||||
enchars = [
|
||||
"a",
|
||||
"b",
|
||||
"v",
|
||||
"g",
|
||||
"d",
|
||||
"e",
|
||||
"yo",
|
||||
"zh",
|
||||
"z",
|
||||
"i",
|
||||
"y",
|
||||
"k",
|
||||
"l",
|
||||
"m",
|
||||
"n",
|
||||
"o",
|
||||
"p",
|
||||
"r",
|
||||
"s",
|
||||
"t",
|
||||
"u",
|
||||
"f",
|
||||
"h",
|
||||
"c",
|
||||
"ch",
|
||||
"sh",
|
||||
"sch",
|
||||
"",
|
||||
"y",
|
||||
"'",
|
||||
"e",
|
||||
"yu",
|
||||
"ya",
|
||||
"-",
|
||||
]
|
||||
return src.translate(str.maketrans(ruchars, enchars))
|
||||
|
||||
|
||||
def generate_unique_slug(src):
|
||||
print("[resolvers.auth] generating slug from: " + src)
|
||||
slug = replace_translit(src.lower())
|
||||
slug = re.sub("[^0-9a-zA-Z]+", "-", slug)
|
||||
if slug != src:
|
||||
print("[resolvers.auth] translited name: " + slug)
|
||||
c = 1
|
||||
with local_session() as session:
|
||||
user = session.query(User).where(User.slug == slug).first()
|
||||
while user:
|
||||
user = session.query(User).where(User.slug == slug).first()
|
||||
slug = slug + "-" + str(c)
|
||||
c += 1
|
||||
if not user:
|
||||
unique_slug = slug
|
||||
print("[resolvers.auth] " + unique_slug)
|
||||
return quote_plus(unique_slug.replace("'", "")).replace("+", "-")
|
||||
|
||||
|
||||
@mutation.field("registerUser")
|
||||
async def register_by_email(_, _info, email: str, password: str = "", name: str = ""):
|
||||
email = email.lower()
|
||||
"""creates new user account"""
|
||||
with local_session() as session:
|
||||
user = session.query(User).filter(User.email == email).first()
|
||||
if user:
|
||||
raise Unauthorized("User already exist")
|
||||
else:
|
||||
slug = generate_unique_slug(name)
|
||||
user = session.query(User).where(User.slug == slug).first()
|
||||
if user:
|
||||
slug = generate_unique_slug(email.split("@")[0])
|
||||
user_dict = {
|
||||
"email": email,
|
||||
"username": email, # will be used to store phone number or some messenger network id
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
}
|
||||
if password:
|
||||
user_dict["password"] = Password.encode(password)
|
||||
user = create_user(user_dict)
|
||||
user = await auth_send_link(_, _info, email)
|
||||
return {"user": user}
|
||||
|
||||
|
||||
@mutation.field("sendLink")
|
||||
async def auth_send_link(_, _info, email, lang="ru", template="email_confirmation"):
|
||||
email = email.lower()
|
||||
"""send link with confirm code to email"""
|
||||
with local_session() as session:
|
||||
user = session.query(User).filter(User.email == email).first()
|
||||
if not user:
|
||||
raise ObjectNotExist("User not found")
|
||||
else:
|
||||
token = await TokenStorage.create_onetime(user)
|
||||
await send_auth_email(user, token, lang, template)
|
||||
return user
|
||||
|
||||
|
||||
@query.field("signIn")
|
||||
async def login(_, info, email: str, password: str = "", lang: str = "ru"):
|
||||
email = email.lower()
|
||||
with local_session() as session:
|
||||
orm_user = session.query(User).filter(User.email == email).first()
|
||||
if orm_user is None:
|
||||
print(f"[auth] {email}: email not found")
|
||||
# return {"error": "email not found"}
|
||||
raise ObjectNotExist("User not found") # contains webserver status
|
||||
|
||||
if not password:
|
||||
print(f"[auth] send confirm link to {email}")
|
||||
token = await TokenStorage.create_onetime(orm_user)
|
||||
await send_auth_email(orm_user, token, lang)
|
||||
# FIXME: not an error, warning
|
||||
return {"error": "no password, email link was sent"}
|
||||
|
||||
else:
|
||||
# sign in using password
|
||||
if not orm_user.emailConfirmed:
|
||||
# not an error, warns users
|
||||
return {"error": "please, confirm email"}
|
||||
else:
|
||||
try:
|
||||
user = Identity.password(orm_user, password)
|
||||
session_token = await TokenStorage.create_session(user)
|
||||
print(f"[auth] user {email} authorized")
|
||||
return {"token": session_token, "user": user}
|
||||
except InvalidPassword:
|
||||
print(f"[auth] {email}: invalid password")
|
||||
raise InvalidPassword("invalid password") # contains webserver status
|
||||
# return {"error": "invalid password"}
|
||||
|
||||
|
||||
@query.field("signOut")
|
||||
@login_required
|
||||
async def sign_out(_, info: GraphQLResolveInfo):
|
||||
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER, "")
|
||||
status = await TokenStorage.revoke(token)
|
||||
return status
|
||||
|
||||
|
||||
@query.field("isEmailUsed")
|
||||
async def is_email_used(_, _info, email):
|
||||
email = email.lower()
|
||||
with local_session() as session:
|
||||
user = session.query(User).filter(User.email == email).first()
|
||||
return user is not None
|
||||
@@ -1,23 +0,0 @@
|
||||
"""
|
||||
Классы состояния авторизации
|
||||
"""
|
||||
|
||||
|
||||
class AuthState:
|
||||
"""
|
||||
Класс для хранения информации о состоянии авторизации пользователя.
|
||||
Используется в аутентификационных middleware и функциях.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.logged_in: bool = False
|
||||
self.author_id: str | None = None
|
||||
self.token: str | None = None
|
||||
self.username: str | None = None
|
||||
self.is_admin: bool = False
|
||||
self.is_editor: bool = False
|
||||
self.error: str | None = None
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""Возвращает True если пользователь авторизован"""
|
||||
return self.logged_in
|
||||
@@ -1,53 +0,0 @@
|
||||
"""
|
||||
Базовый класс для работы с токенами
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from functools import lru_cache
|
||||
|
||||
from .types import TokenType
|
||||
|
||||
|
||||
class BaseTokenManager:
|
||||
"""
|
||||
Базовый класс с общими методами для всех типов токенов
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@lru_cache(maxsize=1000)
|
||||
def _make_token_key(token_type: TokenType, identifier: str, token: str | None = None) -> str:
|
||||
"""
|
||||
Создает унифицированный ключ для токена с кэшированием
|
||||
|
||||
Args:
|
||||
token_type: Тип токена
|
||||
identifier: Идентификатор (user_id, user_id:provider, etc)
|
||||
token: Сам токен (для session и verification)
|
||||
|
||||
Returns:
|
||||
str: Ключ токена
|
||||
"""
|
||||
if token_type == "session": # noqa: S105
|
||||
return f"session:{identifier}:{token}"
|
||||
if token_type == "verification": # noqa: S105
|
||||
return f"verification_token:{token}"
|
||||
if token_type == "oauth_access": # noqa: S105
|
||||
return f"oauth_access:{identifier}"
|
||||
if token_type == "oauth_refresh": # noqa: S105
|
||||
return f"oauth_refresh:{identifier}"
|
||||
|
||||
error_msg = f"Неизвестный тип токена: {token_type}"
|
||||
raise ValueError(error_msg)
|
||||
|
||||
@staticmethod
|
||||
@lru_cache(maxsize=500)
|
||||
def _make_user_tokens_key(user_id: str, token_type: TokenType) -> str:
|
||||
"""Создает ключ для списка токенов пользователя"""
|
||||
if token_type == "session": # noqa: S105
|
||||
return f"user_sessions:{user_id}"
|
||||
return f"user_tokens:{user_id}:{token_type}"
|
||||
|
||||
@staticmethod
|
||||
def generate_token() -> str:
|
||||
"""Генерирует криптографически стойкий токен"""
|
||||
return secrets.token_urlsafe(32)
|
||||
@@ -1,219 +0,0 @@
|
||||
"""
|
||||
Батчевые операции с токенами для оптимизации производительности
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from auth.jwtcodec import JWTCodec
|
||||
from storage.redis import redis as redis_adapter
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
from .base import BaseTokenManager
|
||||
from .types import BATCH_SIZE
|
||||
|
||||
|
||||
class BatchTokenOperations(BaseTokenManager):
|
||||
"""
|
||||
Класс для пакетных операций с токенами
|
||||
"""
|
||||
|
||||
async def batch_validate_tokens(self, tokens: List[str]) -> Dict[str, bool]:
|
||||
"""
|
||||
Пакетная валидация токенов для улучшения производительности
|
||||
|
||||
Args:
|
||||
tokens: Список токенов для валидации
|
||||
|
||||
Returns:
|
||||
Dict[str, bool]: Словарь {токен: валиден}
|
||||
"""
|
||||
if not tokens:
|
||||
return {}
|
||||
|
||||
results = {}
|
||||
|
||||
# Разбиваем на батчи для избежания блокировки Redis
|
||||
for i in range(0, len(tokens), BATCH_SIZE):
|
||||
batch = tokens[i : i + BATCH_SIZE]
|
||||
batch_results = await self._validate_token_batch(batch)
|
||||
results.update(batch_results)
|
||||
|
||||
return results
|
||||
|
||||
async def _validate_token_batch(self, token_batch: List[str]) -> Dict[str, bool]:
|
||||
"""Валидация батча токенов"""
|
||||
results = {}
|
||||
|
||||
# Создаем задачи для декодирования токенов пакетно
|
||||
decode_tasks = [asyncio.create_task(self._safe_decode_token(token)) for token in token_batch]
|
||||
|
||||
decoded_payloads = await asyncio.gather(*decode_tasks, return_exceptions=True)
|
||||
|
||||
# Подготавливаем ключи для проверки
|
||||
token_keys = []
|
||||
valid_tokens = []
|
||||
|
||||
for token, payload in zip(token_batch, decoded_payloads, strict=False):
|
||||
if isinstance(payload, Exception) or payload is None:
|
||||
results[token] = False
|
||||
continue
|
||||
|
||||
# payload может быть словарем или объектом, обрабатываем оба случая
|
||||
user_id = (
|
||||
payload.user_id
|
||||
if hasattr(payload, "user_id")
|
||||
else (payload.get("user_id") if isinstance(payload, dict) else None)
|
||||
)
|
||||
if not user_id:
|
||||
results[token] = False
|
||||
continue
|
||||
|
||||
token_key = self._make_token_key("session", user_id, token)
|
||||
token_keys.append(token_key)
|
||||
valid_tokens.append(token)
|
||||
|
||||
# Проверяем существование ключей пакетно
|
||||
if token_keys:
|
||||
async with redis_adapter.pipeline() as pipe:
|
||||
for key in token_keys:
|
||||
await pipe.exists(key)
|
||||
existence_results = await pipe.execute()
|
||||
|
||||
for token, exists in zip(valid_tokens, existence_results, strict=False):
|
||||
results[token] = bool(exists)
|
||||
|
||||
return results
|
||||
|
||||
async def _safe_decode_token(self, token: str) -> Any | None:
|
||||
"""Безопасное декодирование токена"""
|
||||
try:
|
||||
return JWTCodec.decode(token)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def batch_revoke_tokens(self, tokens: List[str]) -> int:
|
||||
"""
|
||||
Пакетный отзыв токенов
|
||||
|
||||
Args:
|
||||
tokens: Список токенов для отзыва
|
||||
|
||||
Returns:
|
||||
int: Количество отозванных токенов
|
||||
"""
|
||||
if not tokens:
|
||||
return 0
|
||||
|
||||
revoked_count = 0
|
||||
|
||||
# Обрабатываем батчами
|
||||
for i in range(0, len(tokens), BATCH_SIZE):
|
||||
batch = tokens[i : i + BATCH_SIZE]
|
||||
batch_count = await self._revoke_token_batch(batch)
|
||||
revoked_count += batch_count
|
||||
|
||||
return revoked_count
|
||||
|
||||
async def _revoke_token_batch(self, token_batch: List[str]) -> int:
|
||||
"""Отзыв батча токенов"""
|
||||
keys_to_delete = []
|
||||
user_updates: Dict[str, set[str]] = {} # {user_id: {tokens_to_remove}}
|
||||
|
||||
# Декодируем токены и подготавливаем операции
|
||||
for token in token_batch:
|
||||
payload = await self._safe_decode_token(token)
|
||||
if payload is not None:
|
||||
# payload может быть словарем или объектом, обрабатываем оба случая
|
||||
user_id = (
|
||||
payload.user_id
|
||||
if hasattr(payload, "user_id")
|
||||
else (payload.get("user_id") if isinstance(payload, dict) else None)
|
||||
)
|
||||
username = (
|
||||
payload.username
|
||||
if hasattr(payload, "username")
|
||||
else (payload.get("username") if isinstance(payload, dict) else None)
|
||||
)
|
||||
|
||||
if not user_id:
|
||||
continue
|
||||
|
||||
# Ключи для удаления
|
||||
new_key = self._make_token_key("session", user_id, token)
|
||||
old_key = f"{user_id}-{username}-{token}"
|
||||
keys_to_delete.extend([new_key, old_key])
|
||||
|
||||
# Обновления пользовательских списков
|
||||
if user_id not in user_updates:
|
||||
user_updates[user_id] = set()
|
||||
user_updates[user_id].add(token)
|
||||
|
||||
if not keys_to_delete:
|
||||
return 0
|
||||
|
||||
# Выполняем удаление пакетно
|
||||
async with redis_adapter.pipeline() as pipe:
|
||||
# Удаляем ключи токенов
|
||||
await pipe.delete(*keys_to_delete)
|
||||
|
||||
# Обновляем пользовательские списки
|
||||
for user_id, tokens_to_remove in user_updates.items():
|
||||
user_tokens_key = self._make_user_tokens_key(user_id, "session")
|
||||
for token in tokens_to_remove:
|
||||
await pipe.srem(user_tokens_key, token)
|
||||
|
||||
results = await pipe.execute()
|
||||
|
||||
return len([r for r in results if r > 0])
|
||||
|
||||
async def cleanup_expired_tokens(self) -> int:
|
||||
"""Оптимизированная очистка истекших токенов с использованием SCAN"""
|
||||
try:
|
||||
cleaned_count = 0
|
||||
cursor = 0
|
||||
|
||||
# Ищем все ключи пользовательских сессий
|
||||
while True:
|
||||
cursor, keys = await redis_adapter.execute("scan", cursor, "user_sessions:*", 100)
|
||||
|
||||
for user_tokens_key in keys:
|
||||
tokens = await redis_adapter.smembers(user_tokens_key)
|
||||
active_tokens = []
|
||||
|
||||
# Проверяем активность токенов пакетно
|
||||
if tokens:
|
||||
async with redis_adapter.pipeline() as pipe:
|
||||
for token in tokens:
|
||||
token_str = token if isinstance(token, str) else str(token)
|
||||
session_key = self._make_token_key("session", user_tokens_key.split(":")[1], token_str)
|
||||
await pipe.exists(session_key)
|
||||
results = await pipe.execute()
|
||||
|
||||
for token, exists in zip(tokens, results, strict=False):
|
||||
if exists:
|
||||
active_tokens.append(token)
|
||||
else:
|
||||
cleaned_count += 1
|
||||
|
||||
# Обновляем список активных токенов
|
||||
if active_tokens:
|
||||
async with redis_adapter.pipeline() as pipe:
|
||||
await pipe.delete(user_tokens_key)
|
||||
for token in active_tokens:
|
||||
await pipe.sadd(user_tokens_key, token)
|
||||
await pipe.execute()
|
||||
else:
|
||||
await redis_adapter.delete(user_tokens_key)
|
||||
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
if cleaned_count > 0:
|
||||
logger.info(f"Очищено {cleaned_count} ссылок на истекшие токены")
|
||||
|
||||
return cleaned_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки токенов: {e}")
|
||||
return 0
|
||||
@@ -1,187 +0,0 @@
|
||||
"""
|
||||
Статистика и мониторинг системы токенов
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict
|
||||
|
||||
from storage.redis import redis as redis_adapter
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
from .base import BaseTokenManager
|
||||
from .batch import BatchTokenOperations
|
||||
from .sessions import SessionTokenManager
|
||||
from .types import SCAN_BATCH_SIZE
|
||||
|
||||
|
||||
class TokenMonitoring(BaseTokenManager):
|
||||
"""
|
||||
Класс для мониторинга и статистики токенов
|
||||
"""
|
||||
|
||||
async def get_token_statistics(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Получает статистику по токенам для мониторинга
|
||||
|
||||
Returns:
|
||||
Dict: Статистика токенов
|
||||
"""
|
||||
stats = {
|
||||
"session_tokens": 0,
|
||||
"verification_tokens": 0,
|
||||
"oauth_access_tokens": 0,
|
||||
"oauth_refresh_tokens": 0,
|
||||
"user_sessions": 0,
|
||||
"memory_usage": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
# Считаем токены по типам используя SCAN
|
||||
patterns = {
|
||||
"session_tokens": "session:*",
|
||||
"verification_tokens": "verification_token:*",
|
||||
"oauth_access_tokens": "oauth_access:*",
|
||||
"oauth_refresh_tokens": "oauth_refresh:*",
|
||||
"user_sessions": "user_sessions:*",
|
||||
}
|
||||
|
||||
count_tasks = [self._count_keys_by_pattern(pattern) for pattern in patterns.values()]
|
||||
counts = await asyncio.gather(*count_tasks)
|
||||
|
||||
for (stat_name, _), count in zip(patterns.items(), counts, strict=False):
|
||||
stats[stat_name] = count
|
||||
|
||||
# Получаем информацию о памяти Redis
|
||||
memory_info = await redis_adapter.execute("INFO", "MEMORY")
|
||||
stats["memory_usage"] = memory_info.get("used_memory", 0)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики токенов: {e}")
|
||||
|
||||
return stats
|
||||
|
||||
async def _count_keys_by_pattern(self, pattern: str) -> int:
|
||||
"""Подсчет ключей по паттерну используя SCAN"""
|
||||
count = 0
|
||||
cursor = 0
|
||||
|
||||
while True:
|
||||
cursor, keys = await redis_adapter.execute("scan", cursor, pattern, SCAN_BATCH_SIZE)
|
||||
count += len(keys)
|
||||
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
return count
|
||||
|
||||
async def optimize_memory_usage(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Оптимизирует использование памяти Redis
|
||||
|
||||
Returns:
|
||||
Dict: Результаты оптимизации
|
||||
"""
|
||||
results = {"cleaned_expired": 0, "optimized_structures": 0, "memory_saved": 0}
|
||||
|
||||
try:
|
||||
# Очищаем истекшие токены
|
||||
batch_ops = BatchTokenOperations()
|
||||
cleaned = await batch_ops.cleanup_expired_tokens()
|
||||
results["cleaned_expired"] = cleaned
|
||||
|
||||
# Оптимизируем структуры данных
|
||||
optimized = await self._optimize_data_structures()
|
||||
results["optimized_structures"] = optimized
|
||||
|
||||
# Запускаем сборку мусора Redis
|
||||
await redis_adapter.execute("MEMORY", "PURGE")
|
||||
|
||||
logger.info(f"Оптимизация памяти завершена: {results}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка оптимизации памяти: {e}")
|
||||
|
||||
return results
|
||||
|
||||
async def _optimize_data_structures(self) -> int:
|
||||
"""Оптимизирует структуры данных Redis"""
|
||||
optimized_count = 0
|
||||
cursor = 0
|
||||
|
||||
# Оптимизируем пользовательские списки сессий
|
||||
while True:
|
||||
cursor, keys = await redis_adapter.execute("scan", cursor, "user_sessions:*", SCAN_BATCH_SIZE)
|
||||
|
||||
for key in keys:
|
||||
try:
|
||||
# Проверяем размер множества
|
||||
size = await redis_adapter.execute("scard", key)
|
||||
if size == 0:
|
||||
await redis_adapter.delete(key)
|
||||
optimized_count += 1
|
||||
elif size > 100: # Слишком много сессий у одного пользователя
|
||||
# Оставляем только последние 50 сессий
|
||||
members = await redis_adapter.execute("smembers", key)
|
||||
if len(members) > 50:
|
||||
members_list = list(members)
|
||||
to_remove = members_list[:-50]
|
||||
if to_remove:
|
||||
await redis_adapter.srem(key, *to_remove)
|
||||
optimized_count += len(to_remove)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка оптимизации ключа {key}: {e}")
|
||||
continue
|
||||
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
return optimized_count
|
||||
|
||||
async def health_check(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Проверка здоровья системы токенов
|
||||
|
||||
Returns:
|
||||
Dict: Результаты проверки
|
||||
"""
|
||||
health: Dict[str, Any] = {
|
||||
"status": "healthy",
|
||||
"redis_connected": False,
|
||||
"token_operations": False,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
try:
|
||||
# Проверяем подключение к Redis
|
||||
await redis_adapter.ping()
|
||||
health["redis_connected"] = True
|
||||
|
||||
# Тестируем основные операции с токенами
|
||||
session_manager = SessionTokenManager()
|
||||
|
||||
test_user_id = "health_check_user"
|
||||
test_token = await session_manager.create_session(test_user_id)
|
||||
|
||||
if test_token:
|
||||
# Проверяем валидацию
|
||||
valid, _ = await session_manager.validate_session_token(test_token)
|
||||
if valid:
|
||||
# Проверяем отзыв
|
||||
revoked = await session_manager.revoke_session_token(test_token)
|
||||
if revoked:
|
||||
health["token_operations"] = True
|
||||
else:
|
||||
health["errors"].append("Failed to revoke test token") # type: ignore[misc]
|
||||
else:
|
||||
health["errors"].append("Failed to validate test token") # type: ignore[misc]
|
||||
else:
|
||||
health["errors"].append("Failed to create test token") # type: ignore[misc]
|
||||
|
||||
except Exception as e:
|
||||
health["errors"].append(f"Health check error: {e}") # type: ignore[misc]
|
||||
|
||||
if health["errors"]:
|
||||
health["status"] = "unhealthy"
|
||||
|
||||
return health
|
||||
@@ -1,152 +0,0 @@
|
||||
"""
|
||||
Управление OAuth токенов
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from storage.redis import redis as redis_adapter
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
from .base import BaseTokenManager
|
||||
from .types import DEFAULT_TTL, TokenData, TokenType
|
||||
|
||||
|
||||
class OAuthTokenManager(BaseTokenManager):
|
||||
"""
|
||||
Менеджер OAuth токенов
|
||||
"""
|
||||
|
||||
async def store_oauth_tokens(
|
||||
self,
|
||||
user_id: str,
|
||||
provider: str,
|
||||
access_token: str,
|
||||
refresh_token: str | None = None,
|
||||
expires_in: int | None = None,
|
||||
additional_data: TokenData | None = None,
|
||||
) -> bool:
|
||||
"""Сохраняет OAuth токены"""
|
||||
try:
|
||||
# Сохраняем access token
|
||||
access_data = {
|
||||
"token": access_token,
|
||||
"provider": provider,
|
||||
"expires_in": expires_in,
|
||||
**(additional_data or {}),
|
||||
}
|
||||
|
||||
access_ttl = expires_in if expires_in else DEFAULT_TTL["oauth_access"]
|
||||
await self._create_oauth_token(user_id, access_data, access_ttl, provider, "oauth_access")
|
||||
|
||||
# Сохраняем refresh token если есть
|
||||
if refresh_token:
|
||||
refresh_data = {
|
||||
"token": refresh_token,
|
||||
"provider": provider,
|
||||
}
|
||||
await self._create_oauth_token(
|
||||
user_id, refresh_data, DEFAULT_TTL["oauth_refresh"], provider, "oauth_refresh"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сохранения OAuth токенов: {e}")
|
||||
return False
|
||||
|
||||
async def _create_oauth_token(
|
||||
self, user_id: str, token_data: TokenData, ttl: int, provider: str, token_type: TokenType
|
||||
) -> str:
|
||||
"""Оптимизированное создание OAuth токена"""
|
||||
if not provider:
|
||||
error_msg = "OAuth токены требуют указания провайдера"
|
||||
raise ValueError(error_msg)
|
||||
|
||||
identifier = f"{user_id}:{provider}"
|
||||
token_key = self._make_token_key(token_type, identifier)
|
||||
|
||||
# Добавляем метаданные
|
||||
token_data.update(
|
||||
{"user_id": user_id, "token_type": token_type, "provider": provider, "created_at": int(time.time())}
|
||||
)
|
||||
|
||||
# Используем SETEX для атомарной операции
|
||||
serialized_data = json.dumps(token_data, ensure_ascii=False)
|
||||
await redis_adapter.execute("setex", token_key, ttl, serialized_data)
|
||||
|
||||
logger.info(f"Создан {token_type} токен для пользователя {user_id}, провайдер {provider}")
|
||||
return token_key
|
||||
|
||||
async def get_token(self, user_id: int, provider: str, token_type: TokenType) -> TokenData | None:
|
||||
"""Получает токен"""
|
||||
if token_type.startswith("oauth_"):
|
||||
return await self._get_oauth_data_optimized(token_type, str(user_id), provider)
|
||||
return None
|
||||
|
||||
async def _get_oauth_data_optimized(self, token_type: TokenType, user_id: str, provider: str) -> TokenData | None:
|
||||
"""Оптимизированное получение OAuth данных"""
|
||||
if not user_id or not provider:
|
||||
error_msg = "OAuth токены требуют user_id и provider"
|
||||
raise ValueError(error_msg)
|
||||
|
||||
identifier = f"{user_id}:{provider}"
|
||||
token_key = self._make_token_key(token_type, identifier)
|
||||
|
||||
# Получаем данные и TTL в одном pipeline
|
||||
async with redis_adapter.pipeline() as pipe:
|
||||
await pipe.get(token_key)
|
||||
await pipe.ttl(token_key)
|
||||
results = await pipe.execute()
|
||||
|
||||
if results[0]:
|
||||
token_data = json.loads(results[0])
|
||||
if results[1] > 0:
|
||||
token_data["ttl_remaining"] = results[1]
|
||||
return token_data
|
||||
return None
|
||||
|
||||
async def revoke_oauth_tokens(self, user_id: str, provider: str) -> bool:
|
||||
"""Удаляет все OAuth токены для провайдера"""
|
||||
try:
|
||||
result1 = await self._revoke_oauth_token_optimized("oauth_access", user_id, provider)
|
||||
result2 = await self._revoke_oauth_token_optimized("oauth_refresh", user_id, provider)
|
||||
return result1 or result2
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления OAuth токенов: {e}")
|
||||
return False
|
||||
|
||||
async def _revoke_oauth_token_optimized(self, token_type: TokenType, user_id: str, provider: str) -> bool:
|
||||
"""Оптимизированный отзыв OAuth токена"""
|
||||
if not user_id or not provider:
|
||||
error_msg = "OAuth токены требуют user_id и provider"
|
||||
raise ValueError(error_msg)
|
||||
|
||||
identifier = f"{user_id}:{provider}"
|
||||
token_key = self._make_token_key(token_type, identifier)
|
||||
result = await redis_adapter.delete(token_key)
|
||||
return result > 0
|
||||
|
||||
async def revoke_user_oauth_tokens(self, user_id: str, token_type: TokenType) -> int:
|
||||
"""Оптимизированный отзыв OAuth токенов пользователя используя SCAN"""
|
||||
count = 0
|
||||
cursor = 0
|
||||
delete_keys = []
|
||||
pattern = f"{token_type}:{user_id}:*"
|
||||
|
||||
# Используем SCAN для безопасного поиска токенов
|
||||
while True:
|
||||
cursor, keys = await redis_adapter.execute("scan", cursor, pattern, 100)
|
||||
|
||||
if keys:
|
||||
delete_keys.extend(keys)
|
||||
count += len(keys)
|
||||
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
# Удаляем найденные токены пакетно
|
||||
if delete_keys:
|
||||
await redis_adapter.delete(*delete_keys)
|
||||
|
||||
return count
|
||||
@@ -1,268 +0,0 @@
|
||||
"""
|
||||
Управление токенами сессий
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any, List
|
||||
|
||||
from auth.jwtcodec import JWTCodec
|
||||
from storage.redis import redis as redis_adapter
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
from .base import BaseTokenManager
|
||||
from .types import DEFAULT_TTL, TokenData
|
||||
|
||||
|
||||
class SessionTokenManager(BaseTokenManager):
|
||||
"""
|
||||
Менеджер токенов сессий
|
||||
"""
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
user_id: str,
|
||||
auth_data: dict | None = None,
|
||||
username: str | None = None,
|
||||
device_info: dict | None = None,
|
||||
) -> str:
|
||||
"""Создает токен сессии"""
|
||||
session_data = {}
|
||||
|
||||
if auth_data:
|
||||
session_data["auth_data"] = json.dumps(auth_data)
|
||||
if username:
|
||||
session_data["username"] = username
|
||||
if device_info:
|
||||
session_data["device_info"] = json.dumps(device_info)
|
||||
|
||||
return await self.create_session_token(user_id, session_data)
|
||||
|
||||
async def create_session_token(self, user_id: str, token_data: TokenData) -> str:
|
||||
"""Создание JWT токена сессии"""
|
||||
username = token_data.get("username", "")
|
||||
|
||||
# Создаем JWT токен
|
||||
jwt_token = JWTCodec.encode(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
}
|
||||
)
|
||||
|
||||
session_token = jwt_token.decode("utf-8") if isinstance(jwt_token, bytes) else str(jwt_token)
|
||||
token_key = self._make_token_key("session", user_id, session_token)
|
||||
user_tokens_key = self._make_user_tokens_key(user_id, "session")
|
||||
ttl = DEFAULT_TTL["session"]
|
||||
|
||||
# Добавляем метаданные
|
||||
token_data.update({"user_id": user_id, "token_type": "session", "created_at": int(time.time())})
|
||||
|
||||
# Используем новый метод execute_pipeline для избежания deprecated warnings
|
||||
commands: list[tuple[str, tuple[Any, ...]]] = []
|
||||
|
||||
# Сохраняем данные сессии в hash, преобразуя значения в строки
|
||||
for field, value in token_data.items():
|
||||
commands.append(("hset", (token_key, field, str(value))))
|
||||
commands.append(("expire", (token_key, ttl)))
|
||||
|
||||
# Добавляем в список сессий пользователя
|
||||
commands.append(("sadd", (user_tokens_key, session_token)))
|
||||
commands.append(("expire", (user_tokens_key, ttl)))
|
||||
|
||||
await redis_adapter.execute_pipeline(commands)
|
||||
|
||||
logger.info(f"Создан токен сессии для пользователя {user_id}")
|
||||
return session_token
|
||||
|
||||
async def get_session_data(self, token: str, user_id: str | None = None) -> TokenData | None:
|
||||
"""Получение данных сессии"""
|
||||
if not user_id:
|
||||
# Извлекаем user_id из JWT
|
||||
payload = JWTCodec.decode(token)
|
||||
if payload:
|
||||
user_id = payload.get("user_id")
|
||||
else:
|
||||
return None
|
||||
|
||||
token_key = self._make_token_key("session", user_id, token)
|
||||
|
||||
# Используем новый метод execute_pipeline для избежания deprecated warnings
|
||||
commands: list[tuple[str, tuple[Any, ...]]] = [
|
||||
("hgetall", (token_key,)),
|
||||
("hset", (token_key, "last_activity", str(int(time.time())))),
|
||||
]
|
||||
results = await redis_adapter.execute_pipeline(commands)
|
||||
|
||||
token_data = results[0] if results else None
|
||||
return dict(token_data) if token_data else None
|
||||
|
||||
async def validate_session_token(self, token: str) -> tuple[bool, TokenData | None]:
|
||||
"""
|
||||
Проверяет валидность токена сессии
|
||||
"""
|
||||
try:
|
||||
# Декодируем JWT токен
|
||||
payload = JWTCodec.decode(token)
|
||||
if not payload:
|
||||
return False, None
|
||||
|
||||
user_id = payload.get("user_id")
|
||||
token_key = self._make_token_key("session", user_id, token)
|
||||
|
||||
# Проверяем существование и получаем данные
|
||||
commands: list[tuple[str, tuple[Any, ...]]] = [("exists", (token_key,)), ("hgetall", (token_key,))]
|
||||
results = await redis_adapter.execute_pipeline(commands)
|
||||
|
||||
if results and results[0]: # exists
|
||||
return True, dict(results[1])
|
||||
|
||||
return False, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка валидации токена сессии: {e}")
|
||||
return False, None
|
||||
|
||||
async def revoke_session_token(self, token: str) -> bool:
|
||||
"""Отзыв токена сессии"""
|
||||
payload = JWTCodec.decode(token)
|
||||
if not payload:
|
||||
return False
|
||||
|
||||
user_id = payload.get("user_id")
|
||||
|
||||
# Используем новый метод execute_pipeline для избежания deprecated warnings
|
||||
token_key = self._make_token_key("session", user_id, token)
|
||||
user_tokens_key = self._make_user_tokens_key(user_id, "session")
|
||||
|
||||
commands: list[tuple[str, tuple[Any, ...]]] = [("delete", (token_key,)), ("srem", (user_tokens_key, token))]
|
||||
results = await redis_adapter.execute_pipeline(commands)
|
||||
|
||||
return any(result > 0 for result in results if result is not None)
|
||||
|
||||
async def revoke_user_sessions(self, user_id: str) -> int:
|
||||
"""Отзыв всех сессий пользователя"""
|
||||
user_tokens_key = self._make_user_tokens_key(user_id, "session")
|
||||
tokens = await redis_adapter.smembers(user_tokens_key)
|
||||
|
||||
if not tokens:
|
||||
return 0
|
||||
|
||||
# Используем пакетное удаление
|
||||
keys_to_delete = []
|
||||
for token in tokens:
|
||||
token_str = token if isinstance(token, str) else str(token)
|
||||
keys_to_delete.append(self._make_token_key("session", user_id, token_str))
|
||||
|
||||
# Добавляем ключ списка токенов
|
||||
keys_to_delete.append(user_tokens_key)
|
||||
|
||||
# Удаляем все ключи пакетно
|
||||
if keys_to_delete:
|
||||
await redis_adapter.delete(*keys_to_delete)
|
||||
|
||||
return len(tokens)
|
||||
|
||||
async def get_user_sessions(self, user_id: int | str) -> List[TokenData]:
|
||||
"""Получение сессий пользователя"""
|
||||
try:
|
||||
user_tokens_key = self._make_user_tokens_key(str(user_id), "session")
|
||||
tokens = await redis_adapter.smembers(user_tokens_key)
|
||||
|
||||
if not tokens:
|
||||
return []
|
||||
|
||||
# Получаем данные всех сессий пакетно
|
||||
sessions = []
|
||||
async with redis_adapter.pipeline() as pipe:
|
||||
for token in tokens:
|
||||
token_str = token if isinstance(token, str) else str(token)
|
||||
await pipe.hgetall(self._make_token_key("session", str(user_id), token_str))
|
||||
results = await pipe.execute()
|
||||
|
||||
for token, session_data in zip(tokens, results, strict=False):
|
||||
if session_data:
|
||||
token_str = token if isinstance(token, str) else str(token)
|
||||
session_dict = dict(session_data)
|
||||
session_dict["token"] = token_str
|
||||
sessions.append(session_dict)
|
||||
|
||||
return sessions
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения сессий пользователя: {e}")
|
||||
return []
|
||||
|
||||
async def refresh_session(self, user_id: int, old_token: str, device_info: dict | None = None) -> str | None:
|
||||
"""
|
||||
Обновляет сессию пользователя, заменяя старый токен новым
|
||||
"""
|
||||
try:
|
||||
user_id_str = str(user_id)
|
||||
# Получаем данные старой сессии
|
||||
old_session_data = await self.get_session_data(old_token)
|
||||
|
||||
if not old_session_data:
|
||||
logger.warning(f"Сессия не найдена: {user_id}")
|
||||
return None
|
||||
|
||||
# Используем старые данные устройства, если новые не предоставлены
|
||||
if not device_info and "device_info" in old_session_data:
|
||||
try:
|
||||
device_info = json.loads(old_session_data.get("device_info", "{}"))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
device_info = None
|
||||
|
||||
# Создаем новую сессию
|
||||
new_token = await self.create_session(
|
||||
user_id_str, device_info=device_info, username=old_session_data.get("username", "")
|
||||
)
|
||||
|
||||
# Отзываем старую сессию
|
||||
await self.revoke_session_token(old_token)
|
||||
|
||||
return new_token
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления сессии: {e}")
|
||||
return None
|
||||
|
||||
async def verify_session(self, token: str) -> Any | None:
|
||||
"""
|
||||
Проверяет сессию по токену для совместимости с TokenStorage
|
||||
"""
|
||||
if not token:
|
||||
logger.debug("Пустой токен")
|
||||
return None
|
||||
|
||||
logger.debug(f"Проверка сессии для токена: {token[:20]}...")
|
||||
|
||||
try:
|
||||
# Декодируем токен для получения payload
|
||||
payload = JWTCodec.decode(token)
|
||||
if not payload:
|
||||
logger.error("Не удалось декодировать токен")
|
||||
return None
|
||||
|
||||
user_id = payload.get("user_id")
|
||||
if not user_id:
|
||||
logger.error("В токене отсутствует user_id")
|
||||
return None
|
||||
|
||||
logger.debug(f"Успешно декодирован токен, user_id={user_id}")
|
||||
|
||||
# Проверяем наличие сессии в Redis
|
||||
token_key = self._make_token_key("session", str(user_id), token)
|
||||
session_exists = await redis_adapter.exists(token_key)
|
||||
|
||||
if not session_exists:
|
||||
logger.warning(f"Сессия не найдена в Redis для user_id={user_id}")
|
||||
return None
|
||||
|
||||
# Обновляем last_activity
|
||||
await redis_adapter.hset(token_key, "last_activity", str(int(time.time())))
|
||||
|
||||
return payload
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при проверке сессии: {e}")
|
||||
return None
|
||||
@@ -1,114 +0,0 @@
|
||||
"""
|
||||
Простой интерфейс для системы токенов
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .batch import BatchTokenOperations
|
||||
from .monitoring import TokenMonitoring
|
||||
from .oauth import OAuthTokenManager
|
||||
from .sessions import SessionTokenManager
|
||||
from .verification import VerificationTokenManager
|
||||
|
||||
|
||||
class _TokenStorageImpl:
|
||||
"""
|
||||
Внутренний класс для фасада токенов.
|
||||
Использует композицию вместо наследования.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._sessions = SessionTokenManager()
|
||||
self._verification = VerificationTokenManager()
|
||||
self._oauth = OAuthTokenManager()
|
||||
self._batch = BatchTokenOperations()
|
||||
self._monitoring = TokenMonitoring()
|
||||
|
||||
# === МЕТОДЫ ДЛЯ СЕССИЙ ===
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
user_id: str,
|
||||
auth_data: dict | None = None,
|
||||
username: str | None = None,
|
||||
device_info: dict | None = None,
|
||||
) -> str:
|
||||
"""Создание сессии пользователя"""
|
||||
return await self._sessions.create_session(user_id, auth_data, username, device_info)
|
||||
|
||||
async def verify_session(self, token: str) -> Any | None:
|
||||
"""Проверка сессии по токену"""
|
||||
return await self._sessions.verify_session(token)
|
||||
|
||||
async def refresh_session(self, user_id: int, old_token: str, device_info: dict | None = None) -> str | None:
|
||||
"""Обновление сессии пользователя"""
|
||||
return await self._sessions.refresh_session(user_id, old_token, device_info)
|
||||
|
||||
async def revoke_session(self, session_token: str) -> bool:
|
||||
"""Отзыв сессии"""
|
||||
return await self._sessions.revoke_session_token(session_token)
|
||||
|
||||
async def revoke_user_sessions(self, user_id: str) -> int:
|
||||
"""Отзыв всех сессий пользователя"""
|
||||
return await self._sessions.revoke_user_sessions(user_id)
|
||||
|
||||
# === ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ===
|
||||
|
||||
async def cleanup_expired_tokens(self) -> int:
|
||||
"""Очистка истекших токенов"""
|
||||
return await self._batch.cleanup_expired_tokens()
|
||||
|
||||
async def get_token_statistics(self) -> dict:
|
||||
"""Получение статистики токенов"""
|
||||
return await self._monitoring.get_token_statistics()
|
||||
|
||||
|
||||
# Глобальный экземпляр фасада
|
||||
_token_storage = _TokenStorageImpl()
|
||||
|
||||
|
||||
class TokenStorage:
|
||||
"""
|
||||
Статический фасад для системы токенов.
|
||||
Все методы делегируются глобальному экземпляру.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def create_session(
|
||||
user_id: str,
|
||||
auth_data: dict | None = None,
|
||||
username: str | None = None,
|
||||
device_info: dict | None = None,
|
||||
) -> str:
|
||||
"""Создание сессии пользователя"""
|
||||
return await _token_storage.create_session(user_id, auth_data, username, device_info)
|
||||
|
||||
@staticmethod
|
||||
async def verify_session(token: str) -> Any | None:
|
||||
"""Проверка сессии по токену"""
|
||||
return await _token_storage.verify_session(token)
|
||||
|
||||
@staticmethod
|
||||
async def refresh_session(user_id: int, old_token: str, device_info: dict | None = None) -> str | None:
|
||||
"""Обновление сессии пользователя"""
|
||||
return await _token_storage.refresh_session(user_id, old_token, device_info)
|
||||
|
||||
@staticmethod
|
||||
async def revoke_session(session_token: str) -> bool:
|
||||
"""Отзыв сессии"""
|
||||
return await _token_storage.revoke_session(session_token)
|
||||
|
||||
@staticmethod
|
||||
async def revoke_user_sessions(user_id: str) -> int:
|
||||
"""Отзыв всех сессий пользователя"""
|
||||
return await _token_storage.revoke_user_sessions(user_id)
|
||||
|
||||
@staticmethod
|
||||
async def cleanup_expired_tokens() -> int:
|
||||
"""Очистка истекших токенов"""
|
||||
return await _token_storage.cleanup_expired_tokens()
|
||||
|
||||
@staticmethod
|
||||
async def get_token_statistics() -> dict:
|
||||
"""Получение статистики токенов"""
|
||||
return await _token_storage.get_token_statistics()
|
||||
@@ -1,23 +0,0 @@
|
||||
"""
|
||||
Типы и константы для системы токенов
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Literal
|
||||
|
||||
# Типы токенов
|
||||
TokenType = Literal["session", "verification", "oauth_access", "oauth_refresh"]
|
||||
|
||||
# TTL по умолчанию для разных типов токенов
|
||||
DEFAULT_TTL = {
|
||||
"session": 30 * 24 * 60 * 60, # 30 дней
|
||||
"verification": 3600, # 1 час
|
||||
"oauth_access": 3600, # 1 час
|
||||
"oauth_refresh": 86400 * 30, # 30 дней
|
||||
}
|
||||
|
||||
# Размеры батчей для оптимизации Redis операций
|
||||
BATCH_SIZE = 100 # Размер батча для пакетной обработки токенов
|
||||
SCAN_BATCH_SIZE = 1000 # Размер батча для SCAN операций
|
||||
|
||||
# Общие типы данных
|
||||
TokenData = Dict[str, Any]
|
||||
@@ -1,160 +0,0 @@
|
||||
"""
|
||||
Управление токенами подтверждения
|
||||
"""
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from storage.redis import redis as redis_adapter
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
from .base import BaseTokenManager
|
||||
from .types import TokenData
|
||||
|
||||
|
||||
class VerificationTokenManager(BaseTokenManager):
|
||||
"""
|
||||
Менеджер токенов подтверждения
|
||||
"""
|
||||
|
||||
async def create_verification_token(
|
||||
self,
|
||||
user_id: str,
|
||||
verification_type: str,
|
||||
data: TokenData,
|
||||
ttl: int | None = None,
|
||||
) -> str:
|
||||
"""Создает токен подтверждения"""
|
||||
token_data = {"verification_type": verification_type, **data}
|
||||
|
||||
# TTL по типу подтверждения
|
||||
if ttl is None:
|
||||
verification_ttls = {
|
||||
"email_change": 3600, # 1 час
|
||||
"phone_change": 600, # 10 минут
|
||||
"password_reset": 1800, # 30 минут
|
||||
}
|
||||
ttl = verification_ttls.get(verification_type, 3600)
|
||||
|
||||
return await self._create_verification_token(user_id, token_data, ttl)
|
||||
|
||||
async def _create_verification_token(
|
||||
self, user_id: str, token_data: TokenData, ttl: int, token: str | None = None
|
||||
) -> str:
|
||||
"""Оптимизированное создание токена подтверждения"""
|
||||
verification_token = token or secrets.token_urlsafe(32)
|
||||
token_key = self._make_token_key("verification", user_id, verification_token)
|
||||
|
||||
# Добавляем метаданные
|
||||
token_data.update({"user_id": user_id, "token_type": "verification", "created_at": int(time.time())})
|
||||
|
||||
# Отменяем предыдущие токены того же типа
|
||||
verification_type = token_data.get("verification_type", "unknown")
|
||||
await self._cancel_verification_tokens_optimized(user_id, verification_type)
|
||||
|
||||
# Используем SETEX для атомарной операции установки с TTL
|
||||
serialized_data = json.dumps(token_data, ensure_ascii=False)
|
||||
await redis_adapter.execute("setex", token_key, ttl, serialized_data)
|
||||
|
||||
logger.info(f"Создан токен подтверждения {verification_type} для пользователя {user_id}")
|
||||
return verification_token
|
||||
|
||||
async def get_verification_token_data(self, token: str) -> TokenData | None:
|
||||
"""Получает данные токена подтверждения"""
|
||||
token_key = self._make_token_key("verification", "", token)
|
||||
return await redis_adapter.get_and_deserialize(token_key)
|
||||
|
||||
async def validate_verification_token(self, token_str: str) -> tuple[bool, TokenData | None]:
|
||||
"""Проверяет валидность токена подтверждения"""
|
||||
token_key = self._make_token_key("verification", "", token_str)
|
||||
token_data = await redis_adapter.get_and_deserialize(token_key)
|
||||
if token_data:
|
||||
return True, token_data
|
||||
return False, None
|
||||
|
||||
async def confirm_verification_token(self, token_str: str) -> TokenData | None:
|
||||
"""Подтверждает и использует токен подтверждения (одноразовый)"""
|
||||
token_data = await self.get_verification_token_data(token_str)
|
||||
if token_data:
|
||||
# Удаляем токен после использования
|
||||
await self.revoke_verification_token(token_str)
|
||||
return token_data
|
||||
return None
|
||||
|
||||
async def revoke_verification_token(self, token: str) -> bool:
|
||||
"""Отзывает токен подтверждения"""
|
||||
token_key = self._make_token_key("verification", "", token)
|
||||
result = await redis_adapter.delete(token_key)
|
||||
return result > 0
|
||||
|
||||
async def revoke_user_verification_tokens(self, user_id: str) -> int:
|
||||
"""Оптимизированный отзыв токенов подтверждения пользователя используя SCAN вместо KEYS"""
|
||||
count = 0
|
||||
cursor = 0
|
||||
delete_keys = []
|
||||
|
||||
# Используем SCAN для безопасного поиска токенов
|
||||
while True:
|
||||
cursor, keys = await redis_adapter.execute("scan", cursor, "verification_token:*", 100)
|
||||
|
||||
# Проверяем каждый ключ в пакете
|
||||
if keys:
|
||||
async with redis_adapter.pipeline() as pipe:
|
||||
for key in keys:
|
||||
await pipe.get(key)
|
||||
results = await pipe.execute()
|
||||
|
||||
for key, data in zip(keys, results, strict=False):
|
||||
if data:
|
||||
try:
|
||||
token_data = json.loads(data)
|
||||
if token_data.get("user_id") == user_id:
|
||||
delete_keys.append(key)
|
||||
count += 1
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
# Удаляем найденные токены пакетно
|
||||
if delete_keys:
|
||||
await redis_adapter.delete(*delete_keys)
|
||||
|
||||
return count
|
||||
|
||||
async def _cancel_verification_tokens_optimized(self, user_id: str, verification_type: str) -> None:
|
||||
"""Оптимизированная отмена токенов подтверждения используя SCAN"""
|
||||
cursor = 0
|
||||
delete_keys = []
|
||||
|
||||
while True:
|
||||
cursor, keys = await redis_adapter.execute("scan", cursor, "verification_token:*", 100)
|
||||
|
||||
if keys:
|
||||
# Получаем данные пакетно
|
||||
async with redis_adapter.pipeline() as pipe:
|
||||
for key in keys:
|
||||
await pipe.get(key)
|
||||
results = await pipe.execute()
|
||||
|
||||
# Проверяем какие токены нужно удалить
|
||||
for key, data in zip(keys, results, strict=False):
|
||||
if data:
|
||||
try:
|
||||
token_data = json.loads(data)
|
||||
if (
|
||||
token_data.get("user_id") == user_id
|
||||
and token_data.get("verification_type") == verification_type
|
||||
):
|
||||
delete_keys.append(key)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
# Удаляем найденные токены пакетно
|
||||
if delete_keys:
|
||||
await redis_adapter.delete(*delete_keys)
|
||||
73
auth/tokenstorage.py
Normal file
73
auth/tokenstorage.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from auth.jwtcodec import JWTCodec
|
||||
from auth.validations import AuthInput
|
||||
from services.redis import redis
|
||||
from settings import ONETIME_TOKEN_LIFE_SPAN, SESSION_TOKEN_LIFE_SPAN
|
||||
|
||||
|
||||
async def save(token_key, life_span, auto_delete=True):
|
||||
await redis.execute("SET", token_key, "True")
|
||||
if auto_delete:
|
||||
expire_at = (datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)).timestamp()
|
||||
await redis.execute("EXPIREAT", token_key, int(expire_at))
|
||||
|
||||
|
||||
class SessionToken:
|
||||
@classmethod
|
||||
async def verify(cls, token: str):
|
||||
"""
|
||||
Rules for a token to be valid.
|
||||
- token format is legal
|
||||
- token exists in redis database
|
||||
- token is not expired
|
||||
"""
|
||||
try:
|
||||
return JWTCodec.decode(token)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
@classmethod
|
||||
async def get(cls, payload, token):
|
||||
return await TokenStorage.get(f"{payload.user_id}-{payload.username}-{token}")
|
||||
|
||||
|
||||
class TokenStorage:
|
||||
@staticmethod
|
||||
async def get(token_key):
|
||||
print("[tokenstorage.get] " + token_key)
|
||||
# 2041-user@domain.zn-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyMDQxLCJ1c2VybmFtZSI6ImFudG9uLnJld2luK3Rlc3QtbG9hZGNoYXRAZ21haWwuY29tIiwiZXhwIjoxNjcxNzgwNjE2LCJpYXQiOjE2NjkxODg2MTYsImlzcyI6ImRpc2NvdXJzIn0.Nml4oV6iMjMmc6xwM7lTKEZJKBXvJFEIZ-Up1C1rITQ
|
||||
return await redis.execute("GET", token_key)
|
||||
|
||||
@staticmethod
|
||||
async def create_onetime(user: AuthInput) -> str:
|
||||
life_span = ONETIME_TOKEN_LIFE_SPAN
|
||||
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
|
||||
one_time_token = JWTCodec.encode(user, exp)
|
||||
await save(f"{user.id}-{user.username}-{one_time_token}", life_span)
|
||||
return one_time_token
|
||||
|
||||
@staticmethod
|
||||
async def create_session(user: AuthInput) -> str:
|
||||
life_span = SESSION_TOKEN_LIFE_SPAN
|
||||
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
|
||||
session_token = JWTCodec.encode(user, exp)
|
||||
await save(f"{user.id}-{user.username}-{session_token}", life_span)
|
||||
return session_token
|
||||
|
||||
@staticmethod
|
||||
async def revoke(token: str) -> bool:
|
||||
payload = None
|
||||
try:
|
||||
print("[auth.tokenstorage] revoke token")
|
||||
payload = JWTCodec.decode(token)
|
||||
except: # noqa
|
||||
pass
|
||||
else:
|
||||
await redis.execute("DEL", f"{payload.user_id}-{payload.username}-{token}")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def revoke_all(user: AuthInput):
|
||||
tokens = await redis.execute("KEYS", f"{user.id}-*")
|
||||
await redis.execute("DEL", *tokens)
|
||||
119
auth/usermodel.py
Normal file
119
auth/usermodel.py
Normal file
@@ -0,0 +1,119 @@
|
||||
import time
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from services.db import Base
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
__tablename__ = "permission"
|
||||
|
||||
id = Column(String, primary_key=True, unique=True, nullable=False, default=None)
|
||||
resource = Column(String, nullable=False)
|
||||
operation = Column(String, nullable=False)
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "role"
|
||||
|
||||
id = Column(String, primary_key=True, unique=True, nullable=False, default=None)
|
||||
name = Column(String, nullable=False)
|
||||
permissions = relationship(Permission)
|
||||
|
||||
|
||||
class AuthorizerUser(Base):
|
||||
__tablename__ = "authorizer_users"
|
||||
|
||||
id = Column(String, primary_key=True, unique=True, nullable=False, default=None)
|
||||
key = Column(String)
|
||||
email = Column(String, unique=True)
|
||||
email_verified_at = Column(Integer)
|
||||
family_name = Column(String)
|
||||
gender = Column(String)
|
||||
given_name = Column(String)
|
||||
is_multi_factor_auth_enabled = Column(Boolean)
|
||||
middle_name = Column(String)
|
||||
nickname = Column(String)
|
||||
password = Column(String)
|
||||
phone_number = Column(String, unique=True)
|
||||
phone_number_verified_at = Column(Integer)
|
||||
# preferred_username = Column(String, nullable=False)
|
||||
picture = Column(String)
|
||||
revoked_timestamp = Column(Integer)
|
||||
roles = Column(String, default="author,reader")
|
||||
signup_methods = Column(String, default="magic_link_login")
|
||||
created_at = Column(Integer, default=lambda: int(time.time()))
|
||||
updated_at = Column(Integer, default=lambda: int(time.time()))
|
||||
|
||||
|
||||
class UserRating(Base):
|
||||
__tablename__ = "user_rating"
|
||||
|
||||
id = None
|
||||
rater: Column = Column(ForeignKey("user.id"), primary_key=True, index=True)
|
||||
user: Column = Column(ForeignKey("user.id"), primary_key=True, index=True)
|
||||
value: Column = Column(Integer)
|
||||
|
||||
@staticmethod
|
||||
def init_table():
|
||||
pass
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
__tablename__ = "user_role"
|
||||
|
||||
id = None
|
||||
user = Column(ForeignKey("user.id"), primary_key=True, index=True)
|
||||
role = Column(ForeignKey("role.id"), primary_key=True, index=True)
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "user"
|
||||
default_user = None
|
||||
|
||||
email = Column(String, unique=True, nullable=False, comment="Email")
|
||||
username = Column(String, nullable=False, comment="Login")
|
||||
password = Column(String, nullable=True, comment="Password")
|
||||
bio = Column(String, nullable=True, comment="Bio") # status description
|
||||
about = Column(String, nullable=True, comment="About") # long and formatted
|
||||
userpic = Column(String, nullable=True, comment="Userpic")
|
||||
name = Column(String, nullable=True, comment="Display name")
|
||||
slug = Column(String, unique=True, comment="User's slug")
|
||||
links = Column(JSON, nullable=True, comment="Links")
|
||||
oauth = Column(String, nullable=True)
|
||||
oid = Column(String, nullable=True)
|
||||
|
||||
muted = Column(Boolean, default=False)
|
||||
confirmed = Column(Boolean, default=False)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), comment="Created at")
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), comment="Updated at")
|
||||
last_seen = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), comment="Was online at")
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True, comment="Deleted at")
|
||||
|
||||
ratings = relationship(UserRating, foreign_keys=UserRating.user)
|
||||
roles = relationship(lambda: Role, secondary=UserRole.__tablename__)
|
||||
|
||||
def get_permission(self):
|
||||
scope = {}
|
||||
for role in self.roles:
|
||||
for p in role.permissions:
|
||||
if p.resource not in scope:
|
||||
scope[p.resource] = set()
|
||||
scope[p.resource].add(p.operation)
|
||||
print(scope)
|
||||
return scope
|
||||
|
||||
|
||||
# if __name__ == "__main__":
|
||||
# print(User.get_permission(user_id=1))
|
||||
294
auth/utils.py
294
auth/utils.py
@@ -1,294 +0,0 @@
|
||||
"""
|
||||
Вспомогательные функции для аутентификации
|
||||
Содержит функции для работы с токенами, заголовками и запросами
|
||||
"""
|
||||
|
||||
from typing import Any, Tuple
|
||||
|
||||
from settings import SESSION_COOKIE_NAME, SESSION_TOKEN_HEADER
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
|
||||
def get_safe_headers(request: Any) -> dict[str, str]:
|
||||
"""
|
||||
Безопасно получает заголовки запроса.
|
||||
|
||||
Args:
|
||||
request: Объект запроса
|
||||
|
||||
Returns:
|
||||
Dict[str, str]: Словарь заголовков
|
||||
"""
|
||||
headers = {}
|
||||
try:
|
||||
# Первый приоритет: scope из ASGI (самый надежный источник)
|
||||
if hasattr(request, "scope") and isinstance(request.scope, dict):
|
||||
scope_headers = request.scope.get("headers", [])
|
||||
if scope_headers:
|
||||
headers.update({k.decode("utf-8").lower(): v.decode("utf-8") for k, v in scope_headers})
|
||||
logger.debug(f"[decorators] Получены заголовки из request.scope: {len(headers)}")
|
||||
logger.debug(f"[decorators] Заголовки из request.scope: {list(headers.keys())}")
|
||||
|
||||
# Второй приоритет: метод headers() или атрибут headers
|
||||
if hasattr(request, "headers"):
|
||||
if callable(request.headers):
|
||||
h = request.headers()
|
||||
if h:
|
||||
headers.update({k.lower(): v for k, v in h.items()})
|
||||
logger.debug(f"[decorators] Получены заголовки из request.headers() метода: {len(headers)}")
|
||||
else:
|
||||
h = request.headers
|
||||
if hasattr(h, "items") and callable(h.items):
|
||||
headers.update({k.lower(): v for k, v in h.items()})
|
||||
logger.debug(f"[decorators] Получены заголовки из request.headers атрибута: {len(headers)}")
|
||||
elif isinstance(h, dict):
|
||||
headers.update({k.lower(): v for k, v in h.items()})
|
||||
logger.debug(f"[decorators] Получены заголовки из request.headers словаря: {len(headers)}")
|
||||
|
||||
# Третий приоритет: атрибут _headers
|
||||
if hasattr(request, "_headers") and request._headers:
|
||||
headers.update({k.lower(): v for k, v in request._headers.items()})
|
||||
logger.debug(f"[decorators] Получены заголовки из request._headers: {len(headers)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[decorators] Ошибка при доступе к заголовкам: {e}")
|
||||
|
||||
return headers
|
||||
|
||||
|
||||
async def extract_token_from_request(request) -> str | None:
|
||||
"""
|
||||
DRY функция для извлечения токена из request.
|
||||
Проверяет cookies и заголовок Authorization.
|
||||
|
||||
Args:
|
||||
request: Request объект
|
||||
|
||||
Returns:
|
||||
Optional[str]: Токен или None
|
||||
"""
|
||||
if not request:
|
||||
return None
|
||||
|
||||
# 1. Проверяем cookies
|
||||
if hasattr(request, "cookies") and request.cookies:
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
if token:
|
||||
logger.debug(f"[utils] Токен получен из cookie {SESSION_COOKIE_NAME}")
|
||||
return token
|
||||
|
||||
# 2. Проверяем заголовок Authorization
|
||||
headers = get_safe_headers(request)
|
||||
auth_header = headers.get("authorization", "")
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:].strip()
|
||||
logger.debug("[utils] Токен получен из заголовка Authorization")
|
||||
return token
|
||||
|
||||
logger.debug("[utils] Токен не найден ни в cookies, ни в заголовке")
|
||||
return None
|
||||
|
||||
|
||||
async def get_user_data_by_token(token: str) -> Tuple[bool, dict | None, str | None]:
|
||||
"""
|
||||
Получает данные пользователя по токену.
|
||||
|
||||
Args:
|
||||
token: Токен авторизации
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[dict], Optional[str]]: (success, user_data, error_message)
|
||||
"""
|
||||
try:
|
||||
from auth.tokens.storage import TokenStorage as TokenManager
|
||||
from orm.author import Author
|
||||
from storage.db import local_session
|
||||
|
||||
# Проверяем сессию через TokenManager
|
||||
payload = await TokenManager.verify_session(token)
|
||||
|
||||
if not payload:
|
||||
return False, None, "Сессия не найдена"
|
||||
|
||||
# Получаем user_id из payload
|
||||
user_id = payload.user_id if hasattr(payload, "user_id") else payload.get("user_id")
|
||||
|
||||
if not user_id:
|
||||
return False, None, "Токен не содержит user_id"
|
||||
|
||||
# Получаем данные пользователя
|
||||
with local_session() as session:
|
||||
author_obj = session.query(Author).where(Author.id == int(user_id)).first()
|
||||
if not author_obj:
|
||||
return False, None, f"Пользователь с ID {user_id} не найден в БД"
|
||||
|
||||
try:
|
||||
user_data = author_obj.dict()
|
||||
except Exception:
|
||||
user_data = {
|
||||
"id": author_obj.id,
|
||||
"email": author_obj.email,
|
||||
"name": getattr(author_obj, "name", ""),
|
||||
"slug": getattr(author_obj, "slug", ""),
|
||||
}
|
||||
|
||||
logger.debug(f"[utils] Данные пользователя получены для ID {user_id}")
|
||||
return True, user_data, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[utils] Ошибка при получении данных пользователя: {e}")
|
||||
return False, None, f"Ошибка получения данных: {e!s}"
|
||||
|
||||
|
||||
async def get_auth_token_from_context(info: Any) -> str | None:
|
||||
"""
|
||||
Извлекает токен авторизации из GraphQL контекста.
|
||||
Порядок проверки:
|
||||
1. Проверяет заголовок Authorization
|
||||
2. Проверяет cookie session_token
|
||||
3. Переиспользует логику get_auth_token для request
|
||||
|
||||
Args:
|
||||
info: GraphQLResolveInfo объект
|
||||
|
||||
Returns:
|
||||
Optional[str]: Токен авторизации или None
|
||||
"""
|
||||
try:
|
||||
context = getattr(info, "context", {})
|
||||
request = context.get("request")
|
||||
|
||||
if request:
|
||||
# Переиспользуем существующую логику для request
|
||||
return await get_auth_token(request)
|
||||
|
||||
# Если request отсутствует, возвращаем None
|
||||
logger.debug("[utils] Request отсутствует в GraphQL контексте")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[utils] Ошибка при извлечении токена из GraphQL контекста: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_auth_token(request: Any) -> str | None:
|
||||
"""
|
||||
Извлекает токен авторизации из запроса.
|
||||
Порядок проверки:
|
||||
1. Проверяет auth из middleware
|
||||
2. Проверяет auth из scope
|
||||
3. Проверяет заголовок Authorization
|
||||
4. Проверяет cookie с именем auth_token
|
||||
|
||||
Args:
|
||||
request: Объект запроса
|
||||
|
||||
Returns:
|
||||
Optional[str]: Токен авторизации или None
|
||||
"""
|
||||
try:
|
||||
# 1. Проверяем auth из middleware (если middleware уже обработал токен)
|
||||
if hasattr(request, "auth") and request.auth:
|
||||
token = getattr(request.auth, "token", None)
|
||||
if token:
|
||||
token_len = len(token) if hasattr(token, "__len__") else "unknown"
|
||||
logger.debug(f"[decorators] Токен получен из request.auth: {token_len}")
|
||||
return token
|
||||
logger.debug("[decorators] request.auth есть, но token НЕ найден")
|
||||
else:
|
||||
logger.debug("[decorators] request.auth НЕ найден")
|
||||
|
||||
# 2. Проверяем наличие auth_token в scope (приоритет)
|
||||
if hasattr(request, "scope") and isinstance(request.scope, dict) and "auth_token" in request.scope:
|
||||
token = request.scope.get("auth_token")
|
||||
if token is not None:
|
||||
token_len = len(token) if hasattr(token, "__len__") else "unknown"
|
||||
logger.debug(f"[decorators] Токен получен из scope.auth_token: {token_len}")
|
||||
return token
|
||||
|
||||
# 3. Получаем заголовки запроса безопасным способом
|
||||
headers = get_safe_headers(request)
|
||||
logger.debug(f"[decorators] Получены заголовки: {list(headers.keys())}")
|
||||
|
||||
# 4. Проверяем кастомный заголовок авторизации
|
||||
auth_header_key = SESSION_TOKEN_HEADER.lower()
|
||||
if auth_header_key in headers:
|
||||
token = headers[auth_header_key]
|
||||
logger.debug(f"[decorators] Токен найден в заголовке {SESSION_TOKEN_HEADER}")
|
||||
# Убираем префикс Bearer если есть
|
||||
if token.startswith("Bearer "):
|
||||
token = token.replace("Bearer ", "", 1).strip()
|
||||
logger.debug(f"[decorators] Обработанный токен: {len(token)}")
|
||||
return token
|
||||
|
||||
# 5. Проверяем стандартный заголовок Authorization
|
||||
if "authorization" in headers:
|
||||
auth_header = headers["authorization"]
|
||||
logger.debug(f"[decorators] Найден заголовок Authorization: {auth_header[:20]}...")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header.replace("Bearer ", "", 1).strip()
|
||||
logger.debug(f"[decorators] Извлечен Bearer токен: {len(token)}")
|
||||
return token
|
||||
logger.debug("[decorators] Authorization заголовок не содержит Bearer токен")
|
||||
|
||||
# 6. Проверяем cookies
|
||||
if hasattr(request, "cookies") and request.cookies:
|
||||
if isinstance(request.cookies, dict):
|
||||
cookies = request.cookies
|
||||
elif hasattr(request.cookies, "get"):
|
||||
cookies = {k: request.cookies.get(k) for k in getattr(request.cookies, "keys", list)()}
|
||||
else:
|
||||
cookies = {}
|
||||
|
||||
logger.debug(f"[decorators] Доступные cookies: {list(cookies.keys())}")
|
||||
|
||||
# Проверяем кастомную cookie
|
||||
if SESSION_COOKIE_NAME in cookies:
|
||||
token = cookies[SESSION_COOKIE_NAME]
|
||||
logger.debug(f"[decorators] Токен найден в cookie {SESSION_COOKIE_NAME}: {len(token)}")
|
||||
return token
|
||||
|
||||
# Проверяем стандартную cookie
|
||||
if "auth_token" in cookies:
|
||||
token = cookies["auth_token"]
|
||||
logger.debug(f"[decorators] Токен найден в cookie auth_token: {len(token)}")
|
||||
return token
|
||||
|
||||
logger.debug("[decorators] Токен НЕ найден ни в одном источнике")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[decorators] Критическая ошибка при извлечении токена: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def extract_bearer_token(auth_header: str) -> str | None:
|
||||
"""
|
||||
Извлекает токен из заголовка Authorization с Bearer схемой.
|
||||
|
||||
Args:
|
||||
auth_header: Заголовок Authorization
|
||||
|
||||
Returns:
|
||||
Optional[str]: Извлеченный токен или None
|
||||
"""
|
||||
if not auth_header:
|
||||
return None
|
||||
|
||||
if auth_header.startswith("Bearer "):
|
||||
return auth_header[7:].strip()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def format_auth_header(token: str) -> str:
|
||||
"""
|
||||
Форматирует токен в заголовок Authorization.
|
||||
|
||||
Args:
|
||||
token: Токен авторизации
|
||||
|
||||
Returns:
|
||||
str: Отформатированный заголовок
|
||||
"""
|
||||
return f"Bearer {token}"
|
||||
@@ -1,5 +1,6 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
@@ -18,8 +19,7 @@ class AuthInput(BaseModel):
|
||||
@classmethod
|
||||
def validate_user_id(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
msg = "user_id cannot be empty"
|
||||
raise ValueError(msg)
|
||||
raise ValueError("user_id cannot be empty")
|
||||
return v
|
||||
|
||||
|
||||
@@ -35,8 +35,7 @@ class UserRegistrationInput(BaseModel):
|
||||
def validate_email(cls, v: str) -> str:
|
||||
"""Validate email format"""
|
||||
if not re.match(EMAIL_PATTERN, v):
|
||||
msg = "Invalid email format"
|
||||
raise ValueError(msg)
|
||||
raise ValueError("Invalid email format")
|
||||
return v.lower()
|
||||
|
||||
@field_validator("password")
|
||||
@@ -44,17 +43,13 @@ class UserRegistrationInput(BaseModel):
|
||||
def validate_password_strength(cls, v: str) -> str:
|
||||
"""Validate password meets security requirements"""
|
||||
if not any(c.isupper() for c in v):
|
||||
msg = "Password must contain at least one uppercase letter"
|
||||
raise ValueError(msg)
|
||||
raise ValueError("Password must contain at least one uppercase letter")
|
||||
if not any(c.islower() for c in v):
|
||||
msg = "Password must contain at least one lowercase letter"
|
||||
raise ValueError(msg)
|
||||
raise ValueError("Password must contain at least one lowercase letter")
|
||||
if not any(c.isdigit() for c in v):
|
||||
msg = "Password must contain at least one number"
|
||||
raise ValueError(msg)
|
||||
raise ValueError("Password must contain at least one number")
|
||||
if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in v):
|
||||
msg = "Password must contain at least one special character"
|
||||
raise ValueError(msg)
|
||||
raise ValueError("Password must contain at least one special character")
|
||||
return v
|
||||
|
||||
|
||||
@@ -68,8 +63,7 @@ class UserLoginInput(BaseModel):
|
||||
@classmethod
|
||||
def validate_email(cls, v: str) -> str:
|
||||
if not re.match(EMAIL_PATTERN, v):
|
||||
msg = "Invalid email format"
|
||||
raise ValueError(msg)
|
||||
raise ValueError("Invalid email format")
|
||||
return v.lower()
|
||||
|
||||
|
||||
@@ -80,7 +74,7 @@ class TokenPayload(BaseModel):
|
||||
username: str
|
||||
exp: datetime
|
||||
iat: datetime
|
||||
scopes: list[str] | None = []
|
||||
scopes: Optional[List[str]] = []
|
||||
|
||||
|
||||
class OAuthInput(BaseModel):
|
||||
@@ -88,15 +82,14 @@ class OAuthInput(BaseModel):
|
||||
|
||||
provider: str = Field(pattern="^(google|github|facebook)$")
|
||||
code: str
|
||||
redirect_uri: str | None = None
|
||||
redirect_uri: Optional[str] = None
|
||||
|
||||
@field_validator("provider")
|
||||
@classmethod
|
||||
def validate_provider(cls, v: str) -> str:
|
||||
valid_providers = ["google", "github", "facebook"]
|
||||
if v.lower() not in valid_providers:
|
||||
msg = f"Provider must be one of: {', '.join(valid_providers)}"
|
||||
raise ValueError(msg)
|
||||
raise ValueError(f"Provider must be one of: {', '.join(valid_providers)}")
|
||||
return v.lower()
|
||||
|
||||
|
||||
@@ -104,22 +97,20 @@ class AuthResponse(BaseModel):
|
||||
"""Validation model for authentication responses"""
|
||||
|
||||
success: bool
|
||||
token: str | None = None
|
||||
error: str | None = None
|
||||
user: dict[str, str | int | bool] | None = None
|
||||
token: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
user: Optional[Dict[str, Union[str, int, bool]]] = None
|
||||
|
||||
@field_validator("error")
|
||||
@classmethod
|
||||
def validate_error_if_not_success(cls, v: str | None, info) -> str | None:
|
||||
def validate_error_if_not_success(cls, v: Optional[str], info) -> Optional[str]:
|
||||
if not info.data.get("success") and not v:
|
||||
msg = "Error message required when success is False"
|
||||
raise ValueError(msg)
|
||||
raise ValueError("Error message required when success is False")
|
||||
return v
|
||||
|
||||
@field_validator("token")
|
||||
@classmethod
|
||||
def validate_token_if_success(cls, v: str | None, info) -> str | None:
|
||||
def validate_token_if_success(cls, v: Optional[str], info) -> Optional[str]:
|
||||
if info.data.get("success") and not v:
|
||||
msg = "Token required when success is True"
|
||||
raise ValueError(msg)
|
||||
raise ValueError("Token required when success is True")
|
||||
return v
|
||||
|
||||
109
biome.json
109
biome.json
@@ -1,109 +0,0 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
|
||||
"files": {
|
||||
"includes": [
|
||||
"**/*.tsx",
|
||||
"**/*.ts",
|
||||
"**/*.js",
|
||||
"**/*.json",
|
||||
"!dist",
|
||||
"!node_modules",
|
||||
"!**/.husky",
|
||||
"!**/docs",
|
||||
"!**/gen",
|
||||
"!**/*.gen.ts",
|
||||
"!**/*.d.ts"
|
||||
]
|
||||
},
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"defaultBranch": "dev",
|
||||
"useIgnoreFile": true,
|
||||
"clientKind": "git"
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 108,
|
||||
"includes": ["**", "!panel/graphql/generated"]
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"semicolons": "asNeeded",
|
||||
"quoteStyle": "single",
|
||||
"jsxQuoteStyle": "double",
|
||||
"arrowParentheses": "always",
|
||||
"trailingCommas": "none"
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"includes": ["**", "!**/*.scss", "!**/*.md", "!**/.DS_Store", "!**/*.svg", "!**/*.d.ts"],
|
||||
"rules": {
|
||||
"complexity": {
|
||||
"noForEach": "off",
|
||||
"noUselessFragments": "off",
|
||||
"useOptionalChain": "warn",
|
||||
"useLiteralKeys": "off",
|
||||
"noExcessiveCognitiveComplexity": "off",
|
||||
"useSimplifiedLogicExpression": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"useHookAtTopLevel": "off",
|
||||
"useImportExtensions": "off",
|
||||
"noUndeclaredDependencies": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"useHeadingContent": "off",
|
||||
"useKeyWithClickEvents": "off",
|
||||
"useKeyWithMouseEvents": "off",
|
||||
"useAnchorContent": "off",
|
||||
"useValidAnchor": "off",
|
||||
"useMediaCaption": "off",
|
||||
"useAltText": "off",
|
||||
"useButtonType": "off",
|
||||
"noRedundantAlt": "off",
|
||||
"noStaticElementInteractions": "off",
|
||||
"noSvgWithoutTitle": "off",
|
||||
"noLabelWithoutControl": "off"
|
||||
},
|
||||
"performance": {
|
||||
"noBarrelFile": "off",
|
||||
"noNamespaceImport": "warn"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off",
|
||||
"noUselessElse": "off",
|
||||
"useBlockStatements": "off",
|
||||
"noImplicitBoolean": "off",
|
||||
"useNamingConvention": "off",
|
||||
"useImportType": "off",
|
||||
"noDefaultExport": "off",
|
||||
"useFilenamingConvention": "off",
|
||||
"useExplicitLengthCheck": "off",
|
||||
"noParameterAssign": "error",
|
||||
"useAsConstAssertion": "error",
|
||||
"useDefaultParameterLast": "error",
|
||||
"useEnumInitializers": "error",
|
||||
"useSelfClosingElements": "error",
|
||||
"useSingleVarDeclarator": "error",
|
||||
"noUnusedTemplateLiteral": "error",
|
||||
"useNumberNamespace": "error",
|
||||
"noInferrableTypes": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noConsole": "off",
|
||||
"noAssignInExpressions": "off",
|
||||
"useAwait": "off",
|
||||
"noEmptyBlockStatements": "off"
|
||||
},
|
||||
"nursery": {
|
||||
"noFloatingPromises": "warn",
|
||||
"noImportCycles": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
0
cache/__init__.py
vendored
0
cache/__init__.py
vendored
571
cache/cache.py
vendored
571
cache/cache.py
vendored
@@ -5,22 +5,22 @@ Caching system for the Discours platform
|
||||
This module provides a comprehensive caching solution with these key components:
|
||||
|
||||
1. KEY NAMING CONVENTIONS:
|
||||
- Entity-based keys: "entity:property:value" (e.g., "author:id:123")
|
||||
- Collection keys: "entity:collection:params" (e.g., "authors:stats:limit=10:offset=0")
|
||||
- Special case keys: Maintained for backwards compatibility (e.g., "topic_shouts_123")
|
||||
- Entity-based keys: "entity:property:value" (e.g., "author:id:123")
|
||||
- Collection keys: "entity:collection:params" (e.g., "authors:stats:limit=10:offset=0")
|
||||
- Special case keys: Maintained for backwards compatibility (e.g., "topic_shouts_123")
|
||||
|
||||
2. CORE FUNCTIONS:
|
||||
ery(): High-level function for retrieving cached data or executing queries
|
||||
- cached_query(): High-level function for retrieving cached data or executing queries
|
||||
|
||||
3. ENTITY-SPECIFIC FUNCTIONS:
|
||||
- cache_author(), cache_topic(): Cache entity data
|
||||
- get_cached_author(), get_cached_topic(): Retrieve entity data from cache
|
||||
- invalidate_cache_by_prefix(): Invalidate all keys with a specific prefix
|
||||
- cache_author(), cache_topic(): Cache entity data
|
||||
- get_cached_author(), get_cached_topic(): Retrieve entity data from cache
|
||||
- invalidate_cache_by_prefix(): Invalidate all keys with a specific prefix
|
||||
|
||||
4. CACHE INVALIDATION STRATEGY:
|
||||
- Direct invalidation via invalidate_* functions for immediate changes
|
||||
- Delayed invalidation via revalidation_manager for background processing
|
||||
- Event-based triggers for automatic cache updates (see triggers.py)
|
||||
- Direct invalidation via invalidate_* functions for immediate changes
|
||||
- Delayed invalidation via revalidation_manager for background processing
|
||||
- Event-based triggers for automatic cache updates (see triggers.py)
|
||||
|
||||
To maintain consistency with the existing codebase, this module preserves
|
||||
the original key naming patterns while providing a more structured approach
|
||||
@@ -29,8 +29,7 @@ for new cache operations.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import traceback
|
||||
from typing import Any, Callable, Dict, List, Type
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import orjson
|
||||
from sqlalchemy import and_, join, select
|
||||
@@ -38,9 +37,9 @@ from sqlalchemy import and_, join, select
|
||||
from orm.author import Author, AuthorFollower
|
||||
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
||||
from orm.topic import Topic, TopicFollower
|
||||
from storage.db import local_session
|
||||
from storage.redis import redis
|
||||
from utils.encoders import fast_json_dumps
|
||||
from services.db import local_session
|
||||
from services.redis import redis
|
||||
from utils.encoders import CustomJSONEncoder
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
DEFAULT_FOLLOWS = {
|
||||
@@ -61,16 +60,14 @@ CACHE_KEYS = {
|
||||
"TOPIC_FOLLOWERS": "topic:followers:{}",
|
||||
"TOPIC_SHOUTS": "topic_shouts_{}",
|
||||
"AUTHOR_ID": "author:id:{}",
|
||||
"AUTHOR_USER": "author:user:{}",
|
||||
"SHOUTS": "shouts:{}",
|
||||
}
|
||||
|
||||
# Type alias for JSON encoder
|
||||
JSONEncoderType = Type[json.JSONEncoder]
|
||||
|
||||
|
||||
# Cache topic data
|
||||
async def cache_topic(topic: dict) -> None:
|
||||
payload = fast_json_dumps(topic)
|
||||
async def cache_topic(topic: dict):
|
||||
payload = json.dumps(topic, cls=CustomJSONEncoder)
|
||||
await asyncio.gather(
|
||||
redis.execute("SET", f"topic:id:{topic['id']}", payload),
|
||||
redis.execute("SET", f"topic:slug:{topic['slug']}", payload),
|
||||
@@ -78,109 +75,56 @@ async def cache_topic(topic: dict) -> None:
|
||||
|
||||
|
||||
# Cache author data
|
||||
async def cache_author(author: dict) -> None:
|
||||
try:
|
||||
# logger.debug(f"Caching author {author.get('id', 'unknown')} with slug: {author.get('slug', 'unknown')}")
|
||||
payload = fast_json_dumps(author)
|
||||
# logger.debug(f"Author payload size: {len(payload)} bytes")
|
||||
|
||||
await asyncio.gather(
|
||||
redis.execute("SET", f"author:slug:{author['slug'].strip()}", str(author["id"])),
|
||||
redis.execute("SET", f"author:id:{author['id']}", payload),
|
||||
)
|
||||
# logger.debug(f"Successfully cached author {author.get('id', 'unknown')}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error caching author: {e}")
|
||||
logger.error(f"Author data: {author}")
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
raise
|
||||
async def cache_author(author: dict):
|
||||
payload = json.dumps(author, cls=CustomJSONEncoder)
|
||||
await asyncio.gather(
|
||||
redis.execute("SET", f"author:user:{author['user'].strip()}", str(author["id"])),
|
||||
redis.execute("SET", f"author:id:{author['id']}", payload),
|
||||
)
|
||||
|
||||
|
||||
# Cache follows data
|
||||
async def cache_follows(follower_id: int, entity_type: str, entity_id: int, is_insert: bool = True) -> None:
|
||||
async def cache_follows(follower_id: int, entity_type: str, entity_id: int, is_insert=True):
|
||||
key = f"author:follows-{entity_type}s:{follower_id}"
|
||||
follows_str = await redis.execute("GET", key)
|
||||
|
||||
if follows_str:
|
||||
follows = orjson.loads(follows_str)
|
||||
# Для большинства типов используем пустой список ID, кроме communities
|
||||
elif entity_type == "community":
|
||||
follows = DEFAULT_FOLLOWS.get("communities", [])
|
||||
else:
|
||||
follows = []
|
||||
|
||||
follows = orjson.loads(follows_str) if follows_str else DEFAULT_FOLLOWS[entity_type]
|
||||
if is_insert:
|
||||
if entity_id not in follows:
|
||||
follows.append(entity_id)
|
||||
else:
|
||||
follows = [eid for eid in follows if eid != entity_id]
|
||||
await redis.execute("SET", key, fast_json_dumps(follows))
|
||||
await redis.execute("SET", key, json.dumps(follows, cls=CustomJSONEncoder))
|
||||
await update_follower_stat(follower_id, entity_type, len(follows))
|
||||
|
||||
|
||||
# Update follower statistics
|
||||
async def update_follower_stat(follower_id: int, entity_type: str, count: int) -> None:
|
||||
try:
|
||||
logger.debug(f"Updating follower stat for author {follower_id}, entity_type: {entity_type}, count: {count}")
|
||||
follower_key = f"author:id:{follower_id}"
|
||||
follower_str = await redis.execute("GET", follower_key)
|
||||
follower = orjson.loads(follower_str) if follower_str else None
|
||||
if follower:
|
||||
follower["stat"] = {f"{entity_type}s": count}
|
||||
logger.debug(f"Updating follower {follower_id} with new stat: {follower['stat']}")
|
||||
await cache_author(follower)
|
||||
else:
|
||||
logger.warning(f"Follower {follower_id} not found in cache for stat update")
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating follower stat: {e}")
|
||||
logger.error(f"follower_id: {follower_id}, entity_type: {entity_type}, count: {count}")
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
raise
|
||||
async def update_follower_stat(follower_id, entity_type, count):
|
||||
follower_key = f"author:id:{follower_id}"
|
||||
follower_str = await redis.execute("GET", follower_key)
|
||||
follower = orjson.loads(follower_str) if follower_str else None
|
||||
if follower:
|
||||
follower["stat"] = {f"{entity_type}s": count}
|
||||
await cache_author(follower)
|
||||
|
||||
|
||||
# Get author from cache
|
||||
async def get_cached_author(author_id: int, get_with_stat=None) -> dict | None:
|
||||
logger.debug(f"[get_cached_author] Начало выполнения для author_id: {author_id}")
|
||||
|
||||
async def get_cached_author(author_id: int, get_with_stat):
|
||||
author_key = f"author:id:{author_id}"
|
||||
logger.debug(f"[get_cached_author] Проверка кэша по ключу: {author_key}")
|
||||
|
||||
result = await redis.execute("GET", author_key)
|
||||
if result:
|
||||
logger.debug(f"[get_cached_author] Найдены данные в кэше, размер: {len(result)} байт")
|
||||
cached_data = orjson.loads(result)
|
||||
logger.debug(
|
||||
f"[get_cached_author] Кэшированные данные имеют ключи: {list(cached_data.keys()) if cached_data else 'None'}"
|
||||
)
|
||||
return cached_data
|
||||
|
||||
logger.debug("[get_cached_author] Данные не найдены в кэше, загрузка из БД")
|
||||
|
||||
return orjson.loads(result)
|
||||
# Load from database if not found in cache
|
||||
q = select(Author).where(Author.id == author_id)
|
||||
authors = get_with_stat(q)
|
||||
logger.debug(f"[get_cached_author] Результат запроса из БД: {len(authors) if authors else 0} записей")
|
||||
|
||||
if authors:
|
||||
author = authors[0]
|
||||
logger.debug(f"[get_cached_author] Получен автор из БД: {type(author)}, id: {getattr(author, 'id', 'N/A')}")
|
||||
|
||||
# Используем безопасный вызов dict() для Author
|
||||
author_dict = author.dict() if hasattr(author, "dict") else author.__dict__
|
||||
logger.debug(
|
||||
f"[get_cached_author] Сериализованные данные автора: {list(author_dict.keys()) if author_dict else 'None'}"
|
||||
)
|
||||
|
||||
await cache_author(author_dict)
|
||||
logger.debug("[get_cached_author] Автор кэширован")
|
||||
|
||||
return author_dict
|
||||
|
||||
logger.warning(f"[get_cached_author] Автор с ID {author_id} не найден в БД")
|
||||
await cache_author(author.dict())
|
||||
return author.dict()
|
||||
return None
|
||||
|
||||
|
||||
# Function to get cached topic
|
||||
async def get_cached_topic(topic_id: int) -> dict | None:
|
||||
async def get_cached_topic(topic_id: int):
|
||||
"""
|
||||
Fetch topic data from cache or database by id.
|
||||
|
||||
@@ -200,22 +144,19 @@ async def get_cached_topic(topic_id: int) -> dict | None:
|
||||
topic = session.execute(select(Topic).where(Topic.id == topic_id)).scalar_one_or_none()
|
||||
if topic:
|
||||
topic_dict = topic.dict()
|
||||
await redis.execute("SET", topic_key, fast_json_dumps(topic_dict))
|
||||
await redis.execute("SET", topic_key, json.dumps(topic_dict, cls=CustomJSONEncoder))
|
||||
return topic_dict
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Get topic by slug from cache
|
||||
async def get_cached_topic_by_slug(slug: str, get_with_stat=None) -> dict | None:
|
||||
async def get_cached_topic_by_slug(slug: str, get_with_stat):
|
||||
topic_key = f"topic:slug:{slug}"
|
||||
result = await redis.execute("GET", topic_key)
|
||||
if result:
|
||||
return orjson.loads(result)
|
||||
# Load from database if not found in cache
|
||||
if get_with_stat is None:
|
||||
pass # get_with_stat уже импортирован на верхнем уровне
|
||||
|
||||
topic_query = select(Topic).where(Topic.slug == slug)
|
||||
topics = get_with_stat(topic_query)
|
||||
if topics:
|
||||
@@ -226,7 +167,7 @@ async def get_cached_topic_by_slug(slug: str, get_with_stat=None) -> dict | None
|
||||
|
||||
|
||||
# Get list of authors by ID from cache
|
||||
async def get_cached_authors_by_ids(author_ids: list[int]) -> list[dict]:
|
||||
async def get_cached_authors_by_ids(author_ids: List[int]) -> List[dict]:
|
||||
# Fetch all author data concurrently
|
||||
keys = [f"author:id:{author_id}" for author_id in author_ids]
|
||||
results = await asyncio.gather(*(redis.execute("GET", key) for key in keys))
|
||||
@@ -235,14 +176,13 @@ async def get_cached_authors_by_ids(author_ids: list[int]) -> list[dict]:
|
||||
missing_indices = [index for index, author in enumerate(authors) if author is None]
|
||||
if missing_indices:
|
||||
missing_ids = [author_ids[index] for index in missing_indices]
|
||||
query = select(Author).where(Author.id.in_(missing_ids))
|
||||
with local_session() as session:
|
||||
missing_authors = session.execute(query).scalars().unique().all()
|
||||
query = select(Author).where(Author.id.in_(missing_ids))
|
||||
missing_authors = session.execute(query).scalars().all()
|
||||
await asyncio.gather(*(cache_author(author.dict()) for author in missing_authors))
|
||||
for index, author in zip(missing_indices, missing_authors, strict=False):
|
||||
for index, author in zip(missing_indices, missing_authors):
|
||||
authors[index] = author.dict()
|
||||
# Фильтруем None значения для корректного типа возвращаемого значения
|
||||
return [author for author in authors if author is not None]
|
||||
return authors
|
||||
|
||||
|
||||
async def get_cached_topic_followers(topic_id: int):
|
||||
@@ -269,17 +209,17 @@ async def get_cached_topic_followers(topic_id: int):
|
||||
f[0]
|
||||
for f in session.query(Author.id)
|
||||
.join(TopicFollower, TopicFollower.follower == Author.id)
|
||||
.where(TopicFollower.topic == topic_id)
|
||||
.filter(TopicFollower.topic == topic_id)
|
||||
.all()
|
||||
]
|
||||
|
||||
await redis.execute("SETEX", cache_key, CACHE_TTL, fast_json_dumps(followers_ids))
|
||||
await redis.execute("SETEX", cache_key, CACHE_TTL, orjson.dumps(followers_ids))
|
||||
followers = await get_cached_authors_by_ids(followers_ids)
|
||||
logger.debug(f"Cached {len(followers)} followers for topic #{topic_id}")
|
||||
return followers
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting followers for topic #{topic_id}: {e!s}")
|
||||
logger.error(f"Error getting followers for topic #{topic_id}: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
@@ -299,42 +239,35 @@ async def get_cached_author_followers(author_id: int):
|
||||
f[0]
|
||||
for f in session.query(Author.id)
|
||||
.join(AuthorFollower, AuthorFollower.follower == Author.id)
|
||||
.where(AuthorFollower.following == author_id, Author.id != author_id)
|
||||
.filter(AuthorFollower.author == author_id, Author.id != author_id)
|
||||
.all()
|
||||
]
|
||||
await redis.execute("SET", f"author:followers:{author_id}", fast_json_dumps(followers_ids))
|
||||
return await get_cached_authors_by_ids(followers_ids)
|
||||
await redis.execute("SET", f"author:followers:{author_id}", orjson.dumps(followers_ids))
|
||||
followers = await get_cached_authors_by_ids(followers_ids)
|
||||
return followers
|
||||
|
||||
|
||||
# Get cached follower authors
|
||||
async def get_cached_follower_authors(author_id: int):
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
# Attempt to retrieve authors from cache
|
||||
cache_key = f"author:follows-authors:{author_id}"
|
||||
cached = await redis.execute("GET", cache_key)
|
||||
cached = await redis.execute("GET", f"author:follows-authors:{author_id}")
|
||||
if cached:
|
||||
authors_ids = orjson.loads(cached)
|
||||
logger.debug(f"[get_cached_follower_authors] Cache HIT for {cache_key}: {len(authors_ids)} authors")
|
||||
else:
|
||||
logger.debug(f"[get_cached_follower_authors] Cache MISS for {cache_key}, querying DB")
|
||||
logger.info("[get_cached_follower_authors] Cache MISS - this should happen after follow/unfollow operations")
|
||||
# Query authors from database
|
||||
with local_session() as session:
|
||||
authors_ids = [
|
||||
a[0]
|
||||
for a in session.execute(
|
||||
select(Author.id)
|
||||
.select_from(join(Author, AuthorFollower, Author.id == AuthorFollower.following))
|
||||
.select_from(join(Author, AuthorFollower, Author.id == AuthorFollower.author))
|
||||
.where(AuthorFollower.follower == author_id)
|
||||
).all()
|
||||
]
|
||||
await redis.execute("SET", cache_key, fast_json_dumps(authors_ids))
|
||||
logger.debug(
|
||||
f"[get_cached_follower_authors] DB query result for user {author_id}: {len(authors_ids)} authors, IDs: {authors_ids}"
|
||||
)
|
||||
await redis.execute("SET", f"author:follows-authors:{author_id}", orjson.dumps(authors_ids))
|
||||
|
||||
return await get_cached_authors_by_ids(authors_ids)
|
||||
authors = await get_cached_authors_by_ids(authors_ids)
|
||||
return authors
|
||||
|
||||
|
||||
# Get cached follower topics
|
||||
@@ -353,7 +286,7 @@ async def get_cached_follower_topics(author_id: int):
|
||||
.where(TopicFollower.follower == author_id)
|
||||
.all()
|
||||
]
|
||||
await redis.execute("SET", f"author:follows-topics:{author_id}", fast_json_dumps(topics_ids))
|
||||
await redis.execute("SET", f"author:follows-topics:{author_id}", orjson.dumps(topics_ids))
|
||||
|
||||
topics = []
|
||||
for topic_id in topics_ids:
|
||||
@@ -367,31 +300,35 @@ async def get_cached_follower_topics(author_id: int):
|
||||
return topics
|
||||
|
||||
|
||||
# Get author by author_id from cache
|
||||
async def get_cached_author_by_id(author_id: int, get_with_stat=None):
|
||||
# Get author by user ID from cache
|
||||
async def get_cached_author_by_user_id(user_id: str, get_with_stat):
|
||||
"""
|
||||
Retrieve author information by author_id, checking the cache first, then the database.
|
||||
Retrieve author information by user_id, checking the cache first, then the database.
|
||||
|
||||
Args:
|
||||
author_id (int): The author identifier for which to retrieve the author.
|
||||
user_id (str): The user identifier for which to retrieve the author.
|
||||
|
||||
Returns:
|
||||
dict: Dictionary with author data or None if not found.
|
||||
"""
|
||||
# Attempt to find author data by author_id in Redis cache
|
||||
cached_author_data = await redis.execute("GET", f"author:id:{author_id}")
|
||||
if cached_author_data:
|
||||
# If data is found, return parsed JSON
|
||||
return orjson.loads(cached_author_data)
|
||||
# Attempt to find author ID by user_id in Redis cache
|
||||
author_id = await redis.execute("GET", f"author:user:{user_id.strip()}")
|
||||
if author_id:
|
||||
# If ID is found, get full author data by ID
|
||||
author_data = await redis.execute("GET", f"author:id:{author_id}")
|
||||
if author_data:
|
||||
return orjson.loads(author_data)
|
||||
|
||||
author_query = select(Author).where(Author.id == author_id)
|
||||
# If data is not found in cache, query the database
|
||||
author_query = select(Author).where(Author.user == user_id)
|
||||
authors = get_with_stat(author_query)
|
||||
if authors:
|
||||
# Cache the retrieved author data
|
||||
author = authors[0]
|
||||
author_dict = author.dict()
|
||||
await asyncio.gather(
|
||||
redis.execute("SET", f"author:id:{author.id}", fast_json_dumps(author_dict)),
|
||||
redis.execute("SET", f"author:user:{user_id.strip()}", str(author.id)),
|
||||
redis.execute("SET", f"author:id:{author.id}", orjson.dumps(author_dict)),
|
||||
)
|
||||
return author_dict
|
||||
|
||||
@@ -422,17 +359,11 @@ async def get_cached_topic_authors(topic_id: int):
|
||||
select(ShoutAuthor.author)
|
||||
.select_from(join(ShoutTopic, Shout, ShoutTopic.shout == Shout.id))
|
||||
.join(ShoutAuthor, ShoutAuthor.shout == Shout.id)
|
||||
.where(
|
||||
and_(
|
||||
ShoutTopic.topic == topic_id,
|
||||
Shout.published_at.is_not(None),
|
||||
Shout.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
.where(and_(ShoutTopic.topic == topic_id, Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
|
||||
)
|
||||
authors_ids = [author_id for (author_id,) in session.execute(query).all()]
|
||||
# Cache the retrieved author IDs
|
||||
await redis.execute("SET", rkey, fast_json_dumps(authors_ids))
|
||||
await redis.execute("SET", rkey, orjson.dumps(authors_ids))
|
||||
|
||||
# Retrieve full author details from cached IDs
|
||||
if authors_ids:
|
||||
@@ -443,12 +374,15 @@ async def get_cached_topic_authors(topic_id: int):
|
||||
return []
|
||||
|
||||
|
||||
async def invalidate_shouts_cache(cache_keys: list[str]) -> None:
|
||||
async def invalidate_shouts_cache(cache_keys: List[str]):
|
||||
"""
|
||||
Инвалидирует кэш выборок публикаций по переданным ключам.
|
||||
"""
|
||||
for cache_key in cache_keys:
|
||||
for key in cache_keys:
|
||||
try:
|
||||
# Формируем полный ключ кэша
|
||||
cache_key = f"shouts:{key}"
|
||||
|
||||
# Удаляем основной кэш
|
||||
await redis.execute("DEL", cache_key)
|
||||
logger.debug(f"Invalidated cache key: {cache_key}")
|
||||
@@ -457,8 +391,8 @@ async def invalidate_shouts_cache(cache_keys: list[str]) -> None:
|
||||
await redis.execute("SETEX", f"{cache_key}:invalidated", CACHE_TTL, "1")
|
||||
|
||||
# Если это кэш темы, инвалидируем также связанные ключи
|
||||
if cache_key.startswith("topic_"):
|
||||
topic_id = cache_key.split("_")[1]
|
||||
if key.startswith("topic_"):
|
||||
topic_id = key.split("_")[1]
|
||||
related_keys = [
|
||||
f"topic:id:{topic_id}",
|
||||
f"topic:authors:{topic_id}",
|
||||
@@ -470,35 +404,38 @@ async def invalidate_shouts_cache(cache_keys: list[str]) -> None:
|
||||
logger.debug(f"Invalidated related key: {related_key}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error invalidating cache key {cache_key}: {e}")
|
||||
logger.error(f"Error invalidating cache key {key}: {e}")
|
||||
|
||||
|
||||
async def cache_topic_shouts(topic_id: int, shouts: list[dict]) -> None:
|
||||
async def cache_topic_shouts(topic_id: int, shouts: List[dict]):
|
||||
"""Кэширует список публикаций для темы"""
|
||||
key = f"topic_shouts_{topic_id}"
|
||||
payload = fast_json_dumps(shouts)
|
||||
payload = json.dumps(shouts, cls=CustomJSONEncoder)
|
||||
await redis.execute("SETEX", key, CACHE_TTL, payload)
|
||||
|
||||
|
||||
async def get_cached_topic_shouts(topic_id: int) -> list[dict]:
|
||||
async def get_cached_topic_shouts(topic_id: int) -> List[dict]:
|
||||
"""Получает кэшированный список публикаций для темы"""
|
||||
key = f"topic_shouts_{topic_id}"
|
||||
cached = await redis.execute("GET", key)
|
||||
if cached:
|
||||
return orjson.loads(cached)
|
||||
return []
|
||||
return None
|
||||
|
||||
|
||||
async def cache_related_entities(shout: Shout) -> None:
|
||||
async def cache_related_entities(shout: Shout):
|
||||
"""
|
||||
Кэширует все связанные с публикацией сущности (авторов и темы)
|
||||
"""
|
||||
tasks = [cache_by_id(Author, author.id, cache_author) for author in shout.authors]
|
||||
tasks.extend(cache_by_id(Topic, topic.id, cache_topic) for topic in shout.topics)
|
||||
tasks = []
|
||||
for author in shout.authors:
|
||||
tasks.append(cache_by_id(Author, author.id, cache_author))
|
||||
for topic in shout.topics:
|
||||
tasks.append(cache_by_id(Topic, topic.id, cache_topic))
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
|
||||
async def invalidate_shout_related_cache(shout: Shout, author_id: int) -> None:
|
||||
async def invalidate_shout_related_cache(shout: Shout, author_id: int):
|
||||
"""
|
||||
Инвалидирует весь кэш, связанный с публикацией и её связями
|
||||
|
||||
@@ -513,10 +450,6 @@ async def invalidate_shout_related_cache(shout: Shout, author_id: int) -> None:
|
||||
"unrated", # неоцененные
|
||||
"recent", # последние
|
||||
"coauthored", # совместные
|
||||
# 🔧 Добавляем ключи с featured материалами
|
||||
"featured", # featured публикации
|
||||
"featured:recent", # недавние featured
|
||||
"featured:top", # топ featured
|
||||
}
|
||||
|
||||
# Добавляем ключи авторов
|
||||
@@ -527,12 +460,6 @@ async def invalidate_shout_related_cache(shout: Shout, author_id: int) -> None:
|
||||
cache_keys.update(f"topic_{t.id}" for t in shout.topics)
|
||||
cache_keys.update(f"topic_shouts_{t.id}" for t in shout.topics)
|
||||
|
||||
# 🔧 Добавляем ключи featured материалов для каждой темы
|
||||
for topic in shout.topics:
|
||||
cache_keys.update(
|
||||
[f"topic_{topic.id}:featured", f"topic_{topic.id}:featured:recent", f"topic_{topic.id}:featured:top"]
|
||||
)
|
||||
|
||||
await invalidate_shouts_cache(list(cache_keys))
|
||||
|
||||
|
||||
@@ -561,7 +488,7 @@ async def get_cached_entity(entity_type: str, entity_id: int, get_method, cache_
|
||||
return None
|
||||
|
||||
|
||||
async def cache_by_id(entity, entity_id: int, cache_method, get_with_stat=None):
|
||||
async def cache_by_id(entity, entity_id: int, cache_method):
|
||||
"""
|
||||
Кэширует сущность по ID, используя указанный метод кэширования
|
||||
|
||||
@@ -570,15 +497,13 @@ async def cache_by_id(entity, entity_id: int, cache_method, get_with_stat=None):
|
||||
entity_id: ID сущности
|
||||
cache_method: функция кэширования
|
||||
"""
|
||||
from resolvers.stat import get_with_stat
|
||||
|
||||
if get_with_stat is None:
|
||||
pass # get_with_stat уже импортирован на верхнем уровне
|
||||
|
||||
caching_query = select(entity).where(entity.id == entity_id)
|
||||
caching_query = select(entity).filter(entity.id == entity_id)
|
||||
result = get_with_stat(caching_query)
|
||||
if not result or not result[0]:
|
||||
logger.warning(f"{entity.__name__} with id {entity_id} not found")
|
||||
return None
|
||||
return
|
||||
x = result[0]
|
||||
d = x.dict()
|
||||
await cache_method(d)
|
||||
@@ -586,7 +511,7 @@ async def cache_by_id(entity, entity_id: int, cache_method, get_with_stat=None):
|
||||
|
||||
|
||||
# Универсальная функция для сохранения данных в кеш
|
||||
async def cache_data(key: str, data: Any, ttl: int | None = None) -> None:
|
||||
async def cache_data(key: str, data: Any, ttl: Optional[int] = None) -> None:
|
||||
"""
|
||||
Сохраняет данные в кеш по указанному ключу.
|
||||
|
||||
@@ -596,9 +521,7 @@ async def cache_data(key: str, data: Any, ttl: int | None = None) -> None:
|
||||
ttl: Время жизни кеша в секундах (None - бессрочно)
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Attempting to cache data for key: {key}, data type: {type(data)}")
|
||||
payload = fast_json_dumps(data)
|
||||
logger.debug(f"Serialized payload size: {len(payload)} bytes")
|
||||
payload = json.dumps(data, cls=CustomJSONEncoder)
|
||||
if ttl:
|
||||
await redis.execute("SETEX", key, ttl, payload)
|
||||
else:
|
||||
@@ -606,13 +529,10 @@ async def cache_data(key: str, data: Any, ttl: int | None = None) -> None:
|
||||
logger.debug(f"Данные сохранены в кеш по ключу {key}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сохранении данных в кеш: {e}")
|
||||
logger.error(f"Key: {key}, data type: {type(data)}")
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
|
||||
# Универсальная функция для получения данных из кеша
|
||||
async def get_cached_data(key: str) -> Any | None:
|
||||
async def get_cached_data(key: str) -> Optional[Any]:
|
||||
"""
|
||||
Получает данные из кеша по указанному ключу.
|
||||
|
||||
@@ -623,19 +543,14 @@ async def get_cached_data(key: str) -> Any | None:
|
||||
Any: Данные из кеша или None, если данных нет
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Attempting to get cached data for key: {key}")
|
||||
cached_data = await redis.execute("GET", key)
|
||||
if cached_data:
|
||||
logger.debug(f"Raw cached data size: {len(cached_data)} bytes")
|
||||
loaded = orjson.loads(cached_data)
|
||||
logger.debug(f"Данные получены из кеша по ключу {key}: {len(loaded)}")
|
||||
return loaded
|
||||
logger.debug(f"No cached data found for key: {key}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении данных из кеша: {e}")
|
||||
logger.error(f"Key: {key}")
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -659,8 +574,8 @@ async def invalidate_cache_by_prefix(prefix: str) -> None:
|
||||
# Универсальная функция для получения и кеширования данных
|
||||
async def cached_query(
|
||||
cache_key: str,
|
||||
query_func: Callable,
|
||||
ttl: int | None = None,
|
||||
query_func: callable,
|
||||
ttl: Optional[int] = None,
|
||||
force_refresh: bool = False,
|
||||
use_key_format: bool = True,
|
||||
**query_params,
|
||||
@@ -684,7 +599,7 @@ async def cached_query(
|
||||
actual_key = cache_key
|
||||
if use_key_format and "{}" in cache_key:
|
||||
# Look for a template match in CACHE_KEYS
|
||||
for key_format in CACHE_KEYS.values():
|
||||
for key_name, key_format in CACHE_KEYS.items():
|
||||
if cache_key == key_format:
|
||||
# We have a match, now look for the id or value to format with
|
||||
for param_name, param_value in query_params.items():
|
||||
@@ -700,290 +615,14 @@ async def cached_query(
|
||||
|
||||
# If data not in cache or refresh required, execute query
|
||||
try:
|
||||
logger.debug(f"Executing query function for cache key: {actual_key}")
|
||||
result = await query_func(**query_params)
|
||||
logger.debug(
|
||||
f"Query function returned: {type(result)}, length: {len(result) if hasattr(result, '__len__') else 'N/A'}"
|
||||
)
|
||||
if result is not None:
|
||||
# Save result to cache
|
||||
logger.debug(f"Saving result to cache with key: {actual_key}")
|
||||
await cache_data(actual_key, result, ttl)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing query for caching: {e}")
|
||||
logger.error(f"Query function: {query_func.__name__ if hasattr(query_func, '__name__') else 'unknown'}")
|
||||
logger.error(f"Query params: {query_params}")
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
# In case of error, return data from cache if not forcing refresh
|
||||
if not force_refresh:
|
||||
logger.debug(f"Attempting to get cached data as fallback for key: {actual_key}")
|
||||
return await get_cached_data(actual_key)
|
||||
raise
|
||||
|
||||
|
||||
async def save_topic_to_cache(topic: Dict[str, Any]) -> None:
|
||||
"""Сохраняет топик в кеш"""
|
||||
try:
|
||||
topic_id = topic.get("id")
|
||||
if not topic_id:
|
||||
return
|
||||
|
||||
topic_key = f"topic:{topic_id}"
|
||||
payload = fast_json_dumps(topic)
|
||||
await redis.execute("SET", topic_key, payload)
|
||||
await redis.execute("EXPIRE", topic_key, 3600) # 1 час
|
||||
logger.debug(f"Topic {topic_id} saved to cache")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save topic to cache: {e}")
|
||||
|
||||
|
||||
async def save_author_to_cache(author: Dict[str, Any]) -> None:
|
||||
"""Сохраняет автора в кеш"""
|
||||
try:
|
||||
author_id = author.get("id")
|
||||
if not author_id:
|
||||
return
|
||||
|
||||
author_key = f"author:{author_id}"
|
||||
payload = fast_json_dumps(author)
|
||||
await redis.execute("SET", author_key, payload)
|
||||
await redis.execute("EXPIRE", author_key, 1800) # 30 минут
|
||||
logger.debug(f"Author {author_id} saved to cache")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save author to cache: {e}")
|
||||
|
||||
|
||||
async def cache_follows_by_follower(author_id: int, follows: List[Dict[str, Any]]) -> None:
|
||||
"""Кеширует подписки пользователя"""
|
||||
try:
|
||||
key = f"follows:author:{author_id}"
|
||||
await redis.execute("SET", key, fast_json_dumps(follows))
|
||||
await redis.execute("EXPIRE", key, 1800) # 30 минут
|
||||
logger.debug(f"Follows cached for author {author_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cache follows: {e}")
|
||||
|
||||
|
||||
async def get_topic_from_cache(topic_id: int | str) -> Dict[str, Any] | None:
|
||||
"""Получает топик из кеша"""
|
||||
try:
|
||||
topic_key = f"topic:{topic_id}"
|
||||
cached_data = await redis.get(topic_key)
|
||||
|
||||
if cached_data:
|
||||
if isinstance(cached_data, bytes):
|
||||
cached_data = cached_data.decode("utf-8")
|
||||
return json.loads(cached_data)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get topic from cache: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_author_from_cache(author_id: int | str) -> Dict[str, Any] | None:
|
||||
"""Получает автора из кеша"""
|
||||
try:
|
||||
author_key = f"author:{author_id}"
|
||||
cached_data = await redis.get(author_key)
|
||||
|
||||
if cached_data:
|
||||
if isinstance(cached_data, bytes):
|
||||
cached_data = cached_data.decode("utf-8")
|
||||
return json.loads(cached_data)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get author from cache: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def cache_topic_with_content(topic_dict: Dict[str, Any]) -> None:
|
||||
"""Кеширует топик с контентом"""
|
||||
try:
|
||||
topic_id = topic_dict.get("id")
|
||||
if topic_id:
|
||||
topic_key = f"topic_content:{topic_id}"
|
||||
await redis.execute("SET", topic_key, fast_json_dumps(topic_dict))
|
||||
await redis.execute("EXPIRE", topic_key, 7200) # 2 часа
|
||||
logger.debug(f"Topic content {topic_id} cached")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cache topic content: {e}")
|
||||
|
||||
|
||||
async def get_cached_topic_content(topic_id: int | str) -> Dict[str, Any] | None:
|
||||
"""Получает кешированный контент топика"""
|
||||
try:
|
||||
topic_key = f"topic_content:{topic_id}"
|
||||
cached_data = await redis.get(topic_key)
|
||||
|
||||
if cached_data:
|
||||
if isinstance(cached_data, bytes):
|
||||
cached_data = cached_data.decode("utf-8")
|
||||
return json.loads(cached_data)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get cached topic content: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def save_shouts_to_cache(shouts: List[Dict[str, Any]], cache_key: str = "recent_shouts") -> None:
|
||||
"""Сохраняет статьи в кеш"""
|
||||
try:
|
||||
payload = fast_json_dumps(shouts)
|
||||
await redis.execute("SET", cache_key, payload)
|
||||
await redis.execute("EXPIRE", cache_key, 900) # 15 минут
|
||||
logger.debug(f"Shouts saved to cache with key: {cache_key}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save shouts to cache: {e}")
|
||||
|
||||
|
||||
async def get_shouts_from_cache(cache_key: str = "recent_shouts") -> List[Dict[str, Any]] | None:
|
||||
"""Получает статьи из кеша"""
|
||||
try:
|
||||
cached_data = await redis.get(cache_key)
|
||||
|
||||
if cached_data:
|
||||
if isinstance(cached_data, bytes):
|
||||
cached_data = cached_data.decode("utf-8")
|
||||
return json.loads(cached_data)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get shouts from cache: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def cache_search_results(query: str, data: List[Dict[str, Any]], ttl: int = 600) -> None:
|
||||
"""Кеширует результаты поиска"""
|
||||
try:
|
||||
search_key = f"search:{query.lower().replace(' ', '_')}"
|
||||
payload = fast_json_dumps(data)
|
||||
await redis.execute("SET", search_key, payload)
|
||||
await redis.execute("EXPIRE", search_key, ttl)
|
||||
logger.debug(f"Search results cached for query: {query}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cache search results: {e}")
|
||||
|
||||
|
||||
async def get_cached_search_results(query: str) -> List[Dict[str, Any]] | None:
|
||||
"""Получает кешированные результаты поиска"""
|
||||
try:
|
||||
search_key = f"search:{query.lower().replace(' ', '_')}"
|
||||
cached_data = await redis.get(search_key)
|
||||
|
||||
if cached_data:
|
||||
if isinstance(cached_data, bytes):
|
||||
cached_data = cached_data.decode("utf-8")
|
||||
return json.loads(cached_data)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get cached search results: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def invalidate_topic_cache(topic_id: int | str) -> None:
|
||||
"""Инвалидирует кеш топика"""
|
||||
try:
|
||||
topic_key = f"topic:{topic_id}"
|
||||
content_key = f"topic_content:{topic_id}"
|
||||
await redis.delete(topic_key)
|
||||
await redis.delete(content_key)
|
||||
logger.debug(f"Cache invalidated for topic {topic_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to invalidate topic cache: {e}")
|
||||
|
||||
|
||||
async def invalidate_author_cache(author_id: int | str) -> None:
|
||||
"""Инвалидирует кеш автора"""
|
||||
try:
|
||||
author_key = f"author:{author_id}"
|
||||
follows_key = f"follows:author:{author_id}"
|
||||
await redis.delete(author_key)
|
||||
await redis.delete(follows_key)
|
||||
logger.debug(f"Cache invalidated for author {author_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to invalidate author cache: {e}")
|
||||
|
||||
|
||||
async def clear_all_cache() -> None:
|
||||
"""
|
||||
Очищает весь кэш Redis (используйте с осторожностью!)
|
||||
|
||||
Warning:
|
||||
Эта функция удаляет ВСЕ данные из Redis!
|
||||
Используйте только в тестовой среде или при критической необходимости.
|
||||
"""
|
||||
try:
|
||||
await redis.execute("FLUSHDB")
|
||||
logger.info("Весь кэш очищен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при очистке кэша: {e}")
|
||||
|
||||
|
||||
async def invalidate_topic_followers_cache(topic_id: int) -> None:
|
||||
"""
|
||||
Инвалидирует кеши подписчиков при удалении топика.
|
||||
|
||||
Эта функция:
|
||||
1. Получает список всех подписчиков топика
|
||||
2. Инвалидирует персональные кеши подписок для каждого подписчика
|
||||
3. Инвалидирует кеши самого топика
|
||||
4. Логирует процесс для отладки
|
||||
|
||||
Args:
|
||||
topic_id: ID топика для которого нужно инвалидировать кеши подписчиков
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Инвалидация кешей подписчиков для топика {topic_id}")
|
||||
|
||||
# Получаем список всех подписчиков топика из БД
|
||||
with local_session() as session:
|
||||
followers_query = session.query(TopicFollower.follower).where(TopicFollower.topic == topic_id)
|
||||
follower_ids = [row[0] for row in followers_query.all()]
|
||||
|
||||
logger.debug(f"Найдено {len(follower_ids)} подписчиков топика {topic_id}")
|
||||
|
||||
# Инвалидируем кеши подписок для всех подписчиков
|
||||
for follower_id in follower_ids:
|
||||
cache_keys_to_delete = [
|
||||
f"author:follows-topics:{follower_id}", # Список топиков на которые подписан автор
|
||||
f"author:followers:{follower_id}", # Счетчик подписчиков автора
|
||||
f"author:stat:{follower_id}", # Общая статистика автора
|
||||
f"author:id:{follower_id}", # Кешированные данные автора
|
||||
]
|
||||
|
||||
for cache_key in cache_keys_to_delete:
|
||||
try:
|
||||
await redis.execute("DEL", cache_key)
|
||||
logger.debug(f"Удален кеш: {cache_key}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении кеша {cache_key}: {e}")
|
||||
|
||||
# Инвалидируем кеши самого топика
|
||||
topic_cache_keys = [
|
||||
f"topic:followers:{topic_id}", # Список подписчиков топика
|
||||
f"topic:id:{topic_id}", # Данные топика по ID
|
||||
f"topic:authors:{topic_id}", # Авторы топика
|
||||
f"topic_shouts_{topic_id}", # Публикации топика (legacy format)
|
||||
]
|
||||
|
||||
for cache_key in topic_cache_keys:
|
||||
try:
|
||||
await redis.execute("DEL", cache_key)
|
||||
logger.debug(f"Удален кеш топика: {cache_key}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении кеша топика {cache_key}: {e}")
|
||||
|
||||
# Также ищем и удаляем коллекционные кеши, содержащие данные об этом топике
|
||||
try:
|
||||
collection_keys = await redis.execute("KEYS", "topics:stats:*")
|
||||
if collection_keys:
|
||||
await redis.execute("DEL", *collection_keys)
|
||||
logger.debug(f"Удалено {len(collection_keys)} коллекционных ключей тем")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении коллекционных кешей: {e}")
|
||||
|
||||
logger.info(f"Успешно инвалидированы кеши для топика {topic_id} и {len(follower_ids)} подписчиков")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при инвалидации кешей подписчиков топика {topic_id}: {e}")
|
||||
raise
|
||||
|
||||
210
cache/precache.py
vendored
210
cache/precache.py
vendored
@@ -1,45 +1,43 @@
|
||||
import asyncio
|
||||
import traceback
|
||||
import json
|
||||
|
||||
import orjson
|
||||
from sqlalchemy import and_, func, join, select
|
||||
from sqlalchemy import and_, join, select
|
||||
|
||||
# Импорт Author, AuthorFollower отложен для избежания циклических импортов
|
||||
from cache.cache import cache_author, cache_topic
|
||||
from orm.author import Author, AuthorFollower
|
||||
from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic
|
||||
from orm.topic import Topic, TopicFollower
|
||||
from resolvers.stat import get_with_stat
|
||||
from storage.db import local_session
|
||||
from storage.redis import redis
|
||||
from utils.encoders import fast_json_dumps
|
||||
from services.db import local_session
|
||||
from services.redis import redis
|
||||
from utils.encoders import CustomJSONEncoder
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
|
||||
# Предварительное кеширование подписчиков автора
|
||||
async def precache_authors_followers(author_id, session) -> None:
|
||||
authors_followers: set[int] = set()
|
||||
followers_query = select(AuthorFollower.follower).where(AuthorFollower.following == author_id)
|
||||
async def precache_authors_followers(author_id, session):
|
||||
authors_followers = set()
|
||||
followers_query = select(AuthorFollower.follower).where(AuthorFollower.author == author_id)
|
||||
result = session.execute(followers_query)
|
||||
authors_followers.update(row[0] for row in result if row[0])
|
||||
|
||||
followers_payload = fast_json_dumps(list(authors_followers))
|
||||
followers_payload = json.dumps(list(authors_followers), cls=CustomJSONEncoder)
|
||||
await redis.execute("SET", f"author:followers:{author_id}", followers_payload)
|
||||
|
||||
|
||||
# Предварительное кеширование подписок автора
|
||||
async def precache_authors_follows(author_id, session) -> None:
|
||||
async def precache_authors_follows(author_id, session):
|
||||
follows_topics_query = select(TopicFollower.topic).where(TopicFollower.follower == author_id)
|
||||
follows_authors_query = select(AuthorFollower.following).where(AuthorFollower.follower == author_id)
|
||||
follows_authors_query = select(AuthorFollower.author).where(AuthorFollower.follower == author_id)
|
||||
follows_shouts_query = select(ShoutReactionsFollower.shout).where(ShoutReactionsFollower.follower == author_id)
|
||||
|
||||
follows_topics = {row[0] for row in session.execute(follows_topics_query) if row[0]}
|
||||
follows_authors = {row[0] for row in session.execute(follows_authors_query) if row[0]}
|
||||
follows_shouts = {row[0] for row in session.execute(follows_shouts_query) if row[0]}
|
||||
|
||||
topics_payload = fast_json_dumps(list(follows_topics))
|
||||
authors_payload = fast_json_dumps(list(follows_authors))
|
||||
shouts_payload = fast_json_dumps(list(follows_shouts))
|
||||
topics_payload = json.dumps(list(follows_topics), cls=CustomJSONEncoder)
|
||||
authors_payload = json.dumps(list(follows_authors), cls=CustomJSONEncoder)
|
||||
shouts_payload = json.dumps(list(follows_shouts), cls=CustomJSONEncoder)
|
||||
|
||||
await asyncio.gather(
|
||||
redis.execute("SET", f"author:follows-topics:{author_id}", topics_payload),
|
||||
@@ -49,12 +47,12 @@ async def precache_authors_follows(author_id, session) -> None:
|
||||
|
||||
|
||||
# Предварительное кеширование авторов тем
|
||||
async def precache_topics_authors(topic_id: int, session) -> None:
|
||||
async def precache_topics_authors(topic_id: int, session):
|
||||
topic_authors_query = (
|
||||
select(ShoutAuthor.author)
|
||||
.select_from(join(ShoutTopic, Shout, ShoutTopic.shout == Shout.id))
|
||||
.join(ShoutAuthor, ShoutAuthor.shout == Shout.id)
|
||||
.where(
|
||||
.filter(
|
||||
and_(
|
||||
ShoutTopic.topic == topic_id,
|
||||
Shout.published_at.is_not(None),
|
||||
@@ -64,190 +62,72 @@ async def precache_topics_authors(topic_id: int, session) -> None:
|
||||
)
|
||||
topic_authors = {row[0] for row in session.execute(topic_authors_query) if row[0]}
|
||||
|
||||
authors_payload = fast_json_dumps(list(topic_authors))
|
||||
authors_payload = json.dumps(list(topic_authors), cls=CustomJSONEncoder)
|
||||
await redis.execute("SET", f"topic:authors:{topic_id}", authors_payload)
|
||||
|
||||
|
||||
# Предварительное кеширование подписчиков тем
|
||||
async def precache_topics_followers(topic_id: int, session) -> None:
|
||||
try:
|
||||
followers_query = select(TopicFollower.follower).where(TopicFollower.topic == topic_id)
|
||||
topic_followers = {row[0] for row in session.execute(followers_query) if row[0]}
|
||||
async def precache_topics_followers(topic_id: int, session):
|
||||
followers_query = select(TopicFollower.follower).where(TopicFollower.topic == topic_id)
|
||||
topic_followers = {row[0] for row in session.execute(followers_query) if row[0]}
|
||||
|
||||
followers_payload = fast_json_dumps(list(topic_followers))
|
||||
await redis.execute("SET", f"topic:followers:{topic_id}", followers_payload)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error precaching followers for topic #{topic_id}: {e}")
|
||||
# В случае ошибки, устанавливаем пустой список
|
||||
await redis.execute("SET", f"topic:followers:{topic_id}", fast_json_dumps([]))
|
||||
followers_payload = json.dumps(list(topic_followers), cls=CustomJSONEncoder)
|
||||
await redis.execute("SET", f"topic:followers:{topic_id}", followers_payload)
|
||||
|
||||
|
||||
async def precache_data() -> None:
|
||||
async def precache_data():
|
||||
logger.info("precaching...")
|
||||
logger.debug("Entering precache_data")
|
||||
|
||||
# Список паттернов ключей, которые нужно сохранить при FLUSHDB
|
||||
preserve_patterns = [
|
||||
"migrated_views_*", # Данные миграции просмотров
|
||||
"session:*", # Сессии пользователей
|
||||
"env_vars:*", # Переменные окружения
|
||||
"oauth_*", # OAuth токены
|
||||
]
|
||||
|
||||
# Сохраняем все важные ключи перед очисткой
|
||||
all_keys_to_preserve = []
|
||||
preserved_data = {}
|
||||
|
||||
try:
|
||||
for pattern in preserve_patterns:
|
||||
keys = await redis.execute("KEYS", pattern)
|
||||
if keys:
|
||||
all_keys_to_preserve.extend(keys)
|
||||
logger.info(f"Найдено {len(keys)} ключей по паттерну '{pattern}'")
|
||||
|
||||
if all_keys_to_preserve:
|
||||
logger.info(f"Сохраняем {len(all_keys_to_preserve)} важных ключей перед FLUSHDB")
|
||||
for key in all_keys_to_preserve:
|
||||
try:
|
||||
# Определяем тип ключа и сохраняем данные
|
||||
key_type = await redis.execute("TYPE", key)
|
||||
if key_type == "hash":
|
||||
preserved_data[key] = await redis.execute("HGETALL", key)
|
||||
elif key_type == "string":
|
||||
preserved_data[key] = await redis.execute("GET", key)
|
||||
elif key_type == "set":
|
||||
preserved_data[key] = await redis.execute("SMEMBERS", key)
|
||||
elif key_type == "list":
|
||||
preserved_data[key] = await redis.execute("LRANGE", key, 0, -1)
|
||||
elif key_type == "zset":
|
||||
preserved_data[key] = await redis.execute("ZRANGE", key, 0, -1, "WITHSCORES")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сохранении ключа {key}: {e}")
|
||||
continue
|
||||
|
||||
key = "authorizer_env"
|
||||
# cache reset
|
||||
value = await redis.execute("HGETALL", key)
|
||||
await redis.execute("FLUSHDB")
|
||||
logger.debug("Redis database flushed")
|
||||
logger.info("redis: FLUSHDB")
|
||||
|
||||
# Восстанавливаем все сохранённые ключи
|
||||
if preserved_data:
|
||||
logger.info(f"Восстанавливаем {len(preserved_data)} сохранённых ключей")
|
||||
for key, data in preserved_data.items():
|
||||
try:
|
||||
if isinstance(data, dict) and data:
|
||||
# Hash
|
||||
for field, val in data.items():
|
||||
await redis.execute("HSET", key, field, val)
|
||||
elif isinstance(data, str) and data:
|
||||
# String
|
||||
await redis.execute("SET", key, data)
|
||||
elif isinstance(data, list) and data:
|
||||
# List или ZSet
|
||||
if any(isinstance(item, list | tuple) and len(item) == 2 for item in data):
|
||||
# ZSet with scores
|
||||
for item in data:
|
||||
if isinstance(item, list | tuple) and len(item) == 2:
|
||||
await redis.execute("ZADD", key, item[1], item[0])
|
||||
else:
|
||||
# Regular list
|
||||
await redis.execute("LPUSH", key, *data)
|
||||
elif isinstance(data, set) and data:
|
||||
# Set
|
||||
await redis.execute("SADD", key, *data)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при восстановлении ключа {key}: {e}")
|
||||
continue
|
||||
# Преобразуем словарь в список аргументов для HSET
|
||||
if value:
|
||||
# Если значение - словарь, преобразуем его в плоский список для HSET
|
||||
if isinstance(value, dict):
|
||||
flattened = []
|
||||
for field, val in value.items():
|
||||
flattened.extend([field, val])
|
||||
await redis.execute("HSET", key, *flattened)
|
||||
else:
|
||||
# Предполагаем, что значение уже содержит список
|
||||
await redis.execute("HSET", key, *value)
|
||||
logger.info(f"redis hash '{key}' was restored")
|
||||
|
||||
logger.info("Beginning topic precache phase")
|
||||
with local_session() as session:
|
||||
# Проверяем состояние таблицы topic_followers перед кешированием
|
||||
total_followers = session.execute(select(func.count(TopicFollower.topic))).scalar()
|
||||
unique_topics_with_followers = session.execute(
|
||||
select(func.count(func.distinct(TopicFollower.topic)))
|
||||
).scalar()
|
||||
unique_followers = session.execute(select(func.count(func.distinct(TopicFollower.follower)))).scalar()
|
||||
|
||||
logger.info("📊 Database state before precaching:")
|
||||
logger.info(f" Total topic_followers records: {total_followers}")
|
||||
logger.info(f" Unique topics with followers: {unique_topics_with_followers}")
|
||||
logger.info(f" Unique followers: {unique_followers}")
|
||||
|
||||
if total_followers == 0:
|
||||
logger.warning(
|
||||
"🚨 WARNING: topic_followers table is empty! This will cause all topics to show 0 followers."
|
||||
)
|
||||
elif unique_topics_with_followers == 0:
|
||||
logger.warning("🚨 WARNING: No topics have followers! This will cause all topics to show 0 followers.")
|
||||
|
||||
# topics
|
||||
q = select(Topic).where(Topic.community == 1)
|
||||
topics = get_with_stat(q)
|
||||
logger.info(f"Found {len(topics)} topics to precache")
|
||||
for topic in topics:
|
||||
topic_dict = topic.dict() if hasattr(topic, "dict") else topic
|
||||
# logger.debug(f"Precaching topic id={topic_dict.get('id')}")
|
||||
await cache_topic(topic_dict)
|
||||
# logger.debug(f"Cached topic id={topic_dict.get('id')}")
|
||||
await asyncio.gather(
|
||||
precache_topics_followers(topic_dict["id"], session),
|
||||
precache_topics_authors(topic_dict["id"], session),
|
||||
)
|
||||
# logger.debug(f"Finished precaching followers and authors for topic id={topic_dict.get('id')}")
|
||||
logger.info(f"{len(topics)} topics and their followings precached")
|
||||
|
||||
# Выводим список топиков с 0 фолловерами
|
||||
topics_with_zero_followers = []
|
||||
for topic in topics:
|
||||
topic_dict = topic.dict() if hasattr(topic, "dict") else topic
|
||||
topic_id = topic_dict.get("id")
|
||||
topic_slug = topic_dict.get("slug", "unknown")
|
||||
|
||||
# Пропускаем топики без ID
|
||||
if not topic_id:
|
||||
continue
|
||||
|
||||
# Получаем количество фолловеров из кеша
|
||||
followers_cache_key = f"topic:followers:{topic_id}"
|
||||
followers_data = await redis.execute("GET", followers_cache_key)
|
||||
|
||||
if followers_data:
|
||||
followers_count = len(orjson.loads(followers_data))
|
||||
if followers_count == 0:
|
||||
topics_with_zero_followers.append(topic_slug)
|
||||
else:
|
||||
# Если кеш не найден, проверяем БД
|
||||
with local_session() as check_session:
|
||||
followers_count_result = check_session.execute(
|
||||
select(func.count(TopicFollower.follower)).where(TopicFollower.topic == topic_id)
|
||||
).scalar()
|
||||
followers_count = followers_count_result or 0
|
||||
if followers_count == 0:
|
||||
topics_with_zero_followers.append(topic_slug)
|
||||
|
||||
if topics_with_zero_followers:
|
||||
logger.info(f"📋 Топиков с 0 фолловерами: {len(topics_with_zero_followers)}")
|
||||
else:
|
||||
logger.info("✅ Все топики имеют фолловеров")
|
||||
|
||||
# authors
|
||||
authors = get_with_stat(select(Author))
|
||||
# logger.info(f"{len(authors)} authors found in database")
|
||||
authors = get_with_stat(select(Author).where(Author.user.is_not(None)))
|
||||
logger.info(f"{len(authors)} authors found in database")
|
||||
for author in authors:
|
||||
if isinstance(author, Author):
|
||||
profile = author.dict()
|
||||
author_id = profile.get("id")
|
||||
# user_id = profile.get("user", "").strip()
|
||||
if author_id: # and user_id:
|
||||
user_id = profile.get("user", "").strip()
|
||||
if author_id and user_id:
|
||||
await cache_author(profile)
|
||||
await asyncio.gather(
|
||||
precache_authors_followers(author_id, session),
|
||||
precache_authors_follows(author_id, session),
|
||||
precache_authors_followers(author_id, session), precache_authors_follows(author_id, session)
|
||||
)
|
||||
# logger.debug(f"Finished precaching followers and follows for author id={author_id}")
|
||||
else:
|
||||
logger.error(f"fail caching {author}")
|
||||
logger.info(f"{len(authors)} authors and their followings precached")
|
||||
except Exception as exc:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
logger.error(f"Error in precache_data: {exc}")
|
||||
|
||||
58
cache/revalidator.py
vendored
58
cache/revalidator.py
vendored
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
|
||||
from cache.cache import (
|
||||
cache_author,
|
||||
@@ -9,40 +8,25 @@ from cache.cache import (
|
||||
invalidate_cache_by_prefix,
|
||||
)
|
||||
from resolvers.stat import get_with_stat
|
||||
from storage.redis import redis
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
CACHE_REVALIDATION_INTERVAL = 300 # 5 minutes
|
||||
|
||||
|
||||
class CacheRevalidationManager:
|
||||
def __init__(self, interval=CACHE_REVALIDATION_INTERVAL) -> None:
|
||||
def __init__(self, interval=CACHE_REVALIDATION_INTERVAL):
|
||||
"""Инициализация менеджера с заданным интервалом проверки (в секундах)."""
|
||||
self.interval = interval
|
||||
self.items_to_revalidate: dict[str, set[str]] = {
|
||||
"authors": set(),
|
||||
"topics": set(),
|
||||
"shouts": set(),
|
||||
"reactions": set(),
|
||||
}
|
||||
self.items_to_revalidate = {"authors": set(), "topics": set(), "shouts": set(), "reactions": set()}
|
||||
self.lock = asyncio.Lock()
|
||||
self.running = True
|
||||
self.MAX_BATCH_SIZE = 10 # Максимальное количество элементов для поштучной обработки
|
||||
self._redis = redis # Добавлена инициализация _redis для доступа к Redis-клиенту
|
||||
|
||||
async def start(self) -> None:
|
||||
async def start(self):
|
||||
"""Запуск фонового воркера для ревалидации кэша."""
|
||||
# Проверяем, что у нас есть соединение с Redis
|
||||
if not self._redis._client:
|
||||
try:
|
||||
await self._redis.connect()
|
||||
logger.info("Redis connection established for revalidation manager")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to Redis: {e}")
|
||||
|
||||
self.task = asyncio.create_task(self.revalidate_cache())
|
||||
|
||||
async def revalidate_cache(self) -> None:
|
||||
async def revalidate_cache(self):
|
||||
"""Циклическая проверка и ревалидация кэша каждые self.interval секунд."""
|
||||
try:
|
||||
while self.running:
|
||||
@@ -53,12 +37,8 @@ class CacheRevalidationManager:
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred in the revalidation worker: {e}")
|
||||
|
||||
async def process_revalidation(self) -> None:
|
||||
async def process_revalidation(self):
|
||||
"""Обновление кэша для всех сущностей, требующих ревалидации."""
|
||||
# Проверяем соединение с Redis
|
||||
if not self._redis._client:
|
||||
return # Выходим из метода, если не удалось подключиться
|
||||
|
||||
async with self.lock:
|
||||
# Ревалидация кэша авторов
|
||||
if self.items_to_revalidate["authors"]:
|
||||
@@ -67,12 +47,9 @@ class CacheRevalidationManager:
|
||||
if author_id == "all":
|
||||
await invalidate_cache_by_prefix("authors")
|
||||
break
|
||||
try:
|
||||
author = await get_cached_author(int(author_id), get_with_stat)
|
||||
if author:
|
||||
await cache_author(author)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid author_id: {author_id}")
|
||||
author = await get_cached_author(author_id, get_with_stat)
|
||||
if author:
|
||||
await cache_author(author)
|
||||
self.items_to_revalidate["authors"].clear()
|
||||
|
||||
# Ревалидация кэша тем
|
||||
@@ -82,12 +59,9 @@ class CacheRevalidationManager:
|
||||
if topic_id == "all":
|
||||
await invalidate_cache_by_prefix("topics")
|
||||
break
|
||||
try:
|
||||
topic = await get_cached_topic(int(topic_id))
|
||||
if topic:
|
||||
await cache_topic(topic)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid topic_id: {topic_id}")
|
||||
topic = await get_cached_topic(topic_id)
|
||||
if topic:
|
||||
await cache_topic(topic)
|
||||
self.items_to_revalidate["topics"].clear()
|
||||
|
||||
# Ревалидация шаутов (публикаций)
|
||||
@@ -158,24 +132,26 @@ class CacheRevalidationManager:
|
||||
|
||||
self.items_to_revalidate["reactions"].clear()
|
||||
|
||||
def mark_for_revalidation(self, entity_id, entity_type) -> None:
|
||||
def mark_for_revalidation(self, entity_id, entity_type):
|
||||
"""Отметить сущность для ревалидации."""
|
||||
if entity_id and entity_type:
|
||||
self.items_to_revalidate[entity_type].add(entity_id)
|
||||
|
||||
def invalidate_all(self, entity_type) -> None:
|
||||
def invalidate_all(self, entity_type):
|
||||
"""Пометить для инвалидации все элементы указанного типа."""
|
||||
logger.debug(f"Marking all {entity_type} for invalidation")
|
||||
# Особый флаг для полной инвалидации
|
||||
self.items_to_revalidate[entity_type].add("all")
|
||||
|
||||
async def stop(self) -> None:
|
||||
async def stop(self):
|
||||
"""Остановка фонового воркера."""
|
||||
self.running = False
|
||||
if hasattr(self, "task"):
|
||||
self.task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
try:
|
||||
await self.task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
revalidation_manager = CacheRevalidationManager()
|
||||
|
||||
39
cache/triggers.py
vendored
39
cache/triggers.py
vendored
@@ -1,16 +1,15 @@
|
||||
from sqlalchemy import event
|
||||
|
||||
# Импорт Author, AuthorFollower отложен для избежания циклических импортов
|
||||
from cache.revalidator import revalidation_manager
|
||||
from orm.author import Author, AuthorFollower
|
||||
from orm.reaction import Reaction, ReactionKind
|
||||
from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower
|
||||
from orm.topic import Topic, TopicFollower
|
||||
from storage.db import local_session
|
||||
from services.db import local_session
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
|
||||
def mark_for_revalidation(entity, *args) -> None:
|
||||
def mark_for_revalidation(entity, *args):
|
||||
"""Отметка сущности для ревалидации."""
|
||||
entity_type = (
|
||||
"authors"
|
||||
@@ -27,7 +26,7 @@ def mark_for_revalidation(entity, *args) -> None:
|
||||
revalidation_manager.mark_for_revalidation(entity.id, entity_type)
|
||||
|
||||
|
||||
def after_follower_handler(mapper, connection, target, is_delete=False) -> None:
|
||||
def after_follower_handler(mapper, connection, target, is_delete=False):
|
||||
"""Обработчик добавления, обновления или удаления подписки."""
|
||||
entity_type = None
|
||||
if isinstance(target, AuthorFollower):
|
||||
@@ -39,13 +38,13 @@ def after_follower_handler(mapper, connection, target, is_delete=False) -> None:
|
||||
|
||||
if entity_type:
|
||||
revalidation_manager.mark_for_revalidation(
|
||||
target.following if entity_type == "authors" else target.topic, entity_type
|
||||
target.author if entity_type == "authors" else target.topic, entity_type
|
||||
)
|
||||
if not is_delete:
|
||||
revalidation_manager.mark_for_revalidation(target.follower, "authors")
|
||||
|
||||
|
||||
def after_shout_handler(mapper, connection, target) -> None:
|
||||
def after_shout_handler(mapper, connection, target):
|
||||
"""Обработчик изменения статуса публикации"""
|
||||
if not isinstance(target, Shout):
|
||||
return
|
||||
@@ -64,7 +63,7 @@ def after_shout_handler(mapper, connection, target) -> None:
|
||||
revalidation_manager.mark_for_revalidation(target.id, "shouts")
|
||||
|
||||
|
||||
def after_reaction_handler(mapper, connection, target) -> None:
|
||||
def after_reaction_handler(mapper, connection, target):
|
||||
"""Обработчик для комментариев"""
|
||||
if not isinstance(target, Reaction):
|
||||
return
|
||||
@@ -89,11 +88,7 @@ def after_reaction_handler(mapper, connection, target) -> None:
|
||||
with local_session() as session:
|
||||
shout = (
|
||||
session.query(Shout)
|
||||
.where(
|
||||
Shout.id == shout_id,
|
||||
Shout.published_at.is_not(None),
|
||||
Shout.deleted_at.is_(None),
|
||||
)
|
||||
.filter(Shout.id == shout_id, Shout.published_at.is_not(None), Shout.deleted_at.is_(None))
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -105,7 +100,7 @@ def after_reaction_handler(mapper, connection, target) -> None:
|
||||
revalidation_manager.mark_for_revalidation(topic.id, "topics")
|
||||
|
||||
|
||||
def events_register() -> None:
|
||||
def events_register():
|
||||
"""Регистрация обработчиков событий для всех сущностей."""
|
||||
event.listen(ShoutAuthor, "after_insert", mark_for_revalidation)
|
||||
event.listen(ShoutAuthor, "after_update", mark_for_revalidation)
|
||||
@@ -113,27 +108,15 @@ def events_register() -> None:
|
||||
|
||||
event.listen(AuthorFollower, "after_insert", after_follower_handler)
|
||||
event.listen(AuthorFollower, "after_update", after_follower_handler)
|
||||
event.listen(
|
||||
AuthorFollower,
|
||||
"after_delete",
|
||||
lambda mapper, connection, target: after_follower_handler(mapper, connection, target, is_delete=True),
|
||||
)
|
||||
event.listen(AuthorFollower, "after_delete", lambda *args: after_follower_handler(*args, is_delete=True))
|
||||
|
||||
event.listen(TopicFollower, "after_insert", after_follower_handler)
|
||||
event.listen(TopicFollower, "after_update", after_follower_handler)
|
||||
event.listen(
|
||||
TopicFollower,
|
||||
"after_delete",
|
||||
lambda mapper, connection, target: after_follower_handler(mapper, connection, target, is_delete=True),
|
||||
)
|
||||
event.listen(TopicFollower, "after_delete", lambda *args: after_follower_handler(*args, is_delete=True))
|
||||
|
||||
event.listen(ShoutReactionsFollower, "after_insert", after_follower_handler)
|
||||
event.listen(ShoutReactionsFollower, "after_update", after_follower_handler)
|
||||
event.listen(
|
||||
ShoutReactionsFollower,
|
||||
"after_delete",
|
||||
lambda mapper, connection, target: after_follower_handler(mapper, connection, target, is_delete=True),
|
||||
)
|
||||
event.listen(ShoutReactionsFollower, "after_delete", lambda *args: after_follower_handler(*args, is_delete=True))
|
||||
|
||||
event.listen(Reaction, "after_update", mark_for_revalidation)
|
||||
event.listen(Author, "after_update", mark_for_revalidation)
|
||||
|
||||
56
codegen.ts
56
codegen.ts
@@ -1,56 +0,0 @@
|
||||
import type { CodegenConfig } from '@graphql-codegen/cli'
|
||||
|
||||
const config: CodegenConfig = {
|
||||
overwrite: true,
|
||||
// Используем основной endpoint с fallback логикой
|
||||
schema: 'https://v3.discours.io/graphql',
|
||||
documents: ['panel/graphql/queries/**/*.ts', 'panel/**/*.{ts,tsx}', '!panel/graphql/generated/**'],
|
||||
generates: {
|
||||
'./panel/graphql/generated/introspection.json': {
|
||||
plugins: ['introspection'],
|
||||
config: {
|
||||
minify: true
|
||||
}
|
||||
},
|
||||
'./panel/graphql/generated/schema.graphql': {
|
||||
plugins: ['schema-ast'],
|
||||
config: {
|
||||
includeDirectives: false
|
||||
}
|
||||
},
|
||||
'./panel/graphql/generated/': {
|
||||
preset: 'client',
|
||||
plugins: [],
|
||||
presetConfig: {
|
||||
gqlTagName: 'gql',
|
||||
fragmentMasking: false
|
||||
},
|
||||
config: {
|
||||
scalars: {
|
||||
DateTime: 'string',
|
||||
JSON: 'Record<string, any>'
|
||||
},
|
||||
// Настройки для правильной работы
|
||||
skipTypename: false,
|
||||
useTypeImports: true,
|
||||
dedupeOperationSuffix: true,
|
||||
dedupeFragments: true,
|
||||
// Избегаем конфликтов при объединении
|
||||
avoidOptionals: false,
|
||||
enumsAsTypes: false
|
||||
}
|
||||
}
|
||||
},
|
||||
// Глобальные настройки для правильной работы
|
||||
config: {
|
||||
skipTypename: false,
|
||||
useTypeImports: true,
|
||||
dedupeOperationSuffix: true,
|
||||
dedupeFragments: true,
|
||||
// Настройки для объединения схем
|
||||
avoidOptionals: false,
|
||||
enumsAsTypes: false
|
||||
}
|
||||
}
|
||||
|
||||
export default config
|
||||
142
dev.py
142
dev.py
@@ -1,142 +0,0 @@
|
||||
import argparse
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from granian import Granian
|
||||
from granian.constants import Interfaces
|
||||
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
|
||||
def check_mkcert_installed() -> bool | None:
|
||||
"""
|
||||
Проверяет, установлен ли инструмент mkcert в системе
|
||||
|
||||
Returns:
|
||||
bool: True если mkcert установлен, иначе False
|
||||
|
||||
>>> check_mkcert_installed() # doctest: +SKIP
|
||||
True
|
||||
"""
|
||||
try:
|
||||
subprocess.run(["mkcert", "-version"], capture_output=True, check=False)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def generate_certificates(domain="localhost", cert_file="localhost.pem", key_file="localhost-key.pem"):
|
||||
"""
|
||||
Генерирует сертификаты с использованием mkcert
|
||||
|
||||
Args:
|
||||
domain: Домен для сертификата
|
||||
cert_file: Имя файла сертификата
|
||||
key_file: Имя файла ключа
|
||||
|
||||
Returns:
|
||||
tuple: (cert_file, key_file) пути к созданным файлам
|
||||
|
||||
>>> generate_certificates() # doctest: +SKIP
|
||||
('localhost.pem', 'localhost-key.pem')
|
||||
"""
|
||||
# Проверяем, существуют ли сертификаты
|
||||
if Path(cert_file).exists() and Path(key_file).exists():
|
||||
logger.info(f"Сертификаты уже существуют: {cert_file}, {key_file}")
|
||||
return cert_file, key_file
|
||||
|
||||
# Проверяем, установлен ли mkcert
|
||||
if not check_mkcert_installed():
|
||||
logger.error("mkcert не установлен. Установите mkcert с помощью команды:")
|
||||
logger.error(" macOS: brew install mkcert")
|
||||
logger.error(" Linux: apt install mkcert или эквивалент для вашего дистрибутива")
|
||||
logger.error(" Windows: choco install mkcert")
|
||||
logger.error("После установки выполните: mkcert -install")
|
||||
return None, None
|
||||
|
||||
try:
|
||||
# Запускаем mkcert для создания сертификата
|
||||
logger.info(f"Создание сертификатов для {domain} с помощью mkcert...")
|
||||
result = subprocess.run(
|
||||
["mkcert", "-cert-file", cert_file, "-key-file", key_file, domain],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Ошибка при создании сертификатов: {result.stderr}")
|
||||
return None, None
|
||||
|
||||
logger.info(f"Сертификаты созданы: {cert_file}, {key_file}")
|
||||
return cert_file, key_file
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось создать сертификаты: {e!s}")
|
||||
return None, None
|
||||
|
||||
|
||||
def run_server(host="127.0.0.1", port=8000, use_https=False, workers=1, domain="localhost") -> None:
|
||||
"""
|
||||
Запускает сервер Granian с поддержкой HTTPS при необходимости
|
||||
|
||||
Args:
|
||||
host: Хост для запуска сервера
|
||||
port: Порт для запуска сервера
|
||||
use_https: Флаг использования HTTPS
|
||||
workers: Количество рабочих процессов
|
||||
domain: Домен для сертификата
|
||||
|
||||
>>> run_server(use_https=True) # doctest: +SKIP
|
||||
"""
|
||||
# Проблема с многопроцессорным режимом - не поддерживает локальные объекты приложений
|
||||
# Всегда запускаем в режиме одного процесса для отладки
|
||||
if workers > 1:
|
||||
logger.warning("Многопроцессорный режим может вызвать проблемы сериализации приложения. Использую 1 процесс.")
|
||||
workers = 1
|
||||
|
||||
try:
|
||||
if use_https:
|
||||
# Генерируем сертификаты с помощью mkcert
|
||||
cert_file, key_file = generate_certificates(domain=domain)
|
||||
|
||||
if not cert_file or not key_file:
|
||||
logger.error("Не удалось сгенерировать сертификаты для HTTPS")
|
||||
return
|
||||
|
||||
logger.info(f"Запуск HTTPS сервера на https://{host}:{port} с использованием Granian")
|
||||
# Запускаем Granian сервер с явным указанием ASGI
|
||||
server = Granian(
|
||||
address=host,
|
||||
port=port,
|
||||
workers=workers,
|
||||
interface=Interfaces.ASGI,
|
||||
target="main:app",
|
||||
ssl_cert=Path(cert_file),
|
||||
ssl_key=Path(key_file),
|
||||
)
|
||||
else:
|
||||
logger.info(f"Запуск HTTP сервера на http://{host}:{port} с использованием Granian")
|
||||
server = Granian(
|
||||
address=host,
|
||||
port=port,
|
||||
workers=workers,
|
||||
interface=Interfaces.ASGI,
|
||||
target="main:app",
|
||||
)
|
||||
server.serve()
|
||||
except Exception as e:
|
||||
# В случае проблем с Granian, логируем ошибку
|
||||
logger.error(f"Ошибка при запуске Granian: {e!s}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Запуск сервера разработки с поддержкой HTTPS")
|
||||
parser.add_argument("--https", action="store_true", help="Использовать HTTPS")
|
||||
parser.add_argument("--workers", type=int, default=1, help="Количество рабочих процессов")
|
||||
parser.add_argument("--domain", type=str, default="localhost", help="Домен для сертификата")
|
||||
parser.add_argument("--port", type=int, default=8000, help="Порт для запуска сервера")
|
||||
parser.add_argument("--host", type=str, default="127.0.0.1", help="Хост для запуска сервера")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
run_server(host=args.host, port=args.port, use_https=args.https, workers=args.workers, domain=args.domain)
|
||||
107
docs/README.md
107
docs/README.md
@@ -1,107 +0,0 @@
|
||||
# Документация Discours Core v0.9.16
|
||||
|
||||
## 📚 Быстрый старт
|
||||
|
||||
**Discours Core** - это GraphQL API бэкенд для системы управления контентом с реакциями, рейтингами и темами.
|
||||
|
||||
### 🚀 Запуск
|
||||
|
||||
```shell
|
||||
# Подготовка окружения
|
||||
python3.12 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
uv run pip install -r requirements.dev.txt
|
||||
|
||||
# Сертификаты для HTTPS
|
||||
mkcert -install
|
||||
mkcert localhost
|
||||
|
||||
# Запуск сервера
|
||||
uv run python -m granian main:app --interface asgi
|
||||
```
|
||||
|
||||
### 📊 Статус проекта
|
||||
|
||||
- **Версия**: 0.9.16
|
||||
- **Тесты**: 344/344 проходят (включая E2E Playwright тесты) ✅
|
||||
- **Покрытие**: 90%
|
||||
- **Python**: 3.12+
|
||||
- **База данных**: PostgreSQL 16.1
|
||||
- **Кеш**: Redis 6.2.0
|
||||
- **E2E тесты**: Playwright с автоматическим headless режимом
|
||||
|
||||
## 📖 Документация
|
||||
|
||||
### 🔧 Основные компоненты
|
||||
|
||||
- **[API Documentation](api.md)** - GraphQL API и резолверы
|
||||
- **[Authentication System](auth/README.md)** - 🎯 **Основная документация по аутентификации**
|
||||
- **[RBAC System](rbac-system.md)** - Роли и права доступа
|
||||
- **[Redis Schema](redis-schema.md)** - Схема данных Redis и кеширование
|
||||
- **[Security System](security.md)** - Управление паролями и email
|
||||
- **[Search System](search-system.md)** - 🔍 Семантический поиск с эмбедингами
|
||||
- **[Admin Panel](admin-panel.md)** - Админ-панель управления
|
||||
|
||||
### 🔐 Система аутентификации
|
||||
|
||||
- **[Auth Overview](auth/README.md)** - 🎯 **Главная страница аутентификации**
|
||||
- **[System Architecture](auth/system.md)** - Архитектура и компоненты
|
||||
- **[Architecture Diagrams](auth/architecture.md)** - Диаграммы и потоки данных
|
||||
- **[Session Management](auth/sessions.md)** - Управление сессиями и JWT
|
||||
- **[OAuth Integration](auth/oauth.md)** - Социальные провайдеры
|
||||
- **[Microservices Guide](auth/microservices.md)** - 🔍 **Интеграция с другими сервисами**
|
||||
- **[Migration Guide](auth/migration.md)** - Обновление с предыдущих версий
|
||||
|
||||
### 🛡️ Безопасность и права доступа
|
||||
|
||||
- **[RBAC System](rbac-system.md)** - Система ролей и разрешений
|
||||
- **[Security System](security.md)** - Управление паролями и email
|
||||
- **[Redis Schema](redis-schema.md)** - Схема данных и кеширование
|
||||
|
||||
### 🛠️ Разработка
|
||||
|
||||
- **[Features](features.md)** - Обзор возможностей
|
||||
- **[Testing](testing.md)** - Тестирование и покрытие
|
||||
- **[Security](security.md)** - Безопасность и конфигурация
|
||||
|
||||
## 🔍 Текущие проблемы
|
||||
|
||||
### Тестирование
|
||||
- **Ошибки в тестах кастомных ролей**: `test_custom_roles.py`
|
||||
- **Проблемы с JWT**: `test_token_storage_fix.py`
|
||||
- **E2E тесты браузера**: ✅ Исправлены - добавлен автоматический headless режим для CI/CD
|
||||
|
||||
### Git статус
|
||||
- **48 измененных файлов** в рабочей директории
|
||||
- **5 новых файлов** (включая тесты и роуты)
|
||||
- **3 файла** готовы к коммиту
|
||||
|
||||
## 🎯 Следующие шаги
|
||||
|
||||
1. **Исправить тесты** - Устранить ошибки в тестах кастомных ролей и JWT
|
||||
2. **Настроить E2E** - Исправить браузерные тесты
|
||||
3. **Завершить RBAC** - Доработать систему кастомных ролей
|
||||
4. **Обновить docs** - Синхронизировать документацию
|
||||
5. **Подготовить релиз** - Зафиксировать изменения
|
||||
|
||||
## 🔗 Полезные команды
|
||||
|
||||
```shell
|
||||
# Линтинг и форматирование
|
||||
biome check . --write
|
||||
ruff check . --fix --select I
|
||||
ruff format . --line-length=120
|
||||
|
||||
# Тестирование
|
||||
pytest
|
||||
|
||||
# Проверка типов
|
||||
mypy .
|
||||
|
||||
# Запуск в dev режиме
|
||||
python -m granian main:app --interface asgi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Discours Core** - открытый проект под MIT лицензией. [Подробнее о вкладе](CONTRIBUTING.md)
|
||||
@@ -1,613 +0,0 @@
|
||||
# Администраторская панель Discours
|
||||
|
||||
## Обзор
|
||||
|
||||
Администраторская панель — это комплексная система управления платформой Discours, предоставляющая полный контроль над пользователями, публикациями, сообществами и их ролями.
|
||||
|
||||
## Архитектура системы доступа
|
||||
|
||||
### Уровни доступа
|
||||
|
||||
1. **Системные администраторы** — email в переменной `ADMIN_EMAILS` (управление системой через переменные среды)
|
||||
2. **RBAC роли в сообществах** — `reader`, `author`, `artist`, `expert`, `editor`, `admin` (управляемые через админку)
|
||||
|
||||
**ВАЖНО**:
|
||||
- Роль `admin` в RBAC — это обычная роль в сообществе, управляемая через админку
|
||||
- "Системный администратор" — синтетическая роль, которая НЕ хранится в базе данных
|
||||
- Синтетическая роль добавляется только в API ответы для пользователей из `ADMIN_EMAILS`
|
||||
- На фронте в сообществах синтетическая роль НЕ отображается
|
||||
|
||||
### Декораторы безопасности
|
||||
|
||||
```python
|
||||
@admin_auth_required # Доступ только системным админам (ADMIN_EMAILS)
|
||||
@editor_or_admin_required # Доступ редакторам и админам сообщества (RBAC роли)
|
||||
```
|
||||
|
||||
## Модули администрирования
|
||||
|
||||
### 1. Управление пользователями
|
||||
|
||||
#### Получение списка пользователей
|
||||
```graphql
|
||||
query AdminGetUsers(
|
||||
$limit: Int = 20
|
||||
$offset: Int = 0
|
||||
$search: String = ""
|
||||
) {
|
||||
adminGetUsers(limit: $limit, offset: $offset, search: $search) {
|
||||
authors {
|
||||
id
|
||||
email
|
||||
name
|
||||
slug
|
||||
roles
|
||||
created_at
|
||||
last_seen
|
||||
}
|
||||
total
|
||||
page
|
||||
perPage
|
||||
totalPages
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Особенности:**
|
||||
- Поиск по email, имени и ID
|
||||
- Пагинация с ограничением 1-100 записей
|
||||
- Роли получаются из основного сообщества (ID=1)
|
||||
- Автоматическое добавление синтетической роли "Системный администратор" для email из `ADMIN_EMAILS`
|
||||
|
||||
#### Обновление пользователя
|
||||
```graphql
|
||||
mutation AdminUpdateUser($user: AdminUserUpdateInput!) {
|
||||
adminUpdateUser(user: $user) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Поддерживаемые поля:**
|
||||
- `email` — с проверкой уникальности
|
||||
- `name` — имя пользователя
|
||||
- `slug` — с проверкой уникальности
|
||||
- `roles` — массив ролей для основного сообщества
|
||||
|
||||
### 2. Система ролей и разрешений (RBAC)
|
||||
|
||||
#### Иерархия ролей
|
||||
```
|
||||
reader → author → artist → expert → editor → admin
|
||||
```
|
||||
|
||||
Каждая роль наследует права предыдущих **только при инициализации** сообщества.
|
||||
|
||||
#### Получение ролей
|
||||
```graphql
|
||||
query AdminGetRoles($community: Int) {
|
||||
adminGetRoles(community: $community) {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Без `community` — все системные роли
|
||||
- С `community` — роли конкретного сообщества + счетчик разрешений
|
||||
|
||||
#### Управление ролями в сообществах
|
||||
|
||||
**Получение ролей пользователя:**
|
||||
```graphql
|
||||
query AdminGetUserCommunityRoles(
|
||||
$author_id: Int!
|
||||
$community_id: Int!
|
||||
) {
|
||||
adminGetUserCommunityRoles(
|
||||
author_id: $author_id
|
||||
community_id: $community_id
|
||||
) {
|
||||
author_id
|
||||
community_id
|
||||
roles
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Назначение ролей:**
|
||||
```graphql
|
||||
mutation AdminSetUserCommunityRoles(
|
||||
$author_id: Int!
|
||||
$community_id: Int!
|
||||
$roles: [String!]!
|
||||
) {
|
||||
adminSetUserCommunityRoles(
|
||||
author_id: $author_id
|
||||
community_id: $community_id
|
||||
roles: $roles
|
||||
) {
|
||||
success
|
||||
error
|
||||
author_id
|
||||
community_id
|
||||
roles
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Добавление отдельной роли:**
|
||||
```graphql
|
||||
mutation AdminAddUserToRole(
|
||||
$author_id: Int!
|
||||
$role_id: String!
|
||||
$community_id: Int!
|
||||
) {
|
||||
adminAddUserToRole(
|
||||
author_id: $author_id
|
||||
role_id: $role_id
|
||||
community_id: $community_id
|
||||
) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Удаление роли:**
|
||||
```graphql
|
||||
mutation AdminRemoveUserFromRole(
|
||||
$author_id: Int!
|
||||
$role_id: String!
|
||||
$community_id: Int!
|
||||
) {
|
||||
adminRemoveUserFromRole(
|
||||
author_id: $author_id
|
||||
role_id: $role_id
|
||||
community_id: $community_id
|
||||
) {
|
||||
success
|
||||
removed
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Создание новой роли:**
|
||||
```graphql
|
||||
mutation AdminCreateCustomRole($role: CustomRoleInput!) {
|
||||
adminCreateCustomRole(role: $role) {
|
||||
success
|
||||
error
|
||||
role {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Удаление роли:**
|
||||
```graphql
|
||||
mutation AdminDeleteCustomRole($role_id: String!, $community_id: Int!) {
|
||||
adminDeleteCustomRole(role_id: $role_id, community_id: $community_id) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Особенности ролей:**
|
||||
- Создаются для конкретного сообщества
|
||||
- Сохраняются в Redis с ключом `community:custom_roles:{community_id}`
|
||||
- Имеют уникальный ID в рамках сообщества
|
||||
- Поддерживают описание и иконку
|
||||
- По умолчанию не имеют разрешений (пустой список)
|
||||
|
||||
### 3. Управление сообществами
|
||||
|
||||
#### Участники сообщества
|
||||
```graphql
|
||||
query AdminGetCommunityMembers(
|
||||
$community_id: Int!
|
||||
$limit: Int = 20
|
||||
$offset: Int = 0
|
||||
) {
|
||||
adminGetCommunityMembers(
|
||||
community_id: $community_id
|
||||
limit: $limit
|
||||
offset: $offset
|
||||
) {
|
||||
members {
|
||||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
roles
|
||||
}
|
||||
total
|
||||
community_id
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Настройки ролей сообщества
|
||||
|
||||
**Получение настроек:**
|
||||
```graphql
|
||||
query AdminGetCommunityRoleSettings($community_id: Int!) {
|
||||
adminGetCommunityRoleSettings(community_id: $community_id) {
|
||||
community_id
|
||||
default_roles
|
||||
available_roles
|
||||
error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Обновление настроек:**
|
||||
```graphql
|
||||
mutation AdminUpdateCommunityRoleSettings(
|
||||
$community_id: Int!
|
||||
$default_roles: [String!]!
|
||||
$available_roles: [String!]!
|
||||
) {
|
||||
adminUpdateCommunityRoleSettings(
|
||||
community_id: $community_id
|
||||
default_roles: $default_roles
|
||||
available_roles: $available_roles
|
||||
) {
|
||||
success
|
||||
error
|
||||
community_id
|
||||
default_roles
|
||||
available_roles
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Создание пользовательской роли
|
||||
```graphql
|
||||
mutation AdminCreateCustomRole($role: CustomRoleInput!) {
|
||||
adminCreateCustomRole(role: $role) {
|
||||
success
|
||||
error
|
||||
role {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Удаление пользовательской роли
|
||||
```graphql
|
||||
mutation AdminDeleteCustomRole(
|
||||
$role_id: String!
|
||||
$community_id: Int!
|
||||
) {
|
||||
adminDeleteCustomRole(
|
||||
role_id: $role_id
|
||||
community_id: $community_id
|
||||
) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Управление публикациями
|
||||
|
||||
#### Получение списка публикаций
|
||||
```graphql
|
||||
query AdminGetShouts(
|
||||
$limit: Int = 20
|
||||
$offset: Int = 0
|
||||
$search: String = ""
|
||||
$status: String = "all"
|
||||
$community: Int
|
||||
) {
|
||||
adminGetShouts(
|
||||
limit: $limit
|
||||
offset: $offset
|
||||
search: $search
|
||||
status: $status
|
||||
community: $community
|
||||
) {
|
||||
shouts {
|
||||
id
|
||||
title
|
||||
slug
|
||||
body
|
||||
lead
|
||||
subtitle
|
||||
# ... остальные поля
|
||||
created_by {
|
||||
id
|
||||
email
|
||||
name
|
||||
slug
|
||||
}
|
||||
community {
|
||||
id
|
||||
name
|
||||
slug
|
||||
}
|
||||
authors {
|
||||
id
|
||||
email
|
||||
name
|
||||
slug
|
||||
}
|
||||
topics {
|
||||
id
|
||||
title
|
||||
slug
|
||||
}
|
||||
}
|
||||
total
|
||||
page
|
||||
perPage
|
||||
totalPages
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Статусы публикаций:**
|
||||
- `all` — все публикации (включая удаленные)
|
||||
- `published` — опубликованные
|
||||
- `draft` — черновики
|
||||
- `deleted` — удаленные
|
||||
|
||||
#### Операции с публикациями
|
||||
|
||||
**Обновление:**
|
||||
```graphql
|
||||
mutation AdminUpdateShout($shout: AdminShoutUpdateInput!) {
|
||||
adminUpdateShout(shout: $shout) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Удаление (мягкое):**
|
||||
```graphql
|
||||
mutation AdminDeleteShout($shout_id: Int!) {
|
||||
adminDeleteShout(shout_id: $shout_id) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Восстановление:**
|
||||
```graphql
|
||||
mutation AdminRestoreShout($shout_id: Int!) {
|
||||
adminRestoreShout(shout_id: $shout_id) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Управление приглашениями
|
||||
|
||||
#### Получение списка приглашений
|
||||
```graphql
|
||||
query AdminGetInvites(
|
||||
$limit: Int = 20
|
||||
$offset: Int = 0
|
||||
$search: String = ""
|
||||
$status: String = "all"
|
||||
) {
|
||||
adminGetInvites(
|
||||
limit: $limit
|
||||
offset: $offset
|
||||
search: $search
|
||||
status: $status
|
||||
) {
|
||||
invites {
|
||||
inviter_id
|
||||
author_id
|
||||
shout_id
|
||||
status
|
||||
inviter {
|
||||
id
|
||||
email
|
||||
name
|
||||
slug
|
||||
}
|
||||
author {
|
||||
id
|
||||
email
|
||||
name
|
||||
slug
|
||||
}
|
||||
shout {
|
||||
id
|
||||
title
|
||||
slug
|
||||
created_by {
|
||||
id
|
||||
email
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
page
|
||||
perPage
|
||||
totalPages
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Статусы приглашений:**
|
||||
- `PENDING` — ожидает ответа
|
||||
- `ACCEPTED` — принято
|
||||
- `REJECTED` — отклонено
|
||||
|
||||
#### Операции с приглашениями
|
||||
|
||||
**Обновление статуса:**
|
||||
```graphql
|
||||
mutation AdminUpdateInvite($invite: AdminInviteUpdateInput!) {
|
||||
adminUpdateInvite(invite: $invite) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Удаление:**
|
||||
```graphql
|
||||
mutation AdminDeleteInvite(
|
||||
$inviter_id: Int!
|
||||
$author_id: Int!
|
||||
$shout_id: Int!
|
||||
) {
|
||||
adminDeleteInvite(
|
||||
inviter_id: $inviter_id
|
||||
author_id: $author_id
|
||||
shout_id: $shout_id
|
||||
) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Пакетное удаление:**
|
||||
```graphql
|
||||
mutation AdminDeleteInvitesBatch($invites: [AdminInviteIdInput!]!) {
|
||||
adminDeleteInvitesBatch(invites: $invites) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Переменные окружения
|
||||
|
||||
Системные администраторы могут управлять переменными окружения:
|
||||
|
||||
```graphql
|
||||
query GetEnvVariables {
|
||||
getEnvVariables {
|
||||
name
|
||||
description
|
||||
variables {
|
||||
key
|
||||
value
|
||||
description
|
||||
type
|
||||
isSecret
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```graphql
|
||||
mutation UpdateEnvVariable($key: String!, $value: String!) {
|
||||
updateEnvVariable(key: $key, value: $value) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Управление правами
|
||||
|
||||
Системные администраторы могут обновлять права для всех сообществ:
|
||||
|
||||
```graphql
|
||||
mutation AdminUpdatePermissions {
|
||||
adminUpdatePermissions {
|
||||
success
|
||||
error
|
||||
message
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Назначение:**
|
||||
- Обновляет права для всех существующих сообществ
|
||||
- Применяет новую иерархию ролей
|
||||
- Синхронизирует права с файлом `default_role_permissions.json`
|
||||
- Удаляет старые права и инициализирует новые
|
||||
|
||||
**Когда использовать:**
|
||||
- При изменении файла `services/default_role_permissions.json`
|
||||
- При добавлении новых ролей или изменении иерархии прав
|
||||
- При необходимости синхронизировать права всех сообществ с новыми настройками
|
||||
- После обновления системы RBAC
|
||||
|
||||
**⚠️ Внимание:** Эта операция затрагивает все сообщества в системе. Рекомендуется выполнять только при изменении системы прав.
|
||||
|
||||
## Особенности реализации
|
||||
|
||||
### Принцип DRY
|
||||
- Переиспользование логики из `reader.py`, `editor.py`
|
||||
- Общие утилиты в `_get_user_roles()`
|
||||
- Централизованная обработка ошибок
|
||||
|
||||
### Новая RBAC система
|
||||
- Роли хранятся в CSV формате в `CommunityAuthor.roles`
|
||||
- Методы модели: `add_role()`, `remove_role()`, `set_roles()`, `has_role()`
|
||||
- Права наследуются **только при инициализации**
|
||||
- Redis кэширование развернутых прав
|
||||
|
||||
### Синтетические роли
|
||||
- **"Системный администратор"** — добавляется автоматически для пользователей из `ADMIN_EMAILS`
|
||||
- НЕ хранится в базе данных, только в API ответах
|
||||
- НЕ отображается на фронте в интерфейсах управления сообществами
|
||||
- Используется только для индикации системных прав доступа
|
||||
|
||||
### Безопасность
|
||||
- Валидация всех входных данных
|
||||
- Проверка существования сущностей
|
||||
- Контроль доступа через декораторы
|
||||
- Логирование всех административных действий
|
||||
|
||||
### Производительность
|
||||
- Пагинация для всех списков
|
||||
- Индексы по ключевым полям
|
||||
- Ограничения на размер выборки (max 100)
|
||||
- Оптимизированные SQL запросы с `joinedload`
|
||||
|
||||
Функция автоматически переносит роли из старых таблиц в новый формат CSV.
|
||||
|
||||
## Мониторинг и логирование
|
||||
|
||||
Все административные действия логируются с уровнем INFO:
|
||||
- Изменение ролей пользователей
|
||||
- Обновление настроек сообществ
|
||||
- Операции с публикациями
|
||||
- Управление приглашениями
|
||||
- Обновление прав для всех сообществ
|
||||
|
||||
Ошибки логируются с уровнем ERROR и полным стектрейсом.
|
||||
|
||||
## Лучшие практики
|
||||
|
||||
1. **Всегда проверяйте роли перед назначением**
|
||||
2. **Используйте транзакции для групповых операций**
|
||||
3. **Логируйте критические изменения**
|
||||
4. **Валидируйте права доступа на каждом этапе**
|
||||
5. **Применяйте принцип минимальных привилегий**
|
||||
6. **Обновляйте права сообществ только при изменении системы RBAC**
|
||||
|
||||
## Расширение функциональности
|
||||
|
||||
Для добавления новых административных функций:
|
||||
|
||||
1. Создайте резолвер с соответствующим декоратором
|
||||
2. Добавьте GraphQL схему в `schema/admin.graphql`
|
||||
3. Реализуйте логику с переиспользованием существующих компонентов
|
||||
4. Добавьте тесты и документацию
|
||||
5. Обновите права доступа при необходимости
|
||||
40
docs/api.md
40
docs/api.md
@@ -1,40 +0,0 @@
|
||||
|
||||
|
||||
## API Documentation
|
||||
|
||||
### GraphQL Schema
|
||||
- Mutations: Authentication, content management, security
|
||||
- Queries: Content retrieval, user data
|
||||
- Types: Author, Topic, Shout, Community
|
||||
|
||||
### Key Features
|
||||
|
||||
#### Security Management
|
||||
- Password change with validation
|
||||
- Email change with confirmation
|
||||
- Two-factor authentication flow
|
||||
- Protected fields for user privacy
|
||||
|
||||
#### Content Management
|
||||
- Publication system with drafts
|
||||
- Topic and community organization
|
||||
- Author collaboration tools
|
||||
- Real-time notifications
|
||||
|
||||
#### Following System
|
||||
- Subscribe to authors and topics
|
||||
- Cache-optimized operations
|
||||
- Consistent UI state management
|
||||
|
||||
## Database
|
||||
|
||||
### Models
|
||||
- `Author` - User accounts with RBAC
|
||||
- `Shout` - Publications and articles
|
||||
- `Topic` - Content categorization
|
||||
- `Community` - User groups
|
||||
|
||||
### Cache System
|
||||
- Redis-based caching
|
||||
- Automatic cache invalidation
|
||||
- Optimized for real-time updates
|
||||
@@ -1,294 +0,0 @@
|
||||
# 🔐 Система аутентификации Discours Core
|
||||
|
||||
## 📚 Обзор
|
||||
|
||||
Модульная система аутентификации с JWT токенами, Redis-сессиями, OAuth интеграцией и RBAC авторизацией.
|
||||
|
||||
### 🎯 **Гибридный подход авторизации:**
|
||||
|
||||
**Основной сайт (стандартный подход):**
|
||||
- ✅ **OAuth** (Google/GitHub/Yandex/VK) → Bearer токен в URL → localStorage
|
||||
- ✅ **Email/Password** → Bearer токен в response → localStorage
|
||||
- ✅ **GraphQL запросы** → `Authorization: Bearer <token>`
|
||||
- ✅ **Cross-origin совместимость** → работает везде
|
||||
|
||||
**Админка (максимальная безопасность):**
|
||||
- ✅ **Email/Password** → httpOnly cookie (только для /panel)
|
||||
- ✅ **GraphQL запросы** → `credentials: 'include'`
|
||||
- ✅ **Защита от XSS/CSRF** → httpOnly + SameSite cookies
|
||||
- ❌ **OAuth отключен** → только email/password для админов
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### Для разработчиков
|
||||
|
||||
```python
|
||||
from auth.tokens.sessions import SessionTokenManager
|
||||
from auth.utils import extract_token_from_request
|
||||
|
||||
# Проверка токена (автоматически из cookie или Bearer заголовка)
|
||||
sessions = SessionTokenManager()
|
||||
token = await extract_token_from_request(request)
|
||||
payload = await sessions.verify_session(token)
|
||||
|
||||
if payload:
|
||||
user_id = payload.get("user_id")
|
||||
print(f"Пользователь авторизован: {user_id}")
|
||||
```
|
||||
|
||||
### Для фронтенда
|
||||
|
||||
**Основной сайт (Bearer токены):**
|
||||
```typescript
|
||||
// Токен из localStorage
|
||||
const token = localStorage.getItem('access_token');
|
||||
|
||||
const response = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}` // ✅ Bearer токен из localStorage
|
||||
},
|
||||
body: JSON.stringify({ query, variables })
|
||||
});
|
||||
```
|
||||
|
||||
**Админка (httpOnly cookies):**
|
||||
```typescript
|
||||
// Cookies отправляются автоматически
|
||||
const response = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
credentials: 'include', // ✅ КРИТИЧНО: отправляет httpOnly cookies
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, variables })
|
||||
});
|
||||
```
|
||||
|
||||
### Redis ключи для поиска
|
||||
|
||||
```bash
|
||||
# Сессии пользователей
|
||||
session:{user_id}:{token} # Данные сессии (hash)
|
||||
user_sessions:{user_id} # Список активных токенов (set)
|
||||
|
||||
# OAuth токены (для API интеграций)
|
||||
oauth_access:{user_id}:{provider} # Access токен
|
||||
oauth_refresh:{user_id}:{provider} # Refresh токен
|
||||
```
|
||||
|
||||
## 📖 Документация
|
||||
|
||||
### 🏗️ Архитектура
|
||||
- **[Обзор системы](system.md)** - Компоненты и менеджеры токенов
|
||||
- **[Архитектура](architecture.md)** - Диаграммы и потоки данных
|
||||
- **[Миграция](migration.md)** - Обновление с предыдущих версий
|
||||
|
||||
### 🔑 Аутентификация
|
||||
- **[Управление сессиями](sessions.md)** - JWT токены и Redis хранение
|
||||
- **[OAuth интеграция](oauth.md)** - Социальные провайдеры с httpOnly cookies
|
||||
- **[Микросервисы](microservices.md)** - 🎯 **Интеграция с другими сервисами**
|
||||
|
||||
### 🛠️ Разработка
|
||||
- **[API Reference](api.md)** - Методы и примеры кода
|
||||
- **[Безопасность](security.md)** - Лучшие практики
|
||||
- **[Тестирование](testing.md)** - Unit и E2E тесты
|
||||
|
||||
### 🔗 Связанные системы
|
||||
- **[RBAC System](../rbac-system.md)** - Система ролей и разрешений
|
||||
- **[Security System](../security.md)** - Управление паролями и email
|
||||
- **[Redis Schema](../redis-schema.md)** - Схема данных и кеширование
|
||||
|
||||
## 🔄 OAuth Flow (правильный 2025)
|
||||
|
||||
### 1. 🚀 Инициация OAuth
|
||||
```typescript
|
||||
// Пользователь нажимает "Войти через Google"
|
||||
const handleOAuthLogin = (provider: string) => {
|
||||
// Сохраняем текущую страницу для возврата
|
||||
localStorage.setItem('oauth_return_url', window.location.pathname);
|
||||
|
||||
// Редиректим на OAuth endpoint
|
||||
window.location.href = `/oauth/${provider}/login`;
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 🔄 OAuth Callback (бэкенд)
|
||||
```python
|
||||
# Google → /oauth/google/callback
|
||||
# 1. Обменивает code на access_token
|
||||
# 2. Получает профиль пользователя
|
||||
# 3. Создает JWT сессию
|
||||
# 4. Проверяет тип приложения:
|
||||
# - Основной сайт: редиректит с токеном в URL
|
||||
# - Админка: устанавливает httpOnly cookie
|
||||
```
|
||||
|
||||
### 3. 🌐 Фронтенд финализация
|
||||
|
||||
**Основной сайт:**
|
||||
```typescript
|
||||
// Читаем токен из URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const token = urlParams.get('access_token');
|
||||
const error = urlParams.get('error');
|
||||
|
||||
if (error) {
|
||||
console.error('OAuth error:', error);
|
||||
navigate('/login');
|
||||
} else if (token) {
|
||||
// Сохраняем токен в localStorage
|
||||
localStorage.setItem('access_token', token);
|
||||
|
||||
// Очищаем URL от токена
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
|
||||
// Возвращаемся на сохраненную страницу
|
||||
const returnUrl = localStorage.getItem('oauth_return_url') || '/';
|
||||
localStorage.removeItem('oauth_return_url');
|
||||
navigate(returnUrl);
|
||||
}
|
||||
```
|
||||
|
||||
**Админка:**
|
||||
```typescript
|
||||
// httpOnly cookie уже установлен
|
||||
const error = urlParams.get('error');
|
||||
if (error) {
|
||||
console.error('OAuth error:', error);
|
||||
navigate('/panel/login');
|
||||
} else {
|
||||
// Проверяем сессию (cookie отправится автоматически)
|
||||
await auth.checkSession();
|
||||
navigate('/panel');
|
||||
}
|
||||
```
|
||||
|
||||
## 🔍 Для микросервисов
|
||||
|
||||
### Подключение к Redis
|
||||
|
||||
```python
|
||||
# Используйте тот же Redis connection pool
|
||||
from storage.redis import redis
|
||||
|
||||
# Проверка сессии
|
||||
async def check_user_session(token: str) -> dict | None:
|
||||
sessions = SessionTokenManager()
|
||||
return await sessions.verify_session(token)
|
||||
|
||||
# Массовая проверка токенов
|
||||
from auth.tokens.batch import BatchTokenOperations
|
||||
batch = BatchTokenOperations()
|
||||
results = await batch.batch_validate_tokens(token_list)
|
||||
```
|
||||
|
||||
### HTTP заголовки
|
||||
|
||||
```python
|
||||
# Извлечение токена из запроса (cookie или Bearer)
|
||||
from auth.utils import extract_token_from_request
|
||||
|
||||
token = await extract_token_from_request(request)
|
||||
# Автоматически проверяет:
|
||||
# 1. Authorization: Bearer <token>
|
||||
# 2. Cookie: session_token=<token>
|
||||
```
|
||||
|
||||
## 🎯 Основные компоненты
|
||||
|
||||
- **SessionTokenManager** - JWT сессии с Redis хранением + httpOnly cookies
|
||||
- **OAuthTokenManager** - OAuth access/refresh токены для API интеграций
|
||||
- **BatchTokenOperations** - Массовые операции с токенами
|
||||
- **TokenMonitoring** - Мониторинг и статистика
|
||||
- **AuthMiddleware** - HTTP middleware с поддержкой cookies
|
||||
|
||||
## ⚡ Производительность
|
||||
|
||||
- **Connection pooling** для Redis
|
||||
- **Batch операции** для массовых действий (100-1000 токенов)
|
||||
- **Pipeline использование** для атомарности
|
||||
- **SCAN** вместо KEYS для безопасности
|
||||
- **TTL** автоматическая очистка истекших токенов
|
||||
- **httpOnly cookies** - автоматическая отправка браузером
|
||||
|
||||
## 🛡️ Безопасность (2025)
|
||||
|
||||
### Максимальная защита:
|
||||
- **🚫 Защита от XSS**: httpOnly cookies недоступны JavaScript
|
||||
- **🔒 Защита от CSRF**: SameSite=lax cookies
|
||||
- **🛡️ Единообразие**: Все типы авторизации через cookies
|
||||
- **📱 Автоматическая отправка**: Браузер сам включает cookies
|
||||
|
||||
### Миграция с Bearer токенов:
|
||||
- ✅ OAuth теперь использует httpOnly cookies (вместо localStorage)
|
||||
- ✅ Email/Password использует httpOnly cookies (вместо Bearer)
|
||||
- ✅ Фронтенд: `credentials: 'include'` во всех запросах
|
||||
- ✅ Middleware поддерживает оба подхода для совместимости
|
||||
|
||||
## 🔧 Настройка
|
||||
|
||||
### Environment Variables
|
||||
```bash
|
||||
# OAuth провайдеры
|
||||
GOOGLE_CLIENT_ID=your_google_client_id
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
GITHUB_CLIENT_ID=your_github_client_id
|
||||
GITHUB_CLIENT_SECRET=your_github_client_secret
|
||||
|
||||
# Cookie настройки
|
||||
SESSION_COOKIE_SECURE=true
|
||||
SESSION_COOKIE_HTTPONLY=true
|
||||
SESSION_COOKIE_SAMESITE=lax
|
||||
SESSION_COOKIE_MAX_AGE=2592000 # 30 дней
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY=your_jwt_secret_key
|
||||
JWT_EXPIRATION_HOURS=720 # 30 дней
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
```
|
||||
|
||||
### Быстрая проверка
|
||||
```bash
|
||||
# Проверка OAuth провайдеров
|
||||
curl https://v3.discours.io/oauth/google
|
||||
|
||||
# Проверка сессии
|
||||
curl -b "session_token=your_token" https://v3.discours.io/graphql \
|
||||
-d '{"query":"query { getSession { success author { id } } }"}'
|
||||
```
|
||||
|
||||
## 📊 Мониторинг
|
||||
|
||||
```python
|
||||
from auth.tokens.monitoring import TokenMonitoring
|
||||
|
||||
monitoring = TokenMonitoring()
|
||||
|
||||
# Статистика токенов
|
||||
stats = await monitoring.get_token_statistics()
|
||||
print(f"Active sessions: {stats['session_tokens']}")
|
||||
print(f"Memory usage: {stats['memory_usage'] / 1024 / 1024:.2f} MB")
|
||||
|
||||
# Health check
|
||||
health = await monitoring.health_check()
|
||||
if health["status"] == "healthy":
|
||||
print("✅ Auth system is healthy")
|
||||
```
|
||||
|
||||
## 🎯 Результат архитектуры 2025
|
||||
|
||||
**Гибридный подход - лучшее из двух миров:**
|
||||
|
||||
**Основной сайт (стандартный подход):**
|
||||
- ✅ **OAuth**: Google/GitHub → Bearer токен в URL → localStorage → GraphQL запросы
|
||||
- ✅ **Email/Password**: Login form → Bearer токен в response → localStorage → GraphQL запросы
|
||||
- ✅ **Cross-origin совместимость**: Работает везде, включая мобильные приложения
|
||||
- ✅ **Простота интеграции**: Стандартный Bearer токен подход
|
||||
|
||||
**Админка (максимальная безопасность):**
|
||||
- ❌ **OAuth отключен**: Только email/password для админов
|
||||
- ✅ **Email/Password**: Login form → httpOnly cookie → GraphQL запросы
|
||||
- ✅ **Максимальная безопасность**: Защита от XSS и CSRF
|
||||
- ✅ **Автоматическое управление**: Браузер сам отправляет cookies
|
||||
657
docs/auth/api.md
657
docs/auth/api.md
@@ -1,657 +0,0 @@
|
||||
# 🔧 Auth API Reference
|
||||
|
||||
## 🎯 Обзор
|
||||
|
||||
Полный справочник по API системы аутентификации с примерами кода и использования.
|
||||
|
||||
## 📚 Token Managers
|
||||
|
||||
### SessionTokenManager
|
||||
|
||||
```python
|
||||
from auth.tokens.sessions import SessionTokenManager
|
||||
|
||||
sessions = SessionTokenManager()
|
||||
```
|
||||
|
||||
#### Методы
|
||||
|
||||
##### `create_session(user_id, auth_data=None, username=None, device_info=None)`
|
||||
Создает новую сессию для пользователя.
|
||||
|
||||
**Параметры:**
|
||||
- `user_id` (str): ID пользователя
|
||||
- `auth_data` (dict, optional): Данные аутентификации
|
||||
- `username` (str, optional): Имя пользователя
|
||||
- `device_info` (dict, optional): Информация об устройстве
|
||||
|
||||
**Возвращает:** `str` - JWT токен
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
token = await sessions.create_session(
|
||||
user_id="123",
|
||||
username="john_doe",
|
||||
device_info={"ip": "192.168.1.1", "user_agent": "Mozilla/5.0"}
|
||||
)
|
||||
```
|
||||
|
||||
##### `verify_session(token)`
|
||||
Проверяет валидность JWT токена и Redis сессии.
|
||||
|
||||
**Параметры:**
|
||||
- `token` (str): JWT токен
|
||||
|
||||
**Возвращает:** `dict | None` - Payload токена или None
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
payload = await sessions.verify_session(token)
|
||||
if payload:
|
||||
user_id = payload.get("user_id")
|
||||
username = payload.get("username")
|
||||
```
|
||||
|
||||
##### `validate_session_token(token)`
|
||||
Валидирует токен сессии с дополнительными проверками.
|
||||
|
||||
**Параметры:**
|
||||
- `token` (str): JWT токен
|
||||
|
||||
**Возвращает:** `tuple[bool, dict]` - (валидность, данные)
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
valid, data = await sessions.validate_session_token(token)
|
||||
if valid:
|
||||
print(f"Session valid for user: {data.get('user_id')}")
|
||||
```
|
||||
|
||||
##### `get_session_data(token, user_id)`
|
||||
Получает данные сессии из Redis.
|
||||
|
||||
**Параметры:**
|
||||
- `token` (str): JWT токен
|
||||
- `user_id` (str): ID пользователя
|
||||
|
||||
**Возвращает:** `dict | None` - Данные сессии
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
session_data = await sessions.get_session_data(token, user_id)
|
||||
if session_data:
|
||||
last_activity = session_data.get("last_activity")
|
||||
```
|
||||
|
||||
##### `refresh_session(user_id, old_token, device_info=None)`
|
||||
Обновляет сессию пользователя.
|
||||
|
||||
**Параметры:**
|
||||
- `user_id` (str): ID пользователя
|
||||
- `old_token` (str): Старый JWT токен
|
||||
- `device_info` (dict, optional): Информация об устройстве
|
||||
|
||||
**Возвращает:** `str` - Новый JWT токен
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
new_token = await sessions.refresh_session(
|
||||
user_id="123",
|
||||
old_token=old_token,
|
||||
device_info={"ip": "192.168.1.1"}
|
||||
)
|
||||
```
|
||||
|
||||
##### `revoke_session_token(token)`
|
||||
Отзывает конкретный токен сессии.
|
||||
|
||||
**Параметры:**
|
||||
- `token` (str): JWT токен
|
||||
|
||||
**Возвращает:** `bool` - Успешность операции
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
revoked = await sessions.revoke_session_token(token)
|
||||
if revoked:
|
||||
print("Session revoked successfully")
|
||||
```
|
||||
|
||||
##### `get_user_sessions(user_id)`
|
||||
Получает все активные сессии пользователя.
|
||||
|
||||
**Параметры:**
|
||||
- `user_id` (str): ID пользователя
|
||||
|
||||
**Возвращает:** `list[dict]` - Список сессий
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
user_sessions = await sessions.get_user_sessions("123")
|
||||
for session in user_sessions:
|
||||
print(f"Token: {session['token'][:20]}...")
|
||||
print(f"Last activity: {session['last_activity']}")
|
||||
```
|
||||
|
||||
##### `revoke_user_sessions(user_id)`
|
||||
Отзывает все сессии пользователя.
|
||||
|
||||
**Параметры:**
|
||||
- `user_id` (str): ID пользователя
|
||||
|
||||
**Возвращает:** `int` - Количество отозванных сессий
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
revoked_count = await sessions.revoke_user_sessions("123")
|
||||
print(f"Revoked {revoked_count} sessions")
|
||||
```
|
||||
|
||||
### OAuthTokenManager
|
||||
|
||||
```python
|
||||
from auth.tokens.oauth import OAuthTokenManager
|
||||
|
||||
oauth = OAuthTokenManager()
|
||||
```
|
||||
|
||||
#### Методы
|
||||
|
||||
##### `store_oauth_tokens(user_id, provider, access_token, refresh_token=None, expires_in=3600, additional_data=None)`
|
||||
Сохраняет OAuth токены в Redis.
|
||||
|
||||
**Параметры:**
|
||||
- `user_id` (str): ID пользователя
|
||||
- `provider` (str): OAuth провайдер (google, github, etc.)
|
||||
- `access_token` (str): Access токен
|
||||
- `refresh_token` (str, optional): Refresh токен
|
||||
- `expires_in` (int): Время жизни в секундах
|
||||
- `additional_data` (dict, optional): Дополнительные данные
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
await oauth.store_oauth_tokens(
|
||||
user_id="123",
|
||||
provider="google",
|
||||
access_token="ya29.a0AfH6SM...",
|
||||
refresh_token="1//04...",
|
||||
expires_in=3600,
|
||||
additional_data={"scope": "read write"}
|
||||
)
|
||||
```
|
||||
|
||||
##### `get_token(user_id, provider, token_type)`
|
||||
Получает OAuth токен.
|
||||
|
||||
**Параметры:**
|
||||
- `user_id` (str): ID пользователя
|
||||
- `provider` (str): OAuth провайдер
|
||||
- `token_type` (str): Тип токена ("oauth_access" или "oauth_refresh")
|
||||
|
||||
**Возвращает:** `dict | None` - Данные токена
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
access_data = await oauth.get_token("123", "google", "oauth_access")
|
||||
if access_data:
|
||||
token = access_data["token"]
|
||||
expires_in = access_data.get("expires_in")
|
||||
```
|
||||
|
||||
##### `revoke_oauth_tokens(user_id, provider)`
|
||||
Отзывает OAuth токены провайдера.
|
||||
|
||||
**Параметры:**
|
||||
- `user_id` (str): ID пользователя
|
||||
- `provider` (str): OAuth провайдер
|
||||
|
||||
**Возвращает:** `bool` - Успешность операции
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
revoked = await oauth.revoke_oauth_tokens("123", "google")
|
||||
if revoked:
|
||||
print("OAuth tokens revoked")
|
||||
```
|
||||
|
||||
### BatchTokenOperations
|
||||
|
||||
```python
|
||||
from auth.tokens.batch import BatchTokenOperations
|
||||
|
||||
batch = BatchTokenOperations()
|
||||
```
|
||||
|
||||
#### Методы
|
||||
|
||||
##### `batch_validate_tokens(tokens)`
|
||||
Массовая валидация токенов.
|
||||
|
||||
**Параметры:**
|
||||
- `tokens` (list[str]): Список JWT токенов
|
||||
|
||||
**Возвращает:** `dict[str, bool]` - Результаты валидации
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
tokens = ["token1", "token2", "token3"]
|
||||
results = await batch.batch_validate_tokens(tokens)
|
||||
# {"token1": True, "token2": False, "token3": True}
|
||||
|
||||
for token, is_valid in results.items():
|
||||
print(f"Token {token[:10]}... is {'valid' if is_valid else 'invalid'}")
|
||||
```
|
||||
|
||||
##### `batch_revoke_tokens(tokens)`
|
||||
Массовый отзыв токенов.
|
||||
|
||||
**Параметры:**
|
||||
- `tokens` (list[str]): Список JWT токенов
|
||||
|
||||
**Возвращает:** `int` - Количество отозванных токенов
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
revoked_count = await batch.batch_revoke_tokens(tokens)
|
||||
print(f"Revoked {revoked_count} tokens")
|
||||
```
|
||||
|
||||
##### `cleanup_expired_tokens()`
|
||||
Очистка истекших токенов.
|
||||
|
||||
**Возвращает:** `int` - Количество очищенных токенов
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
cleaned_count = await batch.cleanup_expired_tokens()
|
||||
print(f"Cleaned {cleaned_count} expired tokens")
|
||||
```
|
||||
|
||||
### TokenMonitoring
|
||||
|
||||
```python
|
||||
from auth.tokens.monitoring import TokenMonitoring
|
||||
|
||||
monitoring = TokenMonitoring()
|
||||
```
|
||||
|
||||
#### Методы
|
||||
|
||||
##### `get_token_statistics()`
|
||||
Получает статистику токенов.
|
||||
|
||||
**Возвращает:** `dict` - Статистика системы
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
stats = await monitoring.get_token_statistics()
|
||||
print(f"Active sessions: {stats['session_tokens']}")
|
||||
print(f"OAuth tokens: {stats['oauth_access_tokens']}")
|
||||
print(f"Memory usage: {stats['memory_usage'] / 1024 / 1024:.2f} MB")
|
||||
```
|
||||
|
||||
##### `health_check()`
|
||||
Проверка здоровья системы токенов.
|
||||
|
||||
**Возвращает:** `dict` - Статус системы
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
health = await monitoring.health_check()
|
||||
if health["status"] == "healthy":
|
||||
print("Token system is healthy")
|
||||
print(f"Redis connected: {health['redis_connected']}")
|
||||
else:
|
||||
print(f"System unhealthy: {health.get('error')}")
|
||||
```
|
||||
|
||||
##### `optimize_memory_usage()`
|
||||
Оптимизация использования памяти.
|
||||
|
||||
**Возвращает:** `dict` - Результаты оптимизации
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
results = await monitoring.optimize_memory_usage()
|
||||
print(f"Cleaned expired: {results['cleaned_expired']}")
|
||||
print(f"Memory freed: {results['memory_freed']} bytes")
|
||||
```
|
||||
|
||||
## 🛠️ Utility Functions
|
||||
|
||||
### Auth Utils
|
||||
|
||||
```python
|
||||
from auth.utils import (
|
||||
extract_token_from_request,
|
||||
get_auth_token,
|
||||
get_auth_token_from_context,
|
||||
get_safe_headers,
|
||||
get_user_data_by_token
|
||||
)
|
||||
```
|
||||
|
||||
#### `extract_token_from_request(request)`
|
||||
Извлекает токен из HTTP запроса.
|
||||
|
||||
**Параметры:**
|
||||
- `request`: HTTP запрос (FastAPI, Starlette, etc.)
|
||||
|
||||
**Возвращает:** `str | None` - JWT токен или None
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
token = await extract_token_from_request(request)
|
||||
if token:
|
||||
print(f"Found token: {token[:20]}...")
|
||||
```
|
||||
|
||||
#### `get_auth_token(request)`
|
||||
Расширенное извлечение токена с логированием.
|
||||
|
||||
**Параметры:**
|
||||
- `request`: HTTP запрос
|
||||
|
||||
**Возвращает:** `str | None` - JWT токен или None
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
token = await get_auth_token(request)
|
||||
if token:
|
||||
# Токен найден и залогирован
|
||||
pass
|
||||
```
|
||||
|
||||
#### `get_auth_token_from_context(info)`
|
||||
Извлечение токена из GraphQL контекста.
|
||||
|
||||
**Параметры:**
|
||||
- `info`: GraphQL Info объект
|
||||
|
||||
**Возвращает:** `str | None` - JWT токен или None
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
@auth_required
|
||||
async def protected_resolver(info, **kwargs):
|
||||
token = await get_auth_token_from_context(info)
|
||||
# Используем токен для дополнительных проверок
|
||||
```
|
||||
|
||||
#### `get_safe_headers(request)`
|
||||
Безопасное получение заголовков запроса.
|
||||
|
||||
**Параметры:**
|
||||
- `request`: HTTP запрос
|
||||
|
||||
**Возвращает:** `dict[str, str]` - Словарь заголовков
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
headers = get_safe_headers(request)
|
||||
auth_header = headers.get("authorization", "")
|
||||
user_agent = headers.get("user-agent", "")
|
||||
```
|
||||
|
||||
#### `get_user_data_by_token(token)`
|
||||
Получение данных пользователя по токену.
|
||||
|
||||
**Параметры:**
|
||||
- `token` (str): JWT токен
|
||||
|
||||
**Возвращает:** `dict | None` - Данные пользователя
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
user_data = await get_user_data_by_token(token)
|
||||
if user_data:
|
||||
print(f"User: {user_data['username']}")
|
||||
print(f"ID: {user_data['user_id']}")
|
||||
```
|
||||
|
||||
## 🎭 Decorators
|
||||
|
||||
### GraphQL Decorators
|
||||
|
||||
```python
|
||||
from auth.decorators import auth_required, permission_required
|
||||
```
|
||||
|
||||
#### `@auth_required`
|
||||
Требует авторизации для выполнения resolver'а.
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
@auth_required
|
||||
async def get_user_profile(info, **kwargs):
|
||||
"""Получение профиля пользователя"""
|
||||
user = info.context.get('user')
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email
|
||||
}
|
||||
```
|
||||
|
||||
#### `@permission_required(permission)`
|
||||
Требует конкретного разрешения.
|
||||
|
||||
**Параметры:**
|
||||
- `permission` (str): Название разрешения
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
@auth_required
|
||||
@permission_required("shout:create")
|
||||
async def create_shout(info, input_data):
|
||||
"""Создание публикации"""
|
||||
user = info.context.get('user')
|
||||
|
||||
shout = Shout(
|
||||
title=input_data['title'],
|
||||
content=input_data['content'],
|
||||
author_id=user.id
|
||||
)
|
||||
|
||||
return shout
|
||||
```
|
||||
|
||||
## 🔧 Middleware
|
||||
|
||||
### AuthMiddleware
|
||||
|
||||
```python
|
||||
from auth.middleware import AuthMiddleware
|
||||
|
||||
middleware = AuthMiddleware()
|
||||
```
|
||||
|
||||
#### Методы
|
||||
|
||||
##### `authenticate_user(request)`
|
||||
Аутентификация пользователя из запроса.
|
||||
|
||||
**Параметры:**
|
||||
- `request`: HTTP запрос
|
||||
|
||||
**Возвращает:** `dict | None` - Данные пользователя
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
user_data = await middleware.authenticate_user(request)
|
||||
if user_data:
|
||||
request.user = user_data
|
||||
```
|
||||
|
||||
##### `set_cookie(response, token)`
|
||||
Установка httpOnly cookie с токеном.
|
||||
|
||||
**Параметры:**
|
||||
- `response`: HTTP ответ
|
||||
- `token` (str): JWT токен
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
await middleware.set_cookie(response, token)
|
||||
```
|
||||
|
||||
##### `delete_cookie(response)`
|
||||
Удаление cookie с токеном.
|
||||
|
||||
**Параметры:**
|
||||
- `response`: HTTP ответ
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
await middleware.delete_cookie(response)
|
||||
```
|
||||
|
||||
## 🔒 Error Handling
|
||||
|
||||
### Исключения
|
||||
|
||||
```python
|
||||
from auth.exceptions import (
|
||||
AuthenticationError,
|
||||
InvalidTokenError,
|
||||
TokenExpiredError,
|
||||
OAuthError
|
||||
)
|
||||
```
|
||||
|
||||
#### `AuthenticationError`
|
||||
Базовое исключение аутентификации.
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
try:
|
||||
payload = await sessions.verify_session(token)
|
||||
if not payload:
|
||||
raise AuthenticationError("Invalid session token")
|
||||
except AuthenticationError as e:
|
||||
return {"error": str(e), "status": 401}
|
||||
```
|
||||
|
||||
#### `InvalidTokenError`
|
||||
Невалидный токен.
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
try:
|
||||
valid, data = await sessions.validate_session_token(token)
|
||||
if not valid:
|
||||
raise InvalidTokenError("Token validation failed")
|
||||
except InvalidTokenError as e:
|
||||
return {"error": str(e), "status": 401}
|
||||
```
|
||||
|
||||
#### `TokenExpiredError`
|
||||
Истекший токен.
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
try:
|
||||
# Проверка токена
|
||||
pass
|
||||
except TokenExpiredError as e:
|
||||
return {"error": "Token expired", "status": 401}
|
||||
```
|
||||
|
||||
## 📊 Response Formats
|
||||
|
||||
### Успешные ответы
|
||||
|
||||
```python
|
||||
# Успешная аутентификация
|
||||
{
|
||||
"authenticated": True,
|
||||
"user_id": "123",
|
||||
"username": "john_doe",
|
||||
"expires_at": 1640995200
|
||||
}
|
||||
|
||||
# Статистика токенов
|
||||
{
|
||||
"session_tokens": 150,
|
||||
"oauth_access_tokens": 25,
|
||||
"oauth_refresh_tokens": 25,
|
||||
"verification_tokens": 5,
|
||||
"memory_usage": 1048576
|
||||
}
|
||||
|
||||
# Health check
|
||||
{
|
||||
"status": "healthy",
|
||||
"redis_connected": True,
|
||||
"token_count": 205,
|
||||
"timestamp": 1640995200
|
||||
}
|
||||
```
|
||||
|
||||
### Ошибки
|
||||
|
||||
```python
|
||||
# Ошибка аутентификации
|
||||
{
|
||||
"authenticated": False,
|
||||
"error": "Invalid or expired token",
|
||||
"status": 401
|
||||
}
|
||||
|
||||
# Ошибка системы
|
||||
{
|
||||
"status": "error",
|
||||
"error": "Redis connection failed",
|
||||
"timestamp": 1640995200
|
||||
}
|
||||
```
|
||||
|
||||
## 🧪 Testing Helpers
|
||||
|
||||
### Mock Utilities
|
||||
|
||||
```python
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
# Mock SessionTokenManager
|
||||
@patch('auth.tokens.sessions.SessionTokenManager')
|
||||
async def test_auth(mock_sessions):
|
||||
mock_sessions.return_value.verify_session.return_value = {
|
||||
"user_id": "123",
|
||||
"username": "testuser"
|
||||
}
|
||||
|
||||
# Ваш тест здесь
|
||||
pass
|
||||
|
||||
# Mock Redis
|
||||
@patch('storage.redis.redis')
|
||||
async def test_redis_operations(mock_redis):
|
||||
mock_redis.get.return_value = b'{"user_id": "123"}'
|
||||
mock_redis.exists.return_value = True
|
||||
|
||||
# Ваш тест здесь
|
||||
pass
|
||||
```
|
||||
|
||||
### Test Fixtures
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_token():
|
||||
"""Фикстура для создания тестового токена"""
|
||||
sessions = SessionTokenManager()
|
||||
return await sessions.create_session(
|
||||
user_id="test_user",
|
||||
username="testuser"
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
async def authenticated_request(auth_token):
|
||||
"""Фикстура для аутентифицированного запроса"""
|
||||
mock_request = AsyncMock()
|
||||
mock_request.headers = {"authorization": f"Bearer {auth_token}"}
|
||||
return mock_request
|
||||
```
|
||||
@@ -1,306 +0,0 @@
|
||||
# 🏗️ Архитектура системы авторизации Discours Core
|
||||
|
||||
## 🎯 Обзор архитектуры 2025
|
||||
|
||||
Модульная система авторизации с **httpOnly cookies** для максимальной безопасности и единообразия.
|
||||
|
||||
**Ключевые принципы:**
|
||||
- **🍪 httpOnly cookies** для ВСЕХ типов авторизации (OAuth + Email/Password)
|
||||
- **🛡️ Максимальная безопасность** - защита от XSS и CSRF
|
||||
- **🔄 Единообразие** - один механизм для всех провайдеров
|
||||
- **📱 Автоматическое управление** - браузер сам отправляет cookies
|
||||
|
||||
**Хранение данных:**
|
||||
- **Сессии** → Redis (JWT токены) + httpOnly cookies (передача)
|
||||
- **OAuth токены** → Redis (для API интеграций)
|
||||
- **Пользователи** → PostgreSQL (основные данные + OAuth связи)
|
||||
|
||||
## 📊 Схема потоков данных
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Frontend"
|
||||
FE[Web Frontend]
|
||||
MOB[Mobile App]
|
||||
API[API Clients]
|
||||
end
|
||||
|
||||
subgraph "Auth Layer"
|
||||
MW[AuthMiddleware]
|
||||
DEC[GraphQL Decorators]
|
||||
UTILS[Auth Utils]
|
||||
end
|
||||
|
||||
subgraph "Token Managers"
|
||||
STM[SessionTokenManager]
|
||||
VTM[VerificationTokenManager]
|
||||
OTM[OAuthTokenManager]
|
||||
BTM[BatchTokenOperations]
|
||||
MON[TokenMonitoring]
|
||||
end
|
||||
|
||||
subgraph "Storage"
|
||||
REDIS[(Redis)]
|
||||
DB[(PostgreSQL)]
|
||||
end
|
||||
|
||||
subgraph "External OAuth"
|
||||
GOOGLE[Google]
|
||||
GITHUB[GitHub]
|
||||
FACEBOOK[Facebook]
|
||||
VK[VK]
|
||||
YANDEX[Yandex]
|
||||
end
|
||||
|
||||
FE --> MW
|
||||
MOB --> MW
|
||||
API --> MW
|
||||
|
||||
MW --> STM
|
||||
MW --> UTILS
|
||||
|
||||
DEC --> STM
|
||||
UTILS --> STM
|
||||
|
||||
STM --> REDIS
|
||||
VTM --> REDIS
|
||||
OTM --> REDIS
|
||||
BTM --> REDIS
|
||||
MON --> REDIS
|
||||
|
||||
STM --> DB
|
||||
|
||||
OTM --> GOOGLE
|
||||
OTM --> GITHUB
|
||||
OTM --> FACEBOOK
|
||||
OTM --> VK
|
||||
OTM --> YANDEX
|
||||
```
|
||||
|
||||
## 🏗️ Диаграмма компонентов
|
||||
|
||||
**Примечание:** Токены хранятся только в Redis, PostgreSQL используется только для пользовательских данных и OAuth связей.
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "HTTP Layer"
|
||||
REQ[HTTP Request]
|
||||
RESP[HTTP Response]
|
||||
end
|
||||
|
||||
subgraph "Middleware Layer"
|
||||
AUTH_MW[AuthMiddleware]
|
||||
UTILS[Auth Utils]
|
||||
end
|
||||
|
||||
subgraph "Token Management"
|
||||
STM[SessionTokenManager]
|
||||
VTM[VerificationTokenManager]
|
||||
OTM[OAuthTokenManager]
|
||||
BTM[BatchTokenOperations]
|
||||
MON[TokenMonitoring]
|
||||
end
|
||||
|
||||
subgraph "Storage"
|
||||
REDIS[(Redis)]
|
||||
DB[(PostgreSQL)]
|
||||
end
|
||||
|
||||
subgraph "External"
|
||||
OAUTH_PROV[OAuth Providers]
|
||||
end
|
||||
|
||||
REQ --> AUTH_MW
|
||||
AUTH_MW --> UTILS
|
||||
UTILS --> STM
|
||||
|
||||
STM --> REDIS
|
||||
VTM --> REDIS
|
||||
OTM --> REDIS
|
||||
BTM --> REDIS
|
||||
MON --> REDIS
|
||||
|
||||
STM --> DB
|
||||
OTM --> OAUTH_PROV
|
||||
|
||||
STM --> RESP
|
||||
VTM --> RESP
|
||||
OTM --> RESP
|
||||
```
|
||||
|
||||
## 🔐 OAuth Flow (httpOnly cookies)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant F as Frontend
|
||||
participant B as Backend
|
||||
participant R as Redis
|
||||
participant P as OAuth Provider
|
||||
|
||||
U->>F: Click "Login with Provider"
|
||||
F->>B: GET /oauth/{provider}/login
|
||||
B->>R: Store OAuth state (TTL: 10 min)
|
||||
B->>P: Redirect to Provider
|
||||
P->>U: Show authorization page
|
||||
U->>P: Grant permission
|
||||
P->>B: GET /oauth/{provider}/callback?code={code}&state={state}
|
||||
B->>R: Verify state
|
||||
B->>P: Exchange code for token
|
||||
P->>B: Return access token + user data
|
||||
B->>B: Create/update user
|
||||
B->>B: Generate JWT session token
|
||||
B->>R: Store session in Redis
|
||||
B->>F: Redirect + Set httpOnly cookie
|
||||
Note over B,F: Cookie: session_token=JWT<br/>HttpOnly, Secure, SameSite=lax
|
||||
F->>U: User logged in (cookie automatic)
|
||||
|
||||
Note over F,B: All subsequent requests
|
||||
F->>B: GraphQL with credentials: 'include'
|
||||
Note over F,B: Browser automatically sends cookie
|
||||
```
|
||||
|
||||
## 🔄 Session Management (httpOnly cookies)
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Anonymous
|
||||
Anonymous --> Authenticating: Login attempt (OAuth/Email)
|
||||
Authenticating --> Authenticated: Valid JWT + httpOnly cookie set
|
||||
Authenticating --> Anonymous: Invalid credentials
|
||||
Authenticated --> Refreshing: Token near expiry
|
||||
Refreshing --> Authenticated: New httpOnly cookie set
|
||||
Refreshing --> Anonymous: Refresh failed
|
||||
Authenticated --> Anonymous: Logout (cookie deleted)
|
||||
Authenticated --> Anonymous: Token expired (cookie invalid)
|
||||
|
||||
note right of Authenticated
|
||||
All requests include
|
||||
httpOnly cookie automatically
|
||||
via credentials: 'include'
|
||||
end note
|
||||
```
|
||||
|
||||
## 🗄️ Redis структура данных
|
||||
|
||||
```bash
|
||||
# JWT Sessions (основные - передаются через httpOnly cookies)
|
||||
session:{user_id}:{token} # Hash: {user_id, username, device_info, last_activity}
|
||||
user_sessions:{user_id} # Set: {token1, token2, ...}
|
||||
|
||||
# OAuth Tokens (для API интеграций - НЕ для аутентификации)
|
||||
oauth_access:{user_id}:{provider} # JSON: {token, expires_in, scope}
|
||||
oauth_refresh:{user_id}:{provider} # JSON: {token, provider_data}
|
||||
|
||||
# OAuth State (временные - для CSRF защиты)
|
||||
oauth_state:{state} # JSON: {provider, redirect_uri, code_verifier} TTL: 10 мин
|
||||
|
||||
# Verification Tokens (email подтверждения и т.д.)
|
||||
verification_token:{token} # JSON: {user_id, type, data, created_at}
|
||||
```
|
||||
|
||||
### 🔄 Изменения в архитектуре 2025:
|
||||
|
||||
**Убрано:**
|
||||
- ❌ Токены в URL параметрах (небезопасно)
|
||||
- ❌ localStorage для основных токенов (уязвимо к XSS)
|
||||
- ❌ Bearer заголовки для веб-приложений (сложнее управлять)
|
||||
|
||||
**Добавлено:**
|
||||
- ✅ httpOnly cookies для всех типов авторизации
|
||||
- ✅ Автоматическая отправка cookies браузером
|
||||
- ✅ SameSite защита от CSRF
|
||||
- ✅ Secure flag для HTTPS
|
||||
|
||||
### Примеры Redis команд
|
||||
|
||||
```bash
|
||||
# Поиск сессий пользователя
|
||||
redis-cli --scan --pattern "session:123:*"
|
||||
|
||||
# Получение данных сессии
|
||||
redis-cli HGETALL "session:123:your_token_here"
|
||||
|
||||
# Проверка TTL
|
||||
redis-cli TTL "session:123:your_token_here"
|
||||
|
||||
# Поиск OAuth токенов
|
||||
redis-cli --scan --pattern "oauth_access:123:*"
|
||||
```
|
||||
|
||||
## 🔒 Security Components
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Input Validation"
|
||||
EMAIL[Email Format]
|
||||
PASS[Password Strength]
|
||||
TOKEN[JWT Validation]
|
||||
end
|
||||
|
||||
subgraph "Authentication"
|
||||
BCRYPT[bcrypt + SHA256]
|
||||
JWT_SIGN[JWT Signing]
|
||||
OAUTH_VERIFY[OAuth Verification]
|
||||
end
|
||||
|
||||
subgraph "Authorization"
|
||||
RBAC[RBAC System]
|
||||
PERM[Permission Checks]
|
||||
RESOURCE[Resource Access]
|
||||
end
|
||||
|
||||
subgraph "Session Security"
|
||||
TTL[Redis TTL]
|
||||
REVOKE[Token Revocation]
|
||||
REFRESH[Secure Refresh]
|
||||
end
|
||||
|
||||
EMAIL --> BCRYPT
|
||||
PASS --> BCRYPT
|
||||
TOKEN --> JWT_SIGN
|
||||
|
||||
BCRYPT --> RBAC
|
||||
JWT_SIGN --> RBAC
|
||||
OAUTH_VERIFY --> RBAC
|
||||
|
||||
RBAC --> PERM
|
||||
PERM --> RESOURCE
|
||||
|
||||
RESOURCE --> TTL
|
||||
RESOURCE --> REVOKE
|
||||
RESOURCE --> REFRESH
|
||||
```
|
||||
|
||||
## ⚡ Performance & Scaling
|
||||
|
||||
### Горизонтальное масштабирование
|
||||
- **Stateless JWT** токены
|
||||
- **Redis Cluster** для высокой доступности
|
||||
- **Load Balancer** aware session management
|
||||
|
||||
### Оптимизации
|
||||
- **Connection pooling** для Redis
|
||||
- **Batch operations** для массовых операций (100-1000 токенов)
|
||||
- **Pipeline использование** для атомарности
|
||||
- **SCAN** вместо KEYS для безопасности
|
||||
|
||||
### Мониторинг производительности
|
||||
```python
|
||||
from auth.tokens.monitoring import TokenMonitoring
|
||||
|
||||
monitoring = TokenMonitoring()
|
||||
|
||||
# Статистика токенов
|
||||
stats = await monitoring.get_token_statistics()
|
||||
# {
|
||||
# "session_tokens": 150,
|
||||
# "verification_tokens": 5,
|
||||
# "oauth_access_tokens": 25,
|
||||
# "memory_usage": 1048576
|
||||
# }
|
||||
|
||||
# Health check
|
||||
health = await monitoring.health_check()
|
||||
# {"status": "healthy", "redis_connected": True}
|
||||
```
|
||||
@@ -1,546 +0,0 @@
|
||||
# 🔍 Аутентификация для микросервисов
|
||||
|
||||
## 🎯 Обзор
|
||||
|
||||
Руководство по интеграции системы аутентификации Discours Core с другими микросервисами через общий Redis connection pool.
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### Подключение к Redis
|
||||
|
||||
```python
|
||||
# Используйте тот же Redis connection pool
|
||||
from storage.redis import redis
|
||||
|
||||
# Или создайте свой с теми же настройками
|
||||
import aioredis
|
||||
|
||||
redis_client = aioredis.from_url(
|
||||
"redis://localhost:6379/0",
|
||||
max_connections=20,
|
||||
retry_on_timeout=True,
|
||||
socket_keepalive=True,
|
||||
socket_keepalive_options={},
|
||||
health_check_interval=30
|
||||
)
|
||||
```
|
||||
|
||||
### Проверка токена сессии
|
||||
|
||||
```python
|
||||
from auth.tokens.sessions import SessionTokenManager
|
||||
from auth.utils import extract_token_from_request
|
||||
|
||||
async def check_user_session(request) -> dict | None:
|
||||
"""Проверка сессии пользователя в микросервисе"""
|
||||
|
||||
# 1. Извлекаем токен из запроса
|
||||
token = await extract_token_from_request(request)
|
||||
if not token:
|
||||
return None
|
||||
|
||||
# 2. Проверяем сессию через SessionTokenManager
|
||||
sessions = SessionTokenManager()
|
||||
payload = await sessions.verify_session(token)
|
||||
|
||||
if payload:
|
||||
return {
|
||||
"authenticated": True,
|
||||
"user_id": payload.get("user_id"),
|
||||
"username": payload.get("username"),
|
||||
"expires_at": payload.get("exp")
|
||||
}
|
||||
|
||||
return {"authenticated": False, "error": "Invalid token"}
|
||||
```
|
||||
|
||||
## 🔑 Redis ключи для поиска
|
||||
|
||||
### Структура данных
|
||||
|
||||
```bash
|
||||
# Сессии пользователей
|
||||
session:{user_id}:{token} # Hash: {user_id, username, device_info, last_activity}
|
||||
user_sessions:{user_id} # Set: {token1, token2, ...}
|
||||
|
||||
# OAuth токены
|
||||
oauth_access:{user_id}:{provider} # JSON: {token, expires_in, scope}
|
||||
oauth_refresh:{user_id}:{provider} # JSON: {token, provider_data}
|
||||
|
||||
# Токены подтверждения
|
||||
verification_token:{token} # JSON: {user_id, type, data, created_at}
|
||||
|
||||
# OAuth состояние
|
||||
oauth_state:{state} # JSON: {provider, redirect_uri, code_verifier}
|
||||
```
|
||||
|
||||
### Примеры поиска
|
||||
|
||||
```python
|
||||
from storage.redis import redis
|
||||
|
||||
# 1. Поиск всех сессий пользователя
|
||||
async def get_user_sessions(user_id: int) -> list[str]:
|
||||
"""Получить все активные токены пользователя"""
|
||||
session_key = f"user_sessions:{user_id}"
|
||||
tokens = await redis.smembers(session_key)
|
||||
return [token.decode() for token in tokens] if tokens else []
|
||||
|
||||
# 2. Получение данных конкретной сессии
|
||||
async def get_session_data(user_id: int, token: str) -> dict | None:
|
||||
"""Получить данные сессии"""
|
||||
session_key = f"session:{user_id}:{token}"
|
||||
data = await redis.hgetall(session_key)
|
||||
|
||||
if data:
|
||||
return {k.decode(): v.decode() for k, v in data.items()}
|
||||
return None
|
||||
|
||||
# 3. Проверка существования токена
|
||||
async def token_exists(user_id: int, token: str) -> bool:
|
||||
"""Проверить существование токена"""
|
||||
session_key = f"session:{user_id}:{token}"
|
||||
return await redis.exists(session_key)
|
||||
|
||||
# 4. Получение TTL токена
|
||||
async def get_token_ttl(user_id: int, token: str) -> int:
|
||||
"""Получить время жизни токена в секундах"""
|
||||
session_key = f"session:{user_id}:{token}"
|
||||
return await redis.ttl(session_key)
|
||||
```
|
||||
|
||||
## 🛠️ Методы интеграции
|
||||
|
||||
### 1. Прямая проверка токена
|
||||
|
||||
```python
|
||||
from auth.tokens.sessions import SessionTokenManager
|
||||
|
||||
async def authenticate_request(request) -> dict:
|
||||
"""Аутентификация запроса в микросервисе"""
|
||||
|
||||
sessions = SessionTokenManager()
|
||||
|
||||
# Извлекаем токен
|
||||
token = await extract_token_from_request(request)
|
||||
if not token:
|
||||
return {"authenticated": False, "error": "No token provided"}
|
||||
|
||||
try:
|
||||
# Проверяем JWT и Redis сессию
|
||||
payload = await sessions.verify_session(token)
|
||||
|
||||
if payload:
|
||||
user_id = payload.get("user_id")
|
||||
|
||||
# Дополнительно получаем данные сессии из Redis
|
||||
session_data = await sessions.get_session_data(token, user_id)
|
||||
|
||||
return {
|
||||
"authenticated": True,
|
||||
"user_id": user_id,
|
||||
"username": payload.get("username"),
|
||||
"session_data": session_data,
|
||||
"expires_at": payload.get("exp")
|
||||
}
|
||||
else:
|
||||
return {"authenticated": False, "error": "Invalid or expired token"}
|
||||
|
||||
except Exception as e:
|
||||
return {"authenticated": False, "error": f"Authentication error: {str(e)}"}
|
||||
```
|
||||
|
||||
### 2. Массовая проверка токенов
|
||||
|
||||
```python
|
||||
from auth.tokens.batch import BatchTokenOperations
|
||||
|
||||
async def validate_multiple_tokens(tokens: list[str]) -> dict[str, bool]:
|
||||
"""Массовая проверка токенов для API gateway"""
|
||||
|
||||
batch = BatchTokenOperations()
|
||||
return await batch.batch_validate_tokens(tokens)
|
||||
|
||||
# Использование
|
||||
async def api_gateway_auth(request_tokens: list[str]):
|
||||
"""Пример использования в API Gateway"""
|
||||
|
||||
results = await validate_multiple_tokens(request_tokens)
|
||||
|
||||
authenticated_requests = []
|
||||
for token, is_valid in results.items():
|
||||
if is_valid:
|
||||
# Получаем данные пользователя для валидных токенов
|
||||
sessions = SessionTokenManager()
|
||||
payload = await sessions.verify_session(token)
|
||||
if payload:
|
||||
authenticated_requests.append({
|
||||
"token": token,
|
||||
"user_id": payload.get("user_id"),
|
||||
"username": payload.get("username")
|
||||
})
|
||||
|
||||
return authenticated_requests
|
||||
```
|
||||
|
||||
### 3. Получение данных пользователя
|
||||
|
||||
```python
|
||||
from auth.utils import get_user_data_by_token
|
||||
|
||||
async def get_user_info(token: str) -> dict | None:
|
||||
"""Получить информацию о пользователе по токену"""
|
||||
|
||||
try:
|
||||
user_data = await get_user_data_by_token(token)
|
||||
return user_data
|
||||
except Exception as e:
|
||||
print(f"Ошибка получения данных пользователя: {e}")
|
||||
return None
|
||||
|
||||
# Использование
|
||||
async def protected_endpoint(request):
|
||||
"""Пример защищенного endpoint в микросервисе"""
|
||||
|
||||
token = await extract_token_from_request(request)
|
||||
user_info = await get_user_info(token)
|
||||
|
||||
if not user_info:
|
||||
return {"error": "Unauthorized", "status": 401}
|
||||
|
||||
return {
|
||||
"message": f"Hello, {user_info.get('username')}!",
|
||||
"user_id": user_info.get("user_id"),
|
||||
"status": 200
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 HTTP заголовки и извлечение токенов
|
||||
|
||||
### Поддерживаемые форматы
|
||||
|
||||
```python
|
||||
from auth.utils import extract_token_from_request, get_safe_headers
|
||||
|
||||
async def extract_auth_token(request) -> str | None:
|
||||
"""Извлечение токена из различных источников"""
|
||||
|
||||
# 1. Автоматическое извлечение (рекомендуется)
|
||||
token = await extract_token_from_request(request)
|
||||
if token:
|
||||
return token
|
||||
|
||||
# 2. Ручное извлечение из заголовков
|
||||
headers = get_safe_headers(request)
|
||||
|
||||
# Bearer токен в Authorization
|
||||
auth_header = headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
return auth_header[7:].strip()
|
||||
|
||||
# Кастомный заголовок X-Session-Token
|
||||
session_token = headers.get("x-session-token")
|
||||
if session_token:
|
||||
return session_token.strip()
|
||||
|
||||
# Cookie (для веб-приложений)
|
||||
if hasattr(request, "cookies"):
|
||||
cookie_token = request.cookies.get("session_token")
|
||||
if cookie_token:
|
||||
return cookie_token
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### Примеры HTTP запросов
|
||||
|
||||
```bash
|
||||
# 1. Bearer токен в Authorization header
|
||||
curl -H "Authorization: Bearer your_jwt_token_here" \
|
||||
http://localhost:8000/api/protected
|
||||
|
||||
# 2. Кастомный заголовок
|
||||
curl -H "X-Session-Token: your_jwt_token_here" \
|
||||
http://localhost:8000/api/protected
|
||||
|
||||
# 3. Cookie (автоматически для веб-приложений)
|
||||
curl -b "session_token=your_jwt_token_here" \
|
||||
http://localhost:8000/api/protected
|
||||
```
|
||||
|
||||
## 📊 Мониторинг и статистика
|
||||
|
||||
### Health Check
|
||||
|
||||
```python
|
||||
from auth.tokens.monitoring import TokenMonitoring
|
||||
|
||||
async def auth_health_check() -> dict:
|
||||
"""Health check системы аутентификации"""
|
||||
|
||||
monitoring = TokenMonitoring()
|
||||
|
||||
try:
|
||||
# Проверяем состояние системы токенов
|
||||
health = await monitoring.health_check()
|
||||
|
||||
# Получаем статистику
|
||||
stats = await monitoring.get_token_statistics()
|
||||
|
||||
return {
|
||||
"status": health.get("status", "unknown"),
|
||||
"redis_connected": health.get("redis_connected", False),
|
||||
"active_sessions": stats.get("session_tokens", 0),
|
||||
"oauth_tokens": stats.get("oauth_access_tokens", 0) + stats.get("oauth_refresh_tokens", 0),
|
||||
"memory_usage_mb": stats.get("memory_usage", 0) / 1024 / 1024,
|
||||
"timestamp": int(time.time())
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"timestamp": int(time.time())
|
||||
}
|
||||
|
||||
# Использование в endpoint
|
||||
async def health_endpoint():
|
||||
"""Endpoint для мониторинга"""
|
||||
health_data = await auth_health_check()
|
||||
|
||||
if health_data["status"] == "healthy":
|
||||
return {"health": health_data, "status": 200}
|
||||
else:
|
||||
return {"health": health_data, "status": 503}
|
||||
```
|
||||
|
||||
### Статистика использования
|
||||
|
||||
```python
|
||||
async def get_auth_statistics() -> dict:
|
||||
"""Получить статистику использования аутентификации"""
|
||||
|
||||
monitoring = TokenMonitoring()
|
||||
stats = await monitoring.get_token_statistics()
|
||||
|
||||
return {
|
||||
"sessions": {
|
||||
"active": stats.get("session_tokens", 0),
|
||||
"total_memory": stats.get("memory_usage", 0)
|
||||
},
|
||||
"oauth": {
|
||||
"access_tokens": stats.get("oauth_access_tokens", 0),
|
||||
"refresh_tokens": stats.get("oauth_refresh_tokens", 0)
|
||||
},
|
||||
"verification": {
|
||||
"pending": stats.get("verification_tokens", 0)
|
||||
},
|
||||
"redis": {
|
||||
"connected": stats.get("redis_connected", False),
|
||||
"memory_usage_mb": stats.get("memory_usage", 0) / 1024 / 1024
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔒 Безопасность для микросервисов
|
||||
|
||||
### Валидация токенов
|
||||
|
||||
```python
|
||||
async def secure_token_validation(token: str) -> dict:
|
||||
"""Безопасная валидация токена с дополнительными проверками"""
|
||||
|
||||
if not token or len(token) < 10:
|
||||
return {"valid": False, "error": "Invalid token format"}
|
||||
|
||||
try:
|
||||
sessions = SessionTokenManager()
|
||||
|
||||
# 1. Проверяем JWT структуру и подпись
|
||||
payload = await sessions.verify_session(token)
|
||||
if not payload:
|
||||
return {"valid": False, "error": "Invalid JWT token"}
|
||||
|
||||
user_id = payload.get("user_id")
|
||||
if not user_id:
|
||||
return {"valid": False, "error": "Missing user_id in token"}
|
||||
|
||||
# 2. Проверяем существование сессии в Redis
|
||||
session_exists = await redis.exists(f"session:{user_id}:{token}")
|
||||
if not session_exists:
|
||||
return {"valid": False, "error": "Session not found in Redis"}
|
||||
|
||||
# 3. Проверяем TTL
|
||||
ttl = await redis.ttl(f"session:{user_id}:{token}")
|
||||
if ttl <= 0:
|
||||
return {"valid": False, "error": "Session expired"}
|
||||
|
||||
# 4. Обновляем last_activity
|
||||
await redis.hset(f"session:{user_id}:{token}", "last_activity", int(time.time()))
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"user_id": user_id,
|
||||
"username": payload.get("username"),
|
||||
"expires_in": ttl,
|
||||
"last_activity": int(time.time())
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"valid": False, "error": f"Validation error: {str(e)}"}
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
```python
|
||||
from collections import defaultdict
|
||||
import time
|
||||
|
||||
# Простой in-memory rate limiter (для production используйте Redis)
|
||||
request_counts = defaultdict(list)
|
||||
|
||||
async def rate_limit_check(user_id: str, max_requests: int = 100, window_seconds: int = 60) -> bool:
|
||||
"""Проверка rate limiting для пользователя"""
|
||||
|
||||
current_time = time.time()
|
||||
user_requests = request_counts[user_id]
|
||||
|
||||
# Удаляем старые запросы
|
||||
user_requests[:] = [req_time for req_time in user_requests if current_time - req_time < window_seconds]
|
||||
|
||||
# Проверяем лимит
|
||||
if len(user_requests) >= max_requests:
|
||||
return False
|
||||
|
||||
# Добавляем текущий запрос
|
||||
user_requests.append(current_time)
|
||||
return True
|
||||
|
||||
# Использование в middleware
|
||||
async def auth_with_rate_limiting(request):
|
||||
"""Аутентификация с rate limiting"""
|
||||
|
||||
auth_result = await authenticate_request(request)
|
||||
|
||||
if auth_result["authenticated"]:
|
||||
user_id = str(auth_result["user_id"])
|
||||
|
||||
if not await rate_limit_check(user_id):
|
||||
return {"error": "Rate limit exceeded", "status": 429}
|
||||
|
||||
return auth_result
|
||||
```
|
||||
|
||||
## 🧪 Тестирование интеграции
|
||||
|
||||
### Unit тесты
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_microservice_auth():
|
||||
"""Тест аутентификации в микросервисе"""
|
||||
|
||||
# Mock request с токеном
|
||||
mock_request = AsyncMock()
|
||||
mock_request.headers = {"authorization": "Bearer valid_token"}
|
||||
|
||||
# Mock SessionTokenManager
|
||||
with patch('auth.tokens.sessions.SessionTokenManager') as mock_sessions:
|
||||
mock_sessions.return_value.verify_session.return_value = {
|
||||
"user_id": "123",
|
||||
"username": "testuser",
|
||||
"exp": int(time.time()) + 3600
|
||||
}
|
||||
|
||||
result = await authenticate_request(mock_request)
|
||||
|
||||
assert result["authenticated"] is True
|
||||
assert result["user_id"] == "123"
|
||||
assert result["username"] == "testuser"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_token_validation():
|
||||
"""Тест массовой валидации токенов"""
|
||||
|
||||
tokens = ["token1", "token2", "token3"]
|
||||
|
||||
with patch('auth.tokens.batch.BatchTokenOperations') as mock_batch:
|
||||
mock_batch.return_value.batch_validate_tokens.return_value = {
|
||||
"token1": True,
|
||||
"token2": False,
|
||||
"token3": True
|
||||
}
|
||||
|
||||
results = await validate_multiple_tokens(tokens)
|
||||
|
||||
assert results["token1"] is True
|
||||
assert results["token2"] is False
|
||||
assert results["token3"] is True
|
||||
```
|
||||
|
||||
### Integration тесты
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_redis_integration():
|
||||
"""Тест интеграции с Redis"""
|
||||
|
||||
from storage.redis import redis
|
||||
|
||||
# Тестируем подключение
|
||||
ping_result = await redis.ping()
|
||||
assert ping_result is True
|
||||
|
||||
# Тестируем операции с сессиями
|
||||
test_key = "session:test:token123"
|
||||
test_data = {"user_id": "123", "username": "testuser"}
|
||||
|
||||
# Сохраняем данные
|
||||
await redis.hset(test_key, mapping=test_data)
|
||||
await redis.expire(test_key, 3600)
|
||||
|
||||
# Проверяем данные
|
||||
stored_data = await redis.hgetall(test_key)
|
||||
assert stored_data[b"user_id"].decode() == "123"
|
||||
assert stored_data[b"username"].decode() == "testuser"
|
||||
|
||||
# Проверяем TTL
|
||||
ttl = await redis.ttl(test_key)
|
||||
assert ttl > 0
|
||||
|
||||
# Очищаем
|
||||
await redis.delete(test_key)
|
||||
```
|
||||
|
||||
## 📋 Checklist для интеграции
|
||||
|
||||
### Подготовка
|
||||
- [ ] Настроен Redis connection pool с теми же параметрами
|
||||
- [ ] Установлены зависимости: `auth.tokens.*`, `auth.utils`
|
||||
- [ ] Настроены environment variables (JWT_SECRET_KEY, REDIS_URL)
|
||||
|
||||
### Реализация
|
||||
- [ ] Реализована функция извлечения токенов из запросов
|
||||
- [ ] Добавлена проверка сессий через SessionTokenManager
|
||||
- [ ] Настроена обработка ошибок аутентификации
|
||||
- [ ] Добавлен health check endpoint
|
||||
|
||||
### Безопасность
|
||||
- [ ] Валидация токенов включает проверку Redis сессий
|
||||
- [ ] Настроен rate limiting (опционально)
|
||||
- [ ] Логирование событий аутентификации
|
||||
- [ ] Обработка истекших токенов
|
||||
|
||||
### Мониторинг
|
||||
- [ ] Health check интегрирован в систему мониторинга
|
||||
- [ ] Метрики аутентификации собираются
|
||||
- [ ] Алерты настроены для проблем с Redis/JWT
|
||||
|
||||
### Тестирование
|
||||
- [ ] Unit тесты для функций аутентификации
|
||||
- [ ] Integration тесты с Redis
|
||||
- [ ] E2E тесты с реальными токенами
|
||||
- [ ] Load тесты для проверки производительности
|
||||
@@ -1,322 +0,0 @@
|
||||
# Миграция системы авторизации
|
||||
|
||||
## Обзор изменений
|
||||
|
||||
Система авторизации была полностью переработана для улучшения производительности, безопасности и поддерживаемости:
|
||||
|
||||
### Основные изменения
|
||||
- ✅ Упрощена архитектура токенов (убрана прокси-логика)
|
||||
- ✅ Исправлены проблемы с типами (mypy clean)
|
||||
- ✅ Оптимизированы Redis операции
|
||||
- ✅ Добавлена система мониторинга токенов
|
||||
- ✅ Улучшена производительность OAuth
|
||||
- ✅ Удалены deprecated компоненты
|
||||
|
||||
## Миграция кода
|
||||
|
||||
### TokenStorage API
|
||||
|
||||
#### Было (deprecated):
|
||||
```python
|
||||
# Старый универсальный API
|
||||
await TokenStorage.create_token("session", user_id, data, ttl)
|
||||
await TokenStorage.get_token_data("session", token)
|
||||
await TokenStorage.validate_token(token, "session")
|
||||
await TokenStorage.revoke_token("session", token)
|
||||
```
|
||||
|
||||
#### Стало (рекомендуется):
|
||||
```python
|
||||
# Прямое использование менеджеров
|
||||
from auth.tokens.sessions import SessionTokenManager
|
||||
from auth.tokens.verification import VerificationTokenManager
|
||||
from auth.tokens.oauth import OAuthTokenManager
|
||||
|
||||
# Сессии
|
||||
sessions = SessionTokenManager()
|
||||
token = await sessions.create_session(user_id, username=username)
|
||||
valid, data = await sessions.validate_session_token(token)
|
||||
await sessions.revoke_session_token(token)
|
||||
|
||||
# Токены подтверждения
|
||||
verification = VerificationTokenManager()
|
||||
token = await verification.create_verification_token(user_id, "email_change", data)
|
||||
valid, data = await verification.validate_verification_token(token)
|
||||
|
||||
# OAuth токены
|
||||
oauth = OAuthTokenManager()
|
||||
await oauth.store_oauth_tokens(user_id, "google", access_token, refresh_token)
|
||||
```
|
||||
|
||||
#### Фасад TokenStorage (для совместимости):
|
||||
```python
|
||||
# Упрощенный фасад для основных операций
|
||||
await TokenStorage.create_session(user_id, username=username)
|
||||
await TokenStorage.verify_session(token)
|
||||
await TokenStorage.refresh_session(user_id, old_token, device_info)
|
||||
await TokenStorage.revoke_session(token)
|
||||
```
|
||||
|
||||
### Redis Service
|
||||
|
||||
#### Обновленный API:
|
||||
```python
|
||||
from storage.redis import redis
|
||||
|
||||
# Базовые операции
|
||||
await redis.get(key)
|
||||
await redis.set(key, value, ex=ttl)
|
||||
await redis.delete(key)
|
||||
await redis.exists(key)
|
||||
|
||||
# Pipeline операции
|
||||
async with redis.pipeline(transaction=True) as pipe:
|
||||
await pipe.hset(key, field, value)
|
||||
await pipe.expire(key, seconds)
|
||||
results = await pipe.execute()
|
||||
|
||||
# Новые методы
|
||||
await redis.scan(cursor, match=pattern, count=100)
|
||||
await redis.scard(key)
|
||||
await redis.ttl(key)
|
||||
await redis.info(section="memory")
|
||||
```
|
||||
|
||||
### Мониторинг токенов
|
||||
|
||||
#### Новые возможности:
|
||||
```python
|
||||
from auth.tokens.monitoring import TokenMonitoring
|
||||
|
||||
monitoring = TokenMonitoring()
|
||||
|
||||
# Статистика токенов
|
||||
stats = await monitoring.get_token_statistics()
|
||||
print(f"Active sessions: {stats['session_tokens']}")
|
||||
print(f"Memory usage: {stats['memory_usage']} bytes")
|
||||
|
||||
# Health check
|
||||
health = await monitoring.health_check()
|
||||
if health["status"] == "healthy":
|
||||
print("Token system is healthy")
|
||||
|
||||
# Оптимизация памяти
|
||||
results = await monitoring.optimize_memory_usage()
|
||||
print(f"Cleaned {results['cleaned_expired']} expired tokens")
|
||||
```
|
||||
|
||||
### Пакетные операции
|
||||
|
||||
#### Новые возможности:
|
||||
```python
|
||||
from auth.tokens.batch import BatchTokenOperations
|
||||
|
||||
batch = BatchTokenOperations()
|
||||
|
||||
# Массовая валидация
|
||||
tokens = ["token1", "token2", "token3"]
|
||||
results = await batch.batch_validate_tokens(tokens)
|
||||
# {"token1": True, "token2": False, "token3": True}
|
||||
|
||||
# Массовый отзыв
|
||||
revoked_count = await batch.batch_revoke_tokens(tokens)
|
||||
print(f"Revoked {revoked_count} tokens")
|
||||
|
||||
# Очистка истекших
|
||||
cleaned = await batch.cleanup_expired_tokens()
|
||||
print(f"Cleaned {cleaned} expired tokens")
|
||||
```
|
||||
|
||||
## Изменения в конфигурации
|
||||
|
||||
### Переменные окружения
|
||||
|
||||
#### Добавлены:
|
||||
```bash
|
||||
# Новые OAuth провайдеры
|
||||
VK_APP_ID=your_vk_app_id
|
||||
VK_APP_SECRET=your_vk_app_secret
|
||||
YANDEX_CLIENT_ID=your_yandex_client_id
|
||||
YANDEX_CLIENT_SECRET=your_yandex_client_secret
|
||||
|
||||
# Расширенные настройки Redis
|
||||
REDIS_SOCKET_KEEPALIVE=true
|
||||
REDIS_HEALTH_CHECK_INTERVAL=30
|
||||
REDIS_SOCKET_TIMEOUT=5
|
||||
```
|
||||
|
||||
#### Удалены:
|
||||
```bash
|
||||
# Больше не используются
|
||||
OLD_TOKEN_FORMAT_SUPPORT=true # автоматически определяется
|
||||
TOKEN_CLEANUP_INTERVAL=3600 # заменено на on-demand cleanup
|
||||
```
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
### 1. Убраны deprecated методы
|
||||
|
||||
#### Удалено:
|
||||
```python
|
||||
# Эти методы больше не существуют
|
||||
TokenStorage.create_token() # -> используйте конкретные менеджеры
|
||||
TokenStorage.get_token_data() # -> используйте конкретные менеджеры
|
||||
TokenStorage.validate_token() # -> используйте конкретные менеджеры
|
||||
TokenStorage.revoke_user_tokens() # -> используйте конкретные менеджеры
|
||||
```
|
||||
|
||||
#### Альтернативы:
|
||||
```python
|
||||
# Для сессий
|
||||
sessions = SessionTokenManager()
|
||||
await sessions.create_session(user_id)
|
||||
await sessions.revoke_user_sessions(user_id)
|
||||
|
||||
# Для verification
|
||||
verification = VerificationTokenManager()
|
||||
await verification.create_verification_token(user_id, "email", data)
|
||||
await verification.revoke_user_verification_tokens(user_id)
|
||||
```
|
||||
|
||||
### 2. Изменения в compat.py
|
||||
|
||||
Файл `auth/tokens/compat.py` удален. Если вы использовали `CompatibilityMethods`:
|
||||
|
||||
#### Миграция:
|
||||
```python
|
||||
# Было
|
||||
from auth.tokens.compat import CompatibilityMethods
|
||||
compat = CompatibilityMethods()
|
||||
await compat.get(token_key)
|
||||
|
||||
# Стало
|
||||
from storage.redis import redis
|
||||
result = await redis.get(token_key)
|
||||
```
|
||||
|
||||
### 3. Изменения в типах
|
||||
|
||||
#### Обновленные импорты:
|
||||
```python
|
||||
# Было
|
||||
from auth.tokens.storage import TokenType, TokenData
|
||||
|
||||
# Стало
|
||||
from auth.tokens.types import TokenType, TokenData
|
||||
```
|
||||
|
||||
## Рекомендации по миграции
|
||||
|
||||
### Поэтапная миграция
|
||||
|
||||
#### Шаг 1: Обновите импорты
|
||||
```python
|
||||
# Замените старые импорты
|
||||
from auth.tokens.sessions import SessionTokenManager
|
||||
from auth.tokens.verification import VerificationTokenManager
|
||||
from auth.tokens.oauth import OAuthTokenManager
|
||||
```
|
||||
|
||||
#### Шаг 2: Используйте конкретные менеджеры
|
||||
```python
|
||||
# Вместо универсального TokenStorage
|
||||
# используйте специализированные менеджеры
|
||||
sessions = SessionTokenManager()
|
||||
```
|
||||
|
||||
#### Шаг 3: Добавьте мониторинг
|
||||
```python
|
||||
from auth.tokens.monitoring import TokenMonitoring
|
||||
|
||||
# Добавьте health checks в ваши endpoints
|
||||
monitoring = TokenMonitoring()
|
||||
health = await monitoring.health_check()
|
||||
```
|
||||
|
||||
#### Шаг 4: Оптимизируйте батчевые операции
|
||||
```python
|
||||
from auth.tokens.batch import BatchTokenOperations
|
||||
|
||||
# Используйте batch операции для массовых действий
|
||||
batch = BatchTokenOperations()
|
||||
results = await batch.batch_validate_tokens(token_list)
|
||||
```
|
||||
|
||||
### Тестирование миграции
|
||||
|
||||
#### Checklist:
|
||||
- [ ] Все auth тесты проходят
|
||||
- [ ] mypy проверки без ошибок
|
||||
- [ ] OAuth провайдеры работают
|
||||
- [ ] Session management функционирует
|
||||
- [ ] Redis операции оптимизированы
|
||||
- [ ] Мониторинг настроен
|
||||
|
||||
#### Команды для тестирования:
|
||||
```bash
|
||||
# Проверка типов
|
||||
mypy .
|
||||
|
||||
# Запуск auth тестов
|
||||
pytest tests/auth/ -v
|
||||
|
||||
# Проверка Redis подключения
|
||||
python -c "
|
||||
import asyncio
|
||||
from storage.redis import redis
|
||||
async def test():
|
||||
result = await redis.ping()
|
||||
print(f'Redis connection: {result}')
|
||||
asyncio.run(test())
|
||||
"
|
||||
|
||||
# Health check системы токенов
|
||||
python -c "
|
||||
import asyncio
|
||||
from auth.tokens.monitoring import TokenMonitoring
|
||||
async def test():
|
||||
health = await TokenMonitoring().health_check()
|
||||
print(f'Token system health: {health}')
|
||||
asyncio.run(test())
|
||||
"
|
||||
```
|
||||
|
||||
## Производительность
|
||||
|
||||
### Ожидаемые улучшения
|
||||
- **50%** ускорение Redis операций (pipeline использование)
|
||||
- **30%** снижение memory usage (оптимизированные структуры)
|
||||
- **Elimination** of proxy overhead (прямое обращение к менеджерам)
|
||||
- **Real-time** мониторинг и статистика
|
||||
|
||||
### Мониторинг после миграции
|
||||
```python
|
||||
# Регулярно проверяйте статистику
|
||||
from auth.tokens.monitoring import TokenMonitoring
|
||||
|
||||
async def check_performance():
|
||||
monitoring = TokenMonitoring()
|
||||
stats = await monitoring.get_token_statistics()
|
||||
|
||||
print(f"Session tokens: {stats['session_tokens']}")
|
||||
print(f"Memory usage: {stats['memory_usage'] / 1024 / 1024:.2f} MB")
|
||||
|
||||
# Оптимизация при необходимости
|
||||
if stats['memory_usage'] > 100 * 1024 * 1024: # 100MB
|
||||
results = await monitoring.optimize_memory_usage()
|
||||
print(f"Optimized: {results}")
|
||||
```
|
||||
|
||||
## Поддержка
|
||||
|
||||
Если возникли проблемы при миграции:
|
||||
|
||||
1. **Проверьте логи** - все изменения логируются
|
||||
2. **Запустите health check** - `TokenMonitoring().health_check()`
|
||||
3. **Проверьте Redis** - подключение и память
|
||||
4. **Откатитесь к TokenStorage фасаду** при необходимости
|
||||
|
||||
### Контакты
|
||||
- **Issues**: GitHub Issues
|
||||
- **Документация**: `/docs/auth/system.md`
|
||||
- **Архитектура**: `/docs/auth/architecture.md`
|
||||
@@ -1,381 +0,0 @@
|
||||
# 🔐 OAuth Integration Guide
|
||||
|
||||
## 🎯 Обзор
|
||||
|
||||
Система OAuth интеграции с **Bearer токенами** для основного сайта. Поддержка популярных провайдеров с cross-origin совместимостью.
|
||||
|
||||
**Важно:** OAuth доступен только для основного сайта. Админка использует только email/password аутентификацию.
|
||||
|
||||
### 🔄 **Архитектура: стандартный подход**
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant F as Frontend
|
||||
participant B as Backend
|
||||
participant P as OAuth Provider
|
||||
|
||||
U->>F: Click "Login with Provider"
|
||||
F->>B: GET /oauth/{provider}/login
|
||||
B->>P: Redirect to Provider
|
||||
P->>U: Show authorization page
|
||||
U->>P: Grant permission
|
||||
P->>B: GET /oauth/{provider}/callback?code={code}
|
||||
B->>P: Exchange code for token
|
||||
P->>B: Return access token + user data
|
||||
B->>B: Create/update user + JWT session
|
||||
B->>F: Redirect with token in URL
|
||||
Note over B,F: URL: /?access_token=JWT_TOKEN
|
||||
F->>F: Save token to localStorage
|
||||
F->>F: Clear token from URL
|
||||
F->>U: User logged in
|
||||
|
||||
Note over F,B: All subsequent requests
|
||||
F->>B: GraphQL with Authorization: Bearer
|
||||
```
|
||||
|
||||
## 🚀 Поддерживаемые провайдеры
|
||||
|
||||
| Провайдер | Статус | Особенности |
|
||||
|-----------|--------|-------------|
|
||||
| **Google** | ✅ | OpenID Connect, актуальные endpoints |
|
||||
| **GitHub** | ✅ | OAuth 2.0, scope: `read:user user:email` |
|
||||
| **Yandex** | ✅ | OAuth, scope: `login:email login:info` |
|
||||
| **VK** | ✅ | OAuth API v5.199+, scope: `email` |
|
||||
| **Facebook** | ✅ | Facebook Login API v18.0+ |
|
||||
| **X (Twitter)** | ✅ | OAuth 2.0 API v2 |
|
||||
|
||||
## 🔧 OAuth Flow
|
||||
|
||||
### 1. 🚀 Инициация OAuth (Фронтенд)
|
||||
|
||||
```typescript
|
||||
// Простой редирект - backend получит redirect_uri из Referer header
|
||||
const handleOAuthLogin = (provider: string) => {
|
||||
// Сохраняем текущую страницу для возврата
|
||||
localStorage.setItem('oauth_return_url', window.location.pathname);
|
||||
|
||||
// Редиректим на OAuth endpoint
|
||||
window.location.href = `/oauth/${provider}/login`;
|
||||
};
|
||||
|
||||
// Использование
|
||||
<button onClick={() => handleOAuthLogin('google')}>
|
||||
🔐 Войти через Google
|
||||
</button>
|
||||
```
|
||||
|
||||
### 2. 🔄 Backend Endpoints
|
||||
|
||||
#### GET `/oauth/{provider}/login` - Старт OAuth
|
||||
```python
|
||||
# /oauth/github/login
|
||||
# 1. Сохраняет redirect_uri из Referer header в Redis state
|
||||
# 2. Генерирует PKCE challenge для безопасности
|
||||
# 3. Редиректит на провайдера с параметрами авторизации
|
||||
```
|
||||
|
||||
#### GET `/oauth/{provider}/callback` - Callback
|
||||
```python
|
||||
# GitHub → /oauth/github/callback?code=xxx&state=yyy
|
||||
# 1. Валидирует state (CSRF защита)
|
||||
# 2. Обменивает code на access_token
|
||||
# 3. Получает профиль пользователя
|
||||
# 4. Создает/обновляет пользователя в БД
|
||||
# 5. Создает JWT сессию
|
||||
# 6. Устанавливает httpOnly cookie
|
||||
# 7. Редиректит на фронтенд БЕЗ токена в URL
|
||||
```
|
||||
|
||||
### 3. 🌐 Фронтенд финализация
|
||||
|
||||
```typescript
|
||||
// OAuth callback route
|
||||
export default function OAuthCallback() {
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuth();
|
||||
|
||||
onMount(async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const token = urlParams.get('access_token');
|
||||
const error = urlParams.get('error');
|
||||
|
||||
if (error) {
|
||||
// ❌ Ошибка OAuth
|
||||
console.error('OAuth error:', error);
|
||||
navigate('/login?error=' + error);
|
||||
} else if (token) {
|
||||
// ✅ Успех! Сохраняем токен в localStorage
|
||||
localStorage.setItem('access_token', token);
|
||||
|
||||
// Очищаем URL от токена
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
|
||||
// Возвращаемся на сохраненную страницу
|
||||
const returnUrl = localStorage.getItem('oauth_return_url') || '/';
|
||||
localStorage.removeItem('oauth_return_url');
|
||||
navigate(returnUrl);
|
||||
} else {
|
||||
navigate('/login?error=no_token');
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="oauth-callback">
|
||||
<h2>Завершение авторизации...</h2>
|
||||
<p>Пожалуйста, подождите...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 🔑 Использование Bearer токенов
|
||||
|
||||
```typescript
|
||||
// GraphQL клиент использует Bearer токены из localStorage
|
||||
const graphqlRequest = async (query: string, variables?: any) => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
|
||||
const response = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}` // ✅ Bearer токен из localStorage
|
||||
},
|
||||
body: JSON.stringify({ query, variables })
|
||||
});
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// Auth Context
|
||||
export const AuthProvider = (props: { children: JSX.Element }) => {
|
||||
const [user, setUser] = createSignal<User | null>(null);
|
||||
|
||||
const checkSession = async () => {
|
||||
try {
|
||||
const response = await graphqlRequest(`
|
||||
query GetSession {
|
||||
getSession {
|
||||
success
|
||||
author { id slug email name }
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
if (response.data?.getSession?.success) {
|
||||
setUser(response.data.getSession.author);
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Session check failed:', error);
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
// Удаляем httpOnly cookie на бэкенде
|
||||
await graphqlRequest(`mutation { logout { success } }`);
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
// Проверяем сессию при загрузке
|
||||
onMount(() => checkSession());
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
user,
|
||||
isAuthenticated: () => !!user(),
|
||||
checkSession,
|
||||
logout,
|
||||
}}>
|
||||
{props.children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 🔐 Настройка провайдеров
|
||||
|
||||
### Google OAuth
|
||||
1. [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. **APIs & Services** → **Credentials** → **OAuth 2.0 Client ID**
|
||||
3. **Authorized redirect URIs**: `https://v3.discours.io/oauth/google/callback`
|
||||
|
||||
```bash
|
||||
GOOGLE_CLIENT_ID=your_google_client_id
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
```
|
||||
|
||||
### GitHub OAuth
|
||||
1. [GitHub Developer Settings](https://github.com/settings/developers)
|
||||
2. **New OAuth App**
|
||||
3. **Authorization callback URL**: `https://v3.discours.io/oauth/github/callback`
|
||||
|
||||
```bash
|
||||
GITHUB_CLIENT_ID=your_github_client_id
|
||||
GITHUB_CLIENT_SECRET=your_github_client_secret
|
||||
```
|
||||
|
||||
### Yandex OAuth
|
||||
1. [Yandex OAuth](https://oauth.yandex.ru/)
|
||||
2. **Создать новое приложение**
|
||||
3. **Callback URI**: `https://v3.discours.io/oauth/yandex/callback`
|
||||
4. **Права**: `login:info`, `login:email`, `login:avatar`
|
||||
|
||||
```bash
|
||||
YANDEX_CLIENT_ID=your_yandex_client_id
|
||||
YANDEX_CLIENT_SECRET=your_yandex_client_secret
|
||||
```
|
||||
|
||||
### VK OAuth
|
||||
1. [VK Developers](https://dev.vk.com/apps)
|
||||
2. **Создать приложение** → **Веб-сайт**
|
||||
3. **Redirect URI**: `https://v3.discours.io/oauth/vk/callback`
|
||||
|
||||
```bash
|
||||
VK_CLIENT_ID=your_vk_app_id
|
||||
VK_CLIENT_SECRET=your_vk_secure_key
|
||||
```
|
||||
|
||||
## 🛡️ Безопасность
|
||||
|
||||
### httpOnly Cookie настройки
|
||||
```python
|
||||
# settings.py
|
||||
SESSION_COOKIE_NAME = "session_token"
|
||||
SESSION_COOKIE_HTTPONLY = True # Защита от XSS
|
||||
SESSION_COOKIE_SECURE = True # Только HTTPS
|
||||
SESSION_COOKIE_SAMESITE = "lax" # CSRF защита
|
||||
SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 дней
|
||||
```
|
||||
|
||||
### CSRF Protection
|
||||
- **State parameter**: Криптографически стойкий state для каждого запроса
|
||||
- **PKCE**: Code challenge для дополнительной защиты
|
||||
- **Redirect URI validation**: Проверка разрешенных доменов
|
||||
|
||||
### TTL и истечение
|
||||
- **OAuth state**: 10 минут (одноразовое использование)
|
||||
- **Session tokens**: 30 дней (настраивается)
|
||||
- **Автоматическая очистка**: Redis удаляет истекшие токены
|
||||
|
||||
## 🔧 API для разработчиков
|
||||
|
||||
### Проверка OAuth токенов
|
||||
```python
|
||||
from auth.tokens.oauth import OAuthTokenManager
|
||||
|
||||
oauth = OAuthTokenManager()
|
||||
|
||||
# Сохранение OAuth токенов (для API интеграций)
|
||||
await oauth.store_oauth_tokens(
|
||||
user_id="123",
|
||||
provider="google",
|
||||
access_token="ya29.a0AfH6SM...",
|
||||
refresh_token="1//04...",
|
||||
expires_in=3600
|
||||
)
|
||||
|
||||
# Получение токена для API вызовов
|
||||
token_data = await oauth.get_token("123", "google", "oauth_access")
|
||||
if token_data:
|
||||
# Используем токен для вызовов Google API
|
||||
headers = {"Authorization": f"Bearer {token_data['token']}"}
|
||||
```
|
||||
|
||||
### Redis структура
|
||||
```bash
|
||||
# OAuth токены для API интеграций
|
||||
oauth_access:{user_id}:{provider} # Access токен
|
||||
oauth_refresh:{user_id}:{provider} # Refresh токен
|
||||
|
||||
# OAuth state (временный)
|
||||
oauth_state:{state} # Данные авторизации (TTL: 10 мин)
|
||||
|
||||
# Сессии пользователей (основные)
|
||||
session:{user_id}:{token} # JWT сессия (TTL: 30 дней)
|
||||
```
|
||||
|
||||
## 🧪 Тестирование
|
||||
|
||||
### E2E Test
|
||||
```typescript
|
||||
test('OAuth flow with httpOnly cookies', async ({ page }) => {
|
||||
// 1. Инициация OAuth
|
||||
await page.goto('/login');
|
||||
await page.click('[data-testid="google-login"]');
|
||||
|
||||
// 2. Проверяем редирект на Google
|
||||
await expect(page).toHaveURL(/accounts\.google\.com/);
|
||||
|
||||
// 3. Симулируем успешный callback (в тестовой среде)
|
||||
await page.goto('/oauth/callback');
|
||||
|
||||
// 4. Проверяем что cookie установлен
|
||||
const cookies = await page.context().cookies();
|
||||
const authCookie = cookies.find(c => c.name === 'session_token');
|
||||
expect(authCookie).toBeTruthy();
|
||||
expect(authCookie?.httpOnly).toBe(true);
|
||||
|
||||
// 5. Проверяем что пользователь авторизован
|
||||
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Отладка
|
||||
```bash
|
||||
# Проверка OAuth провайдеров
|
||||
curl -v "https://v3.discours.io/oauth/google/login"
|
||||
|
||||
# Проверка callback
|
||||
curl -v "https://v3.discours.io/oauth/google/callback?code=test&state=test"
|
||||
|
||||
# Проверка сессии с cookie
|
||||
curl -b "session_token=your_token" "https://v3.discours.io/graphql" \
|
||||
-d '{"query":"query { getSession { success author { id } } }"}'
|
||||
```
|
||||
|
||||
## 📊 Мониторинг
|
||||
|
||||
```python
|
||||
from auth.tokens.monitoring import TokenMonitoring
|
||||
|
||||
monitoring = TokenMonitoring()
|
||||
|
||||
# Статистика OAuth
|
||||
stats = await monitoring.get_token_statistics()
|
||||
oauth_tokens = stats.get("oauth_access_tokens", 0) + stats.get("oauth_refresh_tokens", 0)
|
||||
print(f"OAuth tokens: {oauth_tokens}")
|
||||
|
||||
# Health check
|
||||
health = await monitoring.health_check()
|
||||
if health["status"] == "healthy":
|
||||
print("✅ OAuth system is healthy")
|
||||
```
|
||||
|
||||
## 🎯 Преимущества новой архитектуры
|
||||
|
||||
### 🛡️ Максимальная безопасность:
|
||||
- **🚫 Защита от XSS**: Токены недоступны JavaScript
|
||||
- **🔒 Защита от CSRF**: SameSite cookies
|
||||
- **🛡️ Единообразие**: Все провайдеры используют один механизм
|
||||
|
||||
### 🚀 Простота использования:
|
||||
- **📱 Автоматическая отправка**: Браузер сам включает cookies
|
||||
- **🧹 Чистый код**: Нет управления токенами в JavaScript
|
||||
- **🔄 Единый API**: Один GraphQL клиент для всех случаев
|
||||
|
||||
### ⚡ Производительность:
|
||||
- **🚀 Быстрее**: Нет localStorage операций
|
||||
- **📦 Меньше кода**: Упрощенная логика фронтенда
|
||||
- **🔄 Автоматическое управление**: Браузер оптимизирует отправку cookies
|
||||
|
||||
**Результат: Самая безопасная и простая OAuth интеграция!** 🔐✨
|
||||
@@ -1,579 +0,0 @@
|
||||
# 🔒 Безопасность системы аутентификации
|
||||
|
||||
## 🎯 Обзор
|
||||
|
||||
Комплексная система безопасности с многоуровневой защитой от различных типов атак.
|
||||
|
||||
## 🛡️ Основные принципы безопасности
|
||||
|
||||
### 1. Defense in Depth
|
||||
- **Многоуровневая защита**: JWT + Redis + RBAC + Rate Limiting
|
||||
- **Fail Secure**: При ошибках система блокирует доступ
|
||||
- **Principle of Least Privilege**: Минимальные необходимые права
|
||||
|
||||
### 2. Zero Trust Architecture
|
||||
- **Verify Everything**: Каждый запрос проверяется
|
||||
- **Never Trust, Always Verify**: Нет доверенных зон
|
||||
- **Continuous Validation**: Постоянная проверка токенов
|
||||
|
||||
## 🔐 JWT Security
|
||||
|
||||
### Алгоритм и ключи
|
||||
```python
|
||||
# settings.py
|
||||
JWT_ALGORITHM = "HS256" # HMAC with SHA-256
|
||||
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY") # Минимум 256 бит
|
||||
JWT_EXPIRATION_DELTA = 30 * 24 * 60 * 60 # 30 дней
|
||||
```
|
||||
|
||||
### Структура токена
|
||||
```python
|
||||
# JWT Payload
|
||||
{
|
||||
"user_id": "123",
|
||||
"username": "john_doe",
|
||||
"iat": 1640995200, # Issued At
|
||||
"exp": 1643587200 # Expiration
|
||||
}
|
||||
```
|
||||
|
||||
### Лучшие практики JWT
|
||||
- **Короткое время жизни**: Максимум 30 дней
|
||||
- **Secure Secret**: Криптографически стойкий ключ
|
||||
- **No Sensitive Data**: Только необходимые данные в payload
|
||||
- **Revocation Support**: Redis для отзыва токенов
|
||||
|
||||
## 🍪 Cookie Security
|
||||
|
||||
### httpOnly Cookies
|
||||
```python
|
||||
# Настройки cookie
|
||||
SESSION_COOKIE_NAME = "session_token"
|
||||
SESSION_COOKIE_HTTPONLY = True # Защита от XSS
|
||||
SESSION_COOKIE_SECURE = True # Только HTTPS
|
||||
SESSION_COOKIE_SAMESITE = "lax" # CSRF защита
|
||||
SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60
|
||||
```
|
||||
|
||||
### Защита от атак
|
||||
- **XSS Protection**: httpOnly cookies недоступны JavaScript
|
||||
- **CSRF Protection**: SameSite=lax предотвращает CSRF
|
||||
- **Secure Flag**: Передача только по HTTPS
|
||||
- **Path Restriction**: Ограничение области действия
|
||||
|
||||
## 🔑 Password Security
|
||||
|
||||
### Хеширование паролей
|
||||
```python
|
||||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(
|
||||
schemes=["bcrypt"],
|
||||
deprecated="auto",
|
||||
bcrypt__rounds=12 # Увеличенная сложность
|
||||
)
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Хеширует пароль с использованием bcrypt"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Проверяет пароль"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
```
|
||||
|
||||
### Требования к паролям
|
||||
```python
|
||||
import re
|
||||
|
||||
def validate_password_strength(password: str) -> bool:
|
||||
"""Проверка силы пароля"""
|
||||
if len(password) < 8:
|
||||
return False
|
||||
|
||||
# Проверки
|
||||
has_upper = re.search(r'[A-Z]', password)
|
||||
has_lower = re.search(r'[a-z]', password)
|
||||
has_digit = re.search(r'\d', password)
|
||||
has_special = re.search(r'[!@#$%^&*(),.?":{}|<>]', password)
|
||||
|
||||
return all([has_upper, has_lower, has_digit, has_special])
|
||||
```
|
||||
|
||||
## 🚫 Защита от брутфорса
|
||||
|
||||
### Account Lockout
|
||||
```python
|
||||
async def handle_login_attempt(author: Author, success: bool) -> None:
|
||||
"""Обрабатывает попытку входа"""
|
||||
if not success:
|
||||
# Увеличиваем счетчик неудачных попыток
|
||||
author.failed_login_attempts += 1
|
||||
|
||||
if author.failed_login_attempts >= 5:
|
||||
# Блокируем аккаунт на 30 минут
|
||||
author.account_locked_until = int(time.time()) + 1800
|
||||
logger.warning(f"Аккаунт {author.email} заблокирован")
|
||||
else:
|
||||
# Сбрасываем счетчик при успешном входе
|
||||
author.failed_login_attempts = 0
|
||||
author.account_locked_until = None
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
```python
|
||||
from collections import defaultdict
|
||||
import time
|
||||
|
||||
# Rate limiter
|
||||
request_counts = defaultdict(list)
|
||||
|
||||
async def rate_limit_check(
|
||||
identifier: str,
|
||||
max_requests: int = 10,
|
||||
window_seconds: int = 60
|
||||
) -> bool:
|
||||
"""Проверка rate limiting"""
|
||||
current_time = time.time()
|
||||
user_requests = request_counts[identifier]
|
||||
|
||||
# Удаляем старые запросы
|
||||
user_requests[:] = [
|
||||
req_time for req_time in user_requests
|
||||
if current_time - req_time < window_seconds
|
||||
]
|
||||
|
||||
# Проверяем лимит
|
||||
if len(user_requests) >= max_requests:
|
||||
return False
|
||||
|
||||
# Добавляем текущий запрос
|
||||
user_requests.append(current_time)
|
||||
return True
|
||||
```
|
||||
|
||||
## 🔒 Redis Security
|
||||
|
||||
### Secure Configuration
|
||||
```python
|
||||
# Redis настройки безопасности
|
||||
REDIS_CONFIG = {
|
||||
"socket_keepalive": True,
|
||||
"socket_keepalive_options": {},
|
||||
"health_check_interval": 30,
|
||||
"retry_on_timeout": True,
|
||||
"socket_timeout": 5,
|
||||
"socket_connect_timeout": 5
|
||||
}
|
||||
```
|
||||
|
||||
### TTL для всех ключей
|
||||
```python
|
||||
async def secure_redis_set(key: str, value: str, ttl: int = 3600):
|
||||
"""Безопасная установка значения с обязательным TTL"""
|
||||
await redis.setex(key, ttl, value)
|
||||
|
||||
# Проверяем, что TTL установлен
|
||||
actual_ttl = await redis.ttl(key)
|
||||
if actual_ttl <= 0:
|
||||
logger.error(f"TTL не установлен для ключа: {key}")
|
||||
await redis.delete(key)
|
||||
```
|
||||
|
||||
### Атомарные операции
|
||||
```python
|
||||
async def atomic_session_update(user_id: str, token: str, data: dict):
|
||||
"""Атомарное обновление сессии"""
|
||||
async with redis.pipeline(transaction=True) as pipe:
|
||||
try:
|
||||
# Начинаем транзакцию
|
||||
await pipe.multi()
|
||||
|
||||
# Обновляем данные сессии
|
||||
session_key = f"session:{user_id}:{token}"
|
||||
await pipe.hset(session_key, mapping=data)
|
||||
await pipe.expire(session_key, 30 * 24 * 60 * 60)
|
||||
|
||||
# Обновляем список активных сессий
|
||||
sessions_key = f"user_sessions:{user_id}"
|
||||
await pipe.sadd(sessions_key, token)
|
||||
await pipe.expire(sessions_key, 30 * 24 * 60 * 60)
|
||||
|
||||
# Выполняем транзакцию
|
||||
await pipe.execute()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка атомарной операции: {e}")
|
||||
raise
|
||||
```
|
||||
|
||||
## 🛡️ OAuth Security
|
||||
|
||||
### State Parameter Protection
|
||||
```python
|
||||
import secrets
|
||||
|
||||
def generate_oauth_state() -> str:
|
||||
"""Генерация криптографически стойкого state"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
async def validate_oauth_state(received_state: str, stored_state: str) -> bool:
|
||||
"""Безопасная проверка state"""
|
||||
if not received_state or not stored_state:
|
||||
return False
|
||||
|
||||
# Используем constant-time comparison
|
||||
return secrets.compare_digest(received_state, stored_state)
|
||||
```
|
||||
|
||||
### PKCE Support
|
||||
```python
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
def generate_code_verifier() -> str:
|
||||
"""Генерация code verifier для PKCE"""
|
||||
return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8').rstrip('=')
|
||||
|
||||
def generate_code_challenge(verifier: str) -> str:
|
||||
"""Генерация code challenge"""
|
||||
digest = hashlib.sha256(verifier.encode('utf-8')).digest()
|
||||
return base64.urlsafe_b64encode(digest).decode('utf-8').rstrip('=')
|
||||
```
|
||||
|
||||
### Redirect URI Validation
|
||||
```python
|
||||
from urllib.parse import urlparse
|
||||
|
||||
def validate_redirect_uri(uri: str) -> bool:
|
||||
"""Валидация redirect URI"""
|
||||
allowed_domains = [
|
||||
"localhost:3000",
|
||||
"discours.io",
|
||||
"new.discours.io"
|
||||
]
|
||||
|
||||
try:
|
||||
parsed = urlparse(uri)
|
||||
|
||||
# Проверяем схему
|
||||
if parsed.scheme not in ['http', 'https']:
|
||||
return False
|
||||
|
||||
# Проверяем домен
|
||||
if not any(domain in parsed.netloc for domain in allowed_domains):
|
||||
return False
|
||||
|
||||
# Проверяем на открытые редиректы
|
||||
if parsed.netloc != parsed.netloc.lower():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
```
|
||||
|
||||
## 🔍 Input Validation
|
||||
|
||||
### Request Validation
|
||||
```python
|
||||
from pydantic import BaseModel, EmailStr, validator
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
@validator('password')
|
||||
def validate_password(cls, v):
|
||||
if len(v) < 8:
|
||||
raise ValueError('Password too short')
|
||||
return v
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
name: str
|
||||
|
||||
@validator('name')
|
||||
def validate_name(cls, v):
|
||||
if len(v.strip()) < 2:
|
||||
raise ValueError('Name too short')
|
||||
# Защита от XSS
|
||||
if '<' in v or '>' in v:
|
||||
raise ValueError('Invalid characters in name')
|
||||
return v.strip()
|
||||
```
|
||||
|
||||
### SQL Injection Prevention
|
||||
```python
|
||||
# Используем ORM и параметризованные запросы
|
||||
from sqlalchemy import text
|
||||
|
||||
# ✅ Безопасно
|
||||
async def get_user_by_email(email: str):
|
||||
query = text("SELECT * FROM authors WHERE email = :email")
|
||||
result = await db.execute(query, {"email": email})
|
||||
return result.fetchone()
|
||||
|
||||
# ❌ Небезопасно
|
||||
async def unsafe_query(email: str):
|
||||
query = f"SELECT * FROM authors WHERE email = '{email}'" # SQL Injection!
|
||||
return await db.execute(query)
|
||||
```
|
||||
|
||||
## 🚨 Security Headers
|
||||
|
||||
### HTTP Security Headers
|
||||
```python
|
||||
def add_security_headers(response):
|
||||
"""Добавляет заголовки безопасности"""
|
||||
response.headers.update({
|
||||
# XSS Protection
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
|
||||
# HTTPS Enforcement
|
||||
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
|
||||
|
||||
# Content Security Policy
|
||||
"Content-Security-Policy": (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: https:; "
|
||||
"connect-src 'self' https://api.discours.io"
|
||||
),
|
||||
|
||||
# Referrer Policy
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
|
||||
# Permissions Policy
|
||||
"Permissions-Policy": "geolocation=(), microphone=(), camera=()"
|
||||
})
|
||||
```
|
||||
|
||||
## 📊 Security Monitoring
|
||||
|
||||
### Audit Logging
|
||||
```python
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
async def log_security_event(
|
||||
event_type: str,
|
||||
user_id: str = None,
|
||||
ip_address: str = None,
|
||||
user_agent: str = None,
|
||||
success: bool = True,
|
||||
details: dict = None
|
||||
):
|
||||
"""Логирование событий безопасности"""
|
||||
|
||||
event = {
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"event_type": event_type,
|
||||
"user_id": user_id,
|
||||
"ip_address": ip_address,
|
||||
"user_agent": user_agent,
|
||||
"success": success,
|
||||
"details": details or {}
|
||||
}
|
||||
|
||||
# Логируем в файл аудита
|
||||
logger.info("security_event", extra=event)
|
||||
|
||||
# Отправляем критические события в SIEM
|
||||
if event_type in ["login_failed", "account_locked", "token_stolen"]:
|
||||
await send_to_siem(event)
|
||||
```
|
||||
|
||||
### Anomaly Detection
|
||||
```python
|
||||
from collections import defaultdict
|
||||
import asyncio
|
||||
|
||||
# Детектор аномалий
|
||||
anomaly_tracker = defaultdict(list)
|
||||
|
||||
async def detect_anomalies(user_id: str, event_type: str, ip_address: str):
|
||||
"""Детекция аномальной активности"""
|
||||
|
||||
current_time = time.time()
|
||||
user_events = anomaly_tracker[user_id]
|
||||
|
||||
# Добавляем событие
|
||||
user_events.append({
|
||||
"type": event_type,
|
||||
"ip": ip_address,
|
||||
"time": current_time
|
||||
})
|
||||
|
||||
# Очищаем старые события (последний час)
|
||||
user_events[:] = [
|
||||
event for event in user_events
|
||||
if current_time - event["time"] < 3600
|
||||
]
|
||||
|
||||
# Проверяем аномалии
|
||||
if len(user_events) > 50: # Слишком много событий
|
||||
await log_security_event(
|
||||
"anomaly_detected",
|
||||
user_id=user_id,
|
||||
details={"reason": "too_many_events", "count": len(user_events)}
|
||||
)
|
||||
|
||||
# Проверяем множественные IP
|
||||
unique_ips = set(event["ip"] for event in user_events)
|
||||
if len(unique_ips) > 5: # Слишком много IP адресов
|
||||
await log_security_event(
|
||||
"anomaly_detected",
|
||||
user_id=user_id,
|
||||
details={"reason": "multiple_ips", "ips": list(unique_ips)}
|
||||
)
|
||||
```
|
||||
|
||||
## 🔧 Security Configuration
|
||||
|
||||
### Environment Variables
|
||||
```bash
|
||||
# JWT Security
|
||||
JWT_SECRET_KEY=your_super_secret_key_minimum_256_bits
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_EXPIRATION_HOURS=720
|
||||
|
||||
# Cookie Security
|
||||
SESSION_COOKIE_SECURE=true
|
||||
SESSION_COOKIE_HTTPONLY=true
|
||||
SESSION_COOKIE_SAMESITE=lax
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_ENABLED=true
|
||||
RATE_LIMIT_REQUESTS=100
|
||||
RATE_LIMIT_WINDOW=3600
|
||||
|
||||
# Security Features
|
||||
ACCOUNT_LOCKOUT_ENABLED=true
|
||||
MAX_LOGIN_ATTEMPTS=5
|
||||
LOCKOUT_DURATION=1800
|
||||
|
||||
# HTTPS Enforcement
|
||||
FORCE_HTTPS=true
|
||||
HSTS_MAX_AGE=31536000
|
||||
```
|
||||
|
||||
### Production Checklist
|
||||
|
||||
#### Authentication Security
|
||||
- [ ] JWT secret минимум 256 бит
|
||||
- [ ] Короткое время жизни токенов (≤ 30 дней)
|
||||
- [ ] httpOnly cookies включены
|
||||
- [ ] Secure cookies для HTTPS
|
||||
- [ ] SameSite cookies настроены
|
||||
|
||||
#### Password Security
|
||||
- [ ] bcrypt с rounds ≥ 12
|
||||
- [ ] Требования к сложности паролей
|
||||
- [ ] Защита от брутфорса
|
||||
- [ ] Account lockout настроен
|
||||
|
||||
#### OAuth Security
|
||||
- [ ] State parameter валидация
|
||||
- [ ] PKCE поддержка включена
|
||||
- [ ] Redirect URI валидация
|
||||
- [ ] Secure client secrets
|
||||
|
||||
#### Infrastructure Security
|
||||
- [ ] HTTPS принудительно
|
||||
- [ ] Security headers настроены
|
||||
- [ ] Rate limiting включен
|
||||
- [ ] Audit logging работает
|
||||
|
||||
#### Redis Security
|
||||
- [ ] TTL для всех ключей
|
||||
- [ ] Атомарные операции
|
||||
- [ ] Connection pooling
|
||||
- [ ] Health checks
|
||||
|
||||
## 🚨 Incident Response
|
||||
|
||||
### Security Incident Types
|
||||
1. **Token Compromise**: Подозрение на кражу токенов
|
||||
2. **Brute Force Attack**: Массовые попытки входа
|
||||
3. **Account Takeover**: Несанкционированный доступ
|
||||
4. **Data Breach**: Утечка данных
|
||||
5. **System Compromise**: Компрометация системы
|
||||
|
||||
### Response Procedures
|
||||
|
||||
#### Token Compromise
|
||||
```python
|
||||
async def handle_token_compromise(user_id: str, reason: str):
|
||||
"""Обработка компрометации токена"""
|
||||
|
||||
# 1. Отзываем все токены пользователя
|
||||
sessions = SessionTokenManager()
|
||||
revoked_count = await sessions.revoke_user_sessions(user_id)
|
||||
|
||||
# 2. Блокируем аккаунт
|
||||
author = await Author.get(user_id)
|
||||
author.account_locked_until = int(time.time()) + 3600 # 1 час
|
||||
await author.save()
|
||||
|
||||
# 3. Логируем инцидент
|
||||
await log_security_event(
|
||||
"token_compromise",
|
||||
user_id=user_id,
|
||||
details={
|
||||
"reason": reason,
|
||||
"revoked_tokens": revoked_count,
|
||||
"account_locked": True
|
||||
}
|
||||
)
|
||||
|
||||
# 4. Уведомляем пользователя
|
||||
await send_security_notification(user_id, "token_compromise")
|
||||
```
|
||||
|
||||
#### Brute Force Response
|
||||
```python
|
||||
async def handle_brute_force(ip_address: str, attempts: int):
|
||||
"""Обработка брутфорс атаки"""
|
||||
|
||||
# 1. Блокируем IP
|
||||
await block_ip_address(ip_address, duration=3600)
|
||||
|
||||
# 2. Логируем атаку
|
||||
await log_security_event(
|
||||
"brute_force_attack",
|
||||
ip_address=ip_address,
|
||||
details={"attempts": attempts}
|
||||
)
|
||||
|
||||
# 3. Уведомляем администраторов
|
||||
await notify_admins("brute_force_detected", {
|
||||
"ip": ip_address,
|
||||
"attempts": attempts
|
||||
})
|
||||
```
|
||||
|
||||
## 📚 Security Best Practices
|
||||
|
||||
### Development
|
||||
- **Secure by Default**: Безопасные настройки по умолчанию
|
||||
- **Fail Securely**: При ошибках блокируем доступ
|
||||
- **Defense in Depth**: Многоуровневая защита
|
||||
- **Principle of Least Privilege**: Минимальные права
|
||||
|
||||
### Operations
|
||||
- **Regular Updates**: Обновление зависимостей
|
||||
- **Security Monitoring**: Постоянный мониторинг
|
||||
- **Incident Response**: Готовность к инцидентам
|
||||
- **Regular Audits**: Регулярные аудиты безопасности
|
||||
|
||||
### Compliance
|
||||
- **GDPR**: Защита персональных данных
|
||||
- **OWASP**: Следование рекомендациям OWASP
|
||||
- **Security Standards**: Соответствие стандартам
|
||||
- **Documentation**: Документирование процедур
|
||||
@@ -1,502 +0,0 @@
|
||||
# 🔑 Управление сессиями
|
||||
|
||||
## 🎯 Обзор
|
||||
|
||||
Система управления сессиями на основе JWT токенов с Redis хранением для отзыва и мониторинга активности.
|
||||
|
||||
## 🏗️ Архитектура
|
||||
|
||||
### Принцип работы
|
||||
1. **JWT токены** с payload `{user_id, username, iat, exp}`
|
||||
2. **Redis хранение** для отзыва и управления жизненным циклом
|
||||
3. **Множественные сессии** на пользователя
|
||||
4. **Автоматическое обновление** `last_activity` при активности
|
||||
|
||||
### Redis структура
|
||||
```bash
|
||||
session:{user_id}:{token} # Hash: {user_id, username, device_info, last_activity}
|
||||
user_sessions:{user_id} # Set: {token1, token2, ...}
|
||||
```
|
||||
|
||||
### Извлечение токена (приоритет)
|
||||
1. Cookie `session_token` (httpOnly)
|
||||
2. Заголовок `Authorization: Bearer <token>`
|
||||
3. Заголовок `X-Session-Token`
|
||||
4. `scope["auth_token"]` (внутренний)
|
||||
|
||||
## 🔧 SessionTokenManager
|
||||
|
||||
### Основные методы
|
||||
|
||||
```python
|
||||
from auth.tokens.sessions import SessionTokenManager
|
||||
|
||||
sessions = SessionTokenManager()
|
||||
|
||||
# Создание сессии
|
||||
token = await sessions.create_session(
|
||||
user_id="123",
|
||||
auth_data={"provider": "local"},
|
||||
username="john_doe",
|
||||
device_info={"ip": "192.168.1.1", "user_agent": "Mozilla/5.0"}
|
||||
)
|
||||
|
||||
# Создание JWT токена сессии
|
||||
token = await sessions.create_session_token(
|
||||
user_id="123",
|
||||
token_data={"username": "john_doe", "device_info": "..."}
|
||||
)
|
||||
|
||||
# Проверка сессии
|
||||
payload = await sessions.verify_session(token)
|
||||
# Возвращает: {"user_id": "123", "username": "john_doe", "iat": 1640995200, "exp": 1643587200}
|
||||
|
||||
# Валидация токена сессии
|
||||
valid, data = await sessions.validate_session_token(token)
|
||||
|
||||
# Получение данных сессии
|
||||
session_data = await sessions.get_session_data(token, user_id)
|
||||
|
||||
# Обновление сессии
|
||||
new_token = await sessions.refresh_session(user_id, old_token, device_info)
|
||||
|
||||
# Отзыв сессии
|
||||
await sessions.revoke_session_token(token)
|
||||
|
||||
# Отзыв всех сессий пользователя
|
||||
revoked_count = await sessions.revoke_user_sessions(user_id)
|
||||
|
||||
# Получение всех сессий пользователя
|
||||
user_sessions = await sessions.get_user_sessions(user_id)
|
||||
```
|
||||
|
||||
## 🍪 httpOnly Cookies
|
||||
|
||||
### Принципы работы
|
||||
|
||||
1. **Безопасное хранение**: Токены сессий хранятся в httpOnly cookies, недоступных для JavaScript
|
||||
2. **Автоматическая отправка**: Cookies автоматически отправляются с каждым запросом
|
||||
3. **Защита от XSS**: httpOnly cookies защищены от кражи через JavaScript
|
||||
4. **Двойная поддержка**: Система поддерживает как cookies, так и заголовок Authorization
|
||||
|
||||
### Конфигурация cookies
|
||||
|
||||
```python
|
||||
# settings.py
|
||||
SESSION_COOKIE_NAME = "session_token"
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SECURE = True # для HTTPS
|
||||
SESSION_COOKIE_SAMESITE = "lax"
|
||||
SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 дней
|
||||
```
|
||||
|
||||
### Установка cookies
|
||||
|
||||
```python
|
||||
# В AuthMiddleware
|
||||
def set_session_cookie(self, response: Response, token: str) -> None:
|
||||
"""Устанавливает httpOnly cookie с токеном сессии"""
|
||||
response.set_cookie(
|
||||
key=SESSION_COOKIE_NAME,
|
||||
value=token,
|
||||
httponly=SESSION_COOKIE_HTTPONLY,
|
||||
secure=SESSION_COOKIE_SECURE,
|
||||
samesite=SESSION_COOKIE_SAMESITE,
|
||||
max_age=SESSION_COOKIE_MAX_AGE
|
||||
)
|
||||
```
|
||||
|
||||
## 🔍 Извлечение токенов
|
||||
|
||||
### Автоматическое извлечение
|
||||
|
||||
```python
|
||||
from auth.utils import extract_token_from_request, get_auth_token, get_safe_headers
|
||||
|
||||
# Простое извлечение из cookies/headers
|
||||
token = await extract_token_from_request(request)
|
||||
|
||||
# Расширенное извлечение с логированием
|
||||
token = await get_auth_token(request)
|
||||
|
||||
# Ручная проверка источников
|
||||
headers = get_safe_headers(request)
|
||||
token = headers.get("authorization", "").replace("Bearer ", "")
|
||||
|
||||
# Извлечение из GraphQL контекста
|
||||
from auth.utils import get_auth_token_from_context
|
||||
token = await get_auth_token_from_context(info)
|
||||
```
|
||||
|
||||
### Приоритет источников
|
||||
|
||||
Система проверяет токены в следующем порядке приоритета:
|
||||
|
||||
1. **httpOnly cookies** - основной источник для веб-приложений
|
||||
2. **Заголовок Authorization** - для API клиентов и мобильных приложений
|
||||
|
||||
```python
|
||||
# auth/utils.py
|
||||
async def extract_token_from_request(request) -> str | None:
|
||||
"""DRY функция для извлечения токена из request"""
|
||||
|
||||
# 1. Проверяем cookies
|
||||
if hasattr(request, "cookies") and request.cookies:
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
if token:
|
||||
return token
|
||||
|
||||
# 2. Проверяем заголовок Authorization
|
||||
headers = get_safe_headers(request)
|
||||
auth_header = headers.get("authorization", "")
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:].strip()
|
||||
return token
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### Безопасное получение заголовков
|
||||
|
||||
```python
|
||||
# auth/utils.py
|
||||
def get_safe_headers(request: Any) -> dict[str, str]:
|
||||
"""Безопасно получает заголовки запроса"""
|
||||
headers = {}
|
||||
try:
|
||||
# Первый приоритет: scope из ASGI
|
||||
if hasattr(request, "scope") and isinstance(request.scope, dict):
|
||||
scope_headers = request.scope.get("headers", [])
|
||||
if scope_headers:
|
||||
headers.update({k.decode("utf-8").lower(): v.decode("utf-8")
|
||||
for k, v in scope_headers})
|
||||
|
||||
# Второй приоритет: метод headers() или атрибут headers
|
||||
if hasattr(request, "headers"):
|
||||
if callable(request.headers):
|
||||
h = request.headers()
|
||||
if h:
|
||||
headers.update({k.lower(): v for k, v in h.items()})
|
||||
else:
|
||||
h = request.headers
|
||||
if hasattr(h, "items") and callable(h.items):
|
||||
headers.update({k.lower(): v for k, v in h.items()})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка при доступе к заголовкам: {e}")
|
||||
|
||||
return headers
|
||||
```
|
||||
|
||||
## 🔄 Жизненный цикл сессии
|
||||
|
||||
### Создание сессии
|
||||
|
||||
```python
|
||||
# auth/tokens/sessions.py
|
||||
async def create_session(author_id: int, email: str, **kwargs) -> str:
|
||||
"""Создает новую сессию для пользователя"""
|
||||
session_data = {
|
||||
"author_id": author_id,
|
||||
"email": email,
|
||||
"created_at": int(time.time()),
|
||||
**kwargs
|
||||
}
|
||||
|
||||
# Генерируем уникальный токен
|
||||
token = generate_session_token()
|
||||
|
||||
# Сохраняем в Redis
|
||||
await redis.execute(
|
||||
"SETEX",
|
||||
f"session:{token}",
|
||||
SESSION_TOKEN_LIFE_SPAN,
|
||||
json.dumps(session_data)
|
||||
)
|
||||
|
||||
return token
|
||||
```
|
||||
|
||||
### Верификация сессии
|
||||
|
||||
```python
|
||||
# auth/tokens/storage.py
|
||||
async def verify_session(token: str) -> dict | None:
|
||||
"""Верифицирует токен сессии"""
|
||||
if not token:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Получаем данные сессии из Redis
|
||||
session_data = await redis.execute("GET", f"session:{token}")
|
||||
if not session_data:
|
||||
return None
|
||||
|
||||
return json.loads(session_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка верификации сессии: {e}")
|
||||
return None
|
||||
```
|
||||
|
||||
### Обновление сессии
|
||||
|
||||
```python
|
||||
async def refresh_session(user_id: str, old_token: str, device_info: dict = None) -> str:
|
||||
"""Обновляет сессию пользователя"""
|
||||
|
||||
# Проверяем старую сессию
|
||||
old_payload = await verify_session(old_token)
|
||||
if not old_payload:
|
||||
raise InvalidTokenError("Invalid session token")
|
||||
|
||||
# Отзываем старый токен
|
||||
await revoke_session_token(old_token)
|
||||
|
||||
# Создаем новый токен
|
||||
new_token = await create_session(
|
||||
user_id=user_id,
|
||||
username=old_payload.get("username"),
|
||||
device_info=device_info or old_payload.get("device_info", {})
|
||||
)
|
||||
|
||||
return new_token
|
||||
```
|
||||
|
||||
### Удаление сессии
|
||||
|
||||
```python
|
||||
# auth/tokens/storage.py
|
||||
async def delete_session(token: str) -> bool:
|
||||
"""Удаляет сессию пользователя"""
|
||||
try:
|
||||
result = await redis.execute("DEL", f"session:{token}")
|
||||
return bool(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления сессии: {e}")
|
||||
return False
|
||||
```
|
||||
|
||||
## 🔒 Безопасность
|
||||
|
||||
### JWT токены
|
||||
- **Алгоритм**: HS256
|
||||
- **Secret**: Из переменной окружения JWT_SECRET_KEY
|
||||
- **Payload**: `{user_id, username, iat, exp}`
|
||||
- **Expiration**: 30 дней (настраивается)
|
||||
|
||||
### Redis security
|
||||
- **TTL** для всех токенов
|
||||
- **Атомарные операции** через pipelines
|
||||
- **SCAN** вместо KEYS для производительности
|
||||
- **Транзакции** для критических операций
|
||||
|
||||
### Защита от атак
|
||||
- **XSS**: httpOnly cookies недоступны для JavaScript
|
||||
- **CSRF**: SameSite cookies и CSRF токены
|
||||
- **Session Hijacking**: Secure cookies и регулярная ротация токенов
|
||||
- **Brute Force**: Ограничение попыток входа и блокировка аккаунтов
|
||||
|
||||
## 📊 Мониторинг сессий
|
||||
|
||||
### Статистика
|
||||
|
||||
```python
|
||||
from auth.tokens.monitoring import TokenMonitoring
|
||||
|
||||
monitoring = TokenMonitoring()
|
||||
|
||||
# Статистика токенов
|
||||
stats = await monitoring.get_token_statistics()
|
||||
print(f"Active sessions: {stats['session_tokens']}")
|
||||
print(f"Memory usage: {stats['memory_usage']} bytes")
|
||||
|
||||
# Health check
|
||||
health = await monitoring.health_check()
|
||||
if health["status"] == "healthy":
|
||||
print("Session system is healthy")
|
||||
```
|
||||
|
||||
### Логирование событий
|
||||
|
||||
```python
|
||||
# auth/middleware.py
|
||||
def log_auth_event(event_type: str, user_id: int | None = None,
|
||||
success: bool = True, **kwargs):
|
||||
"""Логирует события авторизации"""
|
||||
logger.info(
|
||||
"auth_event",
|
||||
event_type=event_type,
|
||||
user_id=user_id,
|
||||
success=success,
|
||||
ip_address=kwargs.get('ip'),
|
||||
user_agent=kwargs.get('user_agent'),
|
||||
**kwargs
|
||||
)
|
||||
```
|
||||
|
||||
### Метрики
|
||||
|
||||
```python
|
||||
# auth/middleware.py
|
||||
from prometheus_client import Counter, Histogram
|
||||
|
||||
# Счетчики
|
||||
login_attempts = Counter('auth_login_attempts_total', 'Number of login attempts', ['success'])
|
||||
session_creations = Counter('auth_sessions_created_total', 'Number of sessions created')
|
||||
session_deletions = Counter('auth_sessions_deleted_total', 'Number of sessions deleted')
|
||||
|
||||
# Гистограммы
|
||||
auth_duration = Histogram('auth_operation_duration_seconds', 'Time spent on auth operations', ['operation'])
|
||||
```
|
||||
|
||||
## 🧪 Тестирование
|
||||
|
||||
### Unit тесты
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(client: AsyncClient):
|
||||
"""Тест успешного входа"""
|
||||
response = await client.post("/auth/login", json={
|
||||
"email": "test@example.com",
|
||||
"password": "password123"
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "token" in data
|
||||
|
||||
# Проверяем установку cookie
|
||||
cookies = response.cookies
|
||||
assert "session_token" in cookies
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_protected_endpoint_with_cookie(client: AsyncClient):
|
||||
"""Тест защищенного endpoint с cookie"""
|
||||
# Сначала входим в систему
|
||||
login_response = await client.post("/auth/login", json={
|
||||
"email": "test@example.com",
|
||||
"password": "password123"
|
||||
})
|
||||
|
||||
# Получаем cookie
|
||||
session_cookie = login_response.cookies.get("session_token")
|
||||
|
||||
# Делаем запрос к защищенному endpoint
|
||||
response = await client.get("/auth/session", cookies={
|
||||
"session_token": session_cookie
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user"]["email"] == "test@example.com"
|
||||
```
|
||||
|
||||
## 💡 Примеры использования
|
||||
|
||||
### 1. Вход в систему
|
||||
|
||||
```typescript
|
||||
// Frontend - React/SolidJS
|
||||
const handleLogin = async (email: string, password: string) => {
|
||||
try {
|
||||
const response = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
credentials: 'include', // Важно для cookies
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Cookie автоматически установится браузером
|
||||
// Перенаправляем на главную страницу
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('Login failed:', error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Проверка авторизации
|
||||
|
||||
```typescript
|
||||
// Frontend - проверка текущей сессии
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const response = await fetch('/auth/session', {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.user) {
|
||||
// Пользователь авторизован
|
||||
setUser(data.user);
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Защищенный API endpoint
|
||||
|
||||
```python
|
||||
# Backend - Python
|
||||
from auth.decorators import login_required, require_permission
|
||||
|
||||
@login_required
|
||||
@require_permission("shout:create")
|
||||
async def create_shout(info, input_data):
|
||||
"""Создание публикации с проверкой прав"""
|
||||
user = info.context.get('user')
|
||||
|
||||
# Создаем публикацию
|
||||
shout = Shout(
|
||||
title=input_data['title'],
|
||||
content=input_data['content'],
|
||||
author_id=user.id
|
||||
)
|
||||
|
||||
db.add(shout)
|
||||
db.commit()
|
||||
|
||||
return shout
|
||||
```
|
||||
|
||||
### 4. Выход из системы
|
||||
|
||||
```typescript
|
||||
// Frontend - выход
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
// Очищаем локальное состояние
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
|
||||
// Перенаправляем на страницу входа
|
||||
window.location.href = '/login';
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
}
|
||||
};
|
||||
```
|
||||
@@ -1,267 +0,0 @@
|
||||
# 🔧 Настройка системы аутентификации
|
||||
|
||||
## 🎯 Быстрая настройка
|
||||
|
||||
### 1. Environment Variables
|
||||
|
||||
```bash
|
||||
# JWT настройки
|
||||
JWT_SECRET_KEY=your_super_secret_key_minimum_256_bits
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_EXPIRATION_HOURS=720 # 30 дней
|
||||
|
||||
# Cookie настройки (httpOnly для безопасности)
|
||||
SESSION_COOKIE_NAME=session_token
|
||||
SESSION_COOKIE_HTTPONLY=true
|
||||
SESSION_COOKIE_SECURE=true # Только HTTPS в продакшене
|
||||
SESSION_COOKIE_SAMESITE=lax # CSRF защита
|
||||
SESSION_COOKIE_MAX_AGE=2592000 # 30 дней
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
REDIS_SOCKET_KEEPALIVE=true
|
||||
REDIS_HEALTH_CHECK_INTERVAL=30
|
||||
|
||||
# OAuth провайдеры
|
||||
GOOGLE_CLIENT_ID=your_google_client_id
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
GITHUB_CLIENT_ID=your_github_client_id
|
||||
GITHUB_CLIENT_SECRET=your_github_client_secret
|
||||
YANDEX_CLIENT_ID=your_yandex_client_id
|
||||
YANDEX_CLIENT_SECRET=your_yandex_client_secret
|
||||
VK_CLIENT_ID=your_vk_app_id
|
||||
VK_CLIENT_SECRET=your_vk_secure_key
|
||||
|
||||
# Безопасность
|
||||
RATE_LIMIT_ENABLED=true
|
||||
MAX_LOGIN_ATTEMPTS=5
|
||||
LOCKOUT_DURATION=1800 # 30 минут
|
||||
```
|
||||
|
||||
### 2. OAuth Провайдеры
|
||||
|
||||
#### Google OAuth
|
||||
1. [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. **APIs & Services** → **Credentials** → **Create OAuth 2.0 Client ID**
|
||||
3. **Authorized redirect URIs**:
|
||||
- `https://v3.discours.io/oauth/google/callback` (продакшн)
|
||||
- `http://localhost:8000/oauth/google/callback` (разработка)
|
||||
|
||||
#### GitHub OAuth
|
||||
1. [GitHub Developer Settings](https://github.com/settings/developers)
|
||||
2. **New OAuth App**
|
||||
3. **Authorization callback URL**: `https://v3.discours.io/oauth/github/callback`
|
||||
|
||||
#### Yandex OAuth
|
||||
1. [Yandex OAuth](https://oauth.yandex.ru/)
|
||||
2. **Создать новое приложение**
|
||||
3. **Callback URI**: `https://v3.discours.io/oauth/yandex/callback`
|
||||
4. **Права**: `login:info`, `login:email`, `login:avatar`
|
||||
|
||||
#### VK OAuth
|
||||
1. [VK Developers](https://dev.vk.com/apps)
|
||||
2. **Создать приложение** → **Веб-сайт**
|
||||
3. **Redirect URI**: `https://v3.discours.io/oauth/vk/callback`
|
||||
|
||||
### 3. Проверка настройки
|
||||
|
||||
```bash
|
||||
# Проверка переменных окружения
|
||||
python -c "
|
||||
import os
|
||||
required = ['JWT_SECRET_KEY', 'REDIS_URL', 'GOOGLE_CLIENT_ID']
|
||||
for var in required:
|
||||
print(f'{var}: {\"✅\" if os.getenv(var) else \"❌\"}')"
|
||||
|
||||
# Проверка Redis подключения
|
||||
python -c "
|
||||
import asyncio
|
||||
from storage.redis import redis
|
||||
async def test():
|
||||
result = await redis.ping()
|
||||
print(f'Redis: {\"✅\" if result else \"❌\"}')
|
||||
asyncio.run(test())"
|
||||
|
||||
# Проверка OAuth провайдеров
|
||||
curl -v "https://v3.discours.io/oauth/google/login"
|
||||
```
|
||||
|
||||
## 🔒 Безопасность в продакшене
|
||||
|
||||
### SSL/HTTPS настройки
|
||||
```bash
|
||||
# Принудительное HTTPS
|
||||
FORCE_HTTPS=true
|
||||
HSTS_MAX_AGE=31536000
|
||||
|
||||
# Secure cookies только для HTTPS
|
||||
SESSION_COOKIE_SECURE=true
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
```bash
|
||||
RATE_LIMIT_REQUESTS=100
|
||||
RATE_LIMIT_WINDOW=3600 # 1 час
|
||||
```
|
||||
|
||||
### Account Lockout
|
||||
```bash
|
||||
MAX_LOGIN_ATTEMPTS=5
|
||||
LOCKOUT_DURATION=1800 # 30 минут
|
||||
```
|
||||
|
||||
## 🐛 Диагностика проблем
|
||||
|
||||
### Частые ошибки
|
||||
|
||||
#### "Provider not configured"
|
||||
```bash
|
||||
# Проверить переменные окружения
|
||||
echo $GOOGLE_CLIENT_ID
|
||||
echo $GOOGLE_CLIENT_SECRET
|
||||
|
||||
# Перезапустить приложение после установки переменных
|
||||
```
|
||||
|
||||
#### "redirect_uri_mismatch"
|
||||
- Проверить точное соответствие URL в настройках провайдера
|
||||
- Убедиться что протокол (http/https) совпадает
|
||||
- Callback URL должен указывать на backend, НЕ на frontend
|
||||
|
||||
#### "Cookies не работают"
|
||||
```bash
|
||||
# Проверить настройки cookie
|
||||
curl -v -b "session_token=test" "https://v3.discours.io/graphql"
|
||||
|
||||
# Проверить что фронтенд отправляет credentials
|
||||
# В коде должно быть: credentials: 'include'
|
||||
```
|
||||
|
||||
#### "CORS ошибки"
|
||||
```python
|
||||
# В настройках CORS должно быть:
|
||||
allow_credentials=True
|
||||
allow_origins=["https://your-frontend-domain.com"]
|
||||
```
|
||||
|
||||
### Логи для отладки
|
||||
```bash
|
||||
# Поиск ошибок аутентификации
|
||||
grep -i "auth\|oauth\|cookie" /var/log/app/app.log
|
||||
|
||||
# Мониторинг Redis операций
|
||||
redis-cli monitor | grep "session\|oauth"
|
||||
```
|
||||
|
||||
## 📊 Мониторинг
|
||||
|
||||
### Health Check
|
||||
```python
|
||||
from auth.tokens.monitoring import TokenMonitoring
|
||||
|
||||
async def auth_health():
|
||||
monitoring = TokenMonitoring()
|
||||
health = await monitoring.health_check()
|
||||
stats = await monitoring.get_token_statistics()
|
||||
|
||||
return {
|
||||
"status": health["status"],
|
||||
"redis_connected": health["redis_connected"],
|
||||
"active_sessions": stats["session_tokens"],
|
||||
"memory_usage_mb": stats["memory_usage"] / 1024 / 1024
|
||||
}
|
||||
```
|
||||
|
||||
### Метрики для мониторинга
|
||||
- Количество активных сессий
|
||||
- Успешность OAuth авторизаций
|
||||
- Rate limit нарушения
|
||||
- Заблокированные аккаунты
|
||||
- Использование памяти Redis
|
||||
|
||||
## 🧪 Тестирование
|
||||
|
||||
### Unit тесты
|
||||
```bash
|
||||
# Запуск auth тестов
|
||||
pytest tests/auth/ -v
|
||||
|
||||
# Проверка типов
|
||||
mypy auth/
|
||||
```
|
||||
|
||||
### E2E тесты
|
||||
```bash
|
||||
# Тестирование OAuth flow
|
||||
playwright test tests/oauth.spec.ts
|
||||
|
||||
# Тестирование cookie аутентификации
|
||||
playwright test tests/auth-cookies.spec.ts
|
||||
```
|
||||
|
||||
### Нагрузочное тестирование
|
||||
```bash
|
||||
# Тестирование login endpoint
|
||||
ab -n 1000 -c 10 -p login.json -T application/json http://localhost:8000/graphql
|
||||
|
||||
# Содержимое login.json:
|
||||
# {"query":"mutation{login(email:\"test@example.com\",password:\"password\"){success}}"}
|
||||
```
|
||||
|
||||
## 🚀 Развертывание
|
||||
|
||||
### Docker
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
ENV JWT_SECRET_KEY=your_secret_here
|
||||
ENV REDIS_URL=redis://redis:6379/0
|
||||
ENV SESSION_COOKIE_SECURE=true
|
||||
```
|
||||
|
||||
### Dokku/Heroku
|
||||
```bash
|
||||
# Установка переменных окружения
|
||||
dokku config:set myapp JWT_SECRET_KEY=xxx REDIS_URL=yyy
|
||||
heroku config:set JWT_SECRET_KEY=xxx REDIS_URL=yyy
|
||||
```
|
||||
|
||||
### Nginx настройки
|
||||
```nginx
|
||||
# Поддержка cookies
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_cookie_path / "/; Secure; HttpOnly; SameSite=lax";
|
||||
|
||||
# CORS для credentials
|
||||
add_header Access-Control-Allow-Credentials true;
|
||||
add_header Access-Control-Allow-Origin https://your-frontend.com;
|
||||
```
|
||||
|
||||
## ✅ Checklist для продакшена
|
||||
|
||||
### Безопасность
|
||||
- [ ] JWT secret минимум 256 бит
|
||||
- [ ] HTTPS принудительно включен
|
||||
- [ ] httpOnly cookies настроены
|
||||
- [ ] SameSite cookies включены
|
||||
- [ ] Rate limiting активен
|
||||
- [ ] Account lockout настроен
|
||||
|
||||
### OAuth
|
||||
- [ ] Все провайдеры настроены
|
||||
- [ ] Redirect URIs правильные
|
||||
- [ ] Client secrets безопасно хранятся
|
||||
- [ ] PKCE включен для поддерживающих провайдеров
|
||||
|
||||
### Мониторинг
|
||||
- [ ] Health checks настроены
|
||||
- [ ] Логирование работает
|
||||
- [ ] Метрики собираются
|
||||
- [ ] Алерты настроены
|
||||
|
||||
### Производительность
|
||||
- [ ] Redis connection pooling
|
||||
- [ ] TTL для всех ключей
|
||||
- [ ] Batch операции для массовых действий
|
||||
- [ ] Memory optimization включена
|
||||
|
||||
**Готово к продакшену!** 🚀✅
|
||||
@@ -1,414 +0,0 @@
|
||||
# 📡 SSE + httpOnly Cookies Integration
|
||||
|
||||
## 🎯 Обзор
|
||||
|
||||
Server-Sent Events (SSE) **отлично работают** с httpOnly cookies! Браузер автоматически отправляет cookies при установке SSE соединения.
|
||||
|
||||
## 🔄 Как это работает
|
||||
|
||||
### 1. 🚀 Установка SSE соединения
|
||||
|
||||
```typescript
|
||||
// Фронтенд - SSE с cross-origin поддоменом
|
||||
const eventSource = new EventSource('https://connect.discours.io/notifications', {
|
||||
withCredentials: true // ✅ КРИТИЧНО: отправляет httpOnly cookies cross-origin
|
||||
});
|
||||
|
||||
// Для продакшена
|
||||
const SSE_URL = process.env.NODE_ENV === 'production'
|
||||
? 'https://connect.discours.io/'
|
||||
: 'https://connect.discours.io/';
|
||||
|
||||
const eventSource = new EventSource(SSE_URL, {
|
||||
withCredentials: true // ✅ Обязательно для cross-origin cookies
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 🔧 Backend SSE endpoint с аутентификацией
|
||||
|
||||
```python
|
||||
# main.py - добавляем SSE endpoint
|
||||
from starlette.responses import StreamingResponse
|
||||
from auth.middleware import auth_middleware
|
||||
|
||||
@app.route("/sse/notifications")
|
||||
async def sse_notifications(request: Request):
|
||||
"""SSE endpoint для real-time уведомлений"""
|
||||
|
||||
# ✅ Аутентификация через httpOnly cookie
|
||||
user_data = await auth_middleware.authenticate_user(request)
|
||||
if not user_data:
|
||||
return Response("Unauthorized", status_code=401)
|
||||
|
||||
user_id = user_data.get("user_id")
|
||||
|
||||
async def event_stream():
|
||||
"""Генератор SSE событий"""
|
||||
try:
|
||||
# Подписываемся на Redis каналы пользователя
|
||||
channels = [
|
||||
f"notifications:{user_id}",
|
||||
f"follower:{user_id}",
|
||||
f"shout:{user_id}"
|
||||
]
|
||||
|
||||
pubsub = redis.pubsub()
|
||||
await pubsub.subscribe(*channels)
|
||||
|
||||
# Отправляем initial heartbeat
|
||||
yield f"data: {json.dumps({'type': 'connected', 'user_id': user_id})}\n\n"
|
||||
|
||||
async for message in pubsub.listen():
|
||||
if message['type'] == 'message':
|
||||
# Форматируем SSE событие
|
||||
data = message['data'].decode('utf-8')
|
||||
yield f"data: {data}\n\n"
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await pubsub.unsubscribe()
|
||||
await pubsub.close()
|
||||
except Exception as e:
|
||||
logger.error(f"SSE error for user {user_id}: {e}")
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"Access-Control-Allow-Credentials": "true", # Для CORS
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 3. 🌐 Фронтенд SSE клиент
|
||||
|
||||
```typescript
|
||||
// SSE клиент с автоматической аутентификацией через cookies
|
||||
class SSEClient {
|
||||
private eventSource: EventSource | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
|
||||
connect() {
|
||||
try {
|
||||
// ✅ Cross-origin SSE с cookies
|
||||
const SSE_URL = process.env.NODE_ENV === 'production'
|
||||
? 'https://connect.discours.io/sse/notifications'
|
||||
: 'https://connect.discours.io/sse/notifications';
|
||||
|
||||
this.eventSource = new EventSource(SSE_URL, {
|
||||
withCredentials: true // ✅ КРИТИЧНО для cross-origin cookies
|
||||
});
|
||||
|
||||
this.eventSource.onopen = () => {
|
||||
console.log('✅ SSE connected');
|
||||
this.reconnectAttempts = 0;
|
||||
};
|
||||
|
||||
this.eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
this.handleNotification(data);
|
||||
} catch (error) {
|
||||
console.error('SSE message parse error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.eventSource.onerror = (error) => {
|
||||
console.error('SSE error:', error);
|
||||
|
||||
// Если получили 401 - cookie недействителен
|
||||
if (this.eventSource?.readyState === EventSource.CLOSED) {
|
||||
this.handleAuthError();
|
||||
} else {
|
||||
this.handleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('SSE connection error:', error);
|
||||
this.handleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private handleNotification(data: any) {
|
||||
switch (data.type) {
|
||||
case 'connected':
|
||||
console.log(`SSE connected for user: ${data.user_id}`);
|
||||
break;
|
||||
|
||||
case 'follower':
|
||||
this.handleFollowerNotification(data);
|
||||
break;
|
||||
|
||||
case 'shout':
|
||||
this.handleShoutNotification(data);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('SSE server error:', data.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleAuthError() {
|
||||
console.warn('SSE authentication failed - redirecting to login');
|
||||
// Cookie недействителен - редиректим на login
|
||||
window.location.href = '/login?error=session_expired';
|
||||
}
|
||||
|
||||
private handleReconnect() {
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++;
|
||||
const delay = Math.pow(2, this.reconnectAttempts) * 1000; // Exponential backoff
|
||||
|
||||
console.log(`Reconnecting SSE in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
||||
|
||||
setTimeout(() => {
|
||||
this.disconnect();
|
||||
this.connect();
|
||||
}, delay);
|
||||
} else {
|
||||
console.error('Max SSE reconnect attempts reached');
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleFollowerNotification(data: any) {
|
||||
// Обновляем UI при новом подписчике
|
||||
if (data.action === 'create') {
|
||||
showNotification(`${data.payload.follower_name} подписался на вас!`);
|
||||
updateFollowersCount(+1);
|
||||
}
|
||||
}
|
||||
|
||||
private handleShoutNotification(data: any) {
|
||||
// Обновляем UI при новых публикациях
|
||||
if (data.action === 'create') {
|
||||
showNotification(`Новая публикация: ${data.payload.title}`);
|
||||
refreshFeed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Использование в приложении
|
||||
const sseClient = new SSEClient();
|
||||
|
||||
// Подключаемся после успешной аутентификации
|
||||
const auth = useAuth();
|
||||
if (auth.isAuthenticated()) {
|
||||
sseClient.connect();
|
||||
}
|
||||
|
||||
// Отключаемся при logout
|
||||
auth.onLogout(() => {
|
||||
sseClient.disconnect();
|
||||
});
|
||||
```
|
||||
|
||||
## 🔧 Интеграция с существующей системой
|
||||
|
||||
### SSE сервер на connect.discours.io
|
||||
|
||||
```python
|
||||
# connect.discours.io / connect.discours.io - отдельный SSE сервер
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.routing import Route
|
||||
|
||||
# SSE приложение
|
||||
sse_app = Starlette(
|
||||
routes=[
|
||||
# ✅ Единственный endpoint - SSE notifications
|
||||
Route("/sse/notifications", sse_notifications, methods=["GET"]),
|
||||
Route("/health", health_check, methods=["GET"]),
|
||||
],
|
||||
middleware=[
|
||||
# ✅ CORS для cross-origin cookies
|
||||
Middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"https://testing.discours.io",
|
||||
"https://discours.io",
|
||||
"https://new.discours.io",
|
||||
"http://localhost:3000", # dev
|
||||
],
|
||||
allow_credentials=True, # ✅ Разрешаем cookies
|
||||
allow_methods=["GET", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# Основной сервер остается без изменений
|
||||
# main.py - БЕЗ SSE routes
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/graphql", graphql_handler, methods=["GET", "POST", "OPTIONS"]),
|
||||
Route("/oauth/{provider}/callback", oauth_callback_http, methods=["GET"]),
|
||||
Route("/oauth/{provider}", oauth_login_http, methods=["GET"]),
|
||||
# SSE НЕ здесь - он на отдельном поддомене!
|
||||
],
|
||||
middleware=middleware,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
```
|
||||
|
||||
### Используем существующую notify систему
|
||||
|
||||
```python
|
||||
# services/notify.py - уже готова!
|
||||
# Ваша система уже отправляет уведомления в Redis каналы:
|
||||
|
||||
async def notify_follower(follower, author_id, action="follow"):
|
||||
channel_name = f"follower:{author_id}"
|
||||
data = {
|
||||
"type": "follower",
|
||||
"action": "create" if action == "follow" else "delete",
|
||||
"entity": "follower",
|
||||
"payload": {
|
||||
"follower_id": follower["id"],
|
||||
"follower_name": follower["name"],
|
||||
"following_id": author_id,
|
||||
}
|
||||
}
|
||||
|
||||
# ✅ Отправляем в Redis - SSE endpoint получит автоматически
|
||||
await redis.publish(channel_name, orjson.dumps(data))
|
||||
```
|
||||
|
||||
## 🛡️ Безопасность SSE + httpOnly cookies
|
||||
|
||||
### Преимущества:
|
||||
- **🚫 Защита от XSS**: Токены недоступны JavaScript
|
||||
- **🔒 Автоматическая аутентификация**: Браузер сам отправляет cookies
|
||||
- **🛡️ CSRF защита**: SameSite cookies
|
||||
- **📱 Простота**: Нет управления токенами в JavaScript
|
||||
|
||||
### CORS настройки для cross-origin SSE:
|
||||
|
||||
```python
|
||||
# connect.discours.io / connect.discours.io - CORS для SSE
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"https://testing.discours.io",
|
||||
"https://discours.io",
|
||||
"https://new.discours.io",
|
||||
# Для разработки
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
],
|
||||
allow_credentials=True, # ✅ КРИТИЧНО: разрешает отправку cookies cross-origin
|
||||
allow_methods=["GET", "OPTIONS"], # SSE использует GET + preflight OPTIONS
|
||||
allow_headers=["*"],
|
||||
)
|
||||
```
|
||||
|
||||
### Cookie Domain настройки:
|
||||
|
||||
```python
|
||||
# settings.py - Cookie должен работать для всех поддоменов
|
||||
SESSION_COOKIE_DOMAIN = ".discours.io" # ✅ Работает для всех поддоменов
|
||||
SESSION_COOKIE_SECURE = True # ✅ Только HTTPS
|
||||
SESSION_COOKIE_SAMESITE = "none" # ✅ Для cross-origin (но secure!)
|
||||
|
||||
# Для продакшена
|
||||
if PRODUCTION:
|
||||
SESSION_COOKIE_DOMAIN = ".discours.io"
|
||||
```
|
||||
|
||||
## 🧪 Тестирование SSE + cookies
|
||||
|
||||
```typescript
|
||||
// Тест SSE соединения
|
||||
test('SSE connects with httpOnly cookies', async ({ page }) => {
|
||||
// 1. Авторизуемся (cookie устанавливается)
|
||||
await page.goto('/login');
|
||||
await loginWithEmail(page, 'test@example.com', 'password');
|
||||
|
||||
// 2. Проверяем что cookie установлен
|
||||
const cookies = await page.context().cookies();
|
||||
const authCookie = cookies.find(c => c.name === 'session_token');
|
||||
expect(authCookie).toBeTruthy();
|
||||
|
||||
// 3. Тестируем cross-origin SSE соединение
|
||||
const sseConnected = await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
const eventSource = new EventSource('https://connect.discours.io/', {
|
||||
withCredentials: true // ✅ Отправляем cookies cross-origin
|
||||
});
|
||||
|
||||
eventSource.onopen = () => {
|
||||
resolve(true);
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
resolve(false);
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
// Timeout после 5 секунд
|
||||
setTimeout(() => {
|
||||
resolve(false);
|
||||
eventSource.close();
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
|
||||
expect(sseConnected).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
## 📊 Мониторинг SSE соединений
|
||||
|
||||
```python
|
||||
# Добавляем метрики SSE
|
||||
from collections import defaultdict
|
||||
|
||||
sse_connections = defaultdict(int)
|
||||
|
||||
async def sse_notifications(request: Request):
|
||||
user_data = await auth_middleware.authenticate_user(request)
|
||||
if not user_data:
|
||||
return Response("Unauthorized", status_code=401)
|
||||
|
||||
user_id = user_data.get("user_id")
|
||||
|
||||
# Увеличиваем счетчик соединений
|
||||
sse_connections[user_id] += 1
|
||||
logger.info(f"SSE connected: user_id={user_id}, total_connections={sse_connections[user_id]}")
|
||||
|
||||
try:
|
||||
async def event_stream():
|
||||
# ... SSE логика ...
|
||||
pass
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
|
||||
finally:
|
||||
# Уменьшаем счетчик при отключении
|
||||
sse_connections[user_id] -= 1
|
||||
logger.info(f"SSE disconnected: user_id={user_id}, remaining_connections={sse_connections[user_id]}")
|
||||
```
|
||||
|
||||
## 🎯 Результат
|
||||
|
||||
**SSE + httpOnly cookies = Идеальное сочетание для real-time уведомлений:**
|
||||
|
||||
- ✅ **Безопасность**: Максимальная защита от XSS/CSRF
|
||||
- ✅ **Простота**: Автоматическая аутентификация
|
||||
- ✅ **Производительность**: Нет дополнительных HTTP запросов для аутентификации
|
||||
- ✅ **Надежность**: Браузер сам управляет отправкой cookies
|
||||
- ✅ **Совместимость**: Работает со всеми современными браузерами
|
||||
|
||||
**Ваша существующая notify система готова к работе с SSE!** 📡🍪✨
|
||||
@@ -1,373 +0,0 @@
|
||||
# Система авторизации Discours Core
|
||||
|
||||
## 🎯 Обзор архитектуры
|
||||
|
||||
Модульная система авторизации с JWT токенами, Redis-сессиями и OAuth интеграцией. Построена на принципах разделения ответственности и высокой производительности.
|
||||
|
||||
```
|
||||
auth/
|
||||
├── tokens/ # 🎯 Система управления токенами
|
||||
│ ├── sessions.py # JWT сессии с Redis
|
||||
│ ├── verification.py # Одноразовые токены
|
||||
│ ├── oauth.py # OAuth токены
|
||||
│ ├── batch.py # Массовые операции
|
||||
│ ├── monitoring.py # Мониторинг и статистика
|
||||
│ ├── storage.py # Фасад для совместимости
|
||||
│ ├── base.py # Базовые классы
|
||||
│ └── types.py # Типы и константы
|
||||
├── middleware.py # HTTP middleware
|
||||
├── decorators.py # GraphQL декораторы
|
||||
├── oauth.py # OAuth провайдеры
|
||||
├── identity.py # Методы идентификации
|
||||
├── jwtcodec.py # JWT кодек
|
||||
├── validations.py # Валидация данных
|
||||
├── credentials.py # Креденшиалы
|
||||
├── exceptions.py # Исключения
|
||||
└── utils.py # Утилиты
|
||||
```
|
||||
|
||||
## 🎯 Система токенов
|
||||
|
||||
### SessionTokenManager
|
||||
|
||||
**Принцип работы:**
|
||||
1. JWT токены с payload `{user_id, username, iat, exp}`
|
||||
2. Redis хранение для отзыва и управления жизненным циклом
|
||||
3. Поддержка множественных сессий на пользователя
|
||||
4. Автоматическое обновление `last_activity` при активности
|
||||
|
||||
**Redis структура:**
|
||||
```bash
|
||||
session:{user_id}:{token} # hash с данными сессии
|
||||
user_sessions:{user_id} # set с активными токенами
|
||||
```
|
||||
|
||||
**Основные методы:**
|
||||
```python
|
||||
from auth.tokens.sessions import SessionTokenManager
|
||||
|
||||
sessions = SessionTokenManager()
|
||||
|
||||
# Создание сессии
|
||||
token = await sessions.create_session(user_id, username=username)
|
||||
|
||||
# Проверка сессии
|
||||
payload = await sessions.verify_session(token)
|
||||
|
||||
# Обновление сессии
|
||||
new_token = await sessions.refresh_session(user_id, old_token)
|
||||
|
||||
# Отзыв сессии
|
||||
await sessions.revoke_session_token(token)
|
||||
|
||||
# Получение всех сессий пользователя
|
||||
user_sessions = await sessions.get_user_sessions(user_id)
|
||||
```
|
||||
|
||||
### Типы токенов
|
||||
|
||||
| Тип | TTL | Назначение | Менеджер |
|
||||
|-----|-----|------------|----------|
|
||||
| `session` | 30 дней | JWT сессии пользователей | `SessionTokenManager` |
|
||||
| `verification` | 1 час | Одноразовые токены подтверждения | `VerificationTokenManager` |
|
||||
| `oauth_access` | 1 час | OAuth access токены | `OAuthTokenManager` |
|
||||
| `oauth_refresh` | 30 дней | OAuth refresh токены | `OAuthTokenManager` |
|
||||
|
||||
### Менеджеры токенов
|
||||
|
||||
#### 1. **SessionTokenManager** - JWT сессии
|
||||
```python
|
||||
from auth.tokens.sessions import SessionTokenManager
|
||||
|
||||
sessions = SessionTokenManager()
|
||||
|
||||
# Создание сессии
|
||||
token = await sessions.create_session(
|
||||
user_id="123",
|
||||
auth_data={"provider": "local"},
|
||||
username="john_doe",
|
||||
device_info={"ip": "192.168.1.1", "user_agent": "Mozilla/5.0"}
|
||||
)
|
||||
|
||||
# Создание JWT токена сессии
|
||||
token = await sessions.create_session_token(
|
||||
user_id="123",
|
||||
token_data={"username": "john_doe", "device_info": "..."}
|
||||
)
|
||||
|
||||
# Проверка сессии (совместимость с TokenStorage)
|
||||
payload = await sessions.verify_session(token)
|
||||
# Возвращает: {"user_id": "123", "username": "john_doe", "iat": 1640995200, "exp": 1643587200}
|
||||
|
||||
# Валидация токена сессии
|
||||
valid, data = await sessions.validate_session_token(token)
|
||||
|
||||
# Получение данных сессии
|
||||
session_data = await sessions.get_session_data(token, user_id)
|
||||
|
||||
# Обновление сессии
|
||||
new_token = await sessions.refresh_session(user_id, old_token, device_info)
|
||||
|
||||
# Отзыв сессии
|
||||
await sessions.revoke_session_token(token)
|
||||
|
||||
# Отзыв всех сессий пользователя
|
||||
revoked_count = await sessions.revoke_user_sessions(user_id)
|
||||
|
||||
# Получение всех сессий пользователя
|
||||
user_sessions = await sessions.get_user_sessions(user_id)
|
||||
```
|
||||
|
||||
#### 2. **VerificationTokenManager** - Одноразовые токены
|
||||
```python
|
||||
from auth.tokens.verification import VerificationTokenManager
|
||||
|
||||
verification = VerificationTokenManager()
|
||||
|
||||
# Создание токена подтверждения email
|
||||
token = await verification.create_verification_token(
|
||||
user_id="123",
|
||||
verification_type="email_change",
|
||||
data={"new_email": "new@example.com"},
|
||||
ttl=3600 # 1 час
|
||||
)
|
||||
|
||||
# Проверка токена
|
||||
valid, data = await verification.validate_verification_token(token)
|
||||
|
||||
# Подтверждение (одноразовое использование)
|
||||
confirmed_data = await verification.confirm_verification_token(token)
|
||||
```
|
||||
|
||||
#### 3. **OAuthTokenManager** - OAuth токены
|
||||
```python
|
||||
from auth.tokens.oauth import OAuthTokenManager
|
||||
|
||||
oauth = OAuthTokenManager()
|
||||
|
||||
# Сохранение OAuth токенов
|
||||
await oauth.store_oauth_tokens(
|
||||
user_id="123",
|
||||
provider="google",
|
||||
access_token="ya29.a0AfH6SM...",
|
||||
refresh_token="1//04...",
|
||||
expires_in=3600,
|
||||
additional_data={"scope": "read write"}
|
||||
)
|
||||
|
||||
# Создание OAuth токена (внутренний метод)
|
||||
token_key = await oauth._create_oauth_token(
|
||||
user_id="123",
|
||||
token_data={"token": "ya29.a0AfH6SM...", "provider": "google"},
|
||||
ttl=3600,
|
||||
provider="google",
|
||||
token_type="oauth_access"
|
||||
)
|
||||
|
||||
# Получение access токена
|
||||
access_data = await oauth.get_token(user_id, "google", "oauth_access")
|
||||
|
||||
# Оптимизированное получение OAuth данных
|
||||
oauth_data = await oauth._get_oauth_data_optimized("oauth_access", "123", "google")
|
||||
|
||||
# Отзыв OAuth токенов
|
||||
await oauth.revoke_oauth_tokens(user_id, "google")
|
||||
|
||||
# Оптимизированный отзыв токена
|
||||
revoked = await oauth._revoke_oauth_token_optimized("oauth_access", "123", "google")
|
||||
|
||||
# Отзыв всех OAuth токенов пользователя
|
||||
revoked_count = await oauth.revoke_user_oauth_tokens(user_id, "oauth_access")
|
||||
```
|
||||
|
||||
#### 4. **BatchTokenOperations** - Массовые операции
|
||||
```python
|
||||
from auth.tokens.batch import BatchTokenOperations
|
||||
|
||||
batch = BatchTokenOperations()
|
||||
|
||||
# Массовая проверка токенов
|
||||
tokens = ["token1", "token2", "token3"]
|
||||
results = await batch.batch_validate_tokens(tokens)
|
||||
# {"token1": True, "token2": False, "token3": True}
|
||||
|
||||
# Валидация батча токенов (внутренний метод)
|
||||
batch_results = await batch._validate_token_batch(tokens)
|
||||
|
||||
# Безопасное декодирование токена
|
||||
payload = await batch._safe_decode_token(token)
|
||||
|
||||
# Массовый отзыв токенов
|
||||
revoked_count = await batch.batch_revoke_tokens(tokens)
|
||||
|
||||
# Отзыв батча токенов (внутренний метод)
|
||||
batch_revoked = await batch._revoke_token_batch(tokens)
|
||||
|
||||
# Очистка истекших токенов
|
||||
cleaned_count = await batch.cleanup_expired_tokens()
|
||||
```
|
||||
|
||||
#### 5. **TokenMonitoring** - Мониторинг
|
||||
```python
|
||||
from auth.tokens.monitoring import TokenMonitoring
|
||||
|
||||
monitoring = TokenMonitoring()
|
||||
|
||||
# Статистика токенов
|
||||
stats = await monitoring.get_token_statistics()
|
||||
# {
|
||||
# "session_tokens": 150,
|
||||
# "verification_tokens": 5,
|
||||
# "oauth_access_tokens": 25,
|
||||
# "oauth_refresh_tokens": 25,
|
||||
# "memory_usage": 1048576
|
||||
# }
|
||||
|
||||
# Подсчет ключей по паттерну (внутренний метод)
|
||||
count = await monitoring._count_keys_by_pattern("session:*")
|
||||
|
||||
# Health check
|
||||
health = await monitoring.health_check()
|
||||
# {"status": "healthy", "redis_connected": True, "token_count": 205}
|
||||
|
||||
# Оптимизация памяти
|
||||
optimization = await monitoring.optimize_memory_usage()
|
||||
# {"cleaned_expired": 10, "memory_freed": 102400}
|
||||
|
||||
# Оптимизация структур данных (внутренний метод)
|
||||
optimized = await monitoring._optimize_data_structures()
|
||||
```
|
||||
|
||||
### TokenStorage (Фасад для совместимости)
|
||||
```python
|
||||
from auth.tokens.storage import TokenStorage
|
||||
|
||||
# Упрощенный API для основных операций
|
||||
await TokenStorage.create_session(user_id, username=username)
|
||||
await TokenStorage.verify_session(token)
|
||||
await TokenStorage.refresh_session(user_id, old_token, device_info)
|
||||
await TokenStorage.revoke_session(token)
|
||||
```
|
||||
|
||||
## 🔧 Middleware и декораторы
|
||||
|
||||
### AuthMiddleware
|
||||
```python
|
||||
from auth.middleware import AuthMiddleware
|
||||
|
||||
# Автоматическая обработка токенов
|
||||
middleware = AuthMiddleware()
|
||||
|
||||
# Извлечение токена из запроса
|
||||
token = await extract_token_from_request(request)
|
||||
|
||||
# Проверка сессии
|
||||
payload = await sessions.verify_session(token)
|
||||
```
|
||||
|
||||
### GraphQL декораторы
|
||||
```python
|
||||
from auth.decorators import auth_required, permission_required
|
||||
|
||||
@auth_required
|
||||
async def protected_resolver(info, **kwargs):
|
||||
"""Требует авторизации"""
|
||||
user = info.context.get('user')
|
||||
return f"Hello, {user.username}!"
|
||||
|
||||
@permission_required("shout:create")
|
||||
async def create_shout(info, input_data):
|
||||
"""Требует права на создание публикаций"""
|
||||
pass
|
||||
```
|
||||
|
||||
## ORM модели
|
||||
|
||||
### Author (Пользователь)
|
||||
```python
|
||||
class Author:
|
||||
id: int
|
||||
email: str
|
||||
name: str
|
||||
slug: str
|
||||
password: Optional[str] # bcrypt hash
|
||||
pic: Optional[str] # URL аватара
|
||||
bio: Optional[str]
|
||||
email_verified: bool
|
||||
phone_verified: bool
|
||||
created_at: int
|
||||
updated_at: int
|
||||
last_seen: int
|
||||
|
||||
# OAuth данные в JSON формате
|
||||
oauth: Optional[dict] # {"google": {"id": "123", "email": "user@gmail.com"}}
|
||||
|
||||
# Поля аутентификации
|
||||
failed_login_attempts: int
|
||||
account_locked_until: Optional[int]
|
||||
```
|
||||
|
||||
### OAuth данные
|
||||
OAuth данные хранятся в JSON поле `oauth` модели `Author`:
|
||||
```python
|
||||
# Формат oauth поля
|
||||
{
|
||||
"google": {
|
||||
"id": "123456789",
|
||||
"email": "user@gmail.com",
|
||||
"name": "John Doe"
|
||||
},
|
||||
"github": {
|
||||
"id": "456789",
|
||||
"login": "johndoe",
|
||||
"email": "user@github.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## ⚙️ Конфигурация
|
||||
|
||||
### Переменные окружения
|
||||
```bash
|
||||
# JWT настройки
|
||||
JWT_SECRET_KEY=your_super_secret_key
|
||||
JWT_EXPIRATION_HOURS=720 # 30 дней
|
||||
|
||||
# Redis подключение
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# OAuth провайдеры
|
||||
GOOGLE_CLIENT_ID=your_google_client_id
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
GITHUB_CLIENT_ID=your_github_client_id
|
||||
GITHUB_CLIENT_SECRET=your_github_client_secret
|
||||
FACEBOOK_APP_ID=your_facebook_app_id
|
||||
FACEBOOK_APP_SECRET=your_facebook_app_secret
|
||||
VK_APP_ID=your_vk_app_id
|
||||
VK_APP_SECRET=your_vk_app_secret
|
||||
YANDEX_CLIENT_ID=your_yandex_client_id
|
||||
YANDEX_CLIENT_SECRET=your_yandex_client_secret
|
||||
|
||||
# Session cookies
|
||||
SESSION_COOKIE_NAME=session_token
|
||||
SESSION_COOKIE_SECURE=true
|
||||
SESSION_COOKIE_HTTPONLY=true
|
||||
SESSION_COOKIE_SAMESITE=lax
|
||||
SESSION_COOKIE_MAX_AGE=2592000 # 30 дней
|
||||
|
||||
# Frontend
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
```
|
||||
|
||||
## Производительность
|
||||
|
||||
### Оптимизации Redis
|
||||
- **Pipeline операции** для атомарности
|
||||
- **Batch обработка** токенов (100-1000 за раз)
|
||||
- **SCAN** вместо KEYS для безопасности
|
||||
- **TTL** автоматическая очистка
|
||||
|
||||
### Кэширование
|
||||
- **@lru_cache** для часто используемых ключей
|
||||
- **Connection pooling** для Redis
|
||||
- **JWT decode caching** в middleware
|
||||
@@ -1,845 +0,0 @@
|
||||
# 🧪 Тестирование системы аутентификации
|
||||
|
||||
## 🎯 Обзор
|
||||
|
||||
Комплексная стратегия тестирования системы аутентификации с unit, integration и E2E тестами.
|
||||
|
||||
## 🏗️ Структура тестов
|
||||
|
||||
```
|
||||
tests/auth/
|
||||
├── unit/
|
||||
│ ├── test_session_manager.py
|
||||
│ ├── test_oauth_manager.py
|
||||
│ ├── test_batch_operations.py
|
||||
│ ├── test_monitoring.py
|
||||
│ └── test_utils.py
|
||||
├── integration/
|
||||
│ ├── test_redis_integration.py
|
||||
│ ├── test_oauth_flow.py
|
||||
│ ├── test_middleware.py
|
||||
│ └── test_decorators.py
|
||||
├── e2e/
|
||||
│ ├── test_login_flow.py
|
||||
│ ├── test_oauth_flow.py
|
||||
│ └── test_session_management.py
|
||||
└── fixtures/
|
||||
├── auth_fixtures.py
|
||||
├── redis_fixtures.py
|
||||
└── oauth_fixtures.py
|
||||
```
|
||||
|
||||
## 🔧 Unit Tests
|
||||
|
||||
### SessionTokenManager Tests
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from auth.tokens.sessions import SessionTokenManager
|
||||
|
||||
class TestSessionTokenManager:
|
||||
|
||||
@pytest.fixture
|
||||
def session_manager(self):
|
||||
return SessionTokenManager()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session(self, session_manager):
|
||||
"""Тест создания сессии"""
|
||||
with patch('auth.tokens.sessions.redis') as mock_redis:
|
||||
mock_redis.hset = AsyncMock()
|
||||
mock_redis.sadd = AsyncMock()
|
||||
mock_redis.expire = AsyncMock()
|
||||
|
||||
token = await session_manager.create_session(
|
||||
user_id="123",
|
||||
username="testuser"
|
||||
)
|
||||
|
||||
assert token is not None
|
||||
assert len(token) > 20
|
||||
mock_redis.hset.assert_called()
|
||||
mock_redis.sadd.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_session_valid(self, session_manager):
|
||||
"""Тест проверки валидной сессии"""
|
||||
with patch('auth.jwtcodec.decode_jwt') as mock_decode:
|
||||
mock_decode.return_value = {
|
||||
"user_id": "123",
|
||||
"username": "testuser",
|
||||
"exp": int(time.time()) + 3600
|
||||
}
|
||||
|
||||
with patch('auth.tokens.sessions.redis') as mock_redis:
|
||||
mock_redis.exists.return_value = True
|
||||
|
||||
payload = await session_manager.verify_session("valid_token")
|
||||
|
||||
assert payload is not None
|
||||
assert payload["user_id"] == "123"
|
||||
assert payload["username"] == "testuser"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_session_invalid(self, session_manager):
|
||||
"""Тест проверки невалидной сессии"""
|
||||
with patch('auth.jwtcodec.decode_jwt') as mock_decode:
|
||||
mock_decode.return_value = None
|
||||
|
||||
payload = await session_manager.verify_session("invalid_token")
|
||||
|
||||
assert payload is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_session_token(self, session_manager):
|
||||
"""Тест отзыва токена сессии"""
|
||||
with patch('auth.tokens.sessions.redis') as mock_redis:
|
||||
mock_redis.delete = AsyncMock(return_value=1)
|
||||
mock_redis.srem = AsyncMock()
|
||||
|
||||
result = await session_manager.revoke_session_token("test_token")
|
||||
|
||||
assert result is True
|
||||
mock_redis.delete.assert_called()
|
||||
mock_redis.srem.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_sessions(self, session_manager):
|
||||
"""Тест получения сессий пользователя"""
|
||||
with patch('auth.tokens.sessions.redis') as mock_redis:
|
||||
mock_redis.smembers.return_value = {b"token1", b"token2"}
|
||||
mock_redis.hgetall.return_value = {
|
||||
b"user_id": b"123",
|
||||
b"username": b"testuser",
|
||||
b"last_activity": b"1640995200"
|
||||
}
|
||||
|
||||
sessions = await session_manager.get_user_sessions("123")
|
||||
|
||||
assert len(sessions) == 2
|
||||
assert sessions[0]["token"] == "token1"
|
||||
assert sessions[0]["user_id"] == "123"
|
||||
```
|
||||
|
||||
### OAuthTokenManager Tests
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from auth.tokens.oauth import OAuthTokenManager
|
||||
|
||||
class TestOAuthTokenManager:
|
||||
|
||||
@pytest.fixture
|
||||
def oauth_manager(self):
|
||||
return OAuthTokenManager()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_oauth_tokens(self, oauth_manager):
|
||||
"""Тест сохранения OAuth токенов"""
|
||||
with patch('auth.tokens.oauth.redis') as mock_redis:
|
||||
mock_redis.setex = AsyncMock()
|
||||
|
||||
await oauth_manager.store_oauth_tokens(
|
||||
user_id="123",
|
||||
provider="google",
|
||||
access_token="access_token_123",
|
||||
refresh_token="refresh_token_123",
|
||||
expires_in=3600
|
||||
)
|
||||
|
||||
# Проверяем, что токены сохранены
|
||||
assert mock_redis.setex.call_count == 2 # access + refresh
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_token(self, oauth_manager):
|
||||
"""Тест получения OAuth токена"""
|
||||
with patch('auth.tokens.oauth.redis') as mock_redis:
|
||||
mock_redis.get.return_value = b'{"token": "access_token_123", "expires_in": 3600}'
|
||||
|
||||
token_data = await oauth_manager.get_token("123", "google", "oauth_access")
|
||||
|
||||
assert token_data is not None
|
||||
assert token_data["token"] == "access_token_123"
|
||||
assert token_data["expires_in"] == 3600
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_oauth_tokens(self, oauth_manager):
|
||||
"""Тест отзыва OAuth токенов"""
|
||||
with patch('auth.tokens.oauth.redis') as mock_redis:
|
||||
mock_redis.delete = AsyncMock(return_value=2)
|
||||
|
||||
result = await oauth_manager.revoke_oauth_tokens("123", "google")
|
||||
|
||||
assert result is True
|
||||
mock_redis.delete.assert_called()
|
||||
```
|
||||
|
||||
### BatchTokenOperations Tests
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from auth.tokens.batch import BatchTokenOperations
|
||||
|
||||
class TestBatchTokenOperations:
|
||||
|
||||
@pytest.fixture
|
||||
def batch_operations(self):
|
||||
return BatchTokenOperations()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_validate_tokens(self, batch_operations):
|
||||
"""Тест массовой валидации токенов"""
|
||||
tokens = ["token1", "token2", "token3"]
|
||||
|
||||
with patch.object(batch_operations, '_validate_token_batch') as mock_validate:
|
||||
mock_validate.return_value = {
|
||||
"token1": True,
|
||||
"token2": False,
|
||||
"token3": True
|
||||
}
|
||||
|
||||
results = await batch_operations.batch_validate_tokens(tokens)
|
||||
|
||||
assert results["token1"] is True
|
||||
assert results["token2"] is False
|
||||
assert results["token3"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_revoke_tokens(self, batch_operations):
|
||||
"""Тест массового отзыва токенов"""
|
||||
tokens = ["token1", "token2", "token3"]
|
||||
|
||||
with patch.object(batch_operations, '_revoke_token_batch') as mock_revoke:
|
||||
mock_revoke.return_value = 2 # 2 токена отозваны
|
||||
|
||||
revoked_count = await batch_operations.batch_revoke_tokens(tokens)
|
||||
|
||||
assert revoked_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_expired_tokens(self, batch_operations):
|
||||
"""Тест очистки истекших токенов"""
|
||||
with patch('auth.tokens.batch.redis') as mock_redis:
|
||||
# Мокаем поиск истекших токенов
|
||||
mock_redis.scan_iter.return_value = [
|
||||
"session:123:expired_token1",
|
||||
"session:456:expired_token2"
|
||||
]
|
||||
mock_redis.ttl.return_value = -1 # Истекший токен
|
||||
mock_redis.delete = AsyncMock(return_value=1)
|
||||
|
||||
cleaned_count = await batch_operations.cleanup_expired_tokens()
|
||||
|
||||
assert cleaned_count >= 0
|
||||
```
|
||||
|
||||
## 🔗 Integration Tests
|
||||
|
||||
### Redis Integration Tests
|
||||
|
||||
```python
|
||||
import pytest
|
||||
import asyncio
|
||||
from storage.redis import redis
|
||||
from auth.tokens.sessions import SessionTokenManager
|
||||
|
||||
class TestRedisIntegration:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redis_connection(self):
|
||||
"""Тест подключения к Redis"""
|
||||
result = await redis.ping()
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_lifecycle(self):
|
||||
"""Тест полного жизненного цикла сессии"""
|
||||
sessions = SessionTokenManager()
|
||||
|
||||
# Создаем сессию
|
||||
token = await sessions.create_session(
|
||||
user_id="test_user",
|
||||
username="testuser"
|
||||
)
|
||||
|
||||
assert token is not None
|
||||
|
||||
# Проверяем сессию
|
||||
payload = await sessions.verify_session(token)
|
||||
assert payload is not None
|
||||
assert payload["user_id"] == "test_user"
|
||||
|
||||
# Получаем сессии пользователя
|
||||
user_sessions = await sessions.get_user_sessions("test_user")
|
||||
assert len(user_sessions) >= 1
|
||||
|
||||
# Отзываем сессию
|
||||
revoked = await sessions.revoke_session_token(token)
|
||||
assert revoked is True
|
||||
|
||||
# Проверяем, что сессия отозвана
|
||||
payload = await sessions.verify_session(token)
|
||||
assert payload is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_sessions(self):
|
||||
"""Тест множественных сессий"""
|
||||
sessions = SessionTokenManager()
|
||||
|
||||
# Создаем несколько сессий одновременно
|
||||
tasks = []
|
||||
for i in range(5):
|
||||
task = sessions.create_session(
|
||||
user_id="concurrent_user",
|
||||
username=f"user_{i}"
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
tokens = await asyncio.gather(*tasks)
|
||||
|
||||
# Проверяем, что все токены созданы
|
||||
assert len(tokens) == 5
|
||||
assert all(token is not None for token in tokens)
|
||||
|
||||
# Проверяем, что все сессии валидны
|
||||
for token in tokens:
|
||||
payload = await sessions.verify_session(token)
|
||||
assert payload is not None
|
||||
|
||||
# Очищаем тестовые данные
|
||||
for token in tokens:
|
||||
await sessions.revoke_session_token(token)
|
||||
```
|
||||
|
||||
### OAuth Flow Integration Tests
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from auth.oauth import oauth_login_http, oauth_callback_http
|
||||
|
||||
class TestOAuthIntegration:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_state_flow(self):
|
||||
"""Тест OAuth state flow"""
|
||||
from auth.oauth import store_oauth_state, get_oauth_state
|
||||
|
||||
# Сохраняем state
|
||||
state = "test_state_123"
|
||||
redirect_uri = "http://localhost:3000"
|
||||
|
||||
await store_oauth_state(state, redirect_uri)
|
||||
|
||||
# Получаем state
|
||||
stored_data = await get_oauth_state(state)
|
||||
|
||||
assert stored_data is not None
|
||||
assert stored_data["redirect_uri"] == redirect_uri
|
||||
|
||||
# Проверяем, что state удален после использования
|
||||
stored_data_again = await get_oauth_state(state)
|
||||
assert stored_data_again is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_login_redirect(self):
|
||||
"""Тест OAuth login redirect"""
|
||||
mock_request = AsyncMock()
|
||||
mock_request.query_params = {
|
||||
"provider": "google",
|
||||
"state": "test_state",
|
||||
"redirect_uri": "http://localhost:3000"
|
||||
}
|
||||
|
||||
with patch('auth.oauth.store_oauth_state') as mock_store:
|
||||
with patch('auth.oauth.generate_provider_url') as mock_generate:
|
||||
mock_generate.return_value = "https://accounts.google.com/oauth/authorize?..."
|
||||
|
||||
response = await oauth_login_http(mock_request)
|
||||
|
||||
assert response.status_code == 307 # Redirect
|
||||
mock_store.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_callback_success(self):
|
||||
"""Тест успешного OAuth callback"""
|
||||
mock_request = AsyncMock()
|
||||
mock_request.query_params = {
|
||||
"code": "auth_code_123",
|
||||
"state": "test_state"
|
||||
}
|
||||
|
||||
with patch('auth.oauth.get_oauth_state') as mock_get_state:
|
||||
mock_get_state.return_value = {
|
||||
"redirect_uri": "http://localhost:3000"
|
||||
}
|
||||
|
||||
with patch('auth.oauth.exchange_code_for_user_data') as mock_exchange:
|
||||
mock_exchange.return_value = {
|
||||
"id": "123",
|
||||
"email": "test@example.com",
|
||||
"name": "Test User"
|
||||
}
|
||||
|
||||
with patch('auth.oauth._create_or_update_user') as mock_create_user:
|
||||
mock_create_user.return_value = AsyncMock(id=123)
|
||||
|
||||
response = await oauth_callback_http(mock_request)
|
||||
|
||||
assert response.status_code == 307 # Redirect
|
||||
assert "access_token=" in response.headers["location"]
|
||||
```
|
||||
|
||||
## 🌐 E2E Tests
|
||||
|
||||
### Login Flow E2E Tests
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from main import app
|
||||
|
||||
class TestLoginFlowE2E:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_login_flow(self):
|
||||
"""Тест полного flow входа в систему"""
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
|
||||
# 1. Регистрация пользователя
|
||||
register_response = await client.post("/auth/register", json={
|
||||
"email": "test@example.com",
|
||||
"password": "TestPassword123!",
|
||||
"name": "Test User"
|
||||
})
|
||||
|
||||
assert register_response.status_code == 200
|
||||
|
||||
# 2. Вход в систему
|
||||
login_response = await client.post("/auth/login", json={
|
||||
"email": "test@example.com",
|
||||
"password": "TestPassword123!"
|
||||
})
|
||||
|
||||
assert login_response.status_code == 200
|
||||
data = login_response.json()
|
||||
assert data["success"] is True
|
||||
assert "token" in data
|
||||
|
||||
# Проверяем установку cookie
|
||||
cookies = login_response.cookies
|
||||
assert "session_token" in cookies
|
||||
|
||||
# 3. Проверка защищенного endpoint с cookie
|
||||
session_response = await client.get("/auth/session", cookies={
|
||||
"session_token": cookies["session_token"]
|
||||
})
|
||||
|
||||
assert session_response.status_code == 200
|
||||
session_data = session_response.json()
|
||||
assert session_data["user"]["email"] == "test@example.com"
|
||||
|
||||
# 4. Выход из системы
|
||||
logout_response = await client.post("/auth/logout", cookies={
|
||||
"session_token": cookies["session_token"]
|
||||
})
|
||||
|
||||
assert logout_response.status_code == 200
|
||||
|
||||
# 5. Проверка, что сессия недоступна после выхода
|
||||
invalid_session_response = await client.get("/auth/session", cookies={
|
||||
"session_token": cookies["session_token"]
|
||||
})
|
||||
|
||||
assert invalid_session_response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bearer_token_auth(self):
|
||||
"""Тест аутентификации через Bearer token"""
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
|
||||
# Вход в систему
|
||||
login_response = await client.post("/auth/login", json={
|
||||
"email": "test@example.com",
|
||||
"password": "TestPassword123!"
|
||||
})
|
||||
|
||||
token = login_response.json()["token"]
|
||||
|
||||
# Использование Bearer token
|
||||
protected_response = await client.get("/auth/session", headers={
|
||||
"Authorization": f"Bearer {token}"
|
||||
})
|
||||
|
||||
assert protected_response.status_code == 200
|
||||
data = protected_response.json()
|
||||
assert data["user"]["email"] == "test@example.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_credentials(self):
|
||||
"""Тест входа с неверными данными"""
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
|
||||
response = await client.post("/auth/login", json={
|
||||
"email": "test@example.com",
|
||||
"password": "WrongPassword"
|
||||
})
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "error" in data
|
||||
```
|
||||
|
||||
### OAuth E2E Tests
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from httpx import AsyncClient
|
||||
from main import app
|
||||
|
||||
class TestOAuthFlowE2E:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_google_flow(self):
|
||||
"""Тест OAuth flow с Google"""
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
|
||||
# 1. Инициация OAuth
|
||||
oauth_response = await client.get(
|
||||
"/auth/oauth/google",
|
||||
params={
|
||||
"state": "test_state_123",
|
||||
"redirect_uri": "http://localhost:3000"
|
||||
},
|
||||
follow_redirects=False
|
||||
)
|
||||
|
||||
assert oauth_response.status_code == 307
|
||||
assert "accounts.google.com" in oauth_response.headers["location"]
|
||||
|
||||
# 2. Мокаем OAuth callback
|
||||
with patch('auth.oauth.exchange_code_for_user_data') as mock_exchange:
|
||||
mock_exchange.return_value = {
|
||||
"id": "google_user_123",
|
||||
"email": "user@gmail.com",
|
||||
"name": "Google User"
|
||||
}
|
||||
|
||||
callback_response = await client.get(
|
||||
"/auth/oauth/google/callback",
|
||||
params={
|
||||
"code": "auth_code_123",
|
||||
"state": "test_state_123"
|
||||
},
|
||||
follow_redirects=False
|
||||
)
|
||||
|
||||
assert callback_response.status_code == 307
|
||||
location = callback_response.headers["location"]
|
||||
assert "access_token=" in location
|
||||
|
||||
# Извлекаем токен из redirect URL
|
||||
import urllib.parse
|
||||
parsed = urllib.parse.urlparse(location)
|
||||
query_params = urllib.parse.parse_qs(parsed.query)
|
||||
access_token = query_params["access_token"][0]
|
||||
|
||||
# 3. Проверяем, что токен работает
|
||||
session_response = await client.get("/auth/session", headers={
|
||||
"Authorization": f"Bearer {access_token}"
|
||||
})
|
||||
|
||||
assert session_response.status_code == 200
|
||||
data = session_response.json()
|
||||
assert data["user"]["email"] == "user@gmail.com"
|
||||
```
|
||||
|
||||
## 🧰 Test Fixtures
|
||||
|
||||
### Auth Fixtures
|
||||
|
||||
```python
|
||||
import pytest
|
||||
import asyncio
|
||||
from auth.tokens.sessions import SessionTokenManager
|
||||
from auth.tokens.oauth import OAuthTokenManager
|
||||
|
||||
@pytest.fixture
|
||||
async def session_manager():
|
||||
"""Фикстура SessionTokenManager"""
|
||||
return SessionTokenManager()
|
||||
|
||||
@pytest.fixture
|
||||
async def oauth_manager():
|
||||
"""Фикстура OAuthTokenManager"""
|
||||
return OAuthTokenManager()
|
||||
|
||||
@pytest.fixture
|
||||
async def test_user_token(session_manager):
|
||||
"""Фикстура для создания тестового токена"""
|
||||
token = await session_manager.create_session(
|
||||
user_id="test_user_123",
|
||||
username="testuser"
|
||||
)
|
||||
|
||||
yield token
|
||||
|
||||
# Cleanup
|
||||
await session_manager.revoke_session_token(token)
|
||||
|
||||
@pytest.fixture
|
||||
async def authenticated_client():
|
||||
"""Фикстура для аутентифицированного клиента"""
|
||||
from httpx import AsyncClient
|
||||
from main import app
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
# Создаем пользователя и получаем токен
|
||||
login_response = await client.post("/auth/login", json={
|
||||
"email": "test@example.com",
|
||||
"password": "TestPassword123!"
|
||||
})
|
||||
|
||||
token = login_response.json()["token"]
|
||||
|
||||
# Настраиваем клиент с токеном
|
||||
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||
|
||||
yield client
|
||||
|
||||
@pytest.fixture
|
||||
async def oauth_tokens(oauth_manager):
|
||||
"""Фикстура для OAuth токенов"""
|
||||
await oauth_manager.store_oauth_tokens(
|
||||
user_id="test_user_123",
|
||||
provider="google",
|
||||
access_token="test_access_token",
|
||||
refresh_token="test_refresh_token",
|
||||
expires_in=3600
|
||||
)
|
||||
|
||||
yield {
|
||||
"user_id": "test_user_123",
|
||||
"provider": "google",
|
||||
"access_token": "test_access_token",
|
||||
"refresh_token": "test_refresh_token"
|
||||
}
|
||||
|
||||
# Cleanup
|
||||
await oauth_manager.revoke_oauth_tokens("test_user_123", "google")
|
||||
```
|
||||
|
||||
### Redis Fixtures
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from storage.redis import redis
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def redis_client():
|
||||
"""Фикстура Redis клиента"""
|
||||
yield redis
|
||||
|
||||
# Cleanup после всех тестов
|
||||
await redis.flushdb()
|
||||
|
||||
@pytest.fixture
|
||||
async def clean_redis():
|
||||
"""Фикстура для очистки Redis перед тестом"""
|
||||
# Очищаем тестовые ключи
|
||||
test_keys = await redis.keys("test:*")
|
||||
if test_keys:
|
||||
await redis.delete(*test_keys)
|
||||
|
||||
yield
|
||||
|
||||
# Очищаем после теста
|
||||
test_keys = await redis.keys("test:*")
|
||||
if test_keys:
|
||||
await redis.delete(*test_keys)
|
||||
```
|
||||
|
||||
## 📊 Test Configuration
|
||||
|
||||
### pytest.ini
|
||||
|
||||
```ini
|
||||
[tool:pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts =
|
||||
-v
|
||||
--tb=short
|
||||
--strict-markers
|
||||
--disable-warnings
|
||||
--cov=auth
|
||||
--cov-report=html
|
||||
--cov-report=term-missing
|
||||
--cov-fail-under=80
|
||||
|
||||
markers =
|
||||
unit: Unit tests
|
||||
integration: Integration tests
|
||||
e2e: End-to-end tests
|
||||
slow: Slow tests
|
||||
redis: Tests requiring Redis
|
||||
oauth: OAuth related tests
|
||||
```
|
||||
|
||||
### conftest.py
|
||||
|
||||
```python
|
||||
import pytest
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
from httpx import AsyncClient
|
||||
from main import app
|
||||
|
||||
# Настройка asyncio для тестов
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Создает event loop для всей сессии тестов"""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
# Мок Redis для unit тестов
|
||||
@pytest.fixture
|
||||
def mock_redis():
|
||||
"""Мок Redis клиента"""
|
||||
mock = AsyncMock()
|
||||
mock.ping.return_value = True
|
||||
mock.get.return_value = None
|
||||
mock.set.return_value = True
|
||||
mock.delete.return_value = 1
|
||||
mock.exists.return_value = False
|
||||
mock.ttl.return_value = -1
|
||||
mock.hset.return_value = 1
|
||||
mock.hgetall.return_value = {}
|
||||
mock.sadd.return_value = 1
|
||||
mock.smembers.return_value = set()
|
||||
mock.srem.return_value = 1
|
||||
mock.expire.return_value = True
|
||||
mock.setex.return_value = True
|
||||
return mock
|
||||
|
||||
# Test client
|
||||
@pytest.fixture
|
||||
async def test_client():
|
||||
"""Тестовый HTTP клиент"""
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
yield client
|
||||
```
|
||||
|
||||
## 🚀 Running Tests
|
||||
|
||||
### Команды запуска
|
||||
|
||||
```bash
|
||||
# Все тесты
|
||||
pytest
|
||||
|
||||
# Unit тесты
|
||||
pytest tests/auth/unit/ -m unit
|
||||
|
||||
# Integration тесты
|
||||
pytest tests/auth/integration/ -m integration
|
||||
|
||||
# E2E тесты
|
||||
pytest tests/auth/e2e/ -m e2e
|
||||
|
||||
# Тесты с покрытием
|
||||
pytest --cov=auth --cov-report=html
|
||||
|
||||
# Параллельный запуск
|
||||
pytest -n auto
|
||||
|
||||
# Только быстрые тесты
|
||||
pytest -m "not slow"
|
||||
|
||||
# Конкретный тест
|
||||
pytest tests/auth/unit/test_session_manager.py::TestSessionTokenManager::test_create_session
|
||||
```
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
```yaml
|
||||
# .github/workflows/tests.yml
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:6.2
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r requirements.dev.txt
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
pytest tests/auth/unit/ -m unit --cov=auth
|
||||
|
||||
- name: Run integration tests
|
||||
run: |
|
||||
pytest tests/auth/integration/ -m integration
|
||||
env:
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
|
||||
- name: Run E2E tests
|
||||
run: |
|
||||
pytest tests/auth/e2e/ -m e2e
|
||||
env:
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
JWT_SECRET_KEY: test_secret_key_for_ci
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
```
|
||||
|
||||
## 📈 Test Metrics
|
||||
|
||||
### Coverage Goals
|
||||
- **Unit Tests**: ≥ 90% coverage
|
||||
- **Integration Tests**: ≥ 80% coverage
|
||||
- **E2E Tests**: Critical paths covered
|
||||
- **Overall**: ≥ 85% coverage
|
||||
|
||||
### Performance Benchmarks
|
||||
- **Unit Tests**: < 100ms per test
|
||||
- **Integration Tests**: < 1s per test
|
||||
- **E2E Tests**: < 10s per test
|
||||
- **Total Test Suite**: < 5 minutes
|
||||
|
||||
### Quality Metrics
|
||||
- **Test Reliability**: ≥ 99% pass rate
|
||||
- **Flaky Tests**: < 1% of total tests
|
||||
- **Test Maintenance**: Regular updates with code changes
|
||||
@@ -1,291 +0,0 @@
|
||||
# 📊 Система статистики авторов
|
||||
|
||||
Полная документация по расчёту и использованию статистики авторов в Discours.
|
||||
|
||||
## 🎯 Обзор
|
||||
|
||||
Система статистики авторов предоставляет многомерную оценку активности, популярности и вовлечённости каждого автора на платформе. Все метрики рассчитываются в реальном времени и кешируются для производительности.
|
||||
|
||||
## 📈 Метрики AuthorStat
|
||||
|
||||
```graphql
|
||||
# Статистика автора - полная метрика активности и популярности
|
||||
type AuthorStat {
|
||||
# Контент автора
|
||||
shouts: Int # Количество опубликованных статей
|
||||
topics: Int # Количество уникальных тем, в которых участвовал
|
||||
comments: Int # Количество созданных комментариев и цитат
|
||||
|
||||
# Взаимодействие с другими авторами
|
||||
coauthors: Int # Количество уникальных соавторов
|
||||
followers: Int # Количество подписчиков
|
||||
|
||||
# Рейтинговая система
|
||||
rating: Int # Общий рейтинг (rating_shouts + rating_comments)
|
||||
rating_shouts: Int # Рейтинг публикаций (сумма реакций LIKE/AGREE/ACCEPT/PROOF/CREDIT минус DISLIKE/DISAGREE/REJECT/DISPROOF)
|
||||
rating_comments: Int # Рейтинг комментариев (реакции на комментарии автора)
|
||||
|
||||
# Метрики вовлечённости
|
||||
replies_count: Int # Количество ответов на контент автора (ответы на комментарии + комментарии на посты)
|
||||
viewed_shouts: Int # Общее количество просмотров всех публикаций автора
|
||||
}
|
||||
```
|
||||
|
||||
### 📝 Контент автора
|
||||
|
||||
#### `shouts: Int`
|
||||
**Количество опубликованных статей**
|
||||
- Учитывает только статьи со статусом `published_at IS NOT NULL`
|
||||
- Исключает удалённые статьи (`deleted_at IS NULL`)
|
||||
- Подсчитывается через таблицу `shout_author`
|
||||
|
||||
```sql
|
||||
SELECT sa.author, COUNT(DISTINCT s.id) as shouts_count
|
||||
FROM shout_author sa
|
||||
JOIN shout s ON sa.shout = s.id
|
||||
WHERE s.deleted_at IS NULL AND s.published_at IS NOT NULL
|
||||
GROUP BY sa.author
|
||||
```
|
||||
|
||||
#### `topics: Int`
|
||||
**Количество уникальных тем, в которых участвовал автор**
|
||||
- Подсчитывает уникальные темы через связку статей автора
|
||||
- Основано на таблицах `shout_author` → `shout_topic`
|
||||
|
||||
```sql
|
||||
SELECT sa.author, COUNT(DISTINCT st.topic) as topics_count
|
||||
FROM shout_author sa
|
||||
JOIN shout s ON sa.shout = s.id AND s.deleted_at IS NULL AND s.published_at IS NOT NULL
|
||||
JOIN shout_topic st ON s.id = st.shout
|
||||
GROUP BY sa.author
|
||||
```
|
||||
|
||||
#### `comments: Int`
|
||||
**Количество созданных комментариев и цитат**
|
||||
- Включает реакции типа `COMMENT` и `QUOTE`
|
||||
- Исключает удалённые комментарии
|
||||
|
||||
```sql
|
||||
SELECT r.created_by, COUNT(DISTINCT r.id) as comments_count
|
||||
FROM reaction r
|
||||
JOIN shout s ON r.shout = s.id AND s.deleted_at IS NULL
|
||||
WHERE r.deleted_at IS NULL AND r.kind IN ('COMMENT', 'QUOTE')
|
||||
GROUP BY r.created_by
|
||||
```
|
||||
|
||||
### 👥 Взаимодействие с другими авторами
|
||||
|
||||
#### `coauthors: Int`
|
||||
**Количество уникальных соавторов**
|
||||
- Подсчитывает авторов, с которыми автор публиковал совместные статьи
|
||||
- Исключает самого автора из подсчёта
|
||||
- Учитывает только опубликованные и неудалённые статьи
|
||||
|
||||
```sql
|
||||
SELECT sa1.author, COUNT(DISTINCT sa2.author) as coauthors_count
|
||||
FROM shout_author sa1
|
||||
JOIN shout s ON sa1.shout = s.id
|
||||
AND s.deleted_at IS NULL
|
||||
AND s.published_at IS NOT NULL
|
||||
JOIN shout_author sa2 ON s.id = sa2.shout
|
||||
AND sa2.author != sa1.author -- исключаем самого автора
|
||||
GROUP BY sa1.author
|
||||
```
|
||||
|
||||
#### `followers: Int`
|
||||
**Количество подписчиков**
|
||||
- Прямой подсчёт из таблицы `author_follower`
|
||||
|
||||
```sql
|
||||
SELECT following, COUNT(DISTINCT follower) as followers_count
|
||||
FROM author_follower
|
||||
GROUP BY following
|
||||
```
|
||||
|
||||
### ⭐ Рейтинговая система
|
||||
|
||||
#### `rating: Int`
|
||||
**Общий рейтинг автора**
|
||||
- Сумма `rating_shouts + rating_comments`
|
||||
- Агрегированная метрика популярности контента
|
||||
|
||||
#### `rating_shouts: Int`
|
||||
**Рейтинг публикаций автора**
|
||||
- Сумма всех реакций на статьи автора
|
||||
- Положительные реакции: `LIKE`, `AGREE`, `ACCEPT`, `PROOF`, `CREDIT` (+1)
|
||||
- Отрицательные реакции: `DISLIKE`, `DISAGREE`, `REJECT`, `DISPROOF` (-1)
|
||||
- Нейтральные реакции: остальные (0)
|
||||
|
||||
```sql
|
||||
SELECT sa.author,
|
||||
SUM(CASE
|
||||
WHEN r.kind IN ('LIKE', 'AGREE', 'ACCEPT', 'PROOF', 'CREDIT') THEN 1
|
||||
WHEN r.kind IN ('DISLIKE', 'DISAGREE', 'REJECT', 'DISPROOF') THEN -1
|
||||
ELSE 0
|
||||
END) as rating_shouts
|
||||
FROM shout_author sa
|
||||
JOIN shout s ON sa.shout = s.id AND s.deleted_at IS NULL AND s.published_at IS NOT NULL
|
||||
JOIN reaction r ON s.id = r.shout AND r.deleted_at IS NULL
|
||||
GROUP BY sa.author
|
||||
```
|
||||
|
||||
#### `rating_comments: Int`
|
||||
**Рейтинг комментариев автора**
|
||||
- Аналогичная система для реакций на комментарии автора
|
||||
- Подсчитывает реакции на комментарии через `reply_to`
|
||||
|
||||
```sql
|
||||
SELECT r1.created_by,
|
||||
SUM(CASE
|
||||
WHEN r2.kind IN ('LIKE', 'AGREE', 'ACCEPT', 'PROOF', 'CREDIT') THEN 1
|
||||
WHEN r2.kind IN ('DISLIKE', 'DISAGREE', 'REJECT', 'DISPROOF') THEN -1
|
||||
ELSE 0
|
||||
END) as rating_comments
|
||||
FROM reaction r1
|
||||
JOIN reaction r2 ON r1.id = r2.reply_to AND r2.deleted_at IS NULL
|
||||
WHERE r1.deleted_at IS NULL AND r1.kind IN ('COMMENT', 'QUOTE')
|
||||
GROUP BY r1.created_by
|
||||
```
|
||||
|
||||
### 🔄 Метрики вовлечённости
|
||||
|
||||
#### `replies_count: Int`
|
||||
**Количество ответов на контент автора**
|
||||
- **Комплексная метрика**, включающая:
|
||||
1. **Ответы на комментарии автора** (через `reply_to`)
|
||||
2. **Комментарии на посты автора** (прямые комментарии к статьям)
|
||||
|
||||
Логика расчёта:
|
||||
```python
|
||||
# Ответы на комментарии
|
||||
replies_to_comments = COUNT(r2) WHERE r1.created_by = author AND r2.reply_to = r1.id
|
||||
|
||||
# Комментарии на посты
|
||||
comments_on_posts = COUNT(r) WHERE sa.author = author AND r.shout = s.id
|
||||
|
||||
# Итого
|
||||
replies_count = replies_to_comments + comments_on_posts
|
||||
```
|
||||
|
||||
#### `viewed_shouts: Int`
|
||||
**Общее количество просмотров всех публикаций автора**
|
||||
- Интеграция с `ViewedStorage` (Google Analytics)
|
||||
- Суммирует просмотры всех статей автора
|
||||
- Обновляется асинхронно из внешних источников
|
||||
|
||||
## 🔍 API использования
|
||||
|
||||
### GraphQL запрос
|
||||
|
||||
```graphql
|
||||
query LoadAuthors($by: AuthorsBy, $limit: Int, $offset: Int) {
|
||||
load_authors_by(by: $by, limit: $limit, offset: $offset) {
|
||||
id
|
||||
slug
|
||||
name
|
||||
bio
|
||||
pic
|
||||
stat {
|
||||
shouts
|
||||
topics
|
||||
coauthors
|
||||
followers
|
||||
rating
|
||||
rating_shouts
|
||||
rating_comments
|
||||
comments
|
||||
replies_count
|
||||
viewed_shouts
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Параметры сортировки
|
||||
|
||||
```graphql
|
||||
# Сортировка по количеству публикаций
|
||||
{ "order": "shouts" }
|
||||
|
||||
# Сортировка по общему рейтингу
|
||||
{ "order": "rating" }
|
||||
|
||||
# Сортировка по вовлечённости
|
||||
{ "order": "replies_count" }
|
||||
|
||||
# Сортировка по просмотрам
|
||||
{ "order": "viewed_shouts" }
|
||||
```
|
||||
|
||||
## ⚡ Производительность
|
||||
|
||||
### Кеширование
|
||||
- **Redis кеш** для результатов запросов
|
||||
- **Ключи кеша**: `authors:stats:limit={limit}:offset={offset}:order={order}`
|
||||
- **TTL**: Настраивается в `cache.py`
|
||||
|
||||
### Оптимизации SQL
|
||||
- **Batch запросы** для получения статистики всех авторов одновременно
|
||||
- **Подготовленные параметры** для защиты от SQL-инъекций
|
||||
- **Индексы** на ключевых полях (`author_id`, `shout_id`, `reaction.kind`)
|
||||
|
||||
### Сортировка
|
||||
- **SQL-уровень сортировки** для метрик статистики
|
||||
- **Подзапросы с JOIN** для производительности
|
||||
- **COALESCE** для обработки NULL значений
|
||||
|
||||
## 🧪 Тестирование
|
||||
|
||||
### Unit тесты
|
||||
```python
|
||||
# Тестирование расчёта статистики
|
||||
async def test_author_stats_calculation():
|
||||
# Создаём тестовые данные
|
||||
# Проверяем корректность расчёта каждой метрики
|
||||
pass
|
||||
|
||||
# Тестирование сортировки
|
||||
async def test_author_sorting():
|
||||
# Проверяем сортировку по разным полям
|
||||
pass
|
||||
```
|
||||
|
||||
### Интеграционные тесты
|
||||
- Тестирование с реальными данными
|
||||
- Проверка производительности на больших объёмах
|
||||
- Валидация кеширования
|
||||
|
||||
## 🔧 Конфигурация
|
||||
|
||||
### Переменные окружения
|
||||
```bash
|
||||
# Google Analytics для просмотров
|
||||
GOOGLE_KEYFILE_PATH=/path/to/service-account.json
|
||||
GOOGLE_PROPERTY_ID=your-property-id
|
||||
|
||||
# Redis для кеширования
|
||||
REDIS_URL=redis://localhost:6379
|
||||
```
|
||||
|
||||
### Настройки реакций
|
||||
Типы реакций определены в `orm/reaction.py`:
|
||||
```python
|
||||
# Положительные (+1)
|
||||
POSITIVE_REACTIONS = ["LIKE", "AGREE", "ACCEPT", "PROOF", "CREDIT"]
|
||||
|
||||
# Отрицательные (-1)
|
||||
NEGATIVE_REACTIONS = ["DISLIKE", "DISAGREE", "REJECT", "DISPROOF"]
|
||||
```
|
||||
|
||||
## 🚀 Развитие
|
||||
|
||||
### Планируемые улучшения
|
||||
- [ ] Исторические тренды статистики
|
||||
- [ ] Сегментация по периодам времени
|
||||
- [ ] Дополнительные метрики вовлечённости
|
||||
- [ ] Персонализированные рекомендации на основе статистики
|
||||
|
||||
### Известные ограничения
|
||||
- Просмотры обновляются с задержкой (Google Analytics API)
|
||||
- Большие объёмы данных могут замедлять запросы без кеша
|
||||
- Сложные запросы сортировки требуют больше ресурсов
|
||||
165
docs/caching.md
165
docs/caching.md
@@ -147,32 +147,16 @@ await invalidate_topics_cache(456)
|
||||
|
||||
```python
|
||||
class CacheRevalidationManager:
|
||||
def __init__(self, interval=CACHE_REVALIDATION_INTERVAL):
|
||||
# ...
|
||||
self._redis = redis # Прямая ссылка на сервис Redis
|
||||
|
||||
async def start(self):
|
||||
# Проверка и установка соединения с Redis
|
||||
# ...
|
||||
|
||||
# ...
|
||||
async def process_revalidation(self):
|
||||
# Обработка элементов для ревалидации
|
||||
# ...
|
||||
|
||||
def mark_for_revalidation(self, entity_id, entity_type):
|
||||
# Добавляет сущность в очередь на ревалидацию
|
||||
# ...
|
||||
```
|
||||
|
||||
Менеджер ревалидации работает как асинхронный фоновый процесс, который периодически (по умолчанию каждые 5 минут) проверяет наличие сущностей для ревалидации.
|
||||
|
||||
**Взаимодействие с Redis:**
|
||||
- CacheRevalidationManager хранит прямую ссылку на сервис Redis через атрибут `_redis`
|
||||
- При запуске проверяется наличие соединения с Redis и при необходимости устанавливается новое
|
||||
- Включена автоматическая проверка соединения перед каждой операцией ревалидации
|
||||
- Система самостоятельно восстанавливает соединение при его потере
|
||||
|
||||
**Особенности реализации:**
|
||||
Особенности реализации:
|
||||
- Для авторов и тем используется поштучная ревалидация каждой записи
|
||||
- Для шаутов и реакций используется батчевая обработка, с порогом в 10 элементов
|
||||
- При достижении порога система переключается на инвалидацию коллекций вместо поштучной обработки
|
||||
@@ -213,14 +197,14 @@ async def precache_data():
|
||||
async def get_topics_with_stats(limit=10, offset=0, by="title"):
|
||||
# Формирование ключа кеша по конвенции
|
||||
cache_key = f"topics:stats:limit={limit}:offset={offset}:sort={by}"
|
||||
|
||||
|
||||
cached_data = await get_cached_data(cache_key)
|
||||
if cached_data:
|
||||
return cached_data
|
||||
|
||||
|
||||
# Выполнение запроса к базе данных
|
||||
result = ... # логика получения данных
|
||||
|
||||
|
||||
await cache_data(cache_key, result, ttl=300)
|
||||
return result
|
||||
```
|
||||
@@ -232,16 +216,16 @@ async def get_topics_with_stats(limit=10, offset=0, by="title"):
|
||||
async def fetch_data(limit, offset, by):
|
||||
# Логика получения данных
|
||||
return result
|
||||
|
||||
|
||||
# Формирование ключа кеша по конвенции
|
||||
cache_key = f"topics:stats:limit={limit}:offset={offset}:sort={by}"
|
||||
|
||||
|
||||
return await cached_query(
|
||||
cache_key,
|
||||
fetch_data,
|
||||
ttl=300,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
cache_key,
|
||||
fetch_data,
|
||||
ttl=300,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
by=by
|
||||
)
|
||||
```
|
||||
@@ -249,129 +233,16 @@ async def get_topics_with_stats(limit=10, offset=0, by="title"):
|
||||
### Точечная инвалидация кеша при изменении данных
|
||||
|
||||
```python
|
||||
async def update_author(author_id, data):
|
||||
async def update_topic(topic_id, new_data):
|
||||
# Обновление данных в базе
|
||||
# ...
|
||||
|
||||
# Инвалидация только кеша этого автора
|
||||
await invalidate_authors_cache(author_id)
|
||||
|
||||
return result
|
||||
|
||||
# Точечная инвалидация кеша только для измененной темы
|
||||
await invalidate_topics_cache(topic_id)
|
||||
|
||||
return updated_topic
|
||||
```
|
||||
|
||||
## Ключи кеширования
|
||||
|
||||
Ниже приведен полный список форматов ключей, используемых в системе кеширования Discours.
|
||||
|
||||
### Ключи для публикаций (Shout)
|
||||
|
||||
| Формат ключа | Описание | Пример |
|
||||
|--------------|----------|--------|
|
||||
| `shouts:{id}` | Публикация по ID | `shouts:123` |
|
||||
| `shouts:{id}:invalidated` | Флаг инвалидации публикации | `shouts:123:invalidated` |
|
||||
| `shouts:feed:limit={n}:offset={m}` | Основная лента публикаций | `shouts:feed:limit=20:offset=0` |
|
||||
| `shouts:recent:limit={n}` | Последние публикации | `shouts:recent:limit=10` |
|
||||
| `shouts:random_top:limit={n}` | Случайные топовые публикации | `shouts:random_top:limit=5` |
|
||||
| `shouts:unrated:limit={n}` | Неоцененные публикации | `shouts:unrated:limit=20` |
|
||||
| `shouts:coauthored:limit={n}` | Совместные публикации | `shouts:coauthored:limit=10` |
|
||||
|
||||
### Ключи для авторов (Author)
|
||||
|
||||
| Формат ключа | Описание | Пример |
|
||||
|--------------|----------|--------|
|
||||
| `author:id:{id}` | Автор по ID | `author:id:123` |
|
||||
| `author:slug:{slug}` | Автор по слагу | `author:slug:john-doe` |
|
||||
| `author:user_id:{user_id}` | Автор по ID пользователя | `author:user_id:abc123` |
|
||||
| `author:{id}` | Публикации автора | `author:123` |
|
||||
| `authored:{id}` | Публикации, созданные автором | `authored:123` |
|
||||
| `authors:all:basic` | Базовый список всех авторов | `authors:all:basic` |
|
||||
| `authors:stats:limit={n}:offset={m}:sort={field}` | Список авторов с пагинацией и сортировкой | `authors:stats:limit=20:offset=0:sort=name` |
|
||||
| `author:followers:{id}` | Подписчики автора | `author:followers:123` |
|
||||
| `author:following:{id}` | Авторы, на которых подписан автор | `author:following:123` |
|
||||
|
||||
### Ключи для тем (Topic)
|
||||
|
||||
| Формат ключа | Описание | Пример |
|
||||
|--------------|----------|--------|
|
||||
| `topic:id:{id}` | Тема по ID | `topic:id:123` |
|
||||
| `topic:slug:{slug}` | Тема по слагу | `topic:slug:technology` |
|
||||
| `topic:{id}` | Публикации по теме | `topic:123` |
|
||||
| `topic_shouts_{id}` | Публикации по теме (старый формат) | `topic_shouts_123` |
|
||||
| `topics:all:basic` | Базовый список всех тем | `topics:all:basic` |
|
||||
| `topics:stats:limit={n}:offset={m}:sort={field}` | Список тем с пагинацией и сортировкой | `topics:stats:limit=20:offset=0:sort=name` |
|
||||
| `topic:authors:{id}` | Авторы темы | `topic:authors:123` |
|
||||
| `topic:followers:{id}` | Подписчики темы | `topic:followers:123` |
|
||||
| `topic:stats:{id}` | Статистика темы | `topic:stats:123` |
|
||||
|
||||
### Ключи для реакций (Reaction)
|
||||
|
||||
| Формат ключа | Описание | Пример |
|
||||
|--------------|----------|--------|
|
||||
| `reactions:shout:{id}:limit={n}:offset={m}` | Реакции на публикацию | `reactions:shout:123:limit=20:offset=0` |
|
||||
| `reactions:comment:{id}:limit={n}:offset={m}` | Реакции на комментарий | `reactions:comment:456:limit=20:offset=0` |
|
||||
| `reactions:author:{id}:limit={n}:offset={m}` | Реакции автора | `reactions:author:123:limit=20:offset=0` |
|
||||
| `reactions:followed:author:{id}:limit={n}` | Реакции авторов, на которых подписан пользователь | `reactions:followed:author:123:limit=20` |
|
||||
|
||||
### Ключи для сообществ (Community)
|
||||
|
||||
| Формат ключа | Описание | Пример |
|
||||
|--------------|----------|--------|
|
||||
| `community:id:{id}` | Сообщество по ID | `community:id:123` |
|
||||
| `community:slug:{slug}` | Сообщество по слагу | `community:slug:tech-club` |
|
||||
| `communities:all:basic` | Базовый список всех сообществ | `communities:all:basic` |
|
||||
| `community:authors:{id}` | Авторы сообщества | `community:authors:123` |
|
||||
| `community:shouts:{id}:limit={n}:offset={m}` | Публикации сообщества | `community:shouts:123:limit=20:offset=0` |
|
||||
|
||||
### Ключи для подписок (Follow)
|
||||
|
||||
| Формат ключа | Описание | Пример |
|
||||
|--------------|----------|--------|
|
||||
| `follow:author:{follower_id}:authors` | Авторы, на которых подписан пользователь | `follow:author:123:authors` |
|
||||
| `follow:author:{follower_id}:topics` | Темы, на которые подписан пользователь | `follow:author:123:topics` |
|
||||
| `follow:topic:{topic_id}:authors` | Авторы, подписанные на тему | `follow:topic:456:authors` |
|
||||
| `follow:author:{author_id}:followers` | Подписчики автора | `follow:author:123:followers` |
|
||||
|
||||
### Ключи для черновиков (Draft)
|
||||
|
||||
| Формат ключа | Описание | Пример |
|
||||
|--------------|----------|--------|
|
||||
| `draft:id:{id}` | Черновик по ID | `draft:id:123` |
|
||||
| `drafts:author:{id}` | Черновики автора | `drafts:author:123` |
|
||||
| `drafts:all:limit={n}:offset={m}` | Список всех черновиков с пагинацией | `drafts:all:limit=20:offset=0` |
|
||||
|
||||
### Ключи для статистики
|
||||
|
||||
| Формат ключа | Описание | Пример |
|
||||
|--------------|----------|--------|
|
||||
| `stats:shout:{id}` | Статистика публикации | `stats:shout:123` |
|
||||
| `stats:author:{id}` | Статистика автора | `stats:author:123` |
|
||||
| `stats:topic:{id}` | Статистика темы | `stats:topic:123` |
|
||||
| `stats:community:{id}` | Статистика сообщества | `stats:community:123` |
|
||||
|
||||
### Ключи для поиска
|
||||
|
||||
| Формат ключа | Описание | Пример |
|
||||
|--------------|----------|--------|
|
||||
| `search:query:{query}:limit={n}:offset={m}` | Результаты поиска | `search:query:технологии:limit=20:offset=0` |
|
||||
| `search:author:{query}:limit={n}` | Результаты поиска авторов | `search:author:иван:limit=10` |
|
||||
| `search:topic:{query}:limit={n}` | Результаты поиска тем | `search:topic:наука:limit=10` |
|
||||
|
||||
### Служебные ключи
|
||||
|
||||
| Формат ключа | Описание | Пример |
|
||||
|--------------|----------|--------|
|
||||
| `revalidation:{entity_type}:{entity_id}` | Метка для ревалидации | `revalidation:author:123` |
|
||||
| `revalidation:batch:{entity_type}` | Батчевая ревалидация | `revalidation:batch:shouts` |
|
||||
| `lock:{resource}` | Блокировка ресурса | `lock:precache` |
|
||||
| `views:shout:{id}` | Счетчик просмотров публикации | `views:shout:123` |
|
||||
|
||||
### Важные замечания по использованию ключей
|
||||
|
||||
1. При инвалидации кеша публикаций через `invalidate_shouts_cache()` необходимо передавать список ID публикаций, а не ключи кеша.
|
||||
2. Функция `invalidate_shout_related_cache()` автоматически инвалидирует все связанные ключи для публикации, включая ключи авторов и тем.
|
||||
3. Для большинства операций с кешем следует использовать асинхронные функции с префиксом `await`.
|
||||
4. При создании новых ключей кеша следует придерживаться существующих конвенций именования.
|
||||
|
||||
## Отладка и мониторинг
|
||||
|
||||
Система кеширования использует логгер для отслеживания операций:
|
||||
|
||||
@@ -150,7 +150,7 @@ const { data } = await client.query({
|
||||
1. Для эффективной работы со сложными ветками обсуждений рекомендуется:
|
||||
|
||||
- Сначала загружать только корневые комментарии с первыми N ответами
|
||||
- При наличии дополнительных ответов (когда `stat.comments_count > first_replies.length`)
|
||||
- При наличии дополнительных ответов (когда `stat.comments_count > first_replies.length`)
|
||||
добавить кнопку "Показать все ответы"
|
||||
- При нажатии на кнопку загружать дополнительные ответы с помощью запроса с указанным `parentId`
|
||||
|
||||
@@ -162,4 +162,4 @@ const { data } = await client.query({
|
||||
3. Для улучшения производительности:
|
||||
- Кешировать результаты запросов на клиенте
|
||||
- Использовать оптимистичные обновления при добавлении/редактировании комментариев
|
||||
- При необходимости загружать комментарии порциями (ленивая загрузка)
|
||||
- При необходимости загружать комментарии порциями (ленивая загрузка)
|
||||
190
docs/features.md
190
docs/features.md
@@ -1,69 +1,8 @@
|
||||
## Админ-панель
|
||||
|
||||
- **Управление пользователями**: Просмотр, поиск, назначение ролей (user/moderator/admin)
|
||||
- **Управление публикациями**: Таблица со всеми публикациями, фильтрация по статусу, превью контента
|
||||
- **Управление топиками**: Полноценное редактирование топиков в админ-панели
|
||||
- **Иерархическое отображение**: Темы показываются в виде дерева с отступами и символами `└─` для дочерних элементов
|
||||
- **Колонки таблицы**: ID, название, slug, описание, сообщество, родители, действия
|
||||
- **Простой интерфейс редактирования**:
|
||||
- **Клик по строке**: Модалка редактирования открывается при клике на любом месте строки таблицы
|
||||
- **Ненавязчивый крестик**: Кнопка удаления в виде серого "×", краснеет при hover
|
||||
- **Простой HTML редактор**: Обычный contenteditable div с моноширинным шрифтом вместо сложного редактора
|
||||
- **Редактируемые поля**:
|
||||
- **ID**: Отображается для идентификации (поле только для чтения)
|
||||
- **Название и slug**: Текстовые поля для основной информации
|
||||
- **Описание**: Простой HTML редактор с placeholder
|
||||
- **Картинка**: URL изображения топика
|
||||
- **Сообщество**: ID сообщества с числовой валидацией
|
||||
- **Родители**: Список parent_ids через запятую с автоматическим парсингом
|
||||
- **Безопасное удаление**: Модальное окно подтверждения при клике на крестик
|
||||
- **Корректная инвалидация кешей**: Автоматическое обновление счетчиков подписок у всех подписчиков
|
||||
- **GraphQL интеграция**: Использование мутаций `UPDATE_TOPIC_MUTATION` и `DELETE_TOPIC_MUTATION`
|
||||
- **Управление переменными среды**: Настройка конфигурации приложения
|
||||
- **TypeScript интеграция**: Полная типизация с автогенерацией типов из GraphQL схемы
|
||||
- **Responsive дизайн**: Адаптивность для разных размеров экранов
|
||||
|
||||
## Codegen интеграция
|
||||
|
||||
- **Автоматическая генерация типов**: TypeScript типы генерируются из GraphQL схемы
|
||||
- **Файл конфигурации**: `codegen.ts` с настройками для client-side генерации
|
||||
- **Структура проекта**: Разделение на queries, mutations и index файлы в `panel/graphql/generated/`
|
||||
- **Type safety**: Строгая типизация для всех GraphQL операций в админ-панели
|
||||
- **Developer Experience**: Автокомплит и проверка типов в IDE
|
||||
|
||||
## 🔍 Семантическая поисковая система
|
||||
|
||||
- **Настоящие векторные эмбединги**: Использование SentenceTransformers вместо псевдослучайных чисел
|
||||
- **Многоязычная поддержка**: Модель `paraphrase-multilingual-MiniLM-L12-v2` с поддержкой русского языка
|
||||
- **Семантическое понимание**: Поиск по смыслу, а не только по ключевым словам
|
||||
- **Оптимизированная индексация**:
|
||||
- **Batch обработка**: Массовая индексация документов за один вызов
|
||||
- **Тихий режим**: Отключение детального логирования при больших объёмах
|
||||
- **FDE сжатие**: Компрессия векторов для экономии памяти
|
||||
- **Высокая производительность**: Косинусное сходство для точного ранжирования результатов
|
||||
- **GraphQL интеграция**:
|
||||
- `load_shouts_search` - поиск по публикациям
|
||||
- `load_authors_search` - поиск по авторам
|
||||
- **Асинхронная архитектура**: Неблокирующая индексация и поиск
|
||||
- **Fallback модели**: Автоматическое переключение на запасную модель при ошибках
|
||||
|
||||
## Улучшенная система кеширования топиков
|
||||
|
||||
- **Централизованная функция**: `invalidate_topic_followers_cache()` в модуле cache
|
||||
- **Комплексная инвалидация**: Обработка кешей как самого топика, так и всех его подписчиков
|
||||
- **Правильная последовательность**: Получение подписчиков ДО удаления данных из БД
|
||||
- **Инвалидируемые кеши**:
|
||||
- `author:follows-topics:{follower_id}` - список подписок на топики
|
||||
- `author:followers:{follower_id}` - счетчики подписчиков
|
||||
- `author:stat:{follower_id}` - общая статистика автора
|
||||
- `topic:followers:{topic_id}` - список подписчиков топика
|
||||
- **Архитектурные принципы**: Разделение ответственности, переиспользуемость, тестируемость
|
||||
|
||||
## Просмотры публикаций
|
||||
|
||||
- Интеграция с Google Analytics для отслеживания просмотров публикаций
|
||||
- Подсчет уникальных пользователей и общего количества просмотров
|
||||
- Автоматическое обновление статистики при запросе данных публикации
|
||||
- Автоматическое обновление статистики при запросе данных публикации
|
||||
|
||||
## Мультидоменная авторизация
|
||||
|
||||
@@ -73,16 +12,7 @@
|
||||
|
||||
## Система кеширования
|
||||
|
||||
- **Redis как основное хранилище**: Кэширование, сессии, токены, временные данные
|
||||
- **Полная документация схемы**: [redis-schema.md](redis-schema.md) - детальное описание всех структур данных
|
||||
- **11 категорий данных**: Аутентификация, кэш сущностей, поиск, просмотры, уведомления
|
||||
- **Система токенов**: Сессии, OAuth токены, токены подтверждения с TTL
|
||||
- **Переменные окружения**: Централизованное хранение конфигурации в Redis
|
||||
- **Кэш сущностей**: Авторы, темы, публикации с автоматической инвалидацией
|
||||
- **Поисковый кэш**: Нормализованные запросы с результатами
|
||||
- **Pub/Sub каналы**: Real-time уведомления и коммуникация
|
||||
- **Оптимизация**: Pipeline операции, стратегии кэширования
|
||||
- **Мониторинг**: Команды диагностики и решение проблем производительности
|
||||
- Redis используется в качестве основного механизма кеширования
|
||||
- Поддержка как синхронных, так и асинхронных функций в декораторе cache_on_arguments
|
||||
- Автоматическая сериализация/десериализация данных в JSON с использованием CustomJSONEncoder
|
||||
- Резервная сериализация через pickle для сложных объектов
|
||||
@@ -90,6 +20,15 @@
|
||||
- Настраиваемое время жизни кеша (TTL)
|
||||
- Возможность ручной инвалидации кеша для конкретных функций и аргументов
|
||||
|
||||
## Webhooks
|
||||
|
||||
- Автоматическая регистрация вебхука для события user.login
|
||||
- Предотвращение создания дублирующихся вебхуков
|
||||
- Автоматическая очистка устаревших вебхуков
|
||||
- Поддержка авторизации вебхуков через WEBHOOK_SECRET
|
||||
- Обработка ошибок при операциях с вебхуками
|
||||
- Динамическое определение endpoint'а на основе окружения
|
||||
|
||||
## CORS Configuration
|
||||
|
||||
- Поддерживаемые методы: GET, POST, OPTIONS
|
||||
@@ -106,109 +45,4 @@
|
||||
- Использование поля `stat.comments_count` для отображения количества ответов на комментарий
|
||||
- Добавление специального поля `first_replies` для хранения первых ответов на комментарий
|
||||
- Поддержка различных методов сортировки (новые, старые, популярные)
|
||||
- Оптимизированные SQL запросы для минимизации нагрузки на базу данных
|
||||
|
||||
## Модульная система авторизации
|
||||
|
||||
- **Специализированные менеджеры токенов**:
|
||||
- `SessionTokenManager`: Управление пользовательскими сессиями
|
||||
- `VerificationTokenManager`: Токены для подтверждения email, телефона, смены пароля
|
||||
- `OAuthTokenManager`: Управление OAuth токенами для внешних провайдеров
|
||||
|
||||
## Авторизация с cookies
|
||||
|
||||
- **getSession без токена**: Мутация `getSession` теперь работает с httpOnly cookies даже без заголовка Authorization
|
||||
- **Dual-авторизация**: Поддержка как токенов в заголовках, так и cookies для максимальной совместимости
|
||||
- **Автоматические cookies**: Middleware автоматически устанавливает httpOnly cookies при успешной авторизации
|
||||
- **Безопасность**: Использование httpOnly, secure и samesite cookies для защиты от XSS и CSRF атак
|
||||
- **Сессии без перелогина**: Пользователи остаются авторизованными между сессиями браузера
|
||||
|
||||
## DRY архитектура авторизации
|
||||
|
||||
- **Централизованные функции**: Все функции для работы с токенами и авторизацией находятся в `auth/utils.py`
|
||||
- **Устранение дублирования**: Единая логика проверки авторизации используется во всех модулях
|
||||
- **Единообразная обработка**: Стандартизированный подход к извлечению токенов из cookies и заголовков
|
||||
- **Улучшенная тестируемость**: Мокирование централизованных функций упрощает тестирование
|
||||
- **Легкость поддержки**: Изменения в логике авторизации требуют правки только в одном месте
|
||||
|
||||
## E2E тестирование с Playwright
|
||||
|
||||
- **Автоматизация браузера**: Полноценное тестирование пользовательского интерфейса админ-панели
|
||||
- **CI/CD совместимость**: Автоматическое переключение между headed/headless режимами
|
||||
- **Переменная окружения**: `PLAYWRIGHT_HEADLESS=true` для CI/CD, `false` для локальной разработки
|
||||
- **Browser тесты**: Тестирование удаления сообществ, авторизации, управления контентом
|
||||
- **Автоматическая установка**: Браузеры устанавливаются автоматически в CI/CD окружении
|
||||
- **Кроссплатформенность**: Работает в Ubuntu, macOS и Windows окружениях
|
||||
- `BatchTokenOperations`: Пакетные операции с токенами
|
||||
- `TokenMonitoring`: Мониторинг и статистика использования токенов
|
||||
- **Улучшенная производительность**:
|
||||
- 50% ускорение Redis операций через пайплайны
|
||||
- 30% снижение потребления памяти
|
||||
- Оптимизированные запросы к базе данных
|
||||
- **Безопасность**:
|
||||
- Поддержка PKCE для всех OAuth провайдеров
|
||||
- Автоматическая очистка истекших токенов
|
||||
- Защита от replay-атак
|
||||
|
||||
## OAuth интеграция
|
||||
|
||||
- **7 поддерживаемых провайдеров**:
|
||||
- Google, GitHub, Facebook
|
||||
- X (Twitter), Telegram
|
||||
- VK (ВКонтакте), Yandex
|
||||
- **Обработка провайдеров без email**:
|
||||
- Генерация временных email для X и Telegram
|
||||
- Возможность обновления email в профиле
|
||||
- **Токены в Redis**:
|
||||
- Хранение access и refresh токенов с TTL
|
||||
- Автоматическое обновление токенов
|
||||
- Централизованное управление через Redis
|
||||
- **Безопасность**:
|
||||
- PKCE для всех OAuth потоков
|
||||
- Временные state параметры в Redis (10 минут TTL)
|
||||
- Одноразовые сессии
|
||||
- Логирование неудачных попыток аутентификации
|
||||
|
||||
## Система управления паролями и email
|
||||
|
||||
- **Мутация updateSecurity**:
|
||||
- Смена пароля с валидацией сложности
|
||||
- Смена email с двухэтапным подтверждением
|
||||
- Одновременная смена пароля и email
|
||||
- **Токены подтверждения в Redis**:
|
||||
- Автоматический TTL для всех токенов
|
||||
- Безопасное хранение данных подтверждения
|
||||
- **Дополнительные мутации**:
|
||||
- confirmEmailChange
|
||||
- cancelEmailChange
|
||||
|
||||
## Система featured публикаций
|
||||
|
||||
- **Автоматическое получение статуса featured**:
|
||||
- Публикация получает статус featured при более чем 4 лайках от авторов с featured статьями
|
||||
- Проверка квалификации автора: наличие опубликованных featured статей
|
||||
- Логирование процесса для отладки и мониторинга
|
||||
- **Условия удаления с главной (unfeatured)**:
|
||||
- **Условие 1**: Менее 5 голосов "за" (положительные реакции)
|
||||
- **Условие 2**: 20% или более отрицательных реакций от общего количества голосов
|
||||
- Проверка выполняется только для уже featured публикаций
|
||||
- **Оптимизированная логика обработки**:
|
||||
- Проверка unfeatured имеет приоритет над featured при обработке реакций
|
||||
- Автоматическая проверка условий при добавлении/удалении реакций
|
||||
- Корректная обработка типов данных в функциях проверки
|
||||
- **Интеграция с системой реакций**:
|
||||
- Обработка в `create_reaction` для новых реакций
|
||||
- Обработка в `delete_reaction` для удаленных реакций
|
||||
- Учет только реакций на саму публикацию (не на комментарии)
|
||||
|
||||
## RBAC
|
||||
|
||||
- **Наследование разрешений между ролями** происходит только при инициализации прав для сообщества. В Redis хранятся уже развернутые (полные) списки разрешений для каждой роли. Проверка прав — это быстрый lookup без on-the-fly наследования.
|
||||
|
||||
## Core features
|
||||
|
||||
- RBAC с иерархией ролей, наследование только при инициализации, быстрый доступ к правам через Redis
|
||||
|
||||
## Changelog
|
||||
|
||||
- v0.6.11: RBAC — наследование только при инициализации, ускорение, упрощение кода, исправлены тесты
|
||||
- Оптимизированные SQL запросы для минимизации нагрузки на базу данных
|
||||
145
docs/follower.md
145
docs/follower.md
@@ -37,12 +37,10 @@ Unfollow an entity.
|
||||
|
||||
**Returns:** Same as `follow`
|
||||
|
||||
**Important:** Always returns current following list even if the subscription was not found, ensuring UI consistency.
|
||||
|
||||
### Queries
|
||||
|
||||
#### get_shout_followers
|
||||
Get list of authors who reacted to a shout.
|
||||
Get list of users who reacted to a shout.
|
||||
|
||||
**Parameters:**
|
||||
- `slug: String` - Shout slug
|
||||
@@ -64,126 +62,9 @@ Author[] // List of authors who reacted
|
||||
### Cache Flow
|
||||
1. On follow/unfollow:
|
||||
- Update entity in cache
|
||||
- **Invalidate user's following list cache** (NEW)
|
||||
- Update follower's following list
|
||||
2. Cache is updated before notifications
|
||||
|
||||
### Cache Invalidation (NEW)
|
||||
Following cache keys are invalidated after operations:
|
||||
- `author:follows-topics:{user_id}` - After topic follow/unfollow
|
||||
- `author:follows-authors:{user_id}` - After author follow/unfollow
|
||||
|
||||
This ensures fresh data is fetched from database on next request.
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Enhanced Error Handling (UPDATED)
|
||||
- UnauthorizedError access check
|
||||
- Entity existence validation
|
||||
- Duplicate follow prevention
|
||||
- **Graceful handling of "following not found" errors**
|
||||
- **Always returns current following list, even on errors**
|
||||
- Full error logging
|
||||
- Transaction safety with `local_session()`
|
||||
|
||||
### Error Response Format
|
||||
```typescript
|
||||
{
|
||||
error?: "following was not found" | "invalid unfollow type" | "access denied",
|
||||
topics?: Topic[], // Always present for topic operations
|
||||
authors?: Author[], // Always present for author operations
|
||||
// ... other entity types
|
||||
}
|
||||
```
|
||||
|
||||
## Recent Fixes (NEW)
|
||||
|
||||
### Issue 1: Stale UI State on Unfollow Errors
|
||||
**Problem:** When unfollow operation failed with "following was not found", the client didn't update its state because it only processed successful responses.
|
||||
|
||||
**Root Cause:**
|
||||
1. `unfollow` mutation returned error with empty follows list `[]`
|
||||
2. Client logic: `if (result && !result.error)` prevented state updates on errors
|
||||
3. User remained "subscribed" in UI despite no actual subscription in database
|
||||
|
||||
**Solution:**
|
||||
1. **Always fetch current following list** from cache/database
|
||||
2. **Return actual following state** even when subscription not found
|
||||
3. **Add cache invalidation** after successful operations
|
||||
4. **Enhanced logging** for debugging
|
||||
|
||||
### Issue 2: Inconsistent Behavior in Follow Operations (NEW)
|
||||
**Problem:** The `follow` function had similar issues to `unfollow`:
|
||||
- Could return `None` instead of actual following list in error scenarios
|
||||
- Cache was not invalidated when trying to follow already-followed entities
|
||||
- Inconsistent error handling between follow/unfollow operations
|
||||
|
||||
**Root Cause:**
|
||||
1. `follow` mutation could return `{topics: null}` when `get_cached_follows_method` was not available
|
||||
2. When user was already following an entity, cache invalidation was skipped
|
||||
3. Error responses didn't include current following state
|
||||
|
||||
**Solution:**
|
||||
1. **Always return actual following list** from cache/database
|
||||
2. **Invalidate cache on every operation** (both new and existing subscriptions)
|
||||
3. **Add "already following" error** while still returning current state
|
||||
4. **Unified error handling** consistent with unfollow
|
||||
|
||||
### Code Changes
|
||||
```python
|
||||
# UNFOLLOW - Before (BROKEN)
|
||||
if sub:
|
||||
# ... process unfollow
|
||||
else:
|
||||
return {"error": "following was not found", f"{entity_type}s": follows} # follows was []
|
||||
|
||||
# UNFOLLOW - After (FIXED)
|
||||
if sub:
|
||||
# ... process unfollow
|
||||
# Invalidate cache
|
||||
await redis.execute("DEL", f"author:follows-{entity_type}s:{follower_id}")
|
||||
else:
|
||||
error = "following was not found"
|
||||
|
||||
# Always get current state
|
||||
existing_follows = await get_cached_follows_method(follower_id)
|
||||
return {f"{entity_type}s": existing_follows, "error": error}
|
||||
|
||||
# FOLLOW - Before (BROKEN)
|
||||
if existing_sub:
|
||||
logger.info(f"User already following...")
|
||||
# Cache not invalidated, could return stale data
|
||||
else:
|
||||
# ... create subscription
|
||||
# Cache invalidated only here
|
||||
follows = None # Could be None!
|
||||
# ... complex logic to build follows list
|
||||
return {f"{entity_type}s": follows} # follows could be None
|
||||
|
||||
# FOLLOW - After (FIXED)
|
||||
if existing_sub:
|
||||
error = "already following"
|
||||
else:
|
||||
# ... create subscription
|
||||
|
||||
# Always invalidate cache and get current state
|
||||
await redis.execute("DEL", f"author:follows-{entity_type}s:{follower_id}")
|
||||
existing_follows = await get_cached_follows_method(follower_id)
|
||||
return {f"{entity_type}s": existing_follows, "error": error}
|
||||
```
|
||||
|
||||
### Impact
|
||||
**Before fixes:**
|
||||
- UI could show incorrect subscription state
|
||||
- Cache inconsistencies between follow/unfollow operations
|
||||
- Client-side logic `if (result && !result.error)` failed on valid error states
|
||||
|
||||
**After fixes:**
|
||||
- ✅ **UI always receives current subscription state**
|
||||
- ✅ **Consistent cache invalidation** on all operations
|
||||
- ✅ **Unified error handling** between follow/unfollow
|
||||
- ✅ **Client can safely update UI** even on error responses
|
||||
|
||||
## Notifications
|
||||
|
||||
- Sent when author is followed/unfollowed
|
||||
@@ -192,6 +73,14 @@ return {f"{entity_type}s": existing_follows, "error": error}
|
||||
- Author ID
|
||||
- Action type ("follow"/"unfollow")
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Unauthorized access check
|
||||
- Entity existence validation
|
||||
- Duplicate follow prevention
|
||||
- Full error logging
|
||||
- Transaction safety with `local_session()`
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Follower Tables
|
||||
@@ -202,18 +91,4 @@ return {f"{entity_type}s": existing_follows, "error": error}
|
||||
|
||||
Each table contains:
|
||||
- `follower` - ID of following user
|
||||
- `{entity_type}` - ID of followed entity
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test script to verify fixes:
|
||||
```bash
|
||||
python test_unfollow_fix.py
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
- ✅ Unfollow existing subscription
|
||||
- ✅ Unfollow non-existent subscription
|
||||
- ✅ Cache invalidation
|
||||
- ✅ Proper error handling
|
||||
- ✅ UI state consistency
|
||||
- `{entity_type}` - ID of followed entity
|
||||
@@ -77,4 +77,4 @@
|
||||
- Проверка прав доступа
|
||||
- Фильтрация удаленного контента
|
||||
- Защита от SQL-инъекций
|
||||
- Валидация входных данных
|
||||
- Валидация входных данных
|
||||
@@ -1,255 +0,0 @@
|
||||
# Nginx Configuration для Dokku
|
||||
|
||||
## Обзор
|
||||
|
||||
Улучшенная конфигурация nginx для Dokku с поддержкой:
|
||||
- Глобального gzip сжатия
|
||||
- Продвинутых настроек прокси
|
||||
- Безопасности и производительности
|
||||
- Поддержки Dokku переменных
|
||||
|
||||
## Основные улучшения
|
||||
|
||||
### 1. Gzip сжатие
|
||||
```nginx
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/xml+rss
|
||||
application/json
|
||||
application/xml
|
||||
image/svg+xml
|
||||
font/ttf
|
||||
font/otf
|
||||
font/woff
|
||||
font/woff2;
|
||||
```
|
||||
|
||||
### 2. Продвинутые настройки прокси
|
||||
- **Proxy buffering**: Оптимизированные буферы для производительности
|
||||
- **X-Forwarded headers**: Правильная передача заголовков прокси
|
||||
- **Keepalive connections**: Поддержка постоянных соединений
|
||||
- **Rate limiting**: Ограничение запросов для защиты от DDoS
|
||||
|
||||
### 3. Безопасность
|
||||
- **Security headers**: HSTS, CSP, X-Frame-Options и др.
|
||||
- **SSL/TLS**: Современные протоколы и шифры
|
||||
- **Rate limiting**: Защита от атак
|
||||
- **Content Security Policy**: Защита от XSS
|
||||
|
||||
### 4. Кэширование
|
||||
- **Static assets**: Агрессивное кэширование (1 год)
|
||||
- **Dynamic content**: Умеренное кэширование (10 минут)
|
||||
- **GraphQL**: Отключение кэширования
|
||||
- **API endpoints**: Умеренное кэширование (5 минут)
|
||||
|
||||
## Использование Dokku переменных
|
||||
|
||||
### Доступные переменные
|
||||
- `{{ $.APP }}` - имя приложения
|
||||
- `{{ $.SSL_SERVER_NAME }}` - домен для SSL
|
||||
- `{{ $.NOSSL_SERVER_NAME }}` - домен для HTTP
|
||||
- `{{ $.APP_SSL_PATH }}` - путь к SSL сертификатам
|
||||
- `{{ $.DOKKU_ROOT }}` - корневая директория Dokku
|
||||
|
||||
### Настройка через nginx:set
|
||||
|
||||
```bash
|
||||
# Установка формата логов
|
||||
dokku nginx:set core access-log-format detailed
|
||||
|
||||
# Установка размера тела запроса
|
||||
dokku nginx:set core client-max-body-size 100M
|
||||
|
||||
# Установка таймаутов
|
||||
dokku nginx:set core proxy-read-timeout 60s
|
||||
dokku nginx:set core proxy-connect-timeout 60s
|
||||
|
||||
# Отключение логов
|
||||
dokku nginx:set core access-log-path off
|
||||
dokku nginx:set core error-log-path off
|
||||
```
|
||||
|
||||
### Поддерживаемые свойства
|
||||
- `access-log-format` - формат access логов
|
||||
- `access-log-path` - путь к access логам
|
||||
- `client-max-body-size` - максимальный размер тела запроса
|
||||
- `proxy-read-timeout` - таймаут чтения от прокси
|
||||
- `proxy-connect-timeout` - таймаут подключения к прокси
|
||||
- `proxy-send-timeout` - таймаут отправки к прокси
|
||||
- `bind-address-ipv4` - привязка к IPv4 адресу
|
||||
- `bind-address-ipv6` - привязка к IPv6 адресу
|
||||
|
||||
## Локации (Locations)
|
||||
|
||||
### 1. Основное приложение (`/`)
|
||||
- Проксирование всех запросов
|
||||
- Кэширование динамического контента
|
||||
- Поддержка WebSocket
|
||||
- Rate limiting
|
||||
|
||||
### 2. GraphQL (`/graphql`)
|
||||
- Отключение кэширования
|
||||
- Увеличенные таймауты (300s)
|
||||
- Специальные заголовки кэширования
|
||||
|
||||
### 3. Статические файлы
|
||||
- Агрессивное кэширование (1 год)
|
||||
- Gzip сжатие
|
||||
- Заголовки `immutable`
|
||||
|
||||
### 4. API endpoints (`/api/`)
|
||||
- Умеренное кэширование (5 минут)
|
||||
- Rate limiting
|
||||
- Заголовки статуса кэша
|
||||
|
||||
### 5. Health check (`/health`)
|
||||
- Отключение логов
|
||||
- Отключение кэширования
|
||||
- Быстрые ответы
|
||||
|
||||
## Мониторинг и логирование
|
||||
|
||||
### Логи
|
||||
- **Access logs**: `/var/log/nginx/core-access.log`
|
||||
- **Error logs**: `/var/log/nginx/core-error.log`
|
||||
- **Custom formats**: JSON и detailed
|
||||
|
||||
### Команды для просмотра логов
|
||||
```bash
|
||||
# Access логи
|
||||
dokku nginx:access-logs core
|
||||
|
||||
# Error логи
|
||||
dokku nginx:error-logs core
|
||||
|
||||
# Следование за логами
|
||||
dokku nginx:access-logs core -t
|
||||
dokku nginx:error-logs core -t
|
||||
```
|
||||
|
||||
### Дополнительные конфигурации
|
||||
|
||||
Для добавления custom log formats и других настроек, создайте файл на сервере Dokku:
|
||||
|
||||
```bash
|
||||
# Подключитесь к серверу Dokku
|
||||
ssh dokku@your-server
|
||||
|
||||
# Создайте файл с log formats
|
||||
sudo mkdir -p /etc/nginx/conf.d
|
||||
sudo nano /etc/nginx/conf.d/00-log-formats.conf
|
||||
```
|
||||
|
||||
Содержимое файла `/etc/nginx/conf.d/00-log-formats.conf`:
|
||||
```nginx
|
||||
# Custom log format for JSON logging (as per Dokku docs)
|
||||
log_format json_combined escape=json
|
||||
'{'
|
||||
'"time_local":"$time_local",'
|
||||
'"remote_addr":"$remote_addr",'
|
||||
'"remote_user":"$remote_user",'
|
||||
'"request":"$request",'
|
||||
'"status":"$status",'
|
||||
'"body_bytes_sent":"$body_bytes_sent",'
|
||||
'"request_time":"$request_time",'
|
||||
'"http_referrer":"$http_referer",'
|
||||
'"http_user_agent":"$http_user_agent",'
|
||||
'"http_x_forwarded_for":"$http_x_forwarded_for",'
|
||||
'"http_x_forwarded_proto":"$http_x_forwarded_proto"'
|
||||
'}';
|
||||
|
||||
# Custom log format for detailed access logs
|
||||
log_format detailed
|
||||
'$remote_addr - $remote_user [$time_local] '
|
||||
'"$request" $status $body_bytes_sent '
|
||||
'"$http_referer" "$http_user_agent" '
|
||||
'rt=$request_time uct="$upstream_connect_time" '
|
||||
'uht="$upstream_header_time" urt="$upstream_response_time"';
|
||||
```
|
||||
|
||||
### Валидация конфигурации
|
||||
```bash
|
||||
# Проверка конфигурации
|
||||
dokku nginx:validate-config core
|
||||
|
||||
# Пересборка конфигурации
|
||||
dokku proxy:build-config core
|
||||
```
|
||||
|
||||
## Производительность
|
||||
|
||||
### Оптимизации
|
||||
1. **Gzip сжатие**: Уменьшение размера передаваемых данных
|
||||
2. **Proxy buffering**: Оптимизация буферов
|
||||
3. **Keepalive**: Переиспользование соединений
|
||||
4. **Кэширование**: Уменьшение нагрузки на бэкенд
|
||||
5. **Rate limiting**: Защита от перегрузки
|
||||
|
||||
### Мониторинг
|
||||
- Заголовок `X-Cache-Status` для отслеживания кэша
|
||||
- Детальные логи с временем ответа
|
||||
- Метрики upstream соединений
|
||||
|
||||
## Безопасность
|
||||
|
||||
### Заголовки безопасности
|
||||
- `Strict-Transport-Security`: Принудительный HTTPS
|
||||
- `Content-Security-Policy`: Защита от XSS
|
||||
- `X-Frame-Options`: Защита от clickjacking
|
||||
- `X-Content-Type-Options`: Защита от MIME sniffing
|
||||
- `Referrer-Policy`: Контроль referrer
|
||||
|
||||
### Rate Limiting
|
||||
- Общие запросы: 20 r/s с burst 20
|
||||
- API endpoints: 10 r/s
|
||||
- GraphQL: 5 r/s
|
||||
- Соединения: 100 одновременных
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Частые проблемы
|
||||
|
||||
1. **SSL ошибки**
|
||||
```bash
|
||||
dokku certs:report core
|
||||
dokku certs:add core <cert-file> <key-file>
|
||||
```
|
||||
|
||||
2. **Проблемы с кэшем**
|
||||
```bash
|
||||
# Очистка кэша nginx
|
||||
sudo rm -rf /var/cache/nginx/*
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
3. **Проблемы с логами**
|
||||
```bash
|
||||
# Проверка прав доступа
|
||||
sudo chown -R nginx:nginx /var/log/nginx/
|
||||
```
|
||||
|
||||
4. **Валидация конфигурации**
|
||||
```bash
|
||||
dokku nginx:validate-config core --clean
|
||||
dokku proxy:build-config core
|
||||
```
|
||||
|
||||
## Обновление конфигурации
|
||||
|
||||
После изменения `nginx.conf.sigil`:
|
||||
```bash
|
||||
git add nginx.conf.sigil
|
||||
git commit -m "Update nginx configuration"
|
||||
git push dokku dev:dev
|
||||
```
|
||||
|
||||
Конфигурация автоматически пересоберется при деплое.
|
||||
@@ -52,7 +52,7 @@ Rate another author (karma system).
|
||||
- Excludes deleted reactions
|
||||
- Excludes comment reactions
|
||||
|
||||
#### Comments Rating
|
||||
#### Comments Rating
|
||||
- Calculated from LIKE/DISLIKE reactions on author's comments
|
||||
- Each LIKE: +1
|
||||
- Each DISLIKE: -1
|
||||
@@ -79,4 +79,4 @@ Rate another author (karma system).
|
||||
- All ratings exclude deleted content
|
||||
- Reactions are unique per user/content
|
||||
- Rating calculations are optimized with SQLAlchemy
|
||||
- System supports both direct author rating and content-based rating
|
||||
- System supports both direct author rating and content-based rating
|
||||
@@ -1,560 +0,0 @@
|
||||
# Система ролей и разрешений (RBAC)
|
||||
|
||||
## Общее описание
|
||||
|
||||
Система управления доступом на основе ролей (Role-Based Access Control, RBAC) обеспечивает гибкое управление правами пользователей в рамках сообществ платформы. Система поддерживает иерархическое наследование разрешений и автоматическое кеширование для оптимальной производительности.
|
||||
|
||||
## Архитектура системы
|
||||
|
||||
### Принципы работы
|
||||
|
||||
1. **Иерархия ролей**: Роли наследуют права друг от друга с рекурсивным вычислением
|
||||
2. **Контекстная проверка**: Права проверяются в контексте конкретного сообщества
|
||||
3. **Системные администраторы**: Пользователи из `ADMIN_EMAILS` автоматически получают роль `admin` в любом сообществе
|
||||
4. **Динамическое определение community_id**: Система автоматически определяет `community_id` из аргументов GraphQL мутаций
|
||||
5. **Рекурсивное наследование**: Разрешения автоматически включают все унаследованные права от родительских ролей
|
||||
|
||||
### Получение community_id
|
||||
|
||||
Система RBAC автоматически определяет `community_id` для проверки прав:
|
||||
|
||||
- **Из аргументов мутации**: Для мутаций типа `delete_community(slug: String!)` система получает `slug` и находит соответствующий `community_id`
|
||||
- **По умолчанию**: Если `community_id` не может быть определен, используется значение `1`
|
||||
- **Логирование**: Все операции получения `community_id` логируются для отладки
|
||||
|
||||
### Основные компоненты
|
||||
|
||||
1. **Community** - сообщество, контекст для ролей
|
||||
2. **CommunityAuthor** - связь пользователя с сообществом и его ролями
|
||||
3. **Role** - роль пользователя (reader, author, editor, admin)
|
||||
4. **Permission** - разрешение на выполнение действия
|
||||
5. **RBAC Service** - сервис управления ролями и разрешениями с рекурсивным наследованием
|
||||
|
||||
### Модель данных
|
||||
|
||||
```sql
|
||||
-- Основная таблица связи пользователя с сообществом
|
||||
CREATE TABLE community_author (
|
||||
id INTEGER PRIMARY KEY,
|
||||
community_id INTEGER REFERENCES community(id),
|
||||
author_id INTEGER REFERENCES author(id),
|
||||
roles TEXT, -- CSV строка ролей: "reader,author,editor"
|
||||
joined_at INTEGER NOT NULL,
|
||||
UNIQUE(community_id, author_id)
|
||||
);
|
||||
|
||||
-- Индексы для производительности
|
||||
CREATE INDEX idx_community_author_community ON community_author(community_id);
|
||||
CREATE INDEX idx_community_author_author ON community_author(author_id);
|
||||
```
|
||||
|
||||
## Роли в системе
|
||||
|
||||
### Базовые роли
|
||||
|
||||
#### 1. `reader` (Читатель)
|
||||
- **Обязательная роль для всех пользователей**
|
||||
- **Права:**
|
||||
- Чтение публикаций
|
||||
- Просмотр комментариев
|
||||
- Подписка на сообщества
|
||||
- Базовая навигация по платформе
|
||||
|
||||
#### 2. `author` (Автор)
|
||||
- **Права:**
|
||||
- Все права `reader`
|
||||
- Создание публикаций (шаутов)
|
||||
- Редактирование своих публикаций
|
||||
- Комментирование
|
||||
- Создание черновиков
|
||||
|
||||
#### 3. `artist` (Художник)
|
||||
- **Права:**
|
||||
- Все права `author`
|
||||
- Может быть указан как credited artist
|
||||
- Загрузка и управление медиафайлами
|
||||
|
||||
#### 4. `expert` (Эксперт)
|
||||
- **Права:**
|
||||
- Все права `author`
|
||||
- Добавление доказательств (evidence)
|
||||
- Верификация контента
|
||||
- Экспертная оценка публикаций
|
||||
|
||||
#### 5. `editor` (Редактор)
|
||||
- **Права:**
|
||||
- Все права `expert`
|
||||
- Модерация контента
|
||||
- Редактирование чужих публикаций
|
||||
- Управление тегами и категориями
|
||||
- Модерация комментариев
|
||||
|
||||
#### 6. `admin` (Администратор)
|
||||
- **Права:**
|
||||
- Все права `editor`
|
||||
- Управление пользователями (`author:delete_any`, `author:update_any`)
|
||||
- Управление ролями
|
||||
- Настройка сообщества (`community:delete_any`, `community:update_any`)
|
||||
- Управление чатами и сообщениями (`chat:delete_any`, `chat:update_any`, `message:delete_any`, `message:update_any`)
|
||||
- Полный доступ к административной панели
|
||||
|
||||
### Иерархия ролей
|
||||
|
||||
```
|
||||
admin > editor > expert > artist/author > reader
|
||||
```
|
||||
|
||||
Каждая роль автоматически включает права всех ролей ниже по иерархии. Система рекурсивно вычисляет все унаследованные разрешения при инициализации сообщества.
|
||||
|
||||
## Разрешения (Permissions)
|
||||
|
||||
### Формат разрешений
|
||||
|
||||
Разрешения записываются в формате `resource:action`:
|
||||
|
||||
- `shout:create` - создание публикаций
|
||||
- `shout:edit` - редактирование публикаций
|
||||
- `shout:delete` - удаление публикаций
|
||||
|
||||
### Централизованная проверка прав
|
||||
|
||||
Система RBAC использует централизованную проверку прав через декораторы:
|
||||
|
||||
- `@require_permission("permission")` - проверка конкретного разрешения
|
||||
- `@require_any_permission(["permission1", "permission2"])` - проверка наличия любого из разрешений
|
||||
- `@require_all_permissions(["permission1", "permission2"])` - проверка наличия всех разрешений
|
||||
|
||||
**Важно**: В resolvers не должна быть дублирующая логика проверки прав - вся проверка осуществляется через систему RBAC.
|
||||
|
||||
### Категории разрешений
|
||||
|
||||
#### Контент (Content)
|
||||
- `shout:create` - создание шаутов
|
||||
- `shout:edit_own` - редактирование своих шаутов
|
||||
- `shout:edit_any` - редактирование любых шаутов
|
||||
- `shout:delete_own` - удаление своих шаутов
|
||||
- `shout:delete_any` - удаление любых шаутов
|
||||
- `shout:publish` - публикация шаутов
|
||||
- `shout:feature` - продвижение шаутов
|
||||
|
||||
#### Комментарии (Comments)
|
||||
- `comment:create` - создание комментариев
|
||||
- `comment:edit_own` - редактирование своих комментариев
|
||||
- `comment:edit_any` - редактирование любых комментариев
|
||||
- `comment:delete_own` - удаление своих комментариев
|
||||
- `comment:delete_any` - удаление любых комментариев
|
||||
- `comment:moderate` - модерация комментариев
|
||||
|
||||
#### Пользователи (Users)
|
||||
- `user:view_profile` - просмотр профилей
|
||||
- `user:edit_own_profile` - редактирование своего профиля
|
||||
- `user:manage_roles` - управление ролями пользователей
|
||||
- `user:ban` - блокировка пользователей
|
||||
|
||||
#### Сообщество (Community)
|
||||
- `community:view` - просмотр сообщества
|
||||
- `community:settings` - настройки сообщества
|
||||
- `community:manage_members` - управление участниками
|
||||
- `community:analytics` - просмотр аналитики
|
||||
|
||||
## Логика работы системы
|
||||
|
||||
### 1. Регистрация пользователя
|
||||
|
||||
При регистрации пользователя:
|
||||
|
||||
```python
|
||||
# 1. Создается запись в Author
|
||||
user = Author(email=email, name=name, ...)
|
||||
|
||||
# 2. Создается связь с дефолтным сообществом (ID=1)
|
||||
community_author = CommunityAuthor(
|
||||
community_id=1,
|
||||
author_id=user.id,
|
||||
roles="reader,author" # Дефолтные роли
|
||||
)
|
||||
|
||||
# 3. Создается подписка на сообщество
|
||||
follower = CommunityFollower(
|
||||
community=1,
|
||||
follower=user.id
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Проверка авторизации
|
||||
|
||||
При входе в систему проверяется наличие роли `reader`:
|
||||
|
||||
```python
|
||||
def login(email, password):
|
||||
# 1. Найти пользователя
|
||||
author = Author.get_by_email(email)
|
||||
|
||||
# 2. Проверить пароль
|
||||
if not verify_password(password, author.password):
|
||||
return error("Неверный пароль")
|
||||
|
||||
# 3. Получить роли в дефолтном сообществе
|
||||
user_roles = get_user_roles_in_community(author.id, community_id=1)
|
||||
|
||||
# 4. Проверить наличие роли reader
|
||||
if "reader" not in user_roles and author.email not in ADMIN_EMAILS:
|
||||
return error("Нет прав для входа. Требуется роль 'reader'.")
|
||||
|
||||
# 5. Создать сессию
|
||||
return create_session(author)
|
||||
```
|
||||
|
||||
### 3. Проверка разрешений
|
||||
|
||||
При выполнении действий проверяются разрешения:
|
||||
|
||||
```python
|
||||
@login_required
|
||||
async def create_shout(info, input):
|
||||
user_id = info.context["author"]["id"]
|
||||
|
||||
# Проверяем разрешение на создание шаутов
|
||||
has_permission = await check_user_permission_in_community(
|
||||
user_id,
|
||||
"shout:create",
|
||||
community_id=1
|
||||
)
|
||||
|
||||
if not has_permission:
|
||||
raise GraphQLError("Недостаточно прав для создания публикации")
|
||||
|
||||
# Создаем шаут
|
||||
return Shout.create(input)
|
||||
```
|
||||
|
||||
### 4. Управление ролями
|
||||
|
||||
#### Назначение ролей
|
||||
|
||||
```python
|
||||
# Назначить роль пользователю
|
||||
assign_role_to_user(user_id=123, role="editor", community_id=1)
|
||||
|
||||
# Убрать роль
|
||||
remove_role_from_user(user_id=123, role="editor", community_id=1)
|
||||
|
||||
# Установить все роли
|
||||
community.set_user_roles(user_id=123, roles=["reader", "author", "editor"])
|
||||
```
|
||||
|
||||
#### Проверка ролей
|
||||
|
||||
```python
|
||||
# Получить роли пользователя
|
||||
roles = get_user_roles_in_community(user_id=123, community_id=1)
|
||||
|
||||
# Проверить конкретную роль
|
||||
has_role = "editor" in roles
|
||||
|
||||
# Проверить разрешение
|
||||
has_permission = await check_user_permission_in_community(
|
||||
user_id=123,
|
||||
permission="shout:edit_any",
|
||||
community_id=1
|
||||
)
|
||||
```
|
||||
|
||||
## Конфигурация сообщества
|
||||
|
||||
### Дефолтные роли
|
||||
|
||||
Каждое сообщество может настроить свои дефолтные роли для новых пользователей:
|
||||
|
||||
```python
|
||||
# Получить дефолтные роли
|
||||
default_roles = community.get_default_roles() # ["reader", "author"]
|
||||
|
||||
# Установить дефолтные роли
|
||||
community.set_default_roles(["reader"]) # Только reader по умолчанию
|
||||
```
|
||||
|
||||
### Доступные роли
|
||||
|
||||
Сообщество может ограничить список доступных ролей:
|
||||
|
||||
```python
|
||||
# Все роли доступны по умолчанию
|
||||
available_roles = ["reader", "author", "artist", "expert", "editor", "admin"]
|
||||
|
||||
# Ограничить только базовыми ролями
|
||||
community.set_available_roles(["reader", "author", "editor"])
|
||||
```
|
||||
|
||||
## Миграция данных
|
||||
|
||||
### Проблемы существующих пользователей
|
||||
|
||||
1. **Пользователи без роли `reader`** - не могут войти в систему
|
||||
2. **Старая система ролей** - данные в `Author.roles` устарели
|
||||
3. **Отсутствие связей `CommunityAuthor`** - новые пользователи без ролей
|
||||
|
||||
### Решения
|
||||
|
||||
#### 1. Автоматическое добавление роли `reader`
|
||||
|
||||
```python
|
||||
async def ensure_user_has_reader_role(user_id: int) -> bool:
|
||||
"""Убеждается, что у пользователя есть роль 'reader'"""
|
||||
existing_roles = get_user_roles_in_community(user_id, community_id=1)
|
||||
|
||||
if "reader" not in existing_roles:
|
||||
success = assign_role_to_user(user_id, "reader", community_id=1)
|
||||
if success:
|
||||
logger.info(f"Роль 'reader' добавлена пользователю {user_id}")
|
||||
return True
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
#### 2. Массовое исправление ролей
|
||||
|
||||
```python
|
||||
async def fix_all_users_reader_role() -> dict[str, int]:
|
||||
"""Проверяет всех пользователей и добавляет роль 'reader'"""
|
||||
stats = {"checked": 0, "fixed": 0, "errors": 0}
|
||||
|
||||
all_authors = session.query(Author).all()
|
||||
|
||||
for author in all_authors:
|
||||
stats["checked"] += 1
|
||||
try:
|
||||
await ensure_user_has_reader_role(author.id)
|
||||
stats["fixed"] += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка для пользователя {author.id}: {e}")
|
||||
stats["errors"] += 1
|
||||
|
||||
return stats
|
||||
```
|
||||
|
||||
## API для работы с ролями
|
||||
|
||||
### GraphQL мутации
|
||||
|
||||
```graphql
|
||||
# Назначить роль пользователю
|
||||
mutation AssignRole($userId: Int!, $role: String!, $communityId: Int) {
|
||||
assignRole(userId: $userId, role: $role, communityId: $communityId) {
|
||||
success
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
# Убрать роль
|
||||
mutation RemoveRole($userId: Int!, $role: String!, $communityId: Int) {
|
||||
removeRole(userId: $userId, role: $role, communityId: $communityId) {
|
||||
success
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
# Установить все роли пользователя
|
||||
mutation SetUserRoles($userId: Int!, $roles: [String!]!, $communityId: Int) {
|
||||
setUserRoles(userId: $userId, roles: $roles, communityId: $communityId) {
|
||||
success
|
||||
message
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GraphQL запросы
|
||||
|
||||
```graphql
|
||||
# Получить роли пользователя
|
||||
query GetUserRoles($userId: Int!, $communityId: Int) {
|
||||
userRoles(userId: $userId, communityId: $communityId) {
|
||||
roles
|
||||
permissions
|
||||
community {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Получить всех участников сообщества с ролями
|
||||
query GetCommunityMembers($communityId: Int!) {
|
||||
communityMembers(communityId: $communityId) {
|
||||
authorId
|
||||
roles
|
||||
permissions
|
||||
joinedAt
|
||||
author {
|
||||
id
|
||||
name
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Безопасность
|
||||
|
||||
### Принципы безопасности
|
||||
|
||||
1. **Принцип минимальных привилегий** - пользователь получает только необходимые права
|
||||
2. **Разделение обязанностей** - разные роли для разных функций
|
||||
3. **Аудит действий** - логирование всех изменений ролей
|
||||
4. **Проверка на каждом уровне** - валидация разрешений в API и UI
|
||||
|
||||
### Защита от атак
|
||||
|
||||
1. **Privilege Escalation** - проверка прав на изменение ролей
|
||||
2. **Mass Assignment** - валидация входных данных
|
||||
3. **CSRF** - использование токенов для изменения ролей
|
||||
4. **XSS** - экранирование данных ролей в UI
|
||||
|
||||
### Логирование
|
||||
|
||||
```python
|
||||
# Логирование изменений ролей
|
||||
logger.info(f"Role {role} assigned to user {user_id} by admin {admin_id}")
|
||||
logger.warning(f"Failed login attempt for user without reader role: {user_id}")
|
||||
logger.error(f"Permission denied: user {user_id} tried to access {resource}")
|
||||
```
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Тестовые сценарии
|
||||
|
||||
1. **Регистрация пользователя** - проверка назначения дефолтных ролей
|
||||
2. **Вход в систему** - проверка требования роли `reader`
|
||||
3. **Назначение ролей** - проверка прав администратора
|
||||
4. **Проверка разрешений** - валидация доступа к ресурсам
|
||||
5. **Иерархия ролей** - наследование прав
|
||||
|
||||
### Пример тестов
|
||||
|
||||
```python
|
||||
def test_user_registration_assigns_default_roles():
|
||||
"""Проверяет назначение дефолтных ролей при регистрации"""
|
||||
user = create_user(email="test@test.com")
|
||||
roles = get_user_roles_in_community(user.id, community_id=1)
|
||||
|
||||
assert "reader" in roles
|
||||
assert "author" in roles
|
||||
|
||||
def test_login_requires_reader_role():
|
||||
"""Проверяет требование роли reader для входа"""
|
||||
user = create_user_without_roles(email="test@test.com")
|
||||
|
||||
result = login(email="test@test.com", password="password")
|
||||
|
||||
assert result["success"] == False
|
||||
assert "reader" in result["error"]
|
||||
|
||||
def test_role_hierarchy():
|
||||
"""Проверяет иерархию ролей"""
|
||||
user = create_user(email="admin@test.com")
|
||||
assign_role_to_user(user.id, "admin", community_id=1)
|
||||
|
||||
# Админ должен иметь все права
|
||||
assert check_permission(user.id, "shout:create")
|
||||
assert check_permission(user.id, "user:manage")
|
||||
assert check_permission(user.id, "community:settings")
|
||||
```
|
||||
|
||||
## Производительность
|
||||
|
||||
### Оптимизации
|
||||
|
||||
1. **Кеширование ролей** - хранение ролей пользователя в Redis
|
||||
2. **Индексы БД** - быстрый поиск по `community_id` и `author_id`
|
||||
3. **Batch операции** - массовое назначение ролей
|
||||
4. **Ленивая загрузка** - загрузка разрешений по требованию
|
||||
|
||||
### Мониторинг
|
||||
|
||||
```python
|
||||
# Метрики для Prometheus
|
||||
role_checks_total = Counter('rbac_role_checks_total')
|
||||
permission_checks_total = Counter('rbac_permission_checks_total')
|
||||
role_assignments_total = Counter('rbac_role_assignments_total')
|
||||
```
|
||||
|
||||
## 🔗 Связанные системы
|
||||
|
||||
- **[Authentication System](auth/README.md)** - Система аутентификации
|
||||
- **[Security System](security.md)** - Управление паролями и email
|
||||
- **[Redis Schema](redis-schema.md)** - Схема данных и кеширование
|
||||
|
||||
## Новые возможности системы
|
||||
|
||||
### Рекурсивное наследование разрешений
|
||||
|
||||
Система теперь поддерживает автоматическое вычисление всех унаследованных разрешений:
|
||||
|
||||
```python
|
||||
# Получить разрешения для конкретной роли с учетом наследования
|
||||
role_permissions = await rbac_ops.get_role_permissions_for_community(
|
||||
community_id=1,
|
||||
role="editor"
|
||||
)
|
||||
# Возвращает: {"editor": ["shout:edit_any", "comment:moderate", "draft:create", "shout:read", ...]}
|
||||
|
||||
# Получить все разрешения для сообщества
|
||||
all_permissions = await rbac_ops.get_all_permissions_for_community(community_id=1)
|
||||
# Возвращает полный словарь всех ролей с их разрешениями
|
||||
```
|
||||
|
||||
### Автоматическая инициализация
|
||||
|
||||
При создании нового сообщества система автоматически инициализирует права с учетом иерархии:
|
||||
|
||||
```python
|
||||
# Автоматически создает расширенные разрешения для всех ролей
|
||||
await rbac_ops.initialize_community_permissions(community_id=123)
|
||||
|
||||
# Система рекурсивно вычисляет все наследованные разрешения
|
||||
# и сохраняет их в Redis для быстрого доступа
|
||||
```
|
||||
|
||||
### Улучшенная производительность
|
||||
|
||||
- **Кеширование в Redis**: Все разрешения кешируются с ключом `community:roles:{community_id}`
|
||||
- **Рекурсивное вычисление**: Разрешения вычисляются один раз при инициализации
|
||||
- **Быстрая проверка**: Проверка разрешений происходит за O(1) из кеша
|
||||
|
||||
### Обновленный API
|
||||
|
||||
```python
|
||||
class RBACOperations(Protocol):
|
||||
# Получить разрешения для конкретной роли с наследованием
|
||||
async def get_role_permissions_for_community(self, community_id: int, role: str) -> dict
|
||||
|
||||
# Получить все разрешения для сообщества
|
||||
async def get_all_permissions_for_community(self, community_id: int) -> dict
|
||||
|
||||
# Проверить разрешения для набора ролей
|
||||
async def roles_have_permission(self, role_slugs: list[str], permission: str, community_id: int) -> bool
|
||||
```
|
||||
|
||||
## Миграция на новую систему
|
||||
|
||||
### Обновление существующего кода
|
||||
|
||||
Если в вашем коде используются старые методы, обновите их:
|
||||
|
||||
```python
|
||||
# Старый код
|
||||
permissions = await rbac_ops._get_role_permissions_for_community(community_id)
|
||||
|
||||
# Новый код
|
||||
permissions = await rbac_ops.get_all_permissions_for_community(community_id)
|
||||
|
||||
# Или для конкретной роли
|
||||
role_permissions = await rbac_ops.get_role_permissions_for_community(community_id, "editor")
|
||||
```
|
||||
|
||||
### Обратная совместимость
|
||||
|
||||
Новая система полностью совместима с существующим кодом:
|
||||
- Все публичные API методы сохранили свои сигнатуры
|
||||
- Декораторы `@require_permission` работают без изменений
|
||||
- Существующие тесты проходят без модификации
|
||||
@@ -1,378 +0,0 @@
|
||||
# Миграция с React 18 на SolidStart: Comprehensive Guide
|
||||
|
||||
## 1. Введение
|
||||
|
||||
### 1.1 Что такое SolidStart?
|
||||
|
||||
SolidStart - это метафреймворк для SolidJS, который предоставляет полнофункциональное решение для создания веб-приложений. Ключевые особенности:
|
||||
|
||||
- Полностью изоморфное приложение (работает на клиенте и сервере)
|
||||
- Встроенная поддержка SSR, SSG и CSR
|
||||
- Интеграция с Vite и Nitro
|
||||
- Гибкая маршрутизация
|
||||
- Встроенные серверные функции и действия
|
||||
|
||||
### 1.2 Основные различия между React и SolidStart
|
||||
|
||||
| Характеристика | React 18 | SolidStart |
|
||||
|---------------|----------|------------|
|
||||
| Рендеринг | Virtual DOM | Компиляция и прямое обновление DOM |
|
||||
| Серверный рендеринг | Сложная настройка | Встроенная поддержка |
|
||||
| Размер бандла | ~40 кБ | ~7.7 кБ |
|
||||
| Реактивность | Хуки с зависимостями | Сигналы без явных зависимостей |
|
||||
| Маршрутизация | react-router | @solidjs/router |
|
||||
|
||||
## 2. Подготовка проекта
|
||||
|
||||
### 2.1 Установка зависимостей
|
||||
|
||||
```bash
|
||||
# Удаление React зависимостей
|
||||
npm uninstall react react-dom react-router-dom
|
||||
|
||||
# Установка SolidStart и связанных библиотек
|
||||
npm install @solidjs/start solid-js @solidjs/router
|
||||
```
|
||||
|
||||
### 2.2 Обновление конфигурации
|
||||
|
||||
#### Vite Configuration (`vite.config.ts`)
|
||||
```typescript
|
||||
import { defineConfig } from 'vite';
|
||||
import solid from 'solid-start/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [solid()],
|
||||
// Дополнительные настройки
|
||||
});
|
||||
```
|
||||
|
||||
#### TypeScript Configuration (`tsconfig.json`)
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"types": ["solid-start/env"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### SolidStart Configuration (`app.config.ts`)
|
||||
```typescript
|
||||
import { defineConfig } from "@solidjs/start/config";
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
// Настройки сервера, например:
|
||||
preset: "netlify" // или другой провайдер
|
||||
},
|
||||
// Дополнительные настройки
|
||||
});
|
||||
```
|
||||
|
||||
## 3. Миграция компонентов и логики
|
||||
|
||||
### 3.1 Состояние и реактивность
|
||||
|
||||
#### React:
|
||||
```typescript
|
||||
const [count, setCount] = useState(0);
|
||||
```
|
||||
|
||||
#### SolidJS:
|
||||
```typescript
|
||||
const [count, setCount] = createSignal(0);
|
||||
// Использование: count(), setCount(newValue)
|
||||
```
|
||||
|
||||
### 3.2 Серверные функции и загрузка данных
|
||||
|
||||
В SolidStart есть несколько способов работы с данными:
|
||||
|
||||
#### Серверная функция
|
||||
```typescript
|
||||
// server/api.ts
|
||||
export function getUser(id: string) {
|
||||
return db.users.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
// Component
|
||||
export default function UserProfile() {
|
||||
const user = createAsync(() => getUser(params.id));
|
||||
|
||||
return <div>{user()?.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
#### Действия (Actions)
|
||||
```typescript
|
||||
export function updateProfile(formData: FormData) {
|
||||
'use server';
|
||||
const name = formData.get('name');
|
||||
// Логика обновления профиля
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Маршрутизация
|
||||
|
||||
```typescript
|
||||
// src/routes/index.tsx
|
||||
import { A } from "@solidjs/router";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div>
|
||||
<A href="/about">О нас</A>
|
||||
<A href="/profile">Профиль</A>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// src/routes/profile.tsx
|
||||
export default function ProfilePage() {
|
||||
return <div>Профиль пользователя</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Оптимизация и производительность
|
||||
|
||||
### 4.1 Мемоизация
|
||||
|
||||
```typescript
|
||||
// Кэширование сложных вычислений
|
||||
const sortedUsers = createMemo(() =>
|
||||
users().sort((a, b) => a.name.localeCompare(b.name))
|
||||
);
|
||||
|
||||
// Ленивая загрузка
|
||||
const UserList = lazy(() => import('./UserList'));
|
||||
```
|
||||
|
||||
### 4.2 Серверный рендеринг и предзагрузка
|
||||
|
||||
```typescript
|
||||
// Предзагрузка данных
|
||||
export function routeData() {
|
||||
return {
|
||||
user: createAsync(() => fetchUser())
|
||||
};
|
||||
}
|
||||
|
||||
export default function UserPage() {
|
||||
const user = useRouteData<typeof routeData>();
|
||||
return <div>{user().name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Особенности миграции
|
||||
|
||||
### 5.1 Ключевые изменения
|
||||
- Замена `useState` на `createSignal`
|
||||
- Использование `createAsync` вместо `useEffect` для загрузки данных
|
||||
- Серверные функции с `'use server'`
|
||||
- Маршрутизация через `@solidjs/router`
|
||||
|
||||
### 5.2 Потенциальные проблемы
|
||||
- Переписать все React-специфичные хуки
|
||||
- Адаптировать библиотеки компонентов
|
||||
- Обновить тесты и CI/CD
|
||||
|
||||
## 6. Деплой
|
||||
|
||||
SolidStart поддерживает множество платформ:
|
||||
- Netlify
|
||||
- Vercel
|
||||
- Cloudflare
|
||||
- AWS
|
||||
- Deno
|
||||
- и другие
|
||||
|
||||
```typescript
|
||||
// app.config.ts
|
||||
export default defineConfig({
|
||||
server: {
|
||||
preset: "netlify" // Выберите вашу платформу
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 7. Инструменты и экосистема
|
||||
|
||||
### Рекомендованные библиотеки
|
||||
- Роутинг: `@solidjs/router`
|
||||
- Состояние: Встроенные примитивы SolidJS
|
||||
- Запросы: `@tanstack/solid-query`
|
||||
- Девтулзы: `solid-devtools`
|
||||
|
||||
## 8. Миграция конкретных компонентов
|
||||
|
||||
### 8.1 Страница регистрации (RegisterPage)
|
||||
|
||||
#### React-версия
|
||||
```typescript
|
||||
import React from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { RegisterForm } from '../components/auth/RegisterForm'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
|
||||
export const RegisterPage: React.FC = () => {
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen ...">
|
||||
<RegisterForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### SolidJS-версия
|
||||
```typescript
|
||||
import { Navigate } from '@solidjs/router'
|
||||
import { Show } from 'solid-js'
|
||||
import { RegisterForm } from '../components/auth/RegisterForm'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
|
||||
return (
|
||||
<Show when={!isAuthenticated()} fallback={<Navigate href="/" />}>
|
||||
<div class="min-h-screen ...">
|
||||
<RegisterForm />
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### Ключевые изменения
|
||||
- Удаление импорта React
|
||||
- Использование `@solidjs/router` вместо `react-router-dom`
|
||||
- Замена `className` на `class`
|
||||
- Использование `Show` для условного рендеринга
|
||||
- Вызов `isAuthenticated()` как функции
|
||||
- Использование `href` вместо `to`
|
||||
- Экспорт по умолчанию вместо именованного экспорта
|
||||
|
||||
### Рекомендации
|
||||
- Всегда используйте `Show` для условного рендеринга
|
||||
- Помните, что сигналы в SolidJS - это функции
|
||||
- Следите за совместимостью импортов и маршрутизации
|
||||
|
||||
## 9. UI Component Migration
|
||||
|
||||
### 9.1 Key Differences in Component Structure
|
||||
|
||||
When migrating UI components from React to SolidJS, several key changes are necessary:
|
||||
|
||||
1. **Props Handling**
|
||||
- Replace `React.FC<Props>` with function component syntax
|
||||
- Use object destructuring for props instead of individual parameters
|
||||
- Replace `className` with `class`
|
||||
- Use `props.children` instead of `children` prop
|
||||
|
||||
2. **Type Annotations**
|
||||
- Use TypeScript interfaces for props
|
||||
- Explicitly type `children` as `any` or a more specific type
|
||||
- Remove React-specific type imports
|
||||
|
||||
3. **Event Handling**
|
||||
- Use SolidJS event types (e.g., `InputEvent`)
|
||||
- Modify event handler signatures to match SolidJS conventions
|
||||
|
||||
### 9.2 Component Migration Example
|
||||
|
||||
#### React Component
|
||||
```typescript
|
||||
import React from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary'
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
variant = 'primary',
|
||||
fullWidth = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const classes = clsx(
|
||||
'button',
|
||||
variant === 'primary' && 'bg-blue-500',
|
||||
fullWidth && 'w-full',
|
||||
className
|
||||
)
|
||||
|
||||
return (
|
||||
<button className={classes} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### SolidJS Component
|
||||
```typescript
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface ButtonProps {
|
||||
variant?: 'primary' | 'secondary'
|
||||
fullWidth?: boolean
|
||||
class?: string
|
||||
children: any
|
||||
disabled?: boolean
|
||||
type?: 'button' | 'submit'
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export const Button = (props: ButtonProps) => {
|
||||
const classes = clsx(
|
||||
'button',
|
||||
props.variant === 'primary' && 'bg-blue-500',
|
||||
props.fullWidth && 'w-full',
|
||||
props.class
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
class={classes}
|
||||
disabled={props.disabled}
|
||||
type={props.type || 'button'}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 Key Migration Strategies
|
||||
|
||||
- Replace `React.FC` with standard function components
|
||||
- Use `props` object instead of individual parameters
|
||||
- Replace `className` with `class`
|
||||
- Modify event handling to match SolidJS patterns
|
||||
- Remove React-specific lifecycle methods
|
||||
- Use SolidJS primitives like `createEffect` for side effects
|
||||
|
||||
## Заключение
|
||||
|
||||
Миграция на SolidStart требует внимательного подхода, но предоставляет значительные преимущества в производительности, простоте разработки и серверных возможностях.
|
||||
|
||||
### Рекомендации
|
||||
- Мигрируйте постепенно
|
||||
- Пишите тесты на каждом этапе
|
||||
- Используйте инструменты совместимости
|
||||
|
||||
---
|
||||
|
||||
Этот гайд поможет вам систематически и безопасно мигрировать ваш проект на SolidStart, сохраняя существующую функциональность и улучшая производительность.
|
||||
@@ -1,440 +0,0 @@
|
||||
# Схема данных Redis в Discours.io
|
||||
|
||||
## Обзор
|
||||
|
||||
Redis используется как основное хранилище для кэширования, сессий, токенов и временных данных. Все ключи следуют структурированным паттернам для обеспечения консистентности и производительности.
|
||||
|
||||
## 🔗 Связанные системы
|
||||
|
||||
- **[Authentication System](auth/README.md)** - Система аутентификации (использует Redis для сессий)
|
||||
- **[RBAC System](rbac-system.md)** - Система ролей (кеширование разрешений)
|
||||
- **[Security System](security.md)** - Управление паролями (токены в Redis)
|
||||
|
||||
## Принципы именования ключей
|
||||
|
||||
### Общие правила
|
||||
- Использование двоеточия `:` как разделителя иерархии
|
||||
- Формат: `{category}:{type}:{identifier}` или `{entity}:{property}:{value}`
|
||||
- Константное время поиска через точные ключи
|
||||
- TTL для всех временных данных
|
||||
|
||||
### Категории данных
|
||||
1. **Аутентификация**: `session:*`, `oauth_*`, `env_vars:*`
|
||||
2. **Кэш сущностей**: `author:*`, `topic:*`, `shout:*`
|
||||
3. **Поиск**: `search_cache:*`
|
||||
4. **Просмотры**: `migrated_views_*`, `viewed_*`
|
||||
5. **Уведомления**: publish/subscribe каналы
|
||||
|
||||
## 1. Система аутентификации
|
||||
|
||||
### 1.1 Сессии пользователей
|
||||
|
||||
#### Структура ключей
|
||||
```
|
||||
session:{user_id}:{jwt_token} # HASH - данные сессии
|
||||
user_sessions:{user_id} # SET - список активных токенов пользователя
|
||||
{user_id}-{username}-{token} # STRING - legacy формат (deprecated)
|
||||
```
|
||||
|
||||
#### Данные сессии (HASH)
|
||||
```redis
|
||||
HGETALL session:123:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
|
||||
```
|
||||
**Поля:**
|
||||
- `user_id`: ID пользователя (string)
|
||||
- `username`: Имя пользователя (string)
|
||||
- `token_type`: "session" (string)
|
||||
- `created_at`: Unix timestamp создания (string)
|
||||
- `last_activity`: Unix timestamp последней активности (string)
|
||||
- `auth_data`: JSON строка с данными авторизации (string, optional)
|
||||
- `device_info`: JSON строка с информацией об устройстве (string, optional)
|
||||
|
||||
**TTL**: 30 дней (2592000 секунд)
|
||||
|
||||
#### Список токенов пользователя (SET)
|
||||
```redis
|
||||
SMEMBERS user_sessions:123
|
||||
```
|
||||
**Содержимое**: JWT токены активных сессий пользователя
|
||||
**TTL**: 30 дней
|
||||
|
||||
### 1.2 OAuth токены
|
||||
|
||||
#### Структура ключей
|
||||
```
|
||||
oauth_access:{user_id}:{provider} # STRING - access токен
|
||||
oauth_refresh:{user_id}:{provider} # STRING - refresh токен
|
||||
oauth_state:{state} # HASH - временное состояние OAuth flow
|
||||
```
|
||||
|
||||
#### Access токены
|
||||
**Провайдеры**: `google`, `github`, `facebook`, `twitter`, `telegram`, `vk`, `yandex`
|
||||
**TTL**: 1 час (3600 секунд)
|
||||
**Пример**:
|
||||
```redis
|
||||
GET oauth_access:123:google
|
||||
# Возвращает: access_token_string
|
||||
```
|
||||
|
||||
#### Refresh токены
|
||||
**TTL**: 30 дней (2592000 секунд)
|
||||
**Пример**:
|
||||
```redis
|
||||
GET oauth_refresh:123:google
|
||||
# Возвращает: refresh_token_string
|
||||
```
|
||||
|
||||
#### OAuth состояние (временное)
|
||||
```redis
|
||||
HGETALL oauth_state:a1b2c3d4e5f6
|
||||
```
|
||||
**Поля:**
|
||||
- `redirect_uri`: URL для перенаправления после авторизации
|
||||
- `csrf_token`: CSRF защита
|
||||
- `provider`: Провайдер OAuth
|
||||
- `created_at`: Время создания
|
||||
|
||||
**TTL**: 10 минут (600 секунд)
|
||||
|
||||
### 1.3 Токены подтверждения
|
||||
|
||||
#### Структура ключей
|
||||
```
|
||||
verification:{user_id}:{type}:{token} # HASH - данные токена подтверждения
|
||||
```
|
||||
|
||||
#### Типы подтверждения
|
||||
- `email_verification`: Подтверждение email
|
||||
- `phone_verification`: Подтверждение телефона
|
||||
- `password_reset`: Сброс пароля
|
||||
- `email_change`: Смена email
|
||||
|
||||
**Поля токена**:
|
||||
- `user_id`: ID пользователя
|
||||
- `token_type`: Тип токена
|
||||
- `verification_type`: Тип подтверждения
|
||||
- `created_at`: Время создания
|
||||
- `data`: JSON с дополнительными данными
|
||||
|
||||
**TTL**: 1 час (3600 секунд)
|
||||
|
||||
## 2. Переменные окружения
|
||||
|
||||
### Структура ключей
|
||||
```
|
||||
env_vars:{variable_name} # STRING - значение переменной
|
||||
```
|
||||
|
||||
### Примеры переменных
|
||||
```redis
|
||||
GET env_vars:JWT_SECRET_KEY # Секретный ключ JWT
|
||||
GET env_vars:REDIS_URL # URL Redis
|
||||
GET env_vars:OAUTH_GOOGLE_CLIENT_ID # Google OAuth Client ID
|
||||
GET env_vars:FEATURE_REGISTRATION # Флаг функции регистрации
|
||||
```
|
||||
|
||||
**Категории переменных**:
|
||||
- **database**: DB_URL, POSTGRES_*
|
||||
- **auth**: JWT_SECRET_KEY, OAUTH_*
|
||||
- **redis**: REDIS_URL, REDIS_HOST, REDIS_PORT
|
||||
- **search**: SEARCH_*
|
||||
- **integrations**: GOOGLE_ANALYTICS_ID, SENTRY_DSN, SMTP_*
|
||||
- **security**: CORS_ORIGINS, ALLOWED_HOSTS
|
||||
- **logging**: LOG_LEVEL, DEBUG
|
||||
- **features**: FEATURE_*
|
||||
|
||||
**TTL**: Без ограничения (постоянное хранение)
|
||||
|
||||
## 3. Кэш сущностей
|
||||
|
||||
### 3.1 Авторы (пользователи)
|
||||
|
||||
#### Структура ключей
|
||||
```
|
||||
author:id:{author_id} # STRING - JSON данные автора
|
||||
author:slug:{author_slug} # STRING - ID автора по slug
|
||||
author:followers:{author_id} # STRING - JSON массив подписчиков
|
||||
author:follows-topics:{author_id} # STRING - JSON массив отслеживаемых тем
|
||||
author:follows-authors:{author_id} # STRING - JSON массив отслеживаемых авторов
|
||||
author:follows-shouts:{author_id} # STRING - JSON массив отслеживаемых публикаций
|
||||
```
|
||||
|
||||
#### Данные автора (JSON)
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"email": "user@example.com",
|
||||
"name": "Имя Пользователя",
|
||||
"slug": "username",
|
||||
"pic": "https://example.com/avatar.jpg",
|
||||
"bio": "Описание автора",
|
||||
"email_verified": true,
|
||||
"created_at": 1640995200,
|
||||
"updated_at": 1640995200,
|
||||
"last_seen": 1640995200,
|
||||
"stat": {
|
||||
"topics": 15,
|
||||
"authors": 8,
|
||||
"shouts": 42
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Подписчики автора
|
||||
```json
|
||||
[123, 456, 789] // Массив ID подписчиков
|
||||
```
|
||||
|
||||
#### Подписки автора
|
||||
```json
|
||||
// author:follows-topics:123
|
||||
[1, 5, 10, 15] // ID отслеживаемых тем
|
||||
|
||||
// author:follows-authors:123
|
||||
[45, 67, 89] // ID отслеживаемых авторов
|
||||
|
||||
// author:follows-shouts:123
|
||||
[101, 102, 103] // ID отслеживаемых публикаций
|
||||
```
|
||||
|
||||
**TTL**: Без ограничения (инвалидация при изменениях)
|
||||
|
||||
### 3.2 Темы
|
||||
|
||||
#### Структура ключей
|
||||
```
|
||||
topic:id:{topic_id} # STRING - JSON данные темы
|
||||
topic:slug:{topic_slug} # STRING - JSON данные темы
|
||||
topic:authors:{topic_id} # STRING - JSON массив авторов темы
|
||||
topic:followers:{topic_id} # STRING - JSON массив подписчиков темы
|
||||
topic_shouts_{topic_id} # STRING - JSON массив публикаций темы (legacy)
|
||||
```
|
||||
|
||||
#### Данные темы (JSON)
|
||||
```json
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Название темы",
|
||||
"slug": "tema-slug",
|
||||
"description": "Описание темы",
|
||||
"pic": "https://example.com/topic.jpg",
|
||||
"community": 1,
|
||||
"created_at": 1640995200,
|
||||
"updated_at": 1640995200,
|
||||
"stat": {
|
||||
"shouts": 150,
|
||||
"authors": 25,
|
||||
"followers": 89
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Авторы темы
|
||||
```json
|
||||
[123, 456, 789] // ID авторов, писавших в теме
|
||||
```
|
||||
|
||||
#### Подписчики темы
|
||||
```json
|
||||
[111, 222, 333, 444] // ID подписчиков темы
|
||||
```
|
||||
|
||||
**TTL**: Без ограничения (инвалидация при изменениях)
|
||||
|
||||
### 3.3 Публикации (Shouts)
|
||||
|
||||
#### Структура ключей
|
||||
```
|
||||
shouts:{params_hash} # STRING - JSON массив публикаций
|
||||
topic_shouts_{topic_id} # STRING - JSON массив публикаций темы
|
||||
```
|
||||
|
||||
#### Примеры ключей публикаций
|
||||
```
|
||||
shouts:limit=20:offset=0:sort=created_at # Последние публикации
|
||||
shouts:author=123:limit=10 # Публикации автора
|
||||
shouts:topic=5:featured=true # Рекомендуемые публикации темы
|
||||
```
|
||||
|
||||
**TTL**: 5 минут (300 секунд)
|
||||
|
||||
## 4. Поисковый кэш
|
||||
|
||||
### Структура ключей
|
||||
```
|
||||
search_cache:{normalized_query} # STRING - JSON результаты поиска
|
||||
```
|
||||
|
||||
### Нормализация запроса
|
||||
- Приведение к нижнему регистру
|
||||
- Удаление лишних пробелов
|
||||
- Сортировка параметров
|
||||
|
||||
### Данные поиска (JSON)
|
||||
```json
|
||||
{
|
||||
"query": "поисковый запрос",
|
||||
"results": [
|
||||
{
|
||||
"type": "shout",
|
||||
"id": 123,
|
||||
"title": "Заголовок публикации",
|
||||
"slug": "publication-slug",
|
||||
"score": 0.95
|
||||
}
|
||||
],
|
||||
"total": 15,
|
||||
"cached_at": 1640995200
|
||||
}
|
||||
```
|
||||
|
||||
**TTL**: 10 минут (600 секунд)
|
||||
|
||||
## 5. Система просмотров
|
||||
|
||||
### Структура ключей
|
||||
```
|
||||
migrated_views_{timestamp} # HASH - просмотры публикаций
|
||||
migrated_views_slugs # HASH - маппинг slug -> id
|
||||
viewed:{shout_id} # STRING - счетчик просмотров
|
||||
```
|
||||
|
||||
### Мигрированные просмотры (HASH)
|
||||
```redis
|
||||
HGETALL migrated_views_1640995200
|
||||
```
|
||||
**Поля**:
|
||||
- `{shout_id}`: количество просмотров (string)
|
||||
- `_timestamp`: время создания записи
|
||||
- `_total`: общее количество записей
|
||||
|
||||
### Маппинг slug -> ID
|
||||
```redis
|
||||
HGETALL migrated_views_slugs
|
||||
```
|
||||
**Поля**: `{shout_slug}` -> `{shout_id}`
|
||||
|
||||
**TTL**: Без ограничения (данные аналитики)
|
||||
|
||||
## 6. Pub/Sub каналы
|
||||
|
||||
### Каналы уведомлений
|
||||
```
|
||||
notifications:{user_id} # Персональные уведомления
|
||||
notifications:global # Глобальные уведомления
|
||||
notifications:topic:{topic_id} # Уведомления темы
|
||||
notifications:shout:{shout_id} # Уведомления публикации
|
||||
```
|
||||
|
||||
### Структура сообщения (JSON)
|
||||
```json
|
||||
{
|
||||
"type": "notification_type",
|
||||
"user_id": 123,
|
||||
"entity_type": "shout",
|
||||
"entity_id": 456,
|
||||
"action": "created|updated|deleted",
|
||||
"data": {
|
||||
"title": "Заголовок",
|
||||
"author": "Автор"
|
||||
},
|
||||
"timestamp": 1640995200
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Временные данные
|
||||
|
||||
### Ключи блокировок
|
||||
```
|
||||
lock:{operation}:{entity_id} # STRING - блокировка операции
|
||||
```
|
||||
|
||||
**TTL**: 30 секунд (автоматическое снятие блокировки)
|
||||
|
||||
### Ключи состояния
|
||||
```
|
||||
state:{process}:{identifier} # HASH - состояние процесса
|
||||
```
|
||||
|
||||
**TTL**: От 1 минуты до 1 часа в зависимости от процесса
|
||||
|
||||
## 8. Мониторинг и статистика
|
||||
|
||||
### Ключи метрик
|
||||
```
|
||||
metrics:{metric_name}:{period} # STRING - значение метрики
|
||||
stats:{entity}:{timeframe} # HASH - статистика сущности
|
||||
```
|
||||
|
||||
### Примеры метрик
|
||||
```
|
||||
metrics:active_sessions:hourly # Количество активных сессий
|
||||
metrics:cache_hits:daily # Попадания в кэш за день
|
||||
stats:topics:weekly # Статистика тем за неделю
|
||||
```
|
||||
|
||||
**TTL**: От 1 часа до 30 дней в зависимости от типа метрики
|
||||
|
||||
## 9. Оптимизация и производительность
|
||||
|
||||
### Пакетные операции
|
||||
Используются Redis pipelines для атомарных операций:
|
||||
```python
|
||||
# Пример создания сессии
|
||||
commands = [
|
||||
("hset", (token_key, "user_id", user_id)),
|
||||
("hset", (token_key, "created_at", timestamp)),
|
||||
("expire", (token_key, ttl)),
|
||||
("sadd", (user_tokens_key, token)),
|
||||
]
|
||||
await redis.execute_pipeline(commands)
|
||||
```
|
||||
|
||||
### Стратегии кэширования
|
||||
1. **Write-through**: Немедленное обновление кэша при изменении данных
|
||||
2. **Cache-aside**: Lazy loading с обновлением при промахе
|
||||
3. **Write-behind**: Отложенная запись в БД
|
||||
|
||||
### Инвалидация кэша
|
||||
- **Точечная**: Удаление конкретных ключей при изменениях
|
||||
- **По префиксу**: Массовое удаление связанных ключей
|
||||
- **TTL**: Автоматическое истечение для временных данных
|
||||
|
||||
## 10. Мониторинг
|
||||
|
||||
### Команды диагностики
|
||||
```bash
|
||||
# Статистика использования памяти
|
||||
redis-cli info memory
|
||||
|
||||
# Количество ключей по типам
|
||||
redis-cli --scan --pattern "session:*" | wc -l
|
||||
redis-cli --scan --pattern "author:*" | wc -l
|
||||
redis-cli --scan --pattern "topic:*" | wc -l
|
||||
|
||||
# Размер конкретного ключа
|
||||
redis-cli memory usage session:123:token...
|
||||
|
||||
# Анализ истечения ключей
|
||||
redis-cli --scan --pattern "*" | xargs -I {} redis-cli ttl {}
|
||||
```
|
||||
|
||||
### Проблемы и решения
|
||||
1. **Память**: Использование TTL для временных данных
|
||||
2. **Производительность**: Pipeline операции, connection pooling
|
||||
3. **Консистентность**: Транзакции для критических операций
|
||||
4. **Масштабирование**: Шардирование по user_id для сессий
|
||||
|
||||
## 11. Безопасность
|
||||
|
||||
### Принципы
|
||||
- TTL для всех временных данных предотвращает накопление мусора
|
||||
- Раздельное хранение секретных данных (токены) и публичных (кэш)
|
||||
- Использование pipeline для атомарных операций
|
||||
- Регулярная очистка истекших ключей
|
||||
|
||||
### Рекомендации
|
||||
- Мониторинг использования памяти Redis
|
||||
- Backup критичных данных (переменные окружения)
|
||||
- Ограничение размера значений для предотвращения OOM
|
||||
- Использование отдельных баз данных для разных типов данных
|
||||
@@ -1,524 +0,0 @@
|
||||
# 🔍 Система поиска
|
||||
|
||||
## Обзор
|
||||
|
||||
Система поиска использует **семантические эмбединги** для точного поиска по публикациям. Поддерживает две архитектуры:
|
||||
|
||||
1. **BiEncoder** (SentenceTransformers) - быстрая, стандартное качество
|
||||
2. **ColBERT** (pylate) - медленнее на ~50ms, но **+175% recall** 🎯
|
||||
|
||||
Обе реализации используют FDE (Fast Document Encoding) для оптимизации хранения.
|
||||
|
||||
## 🎯 Выбор модели
|
||||
|
||||
Управление через `SEARCH_MODEL_TYPE` в env:
|
||||
|
||||
```bash
|
||||
# ColBERT - лучшее качество (по умолчанию)
|
||||
SEARCH_MODEL_TYPE=colbert
|
||||
|
||||
# BiEncoder - быстрее, но хуже recall
|
||||
SEARCH_MODEL_TYPE=biencoder
|
||||
```
|
||||
|
||||
### Сравнение моделей
|
||||
|
||||
| Аспект | BiEncoder | ColBERT |
|
||||
|--------|-----------|---------|
|
||||
| **Recall@10** | ~0.16 | **0.44** ✅ |
|
||||
| **Query time** | ~395ms | ~447ms |
|
||||
| **Indexing** | ~26s | ~12s ✅ |
|
||||
| **Архитектура** | 1 doc = 1 vector | 1 doc = N vectors (multi-vector) |
|
||||
| **Лучше для** | Скорость | Качество |
|
||||
|
||||
💋 **Рекомендация**: используйте `colbert` для production, если качество важнее скорости.
|
||||
|
||||
## 🚀 Основные возможности
|
||||
|
||||
### **1. Семантический поиск**
|
||||
- Понимание смысла запросов, а не только ключевых слов
|
||||
- Поддержка русского и английского языков
|
||||
- Multi-vector retrieval (ColBERT) для точных результатов
|
||||
|
||||
### **2. Оптимизированная индексация**
|
||||
- Batch-обработка для больших объёмов данных
|
||||
- Тихий режим для массовых операций
|
||||
- FDE кодирование для сжатия векторов
|
||||
|
||||
### **3. Высокая производительность**
|
||||
- MaxSim scoring (ColBERT) или косинусное сходство (BiEncoder)
|
||||
- Кеширование результатов
|
||||
- Асинхронная обработка
|
||||
|
||||
## 📋 API
|
||||
|
||||
### GraphQL запросы
|
||||
|
||||
```graphql
|
||||
# Поиск по публикациям
|
||||
query SearchShouts($text: String!, $options: ShoutsOptions) {
|
||||
load_shouts_search(text: $text, options: $options) {
|
||||
id
|
||||
title
|
||||
body
|
||||
topics {
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Поиск по авторам
|
||||
query SearchAuthors($text: String!, $limit: Int, $offset: Int) {
|
||||
load_authors_search(text: $text, limit: $limit, offset: $offset) {
|
||||
id
|
||||
name
|
||||
email
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Параметры поиска
|
||||
|
||||
```python
|
||||
options = {
|
||||
"limit": 10, # Количество результатов
|
||||
"offset": 0, # Смещение для пагинации
|
||||
"filters": { # Дополнительные фильтры
|
||||
"community": 1,
|
||||
"status": "published"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🛠️ Техническая архитектура
|
||||
|
||||
### Компоненты системы
|
||||
|
||||
```
|
||||
📦 Search System
|
||||
├── 🎯 SearchService # API интерфейс + выбор модели
|
||||
│
|
||||
├── 🔵 BiEncoder Path (MuveraWrapper)
|
||||
│ ├── 🧠 SentenceTransformer # paraphrase-multilingual-MiniLM-L12-v2
|
||||
│ ├── 🗜️ Muvera FDE # Сжатие векторов
|
||||
│ └── 📊 Cosine Similarity # Ранжирование
|
||||
│
|
||||
├── 🟢 ColBERT Path (MuveraPylateWrapper) 🎯 NEW!
|
||||
│ ├── 🧠 pylate ColBERT # answerdotai/answerai-colbert-small-v1
|
||||
│ ├── 🗜️ Native MUVERA # Multi-vector FDE (каждый токен → FDE)
|
||||
│ ├── 🚀 FAISS Prefilter # O(log N) → top-1000 кандидатов (опционально)
|
||||
│ └── 📊 TRUE MaxSim Scoring # Token-level similarity на кандидатах
|
||||
│
|
||||
└── 💾 File Persistence # Сохранение в /dump
|
||||
```
|
||||
|
||||
### Модели эмбедингов
|
||||
|
||||
#### BiEncoder (стандарт)
|
||||
**Модель**: `paraphrase-multilingual-MiniLM-L12-v2`
|
||||
- Поддержка 50+ языков включая русский
|
||||
- Размерность: 384D
|
||||
- Fallback: `all-MiniLM-L6-v2`
|
||||
- Алгоритм: average pooling + cosine similarity
|
||||
|
||||
#### ColBERT (улучшенная версия)
|
||||
**Модель**: `answerdotai/answerai-colbert-small-v1`
|
||||
- Многоязычная ColBERT модель
|
||||
- Размерность: 768D
|
||||
- Алгоритм: max pooling + MaxSim scoring
|
||||
- 🤖 **Внимание**: модели, тренированные через дистилляцию, могут иметь проблемы с нормализацией скоров ([pylate#142](https://github.com/lightonai/pylate/issues/142))
|
||||
|
||||
### Процесс индексации
|
||||
|
||||
#### BiEncoder
|
||||
```python
|
||||
# 1. Извлечение текста
|
||||
doc_content = f"{title} {subtitle} {lead} {body}".strip()
|
||||
|
||||
# 2. Генерация single-vector эмбединга
|
||||
embedding = encoder.encode(doc_content) # [384D]
|
||||
|
||||
# 3. FDE кодирование (average pooling)
|
||||
compressed = muvera.encode_fde(embedding, buckets=128, method="avg")
|
||||
|
||||
# 4. Сохранение в индекс
|
||||
embeddings[doc_id] = compressed
|
||||
```
|
||||
|
||||
#### ColBERT (native MUVERA multi-vector) 🎯
|
||||
```python
|
||||
# 1. Извлечение текста
|
||||
doc_content = f"{title} {subtitle} {lead} {body}".strip()
|
||||
|
||||
# 2. Генерация multi-vector эмбединга (по токену)
|
||||
doc_embeddings = encoder.encode([doc_content], is_query=False) # [N_tokens, 768D]
|
||||
|
||||
# 3. 🎯 NATIVE MUVERA: FDE encode КАЖДЫЙ токен отдельно
|
||||
doc_fdes = []
|
||||
for token_vec in doc_embeddings[0]:
|
||||
token_fde = muvera.encode_fde(token_vec.reshape(1, -1), buckets=128, method="avg")
|
||||
doc_fdes.append(token_fde)
|
||||
|
||||
# 4. Сохранение в индекс как СПИСОК векторов
|
||||
embeddings[doc_id] = doc_fdes # List of FDE vectors, not single!
|
||||
```
|
||||
|
||||
### Алгоритм поиска
|
||||
|
||||
#### BiEncoder (косинусное сходство)
|
||||
```python
|
||||
# 1. Эмбединг запроса
|
||||
query_embedding = encoder.encode(query_text)
|
||||
query_fde = muvera.encode_fde(query_embedding, buckets=128, method="avg")
|
||||
|
||||
# 2. Косинусное сходство
|
||||
for doc_id, doc_embedding in embeddings.items():
|
||||
similarity = np.dot(query_fde, doc_embedding) / (
|
||||
np.linalg.norm(query_fde) * np.linalg.norm(doc_embedding)
|
||||
)
|
||||
results.append({"id": doc_id, "score": similarity})
|
||||
|
||||
# 3. Ранжирование
|
||||
results.sort(key=lambda x: x["score"], reverse=True)
|
||||
```
|
||||
|
||||
#### ColBERT (TRUE MaxSim с native MUVERA) 🎯
|
||||
```python
|
||||
# 1. Multi-vector эмбединг запроса
|
||||
query_embeddings = encoder.encode([query_text], is_query=True) # [N_tokens, 768D]
|
||||
|
||||
# 2. 🎯 NATIVE MUVERA: FDE encode КАЖДЫЙ query токен
|
||||
query_fdes = []
|
||||
for token_vec in query_embeddings[0]:
|
||||
token_fde = muvera.encode_fde(token_vec.reshape(1, -1), buckets=128, method="avg")
|
||||
query_fdes.append(token_fde)
|
||||
|
||||
# 3. 🎯 TRUE MaxSim scoring (ColBERT-style)
|
||||
for doc_id, doc_fdes in embeddings.items():
|
||||
# Для каждого query токена находим максимальное сходство с doc токенами
|
||||
max_sims = []
|
||||
for query_fde in query_fdes:
|
||||
token_sims = [
|
||||
np.dot(query_fde, doc_fde) / (np.linalg.norm(query_fde) * np.linalg.norm(doc_fde))
|
||||
for doc_fde in doc_fdes
|
||||
]
|
||||
max_sims.append(max(token_sims))
|
||||
|
||||
# Final score = average of max similarities
|
||||
final_score = np.mean(max_sims)
|
||||
results.append({"id": doc_id, "score": final_score})
|
||||
|
||||
# 4. Ранжирование
|
||||
results.sort(key=lambda x: x["score"], reverse=True)
|
||||
```
|
||||
|
||||
**💡 Ключевое отличие**: Настоящий MaxSim через native MUVERA multi-vector, а не упрощенный через max pooling!
|
||||
|
||||
## 🚀 FAISS Acceleration (для больших индексов)
|
||||
|
||||
### Проблема масштабируемости
|
||||
|
||||
**Без FAISS** (brute force):
|
||||
```python
|
||||
# O(N) сложность - перебор ВСЕХ документов
|
||||
for doc_id in all_50K_documents: # 😱 50K iterations!
|
||||
score = maxsim(query, doc)
|
||||
```
|
||||
|
||||
**С FAISS** (двухэтапный поиск):
|
||||
```python
|
||||
# Stage 1: FAISS prefilter - O(log N)
|
||||
candidates = faiss_index.search(query_avg, k=1000) # Только 1K кандидатов
|
||||
|
||||
# Stage 2: TRUE MaxSim только на кандидатах
|
||||
for doc_id in candidates: # ✅ 1K iterations (50x быстрее!)
|
||||
score = maxsim(query, doc)
|
||||
```
|
||||
|
||||
### Когда включать FAISS?
|
||||
|
||||
| Документов | Без FAISS | С FAISS | Рекомендация |
|
||||
|------------|-----------|---------|--------------|
|
||||
| < 1K | ~50ms | ~30ms | 🤷 Опционально |
|
||||
| 1K-10K | ~200ms | ~40ms | ✅ Желательно |
|
||||
| 10K-50K | ~1-2s | ~60ms | ✅ **Обязательно** |
|
||||
| > 50K | ~5s+ | ~100ms | ✅ **Критично** |
|
||||
|
||||
### Архитектура с FAISS
|
||||
|
||||
```
|
||||
📦 ColBERT + MUVERA + FAISS:
|
||||
|
||||
Indexing:
|
||||
├── ColBERT → [token1_vec, token2_vec, ...]
|
||||
├── MUVERA → [token1_fde, token2_fde, ...]
|
||||
└── FAISS → doc_avg в индекс (для быстрого поиска)
|
||||
|
||||
Search:
|
||||
├── ColBERT query → [q1_vec, q2_vec, ...]
|
||||
├── MUVERA → [q1_fde, q2_fde, ...]
|
||||
│
|
||||
├── 🚀 Stage 1 (FAISS - грубый):
|
||||
│ └── query_avg → top-1000 candidates (быстро!)
|
||||
│
|
||||
└── 🎯 Stage 2 (MaxSim - точный):
|
||||
└── TRUE MaxSim только для candidates (качественно!)
|
||||
```
|
||||
|
||||
### Конфигурация FAISS
|
||||
|
||||
```bash
|
||||
# Включить FAISS (default: true)
|
||||
SEARCH_USE_FAISS=true
|
||||
|
||||
# Сколько кандидатов брать для rerank
|
||||
SEARCH_FAISS_CANDIDATES=1000 # Больше = точнее, но медленнее
|
||||
```
|
||||
|
||||
**💋 Рекомендация**: Оставьте `SEARCH_USE_FAISS=true` если планируется >10K документов.
|
||||
|
||||
## ⚙️ Конфигурация
|
||||
|
||||
### Переменные окружения
|
||||
|
||||
```bash
|
||||
# 🎯 Выбор модели (ключевая настройка!)
|
||||
SEARCH_MODEL_TYPE=colbert # "biencoder" | "colbert" (default: colbert)
|
||||
|
||||
# 🚀 FAISS acceleration (рекомендуется для >10K документов)
|
||||
SEARCH_USE_FAISS=true # Включить FAISS prefilter (default: true)
|
||||
SEARCH_FAISS_CANDIDATES=1000 # Сколько кандидатов для rerank (default: 1000)
|
||||
|
||||
# Индексация и кеширование
|
||||
MUVERA_INDEX_NAME=discours
|
||||
SEARCH_MAX_BATCH_SIZE=25
|
||||
SEARCH_PREFETCH_SIZE=200
|
||||
SEARCH_CACHE_ENABLED=true
|
||||
SEARCH_CACHE_TTL_SECONDS=300
|
||||
```
|
||||
|
||||
### Настройки производительности
|
||||
|
||||
```python
|
||||
# Batch размеры
|
||||
SINGLE_DOC_THRESHOLD = 10 # Меньше = одиночная обработка
|
||||
BATCH_SIZE = 32 # Размер batch для SentenceTransformers
|
||||
FDE_BUCKETS = 128 # Количество bucket для сжатия
|
||||
|
||||
# Logging
|
||||
SILENT_BATCH_MODE = True # Тихий режим для batch операций
|
||||
DEBUG_SINGLE_DOCS = True # Подробные логи для одиночных документов
|
||||
```
|
||||
|
||||
## 🔧 Использование
|
||||
|
||||
### Индексация новых документов
|
||||
|
||||
```python
|
||||
from services.search import search_service
|
||||
|
||||
# Одиночный документ
|
||||
search_service.index(shout)
|
||||
|
||||
# Batch индексация (тихий режим)
|
||||
await search_service.bulk_index(shouts_list)
|
||||
```
|
||||
|
||||
### Поиск
|
||||
|
||||
```python
|
||||
# Поиск публикаций
|
||||
results = await search_service.search("машинное обучение", limit=10, offset=0)
|
||||
|
||||
# Поиск авторов
|
||||
authors = await search_service.search_authors("Иван Петров", limit=5)
|
||||
```
|
||||
|
||||
### Проверка статуса
|
||||
|
||||
```python
|
||||
# Информация о сервисе
|
||||
info = await search_service.info()
|
||||
|
||||
# Статус индекса
|
||||
status = await search_service.check_index_status()
|
||||
|
||||
# Проверка документов
|
||||
verification = await search_service.verify_docs(["1", "2", "3"])
|
||||
```
|
||||
|
||||
## 🐛 Отладка
|
||||
|
||||
### Логирование
|
||||
|
||||
```python
|
||||
# Включить debug логи
|
||||
import logging
|
||||
logging.getLogger("services.search").setLevel(logging.DEBUG)
|
||||
|
||||
# Проверить загрузку модели
|
||||
logger.info("🔍 SentenceTransformer model loaded successfully")
|
||||
```
|
||||
|
||||
### Диагностика
|
||||
|
||||
```python
|
||||
# Проверить количество проиндексированных документов
|
||||
info = await search_service.info()
|
||||
print(f"Documents: {info['muvera_info']['documents_count']}")
|
||||
|
||||
# Найти отсутствующие документы
|
||||
missing = await search_service.verify_docs(expected_doc_ids)
|
||||
print(f"Missing: {missing['missing']}")
|
||||
```
|
||||
|
||||
## 📈 Метрики производительности
|
||||
|
||||
### Benchmark (dataset: NanoFiQA2018, 50 queries)
|
||||
|
||||
#### BiEncoder (MuveraWrapper)
|
||||
```
|
||||
📊 BiEncoder Performance:
|
||||
├── Indexing time: ~26s
|
||||
├── Avg query time: ~395ms
|
||||
├── Recall@10: 0.16 (16%)
|
||||
└── Memory: ~50MB per 1000 docs
|
||||
```
|
||||
|
||||
#### ColBERT (MuveraPylateWrapper) ✅
|
||||
```
|
||||
📊 ColBERT Performance:
|
||||
├── Indexing time: ~12s ✅ (faster!)
|
||||
├── Avg query time: ~447ms (+52ms)
|
||||
├── Recall@10: 0.44 (44%) 🎯 +175%!
|
||||
└── Memory: ~60MB per 1000 docs
|
||||
```
|
||||
|
||||
### Выбор модели: когда что использовать?
|
||||
|
||||
| Сценарий | Рекомендация | Причина |
|
||||
|----------|-------------|---------|
|
||||
| Production поиск | **ColBERT + FAISS** | Качество + скорость |
|
||||
| Dev/testing | BiEncoder | Быстрый старт |
|
||||
| Ограниченная память | BiEncoder | -20% память |
|
||||
| < 10K документов | ColBERT без FAISS | Overhead не нужен |
|
||||
| > 10K документов | **ColBERT + FAISS** | Обязательно для скорости |
|
||||
| Нужен максимальный recall | **ColBERT** | +175% recall |
|
||||
|
||||
### Оптимизация
|
||||
|
||||
1. **Batch обработка** - для массовых операций используйте `bulk_index()`
|
||||
2. **Тихий режим** - отключает детальное логирование
|
||||
3. **Кеширование** - результаты поиска кешируются (опционально)
|
||||
4. **FDE сжатие** - уменьшает размер векторов в 2-3 раза
|
||||
5. **GPU ускорение** - установите `device="cuda"` в ColBERT для 10x speedup
|
||||
|
||||
## 💾 Персистентность и восстановление
|
||||
|
||||
### Автоматическое сохранение в файлы
|
||||
|
||||
Система автоматически сохраняет индекс в файлы после каждой успешной индексации:
|
||||
|
||||
```python
|
||||
# Автосохранение после индексации
|
||||
await self.save_index_to_file("/dump")
|
||||
logger.info("💾 Индекс автоматически сохранен в файл")
|
||||
```
|
||||
|
||||
### Структура файлов
|
||||
|
||||
```
|
||||
/dump/ (или ./dump/)
|
||||
├── discours.pkl.gz # BiEncoder индекс (gzip)
|
||||
└── discours_colbert.pkl.gz # ColBERT индекс (gzip)
|
||||
```
|
||||
|
||||
Каждый файл содержит:
|
||||
- `documents` - контент и метаданные
|
||||
- `embeddings` - FDE-сжатые векторы
|
||||
- `vector_dimension` - размерность
|
||||
- `buckets` - FDE buckets
|
||||
- `model_name` (ColBERT only) - название модели
|
||||
|
||||
### Восстановление при запуске
|
||||
|
||||
При запуске сервиса система автоматически восстанавливает индекс из файла:
|
||||
|
||||
```python
|
||||
# В initialize_search_index()
|
||||
await search_service.async_init() # Восстанавливает из файла
|
||||
|
||||
# Fallback path: /dump (priority) или ./dump
|
||||
```
|
||||
|
||||
## 🆕 Преимущества file-based хранения
|
||||
|
||||
### По сравнению с БД
|
||||
|
||||
- **📦 Простота**: Нет зависимости от Redis/БД для индекса
|
||||
- **💾 Эффективность**: Gzip сжатие (pickle) - быстрое сохранение/загрузка
|
||||
- **🔄 Портативность**: Легко копировать между серверами
|
||||
- **🔒 Целостность**: Атомарная запись через gzip
|
||||
|
||||
### Производительность
|
||||
|
||||
```
|
||||
📊 Хранение индекса:
|
||||
├── File (gzip): ~25MB disk, быстрая загрузка ✅
|
||||
├── Memory only: ~50MB RAM, потеря при рестарте ❌
|
||||
└── БД: ~75MB RAM, медленное восстановление
|
||||
```
|
||||
|
||||
## 🔄 Миграция и обновления
|
||||
|
||||
### Переиндексация
|
||||
|
||||
```python
|
||||
# Полная переиндексация
|
||||
from main import initialize_search_index_with_data
|
||||
await initialize_search_index_with_data()
|
||||
```
|
||||
|
||||
### Обновление модели
|
||||
|
||||
#### Переключение BiEncoder ↔ ColBERT
|
||||
|
||||
```bash
|
||||
# Изменить в .env
|
||||
SEARCH_MODEL_TYPE=colbert # или biencoder
|
||||
|
||||
# Перезапустить сервис
|
||||
dokku ps:restart core
|
||||
|
||||
# Система автоматически:
|
||||
# 1. Загрузит нужную модель
|
||||
# 2. Восстановит соответствующий индекс из файла
|
||||
# 3. Если индекса нет - создаст новый при первой индексации
|
||||
```
|
||||
|
||||
#### Смена конкретной модели
|
||||
|
||||
1. Остановить сервис
|
||||
2. Обновить зависимости (`pip install -U sentence-transformers pylate`)
|
||||
3. Изменить `model_name` в `MuveraWrapper` или `MuveraPylateWrapper`
|
||||
4. Удалить старый индекс файл
|
||||
5. Запустить переиндексацию
|
||||
|
||||
### Резервное копирование
|
||||
|
||||
```bash
|
||||
# Создание бэкапа файлов индекса
|
||||
cp /dump/discours*.pkl.gz /backup/
|
||||
|
||||
# Восстановление из бэкапа
|
||||
cp /backup/discours*.pkl.gz /dump/
|
||||
|
||||
# Или использовать dokku storage
|
||||
dokku storage:mount core /host/path:/dump
|
||||
```
|
||||
|
||||
## 🔗 Связанные документы
|
||||
|
||||
- [API Documentation](api.md) - GraphQL эндпоинты
|
||||
- [Testing](testing.md) - Тестирование поиска
|
||||
- [Performance](performance.md) - Оптимизация производительности
|
||||
218
docs/security.md
218
docs/security.md
@@ -1,218 +0,0 @@
|
||||
# Security System
|
||||
|
||||
## Overview
|
||||
Система безопасности обеспечивает управление паролями и email адресами пользователей через специализированные GraphQL мутации с использованием Redis для хранения токенов.
|
||||
|
||||
## 🔗 Связанные системы
|
||||
|
||||
- **[Authentication System](auth/README.md)** - Основная система аутентификации
|
||||
- **[RBAC System](rbac-system.md)** - Система ролей и разрешений
|
||||
- **[Redis Schema](redis-schema.md)** - Схема данных Redis
|
||||
|
||||
## GraphQL API
|
||||
|
||||
### Мутации
|
||||
|
||||
#### updateSecurity
|
||||
Универсальная мутация для смены пароля и/или email пользователя с полной валидацией и безопасностью.
|
||||
|
||||
**Parameters:**
|
||||
- `email: String` - Новый email (опционально)
|
||||
- `old_password: String` - Текущий пароль (обязательно для любых изменений)
|
||||
- `new_password: String` - Новый пароль (опционально)
|
||||
|
||||
**Returns:**
|
||||
```typescript
|
||||
type SecurityUpdateResult {
|
||||
success: Boolean!
|
||||
error: String
|
||||
author: Author
|
||||
}
|
||||
```
|
||||
|
||||
**Примеры использования:**
|
||||
|
||||
```graphql
|
||||
# Смена пароля
|
||||
mutation {
|
||||
updateSecurity(
|
||||
old_password: "current123"
|
||||
new_password: "newPassword456"
|
||||
) {
|
||||
success
|
||||
error
|
||||
author {
|
||||
id
|
||||
name
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Смена email
|
||||
mutation {
|
||||
updateSecurity(
|
||||
email: "newemail@example.com"
|
||||
old_password: "current123"
|
||||
) {
|
||||
success
|
||||
error
|
||||
author {
|
||||
id
|
||||
name
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Одновременная смена пароля и email
|
||||
mutation {
|
||||
updateSecurity(
|
||||
email: "newemail@example.com"
|
||||
old_password: "current123"
|
||||
new_password: "newPassword456"
|
||||
) {
|
||||
success
|
||||
error
|
||||
author {
|
||||
id
|
||||
name
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### confirmEmailChange
|
||||
Подтверждение смены email по токену, полученному на новый email адрес.
|
||||
|
||||
**Parameters:**
|
||||
- `token: String!` - Токен подтверждения
|
||||
|
||||
**Returns:** `SecurityUpdateResult`
|
||||
|
||||
#### cancelEmailChange
|
||||
Отмена процесса смены email.
|
||||
|
||||
**Returns:** `SecurityUpdateResult`
|
||||
|
||||
### Валидация и Ошибки
|
||||
|
||||
```typescript
|
||||
const ERRORS = {
|
||||
NOT_AUTHENTICATED: "User not authenticated",
|
||||
INCORRECT_OLD_PASSWORD: "incorrect old password",
|
||||
PASSWORDS_NOT_MATCH: "New passwords do not match",
|
||||
EMAIL_ALREADY_EXISTS: "email already exists",
|
||||
INVALID_EMAIL: "Invalid email format",
|
||||
WEAK_PASSWORD: "Password too weak",
|
||||
SAME_PASSWORD: "New password must be different from current",
|
||||
VALIDATION_ERROR: "Validation failed",
|
||||
INVALID_TOKEN: "Invalid token",
|
||||
TOKEN_EXPIRED: "Token expired",
|
||||
NO_PENDING_EMAIL: "No pending email change"
|
||||
}
|
||||
```
|
||||
|
||||
## Логика смены email
|
||||
|
||||
1. **Инициация смены:**
|
||||
- Пользователь вызывает `updateSecurity` с новым email
|
||||
- Генерируется токен подтверждения `token_urlsafe(32)`
|
||||
- Данные смены email сохраняются в Redis с ключом `email_change:{user_id}`
|
||||
- Устанавливается автоматическое истечение токена (1 час)
|
||||
- Отправляется письмо на новый email с токеном
|
||||
|
||||
2. **Подтверждение:**
|
||||
- Пользователь получает письмо с токеном
|
||||
- Вызывает `confirmEmailChange` с токеном
|
||||
- Система проверяет токен и срок действия в Redis
|
||||
- Если токен валиден, email обновляется в базе данных
|
||||
- Данные смены email удаляются из Redis
|
||||
|
||||
3. **Отмена:**
|
||||
- Пользователь может отменить смену через `cancelEmailChange`
|
||||
- Данные смены email удаляются из Redis
|
||||
|
||||
## Redis Storage
|
||||
|
||||
### Хранение токенов смены email
|
||||
```json
|
||||
{
|
||||
"key": "email_change:{user_id}",
|
||||
"value": {
|
||||
"user_id": 123,
|
||||
"old_email": "old@example.com",
|
||||
"new_email": "new@example.com",
|
||||
"token": "random_token_32_chars",
|
||||
"expires_at": 1640995200
|
||||
},
|
||||
"ttl": 3600 // 1 час
|
||||
}
|
||||
```
|
||||
|
||||
### Хранение OAuth токенов
|
||||
```json
|
||||
{
|
||||
"key": "oauth_access:{user_id}:{provider}",
|
||||
"value": {
|
||||
"token": "oauth_access_token",
|
||||
"provider": "google",
|
||||
"user_id": 123,
|
||||
"created_at": 1640995200,
|
||||
"expires_in": 3600,
|
||||
"scope": "profile email"
|
||||
},
|
||||
"ttl": 3600 // время из expires_in или 1 час по умолчанию
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "oauth_refresh:{user_id}:{provider}",
|
||||
"value": {
|
||||
"token": "oauth_refresh_token",
|
||||
"provider": "google",
|
||||
"user_id": 123,
|
||||
"created_at": 1640995200
|
||||
},
|
||||
"ttl": 2592000 // 30 дней по умолчанию
|
||||
}
|
||||
```
|
||||
|
||||
### Преимущества Redis хранения
|
||||
- **Автоматическое истечение**: TTL в Redis автоматически удаляет истекшие токены
|
||||
- **Производительность**: Быстрый доступ к данным токенов
|
||||
- **Масштабируемость**: Не нагружает основную базу данных
|
||||
- **Безопасность**: Токены не хранятся в основной БД
|
||||
- **Простота**: Не требует миграции схемы базы данных
|
||||
- **OAuth токены**: Централизованное управление токенами всех OAuth провайдеров
|
||||
|
||||
## Безопасность
|
||||
|
||||
### Требования к паролю
|
||||
- Минимум 8 символов
|
||||
- Не может совпадать с текущим паролем
|
||||
|
||||
### Аутентификация
|
||||
- Все операции требуют валидного токена аутентификации
|
||||
- Старый пароль обязателен для подтверждения личности
|
||||
|
||||
### Валидация email
|
||||
- Проверка формата email через регулярное выражение
|
||||
- Проверка уникальности email в системе
|
||||
- Защита от race conditions при смене email
|
||||
|
||||
### Токены безопасности
|
||||
- Генерация токенов через `secrets.token_urlsafe(32)`
|
||||
- Автоматическое истечение через 1 час
|
||||
- Удаление токенов после использования или отмены
|
||||
|
||||
## Database Schema
|
||||
|
||||
Система не требует изменений в схеме базы данных. Все токены и временные данные хранятся в Redis.
|
||||
|
||||
### Защищенные поля
|
||||
Следующие поля показываются только владельцу аккаунта:
|
||||
- `email`
|
||||
- `password`
|
||||
310
docs/testing.md
310
docs/testing.md
@@ -1,310 +0,0 @@
|
||||
# Покрытие тестами
|
||||
|
||||
Документация по тестированию и измерению покрытия кода в проекте.
|
||||
|
||||
## Обзор
|
||||
|
||||
Проект использует **pytest** для тестирования и **pytest-cov** для измерения покрытия кода. Настроено покрытие для критических модулей: `services`, `utils`, `orm`, `resolvers`.
|
||||
|
||||
### 🎭 E2E тестирование с Playwright
|
||||
|
||||
Проект включает E2E тесты с использованием **Playwright** для тестирования пользовательского интерфейса:
|
||||
- **Browser тесты**: Автоматизация браузера для тестирования админ-панели
|
||||
- **CI/CD совместимость**: Автоматическое переключение между headed/headless режимами
|
||||
- **Переменная окружения**: `PLAYWRIGHT_HEADLESS=true` для CI/CD, `false` для локальной разработки
|
||||
|
||||
### 🎯 Текущий статус тестирования
|
||||
|
||||
- **Всего тестов**: 344 теста
|
||||
- **Проходящих тестов**: 344/344 (100%)
|
||||
- **Mypy статус**: ✅ Без ошибок типизации
|
||||
- **Последнее обновление**: 2025-07-31
|
||||
|
||||
### 🔧 Последние исправления (v0.9.0)
|
||||
|
||||
#### Исправления падающих тестов
|
||||
- **Рекурсивный вызов в `find_author_in_community`**: Исправлен бесконечный рекурсивный вызов
|
||||
- **Отсутствие колонки `shout` в тестовой SQLite**: Временно исключено поле из модели Draft
|
||||
- **Конфликт уникальности slug**: Добавлен уникальный идентификатор для тестов
|
||||
- **Тесты drafts**: Исправлены тесты создания и загрузки черновиков
|
||||
|
||||
#### Исправления ошибок mypy
|
||||
- **auth/jwtcodec.py**: Исправлены несовместимые типы bytes/str
|
||||
- **services/db.py**: Исправлен метод создания таблиц
|
||||
- **resolvers/reader.py**: Исправлен вызов несуществующего метода `search_shouts`
|
||||
|
||||
## Конфигурация покрытия
|
||||
|
||||
### Playwright конфигурация
|
||||
|
||||
#### Переменные окружения
|
||||
```bash
|
||||
# Локальная разработка - headed режим для отладки
|
||||
export PLAYWRIGHT_HEADLESS=false
|
||||
|
||||
# CI/CD - headless режим без XServer
|
||||
export PLAYWRIGHT_HEADLESS=true
|
||||
```
|
||||
|
||||
#### CI/CD настройки
|
||||
```yaml
|
||||
# .gitea/workflows/main.yml
|
||||
- name: Run Tests
|
||||
env:
|
||||
PLAYWRIGHT_HEADLESS: "true"
|
||||
run: |
|
||||
uv run pytest tests/ -v
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: |
|
||||
uv run playwright install --with-deps chromium
|
||||
```
|
||||
|
||||
### pyproject.toml
|
||||
|
||||
```toml
|
||||
[tool.pytest.ini_options]
|
||||
addopts = [
|
||||
"--cov=services,utils,orm,resolvers", # Измерять покрытие для папок
|
||||
"--cov-report=term-missing", # Показывать непокрытые строки
|
||||
"--cov-report=html", # Генерировать HTML отчет
|
||||
"--cov-fail-under=90", # Минимальное покрытие 90%
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["services", "utils", "orm", "resolvers"]
|
||||
omit = [
|
||||
"main.py",
|
||||
"dev.py",
|
||||
"tests/*",
|
||||
"*/test_*.py",
|
||||
"*/__pycache__/*",
|
||||
"*/migrations/*",
|
||||
|
||||
"*/venv/*",
|
||||
"*/.venv/*",
|
||||
"*/env/*",
|
||||
"*/build/*",
|
||||
"*/dist/*",
|
||||
"*/node_modules/*",
|
||||
"*/panel/*",
|
||||
"*/schema/*",
|
||||
"*/auth/*",
|
||||
"*/cache/*",
|
||||
"*/orm/*",
|
||||
"*/resolvers/*",
|
||||
"*/utils/*",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"if self.debug:",
|
||||
"if settings.DEBUG",
|
||||
"raise AssertionError",
|
||||
"raise NotImplementedError",
|
||||
"if 0:",
|
||||
"if __name__ == .__main__.:",
|
||||
"class .*\\bProtocol\\):",
|
||||
"@(abc\\.)?abstractmethod",
|
||||
]
|
||||
```
|
||||
|
||||
## Текущие метрики покрытия
|
||||
|
||||
### Критические модули
|
||||
|
||||
| Модуль | Покрытие | Статус |
|
||||
|--------|----------|--------|
|
||||
| `services/db.py` | 93% | ✅ Высокое |
|
||||
| `services/redis.py` | 95% | ✅ Высокое |
|
||||
| `utils/` | Базовое | ✅ Покрыт |
|
||||
| `orm/` | Базовое | ✅ Покрыт |
|
||||
| `resolvers/` | Базовое | ✅ Покрыт |
|
||||
| `auth/` | Базовое | ✅ Покрыт |
|
||||
|
||||
### Общая статистика
|
||||
|
||||
- **Всего тестов**: 344 теста (включая 257 тестов покрытия)
|
||||
- **Проходящих тестов**: 344/344 (100%)
|
||||
- **Критические модули**: 90%+ покрытие
|
||||
- **HTML отчеты**: Генерируются автоматически
|
||||
- **Mypy статус**: ✅ Без ошибок типизации
|
||||
|
||||
## Запуск тестов
|
||||
|
||||
### Все тесты покрытия
|
||||
|
||||
```bash
|
||||
# Активировать виртуальное окружение
|
||||
source .venv/bin/activate
|
||||
|
||||
# Запустить все тесты покрытия
|
||||
python3 -m pytest tests/test_*_coverage.py -v --cov=services,utils,orm,resolvers --cov-report=term-missing
|
||||
```
|
||||
|
||||
### Только критические модули
|
||||
|
||||
```bash
|
||||
# Тесты для services/db.py и services/redis.py
|
||||
python3 -m pytest tests/test_db_coverage.py tests/test_redis_coverage.py -v --cov=services --cov-report=term-missing
|
||||
```
|
||||
|
||||
### С HTML отчетом
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/test_*_coverage.py -v --cov=services,utils,orm,resolvers --cov-report=html
|
||||
# Отчет будет создан в папке htmlcov/
|
||||
```
|
||||
|
||||
## Структура тестов
|
||||
|
||||
### Тесты покрытия
|
||||
|
||||
```
|
||||
tests/
|
||||
├── test_db_coverage.py # 113 тестов для services/db.py
|
||||
├── test_redis_coverage.py # 113 тестов для services/redis.py
|
||||
├── test_utils_coverage.py # Тесты для модулей utils
|
||||
├── test_orm_coverage.py # Тесты для ORM моделей
|
||||
├── test_resolvers_coverage.py # Тесты для GraphQL резолверов
|
||||
├── test_auth_coverage.py # Тесты для модулей аутентификации
|
||||
├── test_shouts.py # Существующие тесты (включены в покрытие)
|
||||
└── test_drafts.py # Существующие тесты (включены в покрытие)
|
||||
```
|
||||
|
||||
### Принципы тестирования
|
||||
|
||||
#### DRY (Don't Repeat Yourself)
|
||||
- Переиспользование `MockInfo` и других утилит между тестами
|
||||
- Общие фикстуры для моков GraphQL объектов
|
||||
- Единообразные паттерны тестирования
|
||||
|
||||
#### Изоляция тестов
|
||||
- Каждый тест независим
|
||||
- Использование моков для внешних зависимостей
|
||||
- Очистка состояния между тестами
|
||||
|
||||
#### Покрытие edge cases
|
||||
- Тестирование исключений и ошибок
|
||||
- Проверка граничных условий
|
||||
- Тестирование асинхронных функций
|
||||
|
||||
## Лучшие практики
|
||||
|
||||
### Моки и патчи
|
||||
|
||||
```python
|
||||
from unittest.mock import Mock, patch, AsyncMock
|
||||
|
||||
# Мок для GraphQL info объекта
|
||||
class MockInfo:
|
||||
def __init__(self, author_id: int = None, requested_fields: list[str] = None):
|
||||
self.context = {
|
||||
"request": None,
|
||||
"author": {"id": author_id, "name": "Test User"} if author_id else None,
|
||||
"roles": ["reader", "author"] if author_id else [],
|
||||
"is_admin": False,
|
||||
}
|
||||
self.field_nodes = [MockFieldNode(requested_fields or [])]
|
||||
|
||||
```
|
||||
|
||||
### Асинхронные тесты
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_function():
|
||||
# Тест асинхронной функции
|
||||
result = await some_async_function()
|
||||
assert result is not None
|
||||
```
|
||||
|
||||
### Покрытие исключений
|
||||
|
||||
```python
|
||||
def test_exception_handling():
|
||||
with pytest.raises(ValueError):
|
||||
function_that_raises_value_error()
|
||||
```
|
||||
|
||||
## Мониторинг покрытия
|
||||
|
||||
### Автоматические проверки
|
||||
|
||||
- **CI/CD**: Покрытие проверяется автоматически
|
||||
- **Порог покрытия**: 90% для критических модулей
|
||||
- **HTML отчеты**: Генерируются для анализа
|
||||
|
||||
### Анализ отчетов
|
||||
|
||||
```bash
|
||||
# Просмотр HTML отчета
|
||||
open htmlcov/index.html
|
||||
|
||||
# Просмотр консольного отчета
|
||||
python3 -m pytest --cov=services --cov-report=term-missing
|
||||
```
|
||||
|
||||
### Непокрытые строки
|
||||
|
||||
Если покрытие ниже 90%, отчет покажет непокрытые строки:
|
||||
|
||||
```
|
||||
Name Stmts Miss Cover Missing
|
||||
---------------------------------------------------------
|
||||
services/db.py 128 9 93% 67-68, 105-110, 222
|
||||
services/redis.py 186 9 95% 9, 67-70, 219-221, 275
|
||||
```
|
||||
|
||||
## Добавление новых тестов
|
||||
|
||||
### Для новых модулей
|
||||
|
||||
1. Создать файл `tests/test_<module>_coverage.py`
|
||||
2. Импортировать модуль для покрытия
|
||||
3. Добавить тесты для всех функций и классов
|
||||
4. Проверить покрытие: `python3 -m pytest tests/test_<module>_coverage.py --cov=<module>`
|
||||
|
||||
### Для существующих модулей
|
||||
|
||||
1. Найти непокрытые строки в отчете
|
||||
2. Добавить тесты для недостающих случаев
|
||||
3. Проверить, что покрытие увеличилось
|
||||
4. Обновить документацию при необходимости
|
||||
|
||||
## Интеграция с существующими тестами
|
||||
|
||||
### Включение существующих тестов
|
||||
|
||||
```python
|
||||
# tests/test_shouts.py и tests/test_drafts.py включены в покрытие resolvers
|
||||
# Они используют те же MockInfo и фикстуры
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_shout(db_session):
|
||||
info = MockInfo(requested_fields=["id", "title", "body", "slug"])
|
||||
result = await get_shout(None, info, slug="nonexistent-slug")
|
||||
assert result is None
|
||||
```
|
||||
|
||||
### Совместимость
|
||||
|
||||
- Все тесты используют одинаковые фикстуры
|
||||
- Моки совместимы между тестами
|
||||
- Принцип DRY применяется везде
|
||||
|
||||
## Заключение
|
||||
|
||||
Система тестирования обеспечивает:
|
||||
|
||||
- ✅ **Высокое покрытие** критических модулей (90%+)
|
||||
- ✅ **Автоматическую проверку** в CI/CD
|
||||
- ✅ **Детальные отчеты** для анализа
|
||||
- ✅ **Легкость добавления** новых тестов
|
||||
- ✅ **Совместимость** с существующими тестами
|
||||
|
||||
Регулярно проверяйте покрытие и добавляйте тесты для новых функций!
|
||||
11
env.d.ts
vendored
11
env.d.ts
vendored
@@ -1,11 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
20
index.html
20
index.html
@@ -1,20 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Admin Panel">
|
||||
<title>Admin Panel</title>
|
||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||
<meta name="theme-color" content="#228be6">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/panel/index.tsx"></script>
|
||||
<noscript>
|
||||
<div style="text-align: center; padding: 20px;">
|
||||
Для работы приложения необходим JavaScript
|
||||
</div>
|
||||
</noscript>
|
||||
</body>
|
||||
</html>
|
||||
365
main.py
365
main.py
@@ -1,337 +1,138 @@
|
||||
import asyncio
|
||||
import os
|
||||
import traceback
|
||||
from contextlib import asynccontextmanager
|
||||
import sys
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from os.path import exists
|
||||
|
||||
from ariadne import load_schema_from_path, make_executable_schema
|
||||
from ariadne.asgi import GraphQL
|
||||
from graphql import GraphQLError
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import FileResponse, JSONResponse, Response
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.routing import Route
|
||||
|
||||
from auth.handler import EnhancedGraphQLHTTPHandler
|
||||
from auth.middleware import AuthMiddleware, auth_middleware
|
||||
from auth.oauth import oauth_callback_http, oauth_login_http
|
||||
from cache.precache import precache_data
|
||||
from cache.revalidator import revalidation_manager
|
||||
from rbac import initialize_rbac
|
||||
from services.search import check_search_service, initialize_search_index, search_service
|
||||
from services.exception import ExceptionHandlerMiddleware
|
||||
from services.redis import redis
|
||||
from services.schema import create_all_tables, resolvers
|
||||
#from services.search import search_service
|
||||
from services.search import search_service, initialize_search_index
|
||||
from services.viewed import ViewedStorage
|
||||
from settings import DEV_SERVER_PID_FILE_NAME
|
||||
from storage.redis import redis
|
||||
from storage.schema import create_all_tables, resolvers
|
||||
from utils.exception import ExceptionHandlerMiddleware
|
||||
from utils.logger import custom_error_formatter
|
||||
from utils.logger import root_logger as logger
|
||||
from utils.sentry import start_sentry
|
||||
|
||||
DEVMODE = os.getenv("DOKKU_APP_TYPE", "false").lower() == "false"
|
||||
DIST_DIR = Path(__file__).parent / "dist" # Директория для собранных файлов
|
||||
INDEX_HTML = Path(__file__).parent / "index.html"
|
||||
from services.webhook import WebhookEndpoint, create_webhook_endpoint
|
||||
from settings import DEV_SERVER_PID_FILE_NAME, MODE
|
||||
|
||||
import_module("resolvers")
|
||||
|
||||
schema = make_executable_schema(load_schema_from_path("schema/"), resolvers)
|
||||
|
||||
# Создаем middleware с правильным порядком
|
||||
middleware = [
|
||||
# Начинаем с обработки ошибок
|
||||
Middleware(ExceptionHandlerMiddleware),
|
||||
# CORS должен быть перед другими middleware для корректной обработки preflight-запросов
|
||||
Middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"https://testing.discours.io",
|
||||
"https://testing3.discours.io",
|
||||
"https://v3.discours.io",
|
||||
"https://session-daily.vercel.app",
|
||||
"https://coretest.discours.io",
|
||||
"https://new.discours.io",
|
||||
"https://localhost:3000",
|
||||
],
|
||||
allow_methods=["GET", "POST", "OPTIONS"], # Явно указываем OPTIONS
|
||||
allow_headers=["*"],
|
||||
allow_credentials=True,
|
||||
),
|
||||
# Аутентификация должна быть после CORS
|
||||
Middleware(AuthMiddleware),
|
||||
]
|
||||
|
||||
# Создаем экземпляр GraphQL с улучшенным обработчиком и кастомным форматтером ошибок
|
||||
graphql_app = GraphQL(
|
||||
schema,
|
||||
debug=DEVMODE,
|
||||
http_handler=EnhancedGraphQLHTTPHandler(),
|
||||
error_formatter=custom_error_formatter,
|
||||
)
|
||||
async def start():
|
||||
if MODE == "development":
|
||||
if not exists(DEV_SERVER_PID_FILE_NAME):
|
||||
# pid file management
|
||||
with open(DEV_SERVER_PID_FILE_NAME, "w", encoding="utf-8") as f:
|
||||
f.write(str(os.getpid()))
|
||||
print(f"[main] process started in {MODE} mode")
|
||||
|
||||
async def check_search_service():
|
||||
"""Check if search service is available and log result"""
|
||||
info = await search_service.info()
|
||||
if info.get("status") in ["error", "unavailable"]:
|
||||
print(f"[WARNING] Search service unavailable: {info.get('message', 'unknown reason')}")
|
||||
else:
|
||||
print(f"[INFO] Search service is available: {info}")
|
||||
|
||||
|
||||
# Оборачиваем GraphQL-обработчик для лучшей обработки ошибок
|
||||
|
||||
|
||||
async def graphql_handler(request: Request) -> Response:
|
||||
"""
|
||||
Обработчик GraphQL запросов с поддержкой middleware и обработкой ошибок.
|
||||
|
||||
Выполняет:
|
||||
1. Проверку метода запроса (GET, POST, OPTIONS)
|
||||
2. Обработку GraphQL запроса через ariadne
|
||||
3. Применение middleware для корректной обработки cookie и авторизации
|
||||
4. Обработку исключений и формирование ответа
|
||||
|
||||
Args:
|
||||
request: Starlette Request объект
|
||||
|
||||
Returns:
|
||||
Response: объект ответа (обычно JSONResponse)
|
||||
"""
|
||||
if request.method not in ["GET", "POST", "OPTIONS"]:
|
||||
return JSONResponse({"error": "Method Not Allowed by main.py"}, status_code=405)
|
||||
|
||||
# Проверяем, что все необходимые middleware корректно отработали
|
||||
if not hasattr(request, "scope") or "auth" not in request.scope:
|
||||
logger.warning("[graphql] AuthMiddleware не обработал запрос перед GraphQL обработчиком")
|
||||
|
||||
try:
|
||||
# Обрабатываем запрос через GraphQL приложение
|
||||
result = await graphql_app.handle_request(request)
|
||||
|
||||
# Применяем middleware для установки cookie
|
||||
# Используем метод process_result из auth_middleware для корректной обработки
|
||||
# cookie на основе результатов операций login/logout
|
||||
return await auth_middleware.process_result(request, result)
|
||||
except asyncio.CancelledError:
|
||||
return JSONResponse({"error": "Request cancelled"}, status_code=499)
|
||||
except GraphQLError as e:
|
||||
# Для GraphQL ошибок (например, неавторизованный доступ) не логируем полный трейс
|
||||
logger.warning(f"GraphQL error: {e}")
|
||||
return JSONResponse({"error": str(e)}, status_code=403)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected GraphQL error: {e!s}")
|
||||
logger.debug(f"Unexpected GraphQL error traceback: {traceback.format_exc()}")
|
||||
return JSONResponse({"error": "Internal server error"}, status_code=500)
|
||||
|
||||
|
||||
async def spa_handler(request: Request) -> Response:
|
||||
"""
|
||||
Обработчик для SPA (Single Page Application) fallback.
|
||||
|
||||
Возвращает index.html для всех маршрутов, которые не найдены,
|
||||
чтобы клиентский роутер (SolidJS) мог обработать маршрутизацию.
|
||||
|
||||
Args:
|
||||
request: Starlette Request объект
|
||||
|
||||
Returns:
|
||||
FileResponse: ответ с содержимым index.html
|
||||
"""
|
||||
# Исключаем API маршруты из SPA fallback
|
||||
path = request.url.path
|
||||
if path.startswith(("/graphql", "/oauth", "/assets")):
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
|
||||
index_path = DIST_DIR / "index.html"
|
||||
if index_path.exists():
|
||||
return FileResponse(index_path, media_type="text/html")
|
||||
return JSONResponse({"error": "Admin panel not built"}, status_code=404)
|
||||
|
||||
|
||||
async def health_handler(request: Request) -> Response:
|
||||
"""Health check endpoint with Redis monitoring"""
|
||||
try:
|
||||
redis_info = await redis.get_info()
|
||||
return JSONResponse(
|
||||
{"status": "healthy", "redis": {"connected": redis.is_connected, "ping": await redis.ping(), **redis_info}}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Health check failed: {e}")
|
||||
return JSONResponse({"status": "unhealthy", "error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
async def shutdown() -> None:
|
||||
"""Остановка сервера и освобождение ресурсов"""
|
||||
logger.info("Остановка сервера")
|
||||
|
||||
# Закрываем соединение с Redis
|
||||
await redis.disconnect()
|
||||
|
||||
# Останавливаем поисковый сервис
|
||||
await search_service.close()
|
||||
|
||||
pid_file = Path(DEV_SERVER_PID_FILE_NAME)
|
||||
if pid_file.exists():
|
||||
pid_file.unlink()
|
||||
|
||||
|
||||
async def dev_start() -> None:
|
||||
"""
|
||||
Инициализация сервера в DEV режиме.
|
||||
|
||||
Функция:
|
||||
1. Проверяет наличие DEV режима
|
||||
2. Создает PID-файл для отслеживания процесса
|
||||
3. Логирует информацию о старте сервера
|
||||
|
||||
Используется только при запуске сервера с флагом "dev".
|
||||
"""
|
||||
try:
|
||||
pid_path = Path(DEV_SERVER_PID_FILE_NAME)
|
||||
# Если PID-файл уже существует, проверяем, не запущен ли уже сервер с этим PID
|
||||
if pid_path.exists():
|
||||
try:
|
||||
with pid_path.open(encoding="utf-8") as f:
|
||||
old_pid = int(f.read().strip())
|
||||
# Проверяем, существует ли процесс с таким PID
|
||||
|
||||
try:
|
||||
os.kill(old_pid, 0) # Сигнал 0 только проверяет существование процесса
|
||||
print(f"[warning] DEV server already running with PID {old_pid}")
|
||||
except OSError:
|
||||
print(f"[info] Stale PID file found, previous process {old_pid} not running")
|
||||
except (ValueError, FileNotFoundError):
|
||||
print("[warning] Invalid PID file found, recreating")
|
||||
|
||||
# Создаем или перезаписываем PID-файл
|
||||
with pid_path.open("w", encoding="utf-8") as f:
|
||||
f.write(str(os.getpid()))
|
||||
print(f"[main] process started in DEV mode with PID {os.getpid()}")
|
||||
except Exception as e:
|
||||
logger.error(f"[main] Error during server startup: {e!s}")
|
||||
# Не прерываем запуск сервера из-за ошибки в этой функции
|
||||
print(f"[warning] Error during DEV mode initialization: {e!s}")
|
||||
|
||||
|
||||
async def initialize_search_index_with_data() -> None:
|
||||
"""Инициализация поискового индекса данными из БД"""
|
||||
try:
|
||||
from orm.shout import Shout
|
||||
from storage.db import local_session
|
||||
|
||||
# Получаем все опубликованные шауты из БД
|
||||
with local_session() as session:
|
||||
shouts = session.query(Shout).filter(Shout.published_at.is_not(None)).all()
|
||||
|
||||
if shouts:
|
||||
await initialize_search_index(shouts)
|
||||
print(f"[search] Loaded {len(shouts)} published shouts into search index")
|
||||
else:
|
||||
print("[search] No published shouts found to index")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize search index with data: {e}")
|
||||
|
||||
|
||||
# Глобальная переменная для background tasks
|
||||
background_tasks: list[asyncio.Task] = []
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: Starlette):
|
||||
"""
|
||||
Функция жизненного цикла приложения.
|
||||
|
||||
Обеспечивает:
|
||||
1. Инициализацию всех необходимых сервисов и компонентов
|
||||
2. Предзагрузку кеша данных
|
||||
3. Подключение к Redis и поисковому сервису
|
||||
4. Корректное завершение работы при остановке сервера
|
||||
|
||||
Args:
|
||||
app: экземпляр Starlette приложения
|
||||
|
||||
Yields:
|
||||
None: генератор для управления жизненным циклом
|
||||
"""
|
||||
# indexing DB data
|
||||
# async def indexing():
|
||||
# from services.db import fetch_all_shouts
|
||||
# all_shouts = await fetch_all_shouts()
|
||||
# await initialize_search_index(all_shouts)
|
||||
async def lifespan(_app):
|
||||
try:
|
||||
print("[lifespan] Starting application initialization")
|
||||
create_all_tables()
|
||||
|
||||
# Инициализируем RBAC систему с dependency injection
|
||||
initialize_rbac()
|
||||
|
||||
# Инициализируем Sentry для мониторинга ошибок
|
||||
start_sentry()
|
||||
|
||||
await asyncio.gather(
|
||||
redis.connect(),
|
||||
precache_data(),
|
||||
ViewedStorage.init(),
|
||||
create_webhook_endpoint(),
|
||||
check_search_service(),
|
||||
start(),
|
||||
revalidation_manager.start(),
|
||||
)
|
||||
if DEVMODE:
|
||||
await dev_start()
|
||||
print("[lifespan] Basic initialization complete")
|
||||
|
||||
# Инициализируем поисковый индекс данными из БД
|
||||
print("[lifespan] Initializing search index with existing data...")
|
||||
await initialize_search_index_with_data()
|
||||
print("[lifespan] Search service initialized with Muvera")
|
||||
# Add a delay before starting the intensive search indexing
|
||||
print("[lifespan] Waiting for system stabilization before search indexing...")
|
||||
await asyncio.sleep(10) # 10-second delay to let the system stabilize
|
||||
|
||||
# NOTE: Предзагрузка моделей убрана - ColBERT загружается lazy при первом поиске
|
||||
# BiEncoder модели больше не используются (default=colbert)
|
||||
# Start search indexing as a background task with lower priority
|
||||
asyncio.create_task(initialize_search_index_background())
|
||||
|
||||
yield
|
||||
finally:
|
||||
print("[lifespan] Shutting down application services")
|
||||
|
||||
# Отменяем все background tasks
|
||||
for task in background_tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
|
||||
# Ждем завершения отмены tasks
|
||||
if background_tasks:
|
||||
await asyncio.gather(*background_tasks, return_exceptions=True)
|
||||
|
||||
tasks = [redis.disconnect(), ViewedStorage.stop(), revalidation_manager.stop()]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
print("[lifespan] Shutdown complete")
|
||||
|
||||
# Initialize search index in the background
|
||||
async def initialize_search_index_background():
|
||||
"""Run search indexing as a background task with low priority"""
|
||||
try:
|
||||
print("[search] Starting background search indexing process")
|
||||
from services.db import fetch_all_shouts
|
||||
|
||||
# Get total count first (optional)
|
||||
all_shouts = await fetch_all_shouts()
|
||||
total_count = len(all_shouts) if all_shouts else 0
|
||||
print(f"[search] Fetched {total_count} shouts for background indexing")
|
||||
|
||||
# Start the indexing process with the fetched shouts
|
||||
print("[search] Beginning background search index initialization...")
|
||||
await initialize_search_index(all_shouts)
|
||||
print("[search] Background search index initialization complete")
|
||||
except Exception as e:
|
||||
print(f"[search] Error in background search indexing: {str(e)}")
|
||||
|
||||
# Создаем экземпляр GraphQL
|
||||
graphql_app = GraphQL(schema, debug=True)
|
||||
|
||||
|
||||
# Оборачиваем GraphQL-обработчик для лучшей обработки ошибок
|
||||
async def graphql_handler(request: Request):
|
||||
if request.method not in ["GET", "POST"]:
|
||||
return JSONResponse({"error": "Method Not Allowed"}, status_code=405)
|
||||
|
||||
try:
|
||||
result = await graphql_app.handle_request(request)
|
||||
if isinstance(result, Response):
|
||||
return result
|
||||
return JSONResponse(result)
|
||||
except asyncio.CancelledError:
|
||||
return JSONResponse({"error": "Request cancelled"}, status_code=499)
|
||||
except Exception as e:
|
||||
print(f"GraphQL error: {str(e)}")
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
# Обновляем маршрут в Starlette
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/graphql", graphql_handler, methods=["GET", "POST", "OPTIONS"]),
|
||||
# OAuth маршруты - порядок важен! Более специфичные маршруты должны быть первыми
|
||||
Route("/oauth/{provider}/callback", oauth_callback_http, methods=["GET"]),
|
||||
Route(
|
||||
"/oauth/{provider}/{redirect_uri:path}", oauth_login_http, methods=["GET"]
|
||||
), # Поддержка старого формата фронтенда
|
||||
Route("/oauth/{provider}", oauth_login_http, methods=["GET"]),
|
||||
# Health check endpoint
|
||||
Route("/health", health_handler, methods=["GET"]),
|
||||
# Статические файлы (CSS, JS, изображения)
|
||||
Mount("/assets", app=StaticFiles(directory=str(DIST_DIR / "assets"))),
|
||||
# Корневой маршрут для админ-панели
|
||||
Route("/", spa_handler, methods=["GET"]),
|
||||
# SPA fallback для всех остальных маршрутов
|
||||
Route("/{path:path}", spa_handler, methods=["GET"]),
|
||||
Route("/", graphql_handler, methods=["GET", "POST"]),
|
||||
Route("/new-author", WebhookEndpoint),
|
||||
],
|
||||
middleware=middleware, # Используем единый список middleware
|
||||
lifespan=lifespan,
|
||||
debug=True,
|
||||
)
|
||||
|
||||
if DEVMODE:
|
||||
# Для DEV режима регистрируем дополнительный CORS middleware только для localhost
|
||||
app.add_middleware(ExceptionHandlerMiddleware)
|
||||
if "dev" in sys.argv:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"https://localhost:3000",
|
||||
"https://localhost:3001",
|
||||
"https://localhost:3002",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"http://localhost:3002",
|
||||
],
|
||||
allow_origins=["https://localhost:3000"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
|
||||
93
mypy.ini
93
mypy.ini
@@ -1,93 +0,0 @@
|
||||
[mypy]
|
||||
# Основные настройки
|
||||
python_version = 3.12
|
||||
warn_return_any = False
|
||||
warn_unused_configs = True
|
||||
disallow_untyped_defs = False
|
||||
disallow_incomplete_defs = False
|
||||
no_implicit_optional = False
|
||||
explicit_package_bases = True
|
||||
namespace_packages = True
|
||||
check_untyped_defs = False
|
||||
plugins = sqlalchemy.ext.mypy.plugin
|
||||
# Игнорируем missing imports для внешних библиотек
|
||||
ignore_missing_imports = True
|
||||
|
||||
# Оптимизации производительности
|
||||
cache_dir = .mypy_cache
|
||||
sqlite_cache = True
|
||||
incremental = False
|
||||
show_error_codes = True
|
||||
|
||||
# Исключаем тесты и тяжелые зависимости
|
||||
exclude = ^(tests/.*|.*transformers.*|.*torch.*|.*huggingface.*|.*safetensors.*|.*PIL.*|.*google.*|.*sentence_transformers.*|.*dump/.*|.*node_modules/.*|.*dist/.*)$
|
||||
|
||||
# Настройки для конкретных модулей
|
||||
[mypy-graphql.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-ariadne.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-starlette.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-orjson.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-pytest.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-pydantic.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-granian.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-jwt.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-httpx.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-trafilatura.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-sentry_sdk.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-colorlog.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-google.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-txtai.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-h11.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-hiredis.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-htmldate.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-httpcore.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-courlan.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-certifi.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-charset_normalizer.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-anyio.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-sniffio.*]
|
||||
ignore_missing_imports = True
|
||||
147
nginx.conf.sigil
Normal file
147
nginx.conf.sigil
Normal file
@@ -0,0 +1,147 @@
|
||||
log_format custom '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'origin=$http_origin allow_origin=$allow_origin status=$status '
|
||||
'"$http_referer" "$http_user_agent"';
|
||||
|
||||
{{ $proxy_settings := "proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $http_connection; proxy_set_header Host $http_host; proxy_set_header X-Request-Start $msec;" }}
|
||||
{{ $gzip_settings := "gzip on; gzip_min_length 1100; gzip_buffers 4 32k; gzip_types text/css text/javascript text/xml text/plain text/x-component application/javascript application/x-javascript application/json application/xml application/rss+xml font/truetype application/x-font-ttf font/opentype application/vnd.ms-fontobject image/svg+xml; gzip_vary on; gzip_comp_level 6;" }}
|
||||
|
||||
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=my_cache:10m max_size=1g
|
||||
inactive=60m use_temp_path=off;
|
||||
limit_conn_zone $binary_remote_addr zone=addr:10m;
|
||||
limit_req_zone $binary_remote_addr zone=req_zone:10m rate=20r/s;
|
||||
|
||||
{{ range $port_map := .PROXY_PORT_MAP | split " " }}
|
||||
{{ $port_map_list := $port_map | split ":" }}
|
||||
{{ $scheme := index $port_map_list 0 }}
|
||||
{{ $listen_port := index $port_map_list 1 }}
|
||||
{{ $upstream_port := index $port_map_list 2 }}
|
||||
|
||||
server {
|
||||
{{ if eq $scheme "http" }}
|
||||
listen [::]:{{ $listen_port }};
|
||||
listen {{ $listen_port }};
|
||||
server_name {{ $.NOSSL_SERVER_NAME }};
|
||||
access_log /var/log/nginx/{{ $.APP }}-access.log custom;
|
||||
error_log /var/log/nginx/{{ $.APP }}-error.log;
|
||||
client_max_body_size 100M;
|
||||
|
||||
{{ else if eq $scheme "https" }}
|
||||
listen [::]:{{ $listen_port }} ssl http2;
|
||||
listen {{ $listen_port }} ssl http2;
|
||||
server_name {{ $.NOSSL_SERVER_NAME }};
|
||||
access_log /var/log/nginx/{{ $.APP }}-access.log custom;
|
||||
error_log /var/log/nginx/{{ $.APP }}-error.log;
|
||||
ssl_certificate {{ $.APP_SSL_PATH }}/server.crt;
|
||||
ssl_certificate_key {{ $.APP_SSL_PATH }}/server.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
keepalive_timeout 70;
|
||||
keepalive_requests 500;
|
||||
proxy_read_timeout 3600;
|
||||
limit_conn addr 10000;
|
||||
client_max_body_size 100M;
|
||||
{{ end }}
|
||||
|
||||
|
||||
location / {
|
||||
proxy_pass http://{{ $.APP }}-{{ $upstream_port }};
|
||||
{{ $proxy_settings }}
|
||||
{{ $gzip_settings }}
|
||||
|
||||
# Handle CORS for OPTIONS method
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' $allow_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'POST, GET, OPTIONS';
|
||||
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Max-Age' 1728000;
|
||||
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
# Handle CORS for POST method
|
||||
if ($request_method = 'POST') {
|
||||
add_header 'Access-Control-Allow-Origin' $allow_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'POST, GET, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
}
|
||||
|
||||
# Handle CORS for GET method
|
||||
if ($request_method = 'GET') {
|
||||
add_header 'Access-Control-Allow-Origin' $allow_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'POST, GET, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
}
|
||||
|
||||
proxy_cache my_cache;
|
||||
proxy_cache_revalidate on;
|
||||
proxy_cache_min_uses 2;
|
||||
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
|
||||
proxy_cache_background_update on;
|
||||
proxy_cache_lock on;
|
||||
|
||||
# Connections and request limits increase (bad for DDos)
|
||||
limit_req zone=req_zone burst=10 nodelay;
|
||||
}
|
||||
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
|
||||
proxy_pass http://{{ $.APP }}-{{ $upstream_port }};
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
|
||||
location ~* \.(mp3|wav|ogg|flac|aac|aif|webm)$ {
|
||||
proxy_pass http://{{ $.APP }}-{{ $upstream_port }};
|
||||
if ($request_method = 'GET') {
|
||||
add_header 'Access-Control-Allow-Origin' $allow_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
error_page 400 401 402 403 405 406 407 408 409 410 411 412 413 414 415 416 417 418 420 422 423 424 426 428 429 431 444 449 450 451 /400-error.html;
|
||||
location /400-error.html {
|
||||
root /var/lib/dokku/data/nginx-vhosts/dokku-errors;
|
||||
internal;
|
||||
}
|
||||
|
||||
error_page 404 /404-error.html;
|
||||
location /404-error.html {
|
||||
root /var/lib/dokku/data/nginx-vhosts/dokku-errors;
|
||||
internal;
|
||||
}
|
||||
|
||||
error_page 500 501 503 504 505 506 507 508 509 510 511 /500-error.html;
|
||||
location /500-error.html {
|
||||
root /var/lib/dokku/data/nginx-vhosts/dokku-errors;
|
||||
internal;
|
||||
}
|
||||
|
||||
error_page 502 /502-error.html;
|
||||
location /502-error.html {
|
||||
root /var/lib/dokku/data/nginx-vhosts/dokku-errors;
|
||||
internal;
|
||||
}
|
||||
|
||||
include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf;
|
||||
}
|
||||
{{ end }}
|
||||
|
||||
|
||||
{{ range $upstream_port := $.PROXY_UPSTREAM_PORTS | split " " }}
|
||||
upstream {{ $.APP }}-{{ $upstream_port }} {
|
||||
{{ range $listeners := $.DOKKU_APP_WEB_LISTENERS | split " " }}
|
||||
{{ $listener_list := $listeners | split ":" }}
|
||||
{{ $listener_ip := index $listener_list 0 }}
|
||||
{{ $listener_port := index $listener_list 1 }}
|
||||
server {{ $listener_ip }}:{{ $upstream_port }};
|
||||
{{ end }}
|
||||
}
|
||||
{{ end }}
|
||||
@@ -1,63 +0,0 @@
|
||||
# ORM Models
|
||||
# Re-export models for convenience
|
||||
from orm.author import Author, AuthorBookmark, AuthorFollower, AuthorRating
|
||||
|
||||
from . import (
|
||||
collection,
|
||||
community,
|
||||
draft,
|
||||
invite,
|
||||
notification,
|
||||
rating,
|
||||
reaction,
|
||||
shout,
|
||||
topic,
|
||||
)
|
||||
from .collection import Collection, ShoutCollection
|
||||
from .community import Community, CommunityFollower
|
||||
from .draft import Draft, DraftAuthor, DraftTopic
|
||||
from .invite import Invite
|
||||
from .notification import Notification, NotificationSeen
|
||||
|
||||
# from .rating import Rating # rating.py содержит только константы, не классы
|
||||
from .reaction import REACTION_KINDS, Reaction, ReactionKind
|
||||
from .shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic
|
||||
from .topic import Topic, TopicFollower
|
||||
|
||||
__all__ = [
|
||||
# "Rating", # rating.py содержит только константы, не классы
|
||||
"REACTION_KINDS",
|
||||
# Models
|
||||
"Author",
|
||||
"AuthorBookmark",
|
||||
"AuthorFollower",
|
||||
"AuthorRating",
|
||||
"Collection",
|
||||
"Community",
|
||||
"CommunityFollower",
|
||||
"Draft",
|
||||
"DraftAuthor",
|
||||
"DraftTopic",
|
||||
"Invite",
|
||||
"Notification",
|
||||
"NotificationSeen",
|
||||
"Reaction",
|
||||
"ReactionKind",
|
||||
"Shout",
|
||||
"ShoutAuthor",
|
||||
"ShoutCollection",
|
||||
"ShoutReactionsFollower",
|
||||
"ShoutTopic",
|
||||
"Topic",
|
||||
"TopicFollower",
|
||||
# Modules
|
||||
"collection",
|
||||
"community",
|
||||
"draft",
|
||||
"invite",
|
||||
"notification",
|
||||
"rating",
|
||||
"reaction",
|
||||
"shout",
|
||||
"topic",
|
||||
]
|
||||
405
orm/author.py
405
orm/author.py
@@ -1,302 +1,137 @@
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
PrimaryKeyConstraint,
|
||||
String,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
|
||||
|
||||
from orm.base import BaseModel as Base
|
||||
from utils.password import Password
|
||||
from services.db import Base
|
||||
|
||||
# Общие table_args для всех моделей
|
||||
DEFAULT_TABLE_ARGS = {"extend_existing": True}
|
||||
|
||||
PROTECTED_FIELDS = ["email", "password", "provider_access_token", "provider_refresh_token"]
|
||||
|
||||
|
||||
class Author(Base):
|
||||
"""
|
||||
Расширенная модель автора с функциями аутентификации и авторизации
|
||||
"""
|
||||
|
||||
__tablename__ = "author"
|
||||
__table_args__ = (
|
||||
Index("idx_author_slug", "slug"),
|
||||
Index("idx_author_email", "email"),
|
||||
Index("idx_author_phone", "phone"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
|
||||
# Базовые поля автора
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False, comment="Display name")
|
||||
slug: Mapped[str] = mapped_column(String, unique=True, comment="Author's slug")
|
||||
bio: Mapped[str | None] = mapped_column(String, nullable=True, comment="Bio") # короткое описание
|
||||
about: Mapped[str | None] = mapped_column(
|
||||
String, nullable=True, comment="About"
|
||||
) # длинное форматированное описание
|
||||
pic: Mapped[str | None] = mapped_column(String, nullable=True, comment="Picture")
|
||||
links: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True, comment="Links")
|
||||
|
||||
# OAuth аккаунты - JSON с данными всех провайдеров
|
||||
# Формат: {"google": {"id": "123", "email": "user@gmail.com"}, "github": {"id": "456"}}
|
||||
oauth: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSON, nullable=True, default=dict, comment="OAuth accounts data"
|
||||
)
|
||||
|
||||
# Поля аутентификации
|
||||
email: Mapped[str | None] = mapped_column(String, unique=True, nullable=True, comment="Email")
|
||||
phone: Mapped[str | None] = mapped_column(String, nullable=True, comment="Phone")
|
||||
password: Mapped[str | None] = mapped_column(String, nullable=True, comment="Password hash")
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
phone_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
failed_login_attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
account_locked_until: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
updated_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
last_seen: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
deleted_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
oid: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
|
||||
@property
|
||||
def protected_fields(self) -> list[str]:
|
||||
return PROTECTED_FIELDS
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Проверяет, аутентифицирован ли пользователь"""
|
||||
return self.id is not None
|
||||
|
||||
def verify_password(self, password: str) -> bool:
|
||||
"""Проверяет пароль пользователя"""
|
||||
return Password.verify(password, str(self.password)) if self.password else False
|
||||
|
||||
def set_password(self, password: str) -> None:
|
||||
"""Устанавливает пароль пользователя"""
|
||||
self.password = Password.encode(password)
|
||||
|
||||
def increment_failed_login(self) -> None:
|
||||
"""Увеличивает счетчик неудачных попыток входа"""
|
||||
self.failed_login_attempts += 1
|
||||
if self.failed_login_attempts >= 5:
|
||||
self.account_locked_until = int(time.time()) + 300 # 5 минут
|
||||
|
||||
def reset_failed_login(self) -> None:
|
||||
"""Сбрасывает счетчик неудачных попыток входа"""
|
||||
self.failed_login_attempts = 0
|
||||
self.account_locked_until = None
|
||||
|
||||
def is_locked(self) -> bool:
|
||||
"""Проверяет, заблокирован ли аккаунт"""
|
||||
if not self.account_locked_until:
|
||||
return False
|
||||
return int(time.time()) < self.account_locked_until
|
||||
|
||||
def dict(self, access: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Сериализует объект автора в словарь.
|
||||
|
||||
Args:
|
||||
access: Если True, включает защищенные поля
|
||||
|
||||
Returns:
|
||||
Dict: Словарь с данными автора
|
||||
"""
|
||||
result: Dict[str, Any] = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"slug": self.slug,
|
||||
"bio": self.bio,
|
||||
"about": self.about,
|
||||
"pic": self.pic,
|
||||
"links": self.links,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"last_seen": self.last_seen,
|
||||
"deleted_at": self.deleted_at,
|
||||
"email_verified": self.email_verified,
|
||||
}
|
||||
|
||||
# Добавляем защищенные поля только если запрошен полный доступ
|
||||
if access:
|
||||
result.update({"email": self.email, "phone": self.phone, "oauth": self.oauth})
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def find_by_oauth(cls, provider: str, provider_id: str, session: Session) -> Optional["Author"]:
|
||||
"""
|
||||
Находит автора по OAuth провайдеру и ID
|
||||
|
||||
Args:
|
||||
provider (str): Имя OAuth провайдера (google, github и т.д.)
|
||||
provider_id (str): ID пользователя у провайдера
|
||||
session: Сессия базы данных
|
||||
|
||||
Returns:
|
||||
Author или None: Найденный автор или None если не найден
|
||||
"""
|
||||
# Ищем авторов, у которых есть данный провайдер с данным ID
|
||||
authors = session.query(cls).where(cls.oauth.isnot(None)).all()
|
||||
for author in authors:
|
||||
if author.oauth and provider in author.oauth:
|
||||
oauth_data = author.oauth[provider]
|
||||
if isinstance(oauth_data, dict) and oauth_data.get("id") == provider_id:
|
||||
return author
|
||||
return None
|
||||
|
||||
def set_oauth_account(self, provider: str, provider_id: str, email: str | None = None) -> None:
|
||||
"""
|
||||
Устанавливает OAuth аккаунт для автора
|
||||
|
||||
Args:
|
||||
provider (str): Имя OAuth провайдера (google, github и т.д.)
|
||||
provider_id (str): ID пользователя у провайдера
|
||||
email (Optional[str]): Email от провайдера
|
||||
"""
|
||||
if not self.oauth:
|
||||
self.oauth = {}
|
||||
|
||||
oauth_data: Dict[str, str] = {"id": provider_id}
|
||||
if email:
|
||||
oauth_data["email"] = email
|
||||
|
||||
self.oauth[provider] = oauth_data
|
||||
|
||||
def get_oauth_account(self, provider: str) -> Dict[str, Any] | None:
|
||||
"""
|
||||
Получает OAuth аккаунт провайдера
|
||||
|
||||
Args:
|
||||
provider (str): Имя OAuth провайдера
|
||||
|
||||
Returns:
|
||||
dict или None: Данные OAuth аккаунта или None если не найден
|
||||
"""
|
||||
oauth_data = getattr(self, "oauth", None)
|
||||
if not oauth_data:
|
||||
return None
|
||||
if isinstance(oauth_data, dict):
|
||||
return oauth_data.get(provider)
|
||||
return None
|
||||
|
||||
def remove_oauth_account(self, provider: str):
|
||||
"""
|
||||
Удаляет OAuth аккаунт провайдера
|
||||
|
||||
Args:
|
||||
provider (str): Имя OAuth провайдера
|
||||
"""
|
||||
if self.oauth and provider in self.oauth:
|
||||
del self.oauth[provider]
|
||||
|
||||
def to_dict(self, include_protected: bool = False) -> Dict[str, Any]:
|
||||
"""Конвертирует модель в словарь"""
|
||||
result = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"slug": self.slug,
|
||||
"bio": self.bio,
|
||||
"about": self.about,
|
||||
"pic": self.pic,
|
||||
"links": self.links,
|
||||
"oauth": self.oauth,
|
||||
"email_verified": self.email_verified,
|
||||
"phone_verified": self.phone_verified,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"last_seen": self.last_seen,
|
||||
"deleted_at": self.deleted_at,
|
||||
"oid": self.oid,
|
||||
}
|
||||
|
||||
if include_protected:
|
||||
result.update(
|
||||
{
|
||||
"email": self.email,
|
||||
"phone": self.phone,
|
||||
"failed_login_attempts": self.failed_login_attempts,
|
||||
"account_locked_until": self.account_locked_until,
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Author(id={self.id}, slug='{self.slug}', email='{self.email}')>"
|
||||
|
||||
|
||||
class AuthorFollower(Base):
|
||||
"""
|
||||
Связь подписки между авторами.
|
||||
"""
|
||||
|
||||
__tablename__ = "author_follower"
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("follower", "following"),
|
||||
Index("idx_author_follower_follower", "follower"),
|
||||
Index("idx_author_follower_following", "following"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
|
||||
follower: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False)
|
||||
following: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False)
|
||||
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AuthorFollower(follower={self.follower}, following={self.following})>"
|
||||
|
||||
|
||||
class AuthorBookmark(Base):
|
||||
"""
|
||||
Закладки автора.
|
||||
"""
|
||||
|
||||
__tablename__ = "author_bookmark"
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("author", "shout"),
|
||||
Index("idx_author_bookmark_author", "author"),
|
||||
Index("idx_author_bookmark_shout", "shout"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
|
||||
author: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False)
|
||||
shout: Mapped[int] = mapped_column(Integer, ForeignKey("shout.id"), nullable=False)
|
||||
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AuthorBookmark(author={self.author}, shout={self.shout})>"
|
||||
# from sqlalchemy_utils import TSVectorType
|
||||
|
||||
|
||||
class AuthorRating(Base):
|
||||
"""
|
||||
Рейтинг автора.
|
||||
Рейтинг автора от другого автора.
|
||||
|
||||
Attributes:
|
||||
rater (int): ID оценивающего автора
|
||||
author (int): ID оцениваемого автора
|
||||
plus (bool): Положительная/отрицательная оценка
|
||||
"""
|
||||
|
||||
__tablename__ = "author_rating"
|
||||
|
||||
id = None # type: ignore
|
||||
rater = Column(ForeignKey("author.id"), primary_key=True)
|
||||
author = Column(ForeignKey("author.id"), primary_key=True)
|
||||
plus = Column(Boolean)
|
||||
|
||||
# Определяем индексы
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("author", "rater"),
|
||||
# Индекс для быстрого поиска всех оценок конкретного автора
|
||||
Index("idx_author_rating_author", "author"),
|
||||
# Индекс для быстрого поиска всех оценок, оставленных конкретным автором
|
||||
Index("idx_author_rating_rater", "rater"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
|
||||
author: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False)
|
||||
rater: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False)
|
||||
plus: Mapped[bool] = mapped_column(Boolean, nullable=True)
|
||||
rating: Mapped[int] = mapped_column(Integer, nullable=False, comment="Rating value")
|
||||
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AuthorRating(author={self.author}, rater={self.rater}, rating={self.rating})>"
|
||||
class AuthorFollower(Base):
|
||||
"""
|
||||
Подписка одного автора на другого.
|
||||
|
||||
Attributes:
|
||||
follower (int): ID подписчика
|
||||
author (int): ID автора, на которого подписываются
|
||||
created_at (int): Время создания подписки
|
||||
auto (bool): Признак автоматической подписки
|
||||
"""
|
||||
|
||||
__tablename__ = "author_follower"
|
||||
|
||||
id = None # type: ignore
|
||||
follower = Column(ForeignKey("author.id"), primary_key=True)
|
||||
author = Column(ForeignKey("author.id"), primary_key=True)
|
||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
auto = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Определяем индексы
|
||||
__table_args__ = (
|
||||
# Индекс для быстрого поиска всех подписчиков автора
|
||||
Index("idx_author_follower_author", "author"),
|
||||
# Индекс для быстрого поиска всех авторов, на которых подписан конкретный автор
|
||||
Index("idx_author_follower_follower", "follower"),
|
||||
)
|
||||
|
||||
|
||||
class AuthorBookmark(Base):
|
||||
"""
|
||||
Закладка автора на публикацию.
|
||||
|
||||
Attributes:
|
||||
author (int): ID автора
|
||||
shout (int): ID публикации
|
||||
"""
|
||||
|
||||
__tablename__ = "author_bookmark"
|
||||
|
||||
id = None # type: ignore
|
||||
author = Column(ForeignKey("author.id"), primary_key=True)
|
||||
shout = Column(ForeignKey("shout.id"), primary_key=True)
|
||||
|
||||
# Определяем индексы
|
||||
__table_args__ = (
|
||||
# Индекс для быстрого поиска всех закладок автора
|
||||
Index("idx_author_bookmark_author", "author"),
|
||||
# Индекс для быстрого поиска всех авторов, добавивших публикацию в закладки
|
||||
Index("idx_author_bookmark_shout", "shout"),
|
||||
)
|
||||
|
||||
|
||||
class Author(Base):
|
||||
"""
|
||||
Модель автора в системе.
|
||||
|
||||
Attributes:
|
||||
user (str): Идентификатор пользователя в системе авторизации
|
||||
name (str): Отображаемое имя
|
||||
slug (str): Уникальный строковый идентификатор
|
||||
bio (str): Краткая биография/статус
|
||||
about (str): Полное описание
|
||||
pic (str): URL изображения профиля
|
||||
links (dict): Ссылки на социальные сети и сайты
|
||||
created_at (int): Время создания профиля
|
||||
last_seen (int): Время последнего посещения
|
||||
updated_at (int): Время последнего обновления
|
||||
deleted_at (int): Время удаления (если профиль удален)
|
||||
"""
|
||||
|
||||
__tablename__ = "author"
|
||||
|
||||
user = Column(String) # unbounded link with authorizer's User type
|
||||
|
||||
name = Column(String, nullable=True, comment="Display name")
|
||||
slug = Column(String, unique=True, comment="Author's slug")
|
||||
bio = Column(String, nullable=True, comment="Bio") # status description
|
||||
about = Column(String, nullable=True, comment="About") # long and formatted
|
||||
pic = Column(String, nullable=True, comment="Picture")
|
||||
links = Column(JSON, nullable=True, comment="Links")
|
||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
last_seen = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
updated_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
deleted_at = Column(Integer, nullable=True, comment="Deleted at")
|
||||
|
||||
# search_vector = Column(
|
||||
# TSVectorType("name", "slug", "bio", "about", regconfig="pg_catalog.russian")
|
||||
# )
|
||||
|
||||
# Определяем индексы
|
||||
__table_args__ = (
|
||||
# Индекс для быстрого поиска по slug
|
||||
Index("idx_author_slug", "slug"),
|
||||
# Индекс для быстрого поиска по идентификатору пользователя
|
||||
Index("idx_author_user", "user"),
|
||||
# Индекс для фильтрации неудаленных авторов
|
||||
Index("idx_author_deleted_at", "deleted_at", postgresql_where=deleted_at.is_(None)),
|
||||
# Индекс для сортировки по времени создания (для новых авторов)
|
||||
Index("idx_author_created_at", "created_at"),
|
||||
# Индекс для сортировки по времени последнего посещения
|
||||
Index("idx_author_last_seen", "last_seen"),
|
||||
)
|
||||
|
||||
73
orm/base.py
73
orm/base.py
@@ -1,73 +0,0 @@
|
||||
import builtins
|
||||
import logging
|
||||
from typing import Any, Type
|
||||
|
||||
import orjson
|
||||
from sqlalchemy import JSON
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Глобальный реестр моделей
|
||||
REGISTRY: dict[str, Type[Any]] = {}
|
||||
|
||||
# Список полей для фильтрации при сериализации
|
||||
FILTERED_FIELDS: list[str] = []
|
||||
|
||||
|
||||
class BaseModel(DeclarativeBase):
|
||||
"""
|
||||
Базовая модель с методами сериализации и обновления
|
||||
"""
|
||||
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
REGISTRY[cls.__name__] = cls
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
def dict(self) -> builtins.dict[str, Any]:
|
||||
"""
|
||||
Конвертирует ORM объект в словарь.
|
||||
|
||||
Пропускает атрибуты, которые отсутствуют в объекте, но присутствуют в колонках таблицы.
|
||||
Преобразует JSON поля в словари.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Словарь с атрибутами объекта
|
||||
"""
|
||||
column_names = filter(lambda x: x not in FILTERED_FIELDS, self.__table__.columns.keys())
|
||||
data: builtins.dict[str, Any] = {}
|
||||
# logger.debug(f"Converting object to dictionary {'with access' if access else 'without access'}")
|
||||
try:
|
||||
for column_name in column_names:
|
||||
try:
|
||||
# Проверяем, существует ли атрибут в объекте
|
||||
if hasattr(self, column_name):
|
||||
value = getattr(self, column_name)
|
||||
# Проверяем, является ли значение JSON и декодируем его при необходимости
|
||||
if isinstance(value, str | bytes) and isinstance(
|
||||
self.__table__.columns[column_name].type, JSON
|
||||
):
|
||||
try:
|
||||
data[column_name] = orjson.loads(value)
|
||||
except (TypeError, orjson.JSONDecodeError) as e:
|
||||
logger.warning(f"Error decoding JSON for column '{column_name}': {e}")
|
||||
data[column_name] = value
|
||||
else:
|
||||
data[column_name] = value
|
||||
else:
|
||||
# Пропускаем атрибут, если его нет в объекте (может быть добавлен после миграции)
|
||||
logger.debug(f"Skipping missing attribute '{column_name}' for {self.__class__.__name__}")
|
||||
except AttributeError as e:
|
||||
logger.warning(f"Attribute error for column '{column_name}': {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error occurred while converting object to dictionary {e}")
|
||||
return data
|
||||
|
||||
def update(self, values: builtins.dict[str, Any]) -> None:
|
||||
for key, value in values.items():
|
||||
if hasattr(self, key):
|
||||
setattr(self, key, value)
|
||||
|
||||
|
||||
# Alias for backward compatibility
|
||||
Base = BaseModel
|
||||
@@ -1,37 +1,25 @@
|
||||
import time
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, Integer, PrimaryKeyConstraint, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String
|
||||
|
||||
from orm.base import BaseModel as Base
|
||||
|
||||
|
||||
class Collection(Base):
|
||||
__tablename__ = "collection"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
slug: Mapped[str] = mapped_column(String, unique=True)
|
||||
title: Mapped[str] = mapped_column(String, nullable=False, comment="Title")
|
||||
body: Mapped[str | None] = mapped_column(String, nullable=True, comment="Body")
|
||||
pic: Mapped[str | None] = mapped_column(String, nullable=True, comment="Picture")
|
||||
created_at: Mapped[int] = mapped_column(Integer, default=lambda: int(time.time()))
|
||||
created_by: Mapped[int] = mapped_column(ForeignKey("author.id"), comment="Created By")
|
||||
published_at: Mapped[int] = mapped_column(Integer, default=lambda: int(time.time()))
|
||||
|
||||
created_by_author = relationship("Author", foreign_keys=[created_by])
|
||||
from services.db import Base
|
||||
|
||||
|
||||
class ShoutCollection(Base):
|
||||
__tablename__ = "shout_collection"
|
||||
|
||||
shout: Mapped[int] = mapped_column(ForeignKey("shout.id"))
|
||||
collection: Mapped[int] = mapped_column(ForeignKey("collection.id"))
|
||||
created_at: Mapped[int] = mapped_column(Integer, default=lambda: int(time.time()))
|
||||
created_by: Mapped[int] = mapped_column(ForeignKey("author.id"), comment="Created By")
|
||||
id = None # type: ignore
|
||||
shout = Column(ForeignKey("shout.id"), primary_key=True)
|
||||
collection = Column(ForeignKey("collection.id"), primary_key=True)
|
||||
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint(shout, collection),
|
||||
Index("idx_shout_collection_shout", "shout"),
|
||||
Index("idx_shout_collection_collection", "collection"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
|
||||
class Collection(Base):
|
||||
__tablename__ = "collection"
|
||||
|
||||
slug = Column(String, unique=True)
|
||||
title = Column(String, nullable=False, comment="Title")
|
||||
body = Column(String, nullable=True, comment="Body")
|
||||
pic = Column(String, nullable=True, comment="Picture")
|
||||
created_at = Column(Integer, default=lambda: int(time.time()))
|
||||
created_by = Column(ForeignKey("author.id"), comment="Created By")
|
||||
published_at = Column(Integer, default=lambda: int(time.time()))
|
||||
|
||||
742
orm/community.py
742
orm/community.py
@@ -1,726 +1,106 @@
|
||||
import asyncio
|
||||
import enum
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
PrimaryKeyConstraint,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
distinct,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String, Text, distinct, func
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from orm.author import Author
|
||||
from orm.base import BaseModel
|
||||
from rbac.interface import get_rbac_operations
|
||||
from storage.db import local_session
|
||||
|
||||
# Словарь названий ролей
|
||||
role_names = {
|
||||
"reader": "Читатель",
|
||||
"author": "Автор",
|
||||
"artist": "Художник",
|
||||
"expert": "Эксперт",
|
||||
"editor": "Редактор",
|
||||
"admin": "Администратор",
|
||||
}
|
||||
|
||||
# Словарь описаний ролей
|
||||
role_descriptions = {
|
||||
"reader": "Может читать и комментировать",
|
||||
"author": "Может создавать публикации",
|
||||
"artist": "Может быть credited artist",
|
||||
"expert": "Может добавлять доказательства",
|
||||
"editor": "Может модерировать контент",
|
||||
"admin": "Полные права",
|
||||
}
|
||||
from services.db import Base
|
||||
|
||||
|
||||
class CommunityFollower(BaseModel):
|
||||
"""
|
||||
Простая подписка пользователя на сообщество.
|
||||
class CommunityRole(enum.Enum):
|
||||
READER = "reader" # can read and comment
|
||||
AUTHOR = "author" # + can vote and invite collaborators
|
||||
ARTIST = "artist" # + can be credited as featured artist
|
||||
EXPERT = "expert" # + can add proof or disproof to shouts, can manage topics
|
||||
EDITOR = "editor" # + can manage topics, comments and community settings
|
||||
|
||||
Использует обычный id как первичный ключ для простоты и производительности.
|
||||
Уникальность обеспечивается индексом по (community, follower).
|
||||
"""
|
||||
|
||||
__tablename__ = "community_follower"
|
||||
|
||||
community: Mapped[int] = mapped_column(Integer, ForeignKey("community.id"), nullable=False, index=True)
|
||||
follower: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False, index=True)
|
||||
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
|
||||
# Уникальность по паре сообщество-подписчик
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("community", "follower"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
|
||||
def __init__(self, community: int, follower: int) -> None:
|
||||
self.community = community
|
||||
self.follower = follower
|
||||
@classmethod
|
||||
def as_string_array(cls, roles):
|
||||
return [role.value for role in roles]
|
||||
|
||||
|
||||
class Community(BaseModel):
|
||||
class CommunityFollower(Base):
|
||||
__tablename__ = "community_author"
|
||||
|
||||
author = Column(ForeignKey("author.id"), primary_key=True)
|
||||
community = Column(ForeignKey("community.id"), primary_key=True)
|
||||
joined_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
roles = Column(Text, nullable=True, comment="Roles (comma-separated)")
|
||||
|
||||
def set_roles(self, roles):
|
||||
self.roles = CommunityRole.as_string_array(roles)
|
||||
|
||||
def get_roles(self):
|
||||
return [CommunityRole(role) for role in self.roles]
|
||||
|
||||
|
||||
class Community(Base):
|
||||
__tablename__ = "community"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String, nullable=False, unique=True)
|
||||
desc: Mapped[str] = mapped_column(String, nullable=False, default="")
|
||||
pic: Mapped[str | None] = mapped_column(String, nullable=False, default="")
|
||||
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
created_by: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
settings: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
deleted_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
private: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
name = Column(String, nullable=False)
|
||||
slug = Column(String, nullable=False, unique=True)
|
||||
desc = Column(String, nullable=False, default="")
|
||||
pic = Column(String, nullable=False, default="")
|
||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
created_by = Column(ForeignKey("author.id"), nullable=False)
|
||||
|
||||
@hybrid_property
|
||||
def stat(self):
|
||||
return CommunityStats(self)
|
||||
|
||||
def is_followed_by(self, author_id: int) -> bool:
|
||||
"""Проверяет, подписан ли пользователь на сообщество"""
|
||||
with local_session() as session:
|
||||
follower = (
|
||||
session.query(CommunityFollower)
|
||||
.where(CommunityFollower.community == self.id, CommunityFollower.follower == author_id)
|
||||
.first()
|
||||
)
|
||||
return follower is not None
|
||||
@property
|
||||
def role_list(self):
|
||||
return self.roles.split(",") if self.roles else []
|
||||
|
||||
def get_user_roles(self, user_id: int) -> list[str]:
|
||||
"""
|
||||
Получает роли пользователя в данном сообществе через CommunityAuthor
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Список ролей пользователя в сообществе
|
||||
"""
|
||||
with local_session() as session:
|
||||
community_author = (
|
||||
session.query(CommunityAuthor)
|
||||
.where(CommunityAuthor.community_id == self.id, CommunityAuthor.author_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
return community_author.role_list if community_author else []
|
||||
|
||||
def has_user_role(self, user_id: int, role_id: str) -> bool:
|
||||
"""
|
||||
Проверяет, есть ли у пользователя указанная роль в этом сообществе
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
role_id: ID роли
|
||||
|
||||
Returns:
|
||||
True если роль есть, False если нет
|
||||
"""
|
||||
user_roles = self.get_user_roles(user_id)
|
||||
return role_id in user_roles
|
||||
|
||||
def add_user_role(self, user_id: int, role: str) -> None:
|
||||
"""
|
||||
Добавляет роль пользователю в сообществе
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
role: Название роли
|
||||
"""
|
||||
with local_session() as session:
|
||||
# Ищем существующую запись
|
||||
community_author = (
|
||||
session.query(CommunityAuthor)
|
||||
.where(CommunityAuthor.community_id == self.id, CommunityAuthor.author_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if community_author:
|
||||
# Добавляем роль к существующей записи
|
||||
community_author.add_role(role)
|
||||
else:
|
||||
# Создаем новую запись
|
||||
community_author = CommunityAuthor(community_id=self.id, author_id=user_id, roles=role)
|
||||
session.add(community_author)
|
||||
|
||||
session.commit()
|
||||
|
||||
def remove_user_role(self, user_id: int, role: str) -> None:
|
||||
"""
|
||||
Удаляет роль у пользователя в сообществе
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
role: Название роли
|
||||
"""
|
||||
with local_session() as session:
|
||||
community_author = (
|
||||
session.query(CommunityAuthor)
|
||||
.where(CommunityAuthor.community_id == self.id, CommunityAuthor.author_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if community_author:
|
||||
community_author.remove_role(role)
|
||||
|
||||
# Если ролей не осталось, удаляем запись
|
||||
if not community_author.role_list:
|
||||
session.delete(community_author)
|
||||
|
||||
session.commit()
|
||||
|
||||
def set_user_roles(self, user_id: int, roles: list[str]) -> None:
|
||||
"""
|
||||
Устанавливает полный список ролей пользователя в сообществе
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
roles: Список ролей для установки
|
||||
"""
|
||||
with local_session() as session:
|
||||
# Ищем существующую запись
|
||||
community_author = (
|
||||
session.query(CommunityAuthor)
|
||||
.where(CommunityAuthor.community_id == self.id, CommunityAuthor.author_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if community_author:
|
||||
if roles:
|
||||
# Обновляем роли
|
||||
community_author.set_roles(roles)
|
||||
else:
|
||||
# Если ролей нет, удаляем запись
|
||||
session.delete(community_author)
|
||||
elif roles:
|
||||
# Создаем новую запись, если есть роли
|
||||
community_author = CommunityAuthor(community_id=self.id, author_id=user_id)
|
||||
community_author.set_roles(roles)
|
||||
session.add(community_author)
|
||||
|
||||
session.commit()
|
||||
|
||||
def get_community_members(self, with_roles: bool = False) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Получает список участников сообщества
|
||||
|
||||
Args:
|
||||
with_roles: Если True, включает информацию о ролях
|
||||
|
||||
Returns:
|
||||
Список участников с информацией о ролях
|
||||
"""
|
||||
with local_session() as session:
|
||||
community_authors = session.query(CommunityAuthor).where(CommunityAuthor.community_id == self.id).all()
|
||||
|
||||
members = []
|
||||
for ca in community_authors:
|
||||
member_info: dict[str, Any] = {
|
||||
"author_id": ca.author_id,
|
||||
"joined_at": ca.joined_at,
|
||||
}
|
||||
|
||||
if with_roles:
|
||||
member_info["roles"] = ca.role_list
|
||||
# Получаем разрешения синхронно
|
||||
try:
|
||||
member_info["permissions"] = asyncio.run(ca.get_permissions())
|
||||
except Exception:
|
||||
# Если не удается получить разрешения асинхронно, используем пустой список
|
||||
member_info["permissions"] = []
|
||||
|
||||
members.append(member_info)
|
||||
|
||||
return members
|
||||
|
||||
def assign_default_roles_to_user(self, user_id: int) -> None:
|
||||
"""
|
||||
Назначает дефолтные роли новому пользователю в сообществе
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
"""
|
||||
default_roles = self.get_default_roles()
|
||||
self.set_user_roles(user_id, default_roles)
|
||||
|
||||
def get_default_roles(self) -> list[str]:
|
||||
"""
|
||||
Получает список дефолтных ролей для новых пользователей в сообществе
|
||||
|
||||
Returns:
|
||||
Список ID ролей, которые назначаются новым пользователям по умолчанию
|
||||
"""
|
||||
if not self.settings:
|
||||
return ["reader", "author"] # По умолчанию базовые роли
|
||||
|
||||
return self.settings.get("default_roles", ["reader", "author"])
|
||||
|
||||
def set_default_roles(self, roles: list[str]) -> None:
|
||||
"""
|
||||
Устанавливает дефолтные роли для новых пользователей в сообществе
|
||||
|
||||
Args:
|
||||
roles: Список ID ролей для назначения по умолчанию
|
||||
"""
|
||||
if not self.settings:
|
||||
self.settings = {}
|
||||
|
||||
self.settings["default_roles"] = roles
|
||||
|
||||
async def initialize_role_permissions(self) -> None:
|
||||
"""
|
||||
Инициализирует права ролей для сообщества из дефолтных настроек.
|
||||
Вызывается при создании нового сообщества.
|
||||
"""
|
||||
rbac_ops = get_rbac_operations()
|
||||
await rbac_ops.initialize_community_permissions(int(self.id))
|
||||
|
||||
def get_available_roles(self) -> list[str]:
|
||||
"""
|
||||
Получает список доступных ролей в сообществе
|
||||
|
||||
Returns:
|
||||
Список ID ролей, которые могут быть назначены в этом сообществе
|
||||
"""
|
||||
if not self.settings:
|
||||
return ["reader", "author", "artist", "expert", "editor", "admin"] # Все стандартные роли
|
||||
|
||||
return self.settings.get("available_roles", ["reader", "author", "artist", "expert", "editor", "admin"])
|
||||
|
||||
def set_available_roles(self, roles: list[str]) -> None:
|
||||
"""
|
||||
Устанавливает список доступных ролей в сообществе
|
||||
|
||||
Args:
|
||||
roles: Список ID ролей, доступных в сообществе
|
||||
"""
|
||||
if not self.settings:
|
||||
self.settings = {}
|
||||
|
||||
self.settings["available_roles"] = roles
|
||||
|
||||
def set_slug(self, slug: str) -> None:
|
||||
"""Устанавливает slug сообщества"""
|
||||
self.update({"slug": slug})
|
||||
|
||||
def get_followers(self):
|
||||
"""
|
||||
Получает список подписчиков сообщества.
|
||||
|
||||
Returns:
|
||||
list: Список ID авторов, подписанных на сообщество
|
||||
"""
|
||||
with local_session() as session:
|
||||
return [
|
||||
follower.id
|
||||
for follower in session.query(Author)
|
||||
.join(CommunityFollower, Author.id == CommunityFollower.follower)
|
||||
.where(CommunityFollower.community == self.id)
|
||||
.all()
|
||||
]
|
||||
|
||||
def add_community_creator(self, author_id: int) -> None:
|
||||
"""
|
||||
Создатель сообщества
|
||||
|
||||
Args:
|
||||
author_id: ID пользователя, которому назначаются права
|
||||
"""
|
||||
with local_session() as session:
|
||||
# Проверяем существование связи
|
||||
existing = CommunityAuthor.find_author_in_community(author_id, self.id, session)
|
||||
|
||||
if not existing:
|
||||
# Создаем нового CommunityAuthor с ролью редактора
|
||||
community_author = CommunityAuthor(community_id=self.id, author_id=author_id, roles="editor")
|
||||
session.add(community_author)
|
||||
session.commit()
|
||||
@role_list.setter
|
||||
def role_list(self, value):
|
||||
self.roles = ",".join(value) if value else None
|
||||
|
||||
|
||||
class CommunityStats:
|
||||
def __init__(self, community) -> None:
|
||||
def __init__(self, community):
|
||||
self.community = community
|
||||
|
||||
@property
|
||||
def shouts(self) -> int:
|
||||
return (
|
||||
self.community.session.query(func.count(1))
|
||||
.select_from(text("shout"))
|
||||
.filter(text("shout.community_id = :community_id"))
|
||||
.params(community_id=self.community.id)
|
||||
.scalar()
|
||||
)
|
||||
def shouts(self):
|
||||
from orm.shout import Shout
|
||||
|
||||
return self.community.session.query(func.count(Shout.id)).filter(Shout.community == self.community.id).scalar()
|
||||
|
||||
@property
|
||||
def followers(self) -> int:
|
||||
def followers(self):
|
||||
return (
|
||||
self.community.session.query(func.count(CommunityFollower.follower))
|
||||
self.community.session.query(func.count(CommunityFollower.author))
|
||||
.filter(CommunityFollower.community == self.community.id)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
@property
|
||||
def authors(self) -> int:
|
||||
def authors(self):
|
||||
from orm.shout import Shout
|
||||
|
||||
# author has a shout with community id and its featured_at is not null
|
||||
return (
|
||||
self.community.session.query(func.count(distinct(Author.id)))
|
||||
.select_from(text("author"))
|
||||
.join(text("shout"), text("author.id IN (SELECT author_id FROM shout_author WHERE shout_id = shout.id)"))
|
||||
.filter(text("shout.community_id = :community_id"), text("shout.featured_at IS NOT NULL"))
|
||||
.params(community_id=self.community.id)
|
||||
.join(Shout)
|
||||
.filter(Shout.community == self.community.id, Shout.featured_at.is_not(None), Author.id.in_(Shout.authors))
|
||||
.scalar()
|
||||
)
|
||||
|
||||
|
||||
class CommunityAuthor(BaseModel):
|
||||
"""
|
||||
Связь автора с сообществом и его ролями.
|
||||
|
||||
Attributes:
|
||||
id: Уникальный ID записи
|
||||
community_id: ID сообщества
|
||||
author_id: ID автора
|
||||
roles: CSV строка с ролями (например: "reader,author,editor")
|
||||
joined_at: Время присоединения к сообществу (unix timestamp)
|
||||
"""
|
||||
|
||||
class CommunityAuthor(Base):
|
||||
__tablename__ = "community_author"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
community_id: Mapped[int] = mapped_column(Integer, ForeignKey("community.id"), nullable=False)
|
||||
author_id: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False)
|
||||
roles: Mapped[str | None] = mapped_column(String, nullable=True, comment="Roles (comma-separated)")
|
||||
joined_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
|
||||
# Уникальность по сообществу и автору
|
||||
__table_args__ = (
|
||||
Index("idx_community_author_community", "community_id"),
|
||||
Index("idx_community_author_author", "author_id"),
|
||||
UniqueConstraint("community_id", "author_id", name="uq_community_author"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
id = Column(Integer, primary_key=True)
|
||||
community_id = Column(Integer, ForeignKey("community.id"))
|
||||
author_id = Column(Integer, ForeignKey("author.id"))
|
||||
roles = Column(Text, nullable=True, comment="Roles (comma-separated)")
|
||||
|
||||
@property
|
||||
def role_list(self) -> list[str]:
|
||||
"""Получает список ролей как список строк"""
|
||||
return [role.strip() for role in self.roles.split(",") if role.strip()] if self.roles else []
|
||||
def role_list(self):
|
||||
return self.roles.split(",") if self.roles else []
|
||||
|
||||
@role_list.setter
|
||||
def role_list(self, value: list[str]) -> None:
|
||||
"""Устанавливает список ролей из списка строк"""
|
||||
self.update({"roles": ",".join(value) if value else None})
|
||||
|
||||
def add_role(self, role: str) -> None:
|
||||
"""
|
||||
Добавляет роль в список ролей.
|
||||
|
||||
Args:
|
||||
role (str): Название роли
|
||||
"""
|
||||
if not self.roles:
|
||||
self.roles = role
|
||||
elif role not in self.role_list:
|
||||
self.roles += f",{role}"
|
||||
|
||||
def remove_role(self, role: str) -> None:
|
||||
"""
|
||||
Удаляет роль из списка ролей.
|
||||
|
||||
Args:
|
||||
role (str): Название роли
|
||||
"""
|
||||
if self.roles and role in self.role_list:
|
||||
roles_list = [r for r in self.role_list if r != role]
|
||||
self.roles = ",".join(roles_list) if roles_list else None
|
||||
|
||||
def has_role(self, role: str) -> bool:
|
||||
"""
|
||||
Проверяет наличие роли.
|
||||
|
||||
Args:
|
||||
role (str): Название роли
|
||||
|
||||
Returns:
|
||||
bool: True, если роль есть, иначе False
|
||||
"""
|
||||
return bool(self.roles and role in self.role_list)
|
||||
|
||||
def set_roles(self, roles: list[str]) -> None:
|
||||
"""
|
||||
Устанавливает роли для CommunityAuthor.
|
||||
|
||||
Args:
|
||||
roles: Список ролей для установки
|
||||
"""
|
||||
# Фильтруем и очищаем роли
|
||||
valid_roles = [role.strip() for role in roles if role and role.strip()]
|
||||
|
||||
# Если список пустой, устанавливаем пустую строку
|
||||
self.roles = ",".join(valid_roles) if valid_roles else ""
|
||||
|
||||
async def get_permissions(self) -> list[str]:
|
||||
"""
|
||||
Получает все разрешения автора на основе его ролей в конкретном сообществе
|
||||
|
||||
Returns:
|
||||
Список разрешений (permissions)
|
||||
"""
|
||||
|
||||
all_permissions = set()
|
||||
rbac_ops = get_rbac_operations()
|
||||
for role in self.role_list:
|
||||
role_perms = await rbac_ops.get_permissions_for_role(role, int(self.community_id))
|
||||
all_permissions.update(role_perms)
|
||||
|
||||
return list(all_permissions)
|
||||
|
||||
def has_permission(self, permission: str) -> bool:
|
||||
"""
|
||||
Проверяет, есть ли у пользователя указанное право
|
||||
|
||||
Args:
|
||||
permission: Право для проверки (например, "community:create")
|
||||
|
||||
Returns:
|
||||
True если право есть, False если нет
|
||||
"""
|
||||
# Проверяем права через синхронную функцию
|
||||
try:
|
||||
# В синхронном контексте не можем использовать await
|
||||
# Используем fallback на проверку ролей
|
||||
return permission in self.role_list
|
||||
except Exception:
|
||||
# TODO: Fallback: проверяем роли (старый способ)
|
||||
return any(permission == role for role in self.role_list)
|
||||
|
||||
def dict(self, access: bool = False) -> dict[str, Any]:
|
||||
"""
|
||||
Сериализует объект в словарь
|
||||
|
||||
Args:
|
||||
access: Если True, включает дополнительную информацию
|
||||
|
||||
Returns:
|
||||
Словарь с данными объекта
|
||||
"""
|
||||
result = {
|
||||
"id": self.id,
|
||||
"community_id": self.community_id,
|
||||
"author_id": self.author_id,
|
||||
"roles": self.role_list,
|
||||
"joined_at": self.joined_at,
|
||||
}
|
||||
|
||||
if access:
|
||||
# Note: permissions должны быть получены заранее через await
|
||||
# Здесь мы не можем использовать await в sync методе
|
||||
result["permissions"] = [] # Placeholder - нужно получить асинхронно
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def get_user_communities_with_roles(cls, author_id: int, session=None) -> list[Dict[str, Any]]:
|
||||
"""
|
||||
Получает все сообщества пользователя с его ролями
|
||||
|
||||
Args:
|
||||
author_id: ID автора
|
||||
session: Сессия БД (опционально)
|
||||
|
||||
Returns:
|
||||
Список словарей с информацией о сообществах и ролях
|
||||
"""
|
||||
if session is None:
|
||||
with local_session() as ssession:
|
||||
community_authors = ssession.query(cls).where(cls.author_id == author_id).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"community_id": ca.community_id,
|
||||
"roles": ca.role_list,
|
||||
"permissions": [], # Нужно получить асинхронно
|
||||
"joined_at": ca.joined_at,
|
||||
}
|
||||
for ca in community_authors
|
||||
]
|
||||
|
||||
community_authors = session.query(cls).where(cls.author_id == author_id).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"community_id": ca.community_id,
|
||||
"roles": ca.role_list,
|
||||
"permissions": [], # Нужно получить асинхронно
|
||||
"joined_at": ca.joined_at,
|
||||
}
|
||||
for ca in community_authors
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def find_author_in_community(cls, author_id: int, community_id: int, session=None) -> "CommunityAuthor | None":
|
||||
"""
|
||||
Находит запись CommunityAuthor по ID автора и сообщества
|
||||
|
||||
Args:
|
||||
author_id: ID автора
|
||||
community_id: ID сообщества
|
||||
session: Сессия БД (опционально)
|
||||
|
||||
Returns:
|
||||
CommunityAuthor или None
|
||||
"""
|
||||
if session is None:
|
||||
with local_session() as ssession:
|
||||
return ssession.query(cls).where(cls.author_id == author_id, cls.community_id == community_id).first()
|
||||
|
||||
return session.query(cls).where(cls.author_id == author_id, cls.community_id == community_id).first()
|
||||
|
||||
@classmethod
|
||||
def get_users_with_role(cls, community_id: int, role: str, session=None) -> list[int]:
|
||||
"""
|
||||
Получает список ID пользователей с указанной ролью в сообществе
|
||||
|
||||
Args:
|
||||
community_id: ID сообщества
|
||||
role: Название роли
|
||||
session: Сессия БД (опционально)
|
||||
|
||||
Returns:
|
||||
Список ID пользователей
|
||||
"""
|
||||
if session is None:
|
||||
with local_session() as ssession:
|
||||
community_authors = ssession.query(cls).where(cls.community_id == community_id).all()
|
||||
return [ca.author_id for ca in community_authors if ca.has_role(role)]
|
||||
|
||||
community_authors = session.query(cls).where(cls.community_id == community_id).all()
|
||||
|
||||
return [ca.author_id for ca in community_authors if ca.has_role(role)]
|
||||
|
||||
@classmethod
|
||||
def get_community_stats(cls, community_id: int, session=None) -> Dict[str, Any]:
|
||||
"""
|
||||
Получает статистику ролей в сообществе
|
||||
|
||||
Args:
|
||||
community_id: ID сообщества
|
||||
session: Сессия БД (опционально)
|
||||
|
||||
Returns:
|
||||
Словарь со статистикой ролей
|
||||
"""
|
||||
# Загружаем список авторов сообщества (одним способом вне зависимости от сессии)
|
||||
if session is None:
|
||||
with local_session() as s:
|
||||
community_authors = s.query(cls).where(cls.community_id == community_id).all()
|
||||
else:
|
||||
community_authors = session.query(cls).where(cls.community_id == community_id).all()
|
||||
|
||||
role_counts: dict[str, int] = {}
|
||||
total_members = len(community_authors)
|
||||
|
||||
for ca in community_authors:
|
||||
for role in ca.role_list:
|
||||
role_counts[role] = role_counts.get(role, 0) + 1
|
||||
|
||||
return {
|
||||
"total_members": total_members,
|
||||
"role_counts": role_counts,
|
||||
"roles_distribution": {
|
||||
role: count / total_members if total_members > 0 else 0 for role, count in role_counts.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# === CRUD ОПЕРАЦИИ ДЛЯ RBAC ===
|
||||
|
||||
|
||||
def get_all_community_members_with_roles(community_id: int = 1) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Получает всех участников сообщества с их ролями и разрешениями
|
||||
|
||||
Args:
|
||||
community_id: ID сообщества
|
||||
|
||||
Returns:
|
||||
Список участников с полной информацией
|
||||
"""
|
||||
with local_session() as session:
|
||||
community = session.query(Community).where(Community.id == community_id).first()
|
||||
|
||||
if not community:
|
||||
return []
|
||||
|
||||
return community.get_community_members(with_roles=True)
|
||||
|
||||
|
||||
def bulk_assign_roles(user_role_pairs: list[tuple[int, str]], community_id: int = 1) -> dict[str, int]:
|
||||
"""
|
||||
Массовое назначение ролей пользователям
|
||||
|
||||
Args:
|
||||
user_role_pairs: Список кортежей (author_id, role)
|
||||
community_id: ID сообщества
|
||||
|
||||
Returns:
|
||||
Статистика операции в формате {"success": int, "failed": int}
|
||||
"""
|
||||
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for author_id, role in user_role_pairs:
|
||||
try:
|
||||
if assign_role_to_user(author_id, role, community_id):
|
||||
success_count += 1
|
||||
else:
|
||||
# Если роль уже была, считаем это успехом
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
print(f"[ошибка] Не удалось назначить роль {role} пользователю {author_id}: {e}")
|
||||
failed_count += 1
|
||||
|
||||
return {"success": success_count, "failed": failed_count}
|
||||
|
||||
|
||||
# Алиасы для обратной совместимости (избегаем циклических импортов)
|
||||
def get_user_roles_in_community(author_id: int, community_id: int = 1, session: Any = None) -> list[str]:
|
||||
"""Алиас для rbac.api.get_user_roles_in_community"""
|
||||
from rbac.api import get_user_roles_in_community as _get_user_roles_in_community
|
||||
|
||||
return _get_user_roles_in_community(author_id, community_id, session)
|
||||
|
||||
|
||||
def assign_role_to_user(author_id: int, role: str, community_id: int = 1, session: Any = None) -> bool:
|
||||
"""Алиас для rbac.api.assign_role_to_user"""
|
||||
from rbac.api import assign_role_to_user as _assign_role_to_user
|
||||
|
||||
return _assign_role_to_user(author_id, role, community_id, session)
|
||||
|
||||
|
||||
def remove_role_from_user(author_id: int, role: str, community_id: int = 1, session: Any = None) -> bool:
|
||||
"""Алиас для rbac.api.remove_role_from_user"""
|
||||
from rbac.api import remove_role_from_user as _remove_role_from_user
|
||||
|
||||
return _remove_role_from_user(author_id, role, community_id, session)
|
||||
|
||||
|
||||
async def check_user_permission_in_community(
|
||||
author_id: int, permission: str, community_id: int = 1, session: Any = None
|
||||
) -> bool:
|
||||
"""Алиас для rbac.api.check_user_permission_in_community"""
|
||||
from rbac.api import check_user_permission_in_community as _check_user_permission_in_community
|
||||
|
||||
return await _check_user_permission_in_community(author_id, permission, community_id, session)
|
||||
def role_list(self, value):
|
||||
self.roles = ",".join(value) if value else None
|
||||
|
||||
91
orm/draft.py
91
orm/draft.py
@@ -1,84 +1,55 @@
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, Boolean, ForeignKey, Index, Integer, PrimaryKeyConstraint, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from orm.author import Author
|
||||
from orm.base import BaseModel as Base
|
||||
from orm.topic import Topic
|
||||
|
||||
|
||||
# Author уже импортирован в начале файла
|
||||
def get_author_model():
|
||||
"""Возвращает модель Author для использования в запросах"""
|
||||
return Author
|
||||
from services.db import Base
|
||||
|
||||
|
||||
class DraftTopic(Base):
|
||||
__tablename__ = "draft_topic"
|
||||
|
||||
draft: Mapped[int] = mapped_column(ForeignKey("draft.id"), index=True)
|
||||
topic: Mapped[int] = mapped_column(ForeignKey("topic.id"), index=True)
|
||||
main: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint(draft, topic),
|
||||
Index("idx_draft_topic_topic", "topic"),
|
||||
Index("idx_draft_topic_draft", "draft"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
id = None # type: ignore
|
||||
shout = Column(ForeignKey("draft.id"), primary_key=True, index=True)
|
||||
topic = Column(ForeignKey("topic.id"), primary_key=True, index=True)
|
||||
main = Column(Boolean, nullable=True)
|
||||
|
||||
|
||||
class DraftAuthor(Base):
|
||||
__tablename__ = "draft_author"
|
||||
|
||||
draft: Mapped[int] = mapped_column(ForeignKey("draft.id"), index=True)
|
||||
author: Mapped[int] = mapped_column(ForeignKey("author.id"), index=True)
|
||||
caption: Mapped[str | None] = mapped_column(String, nullable=True, default="")
|
||||
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint(draft, author),
|
||||
Index("idx_draft_author_author", "author"),
|
||||
Index("idx_draft_author_draft", "draft"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
id = None # type: ignore
|
||||
shout = Column(ForeignKey("draft.id"), primary_key=True, index=True)
|
||||
author = Column(ForeignKey("author.id"), primary_key=True, index=True)
|
||||
caption = Column(String, nullable=True, default="")
|
||||
|
||||
|
||||
class Draft(Base):
|
||||
__tablename__ = "draft"
|
||||
# required
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
created_by: Mapped[int] = mapped_column(ForeignKey("author.id"), nullable=False)
|
||||
community: Mapped[int] = mapped_column(ForeignKey("community.id"), nullable=False, default=1)
|
||||
created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
created_by: int = Column(ForeignKey("author.id"), nullable=False)
|
||||
community: int = Column(ForeignKey("community.id"), nullable=False, default=1)
|
||||
|
||||
# optional
|
||||
layout: Mapped[str | None] = mapped_column(String, nullable=True, default="article")
|
||||
slug: Mapped[str | None] = mapped_column(String, unique=True)
|
||||
title: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
subtitle: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
lead: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
body: Mapped[str] = mapped_column(String, nullable=False, comment="Body")
|
||||
media: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
cover: Mapped[str | None] = mapped_column(String, nullable=True, comment="Cover image url")
|
||||
cover_caption: Mapped[str | None] = mapped_column(String, nullable=True, comment="Cover image alt caption")
|
||||
lang: Mapped[str] = mapped_column(String, nullable=False, default="ru", comment="Language")
|
||||
seo: Mapped[str | None] = mapped_column(String, nullable=True) # JSON
|
||||
layout: str = Column(String, nullable=True, default="article")
|
||||
slug: str = Column(String, unique=True)
|
||||
title: str = Column(String, nullable=True)
|
||||
subtitle: str | None = Column(String, nullable=True)
|
||||
lead: str | None = Column(String, nullable=True)
|
||||
body: str = Column(String, nullable=False, comment="Body")
|
||||
media: dict | None = Column(JSON, nullable=True)
|
||||
cover: str | None = Column(String, nullable=True, comment="Cover image url")
|
||||
cover_caption: str | None = Column(String, nullable=True, comment="Cover image alt caption")
|
||||
lang: str = Column(String, nullable=False, default="ru", comment="Language")
|
||||
seo: str | None = Column(String, nullable=True) # JSON
|
||||
|
||||
# auto
|
||||
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
deleted_at: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
updated_by: Mapped[int | None] = mapped_column(ForeignKey("author.id"), nullable=True)
|
||||
deleted_by: Mapped[int | None] = mapped_column(ForeignKey("author.id"), nullable=True)
|
||||
authors = relationship(get_author_model(), secondary=DraftAuthor.__table__)
|
||||
topics = relationship(Topic, secondary=DraftTopic.__table__)
|
||||
|
||||
# shout/publication - связь с опубликованной публикацией
|
||||
shout: Mapped[int | None] = mapped_column(ForeignKey("shout.id"), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_draft_created_by", "created_by"),
|
||||
Index("idx_draft_community", "community"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
updated_at: int | None = Column(Integer, nullable=True, index=True)
|
||||
deleted_at: int | None = Column(Integer, nullable=True, index=True)
|
||||
updated_by: int | None = Column(ForeignKey("author.id"), nullable=True)
|
||||
deleted_by: int | None = Column(ForeignKey("author.id"), nullable=True)
|
||||
authors = relationship(Author, secondary="draft_author")
|
||||
topics = relationship(Topic, secondary="draft_topic")
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import enum
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy import Column, ForeignKey, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from orm.base import BaseModel as Base
|
||||
from services.db import Base
|
||||
|
||||
|
||||
class InviteStatus(enum.Enum):
|
||||
@@ -12,33 +12,24 @@ class InviteStatus(enum.Enum):
|
||||
REJECTED = "REJECTED"
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, value: str) -> "InviteStatus":
|
||||
def from_string(cls, value):
|
||||
return cls(value)
|
||||
|
||||
|
||||
class Invite(Base):
|
||||
__tablename__ = "invite"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
inviter_id: Mapped[int] = mapped_column(ForeignKey("author.id"))
|
||||
author_id: Mapped[int] = mapped_column(ForeignKey("author.id"))
|
||||
shout_id: Mapped[int] = mapped_column(ForeignKey("shout.id"))
|
||||
status: Mapped[str] = mapped_column(String, default=InviteStatus.PENDING.value)
|
||||
inviter_id = Column(ForeignKey("author.id"), primary_key=True)
|
||||
author_id = Column(ForeignKey("author.id"), primary_key=True)
|
||||
shout_id = Column(ForeignKey("shout.id"), primary_key=True)
|
||||
status = Column(String, default=InviteStatus.PENDING.value)
|
||||
|
||||
inviter = relationship("Author", foreign_keys=[inviter_id])
|
||||
author = relationship("Author", foreign_keys=[author_id])
|
||||
shout = relationship("Shout")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(inviter_id, author_id, shout_id),
|
||||
Index("idx_invite_inviter_id", "inviter_id"),
|
||||
Index("idx_invite_author_id", "author_id"),
|
||||
Index("idx_invite_shout_id", "shout_id"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
|
||||
def set_status(self, status: InviteStatus) -> None:
|
||||
def set_status(self, status: InviteStatus):
|
||||
self.status = status.value
|
||||
|
||||
def get_status(self) -> InviteStatus:
|
||||
return InviteStatus.from_string(str(self.status))
|
||||
return InviteStatus.from_string(self.status)
|
||||
|
||||
@@ -1,156 +1,63 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
import enum
|
||||
import time
|
||||
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Index, Integer, PrimaryKeyConstraint, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy import JSON, Column, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
# Импорт Author отложен для избежания циклических импортов
|
||||
from orm.author import Author
|
||||
from orm.base import BaseModel as Base
|
||||
from utils.logger import root_logger as logger
|
||||
from services.db import Base
|
||||
|
||||
|
||||
# Author уже импортирован в начале файла
|
||||
def get_author_model():
|
||||
"""Возвращает модель Author для использования в запросах"""
|
||||
return Author
|
||||
|
||||
|
||||
class NotificationEntity(Enum):
|
||||
"""
|
||||
Перечисление сущностей для уведомлений.
|
||||
Определяет типы объектов, к которым относятся уведомления.
|
||||
"""
|
||||
|
||||
TOPIC = "topic"
|
||||
COMMENT = "comment"
|
||||
SHOUT = "shout"
|
||||
AUTHOR = "author"
|
||||
COMMUNITY = "community"
|
||||
class NotificationEntity(enum.Enum):
|
||||
REACTION = "reaction"
|
||||
SHOUT = "shout"
|
||||
FOLLOWER = "follower"
|
||||
COMMUNITY = "community"
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, value: str) -> "NotificationEntity":
|
||||
"""
|
||||
Создает экземпляр сущности уведомления из строки.
|
||||
|
||||
Args:
|
||||
value (str): Строковое представление сущности.
|
||||
|
||||
Returns:
|
||||
NotificationEntity: Экземпляр сущности уведомления.
|
||||
"""
|
||||
try:
|
||||
return cls(value)
|
||||
except ValueError:
|
||||
logger.error(f"Неверная сущность уведомления: {value}")
|
||||
raise ValueError("Неверная сущность уведомления") # noqa: B904
|
||||
def from_string(cls, value):
|
||||
return cls(value)
|
||||
|
||||
|
||||
class NotificationAction(Enum):
|
||||
"""
|
||||
Перечисление действий для уведомлений.
|
||||
Определяет типы событий, которые могут происходить с сущностями.
|
||||
"""
|
||||
|
||||
class NotificationAction(enum.Enum):
|
||||
CREATE = "create"
|
||||
UPDATE = "update"
|
||||
DELETE = "delete"
|
||||
MENTION = "mention"
|
||||
REACT = "react"
|
||||
SEEN = "seen"
|
||||
FOLLOW = "follow"
|
||||
INVITE = "invite"
|
||||
UNFOLLOW = "unfollow"
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, value: str) -> "NotificationAction":
|
||||
"""
|
||||
Создает экземпляр действия уведомления из строки.
|
||||
|
||||
Args:
|
||||
value (str): Строковое представление действия.
|
||||
|
||||
Returns:
|
||||
NotificationAction: Экземпляр действия уведомления.
|
||||
"""
|
||||
try:
|
||||
return cls(value)
|
||||
except ValueError:
|
||||
logger.error(f"Неверное действие уведомления: {value}")
|
||||
raise ValueError("Неверное действие уведомления") # noqa: B904
|
||||
|
||||
|
||||
# Оставляем для обратной совместимости
|
||||
NotificationStatus = Enum("NotificationStatus", ["UNREAD", "READ", "ARCHIVED"])
|
||||
NotificationKind = NotificationAction # Для совместимости со старым кодом
|
||||
def from_string(cls, value):
|
||||
return cls(value)
|
||||
|
||||
|
||||
class NotificationSeen(Base):
|
||||
__tablename__ = "notification_seen"
|
||||
|
||||
viewer: Mapped[int] = mapped_column(ForeignKey("author.id"))
|
||||
notification: Mapped[int] = mapped_column(ForeignKey("notification.id"))
|
||||
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint(viewer, notification),
|
||||
Index("idx_notification_seen_viewer", "viewer"),
|
||||
Index("idx_notification_seen_notification", "notification"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
|
||||
|
||||
class NotificationUnsubscribe(Base):
|
||||
"""Модель для хранения отписок пользователей от уведомлений по определенным thread_id."""
|
||||
|
||||
__tablename__ = "notification_unsubscribe"
|
||||
|
||||
author_id: Mapped[int] = mapped_column(ForeignKey("author.id"), nullable=False)
|
||||
thread_id: Mapped[str] = mapped_column(String, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint(author_id, thread_id),
|
||||
Index("idx_notification_unsubscribe_author", "author_id"),
|
||||
Index("idx_notification_unsubscribe_thread", "thread_id"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
viewer = Column(ForeignKey("author.id"), primary_key=True)
|
||||
notification = Column(ForeignKey("notification.id"), primary_key=True)
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notification"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
created_at = Column(Integer, server_default=str(int(time.time())))
|
||||
entity = Column(String, nullable=False)
|
||||
action = Column(String, nullable=False)
|
||||
payload = Column(JSON, nullable=True)
|
||||
|
||||
entity: Mapped[str] = mapped_column(String, nullable=False)
|
||||
action: Mapped[str] = mapped_column(String, nullable=False)
|
||||
payload: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
seen = relationship(Author, secondary="notification_seen")
|
||||
|
||||
status: Mapped[NotificationStatus] = mapped_column(default=NotificationStatus.UNREAD)
|
||||
kind: Mapped[NotificationKind] = mapped_column(nullable=False)
|
||||
|
||||
seen = relationship("Author", secondary="notification_seen")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_notification_created_at", "created_at"),
|
||||
Index("idx_notification_status", "status"),
|
||||
Index("idx_notification_kind", "kind"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
|
||||
def set_entity(self, entity: NotificationEntity) -> None:
|
||||
"""Устанавливает сущность уведомления."""
|
||||
def set_entity(self, entity: NotificationEntity):
|
||||
self.entity = entity.value
|
||||
|
||||
def get_entity(self) -> NotificationEntity:
|
||||
"""Возвращает сущность уведомления."""
|
||||
return NotificationEntity.from_string(self.entity)
|
||||
|
||||
def set_action(self, action: NotificationAction) -> None:
|
||||
"""Устанавливает действие уведомления."""
|
||||
def set_action(self, action: NotificationAction):
|
||||
self.action = action.value
|
||||
|
||||
def get_action(self) -> NotificationAction:
|
||||
"""Возвращает действие уведомления."""
|
||||
return NotificationAction.from_string(self.action)
|
||||
|
||||
@@ -10,26 +10,21 @@ PROPOSAL_REACTIONS = [
|
||||
]
|
||||
|
||||
PROOF_REACTIONS = [ReactionKind.PROOF.value, ReactionKind.DISPROOF.value]
|
||||
|
||||
RATING_REACTIONS = [ReactionKind.LIKE.value, ReactionKind.DISLIKE.value]
|
||||
POSITIVE_REACTIONS = [ReactionKind.ACCEPT.value, ReactionKind.LIKE.value, ReactionKind.PROOF.value]
|
||||
NEGATIVE_REACTIONS = [ReactionKind.REJECT.value, ReactionKind.DISLIKE.value, ReactionKind.DISPROOF.value]
|
||||
|
||||
|
||||
def is_negative(x: ReactionKind | str) -> bool:
|
||||
"""Проверяет, является ли реакция негативной.
|
||||
|
||||
Args:
|
||||
x: ReactionKind enum или строка с названием реакции
|
||||
"""
|
||||
value = x.value if isinstance(x, ReactionKind) else x
|
||||
return value in NEGATIVE_REACTIONS
|
||||
def is_negative(x):
|
||||
return x in [
|
||||
ReactionKind.DISLIKE.value,
|
||||
ReactionKind.DISPROOF.value,
|
||||
ReactionKind.REJECT.value,
|
||||
]
|
||||
|
||||
|
||||
def is_positive(x: ReactionKind | str) -> bool:
|
||||
"""Проверяет, является ли реакция позитивной.
|
||||
|
||||
Args:
|
||||
x: ReactionKind enum или строка с названием реакции
|
||||
"""
|
||||
value = x.value if isinstance(x, ReactionKind) else x
|
||||
return value in POSITIVE_REACTIONS
|
||||
def is_positive(x):
|
||||
return x in [
|
||||
ReactionKind.ACCEPT.value,
|
||||
ReactionKind.LIKE.value,
|
||||
ReactionKind.PROOF.value,
|
||||
]
|
||||
|
||||
@@ -1,68 +1,45 @@
|
||||
import time
|
||||
from enum import Enum as Enumeration
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String
|
||||
|
||||
from orm.base import BaseModel as Base
|
||||
from services.db import Base
|
||||
|
||||
|
||||
class ReactionKind(Enumeration):
|
||||
# TYPE = <reaction index> # rating diff
|
||||
|
||||
# editor specials
|
||||
# editor mode
|
||||
AGREE = "AGREE" # +1
|
||||
DISAGREE = "DISAGREE" # -1
|
||||
|
||||
# coauthor specials
|
||||
ASK = "ASK" # 0
|
||||
PROPOSE = "PROPOSE" # 0
|
||||
|
||||
# generic internal reactions
|
||||
ASK = "ASK" # +0
|
||||
PROPOSE = "PROPOSE" # +0
|
||||
ACCEPT = "ACCEPT" # +1
|
||||
REJECT = "REJECT" # -1
|
||||
|
||||
# experts speacials
|
||||
# expert mode
|
||||
PROOF = "PROOF" # +1
|
||||
DISPROOF = "DISPROOF" # -1
|
||||
|
||||
# comment and quote
|
||||
QUOTE = "QUOTE" # 0
|
||||
COMMENT = "COMMENT" # 0
|
||||
|
||||
# generic rating
|
||||
# public feed
|
||||
QUOTE = "QUOTE" # +0 TODO: use to bookmark in collection
|
||||
COMMENT = "COMMENT" # +0
|
||||
LIKE = "LIKE" # +1
|
||||
DISLIKE = "DISLIKE" # -1
|
||||
|
||||
# credit artist or researcher
|
||||
CREDIT = "CREDIT" # +1
|
||||
SILENT = "SILENT" # 0
|
||||
|
||||
|
||||
REACTION_KINDS = ReactionKind.__members__.keys()
|
||||
|
||||
|
||||
class Reaction(Base):
|
||||
__tablename__ = "reaction"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
body: Mapped[str] = mapped_column(String, default="", comment="Reaction Body")
|
||||
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()), index=True)
|
||||
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="Updated at", index=True)
|
||||
deleted_at: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="Deleted at", index=True)
|
||||
deleted_by: Mapped[int | None] = mapped_column(ForeignKey("author.id"), nullable=True)
|
||||
reply_to: Mapped[int | None] = mapped_column(ForeignKey("reaction.id"), nullable=True)
|
||||
quote: Mapped[str | None] = mapped_column(String, nullable=True, comment="Original quoted text")
|
||||
shout: Mapped[int] = mapped_column(ForeignKey("shout.id"), nullable=False, index=True)
|
||||
created_by: Mapped[int] = mapped_column(ForeignKey("author.id"), nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||
body = Column(String, default="", comment="Reaction Body")
|
||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()), index=True)
|
||||
updated_at = Column(Integer, nullable=True, comment="Updated at", index=True)
|
||||
deleted_at = Column(Integer, nullable=True, comment="Deleted at", index=True)
|
||||
deleted_by = Column(ForeignKey("author.id"), nullable=True)
|
||||
reply_to = Column(ForeignKey("reaction.id"), nullable=True)
|
||||
quote = Column(String, nullable=True, comment="Original quoted text")
|
||||
shout = Column(ForeignKey("shout.id"), nullable=False, index=True)
|
||||
created_by = Column(ForeignKey("author.id"), nullable=False)
|
||||
kind = Column(String, nullable=False, index=True)
|
||||
|
||||
oid: Mapped[str | None] = mapped_column(String)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_reaction_created_at", "created_at"),
|
||||
Index("idx_reaction_created_by", "created_by"),
|
||||
Index("idx_reaction_shout", "shout"),
|
||||
Index("idx_reaction_kind", "kind"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
oid = Column(String)
|
||||
|
||||
106
orm/shout.py
106
orm/shout.py
@@ -1,13 +1,15 @@
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, Boolean, ForeignKey, Index, Integer, PrimaryKeyConstraint, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from orm.base import BaseModel
|
||||
from orm.author import Author
|
||||
from orm.reaction import Reaction
|
||||
from orm.topic import Topic
|
||||
from services.db import Base
|
||||
|
||||
|
||||
class ShoutTopic(BaseModel):
|
||||
class ShoutTopic(Base):
|
||||
"""
|
||||
Связь между публикацией и темой.
|
||||
|
||||
@@ -19,36 +21,30 @@ class ShoutTopic(BaseModel):
|
||||
|
||||
__tablename__ = "shout_topic"
|
||||
|
||||
shout: Mapped[int] = mapped_column(ForeignKey("shout.id"), index=True)
|
||||
topic: Mapped[int] = mapped_column(ForeignKey("topic.id"), index=True)
|
||||
main: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
id = None # type: ignore
|
||||
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
|
||||
topic = Column(ForeignKey("topic.id"), primary_key=True, index=True)
|
||||
main = Column(Boolean, nullable=True)
|
||||
|
||||
# Определяем дополнительные индексы
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint(shout, topic),
|
||||
# Оптимизированный составной индекс для запросов, которые ищут публикации по теме
|
||||
Index("idx_shout_topic_topic_shout", "topic", "shout"),
|
||||
)
|
||||
|
||||
|
||||
class ShoutReactionsFollower(BaseModel):
|
||||
class ShoutReactionsFollower(Base):
|
||||
__tablename__ = "shout_reactions_followers"
|
||||
|
||||
follower: Mapped[int] = mapped_column(ForeignKey("author.id"), index=True)
|
||||
shout: Mapped[int] = mapped_column(ForeignKey("shout.id"), index=True)
|
||||
auto: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
deleted_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint(follower, shout),
|
||||
Index("idx_shout_reactions_followers_follower", "follower"),
|
||||
Index("idx_shout_reactions_followers_shout", "shout"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
id = None # type: ignore
|
||||
follower = Column(ForeignKey("author.id"), primary_key=True, index=True)
|
||||
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
|
||||
auto = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
deleted_at = Column(Integer, nullable=True)
|
||||
|
||||
|
||||
class ShoutAuthor(BaseModel):
|
||||
class ShoutAuthor(Base):
|
||||
"""
|
||||
Связь между публикацией и автором.
|
||||
|
||||
@@ -60,55 +56,57 @@ class ShoutAuthor(BaseModel):
|
||||
|
||||
__tablename__ = "shout_author"
|
||||
|
||||
shout: Mapped[int] = mapped_column(ForeignKey("shout.id"), index=True)
|
||||
author: Mapped[int] = mapped_column(ForeignKey("author.id"), index=True)
|
||||
caption: Mapped[str | None] = mapped_column(String, nullable=True, default="")
|
||||
id = None # type: ignore
|
||||
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
|
||||
author = Column(ForeignKey("author.id"), primary_key=True, index=True)
|
||||
caption = Column(String, nullable=True, default="")
|
||||
|
||||
# Определяем дополнительные индексы
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint(shout, author),
|
||||
# Оптимизированный индекс для запросов, которые ищут публикации по автору
|
||||
Index("idx_shout_author_author_shout", "author", "shout"),
|
||||
)
|
||||
|
||||
|
||||
class Shout(BaseModel):
|
||||
class Shout(Base):
|
||||
"""
|
||||
Публикация в системе.
|
||||
"""
|
||||
|
||||
__tablename__ = "shout"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
published_at: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
featured_at: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
deleted_at: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
updated_at: int | None = Column(Integer, nullable=True, index=True)
|
||||
published_at: int | None = Column(Integer, nullable=True, index=True)
|
||||
featured_at: int | None = Column(Integer, nullable=True, index=True)
|
||||
deleted_at: int | None = Column(Integer, nullable=True, index=True)
|
||||
|
||||
created_by: Mapped[int] = mapped_column(ForeignKey("author.id"), nullable=False)
|
||||
updated_by: Mapped[int | None] = mapped_column(ForeignKey("author.id"), nullable=True)
|
||||
deleted_by: Mapped[int | None] = mapped_column(ForeignKey("author.id"), nullable=True)
|
||||
community: Mapped[int] = mapped_column(ForeignKey("community.id"), nullable=False)
|
||||
created_by: int = Column(ForeignKey("author.id"), nullable=False)
|
||||
updated_by: int | None = Column(ForeignKey("author.id"), nullable=True)
|
||||
deleted_by: int | None = Column(ForeignKey("author.id"), nullable=True)
|
||||
community: int = Column(ForeignKey("community.id"), nullable=False)
|
||||
|
||||
body: Mapped[str] = mapped_column(String, nullable=False, comment="Body")
|
||||
slug: Mapped[str | None] = mapped_column(String, unique=True)
|
||||
cover: Mapped[str | None] = mapped_column(String, nullable=True, comment="Cover image url")
|
||||
cover_caption: Mapped[str | None] = mapped_column(String, nullable=True, comment="Cover image alt caption")
|
||||
lead: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
title: Mapped[str] = mapped_column(String, nullable=False)
|
||||
subtitle: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
layout: Mapped[str] = mapped_column(String, nullable=False, default="article")
|
||||
media: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
body: str = Column(String, nullable=False, comment="Body")
|
||||
slug: str = Column(String, unique=True)
|
||||
cover: str | None = Column(String, nullable=True, comment="Cover image url")
|
||||
cover_caption: str | None = Column(String, nullable=True, comment="Cover image alt caption")
|
||||
lead: str | None = Column(String, nullable=True)
|
||||
title: str = Column(String, nullable=False)
|
||||
subtitle: str | None = Column(String, nullable=True)
|
||||
layout: str = Column(String, nullable=False, default="article")
|
||||
media: dict | None = Column(JSON, nullable=True)
|
||||
|
||||
authors = relationship("Author", secondary="shout_author")
|
||||
topics = relationship("Topic", secondary="shout_topic")
|
||||
reactions = relationship("Reaction")
|
||||
authors = relationship(Author, secondary="shout_author")
|
||||
topics = relationship(Topic, secondary="shout_topic")
|
||||
reactions = relationship(Reaction)
|
||||
|
||||
lang: Mapped[str] = mapped_column(String, nullable=False, default="ru", comment="Language")
|
||||
version_of: Mapped[int | None] = mapped_column(ForeignKey("shout.id"), nullable=True)
|
||||
oid: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
seo: Mapped[str | None] = mapped_column(String, nullable=True) # JSON
|
||||
lang: str = Column(String, nullable=False, default="ru", comment="Language")
|
||||
version_of: int | None = Column(ForeignKey("shout.id"), nullable=True)
|
||||
oid: str | None = Column(String, nullable=True)
|
||||
|
||||
seo: str | None = Column(String, nullable=True) # JSON
|
||||
|
||||
draft: int | None = Column(ForeignKey("draft.id"), nullable=True)
|
||||
|
||||
# Определяем индексы
|
||||
__table_args__ = (
|
||||
|
||||
45
orm/topic.py
45
orm/topic.py
@@ -1,24 +1,8 @@
|
||||
import time
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
PrimaryKeyConstraint,
|
||||
String,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
|
||||
|
||||
from orm.author import Author
|
||||
from orm.base import BaseModel as Base
|
||||
|
||||
|
||||
# Author уже импортирован в начале файла
|
||||
def get_author_model():
|
||||
"""Возвращает модель Author для использования в запросах"""
|
||||
return Author
|
||||
from services.db import Base
|
||||
|
||||
|
||||
class TopicFollower(Base):
|
||||
@@ -34,14 +18,14 @@ class TopicFollower(Base):
|
||||
|
||||
__tablename__ = "topic_followers"
|
||||
|
||||
follower: Mapped[int] = mapped_column(ForeignKey("author.id"))
|
||||
topic: Mapped[int] = mapped_column(ForeignKey("topic.id"))
|
||||
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
auto: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
id = None # type: ignore
|
||||
follower = Column(Integer, ForeignKey("author.id"), primary_key=True)
|
||||
topic = Column(Integer, ForeignKey("topic.id"), primary_key=True)
|
||||
created_at = Column(Integer, nullable=False, default=int(time.time()))
|
||||
auto = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Определяем индексы
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint(topic, follower),
|
||||
# Индекс для быстрого поиска всех подписчиков топика
|
||||
Index("idx_topic_followers_topic", "topic"),
|
||||
# Индекс для быстрого поиска всех топиков, на которые подписан автор
|
||||
@@ -65,14 +49,13 @@ class Topic(Base):
|
||||
|
||||
__tablename__ = "topic"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
slug: Mapped[str] = mapped_column(String, unique=True)
|
||||
title: Mapped[str] = mapped_column(String, nullable=False, comment="Title")
|
||||
body: Mapped[str | None] = mapped_column(String, nullable=True, comment="Body")
|
||||
pic: Mapped[str | None] = mapped_column(String, nullable=True, comment="Picture")
|
||||
community: Mapped[int] = mapped_column(ForeignKey("community.id"), default=1)
|
||||
oid: Mapped[str | None] = mapped_column(String, nullable=True, comment="Old ID")
|
||||
parent_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True, comment="Parent Topic IDs")
|
||||
slug = Column(String, unique=True)
|
||||
title = Column(String, nullable=False, comment="Title")
|
||||
body = Column(String, nullable=True, comment="Body")
|
||||
pic = Column(String, nullable=True, comment="Picture")
|
||||
community = Column(ForeignKey("community.id"), default=1)
|
||||
oid = Column(String, nullable=True, comment="Old ID")
|
||||
parent_ids = Column(JSON, nullable=True, comment="Parent Topic IDs")
|
||||
|
||||
# Определяем индексы
|
||||
__table_args__ = (
|
||||
|
||||
6471
package-lock.json
generated
6471
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
39
package.json
39
package.json
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"name": "publy-panel",
|
||||
"version": "0.9.33",
|
||||
"type": "module",
|
||||
"description": "Publy, a modern platform for collaborative text creation, offers a user-friendly interface for authors, editors, and readers, supporting real-time collaboration and structured feedback.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "npm run codegen && vite build",
|
||||
"serve": "vite preview",
|
||||
"lint": "biome check . --fix",
|
||||
"format": "biome format . --write",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"codegen": "graphql-codegen --config codegen.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.2.5",
|
||||
"@graphql-codegen/cli": "^6.0.0",
|
||||
"@graphql-codegen/client-preset": "^5.1.0",
|
||||
"@graphql-codegen/introspection": "^5.0.0",
|
||||
"@graphql-codegen/typescript": "^5.0.2",
|
||||
"@graphql-codegen/typescript-operations": "^5.0.2",
|
||||
"@graphql-codegen/typescript-resolvers": "^5.1.0",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"@types/node": "^24.7.0",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"graphql": "^16.11.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"lightningcss": "^1.30.2",
|
||||
"prismjs": "^1.30.0",
|
||||
"solid-js": "^1.9.9",
|
||||
"terser": "^5.44.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.1.9",
|
||||
"vite-plugin-solid": "^2.11.9"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "^7.1.9"
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Route, Router } from '@solidjs/router'
|
||||
import { lazy, onMount } from 'solid-js'
|
||||
import { AuthProvider } from './context/auth'
|
||||
import { I18nProvider } from './intl/i18n'
|
||||
import LoginPage from './routes/login'
|
||||
|
||||
const ProtectedRoute = lazy(() =>
|
||||
import('./ui/ProtectedRoute').then((module) => ({ default: module.ProtectedRoute }))
|
||||
)
|
||||
/**
|
||||
* Корневой компонент приложения
|
||||
*/
|
||||
const App = () => {
|
||||
console.log('[App] Initializing root component...')
|
||||
|
||||
onMount(() => {
|
||||
console.log('[App] Root component mounted')
|
||||
})
|
||||
|
||||
return (
|
||||
<I18nProvider>
|
||||
<AuthProvider>
|
||||
<div class="app-container">
|
||||
<Router>
|
||||
<Route path="/login" component={LoginPage} />
|
||||
<Route path="/" component={ProtectedRoute} />
|
||||
<Route path="/admin" component={ProtectedRoute} />
|
||||
<Route path="/admin/:tab" component={ProtectedRoute} />
|
||||
</Router>
|
||||
</div>
|
||||
</AuthProvider>
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,22 +0,0 @@
|
||||
<svg width="172" height="32" viewBox="0 0 175 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- D -->
|
||||
<path d="M24.51 28.16H19.78V23H5.074V28.16H0.344V18.055H2.881C3.11033 17.7397 3.397 17.2093 3.741 16.464C4.11367 15.69 4.472 14.6437 4.816 13.325C5.18867 12.0063 5.504 10.3723 5.762 8.423C6.02 6.47367 6.149 4.166 6.149 1.5H21.113V18.055H24.51V28.16ZM15.523 18.313V6.23H11.008C10.9507 7.262 10.8503 8.36567 10.707 9.541C10.5637 10.6877 10.3773 11.8057 10.148 12.895C9.94733 13.9843 9.70367 15.002 9.417 15.948C9.13033 16.894 8.815 17.6823 8.471 18.313H15.523Z" fill="currentColor"/>
|
||||
|
||||
<!-- I -->
|
||||
<path d="M42.3382 13.196L42.5532 10.143H42.4242L32.0612 23H27.6752V1.5H33.2652V11.734L33.0072 14.658H33.1792L43.5422 1.5H47.9282V23H42.3382V13.196Z" fill="currentColor"/>
|
||||
|
||||
<!-- S -->
|
||||
<path d="M73.3244 20.936C72.1491 21.8247 70.7731 22.4983 69.1964 22.957C67.6197 23.387 65.9714 23.602 64.2514 23.602C62.3881 23.602 60.7254 23.3297 59.2634 22.785C57.8301 22.2403 56.6117 21.4807 55.6084 20.506C54.6337 19.5027 53.8884 18.2987 53.3724 16.894C52.8564 15.4893 52.5984 13.9413 52.5984 12.25C52.5984 10.444 52.8994 8.83867 53.5014 7.434C54.1034 6.02933 54.9347 4.83967 55.9954 3.865C57.0847 2.89033 58.3604 2.15933 59.8224 1.672C61.2844 1.156 62.8754 0.898 64.5954 0.898C66.2007 0.898 67.7057 1.08433 69.1104 1.457C70.5151 1.82966 71.5901 2.188 72.3354 2.532V10.1H67.6054V6.144C66.7167 5.94333 65.8281 5.843 64.9394 5.843C64.1367 5.843 63.3341 5.972 62.5314 6.23C61.7574 6.45933 61.0551 6.84633 60.4244 7.391C59.8224 7.907 59.3207 8.56633 58.9194 9.369C58.5467 10.1717 58.3604 11.132 58.3604 12.25C58.3604 13.1673 58.5181 14.013 58.8334 14.787C59.1487 15.561 59.5931 16.2347 60.1664 16.808C60.7397 17.3813 61.4421 17.84 62.2734 18.184C63.1334 18.4993 64.0794 18.657 65.1114 18.657C66.7454 18.657 68.0784 18.4277 69.1104 17.969C70.1711 17.5103 70.9594 17.109 71.4754 16.765L73.3244 20.936Z" fill="currentColor"/>
|
||||
|
||||
<!-- C -->
|
||||
<path d="M86.4226 14.529H83.6276V23H78.0376V1.5H83.6276V10.229H85.9066L93.0016 1.5H99.3226L90.7656 11.519L96.0546 18.27H99.8386V23H93.3886L86.4226 14.529Z" fill="currentColor"/>
|
||||
|
||||
<!-- O -->
|
||||
<path d="M112.636 16.937H114.27L118.441 1.5H124.203L118.097 20.893C117.552 22.4983 117.022 23.9603 116.506 25.279C115.99 26.6263 115.388 27.7873 114.7 28.762C114.012 29.7367 113.195 30.482 112.249 30.998C111.331 31.5427 110.199 31.815 108.852 31.815C108.192 31.815 107.562 31.729 106.96 31.557C106.386 31.385 105.856 31.17 105.369 30.912C104.881 30.6827 104.451 30.4247 104.079 30.138C103.706 29.88 103.405 29.6363 103.176 29.407L106.057 25.58C106.429 25.8953 106.874 26.182 107.39 26.44C107.906 26.7267 108.393 26.87 108.852 26.87C109.712 26.87 110.385 26.569 110.873 25.967C111.389 25.365 111.876 24.376 112.335 23H109.282L100.338 1.5H106.401L112.636 16.937Z" fill="currentColor"/>
|
||||
|
||||
<!-- U -->
|
||||
<path d="M126.444 1.5H133.754L134.399 4.08H134.571C136.061 1.95867 138.469 0.898 141.795 0.898C143.113 0.898 144.317 1.113 145.407 1.543C146.525 1.94433 147.471 2.58933 148.245 3.478C149.047 4.36667 149.664 5.48467 150.094 6.832C150.524 8.17933 150.739 9.799 150.739 11.691C150.739 13.5257 150.495 15.1883 150.008 16.679C149.52 18.141 148.818 19.388 147.901 20.42C146.983 21.452 145.865 22.2403 144.547 22.785C143.228 23.3297 141.723 23.602 140.032 23.602C139.143 23.602 138.269 23.5303 137.409 23.387C136.549 23.2723 135.832 23.0717 135.259 22.785V31.6H129.669V6.23H126.444V1.5ZM140.118 5.628C139.028 5.628 138.025 5.90033 137.108 6.445C136.219 6.98967 135.603 7.80667 135.259 8.896V17.84C135.66 18.1553 136.233 18.4133 136.979 18.614C137.753 18.786 138.527 18.872 139.301 18.872C140.103 18.872 140.849 18.743 141.537 18.485C142.225 18.1983 142.827 17.754 143.343 17.152C143.859 16.55 144.26 15.7903 144.547 14.873C144.833 13.9557 144.977 12.852 144.977 11.562C144.977 9.67 144.518 8.208 143.601 7.176C142.683 6.144 141.522 5.628 140.118 5.628Z" fill="currentColor"/>
|
||||
|
||||
<!-- R -->
|
||||
<path d="M174.845 20.936C173.669 21.8247 172.293 22.4983 170.717 22.957C169.14 23.387 167.492 23.602 165.772 23.602C163.908 23.602 162.246 23.3297 160.784 22.785C159.35 22.2403 158.132 21.4807 157.129 20.506C156.154 19.5027 155.409 18.2987 154.893 16.894C154.377 15.4893 154.119 13.9413 154.119 12.25C154.119 10.444 154.42 8.83867 155.022 7.434C155.624 6.02933 156.455 4.83967 157.516 3.865C158.605 2.89033 159.881 2.15933 161.343 1.672C162.805 1.156 164.396 0.898 166.116 0.898C167.721 0.898 169.226 1.08433 170.631 1.457C172.035 1.82966 173.11 2.188 173.856 2.532V10.1H169.126V6.144C168.237 5.94333 167.348 5.843 166.46 5.843C165.657 5.843 164.854 5.972 164.052 6.23C163.278 6.45933 162.575 6.84633 161.945 7.391C161.343 7.907 160.841 8.56633 160.44 9.369C160.067 10.1717 159.881 11.132 159.881 12.25C159.881 13.1673 160.038 14.013 160.354 14.787C160.669 15.561 161.113 16.2347 161.687 16.808C162.26 17.3813 162.962 17.84 163.794 18.184C164.654 18.4993 165.6 18.657 166.632 18.657C168.266 18.657 169.599 18.4277 170.631 17.969C171.691 17.5103 172.48 17.109 172.996 16.765L174.845 20.936Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user