1 Commits

Author SHA1 Message Date
Stepan Vladovskiy
27b0928e73 feat: title weight procedure
All checks were successful
Deploy on push / deploy (push) Successful in 1m16s
2025-04-15 19:32:34 -03:00
319 changed files with 5922 additions and 78725 deletions

View File

@@ -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

View File

@@ -1,178 +1,14 @@
name: 'Deploy on push'
on: [push]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Cloning repo
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Install uv
run: |
# Try multiple installation methods for uv
if curl -LsSf https://astral.sh/uv/install.sh | sh; then
echo "uv installed successfully via install script"
elif curl -LsSf https://github.com/astral-sh/uv/releases/latest/download/uv-installer.sh | sh; then
echo "uv installed successfully via GitHub installer"
else
echo "uv installation failed, using pip fallback"
pip install uv
fi
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Prepare Environment
run: |
uv --version
python3 --version
- name: Install Dependencies
run: |
uv sync --frozen
uv sync --group dev
- name: Run linting
run: |
echo "🔍 Запускаем проверки качества кода..."
# Ruff linting
echo "📝 Проверяем код с помощью Ruff..."
uv run ruff check . --fix
# Ruff formatting check
echo "🎨 Проверяем форматирование с помощью Ruff..."
uv run ruff format . --line-length 120
- name: Run type checking
continue-on-error: true
run: |
echo "🏷️ Проверяем типы с помощью MyPy..."
echo "📊 Доступная память:"
free -h
# Проверяем доступную память
AVAILABLE_MEM=$(free -m | awk 'NR==2{printf "%.0f", $7}')
echo "📊 Доступно памяти: ${AVAILABLE_MEM}MB"
# Если памяти меньше 1GB, пропускаем mypy
if [ "$AVAILABLE_MEM" -lt 1000 ]; then
echo "⚠️ Недостаточно памяти для mypy (${AVAILABLE_MEM}MB < 1000MB), пропускаем проверку типов"
echo "✅ Проверка типов пропущена из-за нехватки памяти"
exit 0
fi
# Пробуем dmypy сначала, если не работает - fallback на обычный mypy
if command -v dmypy >/dev/null 2>&1 && uv run dmypy run -- auth/ cache/ orm/ resolvers/ services/ storage/ utils/ --ignore-missing-imports; then
echo "✅ dmypy выполнен успешно"
else
echo "⚠️ dmypy недоступен, используем обычный mypy"
# Запускаем mypy только на самых критичных модулях
echo "🔍 Проверяем только критичные модули..."
uv run mypy auth/ orm/ resolvers/ --ignore-missing-imports || echo "⚠️ Ошибки в критичных модулях, но продолжаем"
echo "✅ Проверка типов завершена"
fi
- name: Install Node.js Dependencies
run: |
npm ci
- name: Build Frontend
env:
CI: "true" # 🚨 Указываем что это CI сборка для codegen
run: |
echo "🏗️ Начинаем сборку фронтенда..."
# Запускаем codegen с fallback логикой
echo "📝 Запускаем GraphQL codegen..."
npm run codegen 2>&1 | tee codegen_output.log
if [ ${PIPESTATUS[0]} -ne 0 ]; then
echo "❌ GraphQL codegen упал с v3.discours.io!"
echo "📋 ПОЛНЫЙ ВЫВОД ОШИБКИ:"
cat codegen_output.log
echo "📋 КОНЕЦ ВЫВОДА ОШИБКИ"
echo ""
# Проверяем доступность endpoints
echo "🌐 Проверяем доступность GraphQL endpoints:"
V3_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Content-Type: application/json" \
-d '{"query":"query{__typename}"}' \
https://v3.discours.io/graphql 2>/dev/null || echo "000")
echo "v3.discours.io: $V3_STATUS"
CORETEST_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Content-Type: application/json" \
-d '{"query":"query{__typename}"}' \
https://coretest.discours.io/graphql 2>/dev/null || echo "000")
echo "coretest.discours.io: $CORETEST_STATUS"
# Если coretest доступен, пробуем его
if [ "$CORETEST_STATUS" = "200" ]; then
echo "🔄 Переключаемся на coretest.discours.io..."
# Временно меняем схему в codegen.ts
sed -i "s|https://v3.discours.io/graphql|https://coretest.discours.io/graphql|g" codegen.ts
npm run codegen 2>&1 | tee fallback_output.log
if [ ${PIPESTATUS[0]} -ne 0 ]; then
echo "❌ Fallback тоже не сработал!"
echo "📋 ПОЛНЫЙ ВЫВОД ОШИБКИ FALLBACK:"
cat fallback_output.log
echo "📋 КОНЕЦ ВЫВОДА ОШИБКИ FALLBACK"
# Восстанавливаем оригинальную схему
sed -i "s|https://coretest.discours.io/graphql|https://v3.discours.io/graphql|g" codegen.ts
exit 1
fi
# Восстанавливаем оригинальную схему
sed -i "s|https://coretest.discours.io/graphql|https://v3.discours.io/graphql|g" codegen.ts
else
echo "❌ Оба endpoint недоступны!"
exit 1
fi
fi
echo "🔨 Запускаем Vite build..."
npx vite build
- name: Setup Playwright (use pre-installed browsers)
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
run: |
# Используем предустановленные браузеры в системе
npx playwright --version
- name: Run Tests
env:
PLAYWRIGHT_HEADLESS: "true"
timeout-minutes: 7
run: |
# Запускаем тесты с таймаутом для предотвращения зависания
# continue-on-error: true не работает в Gitea Actions, поэтому используем || true
timeout 900 uv run pytest tests/ -v --timeout=300 || echo "⚠️ Тесты завершились с ошибками/таймаутом, но продолжаем деплой"
continue-on-error: true
- name: Restore Git Repository
if: always()
run: |
echo "🔧 Восстанавливаем git репозиторий для деплоя..."
# Проверяем состояние git
git status || echo "⚠️ Git репозиторий поврежден, восстанавливаем..."
# Если git поврежден, переинициализируем
if [ ! -d ".git" ] || [ ! -f ".git/HEAD" ]; then
echo "🔄 Переинициализируем git репозиторий..."
git init
git remote add origin https://github.com/${{ github.repository }}.git
git fetch origin
git checkout ${{ github.ref_name }}
fi
# Проверяем финальное состояние
git status
echo "✅ Git репозиторий готов для деплоя"
- name: Get Repo Name
id: repo_name
run: echo "::set-output name=repo::$(echo ${GITHUB_REPOSITORY##*/})"
@@ -181,56 +17,28 @@ jobs:
id: branch_name
run: echo "::set-output name=branch::$(echo ${GITHUB_REF##*/})"
- name: Verify Git Before Deploy Main
- name: Push to dokku for main branch
if: github.ref == 'refs/heads/main'
run: |
echo "🔍 Проверяем git перед деплоем на main..."
git status
git log --oneline -5
echo "✅ Git репозиторий готов"
- name: Verify Git Before Deploy
if: github.ref == 'refs/heads/dev'
run: |
echo "🔍 Проверяем git перед деплоем..."
git status
git log --oneline -5
echo "✅ Git репозиторий готов"
- name: Setup SSH for Dev Deploy
if: github.ref == 'refs/heads/dev'
run: |
echo "🔑 Настраиваем SSH для деплоя..."
# Создаем SSH директорию
mkdir -p ~/.ssh
chmod 700 ~/.ssh
# Добавляем приватный ключ
echo "${{ secrets.STAGING_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
# Добавляем v3.discours.io в known_hosts
ssh-keyscan -H v3.discours.io >> ~/.ssh/known_hosts
# Запускаем ssh-agent
eval $(ssh-agent -s)
ssh-add ~/.ssh/id_rsa
echo "✅ SSH настроен для v3.discours.io"
uses: dokku/github-action@master
with:
branch: 'main'
git_remote_url: 'ssh://dokku@v2.discours.io:22/discoursio-api'
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Push to dokku for dev branch
if: github.ref == 'refs/heads/dev'
run: |
echo "🚀 Деплоим на v3.discours.io..."
# Добавляем dokku remote
git remote add dokku ssh://dokku@v3.discours.io:22/core || git remote set-url dokku ssh://dokku@v3.discours.io:22/core
# Проверяем remote
git remote -v
# Деплоим текущую ветку
git push dokku dev -f
echo "✅ Деплой на dev завершен"
uses: dokku/github-action@master
with:
branch: 'main'
force: true
git_remote_url: 'ssh://dokku@v2.discours.io:22/core'
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Push to dokku for staging branch
if: github.ref == 'refs/heads/staging'
uses: dokku/github-action@master
with:
branch: 'dev'
git_remote_url: 'ssh://dokku@staging.discours.io:22/core'
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
git_push_flags: '--force'

View File

@@ -1,233 +1,28 @@
name: CI/CD Pipeline
name: Deploy
on:
push:
branches: [ main, dev, feature/* ]
pull_request:
branches: [ main, dev ]
branches:
- main
jobs:
test:
push_to_target_repository:
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Checkout source repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: "3.13"
- uses: webfactory/ssh-agent@v0.8.0
with:
ssh-private-key: ${{ github.action.secrets.SSH_PRIVATE_KEY }}
- name: Install uv
uses: astral-sh/setup-uv@v1
with:
version: "1.0.0"
- name: Cache dependencies
uses: actions/cache@v3
with:
path: |
.venv
.uv_cache
key: ${{ runner.os }}-uv-3.13-${{ hashFiles('**/uv.lock') }}
restore-keys: ${{ runner.os }}-uv-3.13-
- name: Install dependencies
run: |
uv sync --group dev
cd panel && npm ci && cd ..
- name: Verify Redis connection
run: |
echo "Verifying Redis connection..."
max_retries=5
for attempt in $(seq 1 $max_retries); do
if redis-cli ping > /dev/null 2>&1; then
echo "✅ Redis is ready!"
break
else
if [ $attempt -eq $max_retries ]; then
echo "❌ Redis connection failed after $max_retries attempts"
echo "⚠️ Tests may fail due to Redis unavailability"
# Не выходим с ошибкой, продолжаем тесты
break
else
echo "⚠️ Redis not ready, retrying in 2 seconds... (attempt $attempt/$max_retries)"
sleep 2
fi
fi
done
- name: Run linting and type checking
run: |
echo "🔍 Запускаем проверки качества кода..."
# Ruff linting
echo "📝 Проверяем код с помощью Ruff..."
uv run ruff check . --fix
# Ruff formatting check
echo "🎨 Проверяем форматирование с помощью Ruff..."
uv run ruff format . --line-length 120
# MyPy type checking
echo "🏷️ Проверяем типы с помощью MyPy..."
uv run mypy . --ignore-missing-imports
- name: Setup test environment
run: |
echo "Setting up test environment..."
# Создаем .env.test для тестов
cat > .env.test << EOF
DATABASE_URL=sqlite:///database.db
REDIS_URL=redis://localhost:6379
TEST_MODE=true
EOF
# Проверяем что файл создан
echo "Test environment file created:"
cat .env.test
- name: Initialize test database
run: |
echo "Initializing test database..."
touch database.db
uv run python -c "
import time
import sys
from pathlib import Path
# Добавляем корневую папку в путь
sys.path.insert(0, str(Path.cwd()))
try:
from orm.base import Base
from orm.community import Community, CommunityFollower, CommunityAuthor
from orm.draft import Draft
from orm.invite import Invite
from orm.notification import Notification
from orm.reaction import Reaction
from orm.shout import Shout
from orm.topic import Topic
from orm.author import Author, AuthorBookmark, AuthorRating, AuthorFollower
from storage.db import engine
from sqlalchemy import inspect
print('✅ Engine imported successfully')
print('Creating all tables...')
Base.metadata.create_all(engine)
# Проверяем что таблицы созданы
inspector = inspect(engine)
tables = inspector.get_table_names()
print(f'✅ Created tables: {tables}')
# Проверяем конкретно community_author
if 'community_author' in tables:
print('✅ community_author table exists!')
else:
print('❌ community_author table missing!')
print('Available tables:', tables)
except Exception as e:
print(f'❌ Error initializing database: {e}')
import traceback
traceback.print_exc()
sys.exit(1)
"
- name: Start servers
run: |
chmod +x ./ci-server.py
timeout 300 python ./ci-server.py &
echo $! > ci-server.pid
echo "Waiting for servers..."
timeout 180 bash -c '
while ! (curl -f http://localhost:8000/ > /dev/null 2>&1 && \
curl -f http://localhost:3000/ > /dev/null 2>&1); do
sleep 3
done
echo "Servers ready!"
'
- name: Run tests with retry
run: |
# Создаем папку для результатов тестов
mkdir -p test-results
# Сначала проверяем здоровье серверов
echo "🏥 Проверяем здоровье серверов..."
if uv run pytest tests/test_server_health.py -v; then
echo "✅ Серверы здоровы!"
else
echo "⚠️ Тест здоровья серверов не прошел, но продолжаем..."
fi
for test_type in "not e2e" "integration" "e2e" "browser"; do
echo "Running $test_type tests..."
max_retries=3 # Увеличиваем количество попыток
for attempt in $(seq 1 $max_retries); do
echo "Attempt $attempt/$max_retries for $test_type tests..."
# Добавляем специальные параметры для browser тестов
if [ "$test_type" = "browser" ]; then
echo "🚀 Запускаем browser тесты с увеличенным таймаутом..."
if uv run pytest tests/ -m "$test_type" -v --tb=short --timeout=60; then
echo "✅ $test_type tests passed!"
break
else
if [ $attempt -eq $max_retries ]; then
echo "⚠️ Browser tests failed after $max_retries attempts (expected in CI) - continuing..."
break
else
echo "⚠️ Browser tests failed, retrying in 15 seconds..."
sleep 15
fi
fi
else
# Обычные тесты
if uv run pytest tests/ -m "$test_type" -v --tb=short; then
echo "✅ $test_type tests passed!"
break
else
if [ $attempt -eq $max_retries ]; then
echo "❌ $test_type tests failed after $max_retries attempts"
exit 1
else
echo "⚠️ $test_type tests failed, retrying in 10 seconds..."
sleep 10
fi
fi
fi
done
done
- name: Generate coverage
run: |
uv run pytest tests/ --cov=. --cov-report=xml --cov-report=html
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
fail_ci_if_error: false
- name: Cleanup
if: always()
run: |
[ -f ci-server.pid ] && kill $(cat ci-server.pid) 2>/dev/null || true
pkill -f "python dev.py|npm run dev|vite|ci-server.py" || true
rm -f backend.pid frontend.pid ci-server.pid
- name: Push to dokku
env:
HOST_KEY: ${{ github.action.secrets.HOST_KEY }}
run: |
echo $HOST_KEY > ~/.ssh/known_hosts
git remote add dokku dokku@v2.discours.io:discoursio-api
git push dokku HEAD:main -f

27
.gitignore vendored
View File

@@ -128,6 +128,9 @@ dmypy.json
.idea
temp.*
# Debug
DEBUG.log
discours.key
discours.crt
discours.pem
@@ -162,26 +165,4 @@ views.json
*.crt
*cache.json
.cursor
node_modules/
panel/graphql/generated/
panel/types.gen.ts
.cursorrules
.cursor/
# YoYo AI version control directory
.yoyo/
.autopilot.json
.cursor
tmp
test-results
page_content.html
test_output
docs/progress/*
panel/graphql/generated
test_e2e.db*
uv.lock
.devcontainer/

18
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,18 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: check-yaml
- id: check-toml
- id: end-of-file-fixer
- id: trailing-whitespace
- id: check-added-large-files
- id: detect-private-key
- id: check-ast
- id: check-merge-conflict
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.7
hooks:
- id: ruff
args: [--fix]

File diff suppressed because it is too large Load Diff

View File

@@ -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! 🙏

View File

@@ -1,65 +1,18 @@
# 🏗️ Multi-stage build for optimal caching and size
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
FROM python:slim
# 🔧 System dependencies layer (cached unless OS changes)
RUN apt-get update && apt-get install -y \
postgresql-client \
git \
curl \
build-essential \
gnupg \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# 📦 Install Node.js LTS (cached until Node.js version changes)
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/* \
&& npm upgrade -g npm
WORKDIR /app
# 📦 Node.js dependencies layer (cached unless package*.json changes)
COPY package.json package-lock.json ./
RUN npm ci
# 🐍 Python dependencies compilation (with Rust/maturin support)
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project
# 🏗️ Frontend build (build with all dependencies)
COPY . .
# Install local package in builder stage
RUN uv sync --frozen --no-editable
RUN npm run build
# 🚀 Production stage
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
# 🔧 Runtime dependencies only
RUN apt-get update && apt-get install -y \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 🧠 ML models cache setup (cached unless HF environment changes)
RUN mkdir -p /app/.cache/huggingface && chmod 755 /app/.cache/huggingface
ENV TRANSFORMERS_CACHE=/app/.cache/huggingface
ENV HF_HOME=/app/.cache/huggingface
# Принудительно используем CPU-only версию PyTorch
ENV TORCH_CUDA_AVAILABLE=0
COPY requirements.txt .
RUN pip install -r requirements.txt
# 🚀 Application code (rebuilt on any code change)
COPY . .
# 📦 Copy compiled Python environment from builder (includes all dependencies + local package)
COPY --from=builder /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
# 📦 Copy built frontend from builder stage
COPY --from=builder /app/dist ./dist
EXPOSE 8000
CMD ["python", "-m", "granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port", "8000"]
CMD ["python", "-m", "granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port", "8000"]

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Discours Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

272
README.md
View File

@@ -1,212 +1,102 @@
# Discours.io Core
# GraphQL API Backend
🚀 **Modern community platform** with GraphQL API, RBAC system, and comprehensive testing infrastructure.
Backend service providing GraphQL API for content management system with reactions, ratings and comments.
## 🎯 Features
## Core Features
- **🔐 Authentication**: JWT + OAuth (Google, GitHub, Facebook)
- **🏘️ Communities**: Full community management with roles and permissions
- **🔒 RBAC System**: Role-based access control with inheritance
- **🌐 GraphQL API**: Modern API with comprehensive schema
- **🧪 Testing**: Complete test suite with E2E automation
- **🚀 CI/CD**: Automated testing and deployment pipeline
### Shouts (Posts)
- CRUD operations via GraphQL mutations
- Rich filtering and sorting options
- Support for multiple authors and topics
- Rating system with likes/dislikes
- Comments and nested replies
- Bookmarks and following
## 🚀 Quick Start
### Reactions System
- `ReactionKind` types: LIKE, DISLIKE, COMMENT
- Rating calculation for shouts and comments
- User-specific reaction tracking
- Reaction stats and aggregations
- Nested comments support
### Prerequisites
- Python 3.11+
- Node.js 18+
- Redis
- uv (Python package manager)
### Authors & Topics
- Author profiles with stats
- Topic categorization and hierarchy
- Following system for authors/topics
- Activity tracking and stats
- Community features
### Installation
```bash
# Clone repository
git clone <repository-url>
cd core
## Tech Stack
# Install Python dependencies
uv sync --group dev
- **(Python)[https://www.python.org/]** 3.12+
- **GraphQL** with [Ariadne](https://ariadnegraphql.org/)
- **(SQLAlchemy)[https://docs.sqlalchemy.org/en/20/orm/]**
- **(PostgreSQL)[https://www.postgresql.org/]/(SQLite)[https://www.sqlite.org/]** support
- **(Starlette)[https://www.starlette.io/]** for ASGI server
- **(Redis)[https://redis.io/]** for caching
# Install Node.js dependencies
cd panel
npm ci
cd ..
## Development
# Setup environment
cp .env.example .env
# Edit .env with your configuration
### Prepare environment:
```shell
mkdir .venv
python3.12 -m venv venv
source venv/bin/activate
```
### Development
```bash
# Start backend server
uv run python dev.py
### Run server
# Start frontend (in another terminal)
cd panel
npm run dev
First, certifcates are required to run the server.
```shell
mkcert -install
mkcert localhost
```
## 🧪 Testing
Then, run the server:
### Run All Tests
```bash
uv run pytest tests/ -v
```shell
python server.py dev
```
### Test Categories
### Useful Commands
#### Run only unit tests
```bash
uv run pytest tests/ -m "not e2e" -v
```shell
# Linting and import sorting
ruff check . --fix --select I
# Code formatting
ruff format . --line-length=120
# Run tests
pytest
# Type checking
mypy .
```
#### Run only integration tests
```bash
uv run pytest tests/ -m "integration" -v
### Code Style
We use:
- Ruff for linting and import sorting
- Line length: 120 characters
- Python type hints
- Docstrings for public methods
### GraphQL Development
Test queries in GraphQL Playground at `http://localhost:8000`:
```graphql
# Example query
query GetShout($slug: String) {
get_shout(slug: $slug) {
id
title
main_author {
name
}
}
}
```
#### Run only e2e tests
```bash
uv run pytest tests/ -m "e2e" -v
```
#### Run browser tests
```bash
uv run pytest tests/ -m "browser" -v
```
#### Run API tests
```bash
uv run pytest tests/ -m "api" -v
```
#### Skip slow tests
```bash
uv run pytest tests/ -m "not slow" -v
```
#### Run tests with specific markers
```bash
uv run pytest tests/ -m "db and not slow" -v
```
### Test Markers
- `unit` - Unit tests (fast)
- `integration` - Integration tests
- `e2e` - End-to-end tests
- `browser` - Browser automation tests
- `api` - API-based tests
- `db` - Database tests
- `redis` - Redis tests
- `auth` - Authentication tests
- `slow` - Slow tests (can be skipped)
### E2E Testing
E2E tests automatically start backend and frontend servers:
- Backend: `http://localhost:8000`
- Frontend: `http://localhost:3000`
## 🚀 CI/CD Pipeline
### GitHub Actions Workflow
The project includes a comprehensive CI/CD pipeline that:
1. **🧪 Testing Phase**
- Matrix testing across Python 3.11, 3.12, 3.13
- Unit, integration, and E2E tests
- Code coverage reporting
- Linting and type checking
2. **🚀 Deployment Phase**
- **Staging**: Automatic deployment on `dev` branch
- **Production**: Automatic deployment on `main` branch
- Dokku integration for seamless deployments
### Local CI Testing
Test the CI pipeline locally:
```bash
# Run local CI simulation
chmod +x scripts/test-ci-local.sh
./scripts/test-ci-local.sh
```
### CI Server Management
The `./ci-server.py` script manages servers for CI:
```bash
# Start servers in CI mode
CI_MODE=true python3 ./ci-server.py
```
## 📊 Project Structure
```
core/
├── auth/ # Authentication system
├── orm/ # Database models
├── resolvers/ # GraphQL resolvers
├── services/ # Business logic
├── panel/ # Frontend (SolidJS)
├── tests/ # Test suite
├── scripts/ # CI/CD scripts
└── docs/ # Documentation
```
## 🔧 Configuration
### Environment Variables
- `DATABASE_URL` - Database connection string
- `REDIS_URL` - Redis connection string
- `JWT_SECRET_KEY` - JWT signing secret
- `OAUTH_*` - OAuth provider credentials
### Database
- **Development**: SQLite (default)
- **Production**: PostgreSQL
- **Testing**: In-memory SQLite
## 📚 Documentation
- [API Documentation](docs/api.md)
- [Authentication](docs/auth.md)
- [RBAC System](docs/rbac-system.md)
- [Testing Guide](docs/testing.md)
- [Deployment](docs/deployment.md)
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests for new functionality
5. Ensure all tests pass
6. Submit a pull request
### Development Workflow
```bash
# Create feature branch
git checkout -b feature/your-feature
# Make changes and test
uv run pytest tests/ -v
# Commit changes
git commit -m "feat: add your feature"
# Push and create PR
git push origin feature/your-feature
```
## 📈 Status
![Tests](https://github.com/your-org/discours-core/workflows/Tests/badge.svg)
![Coverage](https://codecov.io/gh/your-org/discours-core/branch/main/graph/badge.svg)
![Python](https://img.shields.io/badge/python-3.11%2B-blue)
![Node.js](https://img.shields.io/badge/node-18%2B-green)
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

View File

@@ -1,6 +1,6 @@
import os
import sys
from pathlib import Path
# Получаем путь к корневой директории проекта
root_path = Path(__file__).parent.parent
sys.path.append(str(root_path))
root_path = os.path.abspath(os.path.dirname(__file__))
sys.path.append(root_path)

76
alembic/env.py Normal file
View File

@@ -0,0 +1,76 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from services.db import Base
from settings import DB_URL
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# override DB_URL
config.set_section_option(config.config_ini_section, "DB_URL", DB_URL)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = [Base.metadata]
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -1,129 +0,0 @@
from starlette.requests import Request
from starlette.responses import JSONResponse, RedirectResponse, Response
from auth.core import verify_internal_auth
from auth.tokens.storage import TokenStorage
from auth.utils import extract_token_from_request
from orm.author import Author
from settings import (
SESSION_COOKIE_HTTPONLY,
SESSION_COOKIE_MAX_AGE,
SESSION_COOKIE_NAME,
SESSION_COOKIE_SAMESITE,
SESSION_COOKIE_SECURE,
)
from storage.db import local_session
from utils.logger import root_logger as logger
async def logout(request: Request) -> Response:
"""
Выход из системы с удалением сессии и cookie.
Поддерживает получение токена из:
1. HTTP-only cookie
2. Заголовка Authorization
"""
token = await extract_token_from_request(request)
# Если токен найден, отзываем его
if token:
try:
# Декодируем токен для получения user_id
user_id, _, _ = await verify_internal_auth(token)
if user_id:
# Отзываем сессию
await TokenStorage.revoke_session(token)
logger.info(f"[auth] logout: Токен успешно отозван для пользователя {user_id}")
else:
logger.warning("[auth] logout: Не удалось получить user_id из токена")
except Exception as e:
logger.error(f"[auth] logout: Ошибка при отзыве токена: {e}")
else:
logger.warning("[auth] logout: Токен не найден в запросе")
# Создаем ответ с редиректом на страницу входа
response = RedirectResponse(url="/")
# Удаляем cookie с токеном
response.delete_cookie(
key=SESSION_COOKIE_NAME,
secure=SESSION_COOKIE_SECURE,
httponly=SESSION_COOKIE_HTTPONLY,
samesite=SESSION_COOKIE_SAMESITE if SESSION_COOKIE_SAMESITE in ["strict", "lax", "none"] else "none",
)
logger.info("[auth] logout: Cookie успешно удалена")
return response
async def refresh_token(request: Request) -> JSONResponse:
"""
Обновление токена аутентификации.
Поддерживает получение токена из:
1. HTTP-only cookie
2. Заголовка Authorization
Возвращает новый токен как в HTTP-only cookie, так и в теле ответа.
"""
token = await extract_token_from_request(request)
if not token:
logger.warning("[auth] refresh_token: Токен не найден в запросе")
return JSONResponse({"success": False, "error": "Токен не найден"}, status_code=401)
try:
# Получаем информацию о пользователе из токена
user_id, _, _ = await verify_internal_auth(token)
if not user_id:
logger.warning("[auth] refresh_token: Недействительный токен")
return JSONResponse({"success": False, "error": "Недействительный токен"}, status_code=401)
# Получаем пользователя из базы данных
with local_session() as session:
author = session.query(Author).where(Author.id == user_id).first()
if not author:
logger.warning(f"[auth] refresh_token: Пользователь с ID {user_id} не найден")
return JSONResponse({"success": False, "error": "Пользователь не найден"}, status_code=404)
# Обновляем сессию (создаем новую и отзываем старую)
device_info = {
"ip": request.client.host if request.client else "unknown",
"user_agent": request.headers.get("user-agent"),
}
new_token = await TokenStorage.refresh_session(user_id, token, device_info)
if not new_token:
logger.error(f"[auth] refresh_token: Не удалось обновить токен для пользователя {user_id}")
return JSONResponse({"success": False, "error": "Не удалось обновить токен"}, status_code=500)
source = "cookie" if token.startswith("Bearer ") else "header"
# Создаем ответ
response = JSONResponse(
{
"success": True,
# Возвращаем токен в теле ответа только если он был получен из заголовка
"token": new_token if source == "header" else None,
"author": {"id": author.id, "email": author.email, "name": author.name},
}
)
# Всегда устанавливаем cookie с новым токеном
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=new_token,
httponly=SESSION_COOKIE_HTTPONLY,
secure=SESSION_COOKIE_SECURE,
samesite=SESSION_COOKIE_SAMESITE if SESSION_COOKIE_SAMESITE in ["strict", "lax", "none"] else "none",
max_age=SESSION_COOKIE_MAX_AGE,
)
logger.info(f"[auth] refresh_token: Токен успешно обновлен для пользователя {user_id}")
return response
except Exception as e:
logger.error(f"[auth] refresh_token: Ошибка при обновлении токена: {e}")
return JSONResponse({"success": False, "error": str(e)}, status_code=401)

96
auth/authenticate.py Normal file
View File

@@ -0,0 +1,96 @@
from functools import wraps
from typing import Optional, Tuple
from graphql.type import GraphQLResolveInfo
from sqlalchemy.orm import exc, joinedload
from starlette.authentication import AuthenticationBackend
from starlette.requests import HTTPConnection
from auth.credentials import AuthCredentials, AuthUser
from auth.exceptions import OperationNotAllowed
from auth.tokenstorage import SessionToken
from auth.usermodel import Role, User
from services.db import local_session
from settings import SESSION_TOKEN_HEADER
class JWTAuthenticate(AuthenticationBackend):
async def authenticate(self, request: HTTPConnection) -> Optional[Tuple[AuthCredentials, AuthUser]]:
if SESSION_TOKEN_HEADER not in request.headers:
return AuthCredentials(scopes={}), AuthUser(user_id=None, username="")
token = request.headers.get(SESSION_TOKEN_HEADER)
if not token:
print("[auth.authenticate] no token in header %s" % SESSION_TOKEN_HEADER)
return AuthCredentials(scopes={}, error_message=str("no token")), AuthUser(user_id=None, username="")
if len(token.split(".")) > 1:
payload = await SessionToken.verify(token)
with local_session() as session:
try:
user = (
session.query(User)
.options(
joinedload(User.roles).options(joinedload(Role.permissions)),
joinedload(User.ratings),
)
.filter(User.id == payload.user_id)
.one()
)
scopes = {} # TODO: integrate await user.get_permission()
return (
AuthCredentials(user_id=payload.user_id, scopes=scopes, logged_in=True),
AuthUser(user_id=user.id, username=""),
)
except exc.NoResultFound:
pass
return AuthCredentials(scopes={}, error_message=str("Invalid token")), AuthUser(user_id=None, username="")
def login_required(func):
@wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
auth: AuthCredentials = info.context["request"].auth
if not auth or not auth.logged_in:
return {"error": "Please login first"}
return await func(parent, info, *args, **kwargs)
return wrap
def permission_required(resource, operation, func):
@wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
print("[auth.authenticate] permission_required for %r with info %r" % (func, info)) # debug only
auth: AuthCredentials = info.context["request"].auth
if not auth.logged_in:
raise OperationNotAllowed(auth.error_message or "Please login")
# TODO: add actual check permission logix here
return await func(parent, info, *args, **kwargs)
return wrap
def login_accepted(func):
@wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
auth: AuthCredentials = info.context["request"].auth
# Если есть авторизация, добавляем данные автора в контекст
if auth and auth.logged_in:
info.context["author"] = auth.author
info.context["user_id"] = auth.author.get("id")
else:
# Очищаем данные автора из контекста если авторизация отсутствует
info.context["author"] = None
info.context["user_id"] = None
return await func(parent, info, *args, **kwargs)
return wrap

View File

@@ -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

View File

@@ -1,95 +1,43 @@
from typing import Any
from typing import List, Optional, Text
from pydantic import BaseModel, Field
from pydantic import BaseModel
# from base.exceptions import UnauthorizedError
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
# from base.exceptions import Unauthorized
class Permission(BaseModel):
"""Модель разрешения для RBAC"""
resource: str
operation: str
def __str__(self) -> str:
return f"{self.resource}:{self.operation}"
name: Text
class AuthCredentials(BaseModel):
"""
Модель учетных данных авторизации.
Используется как часть механизма аутентификации Starlette.
"""
author_id: int | None = Field(None, description="ID автора")
scopes: dict[str, set[str]] = Field(default_factory=dict, description="Разрешения пользователя")
logged_in: bool = Field(default=False, description="Флаг, указывающий, авторизован ли пользователь")
error_message: str = Field("", description="Сообщение об ошибке аутентификации")
email: str | None = Field(None, description="Email пользователя")
token: str | None = Field(None, description="JWT токен авторизации")
def get_permissions(self) -> list[str]:
"""
Возвращает список строковых представлений разрешений.
Например: ["posts:read", "posts:write", "comments:create"].
Returns:
List[str]: Список разрешений
"""
result = []
for resource, operations in self.scopes.items():
for operation in operations:
result.extend([f"{resource}:{operation}"])
return result
def has_permission(self, resource: str, operation: str) -> bool:
"""
Проверяет наличие определенного разрешения.
Args:
resource: Ресурс (например, "posts")
operation: Операция (например, "read")
Returns:
bool: True, если пользователь имеет указанное разрешение
"""
if not self.logged_in:
return False
return resource in self.scopes and operation in self.scopes[resource]
user_id: Optional[int] = None
scopes: Optional[dict] = {}
logged_in: bool = False
error_message: str = ""
@property
def is_admin(self) -> bool:
"""
Проверяет, является ли пользователь администратором.
def is_admin(self):
# TODO: check admin logix
return True
Returns:
bool: True, если email пользователя находится в списке ADMIN_EMAILS
"""
return self.email in ADMIN_EMAILS if self.email else False
async def permissions(self) -> List[Permission]:
if self.user_id is None:
# raise Unauthorized("Please login first")
return {"error": "Please login first"}
else:
# TODO: implement permissions logix
print(self.user_id)
return NotImplemented
async def to_dict(self) -> dict[str, Any]:
"""
Преобразует учетные данные в словарь
Returns:
Dict[str, Any]: Словарь с данными учетных данных
"""
permissions = self.get_permissions()
return {
"author_id": self.author_id,
"logged_in": self.logged_in,
"is_admin": self.is_admin,
"permissions": list(permissions),
}
class AuthUser(BaseModel):
user_id: Optional[int]
username: Optional[str]
async def permissions(self) -> list[Permission]:
if self.author_id is None:
# raise UnauthorizedError("Please login first")
return [] # Возвращаем пустой список вместо dict
# TODO: implement permissions logix
print(self.author_id)
return [] # Возвращаем пустой список вместо NotImplemented
@property
def is_authenticated(self) -> bool:
return self.user_id is not None
# @property
# def display_id(self) -> int:
# return self.user_id

View File

@@ -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

View File

@@ -1,5 +1,3 @@
from typing import Any
import requests
from settings import MAILGUN_API_KEY, MAILGUN_DOMAIN
@@ -9,9 +7,9 @@ noreply = "discours.io <noreply@%s>" % (MAILGUN_DOMAIN or "discours.io")
lang_subject = {"ru": "Подтверждение почты", "en": "Confirm email"}
async def send_auth_email(user: Any, token: str, lang: str = "ru", template: str = "email_confirmation") -> None:
async def send_auth_email(user, token, lang="ru", template="email_confirmation"):
try:
to = f"{user.name} <{user.email}>"
to = "%s <%s>" % (user.name, user.email)
if lang not in ["ru", "en"]:
lang = "ru"
subject = lang_subject.get(lang, lang_subject["en"])
@@ -21,12 +19,12 @@ async def send_auth_email(user: Any, token: str, lang: str = "ru", template: str
"to": to,
"subject": subject,
"template": template,
"h:X-Mailgun-Variables": f'{{ "token": "{token}" }}',
"h:X-Mailgun-Variables": '{ "token": "%s" }' % token,
}
print(f"[auth.email] payload: {payload!r}")
print("[auth.email] payload: %r" % payload)
# debug
# print('http://localhost:3000/?modal=auth&mode=confirm-email&token=%s' % token)
response = requests.post(api_url, auth=("api", MAILGUN_API_KEY), data=payload, timeout=30)
response = requests.post(api_url, auth=("api", MAILGUN_API_KEY), data=payload)
response.raise_for_status()
except Exception as e:
print(e)

View File

@@ -3,43 +3,36 @@ from graphql.error import GraphQLError
# TODO: remove traceback from logs for defined exceptions
class BaseHttpError(GraphQLError):
class BaseHttpException(GraphQLError):
code = 500
message = "500 Server error"
class ExpiredTokenError(BaseHttpError):
class ExpiredToken(BaseHttpException):
code = 401
message = "401 Expired Token"
class InvalidTokenError(BaseHttpError):
class InvalidToken(BaseHttpException):
code = 401
message = "401 Invalid Token"
class UnauthorizedError(BaseHttpError):
class Unauthorized(BaseHttpException):
code = 401
message = "401 UnauthorizedError"
message = "401 Unauthorized"
class ObjectNotExistError(BaseHttpError):
class ObjectNotExist(BaseHttpException):
code = 404
message = "404 Object Does Not Exist"
class OperationNotAllowedError(BaseHttpError):
class OperationNotAllowed(BaseHttpException):
code = 403
message = "403 Operation Is Not Allowed"
class InvalidPasswordError(BaseHttpError):
class InvalidPassword(BaseHttpException):
code = 403
message = "403 Invalid Password"
class AuthorizationError(BaseHttpError):
"""Ошибка авторизации - не должна показывать трейсбек в логах"""
code = 401
message = "401 Authorization Required"

View File

@@ -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

View File

@@ -1,114 +1,97 @@
from typing import Any, TypeVar
from binascii import hexlify
from hashlib import sha256
from auth.exceptions import ExpiredTokenError, InvalidPasswordError, InvalidTokenError
from passlib.hash import bcrypt
from auth.exceptions import ExpiredToken, InvalidToken
from auth.jwtcodec import JWTCodec
from orm.author import Author
from storage.db import local_session
from storage.redis import redis
from utils.logger import root_logger as logger
from utils.password import Password
from auth.tokenstorage import TokenStorage
from orm.user import User
AuthorType = TypeVar("AuthorType", bound=Author)
# from base.exceptions import InvalidPassword, InvalidToken
from services.db import local_session
class Password:
@staticmethod
def _to_bytes(data: str) -> bytes:
return bytes(data.encode())
@classmethod
def _get_sha256(cls, password: str) -> bytes:
bytes_password = cls._to_bytes(password)
return hexlify(sha256(bytes_password).digest())
@staticmethod
def encode(password: str) -> str:
password_sha256 = Password._get_sha256(password)
return bcrypt.using(rounds=10).hash(password_sha256)
@staticmethod
def verify(password: str, hashed: str) -> bool:
"""
Verify that password hash is equal to specified hash. Hash format:
$2a$10$Ro0CUfOqk6cXEKf3dyaM7OhSCvnwM9s4wIX9JeLapehKK5YdLxKcm
\__/\/ \____________________/\_____________________________/ # noqa: W605
| | Salt Hash
| Cost
Version
More info: https://passlib.readthedocs.io/en/stable/lib/passlib.hash.bcrypt.html
:param password: clear text password
:param hashed: hash of the password
:return: True if clear text password matches specified hash
"""
hashed_bytes = Password._to_bytes(hashed)
password_sha256 = Password._get_sha256(password)
return bcrypt.verify(password_sha256, hashed_bytes)
class Identity:
@staticmethod
def password(orm_author: AuthorType, password: str) -> AuthorType:
"""
Проверяет пароль пользователя
Args:
orm_author (Author): Объект пользователя
password (str): Пароль пользователя
Returns:
Author: Объект автора при успешной проверке
Raises:
InvalidPasswordError: Если пароль не соответствует хешу или отсутствует
"""
# Проверим исходный пароль в orm_author
if not orm_author.password:
logger.warning(f"[auth.identity] Пароль в исходном объекте автора пуст: email={orm_author.email}")
msg = "Пароль не установлен для данного пользователя"
raise InvalidPasswordError(msg)
# Проверяем пароль напрямую, не используя dict()
password_hash = str(orm_author.password) if orm_author.password else ""
if not password_hash or not Password.verify(password, password_hash):
logger.warning(f"[auth.identity] Неверный пароль для {orm_author.email}")
msg = "Неверный пароль пользователя"
raise InvalidPasswordError(msg)
# Возвращаем исходный объект, чтобы сохранить все связи
return orm_author
def password(orm_user: User, password: str) -> User:
user = User(**orm_user.dict())
if not user.password:
# raise InvalidPassword("User password is empty")
return {"error": "User password is empty"}
if not Password.verify(password, user.password):
# raise InvalidPassword("Wrong user password")
return {"error": "Wrong user password"}
return user
@staticmethod
def oauth(inp: dict[str, Any]) -> Any:
"""
Создает нового пользователя OAuth, если он не существует
Args:
inp (dict): Данные OAuth пользователя
Returns:
Author: Объект пользователя
"""
# Author уже импортирован в начале файла
def oauth(inp) -> User:
with local_session() as session:
author = session.query(Author).where(Author.email == inp["email"]).first()
if not author:
author = Author(**inp)
author.email_verified = True # type: ignore[assignment]
session.add(author)
user = session.query(User).filter(User.email == inp["email"]).first()
if not user:
user = User.create(**inp, emailConfirmed=True)
session.commit()
return author
return user
@staticmethod
async def onetime(token: str) -> Any:
"""
Проверяет одноразовый токен
Args:
token (str): Одноразовый токен
Returns:
Author: Объект пользователя
"""
async def onetime(token: str) -> User:
try:
print("[auth.identity] using one time token")
payload = JWTCodec.decode(token)
if payload is None:
logger.warning("[Identity.token] Токен не валиден (payload is None)")
return {"error": "Invalid token"}
# Проверяем существование токена в хранилище
user_id = payload.get("user_id")
username = payload.get("username")
if not user_id or not username:
logger.warning("[Identity.token] Нет user_id или username в токене")
return {"error": "Invalid token"}
token_key = f"{user_id}-{username}-{token}"
if not await redis.exists(token_key):
logger.warning(f"[Identity.token] Токен не найден в хранилище: {token_key}")
return {"error": "Token not found"}
# Если все проверки пройдены, ищем автора в базе данных
# Author уже импортирован в начале файла
with local_session() as session:
author = session.query(Author).filter_by(id=user_id).first()
if not author:
logger.warning(f"[Identity.token] Автор с ID {user_id} не найден")
return {"error": "User not found"}
logger.info(f"[Identity.token] Токен валиден для автора {author.id}")
return author
except ExpiredTokenError:
# raise InvalidTokenError("Login token has expired, please try again")
if not await TokenStorage.exist(f"{payload.user_id}-{payload.username}-{token}"):
# raise InvalidToken("Login token has expired, please login again")
return {"error": "Token has expired"}
except ExpiredToken:
# raise InvalidToken("Login token has expired, please try again")
return {"error": "Token has expired"}
except InvalidTokenError:
# raise InvalidTokenError("token format error") from e
except InvalidToken:
# raise InvalidToken("token format error") from e
return {"error": "Token format error"}
with local_session() as session:
user = session.query(User).filter_by(id=payload.user_id).first()
if not user:
# raise Exception("user not exist")
return {"error": "User does not exist"}
if not user.emailConfirmed:
user.emailConfirmed = True
session.commit()
return user

View File

@@ -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"]

View File

@@ -1,93 +1,60 @@
import datetime
import logging
from typing import Any, Dict
from datetime import datetime, timezone
import jwt
from pydantic import BaseModel
from settings import JWT_ALGORITHM, JWT_ISSUER, JWT_REFRESH_TOKEN_EXPIRE_DAYS, JWT_SECRET_KEY
from auth.exceptions import ExpiredToken, InvalidToken
from settings import JWT_ALGORITHM, JWT_SECRET_KEY
class TokenPayload(BaseModel):
user_id: str
username: str
exp: datetime
iat: datetime
iss: str
class JWTCodec:
"""
Кодировщик и декодировщик JWT токенов.
"""
@staticmethod
def encode(
payload: Dict[str, Any],
secret_key: str | None = None,
algorithm: str | None = None,
expiration: datetime.datetime | None = None,
) -> str | bytes:
"""
Кодирует payload в JWT токен.
Args:
payload (Dict[str, Any]): Полезная нагрузка для кодирования
secret_key (Optional[str]): Секретный ключ. По умолчанию используется JWT_SECRET_KEY
algorithm (Optional[str]): Алгоритм шифрования. По умолчанию используется JWT_ALGORITHM
expiration (Optional[datetime.datetime]): Время истечения токена
Returns:
str: Закодированный JWT токен
"""
logger = logging.getLogger("root")
logger.debug(f"[JWTCodec.encode] Кодирование токена для payload: {payload}")
# Используем переданные или дефолтные значения
secret_key = secret_key or JWT_SECRET_KEY
algorithm = algorithm or JWT_ALGORITHM
# Если время истечения не указано, устанавливаем дефолтное
if not expiration:
expiration = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=JWT_REFRESH_TOKEN_EXPIRE_DAYS)
logger.debug(f"[JWTCodec.encode] Время истечения не указано, устанавливаем срок: {expiration}")
# Формируем payload с временными метками
payload.update(
{"exp": int(expiration.timestamp()), "iat": datetime.datetime.now(datetime.UTC), "iss": JWT_ISSUER}
)
logger.debug(f"[JWTCodec.encode] Сформирован payload: {payload}")
def encode(user, exp: datetime) -> str:
payload = {
"user_id": user.id,
"username": user.email or user.phone,
"exp": exp,
"iat": datetime.now(tz=timezone.utc),
"iss": "discours",
}
try:
# Используем PyJWT для кодирования
encoded = jwt.encode(payload, secret_key, algorithm=algorithm)
return encoded.decode("utf-8") if isinstance(encoded, bytes) else encoded
return jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM)
except Exception as e:
logger.warning(f"[JWTCodec.encode] Ошибка при кодировании JWT: {e}")
raise
print("[auth.jwtcodec] JWT encode error %r" % e)
@staticmethod
def decode(
token: str,
secret_key: str | None = None,
algorithms: list | None = None,
) -> Dict[str, Any]:
"""
Декодирует JWT токен.
Args:
token (str): JWT токен
secret_key (Optional[str]): Секретный ключ. По умолчанию используется JWT_SECRET_KEY
algorithms (Optional[list]): Список алгоритмов. По умолчанию используется [JWT_ALGORITHM]
Returns:
Dict[str, Any]: Декодированный payload
"""
logger = logging.getLogger("root")
logger.debug("[JWTCodec.decode] Декодирование токена")
# Используем переданные или дефолтные значения
secret_key = secret_key or JWT_SECRET_KEY
algorithms = algorithms or [JWT_ALGORITHM]
def decode(token: str, verify_exp: bool = True):
r = None
payload = None
try:
# Используем PyJWT для декодирования
return jwt.decode(token, secret_key, algorithms=algorithms)
payload = jwt.decode(
token,
key=JWT_SECRET_KEY,
options={
"verify_exp": verify_exp,
# "verify_signature": False
},
algorithms=[JWT_ALGORITHM],
issuer="discours",
)
r = TokenPayload(**payload)
# print('[auth.jwtcodec] debug token %r' % r)
return r
except jwt.InvalidIssuedAtError:
print("[auth.jwtcodec] invalid issued at: %r" % payload)
raise ExpiredToken("check token issued time")
except jwt.ExpiredSignatureError:
logger.warning("[JWTCodec.decode] Токен просрочен")
raise
except jwt.InvalidTokenError as e:
logger.warning(f"[JWTCodec.decode] Ошибка при декодировании JWT: {e}")
raise
print("[auth.jwtcodec] expired signature %r" % payload)
raise ExpiredToken("check token lifetime")
except jwt.InvalidTokenError:
raise InvalidToken("token is not valid")
except jwt.InvalidSignatureError:
raise InvalidToken("token is not valid")

View File

@@ -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)

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View File

@@ -1,300 +0,0 @@
"""
🔒 OAuth Security Enhancements - Критические исправления безопасности
Исправляет найденные уязвимости в OAuth реализации.
"""
import re
import time
from typing import Dict, List
from urllib.parse import urlparse
from utils.logger import root_logger as logger
def _send_security_alert_to_glitchtip(event_type: str, details: Dict) -> None:
"""
🚨 Отправка алертов безопасности в GlitchTip
Args:
event_type: Тип события безопасности
details: Детали события
"""
try:
import sentry_sdk
# Определяем уровень критичности
critical_events = [
"open_redirect_attempt",
"rate_limit_exceeded",
"invalid_provider",
"suspicious_redirect_uri",
"brute_force_detected",
]
# Создаем контекст для GlitchTip
with sentry_sdk.configure_scope() as scope:
scope.set_tag("security_event", event_type)
scope.set_tag("component", "oauth")
scope.set_context("security_details", details)
# Добавляем дополнительные теги для фильтрации
if "ip" in details:
scope.set_tag("client_ip", details["ip"])
if "provider" in details:
scope.set_tag("oauth_provider", details["provider"])
if "redirect_uri" in details:
scope.set_tag("has_redirect_uri", "true")
# Отправляем в зависимости от критичности
if event_type in critical_events:
# Критичные события как ERROR
sentry_sdk.capture_message(f"🚨 CRITICAL OAuth Security Event: {event_type}", level="error")
logger.error(f"🚨 CRITICAL security alert sent to GlitchTip: {event_type}")
else:
# Обычные события как WARNING
sentry_sdk.capture_message(f"⚠️ OAuth Security Event: {event_type}", level="warning")
logger.info(f"⚠️ Security alert sent to GlitchTip: {event_type}")
except Exception as e:
# Не ломаем основную логику если GlitchTip недоступен
logger.error(f"❌ Failed to send security alert to GlitchTip: {e}")
def send_rate_limit_alert(client_ip: str, attempts: int) -> None:
"""
🚨 Специальный алерт для превышения rate limit
Args:
client_ip: IP адрес нарушителя
attempts: Количество попыток
"""
log_oauth_security_event(
"rate_limit_exceeded",
{
"ip": client_ip,
"attempts": attempts,
"limit": OAUTH_RATE_LIMIT,
"window_seconds": OAUTH_RATE_WINDOW,
"severity": "high",
},
)
def send_open_redirect_alert(malicious_uri: str, client_ip: str = "") -> None:
"""
🚨 Специальный алерт для попытки open redirect атаки
Args:
malicious_uri: Подозрительный URI
client_ip: IP адрес атакующего
"""
log_oauth_security_event(
"open_redirect_attempt",
{"malicious_uri": malicious_uri, "ip": client_ip, "severity": "critical", "attack_type": "open_redirect"},
)
# 🔒 Whitelist разрешенных redirect URI
ALLOWED_REDIRECT_DOMAINS = [
"testing.discours.io",
"new.discours.io",
"discours.io",
"localhost", # Только для разработки
]
# 🔒 Rate limiting для OAuth endpoints
oauth_rate_limits: Dict[str, List[float]] = {}
OAUTH_RATE_LIMIT = 10 # Максимум 10 попыток
OAUTH_RATE_WINDOW = 300 # За 5 минут
def validate_redirect_uri(redirect_uri: str) -> bool:
"""
🔒 Строгая валидация redirect URI против open redirect атак
Args:
redirect_uri: URI для валидации
Returns:
bool: True если URI безопасен
"""
if not redirect_uri:
return False
try:
parsed = urlparse(redirect_uri)
# 1. Проверяем схему (только HTTPS в продакшене)
if parsed.scheme not in ["https", "http"]: # http только для localhost
logger.warning(f"🚨 Invalid scheme in redirect_uri: {parsed.scheme}")
return False
# 2. Проверяем домен против whitelist
hostname = parsed.hostname
if not hostname:
logger.warning(f"🚨 No hostname in redirect_uri: {redirect_uri}")
return False
# 3. Проверяем против разрешенных доменов
is_allowed = False
for allowed_domain in ALLOWED_REDIRECT_DOMAINS:
if hostname == allowed_domain or hostname.endswith(f".{allowed_domain}"):
is_allowed = True
break
if not is_allowed:
logger.warning(f"🚨 Unauthorized domain in redirect_uri: {hostname}")
# 🚨 Отправляем алерт о попытке open redirect атаки
send_open_redirect_alert(redirect_uri)
return False
# 4. Дополнительные проверки безопасности
if len(redirect_uri) > 2048: # Слишком длинный URL
logger.warning(f"🚨 Redirect URI too long: {len(redirect_uri)}")
return False
# 5. Проверяем на подозрительные паттерны
suspicious_patterns = [
r"javascript:",
r"data:",
r"vbscript:",
r"file:",
r"ftp:",
]
for pattern in suspicious_patterns:
if re.search(pattern, redirect_uri, re.IGNORECASE):
logger.warning(f"🚨 Suspicious pattern in redirect_uri: {pattern}")
return False
return True
except Exception as e:
logger.error(f"🚨 Error validating redirect_uri: {e}")
return False
def check_oauth_rate_limit(client_ip: str) -> bool:
"""
🔒 Rate limiting для OAuth endpoints
Args:
client_ip: IP адрес клиента
Returns:
bool: True если запрос разрешен
"""
current_time = time.time()
# Получаем историю запросов для IP
if client_ip not in oauth_rate_limits:
oauth_rate_limits[client_ip] = []
requests = oauth_rate_limits[client_ip]
# Удаляем старые запросы
requests[:] = [req_time for req_time in requests if current_time - req_time < OAUTH_RATE_WINDOW]
# Проверяем лимит
if len(requests) >= OAUTH_RATE_LIMIT:
logger.warning(f"🚨 OAuth rate limit exceeded for IP: {client_ip}")
# 🚨 Отправляем алерт о превышении rate limit
send_rate_limit_alert(client_ip, len(requests))
return False
# Добавляем текущий запрос
requests.append(current_time)
return True
def get_safe_redirect_uri(request, fallback: str = "https://testing.discours.io") -> str:
"""
🔒 Безопасное получение redirect_uri с валидацией
Args:
request: HTTP запрос
fallback: Безопасный fallback URI
Returns:
str: Валидный redirect URI
"""
# Приоритет источников (БЕЗ Referer header!)
candidates = [
request.query_params.get("redirect_uri"),
request.path_params.get("redirect_uri"),
fallback, # Безопасный fallback
]
for candidate in candidates:
if candidate and validate_redirect_uri(candidate):
logger.info(f"✅ Valid redirect_uri: {candidate}")
return candidate
# Если ничего не подошло - используем безопасный fallback
logger.warning(f"🚨 No valid redirect_uri found, using fallback: {fallback}")
return fallback
def log_oauth_security_event(event_type: str, details: Dict) -> None:
"""
🔒 Логирование событий безопасности OAuth
Args:
event_type: Тип события
details: Детали события
"""
logger.warning(f"🚨 OAuth Security Event: {event_type}")
logger.warning(f" Details: {details}")
# 🚨 Отправляем критические события в GlitchTip
_send_security_alert_to_glitchtip(event_type, details)
def validate_oauth_provider(provider: str, log_security_events: bool = True) -> bool:
"""
🔒 Валидация OAuth провайдера
Args:
provider: Название провайдера
log_security_events: Логировать события безопасности
Returns:
bool: True если провайдер валидный
"""
# Импортируем здесь чтобы избежать циклических импортов
from auth.oauth import PROVIDER_CONFIGS
if not provider:
return False
if provider not in PROVIDER_CONFIGS:
if log_security_events:
log_oauth_security_event(
"invalid_provider", {"provider": provider, "available": list(PROVIDER_CONFIGS.keys())}
)
return False
return True
def sanitize_oauth_logs(data: Dict) -> Dict:
"""
🔒 Очистка логов от чувствительной информации
Args:
data: Данные для логирования
Returns:
Dict: Очищенные данные
"""
sensitive_keys = ["state", "code", "access_token", "refresh_token", "client_secret"]
sanitized = {}
for key, value in data.items():
if key.lower() in sensitive_keys:
sanitized[key] = f"***{str(value)[-4:]}" if value else None
else:
sanitized[key] = value
return sanitized

215
auth/resolvers.py Normal file
View File

@@ -0,0 +1,215 @@
# -*- coding: utf-8 -*-
import re
from datetime import datetime, timezone
from urllib.parse import quote_plus
from graphql.type import GraphQLResolveInfo
from auth.authenticate import login_required
from auth.credentials import AuthCredentials
from auth.email import send_auth_email
from auth.exceptions import InvalidPassword, InvalidToken, ObjectNotExist, Unauthorized
from auth.identity import Identity, Password
from auth.jwtcodec import JWTCodec
from auth.tokenstorage import TokenStorage
from orm import Role, User
from services.db import local_session
from services.schema import mutation, query
from settings import SESSION_TOKEN_HEADER
@mutation.field("getSession")
@login_required
async def get_current_user(_, info):
auth: AuthCredentials = info.context["request"].auth
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER)
with local_session() as session:
user = session.query(User).where(User.id == auth.user_id).one()
user.lastSeen = datetime.now(tz=timezone.utc)
session.commit()
return {"token": token, "user": user}
@mutation.field("confirmEmail")
async def confirm_email(_, info, token):
"""confirm owning email address"""
try:
print("[resolvers.auth] confirm email by token")
payload = JWTCodec.decode(token)
user_id = payload.user_id
await TokenStorage.get(f"{user_id}-{payload.username}-{token}")
with local_session() as session:
user = session.query(User).where(User.id == user_id).first()
session_token = await TokenStorage.create_session(user)
user.emailConfirmed = True
user.lastSeen = datetime.now(tz=timezone.utc)
session.add(user)
session.commit()
return {"token": session_token, "user": user}
except InvalidToken as e:
raise InvalidToken(e.message)
except Exception as e:
print(e) # FIXME: debug only
return {"error": "email is not confirmed"}
def create_user(user_dict):
user = User(**user_dict)
with local_session() as session:
user.roles.append(session.query(Role).first())
session.add(user)
session.commit()
return user
def replace_translit(src):
ruchars = "абвгдеёжзийклмнопрстуфхцчшщъыьэюя."
enchars = [
"a",
"b",
"v",
"g",
"d",
"e",
"yo",
"zh",
"z",
"i",
"y",
"k",
"l",
"m",
"n",
"o",
"p",
"r",
"s",
"t",
"u",
"f",
"h",
"c",
"ch",
"sh",
"sch",
"",
"y",
"'",
"e",
"yu",
"ya",
"-",
]
return src.translate(str.maketrans(ruchars, enchars))
def generate_unique_slug(src):
print("[resolvers.auth] generating slug from: " + src)
slug = replace_translit(src.lower())
slug = re.sub("[^0-9a-zA-Z]+", "-", slug)
if slug != src:
print("[resolvers.auth] translited name: " + slug)
c = 1
with local_session() as session:
user = session.query(User).where(User.slug == slug).first()
while user:
user = session.query(User).where(User.slug == slug).first()
slug = slug + "-" + str(c)
c += 1
if not user:
unique_slug = slug
print("[resolvers.auth] " + unique_slug)
return quote_plus(unique_slug.replace("'", "")).replace("+", "-")
@mutation.field("registerUser")
async def register_by_email(_, _info, email: str, password: str = "", name: str = ""):
email = email.lower()
"""creates new user account"""
with local_session() as session:
user = session.query(User).filter(User.email == email).first()
if user:
raise Unauthorized("User already exist")
else:
slug = generate_unique_slug(name)
user = session.query(User).where(User.slug == slug).first()
if user:
slug = generate_unique_slug(email.split("@")[0])
user_dict = {
"email": email,
"username": email, # will be used to store phone number or some messenger network id
"name": name,
"slug": slug,
}
if password:
user_dict["password"] = Password.encode(password)
user = create_user(user_dict)
user = await auth_send_link(_, _info, email)
return {"user": user}
@mutation.field("sendLink")
async def auth_send_link(_, _info, email, lang="ru", template="email_confirmation"):
email = email.lower()
"""send link with confirm code to email"""
with local_session() as session:
user = session.query(User).filter(User.email == email).first()
if not user:
raise ObjectNotExist("User not found")
else:
token = await TokenStorage.create_onetime(user)
await send_auth_email(user, token, lang, template)
return user
@query.field("signIn")
async def login(_, info, email: str, password: str = "", lang: str = "ru"):
email = email.lower()
with local_session() as session:
orm_user = session.query(User).filter(User.email == email).first()
if orm_user is None:
print(f"[auth] {email}: email not found")
# return {"error": "email not found"}
raise ObjectNotExist("User not found") # contains webserver status
if not password:
print(f"[auth] send confirm link to {email}")
token = await TokenStorage.create_onetime(orm_user)
await send_auth_email(orm_user, token, lang)
# FIXME: not an error, warning
return {"error": "no password, email link was sent"}
else:
# sign in using password
if not orm_user.emailConfirmed:
# not an error, warns users
return {"error": "please, confirm email"}
else:
try:
user = Identity.password(orm_user, password)
session_token = await TokenStorage.create_session(user)
print(f"[auth] user {email} authorized")
return {"token": session_token, "user": user}
except InvalidPassword:
print(f"[auth] {email}: invalid password")
raise InvalidPassword("invalid password") # contains webserver status
# return {"error": "invalid password"}
@query.field("signOut")
@login_required
async def sign_out(_, info: GraphQLResolveInfo):
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER, "")
status = await TokenStorage.revoke(token)
return status
@query.field("isEmailUsed")
async def is_email_used(_, _info, email):
email = email.lower()
with local_session() as session:
user = session.query(User).filter(User.email == email).first()
return user is not None

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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]

View File

@@ -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
View File

@@ -0,0 +1,73 @@
from datetime import datetime, timedelta, timezone
from auth.jwtcodec import JWTCodec
from auth.validations import AuthInput
from services.redis import redis
from settings import ONETIME_TOKEN_LIFE_SPAN, SESSION_TOKEN_LIFE_SPAN
async def save(token_key, life_span, auto_delete=True):
await redis.execute("SET", token_key, "True")
if auto_delete:
expire_at = (datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)).timestamp()
await redis.execute("EXPIREAT", token_key, int(expire_at))
class SessionToken:
@classmethod
async def verify(cls, token: str):
"""
Rules for a token to be valid.
- token format is legal
- token exists in redis database
- token is not expired
"""
try:
return JWTCodec.decode(token)
except Exception as e:
raise e
@classmethod
async def get(cls, payload, token):
return await TokenStorage.get(f"{payload.user_id}-{payload.username}-{token}")
class TokenStorage:
@staticmethod
async def get(token_key):
print("[tokenstorage.get] " + token_key)
# 2041-user@domain.zn-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyMDQxLCJ1c2VybmFtZSI6ImFudG9uLnJld2luK3Rlc3QtbG9hZGNoYXRAZ21haWwuY29tIiwiZXhwIjoxNjcxNzgwNjE2LCJpYXQiOjE2NjkxODg2MTYsImlzcyI6ImRpc2NvdXJzIn0.Nml4oV6iMjMmc6xwM7lTKEZJKBXvJFEIZ-Up1C1rITQ
return await redis.execute("GET", token_key)
@staticmethod
async def create_onetime(user: AuthInput) -> str:
life_span = ONETIME_TOKEN_LIFE_SPAN
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
one_time_token = JWTCodec.encode(user, exp)
await save(f"{user.id}-{user.username}-{one_time_token}", life_span)
return one_time_token
@staticmethod
async def create_session(user: AuthInput) -> str:
life_span = SESSION_TOKEN_LIFE_SPAN
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
session_token = JWTCodec.encode(user, exp)
await save(f"{user.id}-{user.username}-{session_token}", life_span)
return session_token
@staticmethod
async def revoke(token: str) -> bool:
payload = None
try:
print("[auth.tokenstorage] revoke token")
payload = JWTCodec.decode(token)
except: # noqa
pass
else:
await redis.execute("DEL", f"{payload.user_id}-{payload.username}-{token}")
return True
@staticmethod
async def revoke_all(user: AuthInput):
tokens = await redis.execute("KEYS", f"{user.id}-*")
await redis.execute("DEL", *tokens)

119
auth/usermodel.py Normal file
View File

@@ -0,0 +1,119 @@
import time
from sqlalchemy import (
JSON,
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
String,
func,
)
from sqlalchemy.orm import relationship
from services.db import Base
class Permission(Base):
__tablename__ = "permission"
id = Column(String, primary_key=True, unique=True, nullable=False, default=None)
resource = Column(String, nullable=False)
operation = Column(String, nullable=False)
class Role(Base):
__tablename__ = "role"
id = Column(String, primary_key=True, unique=True, nullable=False, default=None)
name = Column(String, nullable=False)
permissions = relationship(Permission)
class AuthorizerUser(Base):
__tablename__ = "authorizer_users"
id = Column(String, primary_key=True, unique=True, nullable=False, default=None)
key = Column(String)
email = Column(String, unique=True)
email_verified_at = Column(Integer)
family_name = Column(String)
gender = Column(String)
given_name = Column(String)
is_multi_factor_auth_enabled = Column(Boolean)
middle_name = Column(String)
nickname = Column(String)
password = Column(String)
phone_number = Column(String, unique=True)
phone_number_verified_at = Column(Integer)
# preferred_username = Column(String, nullable=False)
picture = Column(String)
revoked_timestamp = Column(Integer)
roles = Column(String, default="author,reader")
signup_methods = Column(String, default="magic_link_login")
created_at = Column(Integer, default=lambda: int(time.time()))
updated_at = Column(Integer, default=lambda: int(time.time()))
class UserRating(Base):
__tablename__ = "user_rating"
id = None
rater: Column = Column(ForeignKey("user.id"), primary_key=True, index=True)
user: Column = Column(ForeignKey("user.id"), primary_key=True, index=True)
value: Column = Column(Integer)
@staticmethod
def init_table():
pass
class UserRole(Base):
__tablename__ = "user_role"
id = None
user = Column(ForeignKey("user.id"), primary_key=True, index=True)
role = Column(ForeignKey("role.id"), primary_key=True, index=True)
class User(Base):
__tablename__ = "user"
default_user = None
email = Column(String, unique=True, nullable=False, comment="Email")
username = Column(String, nullable=False, comment="Login")
password = Column(String, nullable=True, comment="Password")
bio = Column(String, nullable=True, comment="Bio") # status description
about = Column(String, nullable=True, comment="About") # long and formatted
userpic = Column(String, nullable=True, comment="Userpic")
name = Column(String, nullable=True, comment="Display name")
slug = Column(String, unique=True, comment="User's slug")
links = Column(JSON, nullable=True, comment="Links")
oauth = Column(String, nullable=True)
oid = Column(String, nullable=True)
muted = Column(Boolean, default=False)
confirmed = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), comment="Created at")
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), comment="Updated at")
last_seen = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), comment="Was online at")
deleted_at = Column(DateTime(timezone=True), nullable=True, comment="Deleted at")
ratings = relationship(UserRating, foreign_keys=UserRating.user)
roles = relationship(lambda: Role, secondary=UserRole.__tablename__)
def get_permission(self):
scope = {}
for role in self.roles:
for p in role.permissions:
if p.resource not in scope:
scope[p.resource] = set()
scope[p.resource].add(p.operation)
print(scope)
return scope
# if __name__ == "__main__":
# print(User.get_permission(user_id=1))

View File

@@ -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}"

View File

@@ -1,5 +1,6 @@
import re
from datetime import datetime
from typing import Dict, List, Optional, Union
from pydantic import BaseModel, Field, field_validator
@@ -18,8 +19,7 @@ class AuthInput(BaseModel):
@classmethod
def validate_user_id(cls, v: str) -> str:
if not v.strip():
msg = "user_id cannot be empty"
raise ValueError(msg)
raise ValueError("user_id cannot be empty")
return v
@@ -35,8 +35,7 @@ class UserRegistrationInput(BaseModel):
def validate_email(cls, v: str) -> str:
"""Validate email format"""
if not re.match(EMAIL_PATTERN, v):
msg = "Invalid email format"
raise ValueError(msg)
raise ValueError("Invalid email format")
return v.lower()
@field_validator("password")
@@ -44,17 +43,13 @@ class UserRegistrationInput(BaseModel):
def validate_password_strength(cls, v: str) -> str:
"""Validate password meets security requirements"""
if not any(c.isupper() for c in v):
msg = "Password must contain at least one uppercase letter"
raise ValueError(msg)
raise ValueError("Password must contain at least one uppercase letter")
if not any(c.islower() for c in v):
msg = "Password must contain at least one lowercase letter"
raise ValueError(msg)
raise ValueError("Password must contain at least one lowercase letter")
if not any(c.isdigit() for c in v):
msg = "Password must contain at least one number"
raise ValueError(msg)
raise ValueError("Password must contain at least one number")
if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in v):
msg = "Password must contain at least one special character"
raise ValueError(msg)
raise ValueError("Password must contain at least one special character")
return v
@@ -68,8 +63,7 @@ class UserLoginInput(BaseModel):
@classmethod
def validate_email(cls, v: str) -> str:
if not re.match(EMAIL_PATTERN, v):
msg = "Invalid email format"
raise ValueError(msg)
raise ValueError("Invalid email format")
return v.lower()
@@ -80,7 +74,7 @@ class TokenPayload(BaseModel):
username: str
exp: datetime
iat: datetime
scopes: list[str] | None = []
scopes: Optional[List[str]] = []
class OAuthInput(BaseModel):
@@ -88,15 +82,14 @@ class OAuthInput(BaseModel):
provider: str = Field(pattern="^(google|github|facebook)$")
code: str
redirect_uri: str | None = None
redirect_uri: Optional[str] = None
@field_validator("provider")
@classmethod
def validate_provider(cls, v: str) -> str:
valid_providers = ["google", "github", "facebook"]
if v.lower() not in valid_providers:
msg = f"Provider must be one of: {', '.join(valid_providers)}"
raise ValueError(msg)
raise ValueError(f"Provider must be one of: {', '.join(valid_providers)}")
return v.lower()
@@ -104,22 +97,20 @@ class AuthResponse(BaseModel):
"""Validation model for authentication responses"""
success: bool
token: str | None = None
error: str | None = None
user: dict[str, str | int | bool] | None = None
token: Optional[str] = None
error: Optional[str] = None
user: Optional[Dict[str, Union[str, int, bool]]] = None
@field_validator("error")
@classmethod
def validate_error_if_not_success(cls, v: str | None, info) -> str | None:
def validate_error_if_not_success(cls, v: Optional[str], info) -> Optional[str]:
if not info.data.get("success") and not v:
msg = "Error message required when success is False"
raise ValueError(msg)
raise ValueError("Error message required when success is False")
return v
@field_validator("token")
@classmethod
def validate_token_if_success(cls, v: str | None, info) -> str | None:
def validate_token_if_success(cls, v: Optional[str], info) -> Optional[str]:
if info.data.get("success") and not v:
msg = "Token required when success is True"
raise ValueError(msg)
raise ValueError("Token required when success is True")
return v

View File

@@ -1,109 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
"files": {
"includes": [
"**/*.tsx",
"**/*.ts",
"**/*.js",
"**/*.json",
"!dist",
"!node_modules",
"!**/.husky",
"!**/docs",
"!**/gen",
"!**/*.gen.ts",
"!**/*.d.ts"
]
},
"vcs": {
"enabled": true,
"defaultBranch": "dev",
"useIgnoreFile": true,
"clientKind": "git"
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 108,
"includes": ["**", "!panel/graphql/generated"]
},
"javascript": {
"formatter": {
"enabled": true,
"semicolons": "asNeeded",
"quoteStyle": "single",
"jsxQuoteStyle": "double",
"arrowParentheses": "always",
"trailingCommas": "none"
}
},
"linter": {
"enabled": true,
"includes": ["**", "!**/*.scss", "!**/*.md", "!**/.DS_Store", "!**/*.svg", "!**/*.d.ts"],
"rules": {
"complexity": {
"noForEach": "off",
"noUselessFragments": "off",
"useOptionalChain": "warn",
"useLiteralKeys": "off",
"noExcessiveCognitiveComplexity": "off",
"useSimplifiedLogicExpression": "off"
},
"correctness": {
"useHookAtTopLevel": "off",
"useImportExtensions": "off",
"noUndeclaredDependencies": "off"
},
"a11y": {
"useHeadingContent": "off",
"useKeyWithClickEvents": "off",
"useKeyWithMouseEvents": "off",
"useAnchorContent": "off",
"useValidAnchor": "off",
"useMediaCaption": "off",
"useAltText": "off",
"useButtonType": "off",
"noRedundantAlt": "off",
"noStaticElementInteractions": "off",
"noSvgWithoutTitle": "off",
"noLabelWithoutControl": "off"
},
"performance": {
"noBarrelFile": "off",
"noNamespaceImport": "warn"
},
"style": {
"noNonNullAssertion": "off",
"noUselessElse": "off",
"useBlockStatements": "off",
"noImplicitBoolean": "off",
"useNamingConvention": "off",
"useImportType": "off",
"noDefaultExport": "off",
"useFilenamingConvention": "off",
"useExplicitLengthCheck": "off",
"noParameterAssign": "error",
"useAsConstAssertion": "error",
"useDefaultParameterLast": "error",
"useEnumInitializers": "error",
"useSelfClosingElements": "error",
"useSingleVarDeclarator": "error",
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error"
},
"suspicious": {
"noConsole": "off",
"noAssignInExpressions": "off",
"useAwait": "off",
"noEmptyBlockStatements": "off"
},
"nursery": {
"noFloatingPromises": "warn",
"noImportCycles": "warn"
}
}
}
}

0
cache/__init__.py vendored
View File

571
cache/cache.py vendored
View File

@@ -5,22 +5,22 @@ Caching system for the Discours platform
This module provides a comprehensive caching solution with these key components:
1. KEY NAMING CONVENTIONS:
- Entity-based keys: "entity:property:value" (e.g., "author:id:123")
- Collection keys: "entity:collection:params" (e.g., "authors:stats:limit=10:offset=0")
- Special case keys: Maintained for backwards compatibility (e.g., "topic_shouts_123")
- Entity-based keys: "entity:property:value" (e.g., "author:id:123")
- Collection keys: "entity:collection:params" (e.g., "authors:stats:limit=10:offset=0")
- Special case keys: Maintained for backwards compatibility (e.g., "topic_shouts_123")
2. CORE FUNCTIONS:
ery(): High-level function for retrieving cached data or executing queries
- cached_query(): High-level function for retrieving cached data or executing queries
3. ENTITY-SPECIFIC FUNCTIONS:
- cache_author(), cache_topic(): Cache entity data
- get_cached_author(), get_cached_topic(): Retrieve entity data from cache
- invalidate_cache_by_prefix(): Invalidate all keys with a specific prefix
- cache_author(), cache_topic(): Cache entity data
- get_cached_author(), get_cached_topic(): Retrieve entity data from cache
- invalidate_cache_by_prefix(): Invalidate all keys with a specific prefix
4. CACHE INVALIDATION STRATEGY:
- Direct invalidation via invalidate_* functions for immediate changes
- Delayed invalidation via revalidation_manager for background processing
- Event-based triggers for automatic cache updates (see triggers.py)
- Direct invalidation via invalidate_* functions for immediate changes
- Delayed invalidation via revalidation_manager for background processing
- Event-based triggers for automatic cache updates (see triggers.py)
To maintain consistency with the existing codebase, this module preserves
the original key naming patterns while providing a more structured approach
@@ -29,8 +29,7 @@ for new cache operations.
import asyncio
import json
import traceback
from typing import Any, Callable, Dict, List, Type
from typing import Any, Dict, List, Optional, Union
import orjson
from sqlalchemy import and_, join, select
@@ -38,9 +37,9 @@ from sqlalchemy import and_, join, select
from orm.author import Author, AuthorFollower
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic, TopicFollower
from storage.db import local_session
from storage.redis import redis
from utils.encoders import fast_json_dumps
from services.db import local_session
from services.redis import redis
from utils.encoders import CustomJSONEncoder
from utils.logger import root_logger as logger
DEFAULT_FOLLOWS = {
@@ -61,16 +60,14 @@ CACHE_KEYS = {
"TOPIC_FOLLOWERS": "topic:followers:{}",
"TOPIC_SHOUTS": "topic_shouts_{}",
"AUTHOR_ID": "author:id:{}",
"AUTHOR_USER": "author:user:{}",
"SHOUTS": "shouts:{}",
}
# Type alias for JSON encoder
JSONEncoderType = Type[json.JSONEncoder]
# Cache topic data
async def cache_topic(topic: dict) -> None:
payload = fast_json_dumps(topic)
async def cache_topic(topic: dict):
payload = json.dumps(topic, cls=CustomJSONEncoder)
await asyncio.gather(
redis.execute("SET", f"topic:id:{topic['id']}", payload),
redis.execute("SET", f"topic:slug:{topic['slug']}", payload),
@@ -78,109 +75,56 @@ async def cache_topic(topic: dict) -> None:
# Cache author data
async def cache_author(author: dict) -> None:
try:
# logger.debug(f"Caching author {author.get('id', 'unknown')} with slug: {author.get('slug', 'unknown')}")
payload = fast_json_dumps(author)
# logger.debug(f"Author payload size: {len(payload)} bytes")
await asyncio.gather(
redis.execute("SET", f"author:slug:{author['slug'].strip()}", str(author["id"])),
redis.execute("SET", f"author:id:{author['id']}", payload),
)
# logger.debug(f"Successfully cached author {author.get('id', 'unknown')}")
except Exception as e:
logger.error(f"Error caching author: {e}")
logger.error(f"Author data: {author}")
logger.error(f"Traceback: {traceback.format_exc()}")
raise
async def cache_author(author: dict):
payload = json.dumps(author, cls=CustomJSONEncoder)
await asyncio.gather(
redis.execute("SET", f"author:user:{author['user'].strip()}", str(author["id"])),
redis.execute("SET", f"author:id:{author['id']}", payload),
)
# Cache follows data
async def cache_follows(follower_id: int, entity_type: str, entity_id: int, is_insert: bool = True) -> None:
async def cache_follows(follower_id: int, entity_type: str, entity_id: int, is_insert=True):
key = f"author:follows-{entity_type}s:{follower_id}"
follows_str = await redis.execute("GET", key)
if follows_str:
follows = orjson.loads(follows_str)
# Для большинства типов используем пустой список ID, кроме communities
elif entity_type == "community":
follows = DEFAULT_FOLLOWS.get("communities", [])
else:
follows = []
follows = orjson.loads(follows_str) if follows_str else DEFAULT_FOLLOWS[entity_type]
if is_insert:
if entity_id not in follows:
follows.append(entity_id)
else:
follows = [eid for eid in follows if eid != entity_id]
await redis.execute("SET", key, fast_json_dumps(follows))
await redis.execute("SET", key, json.dumps(follows, cls=CustomJSONEncoder))
await update_follower_stat(follower_id, entity_type, len(follows))
# Update follower statistics
async def update_follower_stat(follower_id: int, entity_type: str, count: int) -> None:
try:
logger.debug(f"Updating follower stat for author {follower_id}, entity_type: {entity_type}, count: {count}")
follower_key = f"author:id:{follower_id}"
follower_str = await redis.execute("GET", follower_key)
follower = orjson.loads(follower_str) if follower_str else None
if follower:
follower["stat"] = {f"{entity_type}s": count}
logger.debug(f"Updating follower {follower_id} with new stat: {follower['stat']}")
await cache_author(follower)
else:
logger.warning(f"Follower {follower_id} not found in cache for stat update")
except Exception as e:
logger.error(f"Error updating follower stat: {e}")
logger.error(f"follower_id: {follower_id}, entity_type: {entity_type}, count: {count}")
logger.error(f"Traceback: {traceback.format_exc()}")
raise
async def update_follower_stat(follower_id, entity_type, count):
follower_key = f"author:id:{follower_id}"
follower_str = await redis.execute("GET", follower_key)
follower = orjson.loads(follower_str) if follower_str else None
if follower:
follower["stat"] = {f"{entity_type}s": count}
await cache_author(follower)
# Get author from cache
async def get_cached_author(author_id: int, get_with_stat=None) -> dict | None:
logger.debug(f"[get_cached_author] Начало выполнения для author_id: {author_id}")
async def get_cached_author(author_id: int, get_with_stat):
author_key = f"author:id:{author_id}"
logger.debug(f"[get_cached_author] Проверка кэша по ключу: {author_key}")
result = await redis.execute("GET", author_key)
if result:
logger.debug(f"[get_cached_author] Найдены данные в кэше, размер: {len(result)} байт")
cached_data = orjson.loads(result)
logger.debug(
f"[get_cached_author] Кэшированные данные имеют ключи: {list(cached_data.keys()) if cached_data else 'None'}"
)
return cached_data
logger.debug("[get_cached_author] Данные не найдены в кэше, загрузка из БД")
return orjson.loads(result)
# Load from database if not found in cache
q = select(Author).where(Author.id == author_id)
authors = get_with_stat(q)
logger.debug(f"[get_cached_author] Результат запроса из БД: {len(authors) if authors else 0} записей")
if authors:
author = authors[0]
logger.debug(f"[get_cached_author] Получен автор из БД: {type(author)}, id: {getattr(author, 'id', 'N/A')}")
# Используем безопасный вызов dict() для Author
author_dict = author.dict() if hasattr(author, "dict") else author.__dict__
logger.debug(
f"[get_cached_author] Сериализованные данные автора: {list(author_dict.keys()) if author_dict else 'None'}"
)
await cache_author(author_dict)
logger.debug("[get_cached_author] Автор кэширован")
return author_dict
logger.warning(f"[get_cached_author] Автор с ID {author_id} не найден в БД")
await cache_author(author.dict())
return author.dict()
return None
# Function to get cached topic
async def get_cached_topic(topic_id: int) -> dict | None:
async def get_cached_topic(topic_id: int):
"""
Fetch topic data from cache or database by id.
@@ -200,22 +144,19 @@ async def get_cached_topic(topic_id: int) -> dict | None:
topic = session.execute(select(Topic).where(Topic.id == topic_id)).scalar_one_or_none()
if topic:
topic_dict = topic.dict()
await redis.execute("SET", topic_key, fast_json_dumps(topic_dict))
await redis.execute("SET", topic_key, json.dumps(topic_dict, cls=CustomJSONEncoder))
return topic_dict
return None
# Get topic by slug from cache
async def get_cached_topic_by_slug(slug: str, get_with_stat=None) -> dict | None:
async def get_cached_topic_by_slug(slug: str, get_with_stat):
topic_key = f"topic:slug:{slug}"
result = await redis.execute("GET", topic_key)
if result:
return orjson.loads(result)
# Load from database if not found in cache
if get_with_stat is None:
pass # get_with_stat уже импортирован на верхнем уровне
topic_query = select(Topic).where(Topic.slug == slug)
topics = get_with_stat(topic_query)
if topics:
@@ -226,7 +167,7 @@ async def get_cached_topic_by_slug(slug: str, get_with_stat=None) -> dict | None
# Get list of authors by ID from cache
async def get_cached_authors_by_ids(author_ids: list[int]) -> list[dict]:
async def get_cached_authors_by_ids(author_ids: List[int]) -> List[dict]:
# Fetch all author data concurrently
keys = [f"author:id:{author_id}" for author_id in author_ids]
results = await asyncio.gather(*(redis.execute("GET", key) for key in keys))
@@ -235,14 +176,13 @@ async def get_cached_authors_by_ids(author_ids: list[int]) -> list[dict]:
missing_indices = [index for index, author in enumerate(authors) if author is None]
if missing_indices:
missing_ids = [author_ids[index] for index in missing_indices]
query = select(Author).where(Author.id.in_(missing_ids))
with local_session() as session:
missing_authors = session.execute(query).scalars().unique().all()
query = select(Author).where(Author.id.in_(missing_ids))
missing_authors = session.execute(query).scalars().all()
await asyncio.gather(*(cache_author(author.dict()) for author in missing_authors))
for index, author in zip(missing_indices, missing_authors, strict=False):
for index, author in zip(missing_indices, missing_authors):
authors[index] = author.dict()
# Фильтруем None значения для корректного типа возвращаемого значения
return [author for author in authors if author is not None]
return authors
async def get_cached_topic_followers(topic_id: int):
@@ -269,17 +209,17 @@ async def get_cached_topic_followers(topic_id: int):
f[0]
for f in session.query(Author.id)
.join(TopicFollower, TopicFollower.follower == Author.id)
.where(TopicFollower.topic == topic_id)
.filter(TopicFollower.topic == topic_id)
.all()
]
await redis.execute("SETEX", cache_key, CACHE_TTL, fast_json_dumps(followers_ids))
await redis.execute("SETEX", cache_key, CACHE_TTL, orjson.dumps(followers_ids))
followers = await get_cached_authors_by_ids(followers_ids)
logger.debug(f"Cached {len(followers)} followers for topic #{topic_id}")
return followers
except Exception as e:
logger.error(f"Error getting followers for topic #{topic_id}: {e!s}")
logger.error(f"Error getting followers for topic #{topic_id}: {str(e)}")
return []
@@ -299,42 +239,35 @@ async def get_cached_author_followers(author_id: int):
f[0]
for f in session.query(Author.id)
.join(AuthorFollower, AuthorFollower.follower == Author.id)
.where(AuthorFollower.following == author_id, Author.id != author_id)
.filter(AuthorFollower.author == author_id, Author.id != author_id)
.all()
]
await redis.execute("SET", f"author:followers:{author_id}", fast_json_dumps(followers_ids))
return await get_cached_authors_by_ids(followers_ids)
await redis.execute("SET", f"author:followers:{author_id}", orjson.dumps(followers_ids))
followers = await get_cached_authors_by_ids(followers_ids)
return followers
# Get cached follower authors
async def get_cached_follower_authors(author_id: int):
from utils.logger import root_logger as logger
# Attempt to retrieve authors from cache
cache_key = f"author:follows-authors:{author_id}"
cached = await redis.execute("GET", cache_key)
cached = await redis.execute("GET", f"author:follows-authors:{author_id}")
if cached:
authors_ids = orjson.loads(cached)
logger.debug(f"[get_cached_follower_authors] Cache HIT for {cache_key}: {len(authors_ids)} authors")
else:
logger.debug(f"[get_cached_follower_authors] Cache MISS for {cache_key}, querying DB")
logger.info("[get_cached_follower_authors] Cache MISS - this should happen after follow/unfollow operations")
# Query authors from database
with local_session() as session:
authors_ids = [
a[0]
for a in session.execute(
select(Author.id)
.select_from(join(Author, AuthorFollower, Author.id == AuthorFollower.following))
.select_from(join(Author, AuthorFollower, Author.id == AuthorFollower.author))
.where(AuthorFollower.follower == author_id)
).all()
]
await redis.execute("SET", cache_key, fast_json_dumps(authors_ids))
logger.debug(
f"[get_cached_follower_authors] DB query result for user {author_id}: {len(authors_ids)} authors, IDs: {authors_ids}"
)
await redis.execute("SET", f"author:follows-authors:{author_id}", orjson.dumps(authors_ids))
return await get_cached_authors_by_ids(authors_ids)
authors = await get_cached_authors_by_ids(authors_ids)
return authors
# Get cached follower topics
@@ -353,7 +286,7 @@ async def get_cached_follower_topics(author_id: int):
.where(TopicFollower.follower == author_id)
.all()
]
await redis.execute("SET", f"author:follows-topics:{author_id}", fast_json_dumps(topics_ids))
await redis.execute("SET", f"author:follows-topics:{author_id}", orjson.dumps(topics_ids))
topics = []
for topic_id in topics_ids:
@@ -367,31 +300,35 @@ async def get_cached_follower_topics(author_id: int):
return topics
# Get author by author_id from cache
async def get_cached_author_by_id(author_id: int, get_with_stat=None):
# Get author by user ID from cache
async def get_cached_author_by_user_id(user_id: str, get_with_stat):
"""
Retrieve author information by author_id, checking the cache first, then the database.
Retrieve author information by user_id, checking the cache first, then the database.
Args:
author_id (int): The author identifier for which to retrieve the author.
user_id (str): The user identifier for which to retrieve the author.
Returns:
dict: Dictionary with author data or None if not found.
"""
# Attempt to find author data by author_id in Redis cache
cached_author_data = await redis.execute("GET", f"author:id:{author_id}")
if cached_author_data:
# If data is found, return parsed JSON
return orjson.loads(cached_author_data)
# Attempt to find author ID by user_id in Redis cache
author_id = await redis.execute("GET", f"author:user:{user_id.strip()}")
if author_id:
# If ID is found, get full author data by ID
author_data = await redis.execute("GET", f"author:id:{author_id}")
if author_data:
return orjson.loads(author_data)
author_query = select(Author).where(Author.id == author_id)
# If data is not found in cache, query the database
author_query = select(Author).where(Author.user == user_id)
authors = get_with_stat(author_query)
if authors:
# Cache the retrieved author data
author = authors[0]
author_dict = author.dict()
await asyncio.gather(
redis.execute("SET", f"author:id:{author.id}", fast_json_dumps(author_dict)),
redis.execute("SET", f"author:user:{user_id.strip()}", str(author.id)),
redis.execute("SET", f"author:id:{author.id}", orjson.dumps(author_dict)),
)
return author_dict
@@ -422,17 +359,11 @@ async def get_cached_topic_authors(topic_id: int):
select(ShoutAuthor.author)
.select_from(join(ShoutTopic, Shout, ShoutTopic.shout == Shout.id))
.join(ShoutAuthor, ShoutAuthor.shout == Shout.id)
.where(
and_(
ShoutTopic.topic == topic_id,
Shout.published_at.is_not(None),
Shout.deleted_at.is_(None),
)
)
.where(and_(ShoutTopic.topic == topic_id, Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
)
authors_ids = [author_id for (author_id,) in session.execute(query).all()]
# Cache the retrieved author IDs
await redis.execute("SET", rkey, fast_json_dumps(authors_ids))
await redis.execute("SET", rkey, orjson.dumps(authors_ids))
# Retrieve full author details from cached IDs
if authors_ids:
@@ -443,12 +374,15 @@ async def get_cached_topic_authors(topic_id: int):
return []
async def invalidate_shouts_cache(cache_keys: list[str]) -> None:
async def invalidate_shouts_cache(cache_keys: List[str]):
"""
Инвалидирует кэш выборок публикаций по переданным ключам.
"""
for cache_key in cache_keys:
for key in cache_keys:
try:
# Формируем полный ключ кэша
cache_key = f"shouts:{key}"
# Удаляем основной кэш
await redis.execute("DEL", cache_key)
logger.debug(f"Invalidated cache key: {cache_key}")
@@ -457,8 +391,8 @@ async def invalidate_shouts_cache(cache_keys: list[str]) -> None:
await redis.execute("SETEX", f"{cache_key}:invalidated", CACHE_TTL, "1")
# Если это кэш темы, инвалидируем также связанные ключи
if cache_key.startswith("topic_"):
topic_id = cache_key.split("_")[1]
if key.startswith("topic_"):
topic_id = key.split("_")[1]
related_keys = [
f"topic:id:{topic_id}",
f"topic:authors:{topic_id}",
@@ -470,35 +404,38 @@ async def invalidate_shouts_cache(cache_keys: list[str]) -> None:
logger.debug(f"Invalidated related key: {related_key}")
except Exception as e:
logger.error(f"Error invalidating cache key {cache_key}: {e}")
logger.error(f"Error invalidating cache key {key}: {e}")
async def cache_topic_shouts(topic_id: int, shouts: list[dict]) -> None:
async def cache_topic_shouts(topic_id: int, shouts: List[dict]):
"""Кэширует список публикаций для темы"""
key = f"topic_shouts_{topic_id}"
payload = fast_json_dumps(shouts)
payload = json.dumps(shouts, cls=CustomJSONEncoder)
await redis.execute("SETEX", key, CACHE_TTL, payload)
async def get_cached_topic_shouts(topic_id: int) -> list[dict]:
async def get_cached_topic_shouts(topic_id: int) -> List[dict]:
"""Получает кэшированный список публикаций для темы"""
key = f"topic_shouts_{topic_id}"
cached = await redis.execute("GET", key)
if cached:
return orjson.loads(cached)
return []
return None
async def cache_related_entities(shout: Shout) -> None:
async def cache_related_entities(shout: Shout):
"""
Кэширует все связанные с публикацией сущности (авторов и темы)
"""
tasks = [cache_by_id(Author, author.id, cache_author) for author in shout.authors]
tasks.extend(cache_by_id(Topic, topic.id, cache_topic) for topic in shout.topics)
tasks = []
for author in shout.authors:
tasks.append(cache_by_id(Author, author.id, cache_author))
for topic in shout.topics:
tasks.append(cache_by_id(Topic, topic.id, cache_topic))
await asyncio.gather(*tasks)
async def invalidate_shout_related_cache(shout: Shout, author_id: int) -> None:
async def invalidate_shout_related_cache(shout: Shout, author_id: int):
"""
Инвалидирует весь кэш, связанный с публикацией и её связями
@@ -513,10 +450,6 @@ async def invalidate_shout_related_cache(shout: Shout, author_id: int) -> None:
"unrated", # неоцененные
"recent", # последние
"coauthored", # совместные
# 🔧 Добавляем ключи с featured материалами
"featured", # featured публикации
"featured:recent", # недавние featured
"featured:top", # топ featured
}
# Добавляем ключи авторов
@@ -527,12 +460,6 @@ async def invalidate_shout_related_cache(shout: Shout, author_id: int) -> None:
cache_keys.update(f"topic_{t.id}" for t in shout.topics)
cache_keys.update(f"topic_shouts_{t.id}" for t in shout.topics)
# 🔧 Добавляем ключи featured материалов для каждой темы
for topic in shout.topics:
cache_keys.update(
[f"topic_{topic.id}:featured", f"topic_{topic.id}:featured:recent", f"topic_{topic.id}:featured:top"]
)
await invalidate_shouts_cache(list(cache_keys))
@@ -561,7 +488,7 @@ async def get_cached_entity(entity_type: str, entity_id: int, get_method, cache_
return None
async def cache_by_id(entity, entity_id: int, cache_method, get_with_stat=None):
async def cache_by_id(entity, entity_id: int, cache_method):
"""
Кэширует сущность по ID, используя указанный метод кэширования
@@ -570,15 +497,13 @@ async def cache_by_id(entity, entity_id: int, cache_method, get_with_stat=None):
entity_id: ID сущности
cache_method: функция кэширования
"""
from resolvers.stat import get_with_stat
if get_with_stat is None:
pass # get_with_stat уже импортирован на верхнем уровне
caching_query = select(entity).where(entity.id == entity_id)
caching_query = select(entity).filter(entity.id == entity_id)
result = get_with_stat(caching_query)
if not result or not result[0]:
logger.warning(f"{entity.__name__} with id {entity_id} not found")
return None
return
x = result[0]
d = x.dict()
await cache_method(d)
@@ -586,7 +511,7 @@ async def cache_by_id(entity, entity_id: int, cache_method, get_with_stat=None):
# Универсальная функция для сохранения данных в кеш
async def cache_data(key: str, data: Any, ttl: int | None = None) -> None:
async def cache_data(key: str, data: Any, ttl: Optional[int] = None) -> None:
"""
Сохраняет данные в кеш по указанному ключу.
@@ -596,9 +521,7 @@ async def cache_data(key: str, data: Any, ttl: int | None = None) -> None:
ttl: Время жизни кеша в секундах (None - бессрочно)
"""
try:
logger.debug(f"Attempting to cache data for key: {key}, data type: {type(data)}")
payload = fast_json_dumps(data)
logger.debug(f"Serialized payload size: {len(payload)} bytes")
payload = json.dumps(data, cls=CustomJSONEncoder)
if ttl:
await redis.execute("SETEX", key, ttl, payload)
else:
@@ -606,13 +529,10 @@ async def cache_data(key: str, data: Any, ttl: int | None = None) -> None:
logger.debug(f"Данные сохранены в кеш по ключу {key}")
except Exception as e:
logger.error(f"Ошибка при сохранении данных в кеш: {e}")
logger.error(f"Key: {key}, data type: {type(data)}")
logger.error(f"Traceback: {traceback.format_exc()}")
raise
# Универсальная функция для получения данных из кеша
async def get_cached_data(key: str) -> Any | None:
async def get_cached_data(key: str) -> Optional[Any]:
"""
Получает данные из кеша по указанному ключу.
@@ -623,19 +543,14 @@ async def get_cached_data(key: str) -> Any | None:
Any: Данные из кеша или None, если данных нет
"""
try:
logger.debug(f"Attempting to get cached data for key: {key}")
cached_data = await redis.execute("GET", key)
if cached_data:
logger.debug(f"Raw cached data size: {len(cached_data)} bytes")
loaded = orjson.loads(cached_data)
logger.debug(f"Данные получены из кеша по ключу {key}: {len(loaded)}")
return loaded
logger.debug(f"No cached data found for key: {key}")
return None
except Exception as e:
logger.error(f"Ошибка при получении данных из кеша: {e}")
logger.error(f"Key: {key}")
logger.error(f"Traceback: {traceback.format_exc()}")
return None
@@ -659,8 +574,8 @@ async def invalidate_cache_by_prefix(prefix: str) -> None:
# Универсальная функция для получения и кеширования данных
async def cached_query(
cache_key: str,
query_func: Callable,
ttl: int | None = None,
query_func: callable,
ttl: Optional[int] = None,
force_refresh: bool = False,
use_key_format: bool = True,
**query_params,
@@ -684,7 +599,7 @@ async def cached_query(
actual_key = cache_key
if use_key_format and "{}" in cache_key:
# Look for a template match in CACHE_KEYS
for key_format in CACHE_KEYS.values():
for key_name, key_format in CACHE_KEYS.items():
if cache_key == key_format:
# We have a match, now look for the id or value to format with
for param_name, param_value in query_params.items():
@@ -700,290 +615,14 @@ async def cached_query(
# If data not in cache or refresh required, execute query
try:
logger.debug(f"Executing query function for cache key: {actual_key}")
result = await query_func(**query_params)
logger.debug(
f"Query function returned: {type(result)}, length: {len(result) if hasattr(result, '__len__') else 'N/A'}"
)
if result is not None:
# Save result to cache
logger.debug(f"Saving result to cache with key: {actual_key}")
await cache_data(actual_key, result, ttl)
return result
except Exception as e:
logger.error(f"Error executing query for caching: {e}")
logger.error(f"Query function: {query_func.__name__ if hasattr(query_func, '__name__') else 'unknown'}")
logger.error(f"Query params: {query_params}")
logger.error(f"Traceback: {traceback.format_exc()}")
# In case of error, return data from cache if not forcing refresh
if not force_refresh:
logger.debug(f"Attempting to get cached data as fallback for key: {actual_key}")
return await get_cached_data(actual_key)
raise
async def save_topic_to_cache(topic: Dict[str, Any]) -> None:
"""Сохраняет топик в кеш"""
try:
topic_id = topic.get("id")
if not topic_id:
return
topic_key = f"topic:{topic_id}"
payload = fast_json_dumps(topic)
await redis.execute("SET", topic_key, payload)
await redis.execute("EXPIRE", topic_key, 3600) # 1 час
logger.debug(f"Topic {topic_id} saved to cache")
except Exception as e:
logger.error(f"Failed to save topic to cache: {e}")
async def save_author_to_cache(author: Dict[str, Any]) -> None:
"""Сохраняет автора в кеш"""
try:
author_id = author.get("id")
if not author_id:
return
author_key = f"author:{author_id}"
payload = fast_json_dumps(author)
await redis.execute("SET", author_key, payload)
await redis.execute("EXPIRE", author_key, 1800) # 30 минут
logger.debug(f"Author {author_id} saved to cache")
except Exception as e:
logger.error(f"Failed to save author to cache: {e}")
async def cache_follows_by_follower(author_id: int, follows: List[Dict[str, Any]]) -> None:
"""Кеширует подписки пользователя"""
try:
key = f"follows:author:{author_id}"
await redis.execute("SET", key, fast_json_dumps(follows))
await redis.execute("EXPIRE", key, 1800) # 30 минут
logger.debug(f"Follows cached for author {author_id}")
except Exception as e:
logger.error(f"Failed to cache follows: {e}")
async def get_topic_from_cache(topic_id: int | str) -> Dict[str, Any] | None:
"""Получает топик из кеша"""
try:
topic_key = f"topic:{topic_id}"
cached_data = await redis.get(topic_key)
if cached_data:
if isinstance(cached_data, bytes):
cached_data = cached_data.decode("utf-8")
return json.loads(cached_data)
return None
except Exception as e:
logger.error(f"Failed to get topic from cache: {e}")
return None
async def get_author_from_cache(author_id: int | str) -> Dict[str, Any] | None:
"""Получает автора из кеша"""
try:
author_key = f"author:{author_id}"
cached_data = await redis.get(author_key)
if cached_data:
if isinstance(cached_data, bytes):
cached_data = cached_data.decode("utf-8")
return json.loads(cached_data)
return None
except Exception as e:
logger.error(f"Failed to get author from cache: {e}")
return None
async def cache_topic_with_content(topic_dict: Dict[str, Any]) -> None:
"""Кеширует топик с контентом"""
try:
topic_id = topic_dict.get("id")
if topic_id:
topic_key = f"topic_content:{topic_id}"
await redis.execute("SET", topic_key, fast_json_dumps(topic_dict))
await redis.execute("EXPIRE", topic_key, 7200) # 2 часа
logger.debug(f"Topic content {topic_id} cached")
except Exception as e:
logger.error(f"Failed to cache topic content: {e}")
async def get_cached_topic_content(topic_id: int | str) -> Dict[str, Any] | None:
"""Получает кешированный контент топика"""
try:
topic_key = f"topic_content:{topic_id}"
cached_data = await redis.get(topic_key)
if cached_data:
if isinstance(cached_data, bytes):
cached_data = cached_data.decode("utf-8")
return json.loads(cached_data)
return None
except Exception as e:
logger.error(f"Failed to get cached topic content: {e}")
return None
async def save_shouts_to_cache(shouts: List[Dict[str, Any]], cache_key: str = "recent_shouts") -> None:
"""Сохраняет статьи в кеш"""
try:
payload = fast_json_dumps(shouts)
await redis.execute("SET", cache_key, payload)
await redis.execute("EXPIRE", cache_key, 900) # 15 минут
logger.debug(f"Shouts saved to cache with key: {cache_key}")
except Exception as e:
logger.error(f"Failed to save shouts to cache: {e}")
async def get_shouts_from_cache(cache_key: str = "recent_shouts") -> List[Dict[str, Any]] | None:
"""Получает статьи из кеша"""
try:
cached_data = await redis.get(cache_key)
if cached_data:
if isinstance(cached_data, bytes):
cached_data = cached_data.decode("utf-8")
return json.loads(cached_data)
return None
except Exception as e:
logger.error(f"Failed to get shouts from cache: {e}")
return None
async def cache_search_results(query: str, data: List[Dict[str, Any]], ttl: int = 600) -> None:
"""Кеширует результаты поиска"""
try:
search_key = f"search:{query.lower().replace(' ', '_')}"
payload = fast_json_dumps(data)
await redis.execute("SET", search_key, payload)
await redis.execute("EXPIRE", search_key, ttl)
logger.debug(f"Search results cached for query: {query}")
except Exception as e:
logger.error(f"Failed to cache search results: {e}")
async def get_cached_search_results(query: str) -> List[Dict[str, Any]] | None:
"""Получает кешированные результаты поиска"""
try:
search_key = f"search:{query.lower().replace(' ', '_')}"
cached_data = await redis.get(search_key)
if cached_data:
if isinstance(cached_data, bytes):
cached_data = cached_data.decode("utf-8")
return json.loads(cached_data)
return None
except Exception as e:
logger.error(f"Failed to get cached search results: {e}")
return None
async def invalidate_topic_cache(topic_id: int | str) -> None:
"""Инвалидирует кеш топика"""
try:
topic_key = f"topic:{topic_id}"
content_key = f"topic_content:{topic_id}"
await redis.delete(topic_key)
await redis.delete(content_key)
logger.debug(f"Cache invalidated for topic {topic_id}")
except Exception as e:
logger.error(f"Failed to invalidate topic cache: {e}")
async def invalidate_author_cache(author_id: int | str) -> None:
"""Инвалидирует кеш автора"""
try:
author_key = f"author:{author_id}"
follows_key = f"follows:author:{author_id}"
await redis.delete(author_key)
await redis.delete(follows_key)
logger.debug(f"Cache invalidated for author {author_id}")
except Exception as e:
logger.error(f"Failed to invalidate author cache: {e}")
async def clear_all_cache() -> None:
"""
Очищает весь кэш Redis (используйте с осторожностью!)
Warning:
Эта функция удаляет ВСЕ данные из Redis!
Используйте только в тестовой среде или при критической необходимости.
"""
try:
await redis.execute("FLUSHDB")
logger.info("Весь кэш очищен")
except Exception as e:
logger.error(f"Ошибка при очистке кэша: {e}")
async def invalidate_topic_followers_cache(topic_id: int) -> None:
"""
Инвалидирует кеши подписчиков при удалении топика.
Эта функция:
1. Получает список всех подписчиков топика
2. Инвалидирует персональные кеши подписок для каждого подписчика
3. Инвалидирует кеши самого топика
4. Логирует процесс для отладки
Args:
topic_id: ID топика для которого нужно инвалидировать кеши подписчиков
"""
try:
logger.debug(f"Инвалидация кешей подписчиков для топика {topic_id}")
# Получаем список всех подписчиков топика из БД
with local_session() as session:
followers_query = session.query(TopicFollower.follower).where(TopicFollower.topic == topic_id)
follower_ids = [row[0] for row in followers_query.all()]
logger.debug(f"Найдено {len(follower_ids)} подписчиков топика {topic_id}")
# Инвалидируем кеши подписок для всех подписчиков
for follower_id in follower_ids:
cache_keys_to_delete = [
f"author:follows-topics:{follower_id}", # Список топиков на которые подписан автор
f"author:followers:{follower_id}", # Счетчик подписчиков автора
f"author:stat:{follower_id}", # Общая статистика автора
f"author:id:{follower_id}", # Кешированные данные автора
]
for cache_key in cache_keys_to_delete:
try:
await redis.execute("DEL", cache_key)
logger.debug(f"Удален кеш: {cache_key}")
except Exception as e:
logger.error(f"Ошибка при удалении кеша {cache_key}: {e}")
# Инвалидируем кеши самого топика
topic_cache_keys = [
f"topic:followers:{topic_id}", # Список подписчиков топика
f"topic:id:{topic_id}", # Данные топика по ID
f"topic:authors:{topic_id}", # Авторы топика
f"topic_shouts_{topic_id}", # Публикации топика (legacy format)
]
for cache_key in topic_cache_keys:
try:
await redis.execute("DEL", cache_key)
logger.debug(f"Удален кеш топика: {cache_key}")
except Exception as e:
logger.error(f"Ошибка при удалении кеша топика {cache_key}: {e}")
# Также ищем и удаляем коллекционные кеши, содержащие данные об этом топике
try:
collection_keys = await redis.execute("KEYS", "topics:stats:*")
if collection_keys:
await redis.execute("DEL", *collection_keys)
logger.debug(f"Удалено {len(collection_keys)} коллекционных ключей тем")
except Exception as e:
logger.error(f"Ошибка при удалении коллекционных кешей: {e}")
logger.info(f"Успешно инвалидированы кеши для топика {topic_id} и {len(follower_ids)} подписчиков")
except Exception as e:
logger.error(f"Ошибка при инвалидации кешей подписчиков топика {topic_id}: {e}")
raise

210
cache/precache.py vendored
View File

@@ -1,45 +1,43 @@
import asyncio
import traceback
import json
import orjson
from sqlalchemy import and_, func, join, select
from sqlalchemy import and_, join, select
# Импорт Author, AuthorFollower отложен для избежания циклических импортов
from cache.cache import cache_author, cache_topic
from orm.author import Author, AuthorFollower
from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic
from orm.topic import Topic, TopicFollower
from resolvers.stat import get_with_stat
from storage.db import local_session
from storage.redis import redis
from utils.encoders import fast_json_dumps
from services.db import local_session
from services.redis import redis
from utils.encoders import CustomJSONEncoder
from utils.logger import root_logger as logger
# Предварительное кеширование подписчиков автора
async def precache_authors_followers(author_id, session) -> None:
authors_followers: set[int] = set()
followers_query = select(AuthorFollower.follower).where(AuthorFollower.following == author_id)
async def precache_authors_followers(author_id, session):
authors_followers = set()
followers_query = select(AuthorFollower.follower).where(AuthorFollower.author == author_id)
result = session.execute(followers_query)
authors_followers.update(row[0] for row in result if row[0])
followers_payload = fast_json_dumps(list(authors_followers))
followers_payload = json.dumps(list(authors_followers), cls=CustomJSONEncoder)
await redis.execute("SET", f"author:followers:{author_id}", followers_payload)
# Предварительное кеширование подписок автора
async def precache_authors_follows(author_id, session) -> None:
async def precache_authors_follows(author_id, session):
follows_topics_query = select(TopicFollower.topic).where(TopicFollower.follower == author_id)
follows_authors_query = select(AuthorFollower.following).where(AuthorFollower.follower == author_id)
follows_authors_query = select(AuthorFollower.author).where(AuthorFollower.follower == author_id)
follows_shouts_query = select(ShoutReactionsFollower.shout).where(ShoutReactionsFollower.follower == author_id)
follows_topics = {row[0] for row in session.execute(follows_topics_query) if row[0]}
follows_authors = {row[0] for row in session.execute(follows_authors_query) if row[0]}
follows_shouts = {row[0] for row in session.execute(follows_shouts_query) if row[0]}
topics_payload = fast_json_dumps(list(follows_topics))
authors_payload = fast_json_dumps(list(follows_authors))
shouts_payload = fast_json_dumps(list(follows_shouts))
topics_payload = json.dumps(list(follows_topics), cls=CustomJSONEncoder)
authors_payload = json.dumps(list(follows_authors), cls=CustomJSONEncoder)
shouts_payload = json.dumps(list(follows_shouts), cls=CustomJSONEncoder)
await asyncio.gather(
redis.execute("SET", f"author:follows-topics:{author_id}", topics_payload),
@@ -49,12 +47,12 @@ async def precache_authors_follows(author_id, session) -> None:
# Предварительное кеширование авторов тем
async def precache_topics_authors(topic_id: int, session) -> None:
async def precache_topics_authors(topic_id: int, session):
topic_authors_query = (
select(ShoutAuthor.author)
.select_from(join(ShoutTopic, Shout, ShoutTopic.shout == Shout.id))
.join(ShoutAuthor, ShoutAuthor.shout == Shout.id)
.where(
.filter(
and_(
ShoutTopic.topic == topic_id,
Shout.published_at.is_not(None),
@@ -64,190 +62,72 @@ async def precache_topics_authors(topic_id: int, session) -> None:
)
topic_authors = {row[0] for row in session.execute(topic_authors_query) if row[0]}
authors_payload = fast_json_dumps(list(topic_authors))
authors_payload = json.dumps(list(topic_authors), cls=CustomJSONEncoder)
await redis.execute("SET", f"topic:authors:{topic_id}", authors_payload)
# Предварительное кеширование подписчиков тем
async def precache_topics_followers(topic_id: int, session) -> None:
try:
followers_query = select(TopicFollower.follower).where(TopicFollower.topic == topic_id)
topic_followers = {row[0] for row in session.execute(followers_query) if row[0]}
async def precache_topics_followers(topic_id: int, session):
followers_query = select(TopicFollower.follower).where(TopicFollower.topic == topic_id)
topic_followers = {row[0] for row in session.execute(followers_query) if row[0]}
followers_payload = fast_json_dumps(list(topic_followers))
await redis.execute("SET", f"topic:followers:{topic_id}", followers_payload)
except Exception as e:
logger.error(f"Error precaching followers for topic #{topic_id}: {e}")
# В случае ошибки, устанавливаем пустой список
await redis.execute("SET", f"topic:followers:{topic_id}", fast_json_dumps([]))
followers_payload = json.dumps(list(topic_followers), cls=CustomJSONEncoder)
await redis.execute("SET", f"topic:followers:{topic_id}", followers_payload)
async def precache_data() -> None:
async def precache_data():
logger.info("precaching...")
logger.debug("Entering precache_data")
# Список паттернов ключей, которые нужно сохранить при FLUSHDB
preserve_patterns = [
"migrated_views_*", # Данные миграции просмотров
"session:*", # Сессии пользователей
"env_vars:*", # Переменные окружения
"oauth_*", # OAuth токены
]
# Сохраняем все важные ключи перед очисткой
all_keys_to_preserve = []
preserved_data = {}
try:
for pattern in preserve_patterns:
keys = await redis.execute("KEYS", pattern)
if keys:
all_keys_to_preserve.extend(keys)
logger.info(f"Найдено {len(keys)} ключей по паттерну '{pattern}'")
if all_keys_to_preserve:
logger.info(f"Сохраняем {len(all_keys_to_preserve)} важных ключей перед FLUSHDB")
for key in all_keys_to_preserve:
try:
# Определяем тип ключа и сохраняем данные
key_type = await redis.execute("TYPE", key)
if key_type == "hash":
preserved_data[key] = await redis.execute("HGETALL", key)
elif key_type == "string":
preserved_data[key] = await redis.execute("GET", key)
elif key_type == "set":
preserved_data[key] = await redis.execute("SMEMBERS", key)
elif key_type == "list":
preserved_data[key] = await redis.execute("LRANGE", key, 0, -1)
elif key_type == "zset":
preserved_data[key] = await redis.execute("ZRANGE", key, 0, -1, "WITHSCORES")
except Exception as e:
logger.error(f"Ошибка при сохранении ключа {key}: {e}")
continue
key = "authorizer_env"
# cache reset
value = await redis.execute("HGETALL", key)
await redis.execute("FLUSHDB")
logger.debug("Redis database flushed")
logger.info("redis: FLUSHDB")
# Восстанавливаем все сохранённые ключи
if preserved_data:
logger.info(f"Восстанавливаем {len(preserved_data)} сохранённых ключей")
for key, data in preserved_data.items():
try:
if isinstance(data, dict) and data:
# Hash
for field, val in data.items():
await redis.execute("HSET", key, field, val)
elif isinstance(data, str) and data:
# String
await redis.execute("SET", key, data)
elif isinstance(data, list) and data:
# List или ZSet
if any(isinstance(item, list | tuple) and len(item) == 2 for item in data):
# ZSet with scores
for item in data:
if isinstance(item, list | tuple) and len(item) == 2:
await redis.execute("ZADD", key, item[1], item[0])
else:
# Regular list
await redis.execute("LPUSH", key, *data)
elif isinstance(data, set) and data:
# Set
await redis.execute("SADD", key, *data)
except Exception as e:
logger.error(f"Ошибка при восстановлении ключа {key}: {e}")
continue
# Преобразуем словарь в список аргументов для HSET
if value:
# Если значение - словарь, преобразуем его в плоский список для HSET
if isinstance(value, dict):
flattened = []
for field, val in value.items():
flattened.extend([field, val])
await redis.execute("HSET", key, *flattened)
else:
# Предполагаем, что значение уже содержит список
await redis.execute("HSET", key, *value)
logger.info(f"redis hash '{key}' was restored")
logger.info("Beginning topic precache phase")
with local_session() as session:
# Проверяем состояние таблицы topic_followers перед кешированием
total_followers = session.execute(select(func.count(TopicFollower.topic))).scalar()
unique_topics_with_followers = session.execute(
select(func.count(func.distinct(TopicFollower.topic)))
).scalar()
unique_followers = session.execute(select(func.count(func.distinct(TopicFollower.follower)))).scalar()
logger.info("📊 Database state before precaching:")
logger.info(f" Total topic_followers records: {total_followers}")
logger.info(f" Unique topics with followers: {unique_topics_with_followers}")
logger.info(f" Unique followers: {unique_followers}")
if total_followers == 0:
logger.warning(
"🚨 WARNING: topic_followers table is empty! This will cause all topics to show 0 followers."
)
elif unique_topics_with_followers == 0:
logger.warning("🚨 WARNING: No topics have followers! This will cause all topics to show 0 followers.")
# topics
q = select(Topic).where(Topic.community == 1)
topics = get_with_stat(q)
logger.info(f"Found {len(topics)} topics to precache")
for topic in topics:
topic_dict = topic.dict() if hasattr(topic, "dict") else topic
# logger.debug(f"Precaching topic id={topic_dict.get('id')}")
await cache_topic(topic_dict)
# logger.debug(f"Cached topic id={topic_dict.get('id')}")
await asyncio.gather(
precache_topics_followers(topic_dict["id"], session),
precache_topics_authors(topic_dict["id"], session),
)
# logger.debug(f"Finished precaching followers and authors for topic id={topic_dict.get('id')}")
logger.info(f"{len(topics)} topics and their followings precached")
# Выводим список топиков с 0 фолловерами
topics_with_zero_followers = []
for topic in topics:
topic_dict = topic.dict() if hasattr(topic, "dict") else topic
topic_id = topic_dict.get("id")
topic_slug = topic_dict.get("slug", "unknown")
# Пропускаем топики без ID
if not topic_id:
continue
# Получаем количество фолловеров из кеша
followers_cache_key = f"topic:followers:{topic_id}"
followers_data = await redis.execute("GET", followers_cache_key)
if followers_data:
followers_count = len(orjson.loads(followers_data))
if followers_count == 0:
topics_with_zero_followers.append(topic_slug)
else:
# Если кеш не найден, проверяем БД
with local_session() as check_session:
followers_count_result = check_session.execute(
select(func.count(TopicFollower.follower)).where(TopicFollower.topic == topic_id)
).scalar()
followers_count = followers_count_result or 0
if followers_count == 0:
topics_with_zero_followers.append(topic_slug)
if topics_with_zero_followers:
logger.info(f"📋 Топиков с 0 фолловерами: {len(topics_with_zero_followers)}")
else:
logger.info("Все топики имеют фолловеров")
# authors
authors = get_with_stat(select(Author))
# logger.info(f"{len(authors)} authors found in database")
authors = get_with_stat(select(Author).where(Author.user.is_not(None)))
logger.info(f"{len(authors)} authors found in database")
for author in authors:
if isinstance(author, Author):
profile = author.dict()
author_id = profile.get("id")
# user_id = profile.get("user", "").strip()
if author_id: # and user_id:
user_id = profile.get("user", "").strip()
if author_id and user_id:
await cache_author(profile)
await asyncio.gather(
precache_authors_followers(author_id, session),
precache_authors_follows(author_id, session),
precache_authors_followers(author_id, session), precache_authors_follows(author_id, session)
)
# logger.debug(f"Finished precaching followers and follows for author id={author_id}")
else:
logger.error(f"fail caching {author}")
logger.info(f"{len(authors)} authors and their followings precached")
except Exception as exc:
import traceback
traceback.print_exc()
logger.error(f"Error in precache_data: {exc}")

58
cache/revalidator.py vendored
View File

@@ -1,5 +1,4 @@
import asyncio
import contextlib
from cache.cache import (
cache_author,
@@ -9,40 +8,25 @@ from cache.cache import (
invalidate_cache_by_prefix,
)
from resolvers.stat import get_with_stat
from storage.redis import redis
from utils.logger import root_logger as logger
CACHE_REVALIDATION_INTERVAL = 300 # 5 minutes
class CacheRevalidationManager:
def __init__(self, interval=CACHE_REVALIDATION_INTERVAL) -> None:
def __init__(self, interval=CACHE_REVALIDATION_INTERVAL):
"""Инициализация менеджера с заданным интервалом проверки (в секундах)."""
self.interval = interval
self.items_to_revalidate: dict[str, set[str]] = {
"authors": set(),
"topics": set(),
"shouts": set(),
"reactions": set(),
}
self.items_to_revalidate = {"authors": set(), "topics": set(), "shouts": set(), "reactions": set()}
self.lock = asyncio.Lock()
self.running = True
self.MAX_BATCH_SIZE = 10 # Максимальное количество элементов для поштучной обработки
self._redis = redis # Добавлена инициализация _redis для доступа к Redis-клиенту
async def start(self) -> None:
async def start(self):
"""Запуск фонового воркера для ревалидации кэша."""
# Проверяем, что у нас есть соединение с Redis
if not self._redis._client:
try:
await self._redis.connect()
logger.info("Redis connection established for revalidation manager")
except Exception as e:
logger.error(f"Failed to connect to Redis: {e}")
self.task = asyncio.create_task(self.revalidate_cache())
async def revalidate_cache(self) -> None:
async def revalidate_cache(self):
"""Циклическая проверка и ревалидация кэша каждые self.interval секунд."""
try:
while self.running:
@@ -53,12 +37,8 @@ class CacheRevalidationManager:
except Exception as e:
logger.error(f"An error occurred in the revalidation worker: {e}")
async def process_revalidation(self) -> None:
async def process_revalidation(self):
"""Обновление кэша для всех сущностей, требующих ревалидации."""
# Проверяем соединение с Redis
if not self._redis._client:
return # Выходим из метода, если не удалось подключиться
async with self.lock:
# Ревалидация кэша авторов
if self.items_to_revalidate["authors"]:
@@ -67,12 +47,9 @@ class CacheRevalidationManager:
if author_id == "all":
await invalidate_cache_by_prefix("authors")
break
try:
author = await get_cached_author(int(author_id), get_with_stat)
if author:
await cache_author(author)
except ValueError:
logger.warning(f"Invalid author_id: {author_id}")
author = await get_cached_author(author_id, get_with_stat)
if author:
await cache_author(author)
self.items_to_revalidate["authors"].clear()
# Ревалидация кэша тем
@@ -82,12 +59,9 @@ class CacheRevalidationManager:
if topic_id == "all":
await invalidate_cache_by_prefix("topics")
break
try:
topic = await get_cached_topic(int(topic_id))
if topic:
await cache_topic(topic)
except ValueError:
logger.warning(f"Invalid topic_id: {topic_id}")
topic = await get_cached_topic(topic_id)
if topic:
await cache_topic(topic)
self.items_to_revalidate["topics"].clear()
# Ревалидация шаутов (публикаций)
@@ -158,24 +132,26 @@ class CacheRevalidationManager:
self.items_to_revalidate["reactions"].clear()
def mark_for_revalidation(self, entity_id, entity_type) -> None:
def mark_for_revalidation(self, entity_id, entity_type):
"""Отметить сущность для ревалидации."""
if entity_id and entity_type:
self.items_to_revalidate[entity_type].add(entity_id)
def invalidate_all(self, entity_type) -> None:
def invalidate_all(self, entity_type):
"""Пометить для инвалидации все элементы указанного типа."""
logger.debug(f"Marking all {entity_type} for invalidation")
# Особый флаг для полной инвалидации
self.items_to_revalidate[entity_type].add("all")
async def stop(self) -> None:
async def stop(self):
"""Остановка фонового воркера."""
self.running = False
if hasattr(self, "task"):
self.task.cancel()
with contextlib.suppress(asyncio.CancelledError):
try:
await self.task
except asyncio.CancelledError:
pass
revalidation_manager = CacheRevalidationManager()

39
cache/triggers.py vendored
View File

@@ -1,16 +1,15 @@
from sqlalchemy import event
# Импорт Author, AuthorFollower отложен для избежания циклических импортов
from cache.revalidator import revalidation_manager
from orm.author import Author, AuthorFollower
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower
from orm.topic import Topic, TopicFollower
from storage.db import local_session
from services.db import local_session
from utils.logger import root_logger as logger
def mark_for_revalidation(entity, *args) -> None:
def mark_for_revalidation(entity, *args):
"""Отметка сущности для ревалидации."""
entity_type = (
"authors"
@@ -27,7 +26,7 @@ def mark_for_revalidation(entity, *args) -> None:
revalidation_manager.mark_for_revalidation(entity.id, entity_type)
def after_follower_handler(mapper, connection, target, is_delete=False) -> None:
def after_follower_handler(mapper, connection, target, is_delete=False):
"""Обработчик добавления, обновления или удаления подписки."""
entity_type = None
if isinstance(target, AuthorFollower):
@@ -39,13 +38,13 @@ def after_follower_handler(mapper, connection, target, is_delete=False) -> None:
if entity_type:
revalidation_manager.mark_for_revalidation(
target.following if entity_type == "authors" else target.topic, entity_type
target.author if entity_type == "authors" else target.topic, entity_type
)
if not is_delete:
revalidation_manager.mark_for_revalidation(target.follower, "authors")
def after_shout_handler(mapper, connection, target) -> None:
def after_shout_handler(mapper, connection, target):
"""Обработчик изменения статуса публикации"""
if not isinstance(target, Shout):
return
@@ -64,7 +63,7 @@ def after_shout_handler(mapper, connection, target) -> None:
revalidation_manager.mark_for_revalidation(target.id, "shouts")
def after_reaction_handler(mapper, connection, target) -> None:
def after_reaction_handler(mapper, connection, target):
"""Обработчик для комментариев"""
if not isinstance(target, Reaction):
return
@@ -89,11 +88,7 @@ def after_reaction_handler(mapper, connection, target) -> None:
with local_session() as session:
shout = (
session.query(Shout)
.where(
Shout.id == shout_id,
Shout.published_at.is_not(None),
Shout.deleted_at.is_(None),
)
.filter(Shout.id == shout_id, Shout.published_at.is_not(None), Shout.deleted_at.is_(None))
.first()
)
@@ -105,7 +100,7 @@ def after_reaction_handler(mapper, connection, target) -> None:
revalidation_manager.mark_for_revalidation(topic.id, "topics")
def events_register() -> None:
def events_register():
"""Регистрация обработчиков событий для всех сущностей."""
event.listen(ShoutAuthor, "after_insert", mark_for_revalidation)
event.listen(ShoutAuthor, "after_update", mark_for_revalidation)
@@ -113,27 +108,15 @@ def events_register() -> None:
event.listen(AuthorFollower, "after_insert", after_follower_handler)
event.listen(AuthorFollower, "after_update", after_follower_handler)
event.listen(
AuthorFollower,
"after_delete",
lambda mapper, connection, target: after_follower_handler(mapper, connection, target, is_delete=True),
)
event.listen(AuthorFollower, "after_delete", lambda *args: after_follower_handler(*args, is_delete=True))
event.listen(TopicFollower, "after_insert", after_follower_handler)
event.listen(TopicFollower, "after_update", after_follower_handler)
event.listen(
TopicFollower,
"after_delete",
lambda mapper, connection, target: after_follower_handler(mapper, connection, target, is_delete=True),
)
event.listen(TopicFollower, "after_delete", lambda *args: after_follower_handler(*args, is_delete=True))
event.listen(ShoutReactionsFollower, "after_insert", after_follower_handler)
event.listen(ShoutReactionsFollower, "after_update", after_follower_handler)
event.listen(
ShoutReactionsFollower,
"after_delete",
lambda mapper, connection, target: after_follower_handler(mapper, connection, target, is_delete=True),
)
event.listen(ShoutReactionsFollower, "after_delete", lambda *args: after_follower_handler(*args, is_delete=True))
event.listen(Reaction, "after_update", mark_for_revalidation)
event.listen(Author, "after_update", mark_for_revalidation)

View File

@@ -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
View File

@@ -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)

View File

@@ -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)

View File

@@ -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. Обновите права доступа при необходимости

View File

@@ -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

View File

@@ -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

View File

@@ -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
```

View File

@@ -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}
```

View File

@@ -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 тесты для проверки производительности

View File

@@ -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`

View File

@@ -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 интеграция!** 🔐✨

View File

@@ -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**: Документирование процедур

View File

@@ -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);
}
};
```

View File

@@ -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 включена
**Готово к продакшену!** 🚀✅

View File

@@ -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!** 📡🍪✨

View File

@@ -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

View File

@@ -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

View File

@@ -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)
- Большие объёмы данных могут замедлять запросы без кеша
- Сложные запросы сортировки требуют больше ресурсов

View File

@@ -147,32 +147,16 @@ await invalidate_topics_cache(456)
```python
class CacheRevalidationManager:
def __init__(self, interval=CACHE_REVALIDATION_INTERVAL):
# ...
self._redis = redis # Прямая ссылка на сервис Redis
async def start(self):
# Проверка и установка соединения с Redis
# ...
# ...
async def process_revalidation(self):
# Обработка элементов для ревалидации
# ...
def mark_for_revalidation(self, entity_id, entity_type):
# Добавляет сущность в очередь на ревалидацию
# ...
```
Менеджер ревалидации работает как асинхронный фоновый процесс, который периодически (по умолчанию каждые 5 минут) проверяет наличие сущностей для ревалидации.
**Взаимодействие с Redis:**
- CacheRevalidationManager хранит прямую ссылку на сервис Redis через атрибут `_redis`
- При запуске проверяется наличие соединения с Redis и при необходимости устанавливается новое
- Включена автоматическая проверка соединения перед каждой операцией ревалидации
- Система самостоятельно восстанавливает соединение при его потере
**Особенности реализации:**
Особенности реализации:
- Для авторов и тем используется поштучная ревалидация каждой записи
- Для шаутов и реакций используется батчевая обработка, с порогом в 10 элементов
- При достижении порога система переключается на инвалидацию коллекций вместо поштучной обработки
@@ -213,14 +197,14 @@ async def precache_data():
async def get_topics_with_stats(limit=10, offset=0, by="title"):
# Формирование ключа кеша по конвенции
cache_key = f"topics:stats:limit={limit}:offset={offset}:sort={by}"
cached_data = await get_cached_data(cache_key)
if cached_data:
return cached_data
# Выполнение запроса к базе данных
result = ... # логика получения данных
await cache_data(cache_key, result, ttl=300)
return result
```
@@ -232,16 +216,16 @@ async def get_topics_with_stats(limit=10, offset=0, by="title"):
async def fetch_data(limit, offset, by):
# Логика получения данных
return result
# Формирование ключа кеша по конвенции
cache_key = f"topics:stats:limit={limit}:offset={offset}:sort={by}"
return await cached_query(
cache_key,
fetch_data,
ttl=300,
limit=limit,
offset=offset,
cache_key,
fetch_data,
ttl=300,
limit=limit,
offset=offset,
by=by
)
```
@@ -249,129 +233,16 @@ async def get_topics_with_stats(limit=10, offset=0, by="title"):
### Точечная инвалидация кеша при изменении данных
```python
async def update_author(author_id, data):
async def update_topic(topic_id, new_data):
# Обновление данных в базе
# ...
# Инвалидация только кеша этого автора
await invalidate_authors_cache(author_id)
return result
# Точечная инвалидация кеша только для измененной темы
await invalidate_topics_cache(topic_id)
return updated_topic
```
## Ключи кеширования
Ниже приведен полный список форматов ключей, используемых в системе кеширования Discours.
### Ключи для публикаций (Shout)
| Формат ключа | Описание | Пример |
|--------------|----------|--------|
| `shouts:{id}` | Публикация по ID | `shouts:123` |
| `shouts:{id}:invalidated` | Флаг инвалидации публикации | `shouts:123:invalidated` |
| `shouts:feed:limit={n}:offset={m}` | Основная лента публикаций | `shouts:feed:limit=20:offset=0` |
| `shouts:recent:limit={n}` | Последние публикации | `shouts:recent:limit=10` |
| `shouts:random_top:limit={n}` | Случайные топовые публикации | `shouts:random_top:limit=5` |
| `shouts:unrated:limit={n}` | Неоцененные публикации | `shouts:unrated:limit=20` |
| `shouts:coauthored:limit={n}` | Совместные публикации | `shouts:coauthored:limit=10` |
### Ключи для авторов (Author)
| Формат ключа | Описание | Пример |
|--------------|----------|--------|
| `author:id:{id}` | Автор по ID | `author:id:123` |
| `author:slug:{slug}` | Автор по слагу | `author:slug:john-doe` |
| `author:user_id:{user_id}` | Автор по ID пользователя | `author:user_id:abc123` |
| `author:{id}` | Публикации автора | `author:123` |
| `authored:{id}` | Публикации, созданные автором | `authored:123` |
| `authors:all:basic` | Базовый список всех авторов | `authors:all:basic` |
| `authors:stats:limit={n}:offset={m}:sort={field}` | Список авторов с пагинацией и сортировкой | `authors:stats:limit=20:offset=0:sort=name` |
| `author:followers:{id}` | Подписчики автора | `author:followers:123` |
| `author:following:{id}` | Авторы, на которых подписан автор | `author:following:123` |
### Ключи для тем (Topic)
| Формат ключа | Описание | Пример |
|--------------|----------|--------|
| `topic:id:{id}` | Тема по ID | `topic:id:123` |
| `topic:slug:{slug}` | Тема по слагу | `topic:slug:technology` |
| `topic:{id}` | Публикации по теме | `topic:123` |
| `topic_shouts_{id}` | Публикации по теме (старый формат) | `topic_shouts_123` |
| `topics:all:basic` | Базовый список всех тем | `topics:all:basic` |
| `topics:stats:limit={n}:offset={m}:sort={field}` | Список тем с пагинацией и сортировкой | `topics:stats:limit=20:offset=0:sort=name` |
| `topic:authors:{id}` | Авторы темы | `topic:authors:123` |
| `topic:followers:{id}` | Подписчики темы | `topic:followers:123` |
| `topic:stats:{id}` | Статистика темы | `topic:stats:123` |
### Ключи для реакций (Reaction)
| Формат ключа | Описание | Пример |
|--------------|----------|--------|
| `reactions:shout:{id}:limit={n}:offset={m}` | Реакции на публикацию | `reactions:shout:123:limit=20:offset=0` |
| `reactions:comment:{id}:limit={n}:offset={m}` | Реакции на комментарий | `reactions:comment:456:limit=20:offset=0` |
| `reactions:author:{id}:limit={n}:offset={m}` | Реакции автора | `reactions:author:123:limit=20:offset=0` |
| `reactions:followed:author:{id}:limit={n}` | Реакции авторов, на которых подписан пользователь | `reactions:followed:author:123:limit=20` |
### Ключи для сообществ (Community)
| Формат ключа | Описание | Пример |
|--------------|----------|--------|
| `community:id:{id}` | Сообщество по ID | `community:id:123` |
| `community:slug:{slug}` | Сообщество по слагу | `community:slug:tech-club` |
| `communities:all:basic` | Базовый список всех сообществ | `communities:all:basic` |
| `community:authors:{id}` | Авторы сообщества | `community:authors:123` |
| `community:shouts:{id}:limit={n}:offset={m}` | Публикации сообщества | `community:shouts:123:limit=20:offset=0` |
### Ключи для подписок (Follow)
| Формат ключа | Описание | Пример |
|--------------|----------|--------|
| `follow:author:{follower_id}:authors` | Авторы, на которых подписан пользователь | `follow:author:123:authors` |
| `follow:author:{follower_id}:topics` | Темы, на которые подписан пользователь | `follow:author:123:topics` |
| `follow:topic:{topic_id}:authors` | Авторы, подписанные на тему | `follow:topic:456:authors` |
| `follow:author:{author_id}:followers` | Подписчики автора | `follow:author:123:followers` |
### Ключи для черновиков (Draft)
| Формат ключа | Описание | Пример |
|--------------|----------|--------|
| `draft:id:{id}` | Черновик по ID | `draft:id:123` |
| `drafts:author:{id}` | Черновики автора | `drafts:author:123` |
| `drafts:all:limit={n}:offset={m}` | Список всех черновиков с пагинацией | `drafts:all:limit=20:offset=0` |
### Ключи для статистики
| Формат ключа | Описание | Пример |
|--------------|----------|--------|
| `stats:shout:{id}` | Статистика публикации | `stats:shout:123` |
| `stats:author:{id}` | Статистика автора | `stats:author:123` |
| `stats:topic:{id}` | Статистика темы | `stats:topic:123` |
| `stats:community:{id}` | Статистика сообщества | `stats:community:123` |
### Ключи для поиска
| Формат ключа | Описание | Пример |
|--------------|----------|--------|
| `search:query:{query}:limit={n}:offset={m}` | Результаты поиска | `search:query:технологии:limit=20:offset=0` |
| `search:author:{query}:limit={n}` | Результаты поиска авторов | `search:author:иван:limit=10` |
| `search:topic:{query}:limit={n}` | Результаты поиска тем | `search:topic:наука:limit=10` |
### Служебные ключи
| Формат ключа | Описание | Пример |
|--------------|----------|--------|
| `revalidation:{entity_type}:{entity_id}` | Метка для ревалидации | `revalidation:author:123` |
| `revalidation:batch:{entity_type}` | Батчевая ревалидация | `revalidation:batch:shouts` |
| `lock:{resource}` | Блокировка ресурса | `lock:precache` |
| `views:shout:{id}` | Счетчик просмотров публикации | `views:shout:123` |
### Важные замечания по использованию ключей
1. При инвалидации кеша публикаций через `invalidate_shouts_cache()` необходимо передавать список ID публикаций, а не ключи кеша.
2. Функция `invalidate_shout_related_cache()` автоматически инвалидирует все связанные ключи для публикации, включая ключи авторов и тем.
3. Для большинства операций с кешем следует использовать асинхронные функции с префиксом `await`.
4. При создании новых ключей кеша следует придерживаться существующих конвенций именования.
## Отладка и мониторинг
Система кеширования использует логгер для отслеживания операций:

View File

@@ -150,7 +150,7 @@ const { data } = await client.query({
1. Для эффективной работы со сложными ветками обсуждений рекомендуется:
- Сначала загружать только корневые комментарии с первыми N ответами
- При наличии дополнительных ответов (когда `stat.comments_count > first_replies.length`)
- При наличии дополнительных ответов (когда `stat.comments_count > first_replies.length`)
добавить кнопку "Показать все ответы"
- При нажатии на кнопку загружать дополнительные ответы с помощью запроса с указанным `parentId`
@@ -162,4 +162,4 @@ const { data } = await client.query({
3. Для улучшения производительности:
- Кешировать результаты запросов на клиенте
- Использовать оптимистичные обновления при добавлении/редактировании комментариев
- При необходимости загружать комментарии порциями (ленивая загрузка)
- При необходимости загружать комментарии порциями (ленивая загрузка)

View File

@@ -1,69 +1,8 @@
## Админ-панель
- **Управление пользователями**: Просмотр, поиск, назначение ролей (user/moderator/admin)
- **Управление публикациями**: Таблица со всеми публикациями, фильтрация по статусу, превью контента
- **Управление топиками**: Полноценное редактирование топиков в админ-панели
- **Иерархическое отображение**: Темы показываются в виде дерева с отступами и символами `└─` для дочерних элементов
- **Колонки таблицы**: ID, название, slug, описание, сообщество, родители, действия
- **Простой интерфейс редактирования**:
- **Клик по строке**: Модалка редактирования открывается при клике на любом месте строки таблицы
- **Ненавязчивый крестик**: Кнопка удаления в виде серого "×", краснеет при hover
- **Простой HTML редактор**: Обычный contenteditable div с моноширинным шрифтом вместо сложного редактора
- **Редактируемые поля**:
- **ID**: Отображается для идентификации (поле только для чтения)
- **Название и slug**: Текстовые поля для основной информации
- **Описание**: Простой HTML редактор с placeholder
- **Картинка**: URL изображения топика
- **Сообщество**: ID сообщества с числовой валидацией
- **Родители**: Список parent_ids через запятую с автоматическим парсингом
- **Безопасное удаление**: Модальное окно подтверждения при клике на крестик
- **Корректная инвалидация кешей**: Автоматическое обновление счетчиков подписок у всех подписчиков
- **GraphQL интеграция**: Использование мутаций `UPDATE_TOPIC_MUTATION` и `DELETE_TOPIC_MUTATION`
- **Управление переменными среды**: Настройка конфигурации приложения
- **TypeScript интеграция**: Полная типизация с автогенерацией типов из GraphQL схемы
- **Responsive дизайн**: Адаптивность для разных размеров экранов
## Codegen интеграция
- **Автоматическая генерация типов**: TypeScript типы генерируются из GraphQL схемы
- **Файл конфигурации**: `codegen.ts` с настройками для client-side генерации
- **Структура проекта**: Разделение на queries, mutations и index файлы в `panel/graphql/generated/`
- **Type safety**: Строгая типизация для всех GraphQL операций в админ-панели
- **Developer Experience**: Автокомплит и проверка типов в IDE
## 🔍 Семантическая поисковая система
- **Настоящие векторные эмбединги**: Использование SentenceTransformers вместо псевдослучайных чисел
- **Многоязычная поддержка**: Модель `paraphrase-multilingual-MiniLM-L12-v2` с поддержкой русского языка
- **Семантическое понимание**: Поиск по смыслу, а не только по ключевым словам
- **Оптимизированная индексация**:
- **Batch обработка**: Массовая индексация документов за один вызов
- **Тихий режим**: Отключение детального логирования при больших объёмах
- **FDE сжатие**: Компрессия векторов для экономии памяти
- **Высокая производительность**: Косинусное сходство для точного ранжирования результатов
- **GraphQL интеграция**:
- `load_shouts_search` - поиск по публикациям
- `load_authors_search` - поиск по авторам
- **Асинхронная архитектура**: Неблокирующая индексация и поиск
- **Fallback модели**: Автоматическое переключение на запасную модель при ошибках
## Улучшенная система кеширования топиков
- **Централизованная функция**: `invalidate_topic_followers_cache()` в модуле cache
- **Комплексная инвалидация**: Обработка кешей как самого топика, так и всех его подписчиков
- **Правильная последовательность**: Получение подписчиков ДО удаления данных из БД
- **Инвалидируемые кеши**:
- `author:follows-topics:{follower_id}` - список подписок на топики
- `author:followers:{follower_id}` - счетчики подписчиков
- `author:stat:{follower_id}` - общая статистика автора
- `topic:followers:{topic_id}` - список подписчиков топика
- **Архитектурные принципы**: Разделение ответственности, переиспользуемость, тестируемость
## Просмотры публикаций
- Интеграция с Google Analytics для отслеживания просмотров публикаций
- Подсчет уникальных пользователей и общего количества просмотров
- Автоматическое обновление статистики при запросе данных публикации
- Автоматическое обновление статистики при запросе данных публикации
## Мультидоменная авторизация
@@ -73,16 +12,7 @@
## Система кеширования
- **Redis как основное хранилище**: Кэширование, сессии, токены, временные данные
- **Полная документация схемы**: [redis-schema.md](redis-schema.md) - детальное описание всех структур данных
- **11 категорий данных**: Аутентификация, кэш сущностей, поиск, просмотры, уведомления
- **Система токенов**: Сессии, OAuth токены, токены подтверждения с TTL
- **Переменные окружения**: Централизованное хранение конфигурации в Redis
- **Кэш сущностей**: Авторы, темы, публикации с автоматической инвалидацией
- **Поисковый кэш**: Нормализованные запросы с результатами
- **Pub/Sub каналы**: Real-time уведомления и коммуникация
- **Оптимизация**: Pipeline операции, стратегии кэширования
- **Мониторинг**: Команды диагностики и решение проблем производительности
- Redis используется в качестве основного механизма кеширования
- Поддержка как синхронных, так и асинхронных функций в декораторе cache_on_arguments
- Автоматическая сериализация/десериализация данных в JSON с использованием CustomJSONEncoder
- Резервная сериализация через pickle для сложных объектов
@@ -90,6 +20,15 @@
- Настраиваемое время жизни кеша (TTL)
- Возможность ручной инвалидации кеша для конкретных функций и аргументов
## Webhooks
- Автоматическая регистрация вебхука для события user.login
- Предотвращение создания дублирующихся вебхуков
- Автоматическая очистка устаревших вебхуков
- Поддержка авторизации вебхуков через WEBHOOK_SECRET
- Обработка ошибок при операциях с вебхуками
- Динамическое определение endpoint'а на основе окружения
## CORS Configuration
- Поддерживаемые методы: GET, POST, OPTIONS
@@ -106,109 +45,4 @@
- Использование поля `stat.comments_count` для отображения количества ответов на комментарий
- Добавление специального поля `first_replies` для хранения первых ответов на комментарий
- Поддержка различных методов сортировки (новые, старые, популярные)
- Оптимизированные SQL запросы для минимизации нагрузки на базу данных
## Модульная система авторизации
- **Специализированные менеджеры токенов**:
- `SessionTokenManager`: Управление пользовательскими сессиями
- `VerificationTokenManager`: Токены для подтверждения email, телефона, смены пароля
- `OAuthTokenManager`: Управление OAuth токенами для внешних провайдеров
## Авторизация с cookies
- **getSession без токена**: Мутация `getSession` теперь работает с httpOnly cookies даже без заголовка Authorization
- **Dual-авторизация**: Поддержка как токенов в заголовках, так и cookies для максимальной совместимости
- **Автоматические cookies**: Middleware автоматически устанавливает httpOnly cookies при успешной авторизации
- **Безопасность**: Использование httpOnly, secure и samesite cookies для защиты от XSS и CSRF атак
- **Сессии без перелогина**: Пользователи остаются авторизованными между сессиями браузера
## DRY архитектура авторизации
- **Централизованные функции**: Все функции для работы с токенами и авторизацией находятся в `auth/utils.py`
- **Устранение дублирования**: Единая логика проверки авторизации используется во всех модулях
- **Единообразная обработка**: Стандартизированный подход к извлечению токенов из cookies и заголовков
- **Улучшенная тестируемость**: Мокирование централизованных функций упрощает тестирование
- **Легкость поддержки**: Изменения в логике авторизации требуют правки только в одном месте
## E2E тестирование с Playwright
- **Автоматизация браузера**: Полноценное тестирование пользовательского интерфейса админ-панели
- **CI/CD совместимость**: Автоматическое переключение между headed/headless режимами
- **Переменная окружения**: `PLAYWRIGHT_HEADLESS=true` для CI/CD, `false` для локальной разработки
- **Browser тесты**: Тестирование удаления сообществ, авторизации, управления контентом
- **Автоматическая установка**: Браузеры устанавливаются автоматически в CI/CD окружении
- **Кроссплатформенность**: Работает в Ubuntu, macOS и Windows окружениях
- `BatchTokenOperations`: Пакетные операции с токенами
- `TokenMonitoring`: Мониторинг и статистика использования токенов
- **Улучшенная производительность**:
- 50% ускорение Redis операций через пайплайны
- 30% снижение потребления памяти
- Оптимизированные запросы к базе данных
- **Безопасность**:
- Поддержка PKCE для всех OAuth провайдеров
- Автоматическая очистка истекших токенов
- Защита от replay-атак
## OAuth интеграция
- **7 поддерживаемых провайдеров**:
- Google, GitHub, Facebook
- X (Twitter), Telegram
- VK (ВКонтакте), Yandex
- **Обработка провайдеров без email**:
- Генерация временных email для X и Telegram
- Возможность обновления email в профиле
- **Токены в Redis**:
- Хранение access и refresh токенов с TTL
- Автоматическое обновление токенов
- Централизованное управление через Redis
- **Безопасность**:
- PKCE для всех OAuth потоков
- Временные state параметры в Redis (10 минут TTL)
- Одноразовые сессии
- Логирование неудачных попыток аутентификации
## Система управления паролями и email
- **Мутация updateSecurity**:
- Смена пароля с валидацией сложности
- Смена email с двухэтапным подтверждением
- Одновременная смена пароля и email
- **Токены подтверждения в Redis**:
- Автоматический TTL для всех токенов
- Безопасное хранение данных подтверждения
- **Дополнительные мутации**:
- confirmEmailChange
- cancelEmailChange
## Система featured публикаций
- **Автоматическое получение статуса featured**:
- Публикация получает статус featured при более чем 4 лайках от авторов с featured статьями
- Проверка квалификации автора: наличие опубликованных featured статей
- Логирование процесса для отладки и мониторинга
- **Условия удаления с главной (unfeatured)**:
- **Условие 1**: Менее 5 голосов "за" (положительные реакции)
- **Условие 2**: 20% или более отрицательных реакций от общего количества голосов
- Проверка выполняется только для уже featured публикаций
- **Оптимизированная логика обработки**:
- Проверка unfeatured имеет приоритет над featured при обработке реакций
- Автоматическая проверка условий при добавлении/удалении реакций
- Корректная обработка типов данных в функциях проверки
- **Интеграция с системой реакций**:
- Обработка в `create_reaction` для новых реакций
- Обработка в `delete_reaction` для удаленных реакций
- Учет только реакций на саму публикацию (не на комментарии)
## RBAC
- **Наследование разрешений между ролями** происходит только при инициализации прав для сообщества. В Redis хранятся уже развернутые (полные) списки разрешений для каждой роли. Проверка прав — это быстрый lookup без on-the-fly наследования.
## Core features
- RBAC с иерархией ролей, наследование только при инициализации, быстрый доступ к правам через Redis
## Changelog
- v0.6.11: RBAC — наследование только при инициализации, ускорение, упрощение кода, исправлены тесты
- Оптимизированные SQL запросы для минимизации нагрузки на базу данных

View File

@@ -37,12 +37,10 @@ Unfollow an entity.
**Returns:** Same as `follow`
**Important:** Always returns current following list even if the subscription was not found, ensuring UI consistency.
### Queries
#### get_shout_followers
Get list of authors who reacted to a shout.
Get list of users who reacted to a shout.
**Parameters:**
- `slug: String` - Shout slug
@@ -64,126 +62,9 @@ Author[] // List of authors who reacted
### Cache Flow
1. On follow/unfollow:
- Update entity in cache
- **Invalidate user's following list cache** (NEW)
- Update follower's following list
2. Cache is updated before notifications
### Cache Invalidation (NEW)
Following cache keys are invalidated after operations:
- `author:follows-topics:{user_id}` - After topic follow/unfollow
- `author:follows-authors:{user_id}` - After author follow/unfollow
This ensures fresh data is fetched from database on next request.
## Error Handling
### Enhanced Error Handling (UPDATED)
- UnauthorizedError access check
- Entity existence validation
- Duplicate follow prevention
- **Graceful handling of "following not found" errors**
- **Always returns current following list, even on errors**
- Full error logging
- Transaction safety with `local_session()`
### Error Response Format
```typescript
{
error?: "following was not found" | "invalid unfollow type" | "access denied",
topics?: Topic[], // Always present for topic operations
authors?: Author[], // Always present for author operations
// ... other entity types
}
```
## Recent Fixes (NEW)
### Issue 1: Stale UI State on Unfollow Errors
**Problem:** When unfollow operation failed with "following was not found", the client didn't update its state because it only processed successful responses.
**Root Cause:**
1. `unfollow` mutation returned error with empty follows list `[]`
2. Client logic: `if (result && !result.error)` prevented state updates on errors
3. User remained "subscribed" in UI despite no actual subscription in database
**Solution:**
1. **Always fetch current following list** from cache/database
2. **Return actual following state** even when subscription not found
3. **Add cache invalidation** after successful operations
4. **Enhanced logging** for debugging
### Issue 2: Inconsistent Behavior in Follow Operations (NEW)
**Problem:** The `follow` function had similar issues to `unfollow`:
- Could return `None` instead of actual following list in error scenarios
- Cache was not invalidated when trying to follow already-followed entities
- Inconsistent error handling between follow/unfollow operations
**Root Cause:**
1. `follow` mutation could return `{topics: null}` when `get_cached_follows_method` was not available
2. When user was already following an entity, cache invalidation was skipped
3. Error responses didn't include current following state
**Solution:**
1. **Always return actual following list** from cache/database
2. **Invalidate cache on every operation** (both new and existing subscriptions)
3. **Add "already following" error** while still returning current state
4. **Unified error handling** consistent with unfollow
### Code Changes
```python
# UNFOLLOW - Before (BROKEN)
if sub:
# ... process unfollow
else:
return {"error": "following was not found", f"{entity_type}s": follows} # follows was []
# UNFOLLOW - After (FIXED)
if sub:
# ... process unfollow
# Invalidate cache
await redis.execute("DEL", f"author:follows-{entity_type}s:{follower_id}")
else:
error = "following was not found"
# Always get current state
existing_follows = await get_cached_follows_method(follower_id)
return {f"{entity_type}s": existing_follows, "error": error}
# FOLLOW - Before (BROKEN)
if existing_sub:
logger.info(f"User already following...")
# Cache not invalidated, could return stale data
else:
# ... create subscription
# Cache invalidated only here
follows = None # Could be None!
# ... complex logic to build follows list
return {f"{entity_type}s": follows} # follows could be None
# FOLLOW - After (FIXED)
if existing_sub:
error = "already following"
else:
# ... create subscription
# Always invalidate cache and get current state
await redis.execute("DEL", f"author:follows-{entity_type}s:{follower_id}")
existing_follows = await get_cached_follows_method(follower_id)
return {f"{entity_type}s": existing_follows, "error": error}
```
### Impact
**Before fixes:**
- UI could show incorrect subscription state
- Cache inconsistencies between follow/unfollow operations
- Client-side logic `if (result && !result.error)` failed on valid error states
**After fixes:**
-**UI always receives current subscription state**
-**Consistent cache invalidation** on all operations
-**Unified error handling** between follow/unfollow
-**Client can safely update UI** even on error responses
## Notifications
- Sent when author is followed/unfollowed
@@ -192,6 +73,14 @@ return {f"{entity_type}s": existing_follows, "error": error}
- Author ID
- Action type ("follow"/"unfollow")
## Error Handling
- Unauthorized access check
- Entity existence validation
- Duplicate follow prevention
- Full error logging
- Transaction safety with `local_session()`
## Database Schema
### Follower Tables
@@ -202,18 +91,4 @@ return {f"{entity_type}s": existing_follows, "error": error}
Each table contains:
- `follower` - ID of following user
- `{entity_type}` - ID of followed entity
## Testing
Run the test script to verify fixes:
```bash
python test_unfollow_fix.py
```
### Test Coverage
- ✅ Unfollow existing subscription
- ✅ Unfollow non-existent subscription
- ✅ Cache invalidation
- ✅ Proper error handling
- ✅ UI state consistency
- `{entity_type}` - ID of followed entity

View File

@@ -77,4 +77,4 @@
- Проверка прав доступа
- Фильтрация удаленного контента
- Защита от SQL-инъекций
- Валидация входных данных
- Валидация входных данных

View File

@@ -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
```
Конфигурация автоматически пересоберется при деплое.

View File

@@ -52,7 +52,7 @@ Rate another author (karma system).
- Excludes deleted reactions
- Excludes comment reactions
#### Comments Rating
#### Comments Rating
- Calculated from LIKE/DISLIKE reactions on author's comments
- Each LIKE: +1
- Each DISLIKE: -1
@@ -79,4 +79,4 @@ Rate another author (karma system).
- All ratings exclude deleted content
- Reactions are unique per user/content
- Rating calculations are optimized with SQLAlchemy
- System supports both direct author rating and content-based rating
- System supports both direct author rating and content-based rating

View File

@@ -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` работают без изменений
- Существующие тесты проходят без модификации

View File

@@ -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, сохраняя существующую функциональность и улучшая производительность.

View File

@@ -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
- Использование отдельных баз данных для разных типов данных

View File

@@ -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) - Оптимизация производительности

View File

@@ -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`

View File

@@ -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
View File

@@ -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;
}

View File

@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Admin Panel">
<title>Admin Panel</title>
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<meta name="theme-color" content="#228be6">
</head>
<body>
<div id="root"></div>
<script type="module" src="/panel/index.tsx"></script>
<noscript>
<div style="text-align: center; padding: 20px;">
Для работы приложения необходим JavaScript
</div>
</noscript>
</body>
</html>

365
main.py
View File

@@ -1,337 +1,138 @@
import asyncio
import os
import traceback
from contextlib import asynccontextmanager
import sys
from importlib import import_module
from pathlib import Path
from os.path import exists
from ariadne import load_schema_from_path, make_executable_schema
from ariadne.asgi import GraphQL
from graphql import GraphQLError
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request
from starlette.responses import FileResponse, JSONResponse, Response
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from starlette.responses import JSONResponse, Response
from starlette.routing import Route
from auth.handler import EnhancedGraphQLHTTPHandler
from auth.middleware import AuthMiddleware, auth_middleware
from auth.oauth import oauth_callback_http, oauth_login_http
from cache.precache import precache_data
from cache.revalidator import revalidation_manager
from rbac import initialize_rbac
from services.search import check_search_service, initialize_search_index, search_service
from services.exception import ExceptionHandlerMiddleware
from services.redis import redis
from services.schema import create_all_tables, resolvers
#from services.search import search_service
from services.search import search_service, initialize_search_index
from services.viewed import ViewedStorage
from settings import DEV_SERVER_PID_FILE_NAME
from storage.redis import redis
from storage.schema import create_all_tables, resolvers
from utils.exception import ExceptionHandlerMiddleware
from utils.logger import custom_error_formatter
from utils.logger import root_logger as logger
from utils.sentry import start_sentry
DEVMODE = os.getenv("DOKKU_APP_TYPE", "false").lower() == "false"
DIST_DIR = Path(__file__).parent / "dist" # Директория для собранных файлов
INDEX_HTML = Path(__file__).parent / "index.html"
from services.webhook import WebhookEndpoint, create_webhook_endpoint
from settings import DEV_SERVER_PID_FILE_NAME, MODE
import_module("resolvers")
schema = make_executable_schema(load_schema_from_path("schema/"), resolvers)
# Создаем middleware с правильным порядком
middleware = [
# Начинаем с обработки ошибок
Middleware(ExceptionHandlerMiddleware),
# CORS должен быть перед другими middleware для корректной обработки preflight-запросов
Middleware(
CORSMiddleware,
allow_origins=[
"https://testing.discours.io",
"https://testing3.discours.io",
"https://v3.discours.io",
"https://session-daily.vercel.app",
"https://coretest.discours.io",
"https://new.discours.io",
"https://localhost:3000",
],
allow_methods=["GET", "POST", "OPTIONS"], # Явно указываем OPTIONS
allow_headers=["*"],
allow_credentials=True,
),
# Аутентификация должна быть после CORS
Middleware(AuthMiddleware),
]
# Создаем экземпляр GraphQL с улучшенным обработчиком и кастомным форматтером ошибок
graphql_app = GraphQL(
schema,
debug=DEVMODE,
http_handler=EnhancedGraphQLHTTPHandler(),
error_formatter=custom_error_formatter,
)
async def start():
if MODE == "development":
if not exists(DEV_SERVER_PID_FILE_NAME):
# pid file management
with open(DEV_SERVER_PID_FILE_NAME, "w", encoding="utf-8") as f:
f.write(str(os.getpid()))
print(f"[main] process started in {MODE} mode")
async def check_search_service():
"""Check if search service is available and log result"""
info = await search_service.info()
if info.get("status") in ["error", "unavailable"]:
print(f"[WARNING] Search service unavailable: {info.get('message', 'unknown reason')}")
else:
print(f"[INFO] Search service is available: {info}")
# Оборачиваем GraphQL-обработчик для лучшей обработки ошибок
async def graphql_handler(request: Request) -> Response:
"""
Обработчик GraphQL запросов с поддержкой middleware и обработкой ошибок.
Выполняет:
1. Проверку метода запроса (GET, POST, OPTIONS)
2. Обработку GraphQL запроса через ariadne
3. Применение middleware для корректной обработки cookie и авторизации
4. Обработку исключений и формирование ответа
Args:
request: Starlette Request объект
Returns:
Response: объект ответа (обычно JSONResponse)
"""
if request.method not in ["GET", "POST", "OPTIONS"]:
return JSONResponse({"error": "Method Not Allowed by main.py"}, status_code=405)
# Проверяем, что все необходимые middleware корректно отработали
if not hasattr(request, "scope") or "auth" not in request.scope:
logger.warning("[graphql] AuthMiddleware не обработал запрос перед GraphQL обработчиком")
try:
# Обрабатываем запрос через GraphQL приложение
result = await graphql_app.handle_request(request)
# Применяем middleware для установки cookie
# Используем метод process_result из auth_middleware для корректной обработки
# cookie на основе результатов операций login/logout
return await auth_middleware.process_result(request, result)
except asyncio.CancelledError:
return JSONResponse({"error": "Request cancelled"}, status_code=499)
except GraphQLError as e:
# Для GraphQL ошибок (например, неавторизованный доступ) не логируем полный трейс
logger.warning(f"GraphQL error: {e}")
return JSONResponse({"error": str(e)}, status_code=403)
except Exception as e:
logger.error(f"Unexpected GraphQL error: {e!s}")
logger.debug(f"Unexpected GraphQL error traceback: {traceback.format_exc()}")
return JSONResponse({"error": "Internal server error"}, status_code=500)
async def spa_handler(request: Request) -> Response:
"""
Обработчик для SPA (Single Page Application) fallback.
Возвращает index.html для всех маршрутов, которые не найдены,
чтобы клиентский роутер (SolidJS) мог обработать маршрутизацию.
Args:
request: Starlette Request объект
Returns:
FileResponse: ответ с содержимым index.html
"""
# Исключаем API маршруты из SPA fallback
path = request.url.path
if path.startswith(("/graphql", "/oauth", "/assets")):
return JSONResponse({"error": "Not found"}, status_code=404)
index_path = DIST_DIR / "index.html"
if index_path.exists():
return FileResponse(index_path, media_type="text/html")
return JSONResponse({"error": "Admin panel not built"}, status_code=404)
async def health_handler(request: Request) -> Response:
"""Health check endpoint with Redis monitoring"""
try:
redis_info = await redis.get_info()
return JSONResponse(
{"status": "healthy", "redis": {"connected": redis.is_connected, "ping": await redis.ping(), **redis_info}}
)
except Exception as e:
logger.error(f"Health check failed: {e}")
return JSONResponse({"status": "unhealthy", "error": str(e)}, status_code=500)
async def shutdown() -> None:
"""Остановка сервера и освобождение ресурсов"""
logger.info("Остановка сервера")
# Закрываем соединение с Redis
await redis.disconnect()
# Останавливаем поисковый сервис
await search_service.close()
pid_file = Path(DEV_SERVER_PID_FILE_NAME)
if pid_file.exists():
pid_file.unlink()
async def dev_start() -> None:
"""
Инициализация сервера в DEV режиме.
Функция:
1. Проверяет наличие DEV режима
2. Создает PID-файл для отслеживания процесса
3. Логирует информацию о старте сервера
Используется только при запуске сервера с флагом "dev".
"""
try:
pid_path = Path(DEV_SERVER_PID_FILE_NAME)
# Если PID-файл уже существует, проверяем, не запущен ли уже сервер с этим PID
if pid_path.exists():
try:
with pid_path.open(encoding="utf-8") as f:
old_pid = int(f.read().strip())
# Проверяем, существует ли процесс с таким PID
try:
os.kill(old_pid, 0) # Сигнал 0 только проверяет существование процесса
print(f"[warning] DEV server already running with PID {old_pid}")
except OSError:
print(f"[info] Stale PID file found, previous process {old_pid} not running")
except (ValueError, FileNotFoundError):
print("[warning] Invalid PID file found, recreating")
# Создаем или перезаписываем PID-файл
with pid_path.open("w", encoding="utf-8") as f:
f.write(str(os.getpid()))
print(f"[main] process started in DEV mode with PID {os.getpid()}")
except Exception as e:
logger.error(f"[main] Error during server startup: {e!s}")
# Не прерываем запуск сервера из-за ошибки в этой функции
print(f"[warning] Error during DEV mode initialization: {e!s}")
async def initialize_search_index_with_data() -> None:
"""Инициализация поискового индекса данными из БД"""
try:
from orm.shout import Shout
from storage.db import local_session
# Получаем все опубликованные шауты из БД
with local_session() as session:
shouts = session.query(Shout).filter(Shout.published_at.is_not(None)).all()
if shouts:
await initialize_search_index(shouts)
print(f"[search] Loaded {len(shouts)} published shouts into search index")
else:
print("[search] No published shouts found to index")
except Exception as e:
logger.error(f"Failed to initialize search index with data: {e}")
# Глобальная переменная для background tasks
background_tasks: list[asyncio.Task] = []
@asynccontextmanager
async def lifespan(app: Starlette):
"""
Функция жизненного цикла приложения.
Обеспечивает:
1. Инициализацию всех необходимых сервисов и компонентов
2. Предзагрузку кеша данных
3. Подключение к Redis и поисковому сервису
4. Корректное завершение работы при остановке сервера
Args:
app: экземпляр Starlette приложения
Yields:
None: генератор для управления жизненным циклом
"""
# indexing DB data
# async def indexing():
# from services.db import fetch_all_shouts
# all_shouts = await fetch_all_shouts()
# await initialize_search_index(all_shouts)
async def lifespan(_app):
try:
print("[lifespan] Starting application initialization")
create_all_tables()
# Инициализируем RBAC систему с dependency injection
initialize_rbac()
# Инициализируем Sentry для мониторинга ошибок
start_sentry()
await asyncio.gather(
redis.connect(),
precache_data(),
ViewedStorage.init(),
create_webhook_endpoint(),
check_search_service(),
start(),
revalidation_manager.start(),
)
if DEVMODE:
await dev_start()
print("[lifespan] Basic initialization complete")
# Инициализируем поисковый индекс данными из БД
print("[lifespan] Initializing search index with existing data...")
await initialize_search_index_with_data()
print("[lifespan] Search service initialized with Muvera")
# Add a delay before starting the intensive search indexing
print("[lifespan] Waiting for system stabilization before search indexing...")
await asyncio.sleep(10) # 10-second delay to let the system stabilize
# NOTE: Предзагрузка моделей убрана - ColBERT загружается lazy при первом поиске
# BiEncoder модели больше не используются (default=colbert)
# Start search indexing as a background task with lower priority
asyncio.create_task(initialize_search_index_background())
yield
finally:
print("[lifespan] Shutting down application services")
# Отменяем все background tasks
for task in background_tasks:
if not task.done():
task.cancel()
# Ждем завершения отмены tasks
if background_tasks:
await asyncio.gather(*background_tasks, return_exceptions=True)
tasks = [redis.disconnect(), ViewedStorage.stop(), revalidation_manager.stop()]
await asyncio.gather(*tasks, return_exceptions=True)
print("[lifespan] Shutdown complete")
# Initialize search index in the background
async def initialize_search_index_background():
"""Run search indexing as a background task with low priority"""
try:
print("[search] Starting background search indexing process")
from services.db import fetch_all_shouts
# Get total count first (optional)
all_shouts = await fetch_all_shouts()
total_count = len(all_shouts) if all_shouts else 0
print(f"[search] Fetched {total_count} shouts for background indexing")
# Start the indexing process with the fetched shouts
print("[search] Beginning background search index initialization...")
await initialize_search_index(all_shouts)
print("[search] Background search index initialization complete")
except Exception as e:
print(f"[search] Error in background search indexing: {str(e)}")
# Создаем экземпляр GraphQL
graphql_app = GraphQL(schema, debug=True)
# Оборачиваем GraphQL-обработчик для лучшей обработки ошибок
async def graphql_handler(request: Request):
if request.method not in ["GET", "POST"]:
return JSONResponse({"error": "Method Not Allowed"}, status_code=405)
try:
result = await graphql_app.handle_request(request)
if isinstance(result, Response):
return result
return JSONResponse(result)
except asyncio.CancelledError:
return JSONResponse({"error": "Request cancelled"}, status_code=499)
except Exception as e:
print(f"GraphQL error: {str(e)}")
return JSONResponse({"error": str(e)}, status_code=500)
# Обновляем маршрут в Starlette
app = Starlette(
routes=[
Route("/graphql", graphql_handler, methods=["GET", "POST", "OPTIONS"]),
# OAuth маршруты - порядок важен! Более специфичные маршруты должны быть первыми
Route("/oauth/{provider}/callback", oauth_callback_http, methods=["GET"]),
Route(
"/oauth/{provider}/{redirect_uri:path}", oauth_login_http, methods=["GET"]
), # Поддержка старого формата фронтенда
Route("/oauth/{provider}", oauth_login_http, methods=["GET"]),
# Health check endpoint
Route("/health", health_handler, methods=["GET"]),
# Статические файлы (CSS, JS, изображения)
Mount("/assets", app=StaticFiles(directory=str(DIST_DIR / "assets"))),
# Корневой маршрут для админ-панели
Route("/", spa_handler, methods=["GET"]),
# SPA fallback для всех остальных маршрутов
Route("/{path:path}", spa_handler, methods=["GET"]),
Route("/", graphql_handler, methods=["GET", "POST"]),
Route("/new-author", WebhookEndpoint),
],
middleware=middleware, # Используем единый список middleware
lifespan=lifespan,
debug=True,
)
if DEVMODE:
# Для DEV режима регистрируем дополнительный CORS middleware только для localhost
app.add_middleware(ExceptionHandlerMiddleware)
if "dev" in sys.argv:
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://localhost:3000",
"https://localhost:3001",
"https://localhost:3002",
"http://localhost:3000",
"http://localhost:3001",
"http://localhost:3002",
],
allow_origins=["https://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],

View File

@@ -1,93 +0,0 @@
[mypy]
# Основные настройки
python_version = 3.12
warn_return_any = False
warn_unused_configs = True
disallow_untyped_defs = False
disallow_incomplete_defs = False
no_implicit_optional = False
explicit_package_bases = True
namespace_packages = True
check_untyped_defs = False
plugins = sqlalchemy.ext.mypy.plugin
# Игнорируем missing imports для внешних библиотек
ignore_missing_imports = True
# Оптимизации производительности
cache_dir = .mypy_cache
sqlite_cache = True
incremental = False
show_error_codes = True
# Исключаем тесты и тяжелые зависимости
exclude = ^(tests/.*|.*transformers.*|.*torch.*|.*huggingface.*|.*safetensors.*|.*PIL.*|.*google.*|.*sentence_transformers.*|.*dump/.*|.*node_modules/.*|.*dist/.*)$
# Настройки для конкретных модулей
[mypy-graphql.*]
ignore_missing_imports = True
[mypy-ariadne.*]
ignore_missing_imports = True
[mypy-starlette.*]
ignore_missing_imports = True
[mypy-orjson.*]
ignore_missing_imports = True
[mypy-pytest.*]
ignore_missing_imports = True
[mypy-pydantic.*]
ignore_missing_imports = True
[mypy-granian.*]
ignore_missing_imports = True
[mypy-jwt.*]
ignore_missing_imports = True
[mypy-httpx.*]
ignore_missing_imports = True
[mypy-trafilatura.*]
ignore_missing_imports = True
[mypy-sentry_sdk.*]
ignore_missing_imports = True
[mypy-colorlog.*]
ignore_missing_imports = True
[mypy-google.*]
ignore_missing_imports = True
[mypy-txtai.*]
ignore_missing_imports = True
[mypy-h11.*]
ignore_missing_imports = True
[mypy-hiredis.*]
ignore_missing_imports = True
[mypy-htmldate.*]
ignore_missing_imports = True
[mypy-httpcore.*]
ignore_missing_imports = True
[mypy-courlan.*]
ignore_missing_imports = True
[mypy-certifi.*]
ignore_missing_imports = True
[mypy-charset_normalizer.*]
ignore_missing_imports = True
[mypy-anyio.*]
ignore_missing_imports = True
[mypy-sniffio.*]
ignore_missing_imports = True

147
nginx.conf.sigil Normal file
View File

@@ -0,0 +1,147 @@
log_format custom '$remote_addr - $remote_user [$time_local] "$request" '
'origin=$http_origin allow_origin=$allow_origin status=$status '
'"$http_referer" "$http_user_agent"';
{{ $proxy_settings := "proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $http_connection; proxy_set_header Host $http_host; proxy_set_header X-Request-Start $msec;" }}
{{ $gzip_settings := "gzip on; gzip_min_length 1100; gzip_buffers 4 32k; gzip_types text/css text/javascript text/xml text/plain text/x-component application/javascript application/x-javascript application/json application/xml application/rss+xml font/truetype application/x-font-ttf font/opentype application/vnd.ms-fontobject image/svg+xml; gzip_vary on; gzip_comp_level 6;" }}
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=my_cache:10m max_size=1g
inactive=60m use_temp_path=off;
limit_conn_zone $binary_remote_addr zone=addr:10m;
limit_req_zone $binary_remote_addr zone=req_zone:10m rate=20r/s;
{{ range $port_map := .PROXY_PORT_MAP | split " " }}
{{ $port_map_list := $port_map | split ":" }}
{{ $scheme := index $port_map_list 0 }}
{{ $listen_port := index $port_map_list 1 }}
{{ $upstream_port := index $port_map_list 2 }}
server {
{{ if eq $scheme "http" }}
listen [::]:{{ $listen_port }};
listen {{ $listen_port }};
server_name {{ $.NOSSL_SERVER_NAME }};
access_log /var/log/nginx/{{ $.APP }}-access.log custom;
error_log /var/log/nginx/{{ $.APP }}-error.log;
client_max_body_size 100M;
{{ else if eq $scheme "https" }}
listen [::]:{{ $listen_port }} ssl http2;
listen {{ $listen_port }} ssl http2;
server_name {{ $.NOSSL_SERVER_NAME }};
access_log /var/log/nginx/{{ $.APP }}-access.log custom;
error_log /var/log/nginx/{{ $.APP }}-error.log;
ssl_certificate {{ $.APP_SSL_PATH }}/server.crt;
ssl_certificate_key {{ $.APP_SSL_PATH }}/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
keepalive_timeout 70;
keepalive_requests 500;
proxy_read_timeout 3600;
limit_conn addr 10000;
client_max_body_size 100M;
{{ end }}
location / {
proxy_pass http://{{ $.APP }}-{{ $upstream_port }};
{{ $proxy_settings }}
{{ $gzip_settings }}
# Handle CORS for OPTIONS method
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $allow_origin always;
add_header 'Access-Control-Allow-Methods' 'POST, GET, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
# Handle CORS for POST method
if ($request_method = 'POST') {
add_header 'Access-Control-Allow-Origin' $allow_origin always;
add_header 'Access-Control-Allow-Methods' 'POST, GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
}
# Handle CORS for GET method
if ($request_method = 'GET') {
add_header 'Access-Control-Allow-Origin' $allow_origin always;
add_header 'Access-Control-Allow-Methods' 'POST, GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
}
proxy_cache my_cache;
proxy_cache_revalidate on;
proxy_cache_min_uses 2;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
# Connections and request limits increase (bad for DDos)
limit_req zone=req_zone burst=10 nodelay;
}
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
proxy_pass http://{{ $.APP }}-{{ $upstream_port }};
expires 30d;
add_header Cache-Control "public, no-transform";
}
location ~* \.(mp3|wav|ogg|flac|aac|aif|webm)$ {
proxy_pass http://{{ $.APP }}-{{ $upstream_port }};
if ($request_method = 'GET') {
add_header 'Access-Control-Allow-Origin' $allow_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
}
}
error_page 400 401 402 403 405 406 407 408 409 410 411 412 413 414 415 416 417 418 420 422 423 424 426 428 429 431 444 449 450 451 /400-error.html;
location /400-error.html {
root /var/lib/dokku/data/nginx-vhosts/dokku-errors;
internal;
}
error_page 404 /404-error.html;
location /404-error.html {
root /var/lib/dokku/data/nginx-vhosts/dokku-errors;
internal;
}
error_page 500 501 503 504 505 506 507 508 509 510 511 /500-error.html;
location /500-error.html {
root /var/lib/dokku/data/nginx-vhosts/dokku-errors;
internal;
}
error_page 502 /502-error.html;
location /502-error.html {
root /var/lib/dokku/data/nginx-vhosts/dokku-errors;
internal;
}
include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf;
}
{{ end }}
{{ range $upstream_port := $.PROXY_UPSTREAM_PORTS | split " " }}
upstream {{ $.APP }}-{{ $upstream_port }} {
{{ range $listeners := $.DOKKU_APP_WEB_LISTENERS | split " " }}
{{ $listener_list := $listeners | split ":" }}
{{ $listener_ip := index $listener_list 0 }}
{{ $listener_port := index $listener_list 1 }}
server {{ $listener_ip }}:{{ $upstream_port }};
{{ end }}
}
{{ end }}

View File

@@ -1,63 +0,0 @@
# ORM Models
# Re-export models for convenience
from orm.author import Author, AuthorBookmark, AuthorFollower, AuthorRating
from . import (
collection,
community,
draft,
invite,
notification,
rating,
reaction,
shout,
topic,
)
from .collection import Collection, ShoutCollection
from .community import Community, CommunityFollower
from .draft import Draft, DraftAuthor, DraftTopic
from .invite import Invite
from .notification import Notification, NotificationSeen
# from .rating import Rating # rating.py содержит только константы, не классы
from .reaction import REACTION_KINDS, Reaction, ReactionKind
from .shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic
from .topic import Topic, TopicFollower
__all__ = [
# "Rating", # rating.py содержит только константы, не классы
"REACTION_KINDS",
# Models
"Author",
"AuthorBookmark",
"AuthorFollower",
"AuthorRating",
"Collection",
"Community",
"CommunityFollower",
"Draft",
"DraftAuthor",
"DraftTopic",
"Invite",
"Notification",
"NotificationSeen",
"Reaction",
"ReactionKind",
"Shout",
"ShoutAuthor",
"ShoutCollection",
"ShoutReactionsFollower",
"ShoutTopic",
"Topic",
"TopicFollower",
# Modules
"collection",
"community",
"draft",
"invite",
"notification",
"rating",
"reaction",
"shout",
"topic",
]

View File

@@ -1,302 +1,137 @@
import time
from typing import Any, Dict, Optional
from sqlalchemy import (
JSON,
Boolean,
ForeignKey,
Index,
Integer,
PrimaryKeyConstraint,
String,
)
from sqlalchemy.orm import Mapped, Session, mapped_column
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
from orm.base import BaseModel as Base
from utils.password import Password
from services.db import Base
# Общие table_args для всех моделей
DEFAULT_TABLE_ARGS = {"extend_existing": True}
PROTECTED_FIELDS = ["email", "password", "provider_access_token", "provider_refresh_token"]
class Author(Base):
"""
Расширенная модель автора с функциями аутентификации и авторизации
"""
__tablename__ = "author"
__table_args__ = (
Index("idx_author_slug", "slug"),
Index("idx_author_email", "email"),
Index("idx_author_phone", "phone"),
{"extend_existing": True},
)
# Базовые поля автора
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False, comment="Display name")
slug: Mapped[str] = mapped_column(String, unique=True, comment="Author's slug")
bio: Mapped[str | None] = mapped_column(String, nullable=True, comment="Bio") # короткое описание
about: Mapped[str | None] = mapped_column(
String, nullable=True, comment="About"
) # длинное форматированное описание
pic: Mapped[str | None] = mapped_column(String, nullable=True, comment="Picture")
links: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True, comment="Links")
# OAuth аккаунты - JSON с данными всех провайдеров
# Формат: {"google": {"id": "123", "email": "user@gmail.com"}, "github": {"id": "456"}}
oauth: Mapped[dict[str, Any] | None] = mapped_column(
JSON, nullable=True, default=dict, comment="OAuth accounts data"
)
# Поля аутентификации
email: Mapped[str | None] = mapped_column(String, unique=True, nullable=True, comment="Email")
phone: Mapped[str | None] = mapped_column(String, nullable=True, comment="Phone")
password: Mapped[str | None] = mapped_column(String, nullable=True, comment="Password hash")
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
phone_verified: Mapped[bool] = mapped_column(Boolean, default=False)
failed_login_attempts: Mapped[int] = mapped_column(Integer, default=0)
account_locked_until: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Временные метки
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
last_seen: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
deleted_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
oid: Mapped[str | None] = mapped_column(String, nullable=True)
@property
def protected_fields(self) -> list[str]:
return PROTECTED_FIELDS
@property
def is_authenticated(self) -> bool:
"""Проверяет, аутентифицирован ли пользователь"""
return self.id is not None
def verify_password(self, password: str) -> bool:
"""Проверяет пароль пользователя"""
return Password.verify(password, str(self.password)) if self.password else False
def set_password(self, password: str) -> None:
"""Устанавливает пароль пользователя"""
self.password = Password.encode(password)
def increment_failed_login(self) -> None:
"""Увеличивает счетчик неудачных попыток входа"""
self.failed_login_attempts += 1
if self.failed_login_attempts >= 5:
self.account_locked_until = int(time.time()) + 300 # 5 минут
def reset_failed_login(self) -> None:
"""Сбрасывает счетчик неудачных попыток входа"""
self.failed_login_attempts = 0
self.account_locked_until = None
def is_locked(self) -> bool:
"""Проверяет, заблокирован ли аккаунт"""
if not self.account_locked_until:
return False
return int(time.time()) < self.account_locked_until
def dict(self, access: bool = False) -> Dict[str, Any]:
"""
Сериализует объект автора в словарь.
Args:
access: Если True, включает защищенные поля
Returns:
Dict: Словарь с данными автора
"""
result: Dict[str, Any] = {
"id": self.id,
"name": self.name,
"slug": self.slug,
"bio": self.bio,
"about": self.about,
"pic": self.pic,
"links": self.links,
"created_at": self.created_at,
"updated_at": self.updated_at,
"last_seen": self.last_seen,
"deleted_at": self.deleted_at,
"email_verified": self.email_verified,
}
# Добавляем защищенные поля только если запрошен полный доступ
if access:
result.update({"email": self.email, "phone": self.phone, "oauth": self.oauth})
return result
@classmethod
def find_by_oauth(cls, provider: str, provider_id: str, session: Session) -> Optional["Author"]:
"""
Находит автора по OAuth провайдеру и ID
Args:
provider (str): Имя OAuth провайдера (google, github и т.д.)
provider_id (str): ID пользователя у провайдера
session: Сессия базы данных
Returns:
Author или None: Найденный автор или None если не найден
"""
# Ищем авторов, у которых есть данный провайдер с данным ID
authors = session.query(cls).where(cls.oauth.isnot(None)).all()
for author in authors:
if author.oauth and provider in author.oauth:
oauth_data = author.oauth[provider]
if isinstance(oauth_data, dict) and oauth_data.get("id") == provider_id:
return author
return None
def set_oauth_account(self, provider: str, provider_id: str, email: str | None = None) -> None:
"""
Устанавливает OAuth аккаунт для автора
Args:
provider (str): Имя OAuth провайдера (google, github и т.д.)
provider_id (str): ID пользователя у провайдера
email (Optional[str]): Email от провайдера
"""
if not self.oauth:
self.oauth = {}
oauth_data: Dict[str, str] = {"id": provider_id}
if email:
oauth_data["email"] = email
self.oauth[provider] = oauth_data
def get_oauth_account(self, provider: str) -> Dict[str, Any] | None:
"""
Получает OAuth аккаунт провайдера
Args:
provider (str): Имя OAuth провайдера
Returns:
dict или None: Данные OAuth аккаунта или None если не найден
"""
oauth_data = getattr(self, "oauth", None)
if not oauth_data:
return None
if isinstance(oauth_data, dict):
return oauth_data.get(provider)
return None
def remove_oauth_account(self, provider: str):
"""
Удаляет OAuth аккаунт провайдера
Args:
provider (str): Имя OAuth провайдера
"""
if self.oauth and provider in self.oauth:
del self.oauth[provider]
def to_dict(self, include_protected: bool = False) -> Dict[str, Any]:
"""Конвертирует модель в словарь"""
result = {
"id": self.id,
"name": self.name,
"slug": self.slug,
"bio": self.bio,
"about": self.about,
"pic": self.pic,
"links": self.links,
"oauth": self.oauth,
"email_verified": self.email_verified,
"phone_verified": self.phone_verified,
"created_at": self.created_at,
"updated_at": self.updated_at,
"last_seen": self.last_seen,
"deleted_at": self.deleted_at,
"oid": self.oid,
}
if include_protected:
result.update(
{
"email": self.email,
"phone": self.phone,
"failed_login_attempts": self.failed_login_attempts,
"account_locked_until": self.account_locked_until,
}
)
return result
def __repr__(self) -> str:
return f"<Author(id={self.id}, slug='{self.slug}', email='{self.email}')>"
class AuthorFollower(Base):
"""
Связь подписки между авторами.
"""
__tablename__ = "author_follower"
__table_args__ = (
PrimaryKeyConstraint("follower", "following"),
Index("idx_author_follower_follower", "follower"),
Index("idx_author_follower_following", "following"),
{"extend_existing": True},
)
follower: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False)
following: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False)
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
def __repr__(self) -> str:
return f"<AuthorFollower(follower={self.follower}, following={self.following})>"
class AuthorBookmark(Base):
"""
Закладки автора.
"""
__tablename__ = "author_bookmark"
__table_args__ = (
PrimaryKeyConstraint("author", "shout"),
Index("idx_author_bookmark_author", "author"),
Index("idx_author_bookmark_shout", "shout"),
{"extend_existing": True},
)
author: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False)
shout: Mapped[int] = mapped_column(Integer, ForeignKey("shout.id"), nullable=False)
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
def __repr__(self) -> str:
return f"<AuthorBookmark(author={self.author}, shout={self.shout})>"
# from sqlalchemy_utils import TSVectorType
class AuthorRating(Base):
"""
Рейтинг автора.
Рейтинг автора от другого автора.
Attributes:
rater (int): ID оценивающего автора
author (int): ID оцениваемого автора
plus (bool): Положительная/отрицательная оценка
"""
__tablename__ = "author_rating"
id = None # type: ignore
rater = Column(ForeignKey("author.id"), primary_key=True)
author = Column(ForeignKey("author.id"), primary_key=True)
plus = Column(Boolean)
# Определяем индексы
__table_args__ = (
PrimaryKeyConstraint("author", "rater"),
# Индекс для быстрого поиска всех оценок конкретного автора
Index("idx_author_rating_author", "author"),
# Индекс для быстрого поиска всех оценок, оставленных конкретным автором
Index("idx_author_rating_rater", "rater"),
{"extend_existing": True},
)
author: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False)
rater: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False)
plus: Mapped[bool] = mapped_column(Boolean, nullable=True)
rating: Mapped[int] = mapped_column(Integer, nullable=False, comment="Rating value")
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
def __repr__(self) -> str:
return f"<AuthorRating(author={self.author}, rater={self.rater}, rating={self.rating})>"
class AuthorFollower(Base):
"""
Подписка одного автора на другого.
Attributes:
follower (int): ID подписчика
author (int): ID автора, на которого подписываются
created_at (int): Время создания подписки
auto (bool): Признак автоматической подписки
"""
__tablename__ = "author_follower"
id = None # type: ignore
follower = Column(ForeignKey("author.id"), primary_key=True)
author = Column(ForeignKey("author.id"), primary_key=True)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
auto = Column(Boolean, nullable=False, default=False)
# Определяем индексы
__table_args__ = (
# Индекс для быстрого поиска всех подписчиков автора
Index("idx_author_follower_author", "author"),
# Индекс для быстрого поиска всех авторов, на которых подписан конкретный автор
Index("idx_author_follower_follower", "follower"),
)
class AuthorBookmark(Base):
"""
Закладка автора на публикацию.
Attributes:
author (int): ID автора
shout (int): ID публикации
"""
__tablename__ = "author_bookmark"
id = None # type: ignore
author = Column(ForeignKey("author.id"), primary_key=True)
shout = Column(ForeignKey("shout.id"), primary_key=True)
# Определяем индексы
__table_args__ = (
# Индекс для быстрого поиска всех закладок автора
Index("idx_author_bookmark_author", "author"),
# Индекс для быстрого поиска всех авторов, добавивших публикацию в закладки
Index("idx_author_bookmark_shout", "shout"),
)
class Author(Base):
"""
Модель автора в системе.
Attributes:
user (str): Идентификатор пользователя в системе авторизации
name (str): Отображаемое имя
slug (str): Уникальный строковый идентификатор
bio (str): Краткая биография/статус
about (str): Полное описание
pic (str): URL изображения профиля
links (dict): Ссылки на социальные сети и сайты
created_at (int): Время создания профиля
last_seen (int): Время последнего посещения
updated_at (int): Время последнего обновления
deleted_at (int): Время удаления (если профиль удален)
"""
__tablename__ = "author"
user = Column(String) # unbounded link with authorizer's User type
name = Column(String, nullable=True, comment="Display name")
slug = Column(String, unique=True, comment="Author's slug")
bio = Column(String, nullable=True, comment="Bio") # status description
about = Column(String, nullable=True, comment="About") # long and formatted
pic = Column(String, nullable=True, comment="Picture")
links = Column(JSON, nullable=True, comment="Links")
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
last_seen = Column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
deleted_at = Column(Integer, nullable=True, comment="Deleted at")
# search_vector = Column(
# TSVectorType("name", "slug", "bio", "about", regconfig="pg_catalog.russian")
# )
# Определяем индексы
__table_args__ = (
# Индекс для быстрого поиска по slug
Index("idx_author_slug", "slug"),
# Индекс для быстрого поиска по идентификатору пользователя
Index("idx_author_user", "user"),
# Индекс для фильтрации неудаленных авторов
Index("idx_author_deleted_at", "deleted_at", postgresql_where=deleted_at.is_(None)),
# Индекс для сортировки по времени создания (для новых авторов)
Index("idx_author_created_at", "created_at"),
# Индекс для сортировки по времени последнего посещения
Index("idx_author_last_seen", "last_seen"),
)

View File

@@ -1,73 +0,0 @@
import builtins
import logging
from typing import Any, Type
import orjson
from sqlalchemy import JSON
from sqlalchemy.orm import DeclarativeBase
logger = logging.getLogger(__name__)
# Глобальный реестр моделей
REGISTRY: dict[str, Type[Any]] = {}
# Список полей для фильтрации при сериализации
FILTERED_FIELDS: list[str] = []
class BaseModel(DeclarativeBase):
"""
Базовая модель с методами сериализации и обновления
"""
def __init_subclass__(cls, **kwargs: Any) -> None:
REGISTRY[cls.__name__] = cls
super().__init_subclass__(**kwargs)
def dict(self) -> builtins.dict[str, Any]:
"""
Конвертирует ORM объект в словарь.
Пропускает атрибуты, которые отсутствуют в объекте, но присутствуют в колонках таблицы.
Преобразует JSON поля в словари.
Returns:
Dict[str, Any]: Словарь с атрибутами объекта
"""
column_names = filter(lambda x: x not in FILTERED_FIELDS, self.__table__.columns.keys())
data: builtins.dict[str, Any] = {}
# logger.debug(f"Converting object to dictionary {'with access' if access else 'without access'}")
try:
for column_name in column_names:
try:
# Проверяем, существует ли атрибут в объекте
if hasattr(self, column_name):
value = getattr(self, column_name)
# Проверяем, является ли значение JSON и декодируем его при необходимости
if isinstance(value, str | bytes) and isinstance(
self.__table__.columns[column_name].type, JSON
):
try:
data[column_name] = orjson.loads(value)
except (TypeError, orjson.JSONDecodeError) as e:
logger.warning(f"Error decoding JSON for column '{column_name}': {e}")
data[column_name] = value
else:
data[column_name] = value
else:
# Пропускаем атрибут, если его нет в объекте (может быть добавлен после миграции)
logger.debug(f"Skipping missing attribute '{column_name}' for {self.__class__.__name__}")
except AttributeError as e:
logger.warning(f"Attribute error for column '{column_name}': {e}")
except Exception as e:
logger.warning(f"Error occurred while converting object to dictionary {e}")
return data
def update(self, values: builtins.dict[str, Any]) -> None:
for key, value in values.items():
if hasattr(self, key):
setattr(self, key, value)
# Alias for backward compatibility
Base = BaseModel

View File

@@ -1,37 +1,25 @@
import time
from sqlalchemy import ForeignKey, Index, Integer, PrimaryKeyConstraint, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import Column, ForeignKey, Integer, String
from orm.base import BaseModel as Base
class Collection(Base):
__tablename__ = "collection"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
slug: Mapped[str] = mapped_column(String, unique=True)
title: Mapped[str] = mapped_column(String, nullable=False, comment="Title")
body: Mapped[str | None] = mapped_column(String, nullable=True, comment="Body")
pic: Mapped[str | None] = mapped_column(String, nullable=True, comment="Picture")
created_at: Mapped[int] = mapped_column(Integer, default=lambda: int(time.time()))
created_by: Mapped[int] = mapped_column(ForeignKey("author.id"), comment="Created By")
published_at: Mapped[int] = mapped_column(Integer, default=lambda: int(time.time()))
created_by_author = relationship("Author", foreign_keys=[created_by])
from services.db import Base
class ShoutCollection(Base):
__tablename__ = "shout_collection"
shout: Mapped[int] = mapped_column(ForeignKey("shout.id"))
collection: Mapped[int] = mapped_column(ForeignKey("collection.id"))
created_at: Mapped[int] = mapped_column(Integer, default=lambda: int(time.time()))
created_by: Mapped[int] = mapped_column(ForeignKey("author.id"), comment="Created By")
id = None # type: ignore
shout = Column(ForeignKey("shout.id"), primary_key=True)
collection = Column(ForeignKey("collection.id"), primary_key=True)
__table_args__ = (
PrimaryKeyConstraint(shout, collection),
Index("idx_shout_collection_shout", "shout"),
Index("idx_shout_collection_collection", "collection"),
{"extend_existing": True},
)
class Collection(Base):
__tablename__ = "collection"
slug = Column(String, unique=True)
title = Column(String, nullable=False, comment="Title")
body = Column(String, nullable=True, comment="Body")
pic = Column(String, nullable=True, comment="Picture")
created_at = Column(Integer, default=lambda: int(time.time()))
created_by = Column(ForeignKey("author.id"), comment="Created By")
published_at = Column(Integer, default=lambda: int(time.time()))

View File

@@ -1,726 +1,106 @@
import asyncio
import enum
import time
from typing import Any, Dict
from sqlalchemy import (
JSON,
Boolean,
ForeignKey,
Index,
Integer,
PrimaryKeyConstraint,
String,
UniqueConstraint,
distinct,
func,
text,
)
from sqlalchemy import Column, ForeignKey, Integer, String, Text, distinct, func
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import Mapped, mapped_column
from orm.author import Author
from orm.base import BaseModel
from rbac.interface import get_rbac_operations
from storage.db import local_session
# Словарь названий ролей
role_names = {
"reader": "Читатель",
"author": "Автор",
"artist": "Художник",
"expert": "Эксперт",
"editor": "Редактор",
"admin": "Администратор",
}
# Словарь описаний ролей
role_descriptions = {
"reader": "Может читать и комментировать",
"author": "Может создавать публикации",
"artist": "Может быть credited artist",
"expert": "Может добавлять доказательства",
"editor": "Может модерировать контент",
"admin": "Полные права",
}
from services.db import Base
class CommunityFollower(BaseModel):
"""
Простая подписка пользователя на сообщество.
class CommunityRole(enum.Enum):
READER = "reader" # can read and comment
AUTHOR = "author" # + can vote and invite collaborators
ARTIST = "artist" # + can be credited as featured artist
EXPERT = "expert" # + can add proof or disproof to shouts, can manage topics
EDITOR = "editor" # + can manage topics, comments and community settings
Использует обычный id как первичный ключ для простоты и производительности.
Уникальность обеспечивается индексом по (community, follower).
"""
__tablename__ = "community_follower"
community: Mapped[int] = mapped_column(Integer, ForeignKey("community.id"), nullable=False, index=True)
follower: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False, index=True)
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
# Уникальность по паре сообщество-подписчик
__table_args__ = (
PrimaryKeyConstraint("community", "follower"),
{"extend_existing": True},
)
def __init__(self, community: int, follower: int) -> None:
self.community = community
self.follower = follower
@classmethod
def as_string_array(cls, roles):
return [role.value for role in roles]
class Community(BaseModel):
class CommunityFollower(Base):
__tablename__ = "community_author"
author = Column(ForeignKey("author.id"), primary_key=True)
community = Column(ForeignKey("community.id"), primary_key=True)
joined_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
roles = Column(Text, nullable=True, comment="Roles (comma-separated)")
def set_roles(self, roles):
self.roles = CommunityRole.as_string_array(roles)
def get_roles(self):
return [CommunityRole(role) for role in self.roles]
class Community(Base):
__tablename__ = "community"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String, nullable=False)
slug: Mapped[str] = mapped_column(String, nullable=False, unique=True)
desc: Mapped[str] = mapped_column(String, nullable=False, default="")
pic: Mapped[str | None] = mapped_column(String, nullable=False, default="")
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
created_by: Mapped[int | None] = mapped_column(Integer, nullable=True)
settings: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
deleted_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
private: Mapped[bool] = mapped_column(Boolean, default=False)
name = Column(String, nullable=False)
slug = Column(String, nullable=False, unique=True)
desc = Column(String, nullable=False, default="")
pic = Column(String, nullable=False, default="")
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
created_by = Column(ForeignKey("author.id"), nullable=False)
@hybrid_property
def stat(self):
return CommunityStats(self)
def is_followed_by(self, author_id: int) -> bool:
"""Проверяет, подписан ли пользователь на сообщество"""
with local_session() as session:
follower = (
session.query(CommunityFollower)
.where(CommunityFollower.community == self.id, CommunityFollower.follower == author_id)
.first()
)
return follower is not None
@property
def role_list(self):
return self.roles.split(",") if self.roles else []
def get_user_roles(self, user_id: int) -> list[str]:
"""
Получает роли пользователя в данном сообществе через CommunityAuthor
Args:
user_id: ID пользователя
Returns:
Список ролей пользователя в сообществе
"""
with local_session() as session:
community_author = (
session.query(CommunityAuthor)
.where(CommunityAuthor.community_id == self.id, CommunityAuthor.author_id == user_id)
.first()
)
return community_author.role_list if community_author else []
def has_user_role(self, user_id: int, role_id: str) -> bool:
"""
Проверяет, есть ли у пользователя указанная роль в этом сообществе
Args:
user_id: ID пользователя
role_id: ID роли
Returns:
True если роль есть, False если нет
"""
user_roles = self.get_user_roles(user_id)
return role_id in user_roles
def add_user_role(self, user_id: int, role: str) -> None:
"""
Добавляет роль пользователю в сообществе
Args:
user_id: ID пользователя
role: Название роли
"""
with local_session() as session:
# Ищем существующую запись
community_author = (
session.query(CommunityAuthor)
.where(CommunityAuthor.community_id == self.id, CommunityAuthor.author_id == user_id)
.first()
)
if community_author:
# Добавляем роль к существующей записи
community_author.add_role(role)
else:
# Создаем новую запись
community_author = CommunityAuthor(community_id=self.id, author_id=user_id, roles=role)
session.add(community_author)
session.commit()
def remove_user_role(self, user_id: int, role: str) -> None:
"""
Удаляет роль у пользователя в сообществе
Args:
user_id: ID пользователя
role: Название роли
"""
with local_session() as session:
community_author = (
session.query(CommunityAuthor)
.where(CommunityAuthor.community_id == self.id, CommunityAuthor.author_id == user_id)
.first()
)
if community_author:
community_author.remove_role(role)
# Если ролей не осталось, удаляем запись
if not community_author.role_list:
session.delete(community_author)
session.commit()
def set_user_roles(self, user_id: int, roles: list[str]) -> None:
"""
Устанавливает полный список ролей пользователя в сообществе
Args:
user_id: ID пользователя
roles: Список ролей для установки
"""
with local_session() as session:
# Ищем существующую запись
community_author = (
session.query(CommunityAuthor)
.where(CommunityAuthor.community_id == self.id, CommunityAuthor.author_id == user_id)
.first()
)
if community_author:
if roles:
# Обновляем роли
community_author.set_roles(roles)
else:
# Если ролей нет, удаляем запись
session.delete(community_author)
elif roles:
# Создаем новую запись, если есть роли
community_author = CommunityAuthor(community_id=self.id, author_id=user_id)
community_author.set_roles(roles)
session.add(community_author)
session.commit()
def get_community_members(self, with_roles: bool = False) -> list[dict[str, Any]]:
"""
Получает список участников сообщества
Args:
with_roles: Если True, включает информацию о ролях
Returns:
Список участников с информацией о ролях
"""
with local_session() as session:
community_authors = session.query(CommunityAuthor).where(CommunityAuthor.community_id == self.id).all()
members = []
for ca in community_authors:
member_info: dict[str, Any] = {
"author_id": ca.author_id,
"joined_at": ca.joined_at,
}
if with_roles:
member_info["roles"] = ca.role_list
# Получаем разрешения синхронно
try:
member_info["permissions"] = asyncio.run(ca.get_permissions())
except Exception:
# Если не удается получить разрешения асинхронно, используем пустой список
member_info["permissions"] = []
members.append(member_info)
return members
def assign_default_roles_to_user(self, user_id: int) -> None:
"""
Назначает дефолтные роли новому пользователю в сообществе
Args:
user_id: ID пользователя
"""
default_roles = self.get_default_roles()
self.set_user_roles(user_id, default_roles)
def get_default_roles(self) -> list[str]:
"""
Получает список дефолтных ролей для новых пользователей в сообществе
Returns:
Список ID ролей, которые назначаются новым пользователям по умолчанию
"""
if not self.settings:
return ["reader", "author"] # По умолчанию базовые роли
return self.settings.get("default_roles", ["reader", "author"])
def set_default_roles(self, roles: list[str]) -> None:
"""
Устанавливает дефолтные роли для новых пользователей в сообществе
Args:
roles: Список ID ролей для назначения по умолчанию
"""
if not self.settings:
self.settings = {}
self.settings["default_roles"] = roles
async def initialize_role_permissions(self) -> None:
"""
Инициализирует права ролей для сообщества из дефолтных настроек.
Вызывается при создании нового сообщества.
"""
rbac_ops = get_rbac_operations()
await rbac_ops.initialize_community_permissions(int(self.id))
def get_available_roles(self) -> list[str]:
"""
Получает список доступных ролей в сообществе
Returns:
Список ID ролей, которые могут быть назначены в этом сообществе
"""
if not self.settings:
return ["reader", "author", "artist", "expert", "editor", "admin"] # Все стандартные роли
return self.settings.get("available_roles", ["reader", "author", "artist", "expert", "editor", "admin"])
def set_available_roles(self, roles: list[str]) -> None:
"""
Устанавливает список доступных ролей в сообществе
Args:
roles: Список ID ролей, доступных в сообществе
"""
if not self.settings:
self.settings = {}
self.settings["available_roles"] = roles
def set_slug(self, slug: str) -> None:
"""Устанавливает slug сообщества"""
self.update({"slug": slug})
def get_followers(self):
"""
Получает список подписчиков сообщества.
Returns:
list: Список ID авторов, подписанных на сообщество
"""
with local_session() as session:
return [
follower.id
for follower in session.query(Author)
.join(CommunityFollower, Author.id == CommunityFollower.follower)
.where(CommunityFollower.community == self.id)
.all()
]
def add_community_creator(self, author_id: int) -> None:
"""
Создатель сообщества
Args:
author_id: ID пользователя, которому назначаются права
"""
with local_session() as session:
# Проверяем существование связи
existing = CommunityAuthor.find_author_in_community(author_id, self.id, session)
if not existing:
# Создаем нового CommunityAuthor с ролью редактора
community_author = CommunityAuthor(community_id=self.id, author_id=author_id, roles="editor")
session.add(community_author)
session.commit()
@role_list.setter
def role_list(self, value):
self.roles = ",".join(value) if value else None
class CommunityStats:
def __init__(self, community) -> None:
def __init__(self, community):
self.community = community
@property
def shouts(self) -> int:
return (
self.community.session.query(func.count(1))
.select_from(text("shout"))
.filter(text("shout.community_id = :community_id"))
.params(community_id=self.community.id)
.scalar()
)
def shouts(self):
from orm.shout import Shout
return self.community.session.query(func.count(Shout.id)).filter(Shout.community == self.community.id).scalar()
@property
def followers(self) -> int:
def followers(self):
return (
self.community.session.query(func.count(CommunityFollower.follower))
self.community.session.query(func.count(CommunityFollower.author))
.filter(CommunityFollower.community == self.community.id)
.scalar()
)
@property
def authors(self) -> int:
def authors(self):
from orm.shout import Shout
# author has a shout with community id and its featured_at is not null
return (
self.community.session.query(func.count(distinct(Author.id)))
.select_from(text("author"))
.join(text("shout"), text("author.id IN (SELECT author_id FROM shout_author WHERE shout_id = shout.id)"))
.filter(text("shout.community_id = :community_id"), text("shout.featured_at IS NOT NULL"))
.params(community_id=self.community.id)
.join(Shout)
.filter(Shout.community == self.community.id, Shout.featured_at.is_not(None), Author.id.in_(Shout.authors))
.scalar()
)
class CommunityAuthor(BaseModel):
"""
Связь автора с сообществом и его ролями.
Attributes:
id: Уникальный ID записи
community_id: ID сообщества
author_id: ID автора
roles: CSV строка с ролями (например: "reader,author,editor")
joined_at: Время присоединения к сообществу (unix timestamp)
"""
class CommunityAuthor(Base):
__tablename__ = "community_author"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
community_id: Mapped[int] = mapped_column(Integer, ForeignKey("community.id"), nullable=False)
author_id: Mapped[int] = mapped_column(Integer, ForeignKey("author.id"), nullable=False)
roles: Mapped[str | None] = mapped_column(String, nullable=True, comment="Roles (comma-separated)")
joined_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
# Уникальность по сообществу и автору
__table_args__ = (
Index("idx_community_author_community", "community_id"),
Index("idx_community_author_author", "author_id"),
UniqueConstraint("community_id", "author_id", name="uq_community_author"),
{"extend_existing": True},
)
id = Column(Integer, primary_key=True)
community_id = Column(Integer, ForeignKey("community.id"))
author_id = Column(Integer, ForeignKey("author.id"))
roles = Column(Text, nullable=True, comment="Roles (comma-separated)")
@property
def role_list(self) -> list[str]:
"""Получает список ролей как список строк"""
return [role.strip() for role in self.roles.split(",") if role.strip()] if self.roles else []
def role_list(self):
return self.roles.split(",") if self.roles else []
@role_list.setter
def role_list(self, value: list[str]) -> None:
"""Устанавливает список ролей из списка строк"""
self.update({"roles": ",".join(value) if value else None})
def add_role(self, role: str) -> None:
"""
Добавляет роль в список ролей.
Args:
role (str): Название роли
"""
if not self.roles:
self.roles = role
elif role not in self.role_list:
self.roles += f",{role}"
def remove_role(self, role: str) -> None:
"""
Удаляет роль из списка ролей.
Args:
role (str): Название роли
"""
if self.roles and role in self.role_list:
roles_list = [r for r in self.role_list if r != role]
self.roles = ",".join(roles_list) if roles_list else None
def has_role(self, role: str) -> bool:
"""
Проверяет наличие роли.
Args:
role (str): Название роли
Returns:
bool: True, если роль есть, иначе False
"""
return bool(self.roles and role in self.role_list)
def set_roles(self, roles: list[str]) -> None:
"""
Устанавливает роли для CommunityAuthor.
Args:
roles: Список ролей для установки
"""
# Фильтруем и очищаем роли
valid_roles = [role.strip() for role in roles if role and role.strip()]
# Если список пустой, устанавливаем пустую строку
self.roles = ",".join(valid_roles) if valid_roles else ""
async def get_permissions(self) -> list[str]:
"""
Получает все разрешения автора на основе его ролей в конкретном сообществе
Returns:
Список разрешений (permissions)
"""
all_permissions = set()
rbac_ops = get_rbac_operations()
for role in self.role_list:
role_perms = await rbac_ops.get_permissions_for_role(role, int(self.community_id))
all_permissions.update(role_perms)
return list(all_permissions)
def has_permission(self, permission: str) -> bool:
"""
Проверяет, есть ли у пользователя указанное право
Args:
permission: Право для проверки (например, "community:create")
Returns:
True если право есть, False если нет
"""
# Проверяем права через синхронную функцию
try:
# В синхронном контексте не можем использовать await
# Используем fallback на проверку ролей
return permission in self.role_list
except Exception:
# TODO: Fallback: проверяем роли (старый способ)
return any(permission == role for role in self.role_list)
def dict(self, access: bool = False) -> dict[str, Any]:
"""
Сериализует объект в словарь
Args:
access: Если True, включает дополнительную информацию
Returns:
Словарь с данными объекта
"""
result = {
"id": self.id,
"community_id": self.community_id,
"author_id": self.author_id,
"roles": self.role_list,
"joined_at": self.joined_at,
}
if access:
# Note: permissions должны быть получены заранее через await
# Здесь мы не можем использовать await в sync методе
result["permissions"] = [] # Placeholder - нужно получить асинхронно
return result
@classmethod
def get_user_communities_with_roles(cls, author_id: int, session=None) -> list[Dict[str, Any]]:
"""
Получает все сообщества пользователя с его ролями
Args:
author_id: ID автора
session: Сессия БД (опционально)
Returns:
Список словарей с информацией о сообществах и ролях
"""
if session is None:
with local_session() as ssession:
community_authors = ssession.query(cls).where(cls.author_id == author_id).all()
return [
{
"community_id": ca.community_id,
"roles": ca.role_list,
"permissions": [], # Нужно получить асинхронно
"joined_at": ca.joined_at,
}
for ca in community_authors
]
community_authors = session.query(cls).where(cls.author_id == author_id).all()
return [
{
"community_id": ca.community_id,
"roles": ca.role_list,
"permissions": [], # Нужно получить асинхронно
"joined_at": ca.joined_at,
}
for ca in community_authors
]
@classmethod
def find_author_in_community(cls, author_id: int, community_id: int, session=None) -> "CommunityAuthor | None":
"""
Находит запись CommunityAuthor по ID автора и сообщества
Args:
author_id: ID автора
community_id: ID сообщества
session: Сессия БД (опционально)
Returns:
CommunityAuthor или None
"""
if session is None:
with local_session() as ssession:
return ssession.query(cls).where(cls.author_id == author_id, cls.community_id == community_id).first()
return session.query(cls).where(cls.author_id == author_id, cls.community_id == community_id).first()
@classmethod
def get_users_with_role(cls, community_id: int, role: str, session=None) -> list[int]:
"""
Получает список ID пользователей с указанной ролью в сообществе
Args:
community_id: ID сообщества
role: Название роли
session: Сессия БД (опционально)
Returns:
Список ID пользователей
"""
if session is None:
with local_session() as ssession:
community_authors = ssession.query(cls).where(cls.community_id == community_id).all()
return [ca.author_id for ca in community_authors if ca.has_role(role)]
community_authors = session.query(cls).where(cls.community_id == community_id).all()
return [ca.author_id for ca in community_authors if ca.has_role(role)]
@classmethod
def get_community_stats(cls, community_id: int, session=None) -> Dict[str, Any]:
"""
Получает статистику ролей в сообществе
Args:
community_id: ID сообщества
session: Сессия БД (опционально)
Returns:
Словарь со статистикой ролей
"""
# Загружаем список авторов сообщества (одним способом вне зависимости от сессии)
if session is None:
with local_session() as s:
community_authors = s.query(cls).where(cls.community_id == community_id).all()
else:
community_authors = session.query(cls).where(cls.community_id == community_id).all()
role_counts: dict[str, int] = {}
total_members = len(community_authors)
for ca in community_authors:
for role in ca.role_list:
role_counts[role] = role_counts.get(role, 0) + 1
return {
"total_members": total_members,
"role_counts": role_counts,
"roles_distribution": {
role: count / total_members if total_members > 0 else 0 for role, count in role_counts.items()
},
}
# === CRUD ОПЕРАЦИИ ДЛЯ RBAC ===
def get_all_community_members_with_roles(community_id: int = 1) -> list[dict[str, Any]]:
"""
Получает всех участников сообщества с их ролями и разрешениями
Args:
community_id: ID сообщества
Returns:
Список участников с полной информацией
"""
with local_session() as session:
community = session.query(Community).where(Community.id == community_id).first()
if not community:
return []
return community.get_community_members(with_roles=True)
def bulk_assign_roles(user_role_pairs: list[tuple[int, str]], community_id: int = 1) -> dict[str, int]:
"""
Массовое назначение ролей пользователям
Args:
user_role_pairs: Список кортежей (author_id, role)
community_id: ID сообщества
Returns:
Статистика операции в формате {"success": int, "failed": int}
"""
success_count = 0
failed_count = 0
for author_id, role in user_role_pairs:
try:
if assign_role_to_user(author_id, role, community_id):
success_count += 1
else:
# Если роль уже была, считаем это успехом
success_count += 1
except Exception as e:
print(f"[ошибка] Не удалось назначить роль {role} пользователю {author_id}: {e}")
failed_count += 1
return {"success": success_count, "failed": failed_count}
# Алиасы для обратной совместимости (избегаем циклических импортов)
def get_user_roles_in_community(author_id: int, community_id: int = 1, session: Any = None) -> list[str]:
"""Алиас для rbac.api.get_user_roles_in_community"""
from rbac.api import get_user_roles_in_community as _get_user_roles_in_community
return _get_user_roles_in_community(author_id, community_id, session)
def assign_role_to_user(author_id: int, role: str, community_id: int = 1, session: Any = None) -> bool:
"""Алиас для rbac.api.assign_role_to_user"""
from rbac.api import assign_role_to_user as _assign_role_to_user
return _assign_role_to_user(author_id, role, community_id, session)
def remove_role_from_user(author_id: int, role: str, community_id: int = 1, session: Any = None) -> bool:
"""Алиас для rbac.api.remove_role_from_user"""
from rbac.api import remove_role_from_user as _remove_role_from_user
return _remove_role_from_user(author_id, role, community_id, session)
async def check_user_permission_in_community(
author_id: int, permission: str, community_id: int = 1, session: Any = None
) -> bool:
"""Алиас для rbac.api.check_user_permission_in_community"""
from rbac.api import check_user_permission_in_community as _check_user_permission_in_community
return await _check_user_permission_in_community(author_id, permission, community_id, session)
def role_list(self, value):
self.roles = ",".join(value) if value else None

View File

@@ -1,84 +1,55 @@
import time
from typing import Any
from sqlalchemy import JSON, Boolean, ForeignKey, Index, Integer, PrimaryKeyConstraint, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from orm.author import Author
from orm.base import BaseModel as Base
from orm.topic import Topic
# Author уже импортирован в начале файла
def get_author_model():
"""Возвращает модель Author для использования в запросах"""
return Author
from services.db import Base
class DraftTopic(Base):
__tablename__ = "draft_topic"
draft: Mapped[int] = mapped_column(ForeignKey("draft.id"), index=True)
topic: Mapped[int] = mapped_column(ForeignKey("topic.id"), index=True)
main: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
__table_args__ = (
PrimaryKeyConstraint(draft, topic),
Index("idx_draft_topic_topic", "topic"),
Index("idx_draft_topic_draft", "draft"),
{"extend_existing": True},
)
id = None # type: ignore
shout = Column(ForeignKey("draft.id"), primary_key=True, index=True)
topic = Column(ForeignKey("topic.id"), primary_key=True, index=True)
main = Column(Boolean, nullable=True)
class DraftAuthor(Base):
__tablename__ = "draft_author"
draft: Mapped[int] = mapped_column(ForeignKey("draft.id"), index=True)
author: Mapped[int] = mapped_column(ForeignKey("author.id"), index=True)
caption: Mapped[str | None] = mapped_column(String, nullable=True, default="")
__table_args__ = (
PrimaryKeyConstraint(draft, author),
Index("idx_draft_author_author", "author"),
Index("idx_draft_author_draft", "draft"),
{"extend_existing": True},
)
id = None # type: ignore
shout = Column(ForeignKey("draft.id"), primary_key=True, index=True)
author = Column(ForeignKey("author.id"), primary_key=True, index=True)
caption = Column(String, nullable=True, default="")
class Draft(Base):
__tablename__ = "draft"
# required
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
created_by: Mapped[int] = mapped_column(ForeignKey("author.id"), nullable=False)
community: Mapped[int] = mapped_column(ForeignKey("community.id"), nullable=False, default=1)
created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time()))
created_by: int = Column(ForeignKey("author.id"), nullable=False)
community: int = Column(ForeignKey("community.id"), nullable=False, default=1)
# optional
layout: Mapped[str | None] = mapped_column(String, nullable=True, default="article")
slug: Mapped[str | None] = mapped_column(String, unique=True)
title: Mapped[str | None] = mapped_column(String, nullable=True)
subtitle: Mapped[str | None] = mapped_column(String, nullable=True)
lead: Mapped[str | None] = mapped_column(String, nullable=True)
body: Mapped[str] = mapped_column(String, nullable=False, comment="Body")
media: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
cover: Mapped[str | None] = mapped_column(String, nullable=True, comment="Cover image url")
cover_caption: Mapped[str | None] = mapped_column(String, nullable=True, comment="Cover image alt caption")
lang: Mapped[str] = mapped_column(String, nullable=False, default="ru", comment="Language")
seo: Mapped[str | None] = mapped_column(String, nullable=True) # JSON
layout: str = Column(String, nullable=True, default="article")
slug: str = Column(String, unique=True)
title: str = Column(String, nullable=True)
subtitle: str | None = Column(String, nullable=True)
lead: str | None = Column(String, nullable=True)
body: str = Column(String, nullable=False, comment="Body")
media: dict | None = Column(JSON, nullable=True)
cover: str | None = Column(String, nullable=True, comment="Cover image url")
cover_caption: str | None = Column(String, nullable=True, comment="Cover image alt caption")
lang: str = Column(String, nullable=False, default="ru", comment="Language")
seo: str | None = Column(String, nullable=True) # JSON
# auto
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
deleted_at: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
updated_by: Mapped[int | None] = mapped_column(ForeignKey("author.id"), nullable=True)
deleted_by: Mapped[int | None] = mapped_column(ForeignKey("author.id"), nullable=True)
authors = relationship(get_author_model(), secondary=DraftAuthor.__table__)
topics = relationship(Topic, secondary=DraftTopic.__table__)
# shout/publication - связь с опубликованной публикацией
shout: Mapped[int | None] = mapped_column(ForeignKey("shout.id"), nullable=True)
__table_args__ = (
Index("idx_draft_created_by", "created_by"),
Index("idx_draft_community", "community"),
{"extend_existing": True},
)
updated_at: int | None = Column(Integer, nullable=True, index=True)
deleted_at: int | None = Column(Integer, nullable=True, index=True)
updated_by: int | None = Column(ForeignKey("author.id"), nullable=True)
deleted_by: int | None = Column(ForeignKey("author.id"), nullable=True)
authors = relationship(Author, secondary="draft_author")
topics = relationship(Topic, secondary="draft_topic")

View File

@@ -1,9 +1,9 @@
import enum
from sqlalchemy import ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import Column, ForeignKey, String
from sqlalchemy.orm import relationship
from orm.base import BaseModel as Base
from services.db import Base
class InviteStatus(enum.Enum):
@@ -12,33 +12,24 @@ class InviteStatus(enum.Enum):
REJECTED = "REJECTED"
@classmethod
def from_string(cls, value: str) -> "InviteStatus":
def from_string(cls, value):
return cls(value)
class Invite(Base):
__tablename__ = "invite"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
inviter_id: Mapped[int] = mapped_column(ForeignKey("author.id"))
author_id: Mapped[int] = mapped_column(ForeignKey("author.id"))
shout_id: Mapped[int] = mapped_column(ForeignKey("shout.id"))
status: Mapped[str] = mapped_column(String, default=InviteStatus.PENDING.value)
inviter_id = Column(ForeignKey("author.id"), primary_key=True)
author_id = Column(ForeignKey("author.id"), primary_key=True)
shout_id = Column(ForeignKey("shout.id"), primary_key=True)
status = Column(String, default=InviteStatus.PENDING.value)
inviter = relationship("Author", foreign_keys=[inviter_id])
author = relationship("Author", foreign_keys=[author_id])
shout = relationship("Shout")
__table_args__ = (
UniqueConstraint(inviter_id, author_id, shout_id),
Index("idx_invite_inviter_id", "inviter_id"),
Index("idx_invite_author_id", "author_id"),
Index("idx_invite_shout_id", "shout_id"),
{"extend_existing": True},
)
def set_status(self, status: InviteStatus) -> None:
def set_status(self, status: InviteStatus):
self.status = status.value
def get_status(self) -> InviteStatus:
return InviteStatus.from_string(str(self.status))
return InviteStatus.from_string(self.status)

View File

@@ -1,156 +1,63 @@
from datetime import datetime
from enum import Enum
from typing import Any
import enum
import time
from sqlalchemy import JSON, DateTime, ForeignKey, Index, Integer, PrimaryKeyConstraint, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import JSON, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
# Импорт Author отложен для избежания циклических импортов
from orm.author import Author
from orm.base import BaseModel as Base
from utils.logger import root_logger as logger
from services.db import Base
# Author уже импортирован в начале файла
def get_author_model():
"""Возвращает модель Author для использования в запросах"""
return Author
class NotificationEntity(Enum):
"""
Перечисление сущностей для уведомлений.
Определяет типы объектов, к которым относятся уведомления.
"""
TOPIC = "topic"
COMMENT = "comment"
SHOUT = "shout"
AUTHOR = "author"
COMMUNITY = "community"
class NotificationEntity(enum.Enum):
REACTION = "reaction"
SHOUT = "shout"
FOLLOWER = "follower"
COMMUNITY = "community"
@classmethod
def from_string(cls, value: str) -> "NotificationEntity":
"""
Создает экземпляр сущности уведомления из строки.
Args:
value (str): Строковое представление сущности.
Returns:
NotificationEntity: Экземпляр сущности уведомления.
"""
try:
return cls(value)
except ValueError:
logger.error(f"Неверная сущность уведомления: {value}")
raise ValueError("Неверная сущность уведомления") # noqa: B904
def from_string(cls, value):
return cls(value)
class NotificationAction(Enum):
"""
Перечисление действий для уведомлений.
Определяет типы событий, которые могут происходить с сущностями.
"""
class NotificationAction(enum.Enum):
CREATE = "create"
UPDATE = "update"
DELETE = "delete"
MENTION = "mention"
REACT = "react"
SEEN = "seen"
FOLLOW = "follow"
INVITE = "invite"
UNFOLLOW = "unfollow"
@classmethod
def from_string(cls, value: str) -> "NotificationAction":
"""
Создает экземпляр действия уведомления из строки.
Args:
value (str): Строковое представление действия.
Returns:
NotificationAction: Экземпляр действия уведомления.
"""
try:
return cls(value)
except ValueError:
logger.error(f"Неверное действие уведомления: {value}")
raise ValueError("Неверное действие уведомления") # noqa: B904
# Оставляем для обратной совместимости
NotificationStatus = Enum("NotificationStatus", ["UNREAD", "READ", "ARCHIVED"])
NotificationKind = NotificationAction # Для совместимости со старым кодом
def from_string(cls, value):
return cls(value)
class NotificationSeen(Base):
__tablename__ = "notification_seen"
viewer: Mapped[int] = mapped_column(ForeignKey("author.id"))
notification: Mapped[int] = mapped_column(ForeignKey("notification.id"))
__table_args__ = (
PrimaryKeyConstraint(viewer, notification),
Index("idx_notification_seen_viewer", "viewer"),
Index("idx_notification_seen_notification", "notification"),
{"extend_existing": True},
)
class NotificationUnsubscribe(Base):
"""Модель для хранения отписок пользователей от уведомлений по определенным thread_id."""
__tablename__ = "notification_unsubscribe"
author_id: Mapped[int] = mapped_column(ForeignKey("author.id"), nullable=False)
thread_id: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
__table_args__ = (
PrimaryKeyConstraint(author_id, thread_id),
Index("idx_notification_unsubscribe_author", "author_id"),
Index("idx_notification_unsubscribe_thread", "thread_id"),
{"extend_existing": True},
)
viewer = Column(ForeignKey("author.id"), primary_key=True)
notification = Column(ForeignKey("notification.id"), primary_key=True)
class Notification(Base):
__tablename__ = "notification"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
id = Column(Integer, primary_key=True, autoincrement=True)
created_at = Column(Integer, server_default=str(int(time.time())))
entity = Column(String, nullable=False)
action = Column(String, nullable=False)
payload = Column(JSON, nullable=True)
entity: Mapped[str] = mapped_column(String, nullable=False)
action: Mapped[str] = mapped_column(String, nullable=False)
payload: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
seen = relationship(Author, secondary="notification_seen")
status: Mapped[NotificationStatus] = mapped_column(default=NotificationStatus.UNREAD)
kind: Mapped[NotificationKind] = mapped_column(nullable=False)
seen = relationship("Author", secondary="notification_seen")
__table_args__ = (
Index("idx_notification_created_at", "created_at"),
Index("idx_notification_status", "status"),
Index("idx_notification_kind", "kind"),
{"extend_existing": True},
)
def set_entity(self, entity: NotificationEntity) -> None:
"""Устанавливает сущность уведомления."""
def set_entity(self, entity: NotificationEntity):
self.entity = entity.value
def get_entity(self) -> NotificationEntity:
"""Возвращает сущность уведомления."""
return NotificationEntity.from_string(self.entity)
def set_action(self, action: NotificationAction) -> None:
"""Устанавливает действие уведомления."""
def set_action(self, action: NotificationAction):
self.action = action.value
def get_action(self) -> NotificationAction:
"""Возвращает действие уведомления."""
return NotificationAction.from_string(self.action)

View File

@@ -10,26 +10,21 @@ PROPOSAL_REACTIONS = [
]
PROOF_REACTIONS = [ReactionKind.PROOF.value, ReactionKind.DISPROOF.value]
RATING_REACTIONS = [ReactionKind.LIKE.value, ReactionKind.DISLIKE.value]
POSITIVE_REACTIONS = [ReactionKind.ACCEPT.value, ReactionKind.LIKE.value, ReactionKind.PROOF.value]
NEGATIVE_REACTIONS = [ReactionKind.REJECT.value, ReactionKind.DISLIKE.value, ReactionKind.DISPROOF.value]
def is_negative(x: ReactionKind | str) -> bool:
"""Проверяет, является ли реакция негативной.
Args:
x: ReactionKind enum или строка с названием реакции
"""
value = x.value if isinstance(x, ReactionKind) else x
return value in NEGATIVE_REACTIONS
def is_negative(x):
return x in [
ReactionKind.DISLIKE.value,
ReactionKind.DISPROOF.value,
ReactionKind.REJECT.value,
]
def is_positive(x: ReactionKind | str) -> bool:
"""Проверяет, является ли реакция позитивной.
Args:
x: ReactionKind enum или строка с названием реакции
"""
value = x.value if isinstance(x, ReactionKind) else x
return value in POSITIVE_REACTIONS
def is_positive(x):
return x in [
ReactionKind.ACCEPT.value,
ReactionKind.LIKE.value,
ReactionKind.PROOF.value,
]

View File

@@ -1,68 +1,45 @@
import time
from enum import Enum as Enumeration
from sqlalchemy import ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Column, ForeignKey, Integer, String
from orm.base import BaseModel as Base
from services.db import Base
class ReactionKind(Enumeration):
# TYPE = <reaction index> # rating diff
# editor specials
# editor mode
AGREE = "AGREE" # +1
DISAGREE = "DISAGREE" # -1
# coauthor specials
ASK = "ASK" # 0
PROPOSE = "PROPOSE" # 0
# generic internal reactions
ASK = "ASK" # +0
PROPOSE = "PROPOSE" # +0
ACCEPT = "ACCEPT" # +1
REJECT = "REJECT" # -1
# experts speacials
# expert mode
PROOF = "PROOF" # +1
DISPROOF = "DISPROOF" # -1
# comment and quote
QUOTE = "QUOTE" # 0
COMMENT = "COMMENT" # 0
# generic rating
# public feed
QUOTE = "QUOTE" # +0 TODO: use to bookmark in collection
COMMENT = "COMMENT" # +0
LIKE = "LIKE" # +1
DISLIKE = "DISLIKE" # -1
# credit artist or researcher
CREDIT = "CREDIT" # +1
SILENT = "SILENT" # 0
REACTION_KINDS = ReactionKind.__members__.keys()
class Reaction(Base):
__tablename__ = "reaction"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
body: Mapped[str] = mapped_column(String, default="", comment="Reaction Body")
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()), index=True)
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="Updated at", index=True)
deleted_at: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="Deleted at", index=True)
deleted_by: Mapped[int | None] = mapped_column(ForeignKey("author.id"), nullable=True)
reply_to: Mapped[int | None] = mapped_column(ForeignKey("reaction.id"), nullable=True)
quote: Mapped[str | None] = mapped_column(String, nullable=True, comment="Original quoted text")
shout: Mapped[int] = mapped_column(ForeignKey("shout.id"), nullable=False, index=True)
created_by: Mapped[int] = mapped_column(ForeignKey("author.id"), nullable=False)
kind: Mapped[str] = mapped_column(String, nullable=False, index=True)
body = Column(String, default="", comment="Reaction Body")
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()), index=True)
updated_at = Column(Integer, nullable=True, comment="Updated at", index=True)
deleted_at = Column(Integer, nullable=True, comment="Deleted at", index=True)
deleted_by = Column(ForeignKey("author.id"), nullable=True)
reply_to = Column(ForeignKey("reaction.id"), nullable=True)
quote = Column(String, nullable=True, comment="Original quoted text")
shout = Column(ForeignKey("shout.id"), nullable=False, index=True)
created_by = Column(ForeignKey("author.id"), nullable=False)
kind = Column(String, nullable=False, index=True)
oid: Mapped[str | None] = mapped_column(String)
__table_args__ = (
Index("idx_reaction_created_at", "created_at"),
Index("idx_reaction_created_by", "created_by"),
Index("idx_reaction_shout", "shout"),
Index("idx_reaction_kind", "kind"),
{"extend_existing": True},
)
oid = Column(String)

View File

@@ -1,13 +1,15 @@
import time
from typing import Any
from sqlalchemy import JSON, Boolean, ForeignKey, Index, Integer, PrimaryKeyConstraint, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
from sqlalchemy.orm import relationship
from orm.base import BaseModel
from orm.author import Author
from orm.reaction import Reaction
from orm.topic import Topic
from services.db import Base
class ShoutTopic(BaseModel):
class ShoutTopic(Base):
"""
Связь между публикацией и темой.
@@ -19,36 +21,30 @@ class ShoutTopic(BaseModel):
__tablename__ = "shout_topic"
shout: Mapped[int] = mapped_column(ForeignKey("shout.id"), index=True)
topic: Mapped[int] = mapped_column(ForeignKey("topic.id"), index=True)
main: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
id = None # type: ignore
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
topic = Column(ForeignKey("topic.id"), primary_key=True, index=True)
main = Column(Boolean, nullable=True)
# Определяем дополнительные индексы
__table_args__ = (
PrimaryKeyConstraint(shout, topic),
# Оптимизированный составной индекс для запросов, которые ищут публикации по теме
Index("idx_shout_topic_topic_shout", "topic", "shout"),
)
class ShoutReactionsFollower(BaseModel):
class ShoutReactionsFollower(Base):
__tablename__ = "shout_reactions_followers"
follower: Mapped[int] = mapped_column(ForeignKey("author.id"), index=True)
shout: Mapped[int] = mapped_column(ForeignKey("shout.id"), index=True)
auto: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
deleted_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
__table_args__ = (
PrimaryKeyConstraint(follower, shout),
Index("idx_shout_reactions_followers_follower", "follower"),
Index("idx_shout_reactions_followers_shout", "shout"),
{"extend_existing": True},
)
id = None # type: ignore
follower = Column(ForeignKey("author.id"), primary_key=True, index=True)
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
auto = Column(Boolean, nullable=False, default=False)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
deleted_at = Column(Integer, nullable=True)
class ShoutAuthor(BaseModel):
class ShoutAuthor(Base):
"""
Связь между публикацией и автором.
@@ -60,55 +56,57 @@ class ShoutAuthor(BaseModel):
__tablename__ = "shout_author"
shout: Mapped[int] = mapped_column(ForeignKey("shout.id"), index=True)
author: Mapped[int] = mapped_column(ForeignKey("author.id"), index=True)
caption: Mapped[str | None] = mapped_column(String, nullable=True, default="")
id = None # type: ignore
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
author = Column(ForeignKey("author.id"), primary_key=True, index=True)
caption = Column(String, nullable=True, default="")
# Определяем дополнительные индексы
__table_args__ = (
PrimaryKeyConstraint(shout, author),
# Оптимизированный индекс для запросов, которые ищут публикации по автору
Index("idx_shout_author_author_shout", "author", "shout"),
)
class Shout(BaseModel):
class Shout(Base):
"""
Публикация в системе.
"""
__tablename__ = "shout"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
published_at: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
featured_at: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
deleted_at: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at: int | None = Column(Integer, nullable=True, index=True)
published_at: int | None = Column(Integer, nullable=True, index=True)
featured_at: int | None = Column(Integer, nullable=True, index=True)
deleted_at: int | None = Column(Integer, nullable=True, index=True)
created_by: Mapped[int] = mapped_column(ForeignKey("author.id"), nullable=False)
updated_by: Mapped[int | None] = mapped_column(ForeignKey("author.id"), nullable=True)
deleted_by: Mapped[int | None] = mapped_column(ForeignKey("author.id"), nullable=True)
community: Mapped[int] = mapped_column(ForeignKey("community.id"), nullable=False)
created_by: int = Column(ForeignKey("author.id"), nullable=False)
updated_by: int | None = Column(ForeignKey("author.id"), nullable=True)
deleted_by: int | None = Column(ForeignKey("author.id"), nullable=True)
community: int = Column(ForeignKey("community.id"), nullable=False)
body: Mapped[str] = mapped_column(String, nullable=False, comment="Body")
slug: Mapped[str | None] = mapped_column(String, unique=True)
cover: Mapped[str | None] = mapped_column(String, nullable=True, comment="Cover image url")
cover_caption: Mapped[str | None] = mapped_column(String, nullable=True, comment="Cover image alt caption")
lead: Mapped[str | None] = mapped_column(String, nullable=True)
title: Mapped[str] = mapped_column(String, nullable=False)
subtitle: Mapped[str | None] = mapped_column(String, nullable=True)
layout: Mapped[str] = mapped_column(String, nullable=False, default="article")
media: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
body: str = Column(String, nullable=False, comment="Body")
slug: str = Column(String, unique=True)
cover: str | None = Column(String, nullable=True, comment="Cover image url")
cover_caption: str | None = Column(String, nullable=True, comment="Cover image alt caption")
lead: str | None = Column(String, nullable=True)
title: str = Column(String, nullable=False)
subtitle: str | None = Column(String, nullable=True)
layout: str = Column(String, nullable=False, default="article")
media: dict | None = Column(JSON, nullable=True)
authors = relationship("Author", secondary="shout_author")
topics = relationship("Topic", secondary="shout_topic")
reactions = relationship("Reaction")
authors = relationship(Author, secondary="shout_author")
topics = relationship(Topic, secondary="shout_topic")
reactions = relationship(Reaction)
lang: Mapped[str] = mapped_column(String, nullable=False, default="ru", comment="Language")
version_of: Mapped[int | None] = mapped_column(ForeignKey("shout.id"), nullable=True)
oid: Mapped[str | None] = mapped_column(String, nullable=True)
seo: Mapped[str | None] = mapped_column(String, nullable=True) # JSON
lang: str = Column(String, nullable=False, default="ru", comment="Language")
version_of: int | None = Column(ForeignKey("shout.id"), nullable=True)
oid: str | None = Column(String, nullable=True)
seo: str | None = Column(String, nullable=True) # JSON
draft: int | None = Column(ForeignKey("draft.id"), nullable=True)
# Определяем индексы
__table_args__ = (

View File

@@ -1,24 +1,8 @@
import time
from sqlalchemy import (
JSON,
Boolean,
ForeignKey,
Index,
Integer,
PrimaryKeyConstraint,
String,
)
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
from orm.author import Author
from orm.base import BaseModel as Base
# Author уже импортирован в начале файла
def get_author_model():
"""Возвращает модель Author для использования в запросах"""
return Author
from services.db import Base
class TopicFollower(Base):
@@ -34,14 +18,14 @@ class TopicFollower(Base):
__tablename__ = "topic_followers"
follower: Mapped[int] = mapped_column(ForeignKey("author.id"))
topic: Mapped[int] = mapped_column(ForeignKey("topic.id"))
created_at: Mapped[int] = mapped_column(Integer, nullable=False, default=lambda: int(time.time()))
auto: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
id = None # type: ignore
follower = Column(Integer, ForeignKey("author.id"), primary_key=True)
topic = Column(Integer, ForeignKey("topic.id"), primary_key=True)
created_at = Column(Integer, nullable=False, default=int(time.time()))
auto = Column(Boolean, nullable=False, default=False)
# Определяем индексы
__table_args__ = (
PrimaryKeyConstraint(topic, follower),
# Индекс для быстрого поиска всех подписчиков топика
Index("idx_topic_followers_topic", "topic"),
# Индекс для быстрого поиска всех топиков, на которые подписан автор
@@ -65,14 +49,13 @@ class Topic(Base):
__tablename__ = "topic"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
slug: Mapped[str] = mapped_column(String, unique=True)
title: Mapped[str] = mapped_column(String, nullable=False, comment="Title")
body: Mapped[str | None] = mapped_column(String, nullable=True, comment="Body")
pic: Mapped[str | None] = mapped_column(String, nullable=True, comment="Picture")
community: Mapped[int] = mapped_column(ForeignKey("community.id"), default=1)
oid: Mapped[str | None] = mapped_column(String, nullable=True, comment="Old ID")
parent_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True, comment="Parent Topic IDs")
slug = Column(String, unique=True)
title = Column(String, nullable=False, comment="Title")
body = Column(String, nullable=True, comment="Body")
pic = Column(String, nullable=True, comment="Picture")
community = Column(ForeignKey("community.id"), default=1)
oid = Column(String, nullable=True, comment="Old ID")
parent_ids = Column(JSON, nullable=True, comment="Parent Topic IDs")
# Определяем индексы
__table_args__ = (

6471
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +0,0 @@
{
"name": "publy-panel",
"version": "0.9.33",
"type": "module",
"description": "Publy, a modern platform for collaborative text creation, offers a user-friendly interface for authors, editors, and readers, supporting real-time collaboration and structured feedback.",
"scripts": {
"dev": "vite",
"build": "npm run codegen && vite build",
"serve": "vite preview",
"lint": "biome check . --fix",
"format": "biome format . --write",
"typecheck": "tsc --noEmit",
"codegen": "graphql-codegen --config codegen.ts"
},
"devDependencies": {
"@biomejs/biome": "^2.2.5",
"@graphql-codegen/cli": "^6.0.0",
"@graphql-codegen/client-preset": "^5.1.0",
"@graphql-codegen/introspection": "^5.0.0",
"@graphql-codegen/typescript": "^5.0.2",
"@graphql-codegen/typescript-operations": "^5.0.2",
"@graphql-codegen/typescript-resolvers": "^5.1.0",
"@solidjs/router": "^0.15.3",
"@types/node": "^24.7.0",
"@types/prismjs": "^1.26.5",
"graphql": "^16.11.0",
"graphql-tag": "^2.12.6",
"lightningcss": "^1.30.2",
"prismjs": "^1.30.0",
"solid-js": "^1.9.9",
"terser": "^5.44.0",
"typescript": "^5.9.3",
"vite": "^7.1.9",
"vite-plugin-solid": "^2.11.9"
},
"overrides": {
"vite": "^7.1.9"
}
}

View File

@@ -1,36 +0,0 @@
import { Route, Router } from '@solidjs/router'
import { lazy, onMount } from 'solid-js'
import { AuthProvider } from './context/auth'
import { I18nProvider } from './intl/i18n'
import LoginPage from './routes/login'
const ProtectedRoute = lazy(() =>
import('./ui/ProtectedRoute').then((module) => ({ default: module.ProtectedRoute }))
)
/**
* Корневой компонент приложения
*/
const App = () => {
console.log('[App] Initializing root component...')
onMount(() => {
console.log('[App] Root component mounted')
})
return (
<I18nProvider>
<AuthProvider>
<div class="app-container">
<Router>
<Route path="/login" component={LoginPage} />
<Route path="/" component={ProtectedRoute} />
<Route path="/admin" component={ProtectedRoute} />
<Route path="/admin/:tab" component={ProtectedRoute} />
</Router>
</div>
</AuthProvider>
</I18nProvider>
)
}
export default App

View File

@@ -1,22 +0,0 @@
<svg width="172" height="32" viewBox="0 0 175 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- D -->
<path d="M24.51 28.16H19.78V23H5.074V28.16H0.344V18.055H2.881C3.11033 17.7397 3.397 17.2093 3.741 16.464C4.11367 15.69 4.472 14.6437 4.816 13.325C5.18867 12.0063 5.504 10.3723 5.762 8.423C6.02 6.47367 6.149 4.166 6.149 1.5H21.113V18.055H24.51V28.16ZM15.523 18.313V6.23H11.008C10.9507 7.262 10.8503 8.36567 10.707 9.541C10.5637 10.6877 10.3773 11.8057 10.148 12.895C9.94733 13.9843 9.70367 15.002 9.417 15.948C9.13033 16.894 8.815 17.6823 8.471 18.313H15.523Z" fill="currentColor"/>
<!-- I -->
<path d="M42.3382 13.196L42.5532 10.143H42.4242L32.0612 23H27.6752V1.5H33.2652V11.734L33.0072 14.658H33.1792L43.5422 1.5H47.9282V23H42.3382V13.196Z" fill="currentColor"/>
<!-- S -->
<path d="M73.3244 20.936C72.1491 21.8247 70.7731 22.4983 69.1964 22.957C67.6197 23.387 65.9714 23.602 64.2514 23.602C62.3881 23.602 60.7254 23.3297 59.2634 22.785C57.8301 22.2403 56.6117 21.4807 55.6084 20.506C54.6337 19.5027 53.8884 18.2987 53.3724 16.894C52.8564 15.4893 52.5984 13.9413 52.5984 12.25C52.5984 10.444 52.8994 8.83867 53.5014 7.434C54.1034 6.02933 54.9347 4.83967 55.9954 3.865C57.0847 2.89033 58.3604 2.15933 59.8224 1.672C61.2844 1.156 62.8754 0.898 64.5954 0.898C66.2007 0.898 67.7057 1.08433 69.1104 1.457C70.5151 1.82966 71.5901 2.188 72.3354 2.532V10.1H67.6054V6.144C66.7167 5.94333 65.8281 5.843 64.9394 5.843C64.1367 5.843 63.3341 5.972 62.5314 6.23C61.7574 6.45933 61.0551 6.84633 60.4244 7.391C59.8224 7.907 59.3207 8.56633 58.9194 9.369C58.5467 10.1717 58.3604 11.132 58.3604 12.25C58.3604 13.1673 58.5181 14.013 58.8334 14.787C59.1487 15.561 59.5931 16.2347 60.1664 16.808C60.7397 17.3813 61.4421 17.84 62.2734 18.184C63.1334 18.4993 64.0794 18.657 65.1114 18.657C66.7454 18.657 68.0784 18.4277 69.1104 17.969C70.1711 17.5103 70.9594 17.109 71.4754 16.765L73.3244 20.936Z" fill="currentColor"/>
<!-- C -->
<path d="M86.4226 14.529H83.6276V23H78.0376V1.5H83.6276V10.229H85.9066L93.0016 1.5H99.3226L90.7656 11.519L96.0546 18.27H99.8386V23H93.3886L86.4226 14.529Z" fill="currentColor"/>
<!-- O -->
<path d="M112.636 16.937H114.27L118.441 1.5H124.203L118.097 20.893C117.552 22.4983 117.022 23.9603 116.506 25.279C115.99 26.6263 115.388 27.7873 114.7 28.762C114.012 29.7367 113.195 30.482 112.249 30.998C111.331 31.5427 110.199 31.815 108.852 31.815C108.192 31.815 107.562 31.729 106.96 31.557C106.386 31.385 105.856 31.17 105.369 30.912C104.881 30.6827 104.451 30.4247 104.079 30.138C103.706 29.88 103.405 29.6363 103.176 29.407L106.057 25.58C106.429 25.8953 106.874 26.182 107.39 26.44C107.906 26.7267 108.393 26.87 108.852 26.87C109.712 26.87 110.385 26.569 110.873 25.967C111.389 25.365 111.876 24.376 112.335 23H109.282L100.338 1.5H106.401L112.636 16.937Z" fill="currentColor"/>
<!-- U -->
<path d="M126.444 1.5H133.754L134.399 4.08H134.571C136.061 1.95867 138.469 0.898 141.795 0.898C143.113 0.898 144.317 1.113 145.407 1.543C146.525 1.94433 147.471 2.58933 148.245 3.478C149.047 4.36667 149.664 5.48467 150.094 6.832C150.524 8.17933 150.739 9.799 150.739 11.691C150.739 13.5257 150.495 15.1883 150.008 16.679C149.52 18.141 148.818 19.388 147.901 20.42C146.983 21.452 145.865 22.2403 144.547 22.785C143.228 23.3297 141.723 23.602 140.032 23.602C139.143 23.602 138.269 23.5303 137.409 23.387C136.549 23.2723 135.832 23.0717 135.259 22.785V31.6H129.669V6.23H126.444V1.5ZM140.118 5.628C139.028 5.628 138.025 5.90033 137.108 6.445C136.219 6.98967 135.603 7.80667 135.259 8.896V17.84C135.66 18.1553 136.233 18.4133 136.979 18.614C137.753 18.786 138.527 18.872 139.301 18.872C140.103 18.872 140.849 18.743 141.537 18.485C142.225 18.1983 142.827 17.754 143.343 17.152C143.859 16.55 144.26 15.7903 144.547 14.873C144.833 13.9557 144.977 12.852 144.977 11.562C144.977 9.67 144.518 8.208 143.601 7.176C142.683 6.144 141.522 5.628 140.118 5.628Z" fill="currentColor"/>
<!-- R -->
<path d="M174.845 20.936C173.669 21.8247 172.293 22.4983 170.717 22.957C169.14 23.387 167.492 23.602 165.772 23.602C163.908 23.602 162.246 23.3297 160.784 22.785C159.35 22.2403 158.132 21.4807 157.129 20.506C156.154 19.5027 155.409 18.2987 154.893 16.894C154.377 15.4893 154.119 13.9413 154.119 12.25C154.119 10.444 154.42 8.83867 155.022 7.434C155.624 6.02933 156.455 4.83967 157.516 3.865C158.605 2.89033 159.881 2.15933 161.343 1.672C162.805 1.156 164.396 0.898 166.116 0.898C167.721 0.898 169.226 1.08433 170.631 1.457C172.035 1.82966 173.11 2.188 173.856 2.532V10.1H169.126V6.144C168.237 5.94333 167.348 5.843 166.46 5.843C165.657 5.843 164.854 5.972 164.052 6.23C163.278 6.45933 162.575 6.84633 161.945 7.391C161.343 7.907 160.841 8.56633 160.44 9.369C160.067 10.1717 159.881 11.132 159.881 12.25C159.881 13.1673 160.038 14.013 160.354 14.787C160.669 15.561 161.113 16.2347 161.687 16.808C162.26 17.3813 162.962 17.84 163.794 18.184C164.654 18.4993 165.6 18.657 166.632 18.657C168.266 18.657 169.599 18.4277 170.631 17.969C171.691 17.5103 172.48 17.109 172.996 16.765L174.845 20.936Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

Some files were not shown because too many files have changed in this diff Show More