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'
|
name: 'Deploy to discoursio-api'
|
||||||
on: [push]
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Cloning repo
|
- name: Cloning repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
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
|
- name: Get Repo Name
|
||||||
id: repo_name
|
id: repo_name
|
||||||
run: echo "::set-output name=repo::$(echo ${GITHUB_REPOSITORY##*/})"
|
run: echo "::set-output name=repo::$(echo ${GITHUB_REPOSITORY##*/})"
|
||||||
@@ -181,56 +21,11 @@ jobs:
|
|||||||
id: branch_name
|
id: branch_name
|
||||||
run: echo "::set-output name=branch::$(echo ${GITHUB_REF##*/})"
|
run: echo "::set-output name=branch::$(echo ${GITHUB_REF##*/})"
|
||||||
|
|
||||||
- name: Verify Git Before Deploy Main
|
- name: Push to dokku
|
||||||
if: github.ref == 'refs/heads/main'
|
uses: dokku/github-action@master
|
||||||
run: |
|
with:
|
||||||
echo "🔍 Проверяем git перед деплоем на main..."
|
branch: 'main'
|
||||||
git status
|
git_remote_url: 'ssh://dokku@v2.discours.io:22/discoursio-api'
|
||||||
git log --oneline -5
|
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
echo "✅ Git репозиторий готов"
|
|
||||||
|
|
||||||
- name: Verify Git Before Deploy
|
|
||||||
if: github.ref == 'refs/heads/dev'
|
|
||||||
run: |
|
|
||||||
echo "🔍 Проверяем git перед деплоем..."
|
|
||||||
git status
|
|
||||||
git log --oneline -5
|
|
||||||
echo "✅ Git репозиторий готов"
|
|
||||||
|
|
||||||
- name: Setup SSH for Dev Deploy
|
|
||||||
if: github.ref == 'refs/heads/dev'
|
|
||||||
run: |
|
|
||||||
echo "🔑 Настраиваем SSH для деплоя..."
|
|
||||||
|
|
||||||
# Создаем SSH директорию
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
chmod 700 ~/.ssh
|
|
||||||
|
|
||||||
# Добавляем приватный ключ
|
|
||||||
echo "${{ secrets.STAGING_PRIVATE_KEY }}" > ~/.ssh/id_rsa
|
|
||||||
chmod 600 ~/.ssh/id_rsa
|
|
||||||
|
|
||||||
# Добавляем v3.discours.io в known_hosts
|
|
||||||
ssh-keyscan -H v3.discours.io >> ~/.ssh/known_hosts
|
|
||||||
|
|
||||||
# Запускаем ssh-agent
|
|
||||||
eval $(ssh-agent -s)
|
|
||||||
ssh-add ~/.ssh/id_rsa
|
|
||||||
|
|
||||||
echo "✅ SSH настроен для v3.discours.io"
|
|
||||||
|
|
||||||
- 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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main, dev, feature/* ]
|
branches:
|
||||||
pull_request:
|
- main
|
||||||
branches: [ main, dev ]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
push_to_target_repository:
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout source repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Setup Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install uv
|
- uses: webfactory/ssh-agent@v0.8.0
|
||||||
uses: astral-sh/setup-uv@v1
|
|
||||||
with:
|
with:
|
||||||
version: "1.0.0"
|
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
|
||||||
- name: Cache dependencies
|
- name: Push to dokku
|
||||||
uses: actions/cache@v3
|
env:
|
||||||
with:
|
HOST_KEY: ${{ secrets.HOST_KEY }}
|
||||||
path: |
|
|
||||||
.venv
|
|
||||||
.uv_cache
|
|
||||||
key: ${{ runner.os }}-uv-3.13-${{ hashFiles('**/uv.lock') }}
|
|
||||||
restore-keys: ${{ runner.os }}-uv-3.13-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
run: |
|
||||||
uv sync --group dev
|
echo $HOST_KEY > ~/.ssh/known_hosts
|
||||||
cd panel && npm ci && cd ..
|
git remote add dokku dokku@v2.discours.io:discoursio-api
|
||||||
|
git push dokku HEAD:main -f
|
||||||
- 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
|
|
||||||
|
|||||||
39
.gitignore
vendored
39
.gitignore
vendored
@@ -147,41 +147,6 @@ migration/content/**/*.md
|
|||||||
*.csv
|
*.csv
|
||||||
dev-server.pid
|
dev-server.pid
|
||||||
backups/
|
backups/
|
||||||
poetry.lock
|
|
||||||
.ruff_cache
|
.ruff_cache
|
||||||
.jj
|
.venv
|
||||||
.zed
|
poetry.lock
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
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 python:3.11-slim
|
||||||
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
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 📦 Node.js dependencies layer (cached unless package*.json changes)
|
EXPOSE 8080
|
||||||
COPY package.json package-lock.json ./
|
ADD nginx.conf.sigil ./
|
||||||
RUN npm ci
|
COPY requirements.txt .
|
||||||
|
RUN apt update && apt install -y git gcc curl postgresql
|
||||||
# 🐍 Python dependencies compilation (with Rust/maturin support)
|
RUN pip install -r requirements.txt
|
||||||
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)
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# 📦 Copy compiled Python environment from builder (includes all dependencies + local package)
|
CMD python server.py
|
||||||
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"]
|
|
||||||
|
|||||||
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)
|
on osx
|
||||||
- **🏘️ Communities**: Full community management with roles and permissions
|
```
|
||||||
- **🔒 RBAC System**: Role-based access control with inheritance
|
brew install redis nginx postgres
|
||||||
- **🌐 GraphQL API**: Modern API with comprehensive schema
|
brew services start redis
|
||||||
- **🧪 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development
|
on debian/ubuntu
|
||||||
```bash
|
```
|
||||||
# Start backend server
|
apt install redis nginx
|
||||||
uv run python dev.py
|
|
||||||
|
|
||||||
# Start frontend (in another terminal)
|
|
||||||
cd panel
|
|
||||||
npm run dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🧪 Testing
|
# Local development
|
||||||
|
|
||||||
### Run All Tests
|
Install deps first
|
||||||
```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
|
|
||||||
|
|
||||||
```
|
```
|
||||||
core/
|
pip install -r requirements.txt
|
||||||
├── auth/ # Authentication system
|
pip install -r requirements-dev.txt
|
||||||
├── orm/ # Database models
|
pre-commit install
|
||||||
├── resolvers/ # GraphQL resolvers
|
|
||||||
├── services/ # Business logic
|
|
||||||
├── panel/ # Frontend (SolidJS)
|
|
||||||
├── tests/ # Test suite
|
|
||||||
├── scripts/ # CI/CD scripts
|
|
||||||
└── docs/ # Documentation
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 Configuration
|
Create database from backup
|
||||||
|
```
|
||||||
### Environment Variables
|
./restdb.sh
|
||||||
- `DATABASE_URL` - Database connection string
|
|
||||||
- `REDIS_URL` - Redis connection string
|
|
||||||
- `JWT_SECRET_KEY` - JWT signing secret
|
|
||||||
- `OAUTH_*` - OAuth provider credentials
|
|
||||||
|
|
||||||
### Database
|
|
||||||
- **Development**: SQLite (default)
|
|
||||||
- **Production**: PostgreSQL
|
|
||||||
- **Testing**: In-memory SQLite
|
|
||||||
|
|
||||||
## 📚 Documentation
|
|
||||||
|
|
||||||
- [API Documentation](docs/api.md)
|
|
||||||
- [Authentication](docs/auth.md)
|
|
||||||
- [RBAC System](docs/rbac-system.md)
|
|
||||||
- [Testing Guide](docs/testing.md)
|
|
||||||
- [Deployment](docs/deployment.md)
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create a feature branch
|
|
||||||
3. Make your changes
|
|
||||||
4. Add tests for new functionality
|
|
||||||
5. Ensure all tests pass
|
|
||||||
6. Submit a pull request
|
|
||||||
|
|
||||||
### Development Workflow
|
|
||||||
```bash
|
|
||||||
# Create feature branch
|
|
||||||
git checkout -b feature/your-feature
|
|
||||||
|
|
||||||
# Make changes and test
|
|
||||||
uv run pytest tests/ -v
|
|
||||||
|
|
||||||
# Commit changes
|
|
||||||
git commit -m "feat: add your feature"
|
|
||||||
|
|
||||||
# Push and create PR
|
|
||||||
git push origin feature/your-feature
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📈 Status
|
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 base.exceptions import Unauthorized
|
||||||
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
|
|
||||||
|
|
||||||
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
|
|
||||||
|
|
||||||
|
|
||||||
class Permission(BaseModel):
|
class Permission(BaseModel):
|
||||||
"""Модель разрешения для RBAC"""
|
name: Text
|
||||||
|
|
||||||
resource: str
|
|
||||||
operation: str
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.resource}:{self.operation}"
|
|
||||||
|
|
||||||
|
|
||||||
class AuthCredentials(BaseModel):
|
class AuthCredentials(BaseModel):
|
||||||
"""
|
user_id: Optional[int] = None
|
||||||
Модель учетных данных авторизации.
|
scopes: Optional[dict] = {}
|
||||||
Используется как часть механизма аутентификации Starlette.
|
logged_in: bool = False
|
||||||
"""
|
error_message: str = ""
|
||||||
|
|
||||||
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]
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_admin(self) -> bool:
|
def is_admin(self):
|
||||||
"""
|
# TODO: check admin logix
|
||||||
Проверяет, является ли пользователь администратором.
|
return True
|
||||||
|
|
||||||
Returns:
|
async def permissions(self) -> List[Permission]:
|
||||||
bool: True, если email пользователя находится в списке ADMIN_EMAILS
|
if self.user_id is None:
|
||||||
"""
|
# raise Unauthorized("Please login first")
|
||||||
return self.email in ADMIN_EMAILS if self.email else False
|
return {"error": "Please login first"}
|
||||||
|
else:
|
||||||
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
|
|
||||||
# TODO: implement permissions logix
|
# TODO: implement permissions logix
|
||||||
print(self.author_id)
|
print(self.user_id)
|
||||||
return [] # Возвращаем пустой список вместо NotImplemented
|
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
|
import requests
|
||||||
|
|
||||||
from settings import MAILGUN_API_KEY, MAILGUN_DOMAIN
|
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"}
|
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:
|
try:
|
||||||
to = f"{user.name} <{user.email}>"
|
to = "%s <%s>" % (user.name, user.email)
|
||||||
if lang not in ["ru", "en"]:
|
if lang not in ["ru", "en"]:
|
||||||
lang = "ru"
|
lang = "ru"
|
||||||
subject = lang_subject.get(lang, lang_subject["en"])
|
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,
|
"to": to,
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
"template": template,
|
"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
|
# debug
|
||||||
# print('http://localhost:3000/?modal=auth&mode=confirm-email&token=%s' % token)
|
# 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()
|
response.raise_for_status()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(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 auth.jwtcodec import JWTCodec
|
||||||
from orm.author import Author
|
from auth.tokenstorage import TokenStorage
|
||||||
from storage.db import local_session
|
|
||||||
from storage.redis import redis
|
|
||||||
from utils.logger import root_logger as logger
|
|
||||||
from utils.password import Password
|
|
||||||
|
|
||||||
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:
|
class Identity:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def password(orm_author: AuthorType, password: str) -> AuthorType:
|
def password(orm_user: User, password: str) -> User:
|
||||||
"""
|
user = User(**orm_user.dict())
|
||||||
Проверяет пароль пользователя
|
if not user.password:
|
||||||
|
# raise InvalidPassword("User password is empty")
|
||||||
Args:
|
return {"error": "User password is empty"}
|
||||||
orm_author (Author): Объект пользователя
|
if not Password.verify(password, user.password):
|
||||||
password (str): Пароль пользователя
|
# raise InvalidPassword("Wrong user password")
|
||||||
|
return {"error": "Wrong user password"}
|
||||||
Returns:
|
return user
|
||||||
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
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def oauth(inp: dict[str, Any]) -> Any:
|
def oauth(inp) -> User:
|
||||||
"""
|
|
||||||
Создает нового пользователя OAuth, если он не существует
|
|
||||||
|
|
||||||
Args:
|
|
||||||
inp (dict): Данные OAuth пользователя
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Author: Объект пользователя
|
|
||||||
"""
|
|
||||||
# Author уже импортирован в начале файла
|
|
||||||
|
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
author = session.query(Author).where(Author.email == inp["email"]).first()
|
user = session.query(User).filter(User.email == inp["email"]).first()
|
||||||
if not author:
|
if not user:
|
||||||
author = Author(**inp)
|
user = User.create(**inp, emailConfirmed=True)
|
||||||
author.email_verified = True # type: ignore[assignment]
|
|
||||||
session.add(author)
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
return author
|
return user
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def onetime(token: str) -> Any:
|
async def onetime(token: str) -> User:
|
||||||
"""
|
|
||||||
Проверяет одноразовый токен
|
|
||||||
|
|
||||||
Args:
|
|
||||||
token (str): Одноразовый токен
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Author: Объект пользователя
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
print("[auth.identity] using one time token")
|
print("[auth.identity] using one time token")
|
||||||
payload = JWTCodec.decode(token)
|
payload = JWTCodec.decode(token)
|
||||||
if payload is None:
|
if not await TokenStorage.exist(f"{payload.user_id}-{payload.username}-{token}"):
|
||||||
logger.warning("[Identity.token] Токен не валиден (payload is None)")
|
# raise InvalidToken("Login token has expired, please login again")
|
||||||
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")
|
|
||||||
return {"error": "Token has expired"}
|
return {"error": "Token has expired"}
|
||||||
except InvalidTokenError:
|
except ExpiredSignatureError:
|
||||||
# raise InvalidTokenError("token format error") from e
|
# 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"}
|
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
|
from datetime import datetime, timezone
|
||||||
import logging
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
import jwt
|
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:
|
class JWTCodec:
|
||||||
"""
|
|
||||||
Кодировщик и декодировщик JWT токенов.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def encode(
|
def encode(user: AuthInput, exp: datetime) -> str:
|
||||||
payload: Dict[str, Any],
|
payload = {
|
||||||
secret_key: str | None = None,
|
"user_id": user.id,
|
||||||
algorithm: str | None = None,
|
"username": user.email or user.phone,
|
||||||
expiration: datetime.datetime | None = None,
|
"exp": exp,
|
||||||
) -> str | bytes:
|
"iat": datetime.now(tz=timezone.utc),
|
||||||
"""
|
"iss": "discours",
|
||||||
Кодирует 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}")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Используем PyJWT для кодирования
|
return jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM)
|
||||||
encoded = jwt.encode(payload, secret_key, algorithm=algorithm)
|
|
||||||
return encoded.decode("utf-8") if isinstance(encoded, bytes) else encoded
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[JWTCodec.encode] Ошибка при кодировании JWT: {e}")
|
print("[auth.jwtcodec] JWT encode error %r" % e)
|
||||||
raise
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def decode(
|
def decode(token: str, verify_exp: bool = True) -> TokenPayload:
|
||||||
token: str,
|
r = None
|
||||||
secret_key: str | None = None,
|
payload = 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]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Используем PyJWT для декодирования
|
payload = jwt.decode(
|
||||||
return jwt.decode(token, secret_key, algorithms=algorithms)
|
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:
|
except jwt.ExpiredSignatureError:
|
||||||
logger.warning("[JWTCodec.decode] Токен просрочен")
|
print("[auth.jwtcodec] expired signature %r" % payload)
|
||||||
raise
|
raise ExpiredToken("check token lifetime")
|
||||||
except jwt.InvalidTokenError as e:
|
except jwt.InvalidTokenError:
|
||||||
logger.warning(f"[JWTCodec.decode] Ошибка при декодировании JWT: {e}")
|
raise InvalidToken("token is not valid")
|
||||||
raise
|
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 asyncio
|
||||||
import os
|
import os
|
||||||
import traceback
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from importlib import import_module
|
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 import load_schema_from_path, make_executable_schema
|
||||||
from ariadne.asgi import GraphQL
|
from ariadne.asgi import GraphQL
|
||||||
from graphql import GraphQLError
|
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.middleware import Middleware
|
from starlette.middleware import Middleware
|
||||||
from starlette.middleware.cors import CORSMiddleware
|
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||||
from starlette.requests import Request
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
from starlette.responses import FileResponse, JSONResponse, Response
|
from starlette.routing import Route
|
||||||
from starlette.routing import Mount, Route
|
|
||||||
from starlette.staticfiles import StaticFiles
|
|
||||||
|
|
||||||
from auth.handler import EnhancedGraphQLHTTPHandler
|
from auth.authenticate import JWTAuthenticate
|
||||||
from auth.middleware import AuthMiddleware, auth_middleware
|
from auth.oauth import oauth_authorize, oauth_login
|
||||||
from auth.oauth import oauth_callback_http, oauth_login_http
|
from base.redis import redis
|
||||||
from cache.precache import precache_data
|
from base.resolvers import resolvers
|
||||||
from cache.revalidator import revalidation_manager
|
from orm import init_tables
|
||||||
from rbac import initialize_rbac
|
from resolvers.upload import upload_handler
|
||||||
from services.search import check_search_service, initialize_search_index, search_service
|
from services.main import storages_init
|
||||||
from services.viewed import ViewedStorage
|
from services.notifications.notification_service import notification_service
|
||||||
from settings import DEV_SERVER_PID_FILE_NAME
|
from services.notifications.sse import sse_subscribe_handler
|
||||||
from storage.redis import redis
|
from services.stat.viewed import ViewedStorage
|
||||||
from storage.schema import create_all_tables, resolvers
|
|
||||||
from utils.exception import ExceptionHandlerMiddleware
|
|
||||||
from utils.logger import custom_error_formatter
|
|
||||||
from utils.logger import root_logger as logger
|
|
||||||
from utils.sentry import start_sentry
|
|
||||||
|
|
||||||
DEVMODE = os.getenv("DOKKU_APP_TYPE", "false").lower() == "false"
|
# from services.zine.gittask import GitTask
|
||||||
DIST_DIR = Path(__file__).parent / "dist" # Директория для собранных файлов
|
from settings import DEV_SERVER_PID_FILE_NAME, SENTRY_DSN, SESSION_SECRET_KEY
|
||||||
INDEX_HTML = Path(__file__).parent / "index.html"
|
|
||||||
|
|
||||||
import_module("resolvers")
|
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 = [
|
||||||
# Начинаем с обработки ошибок
|
Middleware(AuthenticationMiddleware, backend=JWTAuthenticate()),
|
||||||
Middleware(ExceptionHandlerMiddleware),
|
Middleware(SessionMiddleware, secret_key=SESSION_SECRET_KEY),
|
||||||
# CORS должен быть перед другими middleware для корректной обработки preflight-запросов
|
|
||||||
Middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=[
|
|
||||||
"https://testing.discours.io",
|
|
||||||
"https://testing3.discours.io",
|
|
||||||
"https://v3.discours.io",
|
|
||||||
"https://session-daily.vercel.app",
|
|
||||||
"https://coretest.discours.io",
|
|
||||||
"https://new.discours.io",
|
|
||||||
"https://localhost:3000",
|
|
||||||
],
|
|
||||||
allow_methods=["GET", "POST", "OPTIONS"], # Явно указываем OPTIONS
|
|
||||||
allow_headers=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
),
|
|
||||||
# Аутентификация должна быть после CORS
|
|
||||||
Middleware(AuthMiddleware),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Создаем экземпляр GraphQL с улучшенным обработчиком и кастомным форматтером ошибок
|
|
||||||
graphql_app = GraphQL(
|
|
||||||
schema,
|
|
||||||
debug=DEVMODE,
|
|
||||||
http_handler=EnhancedGraphQLHTTPHandler(),
|
|
||||||
error_formatter=custom_error_formatter,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
async def start_up():
|
||||||
# Оборачиваем GraphQL-обработчик для лучшей обработки ошибок
|
init_tables()
|
||||||
|
await redis.connect()
|
||||||
|
await storages_init()
|
||||||
async def graphql_handler(request: Request) -> Response:
|
views_stat_task = asyncio.create_task(ViewedStorage().worker())
|
||||||
"""
|
print(views_stat_task)
|
||||||
Обработчик GraphQL запросов с поддержкой middleware и обработкой ошибок.
|
# git_task = asyncio.create_task(GitTask.git_task_worker())
|
||||||
|
# print(git_task)
|
||||||
Выполняет:
|
notification_service_task = asyncio.create_task(notification_service.worker())
|
||||||
1. Проверку метода запроса (GET, POST, OPTIONS)
|
print(notification_service_task)
|
||||||
2. Обработку GraphQL запроса через ariadne
|
|
||||||
3. Применение middleware для корректной обработки cookie и авторизации
|
|
||||||
4. Обработку исключений и формирование ответа
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: Starlette Request объект
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response: объект ответа (обычно JSONResponse)
|
|
||||||
"""
|
|
||||||
if request.method not in ["GET", "POST", "OPTIONS"]:
|
|
||||||
return JSONResponse({"error": "Method Not Allowed by main.py"}, status_code=405)
|
|
||||||
|
|
||||||
# Проверяем, что все необходимые middleware корректно отработали
|
|
||||||
if not hasattr(request, "scope") or "auth" not in request.scope:
|
|
||||||
logger.warning("[graphql] AuthMiddleware не обработал запрос перед GraphQL обработчиком")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Обрабатываем запрос через GraphQL приложение
|
import sentry_sdk
|
||||||
result = await graphql_app.handle_request(request)
|
|
||||||
|
|
||||||
# Применяем middleware для установки cookie
|
sentry_sdk.init(SENTRY_DSN)
|
||||||
# Используем метод process_result из auth_middleware для корректной обработки
|
|
||||||
# cookie на основе результатов операций login/logout
|
|
||||||
return await auth_middleware.process_result(request, result)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
return JSONResponse({"error": "Request cancelled"}, status_code=499)
|
|
||||||
except GraphQLError as e:
|
|
||||||
# Для GraphQL ошибок (например, неавторизованный доступ) не логируем полный трейс
|
|
||||||
logger.warning(f"GraphQL error: {e}")
|
|
||||||
return JSONResponse({"error": str(e)}, status_code=403)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected GraphQL error: {e!s}")
|
print("[sentry] init error")
|
||||||
logger.debug(f"Unexpected GraphQL error traceback: {traceback.format_exc()}")
|
print(e)
|
||||||
return JSONResponse({"error": "Internal server error"}, status_code=500)
|
|
||||||
|
|
||||||
|
|
||||||
async def spa_handler(request: Request) -> Response:
|
async def dev_start_up():
|
||||||
"""
|
if exists(DEV_SERVER_PID_FILE_NAME):
|
||||||
Обработчик для SPA (Single Page Application) fallback.
|
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 для всех маршрутов, которые не найдены,
|
await start_up()
|
||||||
чтобы клиентский роутер (SolidJS) мог обработать маршрутизацию.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: Starlette Request объект
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FileResponse: ответ с содержимым index.html
|
|
||||||
"""
|
|
||||||
# Исключаем API маршруты из SPA fallback
|
|
||||||
path = request.url.path
|
|
||||||
if path.startswith(("/graphql", "/oauth", "/assets")):
|
|
||||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
|
||||||
|
|
||||||
index_path = DIST_DIR / "index.html"
|
|
||||||
if index_path.exists():
|
|
||||||
return FileResponse(index_path, media_type="text/html")
|
|
||||||
return JSONResponse({"error": "Admin panel not built"}, status_code=404)
|
|
||||||
|
|
||||||
|
|
||||||
async def health_handler(request: Request) -> Response:
|
async def shutdown():
|
||||||
"""Health check endpoint with Redis monitoring"""
|
|
||||||
try:
|
|
||||||
redis_info = await redis.get_info()
|
|
||||||
return JSONResponse(
|
|
||||||
{"status": "healthy", "redis": {"connected": redis.is_connected, "ping": await redis.ping(), **redis_info}}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Health check failed: {e}")
|
|
||||||
return JSONResponse({"status": "unhealthy", "error": str(e)}, status_code=500)
|
|
||||||
|
|
||||||
|
|
||||||
async def shutdown() -> None:
|
|
||||||
"""Остановка сервера и освобождение ресурсов"""
|
|
||||||
logger.info("Остановка сервера")
|
|
||||||
|
|
||||||
# Закрываем соединение с Redis
|
|
||||||
await redis.disconnect()
|
await redis.disconnect()
|
||||||
|
|
||||||
# Останавливаем поисковый сервис
|
|
||||||
await search_service.close()
|
|
||||||
|
|
||||||
pid_file = Path(DEV_SERVER_PID_FILE_NAME)
|
routes = [
|
||||||
if pid_file.exists():
|
Route("/oauth/{provider}", endpoint=oauth_login),
|
||||||
pid_file.unlink()
|
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(
|
app = Starlette(
|
||||||
routes=[
|
on_startup=[start_up],
|
||||||
Route("/graphql", graphql_handler, methods=["GET", "POST", "OPTIONS"]),
|
on_shutdown=[shutdown],
|
||||||
# OAuth маршруты - порядок важен! Более специфичные маршруты должны быть первыми
|
middleware=middleware,
|
||||||
Route("/oauth/{provider}/callback", oauth_callback_http, methods=["GET"]),
|
routes=routes,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
app.mount("/", GraphQL(schema))
|
||||||
|
|
||||||
if DEVMODE:
|
dev_app = Starlette(
|
||||||
# Для DEV режима регистрируем дополнительный CORS middleware только для localhost
|
debug=True,
|
||||||
app.add_middleware(
|
on_startup=[dev_start_up],
|
||||||
CORSMiddleware,
|
on_shutdown=[shutdown],
|
||||||
allow_origins=[
|
middleware=middleware,
|
||||||
"https://localhost:3000",
|
routes=routes,
|
||||||
"https://localhost:3001",
|
)
|
||||||
"https://localhost:3002",
|
dev_app.mount("/", GraphQL(schema, debug=True))
|
||||||
"http://localhost:3000",
|
|
||||||
"http://localhost:3001",
|
|
||||||
"http://localhost:3002",
|
|
||||||
],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|||||||
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