Compare commits
28 Commits
dev
...
1156a32a88
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1156a32a88 | ||
| d848af524f | |||
| c9f88c36cd | |||
| 0ad44a944e | |||
| fbd0e03a33 | |||
|
|
076828f003 | ||
|
|
4f6c459532 | ||
|
|
11524c17ea | ||
| 168f845772 | |||
| 657146cdca | |||
|
|
86111bc9f5 | ||
|
|
a8018a0b2f | ||
|
|
9d8bd629ab | ||
| 1eddf9cc0b | |||
| 6415f86286 | |||
| 5d1c4f0084 | |||
| 1dce947db6 | |||
| 4d9551a93c | |||
| e6471280d5 | |||
| 3e062b4346 | |||
| 5b1a93c781 | |||
| c30001547a | |||
| 025019b544 | |||
| a862a11c91 | |||
| f3d86daea7 | |||
| 296716397e | |||
| 22c42839c1 | |||
| 4fd90e305f |
@@ -1 +0,0 @@
|
||||
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
||||
@@ -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,18 @@
|
||||
name: 'Deploy on push'
|
||||
on: [push]
|
||||
name: 'Deploy to discoursio-api'
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
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 +21,11 @@ jobs:
|
||||
id: branch_name
|
||||
run: echo "::set-output name=branch::$(echo ${GITHUB_REF##*/})"
|
||||
|
||||
- name: Verify Git Before Deploy Main
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
echo "🔍 Проверяем git перед деплоем на main..."
|
||||
git status
|
||||
git log --oneline -5
|
||||
echo "✅ Git репозиторий готов"
|
||||
- name: Push to dokku
|
||||
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: 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"
|
||||
|
||||
- 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 завершен"
|
||||
|
||||
16
.github/workflows/checks.yml
vendored
Normal file
16
.github/workflows/checks.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
name: Checks
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
name: Checks
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.10.6
|
||||
- run: pip install --upgrade pip
|
||||
- run: pip install -r requirements.txt
|
||||
- run: pip install -r requirements-dev.txt
|
||||
- run: ./checks.sh
|
||||
233
.github/workflows/deploy.yml
vendored
233
.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
|
||||
- name: Checkout source repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.13"
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v1
|
||||
- uses: webfactory/ssh-agent@v0.8.0
|
||||
with:
|
||||
version: "1.0.0"
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
|
||||
- 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
|
||||
- name: Push to dokku
|
||||
env:
|
||||
HOST_KEY: ${{ secrets.HOST_KEY }}
|
||||
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
|
||||
echo $HOST_KEY > ~/.ssh/known_hosts
|
||||
git remote add dokku dokku@v2.discours.io:discoursio-api
|
||||
git push dokku HEAD:main -f
|
||||
|
||||
39
.gitignore
vendored
39
.gitignore
vendored
@@ -147,41 +147,6 @@ migration/content/**/*.md
|
||||
*.csv
|
||||
dev-server.pid
|
||||
backups/
|
||||
poetry.lock
|
||||
.ruff_cache
|
||||
.jj
|
||||
.zed
|
||||
|
||||
dokku_config
|
||||
|
||||
*.db
|
||||
*.sqlite3
|
||||
views.json
|
||||
*.pem
|
||||
*.key
|
||||
*.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
|
||||
.venv
|
||||
poetry.lock
|
||||
|
||||
44
.pre-commit-config.yaml
Normal file
44
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,44 @@
|
||||
exclude: |
|
||||
(?x)(
|
||||
^tests/unit_tests/resource|
|
||||
_grpc.py|
|
||||
_pb2.py
|
||||
)
|
||||
|
||||
default_language_version:
|
||||
python: python3.10
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
- id: check-added-large-files
|
||||
- id: check-case-conflict
|
||||
- id: check-docstring-first
|
||||
- id: check-json
|
||||
- id: check-merge-conflict
|
||||
- id: check-toml
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- id: requirements-txt-fixer
|
||||
|
||||
- repo: https://github.com/timothycrosley/isort
|
||||
rev: 5.12.0
|
||||
hooks:
|
||||
- id: isort
|
||||
|
||||
- repo: https://github.com/ambv/black
|
||||
rev: 23.10.1
|
||||
hooks:
|
||||
- id: black
|
||||
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 6.1.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
|
||||
# - repo: https://github.com/python/mypy
|
||||
# rev: v1.6.1
|
||||
# hooks:
|
||||
# - id: mypy
|
||||
2786
CHANGELOG.md
2786
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! 🙏
|
||||
68
Dockerfile
68
Dockerfile
@@ -1,65 +1,11 @@
|
||||
# 🏗️ Multi-stage build for optimal caching and size
|
||||
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
|
||||
|
||||
# 🔧 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
|
||||
|
||||
FROM python:3.11-slim
|
||||
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
|
||||
|
||||
# 🚀 Application code (rebuilt on any code change)
|
||||
EXPOSE 8080
|
||||
ADD nginx.conf.sigil ./
|
||||
COPY requirements.txt .
|
||||
RUN apt update && apt install -y git gcc curl postgresql
|
||||
RUN pip install -r requirements.txt
|
||||
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 server.py
|
||||
|
||||
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.
|
||||
225
README.md
225
README.md
@@ -1,212 +1,47 @@
|
||||
# Discours.io Core
|
||||
# discoursio-api
|
||||
|
||||
🚀 **Modern community platform** with GraphQL API, RBAC system, and comprehensive testing infrastructure.
|
||||
|
||||
## 🎯 Features
|
||||
- sqlalchemy
|
||||
- redis
|
||||
- ariadne
|
||||
- starlette
|
||||
- uvicorn
|
||||
|
||||
- **🔐 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
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.11+
|
||||
- Node.js 18+
|
||||
- Redis
|
||||
- uv (Python package manager)
|
||||
|
||||
### Installation
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone <repository-url>
|
||||
cd core
|
||||
|
||||
# Install Python dependencies
|
||||
uv sync --group dev
|
||||
|
||||
# Install Node.js dependencies
|
||||
cd panel
|
||||
npm ci
|
||||
cd ..
|
||||
|
||||
# Setup environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
on osx
|
||||
```
|
||||
brew install redis nginx postgres
|
||||
brew services start redis
|
||||
```
|
||||
|
||||
### Development
|
||||
```bash
|
||||
# Start backend server
|
||||
uv run python dev.py
|
||||
|
||||
# Start frontend (in another terminal)
|
||||
cd panel
|
||||
npm run dev
|
||||
on debian/ubuntu
|
||||
```
|
||||
apt install redis nginx
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
# Local development
|
||||
|
||||
### Run All Tests
|
||||
```bash
|
||||
uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
### Test Categories
|
||||
|
||||
#### Run only unit tests
|
||||
```bash
|
||||
uv run pytest tests/ -m "not e2e" -v
|
||||
```
|
||||
|
||||
#### Run only integration tests
|
||||
```bash
|
||||
uv run pytest tests/ -m "integration" -v
|
||||
```
|
||||
|
||||
#### 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
|
||||
Install deps first
|
||||
|
||||
```
|
||||
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
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
## 🔧 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
|
||||
Create database from backup
|
||||
```
|
||||
./restdb.sh
|
||||
```
|
||||
|
||||
## 📈 Status
|
||||
Start local server
|
||||
```
|
||||
python3 server.py dev
|
||||
```
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
# How to do an authorized request
|
||||
|
||||
## 📄 License
|
||||
Put the header 'Authorization' with token from signIn query or registerUser mutation.
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
# How to debug Ackee
|
||||
|
||||
Set ACKEE_TOKEN var
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Получаем путь к корневой директории проекта
|
||||
root_path = Path(__file__).parent.parent
|
||||
sys.path.append(str(root_path))
|
||||
110
alembic.ini
Normal file
110
alembic.ini
Normal file
@@ -0,0 +1,110 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python-dateutil library that can be
|
||||
# installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to dateutil.tz.gettz()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = %(DB_URL)
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
3
alembic/README
Normal file
3
alembic/README
Normal file
@@ -0,0 +1,3 @@
|
||||
Generic single-database configuration.
|
||||
|
||||
https://alembic.sqlalchemy.org/en/latest/tutorial.html
|
||||
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 base.orm 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()
|
||||
26
alembic/script.py.mako
Normal file
26
alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
26
alembic/versions/fe943b098418_init_alembic.py
Normal file
26
alembic/versions/fe943b098418_init_alembic.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""init alembic
|
||||
|
||||
Revision ID: fe943b098418
|
||||
Revises:
|
||||
Create Date: 2023-08-19 01:37:57.031933
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
# import sqlalchemy as sa
|
||||
|
||||
# from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "fe943b098418"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
15
app.json
15
app.json
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"healthchecks": {
|
||||
"web": [
|
||||
{
|
||||
"type": "startup",
|
||||
"name": "web check",
|
||||
"description": "Checking if the app responds to the GET /",
|
||||
"path": "/",
|
||||
"attempts": 3,
|
||||
"warn": true,
|
||||
"initialDelay": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
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)
|
||||
89
auth/authenticate.py
Normal file
89
auth/authenticate.py
Normal file
@@ -0,0 +1,89 @@
|
||||
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.tokenstorage import SessionToken
|
||||
from base.exceptions import OperationNotAllowed
|
||||
from base.orm import local_session
|
||||
from orm.user import Role, User
|
||||
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):
|
||||
# debug only
|
||||
# print('[auth.authenticate] login required for %r with info %r' % (func, info))
|
||||
auth: AuthCredentials = info.context["request"].auth
|
||||
# print(auth)
|
||||
if not auth or not auth.logged_in:
|
||||
# raise Unauthorized(auth.error_message or "Please login")
|
||||
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
|
||||
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 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),
|
||||
}
|
||||
|
||||
async def permissions(self) -> list[Permission]:
|
||||
if self.author_id is None:
|
||||
# raise UnauthorizedError("Please login first")
|
||||
return [] # Возвращаем пустой список вместо dict
|
||||
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.author_id)
|
||||
return [] # Возвращаем пустой список вместо NotImplemented
|
||||
print(self.user_id)
|
||||
return NotImplemented
|
||||
|
||||
|
||||
class AuthUser(BaseModel):
|
||||
user_id: Optional[int]
|
||||
username: Optional[str]
|
||||
|
||||
@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)
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
from graphql.error import GraphQLError
|
||||
|
||||
# TODO: remove traceback from logs for defined exceptions
|
||||
|
||||
|
||||
class BaseHttpError(GraphQLError):
|
||||
code = 500
|
||||
message = "500 Server error"
|
||||
|
||||
|
||||
class ExpiredTokenError(BaseHttpError):
|
||||
code = 401
|
||||
message = "401 Expired Token"
|
||||
|
||||
|
||||
class InvalidTokenError(BaseHttpError):
|
||||
code = 401
|
||||
message = "401 Invalid Token"
|
||||
|
||||
|
||||
class UnauthorizedError(BaseHttpError):
|
||||
code = 401
|
||||
message = "401 UnauthorizedError"
|
||||
|
||||
|
||||
class ObjectNotExistError(BaseHttpError):
|
||||
code = 404
|
||||
message = "404 Object Does Not Exist"
|
||||
|
||||
|
||||
class OperationNotAllowedError(BaseHttpError):
|
||||
code = 403
|
||||
message = "403 Operation Is Not Allowed"
|
||||
|
||||
|
||||
class InvalidPasswordError(BaseHttpError):
|
||||
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 jwt import DecodeError, ExpiredSignatureError
|
||||
from passlib.hash import bcrypt
|
||||
|
||||
from auth.exceptions import ExpiredTokenError, InvalidPasswordError, InvalidTokenError
|
||||
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
|
||||
|
||||
AuthorType = TypeVar("AuthorType", bound=Author)
|
||||
# from base.exceptions import InvalidPassword, InvalidToken
|
||||
from base.orm import local_session
|
||||
from orm import User
|
||||
|
||||
|
||||
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 (22) | 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 InvalidTokenError:
|
||||
# raise InvalidTokenError("token format error") from e
|
||||
except ExpiredSignatureError:
|
||||
# raise InvalidToken("Login token has expired, please try again")
|
||||
return {"error": "Token has expired"}
|
||||
except DecodeError:
|
||||
# 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"]
|
||||
119
auth/jwtcodec.py
119
auth/jwtcodec.py
@@ -1,93 +1,52 @@
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import jwt
|
||||
|
||||
from settings import JWT_ALGORITHM, JWT_ISSUER, JWT_REFRESH_TOKEN_EXPIRE_DAYS, JWT_SECRET_KEY
|
||||
from base.exceptions import ExpiredToken, InvalidToken
|
||||
from settings import JWT_ALGORITHM, JWT_SECRET_KEY
|
||||
from validations.auth import AuthInput, TokenPayload
|
||||
|
||||
|
||||
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: AuthInput, 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) -> TokenPayload:
|
||||
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)
|
||||
1206
auth/oauth.py
1206
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
|
||||
@@ -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 base.redis import redis
|
||||
from settings import ONETIME_TOKEN_LIFE_SPAN, SESSION_TOKEN_LIFE_SPAN
|
||||
from validations.auth import AuthInput
|
||||
|
||||
|
||||
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)
|
||||
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,125 +0,0 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
# RFC 5322 compliant email regex pattern
|
||||
EMAIL_PATTERN = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
||||
|
||||
|
||||
class AuthInput(BaseModel):
|
||||
"""Base model for authentication input validation"""
|
||||
|
||||
user_id: str = Field(description="Unique user identifier")
|
||||
username: str = Field(min_length=2, max_length=50)
|
||||
token: str = Field(min_length=32)
|
||||
|
||||
@field_validator("user_id")
|
||||
@classmethod
|
||||
def validate_user_id(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
msg = "user_id cannot be empty"
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
class UserRegistrationInput(BaseModel):
|
||||
"""Validation model for user registration"""
|
||||
|
||||
email: str = Field(max_length=254) # Max email length per RFC 5321
|
||||
password: str = Field(min_length=8, max_length=100)
|
||||
name: str = Field(min_length=2, max_length=50)
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def validate_email(cls, v: str) -> str:
|
||||
"""Validate email format"""
|
||||
if not re.match(EMAIL_PATTERN, v):
|
||||
msg = "Invalid email format"
|
||||
raise ValueError(msg)
|
||||
return v.lower()
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
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)
|
||||
if not any(c.islower() for c in v):
|
||||
msg = "Password must contain at least one lowercase letter"
|
||||
raise ValueError(msg)
|
||||
if not any(c.isdigit() for c in v):
|
||||
msg = "Password must contain at least one number"
|
||||
raise ValueError(msg)
|
||||
if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in v):
|
||||
msg = "Password must contain at least one special character"
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
class UserLoginInput(BaseModel):
|
||||
"""Validation model for user login"""
|
||||
|
||||
email: str = Field(max_length=254)
|
||||
password: str = Field(min_length=8, max_length=100)
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def validate_email(cls, v: str) -> str:
|
||||
if not re.match(EMAIL_PATTERN, v):
|
||||
msg = "Invalid email format"
|
||||
raise ValueError(msg)
|
||||
return v.lower()
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
"""Validation model for JWT token payload"""
|
||||
|
||||
user_id: str
|
||||
username: str
|
||||
exp: datetime
|
||||
iat: datetime
|
||||
scopes: list[str] | None = []
|
||||
|
||||
|
||||
class OAuthInput(BaseModel):
|
||||
"""Validation model for OAuth input"""
|
||||
|
||||
provider: str = Field(pattern="^(google|github|facebook)$")
|
||||
code: str
|
||||
redirect_uri: str | None = 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)
|
||||
return v.lower()
|
||||
|
||||
|
||||
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
|
||||
|
||||
@field_validator("error")
|
||||
@classmethod
|
||||
def validate_error_if_not_success(cls, v: str | None, info) -> str | None:
|
||||
if not info.data.get("success") and not v:
|
||||
msg = "Error message required when success is False"
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
@field_validator("token")
|
||||
@classmethod
|
||||
def validate_token_if_success(cls, v: str | None, info) -> str | None:
|
||||
if info.data.get("success") and not v:
|
||||
msg = "Token required when success is True"
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
38
base/exceptions.py
Normal file
38
base/exceptions.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from graphql.error import GraphQLError
|
||||
|
||||
# TODO: remove traceback from logs for defined exceptions
|
||||
|
||||
|
||||
class BaseHttpException(GraphQLError):
|
||||
code = 500
|
||||
message = "500 Server error"
|
||||
|
||||
|
||||
class ExpiredToken(BaseHttpException):
|
||||
code = 401
|
||||
message = "401 Expired Token"
|
||||
|
||||
|
||||
class InvalidToken(BaseHttpException):
|
||||
code = 401
|
||||
message = "401 Invalid Token"
|
||||
|
||||
|
||||
class Unauthorized(BaseHttpException):
|
||||
code = 401
|
||||
message = "401 Unauthorized"
|
||||
|
||||
|
||||
class ObjectNotExist(BaseHttpException):
|
||||
code = 404
|
||||
message = "404 Object Does Not Exist"
|
||||
|
||||
|
||||
class OperationNotAllowed(BaseHttpException):
|
||||
code = 403
|
||||
message = "403 Operation Is Not Allowed"
|
||||
|
||||
|
||||
class InvalidPassword(BaseHttpException):
|
||||
code = 403
|
||||
message = "403 Invalid Password"
|
||||
57
base/orm.py
Normal file
57
base/orm.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from typing import Any, Callable, Dict, Generic, TypeVar
|
||||
|
||||
from sqlalchemy import Column, Integer, create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql.schema import Table
|
||||
|
||||
from settings import DB_URL
|
||||
|
||||
engine = create_engine(DB_URL, echo=False, pool_size=10, max_overflow=20)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
REGISTRY: Dict[str, type] = {}
|
||||
|
||||
|
||||
def local_session():
|
||||
return Session(bind=engine, expire_on_commit=False)
|
||||
|
||||
|
||||
DeclarativeBase = declarative_base() # type: Any
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
__table__: Table
|
||||
__tablename__: str
|
||||
__new__: Callable
|
||||
__init__: Callable
|
||||
__allow_unmapped__ = True
|
||||
__abstract__ = True
|
||||
__table_args__ = {"extend_existing": True}
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
REGISTRY[cls.__name__] = cls
|
||||
|
||||
@classmethod
|
||||
def create(cls: Generic[T], **kwargs) -> Generic[T]:
|
||||
instance = cls(**kwargs)
|
||||
return instance.save()
|
||||
|
||||
def save(self) -> Generic[T]:
|
||||
with local_session() as session:
|
||||
session.add(self)
|
||||
session.commit()
|
||||
return self
|
||||
|
||||
def update(self, input):
|
||||
column_names = self.__table__.columns.keys()
|
||||
for name, value in input.items():
|
||||
if name in column_names:
|
||||
setattr(self, name, value)
|
||||
|
||||
def dict(self) -> Dict[str, Any]:
|
||||
column_names = self.__table__.columns.keys()
|
||||
return {c: getattr(self, c) for c in column_names}
|
||||
62
base/redis.py
Normal file
62
base/redis.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import redis.asyncio as aredis
|
||||
|
||||
from settings import REDIS_URL
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("[services.redis] ")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
class RedisCache:
|
||||
def __init__(self, uri=REDIS_URL):
|
||||
self._uri: str = uri
|
||||
self.pubsub_channels = []
|
||||
self._client = None
|
||||
|
||||
async def connect(self):
|
||||
self._client = aredis.Redis.from_url(self._uri, decode_responses=True)
|
||||
|
||||
async def disconnect(self):
|
||||
if self._client:
|
||||
await self._client.close()
|
||||
|
||||
async def execute(self, command, *args, **kwargs):
|
||||
if self._client:
|
||||
try:
|
||||
logger.debug(f"{command} {args} {kwargs}")
|
||||
r = await self._client.execute_command(command, *args, **kwargs)
|
||||
logger.debug(type(r))
|
||||
logger.debug(r)
|
||||
return r
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
async def subscribe(self, *channels):
|
||||
if self._client:
|
||||
async with self._client.pubsub() as pubsub:
|
||||
for channel in channels:
|
||||
await pubsub.subscribe(channel)
|
||||
self.pubsub_channels.append(channel)
|
||||
|
||||
async def unsubscribe(self, *channels):
|
||||
if not self._client:
|
||||
return
|
||||
async with self._client.pubsub() as pubsub:
|
||||
for channel in channels:
|
||||
await pubsub.unsubscribe(channel)
|
||||
self.pubsub_channels.remove(channel)
|
||||
|
||||
async def publish(self, channel, data):
|
||||
if not self._client:
|
||||
return
|
||||
await self._client.publish(channel, data)
|
||||
|
||||
async def mget(self, *keys):
|
||||
return await self.execute('MGET', *keys)
|
||||
|
||||
async def lrange(self, key, start, stop):
|
||||
return await self.execute('LRANGE', key, start, stop)
|
||||
|
||||
redis = RedisCache()
|
||||
|
||||
__all__ = ["redis"]
|
||||
13
base/resolvers.py
Normal file
13
base/resolvers.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from ariadne import MutationType, QueryType, ScalarType
|
||||
|
||||
datetime_scalar = ScalarType("DateTime")
|
||||
|
||||
|
||||
@datetime_scalar.serializer
|
||||
def serialize_datetime(value):
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
query = QueryType()
|
||||
mutation = MutationType()
|
||||
resolvers = [query, mutation, datetime_scalar]
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
989
cache/cache.py
vendored
989
cache/cache.py
vendored
@@ -1,989 +0,0 @@
|
||||
"""
|
||||
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")
|
||||
|
||||
2. CORE FUNCTIONS:
|
||||
ery(): 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
|
||||
|
||||
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)
|
||||
|
||||
To maintain consistency with the existing codebase, this module preserves
|
||||
the original key naming patterns while providing a more structured approach
|
||||
for new cache operations.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import traceback
|
||||
from typing import Any, Callable, Dict, List, Type
|
||||
|
||||
import orjson
|
||||
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 utils.logger import root_logger as logger
|
||||
|
||||
DEFAULT_FOLLOWS = {
|
||||
"topics": [],
|
||||
"authors": [],
|
||||
"shouts": [],
|
||||
"communities": [{"id": 1, "name": "Дискурс", "slug": "discours", "pic": ""}],
|
||||
}
|
||||
|
||||
CACHE_TTL = 300 # 5 minutes
|
||||
|
||||
# Key templates for common entity types
|
||||
# These are used throughout the codebase and should be maintained for compatibility
|
||||
CACHE_KEYS = {
|
||||
"TOPIC_ID": "topic:id:{}",
|
||||
"TOPIC_SLUG": "topic:slug:{}",
|
||||
"TOPIC_AUTHORS": "topic:authors:{}",
|
||||
"TOPIC_FOLLOWERS": "topic:followers:{}",
|
||||
"TOPIC_SHOUTS": "topic_shouts_{}",
|
||||
"AUTHOR_ID": "author:id:{}",
|
||||
"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)
|
||||
await asyncio.gather(
|
||||
redis.execute("SET", f"topic:id:{topic['id']}", payload),
|
||||
redis.execute("SET", f"topic:slug:{topic['slug']}", payload),
|
||||
)
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
# Cache follows data
|
||||
async def cache_follows(follower_id: int, entity_type: str, entity_id: int, is_insert: bool = True) -> None:
|
||||
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 = []
|
||||
|
||||
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 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
|
||||
|
||||
|
||||
# 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}")
|
||||
|
||||
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] Данные не найдены в кэше, загрузка из БД")
|
||||
|
||||
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} не найден в БД")
|
||||
return None
|
||||
|
||||
|
||||
# Function to get cached topic
|
||||
async def get_cached_topic(topic_id: int) -> dict | None:
|
||||
"""
|
||||
Fetch topic data from cache or database by id.
|
||||
|
||||
Args:
|
||||
topic_id (int): The identifier for the topic.
|
||||
|
||||
Returns:
|
||||
dict: Topic data or None if not found.
|
||||
"""
|
||||
topic_key = f"topic:id:{topic_id}"
|
||||
cached_topic = await redis.execute("GET", topic_key)
|
||||
if cached_topic:
|
||||
return orjson.loads(cached_topic)
|
||||
|
||||
# If not in cache, fetch from the database
|
||||
with local_session() as session:
|
||||
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))
|
||||
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:
|
||||
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:
|
||||
topic_dict = topics[0].dict()
|
||||
await cache_topic(topic_dict)
|
||||
return topic_dict
|
||||
return None
|
||||
|
||||
|
||||
# Get list of authors by ID from cache
|
||||
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))
|
||||
authors = [orjson.loads(result) if result else None for result in results]
|
||||
# Load missing authors from database and cache
|
||||
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()
|
||||
await asyncio.gather(*(cache_author(author.dict()) for author in missing_authors))
|
||||
for index, author in zip(missing_indices, missing_authors, strict=False):
|
||||
authors[index] = author.dict()
|
||||
# Фильтруем None значения для корректного типа возвращаемого значения
|
||||
return [author for author in authors if author is not None]
|
||||
|
||||
|
||||
async def get_cached_topic_followers(topic_id: int):
|
||||
"""
|
||||
Получает подписчиков темы по ID, используя кеш Redis.
|
||||
|
||||
Args:
|
||||
topic_id: ID темы
|
||||
|
||||
Returns:
|
||||
List[dict]: Список подписчиков с их данными
|
||||
"""
|
||||
try:
|
||||
cache_key = CACHE_KEYS["TOPIC_FOLLOWERS"].format(topic_id)
|
||||
cached = await redis.execute("GET", cache_key)
|
||||
|
||||
if cached:
|
||||
followers_ids = orjson.loads(cached)
|
||||
logger.debug(f"Found {len(followers_ids)} cached followers for topic #{topic_id}")
|
||||
return await get_cached_authors_by_ids(followers_ids)
|
||||
|
||||
with local_session() as session:
|
||||
followers_ids = [
|
||||
f[0]
|
||||
for f in session.query(Author.id)
|
||||
.join(TopicFollower, TopicFollower.follower == Author.id)
|
||||
.where(TopicFollower.topic == topic_id)
|
||||
.all()
|
||||
]
|
||||
|
||||
await redis.execute("SETEX", cache_key, CACHE_TTL, fast_json_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}")
|
||||
return []
|
||||
|
||||
|
||||
# Get cached author followers
|
||||
async def get_cached_author_followers(author_id: int):
|
||||
# Check cache for data
|
||||
cached = await redis.execute("GET", f"author:followers:{author_id}")
|
||||
if cached:
|
||||
followers_ids = orjson.loads(cached)
|
||||
followers = await get_cached_authors_by_ids(followers_ids)
|
||||
logger.debug(f"Cached followers for author #{author_id}: {len(followers)}")
|
||||
return followers
|
||||
|
||||
# Query database if cache is empty
|
||||
with local_session() as session:
|
||||
followers_ids = [
|
||||
f[0]
|
||||
for f in session.query(Author.id)
|
||||
.join(AuthorFollower, AuthorFollower.follower == Author.id)
|
||||
.where(AuthorFollower.following == 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)
|
||||
|
||||
|
||||
# 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)
|
||||
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))
|
||||
.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}"
|
||||
)
|
||||
|
||||
return await get_cached_authors_by_ids(authors_ids)
|
||||
|
||||
|
||||
# Get cached follower topics
|
||||
async def get_cached_follower_topics(author_id: int):
|
||||
# Attempt to retrieve topics from cache
|
||||
cached = await redis.execute("GET", f"author:follows-topics:{author_id}")
|
||||
if cached:
|
||||
topics_ids = orjson.loads(cached)
|
||||
else:
|
||||
# Load topics from database and cache them
|
||||
with local_session() as session:
|
||||
topics_ids = [
|
||||
t[0]
|
||||
for t in session.query(Topic.id)
|
||||
.join(TopicFollower, TopicFollower.topic == Topic.id)
|
||||
.where(TopicFollower.follower == author_id)
|
||||
.all()
|
||||
]
|
||||
await redis.execute("SET", f"author:follows-topics:{author_id}", fast_json_dumps(topics_ids))
|
||||
|
||||
topics = []
|
||||
for topic_id in topics_ids:
|
||||
topic_str = await redis.execute("GET", f"topic:id:{topic_id}")
|
||||
if topic_str:
|
||||
topic = orjson.loads(topic_str)
|
||||
if topic and topic not in topics:
|
||||
topics.append(topic)
|
||||
|
||||
logger.debug(f"Cached topics for author#{author_id}: {len(topics)}")
|
||||
return topics
|
||||
|
||||
|
||||
# Get author by author_id from cache
|
||||
async def get_cached_author_by_id(author_id: int, get_with_stat=None):
|
||||
"""
|
||||
Retrieve author information by author_id, checking the cache first, then the database.
|
||||
|
||||
Args:
|
||||
author_id (int): The author 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)
|
||||
|
||||
author_query = select(Author).where(Author.id == author_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)),
|
||||
)
|
||||
return author_dict
|
||||
|
||||
# Return None if author is not found
|
||||
return None
|
||||
|
||||
|
||||
# Get cached topic authors
|
||||
async def get_cached_topic_authors(topic_id: int):
|
||||
"""
|
||||
Retrieve a list of authors for a given topic, using cache or database.
|
||||
|
||||
Args:
|
||||
topic_id (int): The identifier of the topic for which to retrieve authors.
|
||||
|
||||
Returns:
|
||||
List[dict]: A list of dictionaries containing author data.
|
||||
"""
|
||||
# Attempt to get a list of author IDs from cache
|
||||
rkey = f"topic:authors:{topic_id}"
|
||||
cached_authors_ids = await redis.execute("GET", rkey)
|
||||
if cached_authors_ids:
|
||||
authors_ids = orjson.loads(cached_authors_ids)
|
||||
else:
|
||||
# If cache is empty, get data from the database
|
||||
with local_session() as session:
|
||||
query = (
|
||||
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),
|
||||
)
|
||||
)
|
||||
)
|
||||
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))
|
||||
|
||||
# Retrieve full author details from cached IDs
|
||||
if authors_ids:
|
||||
authors = await get_cached_authors_by_ids(authors_ids)
|
||||
logger.debug(f"Topic#{topic_id} authors fetched and cached: {len(authors)} authors found.")
|
||||
return authors
|
||||
|
||||
return []
|
||||
|
||||
|
||||
async def invalidate_shouts_cache(cache_keys: list[str]) -> None:
|
||||
"""
|
||||
Инвалидирует кэш выборок публикаций по переданным ключам.
|
||||
"""
|
||||
for cache_key in cache_keys:
|
||||
try:
|
||||
# Удаляем основной кэш
|
||||
await redis.execute("DEL", cache_key)
|
||||
logger.debug(f"Invalidated cache key: {cache_key}")
|
||||
|
||||
# Добавляем ключ в список инвалидированных с TTL
|
||||
await redis.execute("SETEX", f"{cache_key}:invalidated", CACHE_TTL, "1")
|
||||
|
||||
# Если это кэш темы, инвалидируем также связанные ключи
|
||||
if cache_key.startswith("topic_"):
|
||||
topic_id = cache_key.split("_")[1]
|
||||
related_keys = [
|
||||
f"topic:id:{topic_id}",
|
||||
f"topic:authors:{topic_id}",
|
||||
f"topic:followers:{topic_id}",
|
||||
f"topic:stats:{topic_id}",
|
||||
]
|
||||
for related_key in related_keys:
|
||||
await redis.execute("DEL", related_key)
|
||||
logger.debug(f"Invalidated related key: {related_key}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error invalidating cache key {cache_key}: {e}")
|
||||
|
||||
|
||||
async def cache_topic_shouts(topic_id: int, shouts: list[dict]) -> None:
|
||||
"""Кэширует список публикаций для темы"""
|
||||
key = f"topic_shouts_{topic_id}"
|
||||
payload = fast_json_dumps(shouts)
|
||||
await redis.execute("SETEX", key, CACHE_TTL, payload)
|
||||
|
||||
|
||||
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 []
|
||||
|
||||
|
||||
async def cache_related_entities(shout: Shout) -> None:
|
||||
"""
|
||||
Кэширует все связанные с публикацией сущности (авторов и темы)
|
||||
"""
|
||||
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)
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
|
||||
async def invalidate_shout_related_cache(shout: Shout, author_id: int) -> None:
|
||||
"""
|
||||
Инвалидирует весь кэш, связанный с публикацией и её связями
|
||||
|
||||
Args:
|
||||
shout: Объект публикации
|
||||
author_id: ID автора
|
||||
"""
|
||||
cache_keys = {
|
||||
"feed", # основная лента
|
||||
f"author_{author_id}", # публикации автора
|
||||
"random_top", # случайные топовые
|
||||
"unrated", # неоцененные
|
||||
"recent", # последние
|
||||
"coauthored", # совместные
|
||||
# 🔧 Добавляем ключи с featured материалами
|
||||
"featured", # featured публикации
|
||||
"featured:recent", # недавние featured
|
||||
"featured:top", # топ featured
|
||||
}
|
||||
|
||||
# Добавляем ключи авторов
|
||||
cache_keys.update(f"author_{a.id}" for a in shout.authors)
|
||||
cache_keys.update(f"authored_{a.id}" for a in shout.authors)
|
||||
|
||||
# Добавляем ключи тем
|
||||
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))
|
||||
|
||||
|
||||
# Function removed - direct Redis calls used throughout the module instead
|
||||
|
||||
|
||||
async def get_cached_entity(entity_type: str, entity_id: int, get_method, cache_method):
|
||||
"""
|
||||
Универсальная функция получения кэшированной сущности
|
||||
|
||||
Args:
|
||||
entity_type: 'author' или 'topic'
|
||||
entity_id: ID сущности
|
||||
get_method: метод получения из БД
|
||||
cache_method: метод кэширования
|
||||
"""
|
||||
key = f"{entity_type}:id:{entity_id}"
|
||||
cached = await redis.execute("GET", key)
|
||||
if cached:
|
||||
return orjson.loads(cached)
|
||||
|
||||
entity = await get_method(entity_id)
|
||||
if entity:
|
||||
await cache_method(entity)
|
||||
return entity
|
||||
return None
|
||||
|
||||
|
||||
async def cache_by_id(entity, entity_id: int, cache_method, get_with_stat=None):
|
||||
"""
|
||||
Кэширует сущность по ID, используя указанный метод кэширования
|
||||
|
||||
Args:
|
||||
entity: класс сущности (Author/Topic)
|
||||
entity_id: ID сущности
|
||||
cache_method: функция кэширования
|
||||
"""
|
||||
|
||||
if get_with_stat is None:
|
||||
pass # get_with_stat уже импортирован на верхнем уровне
|
||||
|
||||
caching_query = select(entity).where(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
|
||||
x = result[0]
|
||||
d = x.dict()
|
||||
await cache_method(d)
|
||||
return d
|
||||
|
||||
|
||||
# Универсальная функция для сохранения данных в кеш
|
||||
async def cache_data(key: str, data: Any, ttl: int | None = None) -> None:
|
||||
"""
|
||||
Сохраняет данные в кеш по указанному ключу.
|
||||
|
||||
Args:
|
||||
key: Ключ кеша
|
||||
data: Данные для сохранения
|
||||
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")
|
||||
if ttl:
|
||||
await redis.execute("SETEX", key, ttl, payload)
|
||||
else:
|
||||
await redis.execute("SET", key, payload)
|
||||
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:
|
||||
"""
|
||||
Получает данные из кеша по указанному ключу.
|
||||
|
||||
Args:
|
||||
key: Ключ кеша
|
||||
|
||||
Returns:
|
||||
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
|
||||
|
||||
|
||||
# Универсальная функция для инвалидации кеша по префиксу
|
||||
async def invalidate_cache_by_prefix(prefix: str) -> None:
|
||||
"""
|
||||
Инвалидирует все ключи кеша с указанным префиксом.
|
||||
|
||||
Args:
|
||||
prefix: Префикс ключей кеша для инвалидации
|
||||
"""
|
||||
try:
|
||||
keys = await redis.execute("KEYS", f"{prefix}:*")
|
||||
if keys:
|
||||
await redis.execute("DEL", *keys)
|
||||
logger.debug(f"Удалено {len(keys)} ключей кеша с префиксом {prefix}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при инвалидации кеша: {e}")
|
||||
|
||||
|
||||
# Универсальная функция для получения и кеширования данных
|
||||
async def cached_query(
|
||||
cache_key: str,
|
||||
query_func: Callable,
|
||||
ttl: int | None = None,
|
||||
force_refresh: bool = False,
|
||||
use_key_format: bool = True,
|
||||
**query_params,
|
||||
) -> Any:
|
||||
"""
|
||||
Gets data from cache or executes query and saves result to cache.
|
||||
Supports existing key formats for compatibility.
|
||||
|
||||
Args:
|
||||
cache_key: Cache key or key template from CACHE_KEYS
|
||||
query_func: Function to execute the query
|
||||
ttl: Cache TTL in seconds (None - indefinite)
|
||||
force_refresh: Force cache refresh
|
||||
use_key_format: Whether to check if cache_key matches a key template in CACHE_KEYS
|
||||
**query_params: Parameters to pass to the query function
|
||||
|
||||
Returns:
|
||||
Any: Data from cache or query result
|
||||
"""
|
||||
# Check if cache_key matches a pattern in CACHE_KEYS
|
||||
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():
|
||||
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():
|
||||
if param_name in ["id", "slug", "user", "topic_id", "author_id"]:
|
||||
actual_key = cache_key.format(param_value)
|
||||
break
|
||||
|
||||
# If not forcing refresh, try to get data from cache
|
||||
if not force_refresh:
|
||||
cached_result = await get_cached_data(actual_key)
|
||||
if cached_result is not None:
|
||||
return cached_result
|
||||
|
||||
# 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
|
||||
253
cache/precache.py
vendored
253
cache/precache.py
vendored
@@ -1,253 +0,0 @@
|
||||
import asyncio
|
||||
import traceback
|
||||
|
||||
import orjson
|
||||
from sqlalchemy import and_, func, 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 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)
|
||||
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))
|
||||
await redis.execute("SET", f"author:followers:{author_id}", followers_payload)
|
||||
|
||||
|
||||
# Предварительное кеширование подписок автора
|
||||
async def precache_authors_follows(author_id, session) -> None:
|
||||
follows_topics_query = select(TopicFollower.topic).where(TopicFollower.follower == author_id)
|
||||
follows_authors_query = select(AuthorFollower.following).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))
|
||||
|
||||
await asyncio.gather(
|
||||
redis.execute("SET", f"author:follows-topics:{author_id}", topics_payload),
|
||||
redis.execute("SET", f"author:follows-authors:{author_id}", authors_payload),
|
||||
redis.execute("SET", f"author:follows-shouts:{author_id}", shouts_payload),
|
||||
)
|
||||
|
||||
|
||||
# Предварительное кеширование авторов тем
|
||||
async def precache_topics_authors(topic_id: int, session) -> None:
|
||||
topic_authors_query = (
|
||||
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),
|
||||
)
|
||||
)
|
||||
)
|
||||
topic_authors = {row[0] for row in session.execute(topic_authors_query) if row[0]}
|
||||
|
||||
authors_payload = fast_json_dumps(list(topic_authors))
|
||||
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]}
|
||||
|
||||
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([]))
|
||||
|
||||
|
||||
async def precache_data() -> None:
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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")
|
||||
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:
|
||||
await cache_author(profile)
|
||||
await asyncio.gather(
|
||||
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:
|
||||
traceback.print_exc()
|
||||
logger.error(f"Error in precache_data: {exc}")
|
||||
181
cache/revalidator.py
vendored
181
cache/revalidator.py
vendored
@@ -1,181 +0,0 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
|
||||
from cache.cache import (
|
||||
cache_author,
|
||||
cache_topic,
|
||||
get_cached_author,
|
||||
get_cached_topic,
|
||||
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:
|
||||
"""Инициализация менеджера с заданным интервалом проверки (в секундах)."""
|
||||
self.interval = interval
|
||||
self.items_to_revalidate: dict[str, set[str]] = {
|
||||
"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:
|
||||
"""Запуск фонового воркера для ревалидации кэша."""
|
||||
# Проверяем, что у нас есть соединение с 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:
|
||||
"""Циклическая проверка и ревалидация кэша каждые self.interval секунд."""
|
||||
try:
|
||||
while self.running:
|
||||
await asyncio.sleep(self.interval)
|
||||
await self.process_revalidation()
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Revalidation worker was stopped.")
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred in the revalidation worker: {e}")
|
||||
|
||||
async def process_revalidation(self) -> None:
|
||||
"""Обновление кэша для всех сущностей, требующих ревалидации."""
|
||||
# Проверяем соединение с Redis
|
||||
if not self._redis._client:
|
||||
return # Выходим из метода, если не удалось подключиться
|
||||
|
||||
async with self.lock:
|
||||
# Ревалидация кэша авторов
|
||||
if self.items_to_revalidate["authors"]:
|
||||
logger.debug(f"Revalidating {len(self.items_to_revalidate['authors'])} authors")
|
||||
for author_id in self.items_to_revalidate["authors"]:
|
||||
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}")
|
||||
self.items_to_revalidate["authors"].clear()
|
||||
|
||||
# Ревалидация кэша тем
|
||||
if self.items_to_revalidate["topics"]:
|
||||
logger.debug(f"Revalidating {len(self.items_to_revalidate['topics'])} topics")
|
||||
for topic_id in self.items_to_revalidate["topics"]:
|
||||
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}")
|
||||
self.items_to_revalidate["topics"].clear()
|
||||
|
||||
# Ревалидация шаутов (публикаций)
|
||||
if self.items_to_revalidate["shouts"]:
|
||||
shouts_count = len(self.items_to_revalidate["shouts"])
|
||||
logger.debug(f"Revalidating {shouts_count} shouts")
|
||||
|
||||
# Проверяем наличие специального флага 'all'
|
||||
if "all" in self.items_to_revalidate["shouts"]:
|
||||
await invalidate_cache_by_prefix("shouts")
|
||||
# Если элементов много, но не 'all', используем специфический подход
|
||||
elif shouts_count > self.MAX_BATCH_SIZE:
|
||||
# Инвалидируем только collections keys, которые затрагивают много сущностей
|
||||
collection_keys = await asyncio.create_task(self._redis.execute("KEYS", "shouts:*"))
|
||||
if collection_keys:
|
||||
await self._redis.execute("DEL", *collection_keys)
|
||||
logger.debug(f"Удалено {len(collection_keys)} коллекционных ключей шаутов")
|
||||
|
||||
# Обновляем кеш каждого конкретного шаута
|
||||
for shout_id in self.items_to_revalidate["shouts"]:
|
||||
if shout_id != "all":
|
||||
# Точечная инвалидация для каждого shout_id
|
||||
specific_keys = [f"shout:id:{shout_id}"]
|
||||
for key in specific_keys:
|
||||
await self._redis.execute("DEL", key)
|
||||
logger.debug(f"Удален ключ кеша {key}")
|
||||
else:
|
||||
# Если элементов немного, обрабатываем каждый
|
||||
for shout_id in self.items_to_revalidate["shouts"]:
|
||||
if shout_id != "all":
|
||||
# Точечная инвалидация для каждого shout_id
|
||||
specific_keys = [f"shout:id:{shout_id}"]
|
||||
for key in specific_keys:
|
||||
await self._redis.execute("DEL", key)
|
||||
logger.debug(f"Удален ключ кеша {key}")
|
||||
|
||||
self.items_to_revalidate["shouts"].clear()
|
||||
|
||||
# Аналогично для реакций - точечная инвалидация
|
||||
if self.items_to_revalidate["reactions"]:
|
||||
reactions_count = len(self.items_to_revalidate["reactions"])
|
||||
logger.debug(f"Revalidating {reactions_count} reactions")
|
||||
|
||||
if "all" in self.items_to_revalidate["reactions"]:
|
||||
await invalidate_cache_by_prefix("reactions")
|
||||
elif reactions_count > self.MAX_BATCH_SIZE:
|
||||
# Инвалидируем только collections keys для реакций
|
||||
collection_keys = await asyncio.create_task(self._redis.execute("KEYS", "reactions:*"))
|
||||
if collection_keys:
|
||||
await self._redis.execute("DEL", *collection_keys)
|
||||
logger.debug(f"Удалено {len(collection_keys)} коллекционных ключей реакций")
|
||||
|
||||
# Точечная инвалидация для каждой реакции
|
||||
for reaction_id in self.items_to_revalidate["reactions"]:
|
||||
if reaction_id != "all":
|
||||
specific_keys = [f"reaction:id:{reaction_id}"]
|
||||
for key in specific_keys:
|
||||
await self._redis.execute("DEL", key)
|
||||
logger.debug(f"Удален ключ кеша {key}")
|
||||
else:
|
||||
# Точечная инвалидация для каждой реакции
|
||||
for reaction_id in self.items_to_revalidate["reactions"]:
|
||||
if reaction_id != "all":
|
||||
specific_keys = [f"reaction:id:{reaction_id}"]
|
||||
for key in specific_keys:
|
||||
await self._redis.execute("DEL", key)
|
||||
logger.debug(f"Удален ключ кеша {key}")
|
||||
|
||||
self.items_to_revalidate["reactions"].clear()
|
||||
|
||||
def mark_for_revalidation(self, entity_id, entity_type) -> None:
|
||||
"""Отметить сущность для ревалидации."""
|
||||
if entity_id and entity_type:
|
||||
self.items_to_revalidate[entity_type].add(entity_id)
|
||||
|
||||
def invalidate_all(self, entity_type) -> None:
|
||||
"""Пометить для инвалидации все элементы указанного типа."""
|
||||
logger.debug(f"Marking all {entity_type} for invalidation")
|
||||
# Особый флаг для полной инвалидации
|
||||
self.items_to_revalidate[entity_type].add("all")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Остановка фонового воркера."""
|
||||
self.running = False
|
||||
if hasattr(self, "task"):
|
||||
self.task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self.task
|
||||
|
||||
|
||||
revalidation_manager = CacheRevalidationManager()
|
||||
148
cache/triggers.py
vendored
148
cache/triggers.py
vendored
@@ -1,148 +0,0 @@
|
||||
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 utils.logger import root_logger as logger
|
||||
|
||||
|
||||
def mark_for_revalidation(entity, *args) -> None:
|
||||
"""Отметка сущности для ревалидации."""
|
||||
entity_type = (
|
||||
"authors"
|
||||
if isinstance(entity, Author)
|
||||
else "topics"
|
||||
if isinstance(entity, Topic)
|
||||
else "reactions"
|
||||
if isinstance(entity, Reaction)
|
||||
else "shouts"
|
||||
if isinstance(entity, Shout)
|
||||
else None
|
||||
)
|
||||
if entity_type:
|
||||
revalidation_manager.mark_for_revalidation(entity.id, entity_type)
|
||||
|
||||
|
||||
def after_follower_handler(mapper, connection, target, is_delete=False) -> None:
|
||||
"""Обработчик добавления, обновления или удаления подписки."""
|
||||
entity_type = None
|
||||
if isinstance(target, AuthorFollower):
|
||||
entity_type = "authors"
|
||||
elif isinstance(target, TopicFollower):
|
||||
entity_type = "topics"
|
||||
elif isinstance(target, ShoutReactionsFollower):
|
||||
entity_type = "shouts"
|
||||
|
||||
if entity_type:
|
||||
revalidation_manager.mark_for_revalidation(
|
||||
target.following 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:
|
||||
"""Обработчик изменения статуса публикации"""
|
||||
if not isinstance(target, Shout):
|
||||
return
|
||||
|
||||
# Проверяем изменение статуса публикации
|
||||
# was_published = target.published_at is not None and target.deleted_at is None
|
||||
|
||||
# Всегда обновляем счетчики для авторов и тем при любом изменении поста
|
||||
for author in target.authors:
|
||||
revalidation_manager.mark_for_revalidation(author.id, "authors")
|
||||
|
||||
for topic in target.topics:
|
||||
revalidation_manager.mark_for_revalidation(topic.id, "topics")
|
||||
|
||||
# Обновляем сам пост
|
||||
revalidation_manager.mark_for_revalidation(target.id, "shouts")
|
||||
|
||||
|
||||
def after_reaction_handler(mapper, connection, target) -> None:
|
||||
"""Обработчик для комментариев"""
|
||||
if not isinstance(target, Reaction):
|
||||
return
|
||||
|
||||
# Проверяем что это комментарий
|
||||
is_comment = target.kind == ReactionKind.COMMENT.value
|
||||
|
||||
# Получаем связанный пост
|
||||
shout_id = target.shout if isinstance(target.shout, int) else target.shout.id
|
||||
if not shout_id:
|
||||
return
|
||||
|
||||
# Обновляем счетчики для автора комментария
|
||||
if target.created_by:
|
||||
revalidation_manager.mark_for_revalidation(target.created_by, "authors")
|
||||
|
||||
# Обновляем счетчики для поста
|
||||
revalidation_manager.mark_for_revalidation(shout_id, "shouts")
|
||||
|
||||
if is_comment:
|
||||
# Для комментариев обновляем также авторов и темы
|
||||
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),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if shout:
|
||||
for author in shout.authors:
|
||||
revalidation_manager.mark_for_revalidation(author.id, "authors")
|
||||
|
||||
for topic in shout.topics:
|
||||
revalidation_manager.mark_for_revalidation(topic.id, "topics")
|
||||
|
||||
|
||||
def events_register() -> None:
|
||||
"""Регистрация обработчиков событий для всех сущностей."""
|
||||
event.listen(ShoutAuthor, "after_insert", mark_for_revalidation)
|
||||
event.listen(ShoutAuthor, "after_update", mark_for_revalidation)
|
||||
event.listen(ShoutAuthor, "after_delete", mark_for_revalidation)
|
||||
|
||||
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(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(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(Reaction, "after_update", mark_for_revalidation)
|
||||
event.listen(Author, "after_update", mark_for_revalidation)
|
||||
event.listen(Topic, "after_update", mark_for_revalidation)
|
||||
event.listen(Shout, "after_update", after_shout_handler)
|
||||
event.listen(Shout, "after_delete", after_shout_handler)
|
||||
|
||||
event.listen(Reaction, "after_insert", after_reaction_handler)
|
||||
event.listen(Reaction, "after_update", after_reaction_handler)
|
||||
event.listen(Reaction, "after_delete", after_reaction_handler)
|
||||
|
||||
logger.info("Event handlers registered successfully.")
|
||||
10
checks.sh
Executable file
10
checks.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "> isort"
|
||||
isort .
|
||||
echo "> black"
|
||||
black .
|
||||
echo "> flake8"
|
||||
flake8 .
|
||||
# echo "> mypy"
|
||||
# mypy .
|
||||
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)
|
||||
- Большие объёмы данных могут замедлять запросы без кеша
|
||||
- Сложные запросы сортировки требуют больше ресурсов
|
||||
408
docs/caching.md
408
docs/caching.md
@@ -1,408 +0,0 @@
|
||||
# Система кеширования Discours
|
||||
|
||||
## Общее описание
|
||||
|
||||
Система кеширования Discours - это комплексное решение для повышения производительности платформы. Она использует Redis для хранения часто запрашиваемых данных и уменьшения нагрузки на основную базу данных.
|
||||
|
||||
Кеширование реализовано как многоуровневая система, состоящая из нескольких модулей:
|
||||
|
||||
- `cache.py` - основной модуль с функциями кеширования
|
||||
- `revalidator.py` - асинхронный менеджер ревалидации кеша
|
||||
- `triggers.py` - триггеры событий SQLAlchemy для автоматической ревалидации
|
||||
- `precache.py` - предварительное кеширование данных при старте приложения
|
||||
|
||||
## Ключевые компоненты
|
||||
|
||||
### 1. Форматы ключей кеша
|
||||
|
||||
Система поддерживает несколько форматов ключей для обеспечения совместимости и удобства использования:
|
||||
|
||||
- **Ключи сущностей**: `entity:property:value` (например, `author:id:123`)
|
||||
- **Ключи коллекций**: `entity:collection:params` (например, `authors:stats:limit=10:offset=0`)
|
||||
- **Специальные ключи**: для обратной совместимости (например, `topic_shouts_123`)
|
||||
|
||||
Все стандартные форматы ключей хранятся в словаре `CACHE_KEYS`:
|
||||
|
||||
```python
|
||||
CACHE_KEYS = {
|
||||
"TOPIC_ID": "topic:id:{}",
|
||||
"TOPIC_SLUG": "topic:slug:{}",
|
||||
"AUTHOR_ID": "author:id:{}",
|
||||
# и другие...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Основные функции кеширования
|
||||
|
||||
#### Структура ключей
|
||||
|
||||
Вместо генерации ключей через вспомогательные функции, система следует строгим конвенциям формирования ключей:
|
||||
|
||||
1. **Ключи для отдельных сущностей** строятся по шаблону:
|
||||
```
|
||||
entity:property:value
|
||||
```
|
||||
Например:
|
||||
- `topic:id:123` - тема с ID 123
|
||||
- `author:slug:john-doe` - автор со слагом "john-doe"
|
||||
- `shout:id:456` - публикация с ID 456
|
||||
|
||||
2. **Ключи для коллекций** строятся по шаблону:
|
||||
```
|
||||
entity:collection[:filter1=value1:filter2=value2:...]
|
||||
```
|
||||
Например:
|
||||
- `topics:all:basic` - базовый список всех тем
|
||||
- `authors:stats:limit=10:offset=0:sort=name` - отсортированный список авторов с пагинацией
|
||||
- `shouts:feed:limit=20:community=1` - лента публикаций с фильтром по сообществу
|
||||
|
||||
3. **Специальные форматы ключей** для обратной совместимости:
|
||||
```
|
||||
entity_action_id
|
||||
```
|
||||
Например:
|
||||
- `topic_shouts_123` - публикации для темы с ID 123
|
||||
|
||||
Во всех модулях системы разработчики должны явно формировать ключи в соответствии с этими конвенциями, что обеспечивает единообразие и предсказуемость кеширования.
|
||||
|
||||
#### Работа с данными в кеше
|
||||
|
||||
```python
|
||||
async def cache_data(key, data, ttl=None)
|
||||
async def get_cached_data(key)
|
||||
```
|
||||
|
||||
Эти функции предоставляют универсальный интерфейс для сохранения и получения данных из кеша. Они напрямую используют Redis через вызовы `redis.execute()`.
|
||||
|
||||
#### Высокоуровневое кеширование запросов
|
||||
|
||||
```python
|
||||
async def cached_query(cache_key, query_func, ttl=None, force_refresh=False, **query_params)
|
||||
```
|
||||
|
||||
Функция `cached_query` объединяет получение данных из кеша и выполнение запроса в случае отсутствия данных в кеше. Это основная функция, которую следует использовать в резолверах для кеширования результатов запросов.
|
||||
|
||||
### 3. Кеширование сущностей
|
||||
|
||||
Для основных типов сущностей реализованы специальные функции:
|
||||
|
||||
```python
|
||||
async def cache_topic(topic: dict)
|
||||
async def cache_author(author: dict)
|
||||
async def get_cached_topic(topic_id: int)
|
||||
async def get_cached_author(author_id: int, get_with_stat)
|
||||
```
|
||||
|
||||
Эти функции упрощают работу с часто используемыми типами данных и обеспечивают единообразный подход к их кешированию.
|
||||
|
||||
### 4. Работа со связями
|
||||
|
||||
Для работы со связями между сущностями предназначены функции:
|
||||
|
||||
```python
|
||||
async def cache_follows(follower_id, entity_type, entity_id, is_insert=True)
|
||||
async def get_cached_topic_followers(topic_id)
|
||||
async def get_cached_author_followers(author_id)
|
||||
async def get_cached_follower_topics(author_id)
|
||||
```
|
||||
|
||||
Они позволяют эффективно кешировать и получать информацию о подписках, связях между авторами, темами и публикациями.
|
||||
|
||||
## Система инвалидации кеша
|
||||
|
||||
### 1. Прямая инвалидация
|
||||
|
||||
Система поддерживает два типа инвалидации кеша:
|
||||
|
||||
#### 1.1. Инвалидация по префиксу
|
||||
|
||||
```python
|
||||
async def invalidate_cache_by_prefix(prefix)
|
||||
```
|
||||
|
||||
Позволяет инвалидировать все ключи кеша, начинающиеся с указанного префикса. Используется в резолверах для инвалидации группы кешей при массовых изменениях.
|
||||
|
||||
#### 1.2. Точечная инвалидация
|
||||
|
||||
```python
|
||||
async def invalidate_authors_cache(author_id=None)
|
||||
async def invalidate_topics_cache(topic_id=None)
|
||||
```
|
||||
|
||||
Эти функции позволяют инвалидировать кеш только для конкретной сущности, что снижает нагрузку на Redis и предотвращает ненужную потерю кешированных данных. Если ID сущности не указан, используется инвалидация по префиксу.
|
||||
|
||||
Примеры использования точечной инвалидации:
|
||||
|
||||
```python
|
||||
# Инвалидация кеша только для автора с ID 123
|
||||
await invalidate_authors_cache(123)
|
||||
|
||||
# Инвалидация кеша только для темы с ID 456
|
||||
await invalidate_topics_cache(456)
|
||||
```
|
||||
|
||||
### 2. Отложенная инвалидация
|
||||
|
||||
Модуль `revalidator.py` реализует систему отложенной инвалидации кеша через класс `CacheRevalidationManager`:
|
||||
|
||||
```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 элементов
|
||||
- При достижении порога система переключается на инвалидацию коллекций вместо поштучной обработки
|
||||
- Специальный флаг `all` позволяет запустить полную инвалидацию всех записей типа
|
||||
|
||||
### 3. Автоматическая инвалидация через триггеры
|
||||
|
||||
Модуль `triggers.py` регистрирует обработчики событий SQLAlchemy, которые автоматически отмечают сущности для ревалидации при изменении данных в базе:
|
||||
|
||||
```python
|
||||
def events_register():
|
||||
event.listen(Author, "after_update", mark_for_revalidation)
|
||||
event.listen(Topic, "after_update", mark_for_revalidation)
|
||||
# и другие...
|
||||
```
|
||||
|
||||
Триггеры имеют следующие особенности:
|
||||
- Реагируют на события вставки, обновления и удаления
|
||||
- Отмечают затронутые сущности для отложенной ревалидации
|
||||
- Учитывают связи между сущностями (например, при изменении темы обновляются связанные шауты)
|
||||
|
||||
## Предварительное кеширование
|
||||
|
||||
Модуль `precache.py` реализует предварительное кеширование часто используемых данных при старте приложения:
|
||||
|
||||
```python
|
||||
async def precache_data():
|
||||
# ...
|
||||
```
|
||||
|
||||
Эта функция выполняется при запуске приложения и заполняет кеш данными, которые будут часто запрашиваться пользователями.
|
||||
|
||||
## Примеры использования
|
||||
|
||||
### Простое кеширование результата запроса
|
||||
|
||||
```python
|
||||
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
|
||||
```
|
||||
|
||||
### Использование обобщенной функции cached_query
|
||||
|
||||
```python
|
||||
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,
|
||||
by=by
|
||||
)
|
||||
```
|
||||
|
||||
### Точечная инвалидация кеша при изменении данных
|
||||
|
||||
```python
|
||||
async def update_author(author_id, data):
|
||||
# Обновление данных в базе
|
||||
# ...
|
||||
|
||||
# Инвалидация только кеша этого автора
|
||||
await invalidate_authors_cache(author_id)
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
## Ключи кеширования
|
||||
|
||||
Ниже приведен полный список форматов ключей, используемых в системе кеширования 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. При создании новых ключей кеша следует придерживаться существующих конвенций именования.
|
||||
|
||||
## Отладка и мониторинг
|
||||
|
||||
Система кеширования использует логгер для отслеживания операций:
|
||||
|
||||
```python
|
||||
logger.debug(f"Данные получены из кеша по ключу {key}")
|
||||
logger.debug(f"Удалено {len(keys)} ключей кеша с префиксом {prefix}")
|
||||
logger.error(f"Ошибка при инвалидации кеша: {e}")
|
||||
```
|
||||
|
||||
Это позволяет отслеживать работу кеша и выявлять возможные проблемы на ранних стадиях.
|
||||
|
||||
## Рекомендации по использованию
|
||||
|
||||
1. **Следуйте конвенциям формирования ключей** - это критически важно для консистентности и предсказуемости кеша.
|
||||
2. **Не создавайте собственные форматы ключей** - используйте существующие шаблоны для обеспечения единообразия.
|
||||
3. **Не забывайте об инвалидации** - всегда инвалидируйте кеш при изменении данных.
|
||||
4. **Используйте точечную инвалидацию** - вместо инвалидации по префиксу для снижения нагрузки на Redis.
|
||||
5. **Устанавливайте разумные TTL** - используйте разные значения TTL в зависимости от частоты изменения данных.
|
||||
6. **Не кешируйте большие объемы данных** - кешируйте только то, что действительно необходимо для повышения производительности.
|
||||
|
||||
## Технические детали реализации
|
||||
|
||||
- **Сериализация данных**: используется `orjson` для эффективной сериализации и десериализации данных.
|
||||
- **Форматирование даты и времени**: для корректной работы с датами используется `CustomJSONEncoder`.
|
||||
- **Асинхронность**: все операции кеширования выполняются асинхронно для минимального влияния на производительность API.
|
||||
- **Прямое взаимодействие с Redis**: все операции выполняются через прямые вызовы `redis.execute()` с обработкой ошибок.
|
||||
- **Батчевая обработка**: для массовых операций используется пороговое значение, после которого применяются оптимизированные стратегии.
|
||||
|
||||
## Известные ограничения
|
||||
|
||||
1. **Согласованность данных** - система не гарантирует абсолютную согласованность данных в кеше и базе данных.
|
||||
2. **Память** - необходимо следить за объемом данных в кеше, чтобы избежать проблем с памятью Redis.
|
||||
3. **Производительность Redis** - при большом количестве операций с кешем может стать узким местом.
|
||||
@@ -1,165 +0,0 @@
|
||||
# Пагинация комментариев
|
||||
|
||||
## Обзор
|
||||
|
||||
Реализована система пагинации комментариев по веткам, которая позволяет эффективно загружать и отображать вложенные ветки обсуждений. Основные преимущества:
|
||||
|
||||
1. Загрузка только необходимых комментариев, а не всего дерева
|
||||
2. Снижение нагрузки на сервер и клиент
|
||||
3. Возможность эффективной навигации по большим обсуждениям
|
||||
4. Предзагрузка первых N ответов для улучшения UX
|
||||
|
||||
## API для иерархической загрузки комментариев
|
||||
|
||||
### GraphQL запрос `load_comments_branch`
|
||||
|
||||
```graphql
|
||||
query LoadCommentsBranch(
|
||||
$shout: Int!,
|
||||
$parentId: Int,
|
||||
$limit: Int,
|
||||
$offset: Int,
|
||||
$sort: ReactionSort,
|
||||
$childrenLimit: Int,
|
||||
$childrenOffset: Int
|
||||
) {
|
||||
load_comments_branch(
|
||||
shout: $shout,
|
||||
parent_id: $parentId,
|
||||
limit: $limit,
|
||||
offset: $offset,
|
||||
sort: $sort,
|
||||
children_limit: $childrenLimit,
|
||||
children_offset: $childrenOffset
|
||||
) {
|
||||
id
|
||||
body
|
||||
created_at
|
||||
created_by {
|
||||
id
|
||||
name
|
||||
slug
|
||||
pic
|
||||
}
|
||||
kind
|
||||
reply_to
|
||||
stat {
|
||||
rating
|
||||
comments_count
|
||||
}
|
||||
first_replies {
|
||||
id
|
||||
body
|
||||
created_at
|
||||
created_by {
|
||||
id
|
||||
name
|
||||
slug
|
||||
pic
|
||||
}
|
||||
kind
|
||||
reply_to
|
||||
stat {
|
||||
rating
|
||||
comments_count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Параметры запроса
|
||||
|
||||
| Параметр | Тип | По умолчанию | Описание |
|
||||
|----------|-----|--------------|----------|
|
||||
| shout | Int! | - | ID статьи, к которой относятся комментарии |
|
||||
| parent_id | Int | null | ID родительского комментария. Если null, загружаются корневые комментарии |
|
||||
| limit | Int | 10 | Максимальное количество комментариев для загрузки |
|
||||
| offset | Int | 0 | Смещение для пагинации |
|
||||
| sort | ReactionSort | newest | Порядок сортировки: newest, oldest, like |
|
||||
| children_limit | Int | 3 | Максимальное количество дочерних комментариев для каждого родительского |
|
||||
| children_offset | Int | 0 | Смещение для пагинации дочерних комментариев |
|
||||
|
||||
### Поля в ответе
|
||||
|
||||
Каждый комментарий содержит следующие основные поля:
|
||||
|
||||
- `id`: ID комментария
|
||||
- `body`: Текст комментария
|
||||
- `created_at`: Время создания
|
||||
- `created_by`: Информация об авторе
|
||||
- `kind`: Тип реакции (COMMENT)
|
||||
- `reply_to`: ID родительского комментария (null для корневых)
|
||||
- `first_replies`: Первые N дочерних комментариев
|
||||
- `stat`: Статистика комментария, включающая:
|
||||
- `comments_count`: Количество ответов на комментарий
|
||||
- `rating`: Рейтинг комментария
|
||||
|
||||
## Примеры использования
|
||||
|
||||
### Загрузка корневых комментариев с первыми ответами
|
||||
|
||||
```javascript
|
||||
const { data } = await client.query({
|
||||
query: LOAD_COMMENTS_BRANCH,
|
||||
variables: {
|
||||
shout: 222,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
sort: "newest",
|
||||
childrenLimit: 3
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Загрузка ответов на конкретный комментарий
|
||||
|
||||
```javascript
|
||||
const { data } = await client.query({
|
||||
query: LOAD_COMMENTS_BRANCH,
|
||||
variables: {
|
||||
shout: 222,
|
||||
parentId: 123, // ID комментария, для которого загружаем ответы
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
sort: "oldest" // Сортируем ответы от старых к новым
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Пагинация дочерних комментариев
|
||||
|
||||
Для загрузки дополнительных ответов на комментарий:
|
||||
|
||||
```javascript
|
||||
const { data } = await client.query({
|
||||
query: LOAD_COMMENTS_BRANCH,
|
||||
variables: {
|
||||
shout: 222,
|
||||
parentId: 123,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
childrenLimit: 5,
|
||||
childrenOffset: 3 // Пропускаем первые 3 комментария (уже загруженные)
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Рекомендации по клиентской реализации
|
||||
|
||||
1. Для эффективной работы со сложными ветками обсуждений рекомендуется:
|
||||
|
||||
- Сначала загружать только корневые комментарии с первыми N ответами
|
||||
- При наличии дополнительных ответов (когда `stat.comments_count > first_replies.length`)
|
||||
добавить кнопку "Показать все ответы"
|
||||
- При нажатии на кнопку загружать дополнительные ответы с помощью запроса с указанным `parentId`
|
||||
|
||||
2. Для сортировки:
|
||||
- По умолчанию использовать `newest` для отображения свежих обсуждений
|
||||
- Предусмотреть переключатель сортировки для всего дерева комментариев
|
||||
- При изменении сортировки перезагружать данные с новым параметром `sort`
|
||||
|
||||
3. Для улучшения производительности:
|
||||
- Кешировать результаты запросов на клиенте
|
||||
- Использовать оптимистичные обновления при добавлении/редактировании комментариев
|
||||
- При необходимости загружать комментарии порциями (ленивая загрузка)
|
||||
214
docs/features.md
214
docs/features.md
@@ -1,214 +0,0 @@
|
||||
## Админ-панель
|
||||
|
||||
- **Управление пользователями**: Просмотр, поиск, назначение ролей (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 для отслеживания просмотров публикаций
|
||||
- Подсчет уникальных пользователей и общего количества просмотров
|
||||
- Автоматическое обновление статистики при запросе данных публикации
|
||||
|
||||
## Мультидоменная авторизация
|
||||
|
||||
- Поддержка авторизации для разных доменов
|
||||
- Автоматическое определение сервера авторизации
|
||||
- Корректная обработка CORS для всех поддерживаемых доменов
|
||||
|
||||
## Система кеширования
|
||||
|
||||
- **Redis как основное хранилище**: Кэширование, сессии, токены, временные данные
|
||||
- **Полная документация схемы**: [redis-schema.md](redis-schema.md) - детальное описание всех структур данных
|
||||
- **11 категорий данных**: Аутентификация, кэш сущностей, поиск, просмотры, уведомления
|
||||
- **Система токенов**: Сессии, OAuth токены, токены подтверждения с TTL
|
||||
- **Переменные окружения**: Централизованное хранение конфигурации в Redis
|
||||
- **Кэш сущностей**: Авторы, темы, публикации с автоматической инвалидацией
|
||||
- **Поисковый кэш**: Нормализованные запросы с результатами
|
||||
- **Pub/Sub каналы**: Real-time уведомления и коммуникация
|
||||
- **Оптимизация**: Pipeline операции, стратегии кэширования
|
||||
- **Мониторинг**: Команды диагностики и решение проблем производительности
|
||||
- Поддержка как синхронных, так и асинхронных функций в декораторе cache_on_arguments
|
||||
- Автоматическая сериализация/десериализация данных в JSON с использованием CustomJSONEncoder
|
||||
- Резервная сериализация через pickle для сложных объектов
|
||||
- Генерация уникальных ключей кеша на основе сигнатуры функции и переданных аргументов
|
||||
- Настраиваемое время жизни кеша (TTL)
|
||||
- Возможность ручной инвалидации кеша для конкретных функций и аргументов
|
||||
|
||||
## CORS Configuration
|
||||
|
||||
- Поддерживаемые методы: GET, POST, OPTIONS
|
||||
- Настроена поддержка credentials
|
||||
- Разрешенные заголовки: Authorization, Content-Type, X-Requested-With, DNT, Cache-Control
|
||||
- Настроено кэширование preflight-ответов на 20 дней (1728000 секунд)
|
||||
|
||||
## Пагинация комментариев по веткам
|
||||
|
||||
- Эффективная загрузка комментариев с учетом их иерархической структуры
|
||||
- Отдельный запрос `load_comments_branch` для оптимизированной загрузки ветки комментариев
|
||||
- Возможность загрузки корневых комментариев статьи с первыми ответами на них
|
||||
- Гибкая пагинация как для корневых, так и для дочерних комментариев
|
||||
- Использование поля `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 — наследование только при инициализации, ускорение, упрощение кода, исправлены тесты
|
||||
219
docs/follower.md
219
docs/follower.md
@@ -1,219 +0,0 @@
|
||||
# Following System
|
||||
|
||||
## Overview
|
||||
System supports following different entity types:
|
||||
- Authors
|
||||
- Topics
|
||||
- Communities
|
||||
- Shouts (Posts)
|
||||
|
||||
## GraphQL API
|
||||
|
||||
### Mutations
|
||||
|
||||
#### follow
|
||||
Follow an entity (author/topic/community/shout).
|
||||
|
||||
**Parameters:**
|
||||
- `what: String!` - Entity type (`AUTHOR`, `TOPIC`, `COMMUNITY`, `SHOUT`)
|
||||
- `slug: String` - Entity slug
|
||||
- `entity_id: Int` - Optional entity ID
|
||||
|
||||
**Returns:**
|
||||
```typescript
|
||||
{
|
||||
authors?: Author[] // For AUTHOR type
|
||||
topics?: Topic[] // For TOPIC type
|
||||
communities?: Community[] // For COMMUNITY type
|
||||
shouts?: Shout[] // For SHOUT type
|
||||
error?: String // Error message if any
|
||||
}
|
||||
```
|
||||
|
||||
#### unfollow
|
||||
Unfollow an entity.
|
||||
|
||||
**Parameters:** Same as `follow`
|
||||
|
||||
**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.
|
||||
|
||||
**Parameters:**
|
||||
- `slug: String` - Shout slug
|
||||
- `shout_id: Int` - Optional shout ID
|
||||
|
||||
**Returns:**
|
||||
```typescript
|
||||
Author[] // List of authors who reacted
|
||||
```
|
||||
|
||||
## Caching System
|
||||
|
||||
### Supported Entity Types
|
||||
- Authors: `cache_author`, `get_cached_follower_authors`
|
||||
- Topics: `cache_topic`, `get_cached_follower_topics`
|
||||
- Communities: No cache
|
||||
- Shouts: No cache
|
||||
|
||||
### 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
|
||||
- Contains:
|
||||
- Follower info
|
||||
- Author ID
|
||||
- Action type ("follow"/"unfollow")
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Follower Tables
|
||||
- `AuthorFollower`
|
||||
- `TopicFollower`
|
||||
- `CommunityFollower`
|
||||
- `ShoutReactionsFollower`
|
||||
|
||||
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
|
||||
@@ -1,80 +0,0 @@
|
||||
# Система загрузки публикаций
|
||||
|
||||
## Особенности реализации
|
||||
|
||||
### Базовый запрос
|
||||
- Автоматически подгружает основного автора
|
||||
- Добавляет основную тему публикации
|
||||
- Поддерживает гибкую систему фильтрации
|
||||
- Оптимизирует запросы на основе запрошенных полей
|
||||
|
||||
### Статистика
|
||||
- Подсчёт лайков/дислайков
|
||||
- Количество комментариев
|
||||
- Дата последней реакции
|
||||
- Статистика подгружается только при запросе поля `stat`
|
||||
|
||||
### Оптимизация производительности
|
||||
- Ленивая загрузка связанных данных
|
||||
- Кэширование результатов на 5 минут
|
||||
- Пакетная загрузка авторов и тем
|
||||
- Использование подзапросов для сложных выборок
|
||||
|
||||
## Типы лент
|
||||
|
||||
### Случайные топовые посты (load_shouts_random_top)
|
||||
**Преимущества:**
|
||||
- Разнообразный контент
|
||||
- Быстрая выборка из кэша топовых постов
|
||||
- Настраиваемый размер пула для выборки
|
||||
|
||||
**Ограничения:**
|
||||
- Обновление раз в 5 минут
|
||||
- Максимальный размер пула: 100 постов
|
||||
- Учитываются только лайки/дислайки (без комментариев)
|
||||
|
||||
### Неоцененные посты (load_shouts_unrated)
|
||||
**Преимущества:**
|
||||
- Помогает найти новый контент
|
||||
- Равномерное распределение оценок
|
||||
- Случайный порядок выдачи
|
||||
|
||||
**Ограничения:**
|
||||
- Только посты с менее чем 3 реакциями
|
||||
- Не учитываются комментарии
|
||||
- Без сортировки по рейтингу
|
||||
|
||||
### Закладки (load_shouts_bookmarked)
|
||||
**Преимущества:**
|
||||
- Персонализированная выборка
|
||||
- Быстрый доступ к сохраненному
|
||||
- Поддержка всех фильтров
|
||||
|
||||
**Ограничения:**
|
||||
- Требует авторизации
|
||||
- Ограничение на количество закладок
|
||||
- Кэширование отключено
|
||||
|
||||
## Важные моменты
|
||||
|
||||
### Пагинация
|
||||
- Стандартный размер страницы: 10
|
||||
- Максимальный размер: 100
|
||||
- Поддержка курсор-пагинации
|
||||
|
||||
### Кэширование
|
||||
- TTL: 5 минут
|
||||
- Инвалидация при изменении поста
|
||||
- Отдельный кэш для каждого типа сортировки
|
||||
|
||||
### Сортировка
|
||||
- По рейтингу (лайки минус дислайки)
|
||||
- По количеству комментариев
|
||||
- По дате последней реакции
|
||||
- По дате публикации (по умолчанию)
|
||||
|
||||
### Безопасность
|
||||
- Проверка прав доступа
|
||||
- Фильтрация удаленного контента
|
||||
- Защита от 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
|
||||
```
|
||||
|
||||
Конфигурация автоматически пересоберется при деплое.
|
||||
@@ -1,82 +0,0 @@
|
||||
# Rating System
|
||||
|
||||
## GraphQL Resolvers
|
||||
|
||||
### Queries
|
||||
|
||||
#### get_my_rates_shouts
|
||||
Get user's reactions (LIKE/DISLIKE) for specified posts.
|
||||
|
||||
**Parameters:**
|
||||
- `shouts: [Int!]!` - array of shout IDs
|
||||
|
||||
**Returns:**
|
||||
```typescript
|
||||
[{
|
||||
shout_id: Int
|
||||
my_rate: ReactionKind // LIKE or DISLIKE
|
||||
}]
|
||||
```
|
||||
|
||||
#### get_my_rates_comments
|
||||
Get user's reactions (LIKE/DISLIKE) for specified comments.
|
||||
|
||||
**Parameters:**
|
||||
- `comments: [Int!]!` - array of comment IDs
|
||||
|
||||
**Returns:**
|
||||
```typescript
|
||||
[{
|
||||
comment_id: Int
|
||||
my_rate: ReactionKind // LIKE or DISLIKE
|
||||
}]
|
||||
```
|
||||
|
||||
### Mutations
|
||||
|
||||
#### rate_author
|
||||
Rate another author (karma system).
|
||||
|
||||
**Parameters:**
|
||||
- `rated_slug: String!` - author's slug
|
||||
- `value: Int!` - rating value (positive/negative)
|
||||
|
||||
## Rating Calculation
|
||||
|
||||
### Author Rating Components
|
||||
|
||||
#### Shouts Rating
|
||||
- Calculated from LIKE/DISLIKE reactions on author's posts
|
||||
- Each LIKE: +1
|
||||
- Each DISLIKE: -1
|
||||
- Excludes deleted reactions
|
||||
- Excludes comment reactions
|
||||
|
||||
#### Comments Rating
|
||||
- Calculated from LIKE/DISLIKE reactions on author's comments
|
||||
- Each LIKE: +1
|
||||
- Each DISLIKE: -1
|
||||
- Only counts reactions to COMMENT type reactions
|
||||
- Excludes deleted reactions
|
||||
|
||||
#### Legacy Karma
|
||||
- Based on direct author ratings via `rate_author` mutation
|
||||
- Stored in `AuthorRating` table
|
||||
- Each positive rating: +1
|
||||
- Each negative rating: -1
|
||||
|
||||
### Helper Functions
|
||||
|
||||
- `count_author_comments_rating()` - Calculate comment rating
|
||||
- `count_author_shouts_rating()` - Calculate posts rating
|
||||
- `get_author_rating_old()` - Get legacy karma rating
|
||||
- `get_author_rating_shouts()` - Get posts rating (optimized)
|
||||
- `get_author_rating_comments()` - Get comments rating (optimized)
|
||||
- `add_author_rating_columns()` - Add rating columns to author query
|
||||
|
||||
## Notes
|
||||
|
||||
- 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
|
||||
@@ -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;
|
||||
}
|
||||
1
generate_gql_types.sh
Executable file
1
generate_gql_types.sh
Executable file
@@ -0,0 +1 @@
|
||||
python -m gql_schema_codegen -p ./schema.graphql -t ./schema_types.py
|
||||
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>
|
||||
366
main.py
366
main.py
@@ -1,338 +1,94 @@
|
||||
import asyncio
|
||||
import os
|
||||
import traceback
|
||||
from contextlib import asynccontextmanager
|
||||
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.middleware.authentication import AuthenticationMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
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.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
|
||||
from auth.authenticate import JWTAuthenticate
|
||||
from auth.oauth import oauth_authorize, oauth_login
|
||||
from base.redis import redis
|
||||
from base.resolvers import resolvers
|
||||
from orm import init_tables
|
||||
from resolvers.upload import upload_handler
|
||||
from services.main import storages_init
|
||||
from services.notifications.notification_service import notification_service
|
||||
from services.notifications.sse import sse_subscribe_handler
|
||||
from services.stat.viewed import ViewedStorage
|
||||
|
||||
DEVMODE = os.getenv("DOKKU_APP_TYPE", "false").lower() == "false"
|
||||
DIST_DIR = Path(__file__).parent / "dist" # Директория для собранных файлов
|
||||
INDEX_HTML = Path(__file__).parent / "index.html"
|
||||
# from services.zine.gittask import GitTask
|
||||
from settings import DEV_SERVER_PID_FILE_NAME, SENTRY_DSN, SESSION_SECRET_KEY
|
||||
|
||||
import_module("resolvers")
|
||||
schema = make_executable_schema(load_schema_from_path("schema.graphql"), 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),
|
||||
Middleware(AuthenticationMiddleware, backend=JWTAuthenticate()),
|
||||
Middleware(SessionMiddleware, secret_key=SESSION_SECRET_KEY),
|
||||
]
|
||||
|
||||
# Создаем экземпляр GraphQL с улучшенным обработчиком и кастомным форматтером ошибок
|
||||
graphql_app = GraphQL(
|
||||
schema,
|
||||
debug=DEVMODE,
|
||||
http_handler=EnhancedGraphQLHTTPHandler(),
|
||||
error_formatter=custom_error_formatter,
|
||||
)
|
||||
|
||||
|
||||
# Оборачиваем 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 обработчиком")
|
||||
async def start_up():
|
||||
init_tables()
|
||||
await redis.connect()
|
||||
await storages_init()
|
||||
views_stat_task = asyncio.create_task(ViewedStorage().worker())
|
||||
print(views_stat_task)
|
||||
# git_task = asyncio.create_task(GitTask.git_task_worker())
|
||||
# print(git_task)
|
||||
notification_service_task = asyncio.create_task(notification_service.worker())
|
||||
print(notification_service_task)
|
||||
|
||||
try:
|
||||
# Обрабатываем запрос через GraphQL приложение
|
||||
result = await graphql_app.handle_request(request)
|
||||
import sentry_sdk
|
||||
|
||||
# Применяем 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)
|
||||
sentry_sdk.init(SENTRY_DSN)
|
||||
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)
|
||||
print("[sentry] init error")
|
||||
print(e)
|
||||
|
||||
|
||||
async def spa_handler(request: Request) -> Response:
|
||||
"""
|
||||
Обработчик для SPA (Single Page Application) fallback.
|
||||
async def dev_start_up():
|
||||
if exists(DEV_SERVER_PID_FILE_NAME):
|
||||
await redis.connect()
|
||||
return
|
||||
else:
|
||||
with open(DEV_SERVER_PID_FILE_NAME, "w", encoding="utf-8") as f:
|
||||
f.write(str(os.getpid()))
|
||||
|
||||
Возвращает 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)
|
||||
await start_up()
|
||||
|
||||
|
||||
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
|
||||
async def shutdown():
|
||||
await redis.disconnect()
|
||||
|
||||
# Останавливаем поисковый сервис
|
||||
await search_service.close()
|
||||
|
||||
pid_file = Path(DEV_SERVER_PID_FILE_NAME)
|
||||
if pid_file.exists():
|
||||
pid_file.unlink()
|
||||
routes = [
|
||||
Route("/oauth/{provider}", endpoint=oauth_login),
|
||||
Route("/oauth-authorize", endpoint=oauth_authorize),
|
||||
Route("/upload", endpoint=upload_handler, methods=["POST"]),
|
||||
Route("/subscribe/{user_id}", endpoint=sse_subscribe_handler),
|
||||
]
|
||||
|
||||
|
||||
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: генератор для управления жизненным циклом
|
||||
"""
|
||||
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(),
|
||||
check_search_service(),
|
||||
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")
|
||||
|
||||
# NOTE: Предзагрузка моделей убрана - ColBERT загружается lazy при первом поиске
|
||||
# BiEncoder модели больше не используются (default=colbert)
|
||||
|
||||
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")
|
||||
|
||||
|
||||
# Обновляем маршрут в 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"]),
|
||||
],
|
||||
middleware=middleware, # Используем единый список middleware
|
||||
lifespan=lifespan,
|
||||
debug=True,
|
||||
on_startup=[start_up],
|
||||
on_shutdown=[shutdown],
|
||||
middleware=middleware,
|
||||
routes=routes,
|
||||
)
|
||||
app.mount("/", GraphQL(schema))
|
||||
|
||||
if DEVMODE:
|
||||
# Для DEV режима регистрируем дополнительный CORS middleware только для localhost
|
||||
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_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
dev_app = Starlette(
|
||||
debug=True,
|
||||
on_startup=[dev_start_up],
|
||||
on_shutdown=[shutdown],
|
||||
middleware=middleware,
|
||||
routes=routes,
|
||||
)
|
||||
dev_app.mount("/", GraphQL(schema, debug=True))
|
||||
|
||||
18
migrate.sh
Normal file
18
migrate.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
database_name="discoursio"
|
||||
|
||||
echo "DATABASE MIGRATION STARTED"
|
||||
|
||||
echo "Dropping database $database_name"
|
||||
dropdb $database_name --force
|
||||
if [ $? -ne 0 ]; then { echo "Failed to drop database, aborting." ; exit 1; } fi
|
||||
echo "Database $database_name dropped"
|
||||
|
||||
echo "Creating database $database_name"
|
||||
createdb $database_name
|
||||
if [ $? -ne 0 ]; then { echo "Failed to create database, aborting." ; exit 1; } fi
|
||||
echo "Database $database_name successfully created"
|
||||
|
||||
echo "Start migration"
|
||||
python3 server.py migrate
|
||||
if [ $? -ne 0 ]; then { echo "Migration failed, aborting." ; exit 1; } fi
|
||||
echo 'Done!'
|
||||
279
migration/__init__.py
Normal file
279
migration/__init__.py
Normal file
@@ -0,0 +1,279 @@
|
||||
""" cmd managed migration """
|
||||
import asyncio
|
||||
import gc
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import bs4
|
||||
|
||||
from migration.export import export_mdx
|
||||
from migration.tables.comments import migrate as migrateComment
|
||||
from migration.tables.comments import migrate_2stage as migrateComment_2stage
|
||||
from migration.tables.content_items import get_shout_slug
|
||||
from migration.tables.content_items import migrate as migrateShout
|
||||
|
||||
# from migration.tables.remarks import migrate as migrateRemark
|
||||
from migration.tables.topics import migrate as migrateTopic
|
||||
from migration.tables.users import migrate as migrateUser
|
||||
from migration.tables.users import migrate_2stage as migrateUser_2stage
|
||||
from migration.tables.users import post_migrate as users_post_migrate
|
||||
from orm import init_tables
|
||||
from orm.reaction import Reaction
|
||||
|
||||
TODAY = datetime.strftime(datetime.now(tz=timezone.utc), "%Y%m%d")
|
||||
OLD_DATE = "2016-03-05 22:22:00.350000"
|
||||
|
||||
|
||||
async def users_handle(storage):
|
||||
"""migrating users first"""
|
||||
counter = 0
|
||||
id_map = {}
|
||||
print("[migration] migrating %d users" % (len(storage["users"]["data"])))
|
||||
for entry in storage["users"]["data"]:
|
||||
oid = entry["_id"]
|
||||
user = migrateUser(entry)
|
||||
storage["users"]["by_oid"][oid] = user # full
|
||||
del user["password"]
|
||||
del user["emailConfirmed"]
|
||||
del user["username"]
|
||||
del user["email"]
|
||||
storage["users"]["by_slug"][user["slug"]] = user # public
|
||||
id_map[user["oid"]] = user["slug"]
|
||||
counter += 1
|
||||
ce = 0
|
||||
for entry in storage["users"]["data"]:
|
||||
ce += migrateUser_2stage(entry, id_map)
|
||||
users_post_migrate()
|
||||
|
||||
|
||||
async def topics_handle(storage):
|
||||
"""topics from categories and tags"""
|
||||
counter = 0
|
||||
for t in storage["topics"]["tags"] + storage["topics"]["cats"]:
|
||||
if t["slug"] in storage["replacements"]:
|
||||
t["slug"] = storage["replacements"][t["slug"]]
|
||||
topic = migrateTopic(t)
|
||||
storage["topics"]["by_oid"][t["_id"]] = topic
|
||||
storage["topics"]["by_slug"][t["slug"]] = topic
|
||||
counter += 1
|
||||
else:
|
||||
print("[migration] topic " + t["slug"] + " ignored")
|
||||
for oldslug, newslug in storage["replacements"].items():
|
||||
if oldslug != newslug and oldslug in storage["topics"]["by_slug"]:
|
||||
oid = storage["topics"]["by_slug"][oldslug]["_id"]
|
||||
del storage["topics"]["by_slug"][oldslug]
|
||||
storage["topics"]["by_oid"][oid] = storage["topics"]["by_slug"][newslug]
|
||||
print("[migration] " + str(counter) + " topics migrated")
|
||||
print("[migration] " + str(len(storage["topics"]["by_oid"].values())) + " topics by oid")
|
||||
print("[migration] " + str(len(storage["topics"]["by_slug"].values())) + " topics by slug")
|
||||
|
||||
|
||||
async def shouts_handle(storage, args):
|
||||
"""migrating content items one by one"""
|
||||
counter = 0
|
||||
discours_author = 0
|
||||
anonymous_author = 0
|
||||
pub_counter = 0
|
||||
ignored = 0
|
||||
topics_dataset_bodies = []
|
||||
topics_dataset_tlist = []
|
||||
for entry in storage["shouts"]["data"]:
|
||||
gc.collect()
|
||||
# slug
|
||||
slug = get_shout_slug(entry)
|
||||
|
||||
# single slug mode
|
||||
if "-" in args and slug not in args:
|
||||
continue
|
||||
|
||||
# migrate
|
||||
shout_dict = await migrateShout(entry, storage)
|
||||
if shout_dict:
|
||||
storage["shouts"]["by_oid"][entry["_id"]] = shout_dict
|
||||
storage["shouts"]["by_slug"][shout_dict["slug"]] = shout_dict
|
||||
# shouts.topics
|
||||
if not shout_dict["topics"]:
|
||||
print("[migration] no topics!")
|
||||
|
||||
# with author
|
||||
author = shout_dict["authors"][0]
|
||||
if author["slug"] == "discours":
|
||||
discours_author += 1
|
||||
if author["slug"] == "anonymous":
|
||||
anonymous_author += 1
|
||||
# print('[migration] ' + shout['slug'] + ' with author ' + author)
|
||||
|
||||
if entry.get("published"):
|
||||
if "mdx" in args:
|
||||
export_mdx(shout_dict)
|
||||
pub_counter += 1
|
||||
|
||||
# print main counter
|
||||
counter += 1
|
||||
print(
|
||||
"[migration] shouts_handle %d: %s @%s"
|
||||
% ((counter + 1), shout_dict["slug"], author["slug"])
|
||||
)
|
||||
|
||||
b = bs4.BeautifulSoup(shout_dict["body"], "html.parser")
|
||||
texts = [shout_dict["title"].lower().replace(r"[^а-яА-Яa-zA-Z]", "")]
|
||||
texts = texts + b.findAll(text=True)
|
||||
topics_dataset_bodies.append(" ".join([x.strip().lower() for x in texts]))
|
||||
topics_dataset_tlist.append(shout_dict["topics"])
|
||||
else:
|
||||
ignored += 1
|
||||
|
||||
# np.savetxt('topics_dataset.csv', (topics_dataset_bodies, topics_dataset_tlist), delimiter=',
|
||||
# ', fmt='%s')
|
||||
|
||||
print("[migration] " + str(counter) + " content items were migrated")
|
||||
print("[migration] " + str(pub_counter) + " have been published")
|
||||
print("[migration] " + str(discours_author) + " authored by @discours")
|
||||
print("[migration] " + str(anonymous_author) + " authored by @anonymous")
|
||||
|
||||
|
||||
# async def remarks_handle(storage):
|
||||
# print("[migration] comments")
|
||||
# c = 0
|
||||
# for entry_remark in storage["remarks"]["data"]:
|
||||
# remark = await migrateRemark(entry_remark, storage)
|
||||
# c += 1
|
||||
# print("[migration] " + str(c) + " remarks migrated")
|
||||
|
||||
|
||||
async def comments_handle(storage):
|
||||
print("[migration] comments")
|
||||
id_map = {}
|
||||
ignored_counter = 0
|
||||
missed_shouts = {}
|
||||
for oldcomment in storage["reactions"]["data"]:
|
||||
if not oldcomment.get("deleted"):
|
||||
reaction = await migrateComment(oldcomment, storage)
|
||||
if isinstance(reaction, str):
|
||||
missed_shouts[reaction] = oldcomment
|
||||
elif isinstance(reaction, Reaction):
|
||||
reaction = reaction.dict()
|
||||
rid = reaction["id"]
|
||||
oid = reaction["oid"]
|
||||
id_map[oid] = rid
|
||||
else:
|
||||
ignored_counter += 1
|
||||
|
||||
for reaction in storage["reactions"]["data"]:
|
||||
migrateComment_2stage(reaction, id_map)
|
||||
print("[migration] " + str(len(id_map)) + " comments migrated")
|
||||
print("[migration] " + str(ignored_counter) + " comments ignored")
|
||||
print("[migration] " + str(len(missed_shouts.keys())) + " commented shouts missed")
|
||||
missed_counter = 0
|
||||
for missed in missed_shouts.values():
|
||||
missed_counter += len(missed)
|
||||
print("[migration] " + str(missed_counter) + " comments dropped")
|
||||
|
||||
|
||||
async def all_handle(storage, args):
|
||||
print("[migration] handle everything")
|
||||
await users_handle(storage)
|
||||
await topics_handle(storage)
|
||||
print("[migration] users and topics are migrated")
|
||||
await shouts_handle(storage, args)
|
||||
# print("[migration] remarks...")
|
||||
# await remarks_handle(storage)
|
||||
print("[migration] migrating comments")
|
||||
await comments_handle(storage)
|
||||
# export_email_subscriptions()
|
||||
print("[migration] done!")
|
||||
|
||||
|
||||
def data_load():
|
||||
storage = {
|
||||
"content_items": {
|
||||
"by_oid": {},
|
||||
"by_slug": {},
|
||||
},
|
||||
"shouts": {"by_oid": {}, "by_slug": {}, "data": []},
|
||||
"reactions": {"by_oid": {}, "by_slug": {}, "by_content": {}, "data": []},
|
||||
"topics": {
|
||||
"by_oid": {},
|
||||
"by_slug": {},
|
||||
"cats": [],
|
||||
"tags": [],
|
||||
},
|
||||
"remarks": {"data": []},
|
||||
"users": {"by_oid": {}, "by_slug": {}, "data": []},
|
||||
"replacements": json.loads(open("migration/tables/replacements.json").read()),
|
||||
}
|
||||
try:
|
||||
users_data = json.loads(open("migration/data/users.json").read())
|
||||
print("[migration.load] " + str(len(users_data)) + " users ")
|
||||
tags_data = json.loads(open("migration/data/tags.json").read())
|
||||
storage["topics"]["tags"] = tags_data
|
||||
print("[migration.load] " + str(len(tags_data)) + " tags ")
|
||||
cats_data = json.loads(open("migration/data/content_item_categories.json").read())
|
||||
storage["topics"]["cats"] = cats_data
|
||||
print("[migration.load] " + str(len(cats_data)) + " cats ")
|
||||
comments_data = json.loads(open("migration/data/comments.json").read())
|
||||
storage["reactions"]["data"] = comments_data
|
||||
print("[migration.load] " + str(len(comments_data)) + " comments ")
|
||||
content_data = json.loads(open("migration/data/content_items.json").read())
|
||||
storage["shouts"]["data"] = content_data
|
||||
print("[migration.load] " + str(len(content_data)) + " content items ")
|
||||
|
||||
remarks_data = json.loads(open("migration/data/remarks.json").read())
|
||||
storage["remarks"]["data"] = remarks_data
|
||||
print("[migration.load] " + str(len(remarks_data)) + " remarks data ")
|
||||
|
||||
# fill out storage
|
||||
for x in users_data:
|
||||
storage["users"]["by_oid"][x["_id"]] = x
|
||||
# storage['users']['by_slug'][x['slug']] = x
|
||||
# no user.slug yet
|
||||
print("[migration.load] " + str(len(storage["users"]["by_oid"].keys())) + " users by oid")
|
||||
for x in tags_data:
|
||||
storage["topics"]["by_oid"][x["_id"]] = x
|
||||
storage["topics"]["by_slug"][x["slug"]] = x
|
||||
for x in cats_data:
|
||||
storage["topics"]["by_oid"][x["_id"]] = x
|
||||
storage["topics"]["by_slug"][x["slug"]] = x
|
||||
print(
|
||||
"[migration.load] " + str(len(storage["topics"]["by_slug"].keys())) + " topics by slug"
|
||||
)
|
||||
for item in content_data:
|
||||
slug = get_shout_slug(item)
|
||||
storage["content_items"]["by_slug"][slug] = item
|
||||
storage["content_items"]["by_oid"][item["_id"]] = item
|
||||
print("[migration.load] " + str(len(content_data)) + " content items")
|
||||
for x in comments_data:
|
||||
storage["reactions"]["by_oid"][x["_id"]] = x
|
||||
cid = x["contentItem"]
|
||||
storage["reactions"]["by_content"][cid] = x
|
||||
ci = storage["content_items"]["by_oid"].get(cid, {})
|
||||
if "slug" in ci:
|
||||
storage["reactions"]["by_slug"][ci["slug"]] = x
|
||||
print(
|
||||
"[migration.load] "
|
||||
+ str(len(storage["reactions"]["by_content"].keys()))
|
||||
+ " with comments"
|
||||
)
|
||||
storage["users"]["data"] = users_data
|
||||
storage["topics"]["tags"] = tags_data
|
||||
storage["topics"]["cats"] = cats_data
|
||||
storage["shouts"]["data"] = content_data
|
||||
storage["reactions"]["data"] = comments_data
|
||||
except Exception as e:
|
||||
raise e
|
||||
return storage
|
||||
|
||||
|
||||
async def handling_migration():
|
||||
init_tables()
|
||||
await all_handle(data_load(), sys.argv)
|
||||
|
||||
|
||||
def process():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(handling_migration())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
process()
|
||||
33
migration/bson2json.py
Normal file
33
migration/bson2json.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import gc
|
||||
import json
|
||||
import os
|
||||
|
||||
import bson
|
||||
|
||||
from .utils import DateTimeEncoder
|
||||
|
||||
|
||||
def json_tables():
|
||||
print("[migration] unpack dump/discours/*.bson to migration/data/*.json")
|
||||
data = {
|
||||
"content_items": [],
|
||||
"content_item_categories": [],
|
||||
"tags": [],
|
||||
"email_subscriptions": [],
|
||||
"users": [],
|
||||
"comments": [],
|
||||
"remarks": [],
|
||||
}
|
||||
for table in data.keys():
|
||||
print("[migration] bson2json for " + table)
|
||||
gc.collect()
|
||||
lc = []
|
||||
bs = open("dump/discours/" + table + ".bson", "rb").read()
|
||||
base = 0
|
||||
while base < len(bs):
|
||||
base, d = bson.decode_document(bs, base)
|
||||
lc.append(d)
|
||||
data[table] = lc
|
||||
open(os.getcwd() + "/migration/data/" + table + ".json", "w").write(
|
||||
json.dumps(lc, cls=DateTimeEncoder)
|
||||
)
|
||||
137
migration/export.py
Normal file
137
migration/export.py
Normal file
@@ -0,0 +1,137 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import frontmatter
|
||||
|
||||
from .extract import extract_html, extract_media
|
||||
from .utils import DateTimeEncoder
|
||||
|
||||
OLD_DATE = "2016-03-05 22:22:00.350000"
|
||||
EXPORT_DEST = "../discoursio-web/data/"
|
||||
parentDir = "/".join(os.getcwd().split("/")[:-1])
|
||||
contentDir = parentDir + "/discoursio-web/content/"
|
||||
ts = datetime.now(tz=timezone.utc)
|
||||
|
||||
|
||||
def get_metadata(r):
|
||||
authors = []
|
||||
for a in r["authors"]:
|
||||
authors.append(
|
||||
{ # a short version for public listings
|
||||
"slug": a.slug or "discours",
|
||||
"name": a.name or "Дискурс",
|
||||
"userpic": a.userpic or "https://discours.io/static/img/discours.png",
|
||||
}
|
||||
)
|
||||
metadata = {}
|
||||
metadata["title"] = r.get("title", "").replace("{", "(").replace("}", ")")
|
||||
metadata["authors"] = authors
|
||||
metadata["createdAt"] = r.get("createdAt", ts)
|
||||
metadata["layout"] = r["layout"]
|
||||
metadata["topics"] = [topic for topic in r["topics"]]
|
||||
metadata["topics"].sort()
|
||||
if r.get("cover", False):
|
||||
metadata["cover"] = r.get("cover")
|
||||
return metadata
|
||||
|
||||
|
||||
def export_mdx(r):
|
||||
# print('[export] mdx %s' % r['slug'])
|
||||
content = ""
|
||||
metadata = get_metadata(r)
|
||||
content = frontmatter.dumps(frontmatter.Post(r["body"], **metadata))
|
||||
ext = "mdx"
|
||||
filepath = contentDir + r["slug"]
|
||||
bc = bytes(content, "utf-8").decode("utf-8", "ignore")
|
||||
open(filepath + "." + ext, "w").write(bc)
|
||||
|
||||
|
||||
def export_body(shout, storage):
|
||||
entry = storage["content_items"]["by_oid"][shout["oid"]]
|
||||
if entry:
|
||||
body = extract_html(entry)
|
||||
media = extract_media(entry)
|
||||
shout["body"] = body # prepare_html_body(entry) # prepare_md_body(entry)
|
||||
shout["media"] = media
|
||||
export_mdx(shout)
|
||||
print("[export] html for %s" % shout["slug"])
|
||||
open(contentDir + shout["slug"] + ".html", "w").write(body)
|
||||
else:
|
||||
raise Exception("no content_items entry found")
|
||||
|
||||
|
||||
def export_slug(slug, storage):
|
||||
shout = storage["shouts"]["by_slug"][slug]
|
||||
shout = storage["shouts"]["by_slug"].get(slug)
|
||||
assert shout, "[export] no shout found by slug: %s " % slug
|
||||
author = shout["authors"][0]
|
||||
assert author, "[export] no author error"
|
||||
export_body(shout, storage)
|
||||
|
||||
|
||||
def export_email_subscriptions():
|
||||
email_subscriptions_data = json.loads(open("migration/data/email_subscriptions.json").read())
|
||||
for data in email_subscriptions_data:
|
||||
# TODO: migrate to mailgun list manually
|
||||
# migrate_email_subscription(data)
|
||||
pass
|
||||
print("[migration] " + str(len(email_subscriptions_data)) + " email subscriptions exported")
|
||||
|
||||
|
||||
def export_shouts(storage):
|
||||
# update what was just migrated or load json again
|
||||
if len(storage["users"]["by_slugs"].keys()) == 0:
|
||||
storage["users"]["by_slugs"] = json.loads(open(EXPORT_DEST + "authors.json").read())
|
||||
print("[migration] " + str(len(storage["users"]["by_slugs"].keys())) + " exported authors ")
|
||||
if len(storage["shouts"]["by_slugs"].keys()) == 0:
|
||||
storage["shouts"]["by_slugs"] = json.loads(open(EXPORT_DEST + "articles.json").read())
|
||||
print(
|
||||
"[migration] " + str(len(storage["shouts"]["by_slugs"].keys())) + " exported articles "
|
||||
)
|
||||
for slug in storage["shouts"]["by_slugs"].keys():
|
||||
export_slug(slug, storage)
|
||||
|
||||
|
||||
def export_json(export_articles={}, export_authors={}, export_topics={}, export_comments={}):
|
||||
open(EXPORT_DEST + "authors.json", "w").write(
|
||||
json.dumps(
|
||||
export_authors,
|
||||
cls=DateTimeEncoder,
|
||||
indent=4,
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
print("[migration] " + str(len(export_authors.items())) + " authors exported")
|
||||
open(EXPORT_DEST + "topics.json", "w").write(
|
||||
json.dumps(
|
||||
export_topics,
|
||||
cls=DateTimeEncoder,
|
||||
indent=4,
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
print("[migration] " + str(len(export_topics.keys())) + " topics exported")
|
||||
|
||||
open(EXPORT_DEST + "articles.json", "w").write(
|
||||
json.dumps(
|
||||
export_articles,
|
||||
cls=DateTimeEncoder,
|
||||
indent=4,
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
print("[migration] " + str(len(export_articles.items())) + " articles exported")
|
||||
open(EXPORT_DEST + "comments.json", "w").write(
|
||||
json.dumps(
|
||||
export_comments,
|
||||
cls=DateTimeEncoder,
|
||||
indent=4,
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
print("[migration] " + str(len(export_comments.items())) + " exported articles with comments")
|
||||
276
migration/extract.py
Normal file
276
migration/extract.py
Normal file
@@ -0,0 +1,276 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
TOOLTIP_REGEX = r"(\/\/\/(.+)\/\/\/)"
|
||||
contentDir = os.path.join(
|
||||
os.path.dirname(os.path.realpath(__file__)), "..", "..", "discoursio-web", "content"
|
||||
)
|
||||
|
||||
cdn = "https://images.discours.io"
|
||||
|
||||
|
||||
def replace_tooltips(body):
|
||||
# change if you prefer regexp
|
||||
newbody = body
|
||||
matches = list(re.finditer(TOOLTIP_REGEX, body, re.IGNORECASE | re.MULTILINE))[1:]
|
||||
for match in matches:
|
||||
newbody = body.replace(
|
||||
match.group(1), '<Tooltip text="' + match.group(2) + '" />'
|
||||
) # NOTE: doesn't work
|
||||
if len(matches) > 0:
|
||||
print("[extract] found %d tooltips" % len(matches))
|
||||
return newbody
|
||||
|
||||
|
||||
# def extract_footnotes(body, shout_dict):
|
||||
# parts = body.split("&&&")
|
||||
# lll = len(parts)
|
||||
# newparts = list(parts)
|
||||
# placed = False
|
||||
# if lll & 1:
|
||||
# if lll > 1:
|
||||
# i = 1
|
||||
# print("[extract] found %d footnotes in body" % (lll - 1))
|
||||
# for part in parts[1:]:
|
||||
# if i & 1:
|
||||
# placed = True
|
||||
# if 'a class="footnote-url" href=' in part:
|
||||
# print("[extract] footnote: " + part)
|
||||
# fn = 'a class="footnote-url" href="'
|
||||
# exxtracted_link = part.split(fn, 1)[1].split('"', 1)[0]
|
||||
# extracted_body = part.split(fn, 1)[1].split(">", 1)[1].split("</a>", 1)[0]
|
||||
# print("[extract] footnote link: " + extracted_link)
|
||||
# with local_session() as session:
|
||||
# Reaction.create(
|
||||
# {
|
||||
# "shout": shout_dict["id"],
|
||||
# "kind": ReactionKind.FOOTNOTE,
|
||||
# "body": extracted_body,
|
||||
# "range": str(body.index(fn + link) - len("<"))
|
||||
# + ":"
|
||||
# + str(body.index(extracted_body) + len("</a>")),
|
||||
# }
|
||||
# )
|
||||
# newparts[i] = "<a href='#'>ℹ️</a>"
|
||||
# else:
|
||||
# newparts[i] = part
|
||||
# i += 1
|
||||
# return ("".join(newparts), placed)
|
||||
|
||||
|
||||
# def place_tooltips(body):
|
||||
# parts = body.split("&&&")
|
||||
# lll = len(parts)
|
||||
# newparts = list(parts)
|
||||
# placed = False
|
||||
# if lll & 1:
|
||||
# if lll > 1:
|
||||
# i = 1
|
||||
# print("[extract] found %d tooltips" % (lll - 1))
|
||||
# for part in parts[1:]:
|
||||
# if i & 1:
|
||||
# placed = True
|
||||
# if 'a class="footnote-url" href=' in part:
|
||||
# print("[extract] footnote: " + part)
|
||||
# fn = 'a class="footnote-url" href="'
|
||||
# link = part.split(fn, 1)[1].split('"', 1)[0]
|
||||
# extracted_part = part.split(fn, 1)[0] + " " + part.split("/", 1)[-1]
|
||||
# newparts[i] = (
|
||||
# "<Tooltip"
|
||||
# + (' link="' + link + '" ' if link else "")
|
||||
# + ">"
|
||||
# + extracted_part
|
||||
# + "</Tooltip>"
|
||||
# )
|
||||
# else:
|
||||
# newparts[i] = "<Tooltip>%s</Tooltip>" % part
|
||||
# # print('[extract] ' + newparts[i])
|
||||
# else:
|
||||
# # print('[extract] ' + part[:10] + '..')
|
||||
# newparts[i] = part
|
||||
# i += 1
|
||||
# return ("".join(newparts), placed)
|
||||
|
||||
|
||||
IMG_REGEX = (
|
||||
r"\!\[(.*?)\]\((data\:image\/(png|jpeg|jpg);base64\,((?:[A-Za-z\d+\/]{4})*(?:[A-Za-z\d+\/]{3}="
|
||||
)
|
||||
IMG_REGEX += r"|[A-Za-z\d+\/]{2}==)))\)"
|
||||
|
||||
parentDir = "/".join(os.getcwd().split("/")[:-1])
|
||||
public = parentDir + "/discoursio-web/public"
|
||||
cache = {}
|
||||
|
||||
|
||||
# def reextract_images(body, oid):
|
||||
# # change if you prefer regexp
|
||||
# matches = list(re.finditer(IMG_REGEX, body, re.IGNORECASE | re.MULTILINE))[1:]
|
||||
# i = 0
|
||||
# for match in matches:
|
||||
# print("[extract] image " + match.group(1))
|
||||
# ext = match.group(3)
|
||||
# name = oid + str(i)
|
||||
# link = public + "/upload/image-" + name + "." + ext
|
||||
# img = match.group(4)
|
||||
# title = match.group(1) # NOTE: this is not the title
|
||||
# if img not in cache:
|
||||
# content = base64.b64decode(img + "==")
|
||||
# print(str(len(img)) + " image bytes been written")
|
||||
# open("../" + link, "wb").write(content)
|
||||
# cache[img] = name
|
||||
# i += 1
|
||||
# else:
|
||||
# print("[extract] image cached " + cache[img])
|
||||
# body.replace(
|
||||
# str(match), ""
|
||||
# ) # WARNING: this does not work
|
||||
# return body
|
||||
|
||||
|
||||
IMAGES = {
|
||||
"data:image/png": "png",
|
||||
"data:image/jpg": "jpg",
|
||||
"data:image/jpeg": "jpg",
|
||||
}
|
||||
|
||||
b64 = ";base64,"
|
||||
|
||||
di = "data:image"
|
||||
|
||||
|
||||
def extract_media(entry):
|
||||
"""normalized media extraction method"""
|
||||
# media [ { title pic url body } ]}
|
||||
kind = entry.get("type")
|
||||
if not kind:
|
||||
print(entry)
|
||||
raise Exception("shout no layout")
|
||||
media = []
|
||||
for m in entry.get("media") or []:
|
||||
# title
|
||||
title = m.get("title", "").replace("\n", " ").replace(" ", " ")
|
||||
artist = m.get("performer") or m.get("artist")
|
||||
if artist:
|
||||
title = artist + " - " + title
|
||||
|
||||
# pic
|
||||
url = m.get("fileUrl") or m.get("url", "")
|
||||
pic = ""
|
||||
if m.get("thumborId"):
|
||||
pic = cdn + "/unsafe/" + m["thumborId"]
|
||||
|
||||
# url
|
||||
if not url:
|
||||
if kind == "Image":
|
||||
url = pic
|
||||
elif "youtubeId" in m:
|
||||
url = "https://youtube.com/?watch=" + m["youtubeId"]
|
||||
elif "vimeoId" in m:
|
||||
url = "https://vimeo.com/" + m["vimeoId"]
|
||||
# body
|
||||
body = m.get("body") or m.get("literatureBody") or ""
|
||||
media.append({"url": url, "pic": pic, "title": title, "body": body})
|
||||
return media
|
||||
|
||||
|
||||
def prepare_html_body(entry):
|
||||
# body modifications
|
||||
body = ""
|
||||
kind = entry.get("type")
|
||||
addon = ""
|
||||
if kind == "Video":
|
||||
addon = ""
|
||||
for m in entry.get("media") or []:
|
||||
if "youtubeId" in m:
|
||||
addon += '<iframe width="420" height="345" src="http://www.youtube.com/embed/'
|
||||
addon += m["youtubeId"]
|
||||
addon += '?autoplay=1" frameborder="0" allowfullscreen></iframe>\n'
|
||||
elif "vimeoId" in m:
|
||||
addon += '<iframe src="https://player.vimeo.com/video/'
|
||||
addon += m["vimeoId"]
|
||||
addon += ' width="420" height="345" frameborder="0" allow="autoplay; fullscreen"'
|
||||
addon += " allowfullscreen></iframe>"
|
||||
else:
|
||||
print("[extract] media is not supported")
|
||||
print(m)
|
||||
body += addon
|
||||
|
||||
elif kind == "Music":
|
||||
addon = ""
|
||||
for m in entry.get("media") or []:
|
||||
artist = m.get("performer")
|
||||
trackname = ""
|
||||
if artist:
|
||||
trackname += artist + " - "
|
||||
if "title" in m:
|
||||
trackname += m.get("title", "")
|
||||
addon += "<figure><figcaption>"
|
||||
addon += trackname
|
||||
addon += '</figcaption><audio controls src="'
|
||||
addon += m.get("fileUrl", "")
|
||||
addon += '"></audio></figure>'
|
||||
body += addon
|
||||
|
||||
body = extract_html(entry)
|
||||
# if body_orig: body += extract_md(html2text(body_orig), entry['_id'])
|
||||
return body
|
||||
|
||||
|
||||
def cleanup_html(body: str) -> str:
|
||||
new_body = body
|
||||
regex_remove = [
|
||||
r"style=\"width:\s*\d+px;height:\s*\d+px;\"",
|
||||
r"style=\"width:\s*\d+px;\"",
|
||||
r"style=\"color: #000000;\"",
|
||||
r"style=\"float: none;\"",
|
||||
r"style=\"background: white;\"",
|
||||
r"class=\"Apple-interchange-newline\"",
|
||||
r"class=\"MsoNormalCxSpMiddle\"",
|
||||
r"class=\"MsoNormal\"",
|
||||
r"lang=\"EN-US\"",
|
||||
r"id=\"docs-internal-guid-[\w-]+\"",
|
||||
r"<p>\s*</p>",
|
||||
r"<span></span>",
|
||||
r"<i>\s*</i>",
|
||||
r"<b>\s*</b>",
|
||||
r"<h1>\s*</h1>",
|
||||
r"<h2>\s*</h2>",
|
||||
r"<h3>\s*</h3>",
|
||||
r"<h4>\s*</h4>",
|
||||
r"<div>\s*</div>",
|
||||
]
|
||||
regex_replace = {r"<br>\s*</p>": "</p>"}
|
||||
changed = True
|
||||
while changed:
|
||||
# we need several iterations to clean nested tags this way
|
||||
changed = False
|
||||
new_body_iteration = new_body
|
||||
for regex in regex_remove:
|
||||
new_body = re.sub(regex, "", new_body)
|
||||
for regex, replace in regex_replace.items():
|
||||
new_body = re.sub(regex, replace, new_body)
|
||||
if new_body_iteration != new_body:
|
||||
changed = True
|
||||
return new_body
|
||||
|
||||
|
||||
def extract_html(entry, shout_id=None, cleanup=False):
|
||||
body_orig = (entry.get("body") or "").replace(r"\(", "(").replace(r"\)", ")")
|
||||
if cleanup:
|
||||
# we do that before bs parsing to catch the invalid html
|
||||
body_clean = cleanup_html(body_orig)
|
||||
if body_clean != body_orig:
|
||||
print(f"[migration] html cleaned for slug {entry.get('slug', None)}")
|
||||
body_orig = body_clean
|
||||
# if shout_id:
|
||||
# extract_footnotes(body_orig, shout_id)
|
||||
body_html = str(BeautifulSoup(body_orig, features="html.parser"))
|
||||
if cleanup:
|
||||
# we do that after bs parsing because it can add dummy tags
|
||||
body_clean_html = cleanup_html(body_html)
|
||||
if body_clean_html != body_html:
|
||||
print(f"[migration] html cleaned after bs4 for slug {entry.get('slug', None)}")
|
||||
body_html = body_clean_html
|
||||
return body_html
|
||||
1023
migration/html2text/__init__.py
Normal file
1023
migration/html2text/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
3
migration/html2text/__main__.py
Normal file
3
migration/html2text/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .cli import main
|
||||
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user