Compare commits
28 Commits
feature/au
...
1156a32a88
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1156a32a88 | ||
| d848af524f | |||
| c9f88c36cd | |||
| 0ad44a944e | |||
| fbd0e03a33 | |||
|
|
076828f003 | ||
|
|
4f6c459532 | ||
|
|
11524c17ea | ||
| 168f845772 | |||
| 657146cdca | |||
|
|
86111bc9f5 | ||
|
|
a8018a0b2f | ||
|
|
9d8bd629ab | ||
| 1eddf9cc0b | |||
| 6415f86286 | |||
| 5d1c4f0084 | |||
| 1dce947db6 | |||
| 4d9551a93c | |||
| e6471280d5 | |||
| 3e062b4346 | |||
| 5b1a93c781 | |||
| c30001547a | |||
| 025019b544 | |||
| a862a11c91 | |||
| f3d86daea7 | |||
| 296716397e | |||
| 22c42839c1 | |||
| 4fd90e305f |
@@ -1 +0,0 @@
|
|||||||
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
name: 'Deploy on push'
|
name: 'Deploy to discoursio-api'
|
||||||
on: [push]
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Cloning repo
|
- name: Cloning repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -18,28 +21,11 @@ jobs:
|
|||||||
id: branch_name
|
id: branch_name
|
||||||
run: echo "::set-output name=branch::$(echo ${GITHUB_REF##*/})"
|
run: echo "::set-output name=branch::$(echo ${GITHUB_REF##*/})"
|
||||||
|
|
||||||
- name: Push to dokku for main branch
|
- name: Push to dokku
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
uses: dokku/github-action@master
|
uses: dokku/github-action@master
|
||||||
with:
|
with:
|
||||||
branch: 'main'
|
branch: 'main'
|
||||||
git_remote_url: 'ssh://dokku@v2.discours.io:22/discoursio-api'
|
git_remote_url: 'ssh://dokku@v2.discours.io:22/discoursio-api'
|
||||||
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
|
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
|
||||||
- name: Push to dokku for dev branch
|
|
||||||
if: github.ref == 'refs/heads/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'
|
|
||||||
|
|||||||
16
.github/workflows/checks.yml
vendored
Normal file
16
.github/workflows/checks.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
name: Checks
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Checks
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: 3.10.6
|
||||||
|
- run: pip install --upgrade pip
|
||||||
|
- run: pip install -r requirements.txt
|
||||||
|
- run: pip install -r requirements-dev.txt
|
||||||
|
- run: ./checks.sh
|
||||||
6
.github/workflows/deploy.yml
vendored
6
.github/workflows/deploy.yml
vendored
@@ -11,17 +11,17 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source repository
|
- name: Checkout source repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: webfactory/ssh-agent@v0.8.0
|
- uses: webfactory/ssh-agent@v0.8.0
|
||||||
with:
|
with:
|
||||||
ssh-private-key: ${{ github.action.secrets.SSH_PRIVATE_KEY }}
|
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
|
||||||
- name: Push to dokku
|
- name: Push to dokku
|
||||||
env:
|
env:
|
||||||
HOST_KEY: ${{ github.action.secrets.HOST_KEY }}
|
HOST_KEY: ${{ secrets.HOST_KEY }}
|
||||||
run: |
|
run: |
|
||||||
echo $HOST_KEY > ~/.ssh/known_hosts
|
echo $HOST_KEY > ~/.ssh/known_hosts
|
||||||
git remote add dokku dokku@v2.discours.io:discoursio-api
|
git remote add dokku dokku@v2.discours.io:discoursio-api
|
||||||
|
|||||||
23
.gitignore
vendored
23
.gitignore
vendored
@@ -147,25 +147,6 @@ migration/content/**/*.md
|
|||||||
*.csv
|
*.csv
|
||||||
dev-server.pid
|
dev-server.pid
|
||||||
backups/
|
backups/
|
||||||
poetry.lock
|
|
||||||
.ruff_cache
|
.ruff_cache
|
||||||
.jj
|
.venv
|
||||||
.zed
|
poetry.lock
|
||||||
|
|
||||||
dokku_config
|
|
||||||
|
|
||||||
*.db
|
|
||||||
*.sqlite3
|
|
||||||
views.json
|
|
||||||
*.pem
|
|
||||||
*.key
|
|
||||||
*.crt
|
|
||||||
*cache.json
|
|
||||||
.cursor
|
|
||||||
|
|
||||||
node_modules/
|
|
||||||
panel/graphql/generated/
|
|
||||||
panel/types.gen.ts
|
|
||||||
|
|
||||||
.cursorrules
|
|
||||||
.cursor/
|
|
||||||
|
|||||||
@@ -1,74 +1,44 @@
|
|||||||
|
exclude: |
|
||||||
|
(?x)(
|
||||||
|
^tests/unit_tests/resource|
|
||||||
|
_grpc.py|
|
||||||
|
_pb2.py
|
||||||
|
)
|
||||||
|
|
||||||
|
default_language_version:
|
||||||
|
python: python3.10
|
||||||
|
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v5.0.0
|
rev: v4.5.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-yaml
|
- id: check-added-large-files
|
||||||
|
- id: check-case-conflict
|
||||||
|
- id: check-docstring-first
|
||||||
|
- id: check-json
|
||||||
|
- id: check-merge-conflict
|
||||||
- id: check-toml
|
- id: check-toml
|
||||||
|
- id: check-yaml
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- id: check-added-large-files
|
- id: requirements-txt-fixer
|
||||||
- id: detect-private-key
|
|
||||||
- id: check-ast
|
|
||||||
- id: check-merge-conflict
|
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/timothycrosley/isort
|
||||||
rev: v0.11.12
|
rev: 5.12.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: isort
|
||||||
name: ruff lint with fixes
|
|
||||||
args: [
|
|
||||||
--fix,
|
|
||||||
--ignore, UP035,
|
|
||||||
--ignore, UP006,
|
|
||||||
--ignore, TRY400,
|
|
||||||
--ignore, TRY401,
|
|
||||||
--ignore, FBT001,
|
|
||||||
--ignore, FBT002,
|
|
||||||
--ignore, ARG002,
|
|
||||||
--ignore, SLF001,
|
|
||||||
--ignore, RUF012,
|
|
||||||
--ignore, RUF013,
|
|
||||||
--ignore, PERF203,
|
|
||||||
--ignore, PERF403,
|
|
||||||
--ignore, SIM105,
|
|
||||||
--ignore, SIM108,
|
|
||||||
--ignore, SIM118,
|
|
||||||
--ignore, S110,
|
|
||||||
--ignore, PLR0911,
|
|
||||||
--ignore, RET504,
|
|
||||||
--ignore, INP001,
|
|
||||||
--ignore, F811,
|
|
||||||
--ignore, F841,
|
|
||||||
--ignore, B012,
|
|
||||||
--ignore, E712,
|
|
||||||
--ignore, ANN001,
|
|
||||||
--ignore, ANN201,
|
|
||||||
--ignore, SIM102,
|
|
||||||
--ignore, FBT003
|
|
||||||
]
|
|
||||||
- id: ruff-format
|
|
||||||
name: ruff format
|
|
||||||
|
|
||||||
# Временно отключаем mypy для стабильности
|
- repo: https://github.com/ambv/black
|
||||||
# - repo: https://github.com/pre-commit/mirrors-mypy
|
rev: 23.10.1
|
||||||
# rev: v1.16.0
|
hooks:
|
||||||
# hooks:
|
- id: black
|
||||||
# - id: mypy
|
|
||||||
# name: mypy type checking
|
- repo: https://github.com/PyCQA/flake8
|
||||||
# entry: mypy
|
rev: 6.1.0
|
||||||
# language: python
|
hooks:
|
||||||
# types: [python]
|
- id: flake8
|
||||||
# require_serial: true
|
|
||||||
# additional_dependencies: [
|
# - repo: https://github.com/python/mypy
|
||||||
# "types-redis",
|
# rev: v1.6.1
|
||||||
# "types-requests",
|
# hooks:
|
||||||
# "types-passlib",
|
# - id: mypy
|
||||||
# "types-Authlib",
|
|
||||||
# "sqlalchemy[mypy]"
|
|
||||||
# ]
|
|
||||||
# args: [
|
|
||||||
# "--config-file=mypy.ini",
|
|
||||||
# "--show-error-codes",
|
|
||||||
# "--no-error-summary",
|
|
||||||
# "--ignore-missing-imports"
|
|
||||||
# ]
|
|
||||||
|
|||||||
1803
CHANGELOG.md
1803
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
134
CONTRIBUTING.md
134
CONTRIBUTING.md
@@ -1,134 +0,0 @@
|
|||||||
# Contributing to Discours Core
|
|
||||||
|
|
||||||
🎉 Thanks for taking the time to contribute!
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create a feature branch: `git checkout -b my-new-feature`
|
|
||||||
3. Make your changes
|
|
||||||
4. Add tests for your changes
|
|
||||||
5. Run the test suite: `pytest`
|
|
||||||
6. Run the linter: `ruff check . --fix && ruff format . --line-length=120`
|
|
||||||
7. Commit your changes: `git commit -am 'Add some feature'`
|
|
||||||
8. Push to the branch: `git push origin my-new-feature`
|
|
||||||
9. Create a Pull Request
|
|
||||||
|
|
||||||
## 📋 Development Guidelines
|
|
||||||
|
|
||||||
### Code Style
|
|
||||||
|
|
||||||
- **Python 3.12+** required
|
|
||||||
- **Line length**: 120 characters max
|
|
||||||
- **Type hints**: Required for all functions
|
|
||||||
- **Docstrings**: Required for public methods
|
|
||||||
- **Ruff**: linting and formatting
|
|
||||||
- **MyPy**: typechecks
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
- **Pytest** for testing
|
|
||||||
- **85%+ coverage** required
|
|
||||||
- Test both positive and negative cases
|
|
||||||
- Mock external dependencies
|
|
||||||
|
|
||||||
### Commit Messages
|
|
||||||
|
|
||||||
We follow [Conventional Commits](https://conventionalcommits.org/):
|
|
||||||
|
|
||||||
```
|
|
||||||
feat: add user authentication
|
|
||||||
fix: resolve database connection issue
|
|
||||||
docs: update API documentation
|
|
||||||
test: add tests for reaction system
|
|
||||||
refactor: improve GraphQL resolvers
|
|
||||||
```
|
|
||||||
|
|
||||||
### Python Code Standards
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Good example
|
|
||||||
async def create_reaction(
|
|
||||||
session: Session,
|
|
||||||
author_id: int,
|
|
||||||
reaction_data: dict[str, Any]
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Create a new reaction.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: Database session
|
|
||||||
author_id: ID of the author creating the reaction
|
|
||||||
reaction_data: Reaction data
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Created reaction data
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If reaction data is invalid
|
|
||||||
"""
|
|
||||||
if not reaction_data.get("kind"):
|
|
||||||
raise ValueError("Reaction kind is required")
|
|
||||||
|
|
||||||
reaction = Reaction(**reaction_data)
|
|
||||||
session.add(reaction)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
return reaction.dict()
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🐛 Bug Reports
|
|
||||||
|
|
||||||
When filing a bug report, please include:
|
|
||||||
|
|
||||||
- **Python version**
|
|
||||||
- **Package versions** (`pip freeze`)
|
|
||||||
- **Error message** and full traceback
|
|
||||||
- **Steps to reproduce**
|
|
||||||
- **Expected vs actual behavior**
|
|
||||||
|
|
||||||
## 💡 Feature Requests
|
|
||||||
|
|
||||||
For feature requests, please include:
|
|
||||||
|
|
||||||
- **Use case** description
|
|
||||||
- **Proposed solution**
|
|
||||||
- **Alternatives considered**
|
|
||||||
- **Breaking changes** (if any)
|
|
||||||
|
|
||||||
## 📚 Documentation
|
|
||||||
|
|
||||||
- Update documentation for new features
|
|
||||||
- Add examples for complex functionality
|
|
||||||
- Use Russian comments for Russian-speaking team members
|
|
||||||
- Keep README.md up to date
|
|
||||||
|
|
||||||
## 🔍 Code Review Process
|
|
||||||
|
|
||||||
1. **Automated checks** must pass (tests, linting)
|
|
||||||
2. **Manual review** by at least one maintainer
|
|
||||||
3. **Documentation** must be updated if needed
|
|
||||||
4. **Breaking changes** require discussion
|
|
||||||
|
|
||||||
## 🏷️ Release Process
|
|
||||||
|
|
||||||
We follow [Semantic Versioning](https://semver.org/):
|
|
||||||
|
|
||||||
- **MAJOR**: Breaking changes
|
|
||||||
- **MINOR**: New features (backward compatible)
|
|
||||||
- **PATCH**: Bug fixes (backward compatible)
|
|
||||||
|
|
||||||
## 🤝 Community
|
|
||||||
|
|
||||||
- Be respectful and inclusive
|
|
||||||
- Help newcomers get started
|
|
||||||
- Share knowledge and best practices
|
|
||||||
- Follow our [Code of Conduct](CODE_OF_CONDUCT.md)
|
|
||||||
|
|
||||||
## 📞 Getting Help
|
|
||||||
|
|
||||||
- **Issues**: For bugs and feature requests
|
|
||||||
- **Discussions**: For questions and general discussion
|
|
||||||
- **Documentation**: Check `docs/` folder first
|
|
||||||
|
|
||||||
Thank you for contributing! 🙏
|
|
||||||
29
Dockerfile
29
Dockerfile
@@ -1,26 +1,11 @@
|
|||||||
FROM python:slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
postgresql-client \
|
|
||||||
curl \
|
|
||||||
build-essential \
|
|
||||||
gnupg \
|
|
||||||
ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Установка Node.js LTS и npm
|
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
|
|
||||||
apt-get install -y nodejs \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
EXPOSE 8080
|
||||||
RUN npm ci
|
ADD nginx.conf.sigil ./
|
||||||
COPY . .
|
COPY requirements.txt .
|
||||||
RUN npm run build
|
RUN apt update && apt install -y git gcc curl postgresql
|
||||||
RUN pip install -r requirements.txt
|
RUN pip install -r requirements.txt
|
||||||
|
COPY . .
|
||||||
|
|
||||||
EXPOSE 8000
|
CMD python server.py
|
||||||
|
|
||||||
CMD ["python", "-m", "granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port", "8000"]
|
|
||||||
|
|||||||
21
LICENSE
21
LICENSE
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2025 Discours Team
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
193
README.md
193
README.md
@@ -1,180 +1,47 @@
|
|||||||
# GraphQL API Backend
|
# discoursio-api
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||

|
- sqlalchemy
|
||||||

|
- redis
|
||||||

|
- ariadne
|
||||||

|
- starlette
|
||||||

|
- uvicorn
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
</div>
|
on osx
|
||||||
|
```
|
||||||
Backend service providing GraphQL API for content management system with reactions, ratings and topics.
|
brew install redis nginx postgres
|
||||||
|
brew services start redis
|
||||||
## 📚 Documentation
|
|
||||||
|
|
||||||
- [API Documentation](docs/api.md)
|
|
||||||
- [Authentication Guide](docs/auth.md)
|
|
||||||
- [Caching System](docs/redis-schema.md)
|
|
||||||
- [Features Overview](docs/features.md)
|
|
||||||
- [RBAC System](docs/rbac-system.md)
|
|
||||||
|
|
||||||
## 🚀 Core Features
|
|
||||||
### 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
|
|
||||||
|
|
||||||
### Reactions System
|
|
||||||
- `ReactionKind` types: LIKE, DISLIKE, COMMENT
|
|
||||||
- Rating calculation for shouts and comments
|
|
||||||
- User-specific reaction tracking
|
|
||||||
- Reaction stats and aggregations
|
|
||||||
- Nested comments support
|
|
||||||
|
|
||||||
### Authors & Topics
|
|
||||||
- Author profiles with stats
|
|
||||||
- Topic categorization and hierarchy
|
|
||||||
- Following system for authors/topics
|
|
||||||
- Activity tracking and stats
|
|
||||||
- Community features
|
|
||||||
|
|
||||||
### RBAC & Permissions
|
|
||||||
- RBAC with hierarchy using Redis
|
|
||||||
|
|
||||||
## 🛠️ Tech Stack
|
|
||||||
|
|
||||||
**Core:** Python 3.12 • GraphQL • PostgreSQL • SQLAlchemy • JWT • Redis • txtai
|
|
||||||
**Server:** Starlette • Granian 1.8.0 • Nginx
|
|
||||||
**Frontend:** SolidJS 1.9.1 • TypeScript 5.7.2 • Vite 5.4.11
|
|
||||||
**GraphQL:** Ariadne 0.23.0
|
|
||||||
**Tools:** Pytest • MyPy • Biome 2.0.6
|
|
||||||
|
|
||||||
## 🔧 Development
|
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
### 📦 Prepare environment:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
python3.12 -m venv venv
|
|
||||||
source venv/bin/activate
|
|
||||||
pip install -r requirements.dev.txt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Run server
|
on debian/ubuntu
|
||||||
|
```
|
||||||
First, certificates are required to run the server with HTTPS.
|
apt install redis nginx
|
||||||
|
|
||||||
```shell
|
|
||||||
mkcert -install
|
|
||||||
mkcert localhost
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, run the server:
|
# Local development
|
||||||
|
|
||||||
```shell
|
Install deps first
|
||||||
python -m granian main:app --interface asgi
|
|
||||||
|
```
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
pre-commit install
|
||||||
```
|
```
|
||||||
|
|
||||||
### ⚡ Useful Commands
|
Create database from backup
|
||||||
|
```
|
||||||
```shell
|
./restdb.sh
|
||||||
# Linting and formatting with Biome
|
|
||||||
biome check . --write
|
|
||||||
|
|
||||||
# Lint only
|
|
||||||
biome lint .
|
|
||||||
|
|
||||||
# Format only
|
|
||||||
biome format . --write
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
pytest
|
|
||||||
|
|
||||||
# Type checking
|
|
||||||
mypy .
|
|
||||||
|
|
||||||
# dev run
|
|
||||||
python -m granian main:app --interface asgi
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 📝 Code Style
|
Start local server
|
||||||
|
```
|
||||||

|
python3 server.py dev
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
**Biome 2.1.2** for linting and formatting • **120 char** lines • **Type hints** required • **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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
# How to do an authorized request
|
||||||
|
|
||||||
## 📊 Project Stats
|
Put the header 'Authorization' with token from signIn query or registerUser mutation.
|
||||||
|
|
||||||
<div align="center">
|
# How to debug Ackee
|
||||||
|
|
||||||

|
Set ACKEE_TOKEN var
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
|
||||||
|
|
||||||
[CHANGELOG.md](CHANGELOG.md)
|
|
||||||
|
|
||||||
 • [Read the guide](CONTRIBUTING.md)
|
|
||||||
|
|
||||||
We welcome contributions! Please read our contributing guide before submitting PRs.
|
|
||||||
|
|
||||||
## 📄 License
|
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
||||||
|
|
||||||
## 🔗 Links
|
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||
• [discours.io](https://discours.io)
|
|
||||||
• [Source Code](https://github.com/discours/core)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
**Made with ❤️ by the Discours Team**
|
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Получаем путь к корневой директории проекта
|
|
||||||
root_path = os.path.abspath(os.path.dirname(__file__))
|
|
||||||
sys.path.append(root_path)
|
|
||||||
27
alembic.ini
27
alembic.ini
@@ -6,6 +6,8 @@ script_location = alembic
|
|||||||
|
|
||||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
# Uncomment the line below if you want the files to be prepended with date and time
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
# sys.path path, will be prepended to sys.path if present.
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
@@ -33,17 +35,32 @@ prepend_sys_path = .
|
|||||||
# versions/ directory
|
# versions/ directory
|
||||||
# sourceless = false
|
# sourceless = false
|
||||||
|
|
||||||
# version number format.
|
# version location specification; This defaults
|
||||||
version_num_format = %%04d
|
# to alembic/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||||
|
|
||||||
# version name format.
|
# version path separator; As mentioned above, this is the character used to split
|
||||||
version_name_format = %%s
|
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||||
|
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||||
|
# Valid values for version_path_separator are:
|
||||||
|
#
|
||||||
|
# version_path_separator = :
|
||||||
|
# version_path_separator = ;
|
||||||
|
# version_path_separator = space
|
||||||
|
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
# the output encoding used when revision files
|
# the output encoding used when revision files
|
||||||
# are written from script.py.mako
|
# are written from script.py.mako
|
||||||
# output_encoding = utf-8
|
# output_encoding = utf-8
|
||||||
|
|
||||||
sqlalchemy.url = sqlite:///discoursio.db
|
sqlalchemy.url = %(DB_URL)
|
||||||
|
|
||||||
|
|
||||||
[post_write_hooks]
|
[post_write_hooks]
|
||||||
|
|||||||
3
alembic/README
Normal file
3
alembic/README
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
|
|
||||||
|
https://alembic.sqlalchemy.org/en/latest/tutorial.html
|
||||||
@@ -2,9 +2,8 @@ from logging.config import fileConfig
|
|||||||
|
|
||||||
from sqlalchemy import engine_from_config, pool
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
# Импорт всех моделей для корректной генерации миграций
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
from services.db import Base
|
from base.orm import Base
|
||||||
from settings import DB_URL
|
from settings import DB_URL
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
# this is the Alembic Config object, which provides
|
||||||
@@ -12,7 +11,7 @@ from settings import DB_URL
|
|||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
# override DB_URL
|
# override DB_URL
|
||||||
config.set_main_option("sqlalchemy.url", DB_URL)
|
config.set_section_option(config.config_ini_section, "DB_URL", DB_URL)
|
||||||
|
|
||||||
# Interpret the config file for Python logging.
|
# Interpret the config file for Python logging.
|
||||||
# This line sets up loggers basically.
|
# This line sets up loggers basically.
|
||||||
|
|||||||
@@ -5,15 +5,17 @@ Revises: ${down_revision | comma,n}
|
|||||||
Create Date: ${create_date}
|
Create Date: ${create_date}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
${imports if imports else ""}
|
${imports if imports else ""}
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = ${repr(up_revision)}
|
revision: str = ${repr(up_revision)}
|
||||||
down_revision = ${repr(down_revision)}
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
branch_labels = ${repr(branch_labels)}
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
depends_on = ${repr(depends_on)}
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
|||||||
26
alembic/versions/fe943b098418_init_alembic.py
Normal file
26
alembic/versions/fe943b098418_init_alembic.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""init alembic
|
||||||
|
|
||||||
|
Revision ID: fe943b098418
|
||||||
|
Revises:
|
||||||
|
Create Date: 2023-08-19 01:37:57.031933
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
# import sqlalchemy as sa
|
||||||
|
|
||||||
|
# from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "fe943b098418"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
15
app.json
15
app.json
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"healthchecks": {
|
|
||||||
"web": [
|
|
||||||
{
|
|
||||||
"type": "startup",
|
|
||||||
"name": "web check",
|
|
||||||
"description": "Checking if the app responds to the GET /",
|
|
||||||
"path": "/",
|
|
||||||
"attempts": 3,
|
|
||||||
"warn": true,
|
|
||||||
"initialDelay": 1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
179
auth/__init__.py
179
auth/__init__.py
@@ -1,179 +0,0 @@
|
|||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import JSONResponse, RedirectResponse, Response
|
|
||||||
|
|
||||||
from auth.internal import verify_internal_auth
|
|
||||||
from auth.orm import Author
|
|
||||||
from auth.tokens.storage import TokenStorage
|
|
||||||
from services.db import local_session
|
|
||||||
from settings import (
|
|
||||||
SESSION_COOKIE_HTTPONLY,
|
|
||||||
SESSION_COOKIE_MAX_AGE,
|
|
||||||
SESSION_COOKIE_NAME,
|
|
||||||
SESSION_COOKIE_SAMESITE,
|
|
||||||
SESSION_COOKIE_SECURE,
|
|
||||||
SESSION_TOKEN_HEADER,
|
|
||||||
)
|
|
||||||
from utils.logger import root_logger as logger
|
|
||||||
|
|
||||||
|
|
||||||
async def logout(request: Request) -> Response:
|
|
||||||
"""
|
|
||||||
Выход из системы с удалением сессии и cookie.
|
|
||||||
|
|
||||||
Поддерживает получение токена из:
|
|
||||||
1. HTTP-only cookie
|
|
||||||
2. Заголовка Authorization
|
|
||||||
"""
|
|
||||||
token = None
|
|
||||||
# Получаем токен из cookie
|
|
||||||
if SESSION_COOKIE_NAME in request.cookies:
|
|
||||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
|
||||||
logger.debug(f"[auth] logout: Получен токен из cookie {SESSION_COOKIE_NAME}")
|
|
||||||
|
|
||||||
# Если токен не найден в cookie, проверяем заголовок
|
|
||||||
if not token:
|
|
||||||
# Сначала проверяем основной заголовок авторизации
|
|
||||||
auth_header = request.headers.get(SESSION_TOKEN_HEADER)
|
|
||||||
if auth_header:
|
|
||||||
if auth_header.startswith("Bearer "):
|
|
||||||
token = auth_header[7:].strip()
|
|
||||||
logger.debug(f"[auth] logout: Получен Bearer токен из заголовка {SESSION_TOKEN_HEADER}")
|
|
||||||
else:
|
|
||||||
token = auth_header.strip()
|
|
||||||
logger.debug(f"[auth] logout: Получен прямой токен из заголовка {SESSION_TOKEN_HEADER}")
|
|
||||||
|
|
||||||
# Если токен не найден в основном заголовке, проверяем стандартный Authorization
|
|
||||||
if not token and "Authorization" in request.headers:
|
|
||||||
auth_header = request.headers.get("Authorization")
|
|
||||||
if auth_header and auth_header.startswith("Bearer "):
|
|
||||||
token = auth_header[7:].strip()
|
|
||||||
logger.debug("[auth] logout: Получен Bearer токен из заголовка Authorization")
|
|
||||||
|
|
||||||
# Если токен найден, отзываем его
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
logger.info("[auth] logout: Cookie успешно удалена")
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
async def refresh_token(request: Request) -> JSONResponse:
|
|
||||||
"""
|
|
||||||
Обновление токена аутентификации.
|
|
||||||
|
|
||||||
Поддерживает получение токена из:
|
|
||||||
1. HTTP-only cookie
|
|
||||||
2. Заголовка Authorization
|
|
||||||
|
|
||||||
Возвращает новый токен как в HTTP-only cookie, так и в теле ответа.
|
|
||||||
"""
|
|
||||||
token = None
|
|
||||||
source = None
|
|
||||||
|
|
||||||
# Получаем текущий токен из cookie
|
|
||||||
if SESSION_COOKIE_NAME in request.cookies:
|
|
||||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
|
||||||
source = "cookie"
|
|
||||||
logger.debug(f"[auth] refresh_token: Токен получен из cookie {SESSION_COOKIE_NAME}")
|
|
||||||
|
|
||||||
# Если токен не найден в cookie, проверяем заголовок авторизации
|
|
||||||
if not token:
|
|
||||||
# Проверяем основной заголовок авторизации
|
|
||||||
auth_header = request.headers.get(SESSION_TOKEN_HEADER)
|
|
||||||
if auth_header:
|
|
||||||
if auth_header.startswith("Bearer "):
|
|
||||||
token = auth_header[7:].strip()
|
|
||||||
source = "header"
|
|
||||||
logger.debug(f"[auth] refresh_token: Токен получен из заголовка {SESSION_TOKEN_HEADER} (Bearer)")
|
|
||||||
else:
|
|
||||||
token = auth_header.strip()
|
|
||||||
source = "header"
|
|
||||||
logger.debug(f"[auth] refresh_token: Токен получен из заголовка {SESSION_TOKEN_HEADER} (прямой)")
|
|
||||||
|
|
||||||
# Если токен не найден в основном заголовке, проверяем стандартный Authorization
|
|
||||||
if not token and "Authorization" in request.headers:
|
|
||||||
auth_header = request.headers.get("Authorization")
|
|
||||||
if auth_header and auth_header.startswith("Bearer "):
|
|
||||||
token = auth_header[7:].strip()
|
|
||||||
source = "header"
|
|
||||||
logger.debug("[auth] refresh_token: Токен получен из заголовка Authorization")
|
|
||||||
|
|
||||||
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).filter(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)
|
|
||||||
|
|
||||||
# Создаем ответ
|
|
||||||
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,
|
|
||||||
max_age=SESSION_COOKIE_MAX_AGE,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"[auth] refresh_token: Токен успешно обновлен для пользователя {user_id}")
|
|
||||||
return response
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[auth] refresh_token: Ошибка при обновлении токена: {e}")
|
|
||||||
return JSONResponse({"success": False, "error": str(e)}, status_code=401)
|
|
||||||
89
auth/authenticate.py
Normal file
89
auth/authenticate.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from functools import wraps
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from graphql.type import GraphQLResolveInfo
|
||||||
|
from sqlalchemy.orm import exc, joinedload
|
||||||
|
from starlette.authentication import AuthenticationBackend
|
||||||
|
from starlette.requests import HTTPConnection
|
||||||
|
|
||||||
|
from auth.credentials import AuthCredentials, AuthUser
|
||||||
|
from auth.tokenstorage import SessionToken
|
||||||
|
from base.exceptions import OperationNotAllowed
|
||||||
|
from base.orm import local_session
|
||||||
|
from orm.user import Role, User
|
||||||
|
from settings import SESSION_TOKEN_HEADER
|
||||||
|
|
||||||
|
|
||||||
|
class JWTAuthenticate(AuthenticationBackend):
|
||||||
|
async def authenticate(
|
||||||
|
self, request: HTTPConnection
|
||||||
|
) -> Optional[Tuple[AuthCredentials, AuthUser]]:
|
||||||
|
if SESSION_TOKEN_HEADER not in request.headers:
|
||||||
|
return AuthCredentials(scopes={}), AuthUser(user_id=None, username="")
|
||||||
|
|
||||||
|
token = request.headers.get(SESSION_TOKEN_HEADER)
|
||||||
|
if not token:
|
||||||
|
print("[auth.authenticate] no token in header %s" % SESSION_TOKEN_HEADER)
|
||||||
|
return AuthCredentials(scopes={}, error_message=str("no token")), AuthUser(
|
||||||
|
user_id=None, username=""
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(token.split(".")) > 1:
|
||||||
|
payload = await SessionToken.verify(token)
|
||||||
|
|
||||||
|
with local_session() as session:
|
||||||
|
try:
|
||||||
|
user = (
|
||||||
|
session.query(User)
|
||||||
|
.options(
|
||||||
|
joinedload(User.roles).options(joinedload(Role.permissions)),
|
||||||
|
joinedload(User.ratings),
|
||||||
|
)
|
||||||
|
.filter(User.id == payload.user_id)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
|
||||||
|
scopes = {} # TODO: integrate await user.get_permission()
|
||||||
|
|
||||||
|
return (
|
||||||
|
AuthCredentials(user_id=payload.user_id, scopes=scopes, logged_in=True),
|
||||||
|
AuthUser(user_id=user.id, username=""),
|
||||||
|
)
|
||||||
|
except exc.NoResultFound:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return AuthCredentials(scopes={}, error_message=str("Invalid token")), AuthUser(
|
||||||
|
user_id=None, username=""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(func):
|
||||||
|
@wraps(func)
|
||||||
|
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
|
||||||
|
# debug only
|
||||||
|
# print('[auth.authenticate] login required for %r with info %r' % (func, info))
|
||||||
|
auth: AuthCredentials = info.context["request"].auth
|
||||||
|
# print(auth)
|
||||||
|
if not auth or not auth.logged_in:
|
||||||
|
# raise Unauthorized(auth.error_message or "Please login")
|
||||||
|
return {"error": "Please login first"}
|
||||||
|
return await func(parent, info, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrap
|
||||||
|
|
||||||
|
|
||||||
|
def permission_required(resource, operation, func):
|
||||||
|
@wraps(func)
|
||||||
|
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
|
||||||
|
print(
|
||||||
|
"[auth.authenticate] permission_required for %r with info %r" % (func, info)
|
||||||
|
) # debug only
|
||||||
|
auth: AuthCredentials = info.context["request"].auth
|
||||||
|
if not auth.logged_in:
|
||||||
|
raise OperationNotAllowed(auth.error_message or "Please login")
|
||||||
|
|
||||||
|
# TODO: add actual check permission logix here
|
||||||
|
|
||||||
|
return await func(parent, info, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrap
|
||||||
@@ -1,95 +1,43 @@
|
|||||||
from typing import Any, Optional
|
from typing import List, Optional, Text
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel
|
||||||
|
|
||||||
# from base.exceptions import Unauthorized
|
# from base.exceptions import Unauthorized
|
||||||
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
|
|
||||||
|
|
||||||
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
|
|
||||||
|
|
||||||
|
|
||||||
class Permission(BaseModel):
|
class Permission(BaseModel):
|
||||||
"""Модель разрешения для RBAC"""
|
name: Text
|
||||||
|
|
||||||
resource: str
|
|
||||||
operation: str
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.resource}:{self.operation}"
|
|
||||||
|
|
||||||
|
|
||||||
class AuthCredentials(BaseModel):
|
class AuthCredentials(BaseModel):
|
||||||
"""
|
user_id: Optional[int] = None
|
||||||
Модель учетных данных авторизации.
|
scopes: Optional[dict] = {}
|
||||||
Используется как часть механизма аутентификации Starlette.
|
logged_in: bool = False
|
||||||
"""
|
error_message: str = ""
|
||||||
|
|
||||||
author_id: Optional[int] = Field(None, description="ID автора")
|
|
||||||
scopes: dict[str, set[str]] = Field(default_factory=dict, description="Разрешения пользователя")
|
|
||||||
logged_in: bool = Field(False, description="Флаг, указывающий, авторизован ли пользователь")
|
|
||||||
error_message: str = Field("", description="Сообщение об ошибке аутентификации")
|
|
||||||
email: Optional[str] = Field(None, description="Email пользователя")
|
|
||||||
token: Optional[str] = Field(None, description="JWT токен авторизации")
|
|
||||||
|
|
||||||
def get_permissions(self) -> list[str]:
|
|
||||||
"""
|
|
||||||
Возвращает список строковых представлений разрешений.
|
|
||||||
Например: ["posts:read", "posts:write", "comments:create"].
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List[str]: Список разрешений
|
|
||||||
"""
|
|
||||||
result = []
|
|
||||||
for resource, operations in self.scopes.items():
|
|
||||||
for operation in operations:
|
|
||||||
result.extend([f"{resource}:{operation}"])
|
|
||||||
return result
|
|
||||||
|
|
||||||
def has_permission(self, resource: str, operation: str) -> bool:
|
|
||||||
"""
|
|
||||||
Проверяет наличие определенного разрешения.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
resource: Ресурс (например, "posts")
|
|
||||||
operation: Операция (например, "read")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True, если пользователь имеет указанное разрешение
|
|
||||||
"""
|
|
||||||
if not self.logged_in:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return resource in self.scopes and operation in self.scopes[resource]
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_admin(self) -> bool:
|
def is_admin(self):
|
||||||
"""
|
# TODO: check admin logix
|
||||||
Проверяет, является ли пользователь администратором.
|
return True
|
||||||
|
|
||||||
Returns:
|
async def permissions(self) -> List[Permission]:
|
||||||
bool: True, если email пользователя находится в списке ADMIN_EMAILS
|
if self.user_id is None:
|
||||||
"""
|
|
||||||
return self.email in ADMIN_EMAILS if self.email else False
|
|
||||||
|
|
||||||
async def to_dict(self) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Преобразует учетные данные в словарь
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict[str, Any]: Словарь с данными учетных данных
|
|
||||||
"""
|
|
||||||
permissions = self.get_permissions()
|
|
||||||
return {
|
|
||||||
"author_id": self.author_id,
|
|
||||||
"logged_in": self.logged_in,
|
|
||||||
"is_admin": self.is_admin,
|
|
||||||
"permissions": list(permissions),
|
|
||||||
}
|
|
||||||
|
|
||||||
async def permissions(self) -> list[Permission]:
|
|
||||||
if self.author_id is None:
|
|
||||||
# raise Unauthorized("Please login first")
|
# raise Unauthorized("Please login first")
|
||||||
return [] # Возвращаем пустой список вместо dict
|
return {"error": "Please login first"}
|
||||||
|
else:
|
||||||
# TODO: implement permissions logix
|
# TODO: implement permissions logix
|
||||||
print(self.author_id)
|
print(self.user_id)
|
||||||
return [] # Возвращаем пустой список вместо NotImplemented
|
return NotImplemented
|
||||||
|
|
||||||
|
|
||||||
|
class AuthUser(BaseModel):
|
||||||
|
user_id: Optional[int]
|
||||||
|
username: Optional[str]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_authenticated(self) -> bool:
|
||||||
|
return self.user_id is not None
|
||||||
|
|
||||||
|
# @property
|
||||||
|
# def display_id(self) -> int:
|
||||||
|
# return self.user_id
|
||||||
|
|||||||
@@ -1,521 +0,0 @@
|
|||||||
from collections.abc import Callable
|
|
||||||
from functools import wraps
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
from graphql import GraphQLError, GraphQLResolveInfo
|
|
||||||
from sqlalchemy import exc
|
|
||||||
|
|
||||||
from auth.credentials import AuthCredentials
|
|
||||||
from auth.exceptions import OperationNotAllowed
|
|
||||||
from auth.internal import authenticate
|
|
||||||
from auth.orm import Author
|
|
||||||
from orm.community import CommunityAuthor
|
|
||||||
from services.db import local_session
|
|
||||||
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
|
|
||||||
from settings import SESSION_COOKIE_NAME, SESSION_TOKEN_HEADER
|
|
||||||
from utils.logger import root_logger as logger
|
|
||||||
|
|
||||||
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
|
|
||||||
|
|
||||||
|
|
||||||
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)}")
|
|
||||||
|
|
||||||
# Второй приоритет: метод 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
|
|
||||||
|
|
||||||
|
|
||||||
def get_auth_token(request: Any) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Извлекает токен авторизации из запроса.
|
|
||||||
Порядок проверки:
|
|
||||||
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:
|
|
||||||
logger.debug(f"[decorators] Токен получен из request.auth: {len(token)}")
|
|
||||||
return token
|
|
||||||
|
|
||||||
# 2. Проверяем наличие auth в scope
|
|
||||||
if hasattr(request, "scope") and isinstance(request.scope, dict) and "auth" in request.scope:
|
|
||||||
auth_info = request.scope.get("auth", {})
|
|
||||||
if isinstance(auth_info, dict) and "token" in auth_info:
|
|
||||||
token = auth_info["token"]
|
|
||||||
logger.debug(f"[decorators] Токен получен из request.scope['auth']: {len(token)}")
|
|
||||||
return token
|
|
||||||
|
|
||||||
# 3. Проверяем заголовок Authorization
|
|
||||||
headers = get_safe_headers(request)
|
|
||||||
|
|
||||||
# Сначала проверяем основной заголовок авторизации
|
|
||||||
auth_header = headers.get(SESSION_TOKEN_HEADER.lower(), "")
|
|
||||||
if auth_header:
|
|
||||||
if auth_header.startswith("Bearer "):
|
|
||||||
token = auth_header[7:].strip()
|
|
||||||
logger.debug(f"[decorators] Токен получен из заголовка {SESSION_TOKEN_HEADER}: {len(token)}")
|
|
||||||
return token
|
|
||||||
token = auth_header.strip()
|
|
||||||
logger.debug(f"[decorators] Прямой токен получен из заголовка {SESSION_TOKEN_HEADER}: {len(token)}")
|
|
||||||
return token
|
|
||||||
|
|
||||||
# Затем проверяем стандартный заголовок Authorization, если основной не определен
|
|
||||||
if SESSION_TOKEN_HEADER.lower() != "authorization":
|
|
||||||
auth_header = headers.get("authorization", "")
|
|
||||||
if auth_header and auth_header.startswith("Bearer "):
|
|
||||||
token = auth_header[7:].strip()
|
|
||||||
logger.debug(f"[decorators] Токен получен из заголовка Authorization: {len(token)}")
|
|
||||||
return token
|
|
||||||
|
|
||||||
# 4. Проверяем cookie
|
|
||||||
if hasattr(request, "cookies") and request.cookies:
|
|
||||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
|
||||||
if token:
|
|
||||||
logger.debug(f"[decorators] Токен получен из cookie {SESSION_COOKIE_NAME}: {len(token)}")
|
|
||||||
return token
|
|
||||||
|
|
||||||
# Если токен не найден ни в одном из мест
|
|
||||||
logger.debug("[decorators] Токен авторизации не найден")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[decorators] Ошибка при извлечении токена: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
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.error("[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
|
|
||||||
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 = get_auth_token(request)
|
|
||||||
if not token:
|
|
||||||
# Если токен не найден, бросаем ошибку авторизации
|
|
||||||
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.warning(f"[validate_graphql_context] Токен авторизации не найден: {client_info}")
|
|
||||||
msg = "Unauthorized - please login"
|
|
||||||
raise GraphQLError(msg)
|
|
||||||
|
|
||||||
# Логируем информацию о найденном токене
|
|
||||||
logger.debug(f"[validate_graphql_context] Токен найден, длина: {len(token)}")
|
|
||||||
|
|
||||||
# Используем единый механизм проверки токена из 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"Unauthorized - {error_msg}"
|
|
||||||
raise GraphQLError(msg)
|
|
||||||
|
|
||||||
# Если все проверки пройдены, создаем AuthCredentials и устанавливаем в request.scope
|
|
||||||
with local_session() as session:
|
|
||||||
try:
|
|
||||||
author = session.query(Author).filter(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.error("[validate_graphql_context] Не удалось установить auth: отсутствует request.scope")
|
|
||||||
msg = "Internal server error: unable to set authentication context"
|
|
||||||
raise GraphQLError(msg)
|
|
||||||
except exc.NoResultFound:
|
|
||||||
logger.error(f"[validate_graphql_context] Пользователь с ID {auth_state.author_id} не найден в базе данных")
|
|
||||||
msg = "Unauthorized - 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: Optional[GraphQLResolveInfo] = None, **kwargs: dict[str, Any]) -> Any:
|
|
||||||
# Подробное логирование для диагностики
|
|
||||||
logger.debug(f"[admin_auth_required] Начало проверки авторизации для {resolver.__name__}")
|
|
||||||
|
|
||||||
# Проверяем авторизацию пользователя
|
|
||||||
if info is None:
|
|
||||||
logger.error("[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 = 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.error("[admin_auth_required] Auth не найден ни в scope, ни в request")
|
|
||||||
|
|
||||||
if not auth or not getattr(auth, "logged_in", False):
|
|
||||||
logger.error("[admin_auth_required] Пользователь не авторизован после validate_graphql_context")
|
|
||||||
msg = "Unauthorized - 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.error(f"[admin_auth_required] ID автора не определен: {auth}")
|
|
||||||
msg = "Unauthorized - invalid user ID"
|
|
||||||
raise GraphQLError(msg)
|
|
||||||
|
|
||||||
author = session.query(Author).filter(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 = "Unauthorized - system admin access required"
|
|
||||||
raise GraphQLError(msg)
|
|
||||||
|
|
||||||
except exc.NoResultFound:
|
|
||||||
logger.error(f"[admin_auth_required] Пользователь с ID {auth.author_id} не найден в базе данных")
|
|
||||||
msg = "Unauthorized - 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 OperationNotAllowed(msg)
|
|
||||||
|
|
||||||
# Проверяем разрешения
|
|
||||||
with local_session() as session:
|
|
||||||
try:
|
|
||||||
author = session.query(Author).filter(Author.id == auth.author_id).one()
|
|
||||||
|
|
||||||
# Проверяем базовые условия
|
|
||||||
if author.is_locked():
|
|
||||||
msg = "Account is locked"
|
|
||||||
raise OperationNotAllowed(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()
|
|
||||||
if ca:
|
|
||||||
user_roles = ca.role_list
|
|
||||||
else:
|
|
||||||
user_roles = []
|
|
||||||
|
|
||||||
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 author.has_permission(resource, operation):
|
|
||||||
logger.warning(
|
|
||||||
f"[permission_required] У пользователя {author.email} нет разрешения {operation} на {resource}"
|
|
||||||
)
|
|
||||||
msg = f"No permission for {operation} on {resource}"
|
|
||||||
raise OperationNotAllowed(msg)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"[permission_required] Пользователь {author.email} имеет разрешение {operation} на {resource}"
|
|
||||||
)
|
|
||||||
return await func(parent, info, *args, **kwargs)
|
|
||||||
except exc.NoResultFound:
|
|
||||||
logger.error(f"[permission_required] Пользователь с ID {auth.author_id} не найден в базе данных")
|
|
||||||
msg = "User not found"
|
|
||||||
raise OperationNotAllowed(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).filter(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()
|
|
||||||
if ca:
|
|
||||||
user_roles = ca.role_list
|
|
||||||
else:
|
|
||||||
user_roles = []
|
|
||||||
logger.debug(f"[decorators] Роли пользователя {author_id}: {user_roles}")
|
|
||||||
|
|
||||||
# Проверяем наличие роли admin или editor
|
|
||||||
if "admin" in user_roles or "editor" in user_roles:
|
|
||||||
logger.debug(f"[decorators] Пользователь {author_id} имеет разрешение (роли: {user_roles})")
|
|
||||||
return await func(parent, info, *args, **kwargs)
|
|
||||||
|
|
||||||
# Если нет нужных ролей
|
|
||||||
logger.warning(f"[decorators] Пользователю {author_id} отказано в доступе. Роли: {user_roles}")
|
|
||||||
raise GraphQLError("Доступ запрещен. Требуется роль редактора или администратора.")
|
|
||||||
|
|
||||||
except GraphQLError:
|
|
||||||
# Пробрасываем ошибки авторизации далее
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[decorators] Неожиданная ошибка в editor_or_admin_required: {e}")
|
|
||||||
raise GraphQLError("Внутренняя ошибка сервера") from e
|
|
||||||
|
|
||||||
return wrap
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
from typing import Any
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from settings import MAILGUN_API_KEY, MAILGUN_DOMAIN
|
from settings import MAILGUN_API_KEY, MAILGUN_DOMAIN
|
||||||
@@ -9,9 +7,9 @@ noreply = "discours.io <noreply@%s>" % (MAILGUN_DOMAIN or "discours.io")
|
|||||||
lang_subject = {"ru": "Подтверждение почты", "en": "Confirm email"}
|
lang_subject = {"ru": "Подтверждение почты", "en": "Confirm email"}
|
||||||
|
|
||||||
|
|
||||||
async def send_auth_email(user: Any, token: str, lang: str = "ru", template: str = "email_confirmation") -> None:
|
async def send_auth_email(user, token, lang="ru", template="email_confirmation"):
|
||||||
try:
|
try:
|
||||||
to = f"{user.name} <{user.email}>"
|
to = "%s <%s>" % (user.name, user.email)
|
||||||
if lang not in ["ru", "en"]:
|
if lang not in ["ru", "en"]:
|
||||||
lang = "ru"
|
lang = "ru"
|
||||||
subject = lang_subject.get(lang, lang_subject["en"])
|
subject = lang_subject.get(lang, lang_subject["en"])
|
||||||
@@ -21,12 +19,12 @@ async def send_auth_email(user: Any, token: str, lang: str = "ru", template: str
|
|||||||
"to": to,
|
"to": to,
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
"template": template,
|
"template": template,
|
||||||
"h:X-Mailgun-Variables": f'{{ "token": "{token}" }}',
|
"h:X-Mailgun-Variables": '{ "token": "%s" }' % token,
|
||||||
}
|
}
|
||||||
print(f"[auth.email] payload: {payload!r}")
|
print("[auth.email] payload: %r" % payload)
|
||||||
# debug
|
# debug
|
||||||
# print('http://localhost:3000/?modal=auth&mode=confirm-email&token=%s' % token)
|
# print('http://localhost:3000/?modal=auth&mode=confirm-email&token=%s' % token)
|
||||||
response = requests.post(api_url, auth=("api", MAILGUN_API_KEY), data=payload, timeout=30)
|
response = requests.post(api_url, auth=("api", MAILGUN_API_KEY), data=payload)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
print(e)
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
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
|
|
||||||
"""
|
|
||||||
# Получаем стандартный контекст от базового класса
|
|
||||||
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 = request.scope.get("auth")
|
|
||||||
context["auth"] = auth_cred
|
|
||||||
# Безопасно логируем информацию о типе объекта auth
|
|
||||||
logger.debug(f"[graphql] Добавлены данные авторизации в контекст из scope: {type(auth_cred).__name__}")
|
|
||||||
|
|
||||||
logger.debug("[graphql] Подготовлен расширенный контекст для запроса")
|
|
||||||
|
|
||||||
return context
|
|
||||||
144
auth/identity.py
144
auth/identity.py
@@ -1,20 +1,15 @@
|
|||||||
from binascii import hexlify
|
from binascii import hexlify
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from typing import TYPE_CHECKING, Any, TypeVar
|
|
||||||
|
|
||||||
|
from jwt import DecodeError, ExpiredSignatureError
|
||||||
from passlib.hash import bcrypt
|
from passlib.hash import bcrypt
|
||||||
|
|
||||||
from auth.exceptions import ExpiredToken, InvalidPassword, InvalidToken
|
|
||||||
from auth.jwtcodec import JWTCodec
|
from auth.jwtcodec import JWTCodec
|
||||||
from services.db import local_session
|
from auth.tokenstorage import TokenStorage
|
||||||
from services.redis import redis
|
|
||||||
from utils.logger import root_logger as logger
|
|
||||||
|
|
||||||
# Для типизации
|
# from base.exceptions import InvalidPassword, InvalidToken
|
||||||
if TYPE_CHECKING:
|
from base.orm import local_session
|
||||||
from auth.orm import Author
|
from orm import User
|
||||||
|
|
||||||
AuthorType = TypeVar("AuthorType", bound="Author")
|
|
||||||
|
|
||||||
|
|
||||||
class Password:
|
class Password:
|
||||||
@@ -29,26 +24,17 @@ class Password:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def encode(password: str) -> str:
|
def encode(password: str) -> str:
|
||||||
"""
|
|
||||||
Кодирует пароль пользователя
|
|
||||||
|
|
||||||
Args:
|
|
||||||
password (str): Пароль пользователя
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Закодированный пароль
|
|
||||||
"""
|
|
||||||
password_sha256 = Password._get_sha256(password)
|
password_sha256 = Password._get_sha256(password)
|
||||||
return bcrypt.using(rounds=10).hash(password_sha256)
|
return bcrypt.using(rounds=10).hash(password_sha256)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def verify(password: str, hashed: str) -> bool:
|
def verify(password: str, hashed: str) -> bool:
|
||||||
r"""
|
"""
|
||||||
Verify that password hash is equal to specified hash. Hash format:
|
Verify that password hash is equal to specified hash. Hash format:
|
||||||
|
|
||||||
$2a$10$Ro0CUfOqk6cXEKf3dyaM7OhSCvnwM9s4wIX9JeLapehKK5YdLxKcm
|
$2a$10$Ro0CUfOqk6cXEKf3dyaM7OhSCvnwM9s4wIX9JeLapehKK5YdLxKcm
|
||||||
\__/\/ \____________________/\_____________________________/
|
__ __ ____________________________________________________ # noqa: W605
|
||||||
| | Salt Hash
|
| | | Salt (22) | Hash
|
||||||
| Cost
|
| Cost
|
||||||
Version
|
Version
|
||||||
|
|
||||||
@@ -66,102 +52,46 @@ class Password:
|
|||||||
|
|
||||||
class Identity:
|
class Identity:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def password(orm_author: AuthorType, password: str) -> AuthorType:
|
def password(orm_user: User, password: str) -> User:
|
||||||
"""
|
user = User(**orm_user.dict())
|
||||||
Проверяет пароль пользователя
|
if not user.password:
|
||||||
|
# raise InvalidPassword("User password is empty")
|
||||||
Args:
|
return {"error": "User password is empty"}
|
||||||
orm_author (Author): Объект пользователя
|
if not Password.verify(password, user.password):
|
||||||
password (str): Пароль пользователя
|
# raise InvalidPassword("Wrong user password")
|
||||||
|
return {"error": "Wrong user password"}
|
||||||
Returns:
|
return user
|
||||||
Author: Объект автора при успешной проверке
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
InvalidPassword: Если пароль не соответствует хешу или отсутствует
|
|
||||||
"""
|
|
||||||
# Импортируем внутри функции для избежания циклических импортов
|
|
||||||
from utils.logger import root_logger as logger
|
|
||||||
|
|
||||||
# Проверим исходный пароль в orm_author
|
|
||||||
if not orm_author.password:
|
|
||||||
logger.warning(f"[auth.identity] Пароль в исходном объекте автора пуст: email={orm_author.email}")
|
|
||||||
msg = "Пароль не установлен для данного пользователя"
|
|
||||||
raise InvalidPassword(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 InvalidPassword(msg)
|
|
||||||
|
|
||||||
# Возвращаем исходный объект, чтобы сохранить все связи
|
|
||||||
return orm_author
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def oauth(inp: dict[str, Any]) -> Any:
|
def oauth(inp) -> User:
|
||||||
"""
|
|
||||||
Создает нового пользователя OAuth, если он не существует
|
|
||||||
|
|
||||||
Args:
|
|
||||||
inp (dict): Данные OAuth пользователя
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Author: Объект пользователя
|
|
||||||
"""
|
|
||||||
# Импортируем внутри функции для избежания циклических импортов
|
|
||||||
from auth.orm import Author
|
|
||||||
|
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
author = session.query(Author).filter(Author.email == inp["email"]).first()
|
user = session.query(User).filter(User.email == inp["email"]).first()
|
||||||
if not author:
|
if not user:
|
||||||
author = Author(**inp)
|
user = User.create(**inp, emailConfirmed=True)
|
||||||
author.email_verified = True # type: ignore[assignment]
|
|
||||||
session.add(author)
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
return author
|
return user
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def onetime(token: str) -> Any:
|
async def onetime(token: str) -> User:
|
||||||
"""
|
|
||||||
Проверяет одноразовый токен
|
|
||||||
|
|
||||||
Args:
|
|
||||||
token (str): Одноразовый токен
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Author: Объект пользователя
|
|
||||||
"""
|
|
||||||
# Импортируем внутри функции для избежания циклических импортов
|
|
||||||
from auth.orm import Author
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print("[auth.identity] using one time token")
|
print("[auth.identity] using one time token")
|
||||||
payload = JWTCodec.decode(token)
|
payload = JWTCodec.decode(token)
|
||||||
if payload is None:
|
if not await TokenStorage.exist(f"{payload.user_id}-{payload.username}-{token}"):
|
||||||
logger.warning("[Identity.token] Токен не валиден (payload is None)")
|
# raise InvalidToken("Login token has expired, please login again")
|
||||||
return {"error": "Invalid token"}
|
return {"error": "Token has expired"}
|
||||||
|
except ExpiredSignatureError:
|
||||||
# Проверяем существование токена в хранилище
|
|
||||||
token_key = f"{payload.user_id}-{payload.username}-{token}"
|
|
||||||
if not await redis.exists(token_key):
|
|
||||||
logger.warning(f"[Identity.token] Токен не найден в хранилище: {token_key}")
|
|
||||||
return {"error": "Token not found"}
|
|
||||||
|
|
||||||
# Если все проверки пройдены, ищем автора в базе данных
|
|
||||||
with local_session() as session:
|
|
||||||
author = session.query(Author).filter_by(id=payload.user_id).first()
|
|
||||||
if not author:
|
|
||||||
logger.warning(f"[Identity.token] Автор с ID {payload.user_id} не найден")
|
|
||||||
return {"error": "User not found"}
|
|
||||||
|
|
||||||
logger.info(f"[Identity.token] Токен валиден для автора {author.id}")
|
|
||||||
return author
|
|
||||||
except ExpiredToken:
|
|
||||||
# raise InvalidToken("Login token has expired, please try again")
|
# raise InvalidToken("Login token has expired, please try again")
|
||||||
return {"error": "Token has expired"}
|
return {"error": "Token has expired"}
|
||||||
except InvalidToken:
|
except DecodeError:
|
||||||
# raise InvalidToken("token format error") from e
|
# raise InvalidToken("token format error") from e
|
||||||
return {"error": "Token format error"}
|
return {"error": "Token format error"}
|
||||||
|
with local_session() as session:
|
||||||
|
user = session.query(User).filter_by(id=payload.user_id).first()
|
||||||
|
if not user:
|
||||||
|
# raise Exception("user not exist")
|
||||||
|
return {"error": "User does not exist"}
|
||||||
|
if not user.emailConfirmed:
|
||||||
|
user.emailConfirmed = True
|
||||||
|
session.commit()
|
||||||
|
return user
|
||||||
|
|||||||
147
auth/internal.py
147
auth/internal.py
@@ -1,147 +0,0 @@
|
|||||||
"""
|
|
||||||
Утилитные функции для внутренней аутентификации
|
|
||||||
Используются в GraphQL резолверах и декораторах
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy.orm import exc
|
|
||||||
|
|
||||||
from auth.orm import Author
|
|
||||||
from auth.state import AuthState
|
|
||||||
from auth.tokens.storage import TokenStorage as TokenManager
|
|
||||||
from services.db import local_session
|
|
||||||
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
|
|
||||||
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
|
|
||||||
|
|
||||||
logger.debug(f"[verify_internal_auth] Токен действителен, user_id={payload.user_id}")
|
|
||||||
|
|
||||||
with local_session() as session:
|
|
||||||
try:
|
|
||||||
author = session.query(Author).filter(Author.id == payload.user_id).one()
|
|
||||||
|
|
||||||
# Получаем роли
|
|
||||||
from orm.community import CommunityAuthor
|
|
||||||
|
|
||||||
ca = session.query(CommunityAuthor).filter_by(author_id=author.id, community_id=1).first()
|
|
||||||
if ca:
|
|
||||||
roles = ca.role_list
|
|
||||||
else:
|
|
||||||
roles = []
|
|
||||||
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 exc.NoResultFound:
|
|
||||||
logger.warning(f"[verify_internal_auth] Пользователь с ID {payload.user_id} не найден в БД или не активен")
|
|
||||||
return 0, [], False
|
|
||||||
|
|
||||||
|
|
||||||
async def create_internal_session(author: Author, device_info: Optional[dict] = None) -> str:
|
|
||||||
"""
|
|
||||||
Создает новую сессию для автора
|
|
||||||
|
|
||||||
Args:
|
|
||||||
author: Объект автора
|
|
||||||
device_info: Информация об устройстве (опционально)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Токен сессии
|
|
||||||
"""
|
|
||||||
# Сбрасываем счетчик неудачных попыток
|
|
||||||
author.reset_failed_login()
|
|
||||||
|
|
||||||
# Обновляем last_seen
|
|
||||||
author.last_seen = int(time.time()) # type: ignore[assignment]
|
|
||||||
|
|
||||||
# Создаем сессию, используя 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 authenticate(request) -> AuthState:
|
|
||||||
"""
|
|
||||||
Аутентифицирует пользователя по токену из запроса.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: Объект запроса
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AuthState: Состояние аутентификации
|
|
||||||
"""
|
|
||||||
from auth.decorators import get_auth_token
|
|
||||||
from utils.logger import root_logger as logger
|
|
||||||
|
|
||||||
logger.debug("[authenticate] Начало аутентификации")
|
|
||||||
|
|
||||||
# Создаем объект AuthState
|
|
||||||
auth_state = AuthState()
|
|
||||||
auth_state.logged_in = False
|
|
||||||
auth_state.author_id = None
|
|
||||||
auth_state.error = None
|
|
||||||
auth_state.token = None
|
|
||||||
|
|
||||||
# Получаем токен из запроса
|
|
||||||
token = get_auth_token(request)
|
|
||||||
if not token:
|
|
||||||
logger.warning("[authenticate] Токен не найден в запросе")
|
|
||||||
auth_state.error = "No authentication token provided"
|
|
||||||
return auth_state
|
|
||||||
|
|
||||||
logger.debug(f"[authenticate] Токен найден, длина: {len(token)}")
|
|
||||||
|
|
||||||
# Проверяем токен
|
|
||||||
try:
|
|
||||||
# Используем TokenManager вместо прямого создания SessionTokenManager
|
|
||||||
auth_result = await TokenManager.verify_session(token)
|
|
||||||
|
|
||||||
if auth_result and hasattr(auth_result, "user_id") and auth_result.user_id:
|
|
||||||
logger.debug(f"[authenticate] Успешная аутентификация, user_id: {auth_result.user_id}")
|
|
||||||
auth_state.logged_in = True
|
|
||||||
auth_state.author_id = auth_result.user_id
|
|
||||||
auth_state.token = token
|
|
||||||
return auth_state
|
|
||||||
|
|
||||||
error_msg = "Invalid or expired token"
|
|
||||||
logger.warning(f"[authenticate] Недействительный токен: {error_msg}")
|
|
||||||
auth_state.error = error_msg
|
|
||||||
return auth_state
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[authenticate] Ошибка при проверке токена: {e}")
|
|
||||||
auth_state.error = f"Authentication error: {e!s}"
|
|
||||||
return auth_state
|
|
||||||
111
auth/jwtcodec.py
111
auth/jwtcodec.py
@@ -1,79 +1,31 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Optional, Union
|
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
from base.exceptions import ExpiredToken, InvalidToken
|
||||||
from settings import JWT_ALGORITHM, JWT_SECRET_KEY
|
from settings import JWT_ALGORITHM, JWT_SECRET_KEY
|
||||||
from utils.logger import root_logger as logger
|
from validations.auth import AuthInput, TokenPayload
|
||||||
|
|
||||||
|
|
||||||
class TokenPayload(BaseModel):
|
|
||||||
user_id: str
|
|
||||||
username: str
|
|
||||||
exp: Optional[datetime] = None
|
|
||||||
iat: datetime
|
|
||||||
iss: str
|
|
||||||
|
|
||||||
|
|
||||||
class JWTCodec:
|
class JWTCodec:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def encode(user: Union[dict[str, Any], Any], exp: Optional[datetime] = None) -> str:
|
def encode(user: AuthInput, exp: datetime) -> str:
|
||||||
# Поддержка как объектов, так и словарей
|
|
||||||
if isinstance(user, dict):
|
|
||||||
# В TokenStorage.create_session передается словарь {"user_id": user_id, "username": username}
|
|
||||||
user_id = str(user.get("user_id", "") or user.get("id", ""))
|
|
||||||
username = user.get("username", "") or user.get("email", "")
|
|
||||||
else:
|
|
||||||
# Для объектов с атрибутами
|
|
||||||
user_id = str(getattr(user, "id", ""))
|
|
||||||
username = getattr(user, "slug", "") or getattr(user, "email", "") or getattr(user, "phone", "") or ""
|
|
||||||
|
|
||||||
logger.debug(f"[JWTCodec.encode] Кодирование токена для user_id={user_id}, username={username}")
|
|
||||||
|
|
||||||
# Если время истечения не указано, установим срок годности на 30 дней
|
|
||||||
if exp is None:
|
|
||||||
exp = datetime.now(tz=timezone.utc) + timedelta(days=30)
|
|
||||||
logger.debug(f"[JWTCodec.encode] Время истечения не указано, устанавливаем срок: {exp}")
|
|
||||||
|
|
||||||
# Важно: убедимся, что exp всегда является либо datetime, либо целым числом от timestamp
|
|
||||||
if isinstance(exp, datetime):
|
|
||||||
# Преобразуем datetime в timestamp чтобы гарантировать правильный формат
|
|
||||||
exp_timestamp = int(exp.timestamp())
|
|
||||||
else:
|
|
||||||
# Если передано что-то другое, установим значение по умолчанию
|
|
||||||
logger.warning(f"[JWTCodec.encode] Некорректный формат exp: {exp}, используем значение по умолчанию")
|
|
||||||
exp_timestamp = int((datetime.now(tz=timezone.utc) + timedelta(days=30)).timestamp())
|
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"user_id": user_id,
|
"user_id": user.id,
|
||||||
"username": username,
|
"username": user.email or user.phone,
|
||||||
"exp": exp_timestamp, # Используем timestamp вместо datetime
|
"exp": exp,
|
||||||
"iat": datetime.now(tz=timezone.utc),
|
"iat": datetime.now(tz=timezone.utc),
|
||||||
"iss": "discours",
|
"iss": "discours",
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(f"[JWTCodec.encode] Сформирован payload: {payload}")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
token = jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM)
|
return jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM)
|
||||||
logger.debug(f"[JWTCodec.encode] Токен успешно создан, длина: {len(token) if token else 0}")
|
|
||||||
# Ensure we always return str, not bytes
|
|
||||||
if isinstance(token, bytes):
|
|
||||||
return token.decode("utf-8")
|
|
||||||
return str(token)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[JWTCodec.encode] Ошибка при кодировании JWT: {e}")
|
print("[auth.jwtcodec] JWT encode error %r" % e)
|
||||||
raise
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def decode(token: str, verify_exp: bool = True) -> Optional[TokenPayload]:
|
def decode(token: str, verify_exp: bool = True) -> TokenPayload:
|
||||||
logger.debug(f"[JWTCodec.decode] Начало декодирования токена длиной {len(token) if token else 0}")
|
r = None
|
||||||
|
payload = None
|
||||||
if not token:
|
|
||||||
logger.error("[JWTCodec.decode] Пустой токен")
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
token,
|
token,
|
||||||
@@ -85,39 +37,16 @@ class JWTCodec:
|
|||||||
algorithms=[JWT_ALGORITHM],
|
algorithms=[JWT_ALGORITHM],
|
||||||
issuer="discours",
|
issuer="discours",
|
||||||
)
|
)
|
||||||
logger.debug(f"[JWTCodec.decode] Декодирован payload: {payload}")
|
|
||||||
|
|
||||||
# Убедимся, что exp существует (добавим обработку если exp отсутствует)
|
|
||||||
if "exp" not in payload:
|
|
||||||
logger.warning("[JWTCodec.decode] В токене отсутствует поле exp")
|
|
||||||
# Добавим exp по умолчанию, чтобы избежать ошибки при создании TokenPayload
|
|
||||||
payload["exp"] = int((datetime.now(tz=timezone.utc) + timedelta(days=30)).timestamp())
|
|
||||||
|
|
||||||
try:
|
|
||||||
r = TokenPayload(**payload)
|
r = TokenPayload(**payload)
|
||||||
logger.debug(
|
# print('[auth.jwtcodec] debug token %r' % r)
|
||||||
f"[JWTCodec.decode] Создан объект TokenPayload: user_id={r.user_id}, username={r.username}"
|
|
||||||
)
|
|
||||||
return r
|
return r
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[JWTCodec.decode] Ошибка при создании TokenPayload: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except jwt.InvalidIssuedAtError:
|
except jwt.InvalidIssuedAtError:
|
||||||
logger.error("[JWTCodec.decode] Недействительное время выпуска токена")
|
print("[auth.jwtcodec] invalid issued at: %r" % payload)
|
||||||
return None
|
raise ExpiredToken("check token issued time")
|
||||||
except jwt.ExpiredSignatureError:
|
except jwt.ExpiredSignatureError:
|
||||||
logger.error("[JWTCodec.decode] Истек срок действия токена")
|
print("[auth.jwtcodec] expired signature %r" % payload)
|
||||||
return None
|
raise ExpiredToken("check token lifetime")
|
||||||
except jwt.InvalidSignatureError:
|
|
||||||
logger.error("[JWTCodec.decode] Недействительная подпись токена")
|
|
||||||
return None
|
|
||||||
except jwt.InvalidTokenError:
|
except jwt.InvalidTokenError:
|
||||||
logger.error("[JWTCodec.decode] Недействительный токен")
|
raise InvalidToken("token is not valid")
|
||||||
return None
|
except jwt.InvalidSignatureError:
|
||||||
except jwt.InvalidKeyError:
|
raise InvalidToken("token is not valid")
|
||||||
logger.error("[JWTCodec.decode] Недействительный ключ")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[JWTCodec.decode] Неожиданная ошибка при декодировании: {e}")
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -1,439 +0,0 @@
|
|||||||
"""
|
|
||||||
Единый middleware для обработки авторизации в GraphQL запросах
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
from collections.abc import Awaitable, MutableMapping
|
|
||||||
from typing import Any, Callable, Optional
|
|
||||||
|
|
||||||
from graphql import GraphQLResolveInfo
|
|
||||||
from sqlalchemy.orm import exc
|
|
||||||
from starlette.authentication import UnauthenticatedUser
|
|
||||||
from starlette.datastructures import Headers
|
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import JSONResponse, Response
|
|
||||||
from starlette.types import ASGIApp
|
|
||||||
|
|
||||||
from auth.credentials import AuthCredentials
|
|
||||||
from auth.orm import Author
|
|
||||||
from auth.tokens.storage import TokenStorage as TokenManager
|
|
||||||
from orm.community import CommunityAuthor
|
|
||||||
from services.db import local_session
|
|
||||||
from settings import (
|
|
||||||
ADMIN_EMAILS as ADMIN_EMAILS_LIST,
|
|
||||||
)
|
|
||||||
from settings import (
|
|
||||||
SESSION_COOKIE_HTTPONLY,
|
|
||||||
SESSION_COOKIE_MAX_AGE,
|
|
||||||
SESSION_COOKIE_NAME,
|
|
||||||
SESSION_COOKIE_SAMESITE,
|
|
||||||
SESSION_COOKIE_SECURE,
|
|
||||||
SESSION_TOKEN_HEADER,
|
|
||||||
)
|
|
||||||
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: Optional[list] = None,
|
|
||||||
permissions: Optional[dict] = None,
|
|
||||||
token: Optional[str] = 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:
|
|
||||||
logger.debug("[auth.authenticate] Токен отсутствует")
|
|
||||||
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:
|
|
||||||
author = session.query(Author).filter(Author.id == payload.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] = {}
|
|
||||||
|
|
||||||
# Получаем роли для пользователя
|
|
||||||
ca = session.query(CommunityAuthor).filter_by(author_id=author.id, community_id=1).first()
|
|
||||||
if ca:
|
|
||||||
roles = ca.role_list
|
|
||||||
else:
|
|
||||||
roles = []
|
|
||||||
|
|
||||||
# Обновляем 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.error(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.error(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
|
|
||||||
|
|
||||||
# Извлекаем заголовки
|
|
||||||
headers = Headers(scope=scope)
|
|
||||||
token = None
|
|
||||||
|
|
||||||
# Сначала пробуем получить токен из заголовка авторизации
|
|
||||||
auth_header = headers.get(SESSION_TOKEN_HEADER)
|
|
||||||
if auth_header:
|
|
||||||
if auth_header.startswith("Bearer "):
|
|
||||||
token = auth_header.replace("Bearer ", "", 1).strip()
|
|
||||||
logger.debug(
|
|
||||||
f"[middleware] Извлечен Bearer токен из заголовка {SESSION_TOKEN_HEADER}, длина: {len(token) if token else 0}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Если заголовок не начинается с Bearer, предполагаем, что это чистый токен
|
|
||||||
token = auth_header.strip()
|
|
||||||
logger.debug(
|
|
||||||
f"[middleware] Извлечен прямой токен из заголовка {SESSION_TOKEN_HEADER}, длина: {len(token) if token else 0}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Если токен не получен из основного заголовка и это не Authorization, проверяем заголовок Authorization
|
|
||||||
if not token and SESSION_TOKEN_HEADER.lower() != "authorization":
|
|
||||||
auth_header = headers.get("Authorization")
|
|
||||||
if auth_header and auth_header.startswith("Bearer "):
|
|
||||||
token = auth_header.replace("Bearer ", "", 1).strip()
|
|
||||||
logger.debug(
|
|
||||||
f"[middleware] Извлечен Bearer токен из заголовка Authorization, длина: {len(token) if token else 0}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Если токен не получен из заголовка, пробуем взять из cookie
|
|
||||||
if not token:
|
|
||||||
cookies = headers.get("cookie", "")
|
|
||||||
cookie_items = cookies.split(";")
|
|
||||||
for item in cookie_items:
|
|
||||||
if "=" in item:
|
|
||||||
name, value = item.split("=", 1)
|
|
||||||
if name.strip() == SESSION_COOKIE_NAME:
|
|
||||||
token = value.strip()
|
|
||||||
logger.debug(
|
|
||||||
f"[middleware] Извлечен токен из cookie {SESSION_COOKIE_NAME}, длина: {len(token) if token else 0}"
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
# Аутентифицируем пользователя
|
|
||||||
auth, user = await self.authenticate_user(token or "")
|
|
||||||
|
|
||||||
# Добавляем в scope данные авторизации и пользователя
|
|
||||||
scope["auth"] = auth
|
|
||||||
scope["user"] = user
|
|
||||||
|
|
||||||
if token:
|
|
||||||
# Обновляем заголовки в scope для совместимости
|
|
||||||
new_headers: list[tuple[bytes, bytes]] = []
|
|
||||||
for name, value in scope["headers"]:
|
|
||||||
header_name = name.decode("latin1") if isinstance(name, bytes) else str(name)
|
|
||||||
if header_name.lower() != SESSION_TOKEN_HEADER.lower():
|
|
||||||
# Ensure both name and value are bytes
|
|
||||||
name_bytes = name if isinstance(name, bytes) else str(name).encode("latin1")
|
|
||||||
value_bytes = value if isinstance(value, bytes) else str(value).encode("latin1")
|
|
||||||
new_headers.append((name_bytes, value_bytes))
|
|
||||||
new_headers.append((SESSION_TOKEN_HEADER.encode("latin1"), token.encode("latin1")))
|
|
||||||
scope["headers"] = new_headers
|
|
||||||
|
|
||||||
logger.debug(f"[middleware] Пользователь аутентифицирован: {user.is_authenticated}")
|
|
||||||
else:
|
|
||||||
logger.debug("[middleware] Токен не найден, пользователь неаутентифицирован")
|
|
||||||
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
|
|
||||||
def set_context(self, context) -> None:
|
|
||||||
"""Сохраняет ссылку на контекст GraphQL запроса"""
|
|
||||||
self._context = context
|
|
||||||
logger.debug(f"[middleware] Установлен контекст GraphQL: {bool(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)
|
|
||||||
logger.debug(f"[middleware] Установлена cookie {key} через response")
|
|
||||||
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)
|
|
||||||
logger.debug(f"[middleware] Установлена cookie {key} через _response")
|
|
||||||
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)
|
|
||||||
logger.debug(f"[middleware] Удалена cookie {key} через response")
|
|
||||||
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)
|
|
||||||
logger.debug(f"[middleware] Удалена cookie {key} через _response")
|
|
||||||
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"]:
|
|
||||||
from starlette.responses import JSONResponse
|
|
||||||
|
|
||||||
context["response"] = JSONResponse({})
|
|
||||||
logger.debug("[middleware] Создан новый response объект в контексте GraphQL")
|
|
||||||
|
|
||||||
logger.debug("[middleware] GraphQL resolve: контекст подготовлен, добавлены расширения для работы с cookie")
|
|
||||||
|
|
||||||
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
|
|
||||||
if isinstance(result, Response):
|
|
||||||
response = result
|
|
||||||
# Пытаемся получить данные из response для проверки логина/логаута
|
|
||||||
result_data = {}
|
|
||||||
if isinstance(result, JSONResponse):
|
|
||||||
try:
|
|
||||||
import json
|
|
||||||
|
|
||||||
body_content = result.body
|
|
||||||
if isinstance(body_content, (bytes, memoryview)):
|
|
||||||
body_text = bytes(body_content).decode("utf-8")
|
|
||||||
result_data = json.loads(body_text)
|
|
||||||
else:
|
|
||||||
result_data = json.loads(str(body_content))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[process_result] Не удалось извлечь данные из JSONResponse: {e!s}")
|
|
||||||
else:
|
|
||||||
response = JSONResponse(result)
|
|
||||||
result_data = result
|
|
||||||
|
|
||||||
# Проверяем, был ли токен в запросе или ответе
|
|
||||||
if request.method == "POST":
|
|
||||||
try:
|
|
||||||
data = await request.json()
|
|
||||||
op_name = data.get("operationName", "").lower()
|
|
||||||
|
|
||||||
# Если это операция логина или обновления токена, и в ответе есть токен
|
|
||||||
if op_name in ["login", "refreshtoken"]:
|
|
||||||
token = None
|
|
||||||
# Пытаемся извлечь токен из данных ответа
|
|
||||||
if result_data and isinstance(result_data, dict):
|
|
||||||
data_obj = result_data.get("data", {})
|
|
||||||
if isinstance(data_obj, dict) and op_name in data_obj:
|
|
||||||
op_result = data_obj.get(op_name, {})
|
|
||||||
if isinstance(op_result, dict) and "token" in op_result:
|
|
||||||
token = op_result.get("token")
|
|
||||||
|
|
||||||
if token:
|
|
||||||
# Устанавливаем 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,
|
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
f"[graphql_handler] Установлена cookie {SESSION_COOKIE_NAME} для операции {op_name}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Если это операция logout, удаляем cookie
|
|
||||||
elif op_name == "logout":
|
|
||||||
response.delete_cookie(
|
|
||||||
key=SESSION_COOKIE_NAME,
|
|
||||||
secure=SESSION_COOKIE_SECURE,
|
|
||||||
httponly=SESSION_COOKIE_HTTPONLY,
|
|
||||||
samesite=SESSION_COOKIE_SAMESITE,
|
|
||||||
)
|
|
||||||
logger.debug(f"[graphql_handler] Удалена cookie {SESSION_COOKIE_NAME} для операции {op_name}")
|
|
||||||
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)
|
|
||||||
668
auth/oauth.py
668
auth/oauth.py
@@ -1,618 +1,98 @@
|
|||||||
import time
|
|
||||||
from secrets import token_urlsafe
|
|
||||||
from typing import Any, Callable, Optional
|
|
||||||
|
|
||||||
import orjson
|
|
||||||
from authlib.integrations.starlette_client import OAuth
|
from authlib.integrations.starlette_client import OAuth
|
||||||
from authlib.oauth2.rfc7636 import create_s256_code_challenge
|
from starlette.responses import RedirectResponse
|
||||||
from graphql import GraphQLResolveInfo
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import JSONResponse, RedirectResponse
|
|
||||||
|
|
||||||
from auth.orm import Author
|
|
||||||
from auth.tokens.storage import TokenStorage
|
|
||||||
from services.db import local_session
|
|
||||||
from services.redis import redis
|
|
||||||
from settings import (
|
|
||||||
FRONTEND_URL,
|
|
||||||
OAUTH_CLIENTS,
|
|
||||||
SESSION_COOKIE_HTTPONLY,
|
|
||||||
SESSION_COOKIE_MAX_AGE,
|
|
||||||
SESSION_COOKIE_NAME,
|
|
||||||
SESSION_COOKIE_SAMESITE,
|
|
||||||
SESSION_COOKIE_SECURE,
|
|
||||||
)
|
|
||||||
from utils.generate_slug import generate_unique_slug
|
|
||||||
from utils.logger import root_logger as logger
|
|
||||||
|
|
||||||
# Type для dependency injection сессии
|
|
||||||
SessionFactory = Callable[[], Session]
|
|
||||||
|
|
||||||
|
|
||||||
class SessionManager:
|
|
||||||
"""Менеджер сессий для dependency injection с поддержкой тестирования"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._factory: SessionFactory = local_session
|
|
||||||
|
|
||||||
def set_factory(self, factory: SessionFactory) -> None:
|
|
||||||
"""Устанавливает фабрику сессий для dependency injection"""
|
|
||||||
self._factory = factory
|
|
||||||
|
|
||||||
def get_session(self) -> Session:
|
|
||||||
"""Получает сессию БД через dependency injection"""
|
|
||||||
return self._factory()
|
|
||||||
|
|
||||||
|
|
||||||
# Глобальный менеджер сессий
|
|
||||||
session_manager = SessionManager()
|
|
||||||
|
|
||||||
|
|
||||||
def set_session_factory(factory: SessionFactory) -> None:
|
|
||||||
"""
|
|
||||||
Устанавливает фабрику сессий для dependency injection.
|
|
||||||
Используется в тестах для подмены реальной БД на тестовую.
|
|
||||||
"""
|
|
||||||
session_manager.set_factory(factory)
|
|
||||||
|
|
||||||
|
|
||||||
def get_session() -> Session:
|
|
||||||
"""
|
|
||||||
Получает сессию БД через dependency injection.
|
|
||||||
Возвращает сессию которую нужно явно закрывать после использования.
|
|
||||||
|
|
||||||
Внимание: не забывайте закрывать сессию после использования!
|
|
||||||
Рекомендуется использовать try/finally блок.
|
|
||||||
"""
|
|
||||||
return session_manager.get_session()
|
|
||||||
|
|
||||||
|
from auth.identity import Identity
|
||||||
|
from auth.tokenstorage import TokenStorage
|
||||||
|
from settings import FRONTEND_URL, OAUTH_CLIENTS
|
||||||
|
|
||||||
oauth = OAuth()
|
oauth = OAuth()
|
||||||
|
|
||||||
# OAuth state management через Redis (TTL 10 минут)
|
oauth.register(
|
||||||
OAUTH_STATE_TTL = 600 # 10 минут
|
name="facebook",
|
||||||
|
client_id=OAUTH_CLIENTS["FACEBOOK"]["id"],
|
||||||
|
client_secret=OAUTH_CLIENTS["FACEBOOK"]["key"],
|
||||||
|
access_token_url="https://graph.facebook.com/v11.0/oauth/access_token",
|
||||||
|
access_token_params=None,
|
||||||
|
authorize_url="https://www.facebook.com/v11.0/dialog/oauth",
|
||||||
|
authorize_params=None,
|
||||||
|
api_base_url="https://graph.facebook.com/",
|
||||||
|
client_kwargs={"scope": "public_profile email"},
|
||||||
|
)
|
||||||
|
|
||||||
# Конфигурация провайдеров для регистрации
|
oauth.register(
|
||||||
PROVIDER_CONFIGS = {
|
name="github",
|
||||||
"google": {
|
client_id=OAUTH_CLIENTS["GITHUB"]["id"],
|
||||||
"server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
|
client_secret=OAUTH_CLIENTS["GITHUB"]["key"],
|
||||||
},
|
access_token_url="https://github.com/login/oauth/access_token",
|
||||||
"github": {
|
access_token_params=None,
|
||||||
"access_token_url": "https://github.com/login/oauth/access_token",
|
authorize_url="https://github.com/login/oauth/authorize",
|
||||||
"authorize_url": "https://github.com/login/oauth/authorize",
|
authorize_params=None,
|
||||||
"api_base_url": "https://api.github.com/",
|
api_base_url="https://api.github.com/",
|
||||||
},
|
client_kwargs={"scope": "user:email"},
|
||||||
"facebook": {
|
)
|
||||||
"access_token_url": "https://graph.facebook.com/v13.0/oauth/access_token",
|
|
||||||
"authorize_url": "https://www.facebook.com/v13.0/dialog/oauth",
|
|
||||||
"api_base_url": "https://graph.facebook.com/",
|
|
||||||
},
|
|
||||||
"x": {
|
|
||||||
"access_token_url": "https://api.twitter.com/2/oauth2/token",
|
|
||||||
"authorize_url": "https://twitter.com/i/oauth2/authorize",
|
|
||||||
"api_base_url": "https://api.twitter.com/2/",
|
|
||||||
},
|
|
||||||
"telegram": {
|
|
||||||
"authorize_url": "https://oauth.telegram.org/auth",
|
|
||||||
"api_base_url": "https://api.telegram.org/",
|
|
||||||
},
|
|
||||||
"vk": {
|
|
||||||
"access_token_url": "https://oauth.vk.com/access_token",
|
|
||||||
"authorize_url": "https://oauth.vk.com/authorize",
|
|
||||||
"api_base_url": "https://api.vk.com/method/",
|
|
||||||
},
|
|
||||||
"yandex": {
|
|
||||||
"access_token_url": "https://oauth.yandex.ru/token",
|
|
||||||
"authorize_url": "https://oauth.yandex.ru/authorize",
|
|
||||||
"api_base_url": "https://login.yandex.ru/info",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Константы для генерации временного email
|
oauth.register(
|
||||||
TEMP_EMAIL_SUFFIX = "@oauth.local"
|
name="google",
|
||||||
|
client_id=OAUTH_CLIENTS["GOOGLE"]["id"],
|
||||||
|
client_secret=OAUTH_CLIENTS["GOOGLE"]["key"],
|
||||||
|
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||||
|
client_kwargs={"scope": "openid email profile"},
|
||||||
|
authorize_state="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _generate_temp_email(provider: str, user_id: str) -> str:
|
async def google_profile(client, request, token):
|
||||||
"""Генерирует временный email для OAuth провайдеров без email"""
|
userinfo = token["userinfo"]
|
||||||
return f"{provider}_{user_id}@oauth.local"
|
|
||||||
|
profile = {"name": userinfo["name"], "email": userinfo["email"], "id": userinfo["sub"]}
|
||||||
|
|
||||||
|
if userinfo["picture"]:
|
||||||
|
userpic = userinfo["picture"].replace("=s96", "=s600")
|
||||||
|
profile["userpic"] = userpic
|
||||||
|
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
def _register_oauth_provider(provider: str, client_config: dict) -> None:
|
async def facebook_profile(client, request, token):
|
||||||
"""Регистрирует OAuth провайдер в зависимости от его типа"""
|
profile = await client.get("me?fields=name,id,email", token=token)
|
||||||
try:
|
return profile.json()
|
||||||
provider_config = PROVIDER_CONFIGS.get(provider, {})
|
|
||||||
if not provider_config:
|
|
||||||
logger.warning(f"Unknown OAuth provider: {provider}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Базовые параметры для всех провайдеров
|
|
||||||
register_params = {
|
|
||||||
"name": provider,
|
|
||||||
"client_id": client_config["id"],
|
|
||||||
"client_secret": client_config["key"],
|
|
||||||
**provider_config,
|
|
||||||
}
|
|
||||||
|
|
||||||
oauth.register(**register_params)
|
|
||||||
logger.info(f"OAuth provider {provider} registered successfully")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to register OAuth provider {provider}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
for provider in PROVIDER_CONFIGS:
|
async def github_profile(client, request, token):
|
||||||
if provider in OAUTH_CLIENTS and OAUTH_CLIENTS[provider.upper()]:
|
|
||||||
client_config = OAUTH_CLIENTS[provider.upper()]
|
|
||||||
if "id" in client_config and "key" in client_config:
|
|
||||||
_register_oauth_provider(provider, client_config)
|
|
||||||
|
|
||||||
|
|
||||||
# Провайдеры со специальной обработкой данных
|
|
||||||
PROVIDER_HANDLERS = {
|
|
||||||
"google": lambda token, _: {
|
|
||||||
"id": token.get("userinfo", {}).get("sub"),
|
|
||||||
"email": token.get("userinfo", {}).get("email"),
|
|
||||||
"name": token.get("userinfo", {}).get("name"),
|
|
||||||
"picture": token.get("userinfo", {}).get("picture", "").replace("=s96", "=s600"),
|
|
||||||
},
|
|
||||||
"telegram": lambda token, _: {
|
|
||||||
"id": str(token.get("id", "")),
|
|
||||||
"email": None,
|
|
||||||
"phone": str(token.get("phone_number", "")),
|
|
||||||
"name": token.get("first_name", "") + " " + token.get("last_name", ""),
|
|
||||||
"picture": token.get("photo_url"),
|
|
||||||
},
|
|
||||||
"x": lambda _, profile_data: {
|
|
||||||
"id": profile_data.get("data", {}).get("id"),
|
|
||||||
"email": None,
|
|
||||||
"name": profile_data.get("data", {}).get("name") or profile_data.get("data", {}).get("username"),
|
|
||||||
"picture": profile_data.get("data", {}).get("profile_image_url", "").replace("_normal", "_400x400"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_github_profile(client: Any, token: Any) -> dict:
|
|
||||||
"""Получает профиль из GitHub API"""
|
|
||||||
profile = await client.get("user", token=token)
|
profile = await client.get("user", token=token)
|
||||||
profile_data = profile.json()
|
return profile.json()
|
||||||
emails = await client.get("user/emails", token=token)
|
|
||||||
emails_data = emails.json()
|
|
||||||
primary_email = next((email["email"] for email in emails_data if email["primary"]), None)
|
|
||||||
return {
|
|
||||||
"id": str(profile_data["id"]),
|
|
||||||
"email": primary_email or profile_data.get("email"),
|
|
||||||
"name": profile_data.get("name") or profile_data.get("login"),
|
|
||||||
"picture": profile_data.get("avatar_url"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_facebook_profile(client: Any, token: Any) -> dict:
|
profile_callbacks = {
|
||||||
"""Получает профиль из Facebook API"""
|
"google": google_profile,
|
||||||
profile = await client.get("me?fields=id,name,email,picture.width(600)", token=token)
|
"facebook": facebook_profile,
|
||||||
profile_data = profile.json()
|
"github": github_profile,
|
||||||
return {
|
}
|
||||||
"id": profile_data["id"],
|
|
||||||
"email": profile_data.get("email"),
|
|
||||||
"name": profile_data.get("name"),
|
|
||||||
"picture": profile_data.get("picture", {}).get("data", {}).get("url"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_x_profile(client: Any, token: Any) -> dict:
|
async def oauth_login(request):
|
||||||
"""Получает профиль из X (Twitter) API"""
|
provider = request.path_params["provider"]
|
||||||
profile = await client.get("authors/me?user.fields=id,name,username,profile_image_url", token=token)
|
|
||||||
profile_data = profile.json()
|
|
||||||
return PROVIDER_HANDLERS["x"](token, profile_data)
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_vk_profile(client: Any, token: Any) -> dict:
|
|
||||||
"""Получает профиль из VK API"""
|
|
||||||
profile = await client.get("authors.get?fields=photo_400_orig,contacts&v=5.131", token=token)
|
|
||||||
profile_data = profile.json()
|
|
||||||
if profile_data.get("response"):
|
|
||||||
user_data = profile_data["response"][0]
|
|
||||||
return {
|
|
||||||
"id": str(user_data["id"]),
|
|
||||||
"email": user_data.get("contacts", {}).get("email"),
|
|
||||||
"name": f"{user_data.get('first_name', '')} {user_data.get('last_name', '')}".strip(),
|
|
||||||
"picture": user_data.get("photo_400_orig"),
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_yandex_profile(client: Any, token: Any) -> dict:
|
|
||||||
"""Получает профиль из Yandex API"""
|
|
||||||
profile = await client.get("?format=json", token=token)
|
|
||||||
profile_data = profile.json()
|
|
||||||
return {
|
|
||||||
"id": profile_data.get("id"),
|
|
||||||
"email": profile_data.get("default_email"),
|
|
||||||
"name": profile_data.get("display_name") or profile_data.get("real_name"),
|
|
||||||
"picture": f"https://avatars.yandex.net/get-yapic/{profile_data.get('default_avatar_id')}/islands-200"
|
|
||||||
if profile_data.get("default_avatar_id")
|
|
||||||
else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def get_user_profile(provider: str, client: Any, token: Any) -> dict:
|
|
||||||
"""Получает профиль пользователя от провайдера OAuth"""
|
|
||||||
# Простые провайдеры с обработкой через lambda
|
|
||||||
if provider in PROVIDER_HANDLERS:
|
|
||||||
return PROVIDER_HANDLERS[provider](token, None)
|
|
||||||
|
|
||||||
# Провайдеры требующие API вызовов
|
|
||||||
profile_fetchers = {
|
|
||||||
"github": _fetch_github_profile,
|
|
||||||
"facebook": _fetch_facebook_profile,
|
|
||||||
"x": _fetch_x_profile,
|
|
||||||
"vk": _fetch_vk_profile,
|
|
||||||
"yandex": _fetch_yandex_profile,
|
|
||||||
}
|
|
||||||
|
|
||||||
if provider in profile_fetchers:
|
|
||||||
return await profile_fetchers[provider](client, token)
|
|
||||||
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
async def oauth_login(_: None, _info: GraphQLResolveInfo, provider: str, callback_data: dict[str, Any]) -> JSONResponse:
|
|
||||||
"""
|
|
||||||
Обработка OAuth авторизации
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider: Провайдер OAuth (google, github, etc.)
|
|
||||||
callback_data: Данные из callback-а
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Результат авторизации с токеном или ошибкой
|
|
||||||
"""
|
|
||||||
if provider not in PROVIDER_CONFIGS:
|
|
||||||
return JSONResponse({"error": "Invalid provider"}, status_code=400)
|
|
||||||
|
|
||||||
client = oauth.create_client(provider)
|
|
||||||
if not client:
|
|
||||||
return JSONResponse({"error": "Provider not configured"}, status_code=400)
|
|
||||||
|
|
||||||
# Получаем параметры из query string
|
|
||||||
state = callback_data.get("state")
|
|
||||||
redirect_uri = callback_data.get("redirect_uri", FRONTEND_URL)
|
|
||||||
|
|
||||||
if not state:
|
|
||||||
return JSONResponse({"error": "State parameter is required"}, status_code=400)
|
|
||||||
|
|
||||||
# Генерируем PKCE challenge
|
|
||||||
code_verifier = token_urlsafe(32)
|
|
||||||
code_challenge = create_s256_code_challenge(code_verifier)
|
|
||||||
|
|
||||||
# Сохраняем состояние OAuth в Redis
|
|
||||||
oauth_data = {
|
|
||||||
"code_verifier": code_verifier,
|
|
||||||
"provider": provider,
|
|
||||||
"redirect_uri": redirect_uri,
|
|
||||||
"created_at": int(time.time()),
|
|
||||||
}
|
|
||||||
await store_oauth_state(state, oauth_data)
|
|
||||||
|
|
||||||
# Используем URL из фронтенда для callback
|
|
||||||
oauth_callback_uri = f"{callback_data['base_url']}oauth/{provider}/callback"
|
|
||||||
|
|
||||||
try:
|
|
||||||
return await client.authorize_redirect(
|
|
||||||
callback_data["request"],
|
|
||||||
oauth_callback_uri,
|
|
||||||
code_challenge=code_challenge,
|
|
||||||
code_challenge_method="S256",
|
|
||||||
state=state,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"OAuth redirect error for {provider}: {e!s}")
|
|
||||||
return JSONResponse({"error": str(e)}, status_code=500)
|
|
||||||
|
|
||||||
|
|
||||||
async def oauth_callback(request: Any) -> JSONResponse | RedirectResponse:
|
|
||||||
"""
|
|
||||||
Обработчик OAuth callback.
|
|
||||||
Создает или обновляет пользователя и устанавливает сессионный токен.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
provider = request.path_params.get("provider")
|
|
||||||
if not provider:
|
|
||||||
return JSONResponse({"error": "Provider not specified"}, status_code=400)
|
|
||||||
|
|
||||||
# Получаем OAuth клиента
|
|
||||||
client = oauth.create_client(provider)
|
|
||||||
if not client:
|
|
||||||
return JSONResponse({"error": "Invalid provider"}, status_code=400)
|
|
||||||
|
|
||||||
# Получаем токен
|
|
||||||
token = await client.authorize_access_token(request)
|
|
||||||
if not token:
|
|
||||||
return JSONResponse({"error": "Failed to get access token"}, status_code=400)
|
|
||||||
|
|
||||||
# Получаем профиль пользователя
|
|
||||||
profile = await get_user_profile(provider, client, token)
|
|
||||||
if not profile:
|
|
||||||
return JSONResponse({"error": "Failed to get user profile"}, status_code=400)
|
|
||||||
|
|
||||||
# Создаем или обновляем пользователя
|
|
||||||
author = await _create_or_update_user(provider, profile)
|
|
||||||
if not author:
|
|
||||||
return JSONResponse({"error": "Failed to create/update user"}, status_code=500)
|
|
||||||
|
|
||||||
# Создаем сессию
|
|
||||||
session_token = await TokenStorage.create_session(
|
|
||||||
str(author.id),
|
|
||||||
auth_data={
|
|
||||||
"provider": provider,
|
|
||||||
"profile": profile,
|
|
||||||
},
|
|
||||||
username=author.name
|
|
||||||
if isinstance(author.name, str)
|
|
||||||
else str(author.name)
|
|
||||||
if author.name is not None
|
|
||||||
else None,
|
|
||||||
device_info={
|
|
||||||
"user_agent": request.headers.get("user-agent"),
|
|
||||||
"ip": request.client.host if hasattr(request, "client") else None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Получаем state из Redis для редиректа
|
|
||||||
state = request.query_params.get("state")
|
|
||||||
state_data = await get_oauth_state(state) if state else None
|
|
||||||
redirect_uri = state_data.get("redirect_uri") if state_data else FRONTEND_URL
|
|
||||||
if not isinstance(redirect_uri, str) or not redirect_uri:
|
|
||||||
redirect_uri = FRONTEND_URL
|
|
||||||
|
|
||||||
# Создаем ответ с редиректом
|
|
||||||
response = RedirectResponse(url=str(redirect_uri))
|
|
||||||
|
|
||||||
# Устанавливаем cookie с сессией
|
|
||||||
response.set_cookie(
|
|
||||||
SESSION_COOKIE_NAME,
|
|
||||||
session_token,
|
|
||||||
httponly=SESSION_COOKIE_HTTPONLY,
|
|
||||||
secure=SESSION_COOKIE_SECURE,
|
|
||||||
samesite=SESSION_COOKIE_SAMESITE,
|
|
||||||
max_age=SESSION_COOKIE_MAX_AGE,
|
|
||||||
path="/", # Важно: устанавливаем path="/" для доступности cookie во всех путях
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"OAuth успешно завершен для {provider}, user_id={author.id}")
|
|
||||||
return response
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"OAuth callback error: {e!s}")
|
|
||||||
# В случае ошибки редиректим на фронтенд с ошибкой
|
|
||||||
fallback_redirect = request.query_params.get("redirect_uri", FRONTEND_URL)
|
|
||||||
return RedirectResponse(url=f"{fallback_redirect}?error=auth_failed")
|
|
||||||
|
|
||||||
|
|
||||||
async def store_oauth_state(state: str, data: dict) -> None:
|
|
||||||
"""Сохраняет OAuth состояние в Redis с TTL"""
|
|
||||||
key = f"oauth_state:{state}"
|
|
||||||
await redis.execute("SETEX", key, OAUTH_STATE_TTL, orjson.dumps(data))
|
|
||||||
|
|
||||||
|
|
||||||
async def get_oauth_state(state: str) -> Optional[dict]:
|
|
||||||
"""Получает и удаляет OAuth состояние из Redis (one-time use)"""
|
|
||||||
key = f"oauth_state:{state}"
|
|
||||||
data = await redis.execute("GET", key)
|
|
||||||
if data:
|
|
||||||
await redis.execute("DEL", key) # Одноразовое использование
|
|
||||||
return orjson.loads(data)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# HTTP handlers для тестирования
|
|
||||||
async def oauth_login_http(request: Request) -> JSONResponse | RedirectResponse:
|
|
||||||
"""HTTP handler для OAuth login"""
|
|
||||||
try:
|
|
||||||
provider = request.path_params.get("provider")
|
|
||||||
if not provider or provider not in PROVIDER_CONFIGS:
|
|
||||||
return JSONResponse({"error": "Invalid provider"}, status_code=400)
|
|
||||||
|
|
||||||
client = oauth.create_client(provider)
|
|
||||||
if not client:
|
|
||||||
return JSONResponse({"error": "Provider not configured"}, status_code=400)
|
|
||||||
|
|
||||||
# Генерируем PKCE challenge
|
|
||||||
code_verifier = token_urlsafe(32)
|
|
||||||
code_challenge = create_s256_code_challenge(code_verifier)
|
|
||||||
state = token_urlsafe(32)
|
|
||||||
|
|
||||||
# Сохраняем состояние в сессии
|
|
||||||
request.session["code_verifier"] = code_verifier
|
|
||||||
request.session["provider"] = provider
|
request.session["provider"] = provider
|
||||||
request.session["state"] = state
|
client = oauth.create_client(provider)
|
||||||
|
redirect_uri = "https://v2.discours.io/oauth-authorize"
|
||||||
# Сохраняем состояние OAuth в Redis
|
return await client.authorize_redirect(request, redirect_uri)
|
||||||
oauth_data = {
|
|
||||||
"code_verifier": code_verifier,
|
|
||||||
"provider": provider,
|
|
||||||
"redirect_uri": FRONTEND_URL,
|
|
||||||
"created_at": int(time.time()),
|
|
||||||
}
|
|
||||||
await store_oauth_state(state, oauth_data)
|
|
||||||
|
|
||||||
# URL для callback
|
|
||||||
callback_uri = f"{FRONTEND_URL}oauth/{provider}/callback"
|
|
||||||
|
|
||||||
return await client.authorize_redirect(
|
|
||||||
request,
|
|
||||||
callback_uri,
|
|
||||||
code_challenge=code_challenge,
|
|
||||||
code_challenge_method="S256",
|
|
||||||
state=state,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"OAuth login error: {e}")
|
|
||||||
return JSONResponse({"error": "OAuth login failed"}, status_code=500)
|
|
||||||
|
|
||||||
|
|
||||||
async def oauth_callback_http(request: Request) -> JSONResponse | RedirectResponse:
|
async def oauth_authorize(request):
|
||||||
"""HTTP handler для OAuth callback"""
|
provider = request.session["provider"]
|
||||||
try:
|
|
||||||
# Используем GraphQL resolver логику
|
|
||||||
provider = request.session.get("provider")
|
|
||||||
if not provider:
|
|
||||||
return JSONResponse({"error": "No OAuth session found"}, status_code=400)
|
|
||||||
|
|
||||||
state = request.query_params.get("state")
|
|
||||||
session_state = request.session.get("state")
|
|
||||||
|
|
||||||
if not state or state != session_state:
|
|
||||||
return JSONResponse({"error": "Invalid or expired OAuth state"}, status_code=400)
|
|
||||||
|
|
||||||
oauth_data = await get_oauth_state(state)
|
|
||||||
if not oauth_data:
|
|
||||||
return JSONResponse({"error": "Invalid or expired OAuth state"}, status_code=400)
|
|
||||||
|
|
||||||
# Используем существующую логику
|
|
||||||
client = oauth.create_client(provider)
|
client = oauth.create_client(provider)
|
||||||
token = await client.authorize_access_token(request)
|
token = await client.authorize_access_token(request)
|
||||||
|
get_profile = profile_callbacks[provider]
|
||||||
profile = await get_user_profile(provider, client, token)
|
profile = await get_profile(client, request, token)
|
||||||
if not profile:
|
user_oauth_info = "%s:%s" % (provider, profile["id"])
|
||||||
return JSONResponse({"error": "Failed to get user profile"}, status_code=400)
|
user_input = {
|
||||||
|
"oauth": user_oauth_info,
|
||||||
# Создаем или обновляем пользователя используя helper функцию
|
"email": profile["email"],
|
||||||
author = await _create_or_update_user(provider, profile)
|
"username": profile["name"],
|
||||||
|
"userpic": profile["userpic"],
|
||||||
# Создаем токен сессии
|
}
|
||||||
session_token = await TokenStorage.create_session(str(author.id))
|
user = Identity.oauth(user_input)
|
||||||
|
session_token = await TokenStorage.create_session(user)
|
||||||
# Очищаем OAuth сессию
|
response = RedirectResponse(url=FRONTEND_URL + "/confirm")
|
||||||
request.session.pop("code_verifier", None)
|
response.set_cookie("token", session_token)
|
||||||
request.session.pop("provider", None)
|
|
||||||
request.session.pop("state", None)
|
|
||||||
|
|
||||||
# Возвращаем redirect с cookie
|
|
||||||
response = RedirectResponse(url="/auth/success", status_code=307)
|
|
||||||
response.set_cookie(
|
|
||||||
SESSION_COOKIE_NAME,
|
|
||||||
session_token,
|
|
||||||
httponly=SESSION_COOKIE_HTTPONLY,
|
|
||||||
secure=SESSION_COOKIE_SECURE,
|
|
||||||
samesite=SESSION_COOKIE_SAMESITE,
|
|
||||||
max_age=SESSION_COOKIE_MAX_AGE,
|
|
||||||
)
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"OAuth callback error: {e}")
|
|
||||||
return JSONResponse({"error": "OAuth callback failed"}, status_code=500)
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_or_update_user(provider: str, profile: dict) -> Author:
|
|
||||||
"""
|
|
||||||
Создает или обновляет пользователя на основе OAuth профиля.
|
|
||||||
Возвращает объект Author.
|
|
||||||
"""
|
|
||||||
# Для некоторых провайдеров (X, Telegram) email может отсутствовать
|
|
||||||
email = profile.get("email")
|
|
||||||
if not email:
|
|
||||||
# Генерируем временный email на основе провайдера и ID
|
|
||||||
email = _generate_temp_email(provider, profile.get("id", "unknown"))
|
|
||||||
logger.info(f"Generated temporary email for {provider} user: {email}")
|
|
||||||
|
|
||||||
# Создаем или обновляем пользователя
|
|
||||||
session = get_session()
|
|
||||||
try:
|
|
||||||
# Сначала ищем пользователя по OAuth
|
|
||||||
author = Author.find_by_oauth(provider, profile["id"], session)
|
|
||||||
|
|
||||||
if author:
|
|
||||||
# Пользователь найден по OAuth - обновляем данные
|
|
||||||
author.set_oauth_account(provider, profile["id"], email=profile.get("email"))
|
|
||||||
_update_author_profile(author, profile)
|
|
||||||
else:
|
|
||||||
# Ищем пользователя по email если есть настоящий email
|
|
||||||
author = None
|
|
||||||
if email and not email.endswith(TEMP_EMAIL_SUFFIX):
|
|
||||||
author = session.query(Author).filter(Author.email == email).first()
|
|
||||||
|
|
||||||
if author:
|
|
||||||
# Пользователь найден по email - добавляем OAuth данные
|
|
||||||
author.set_oauth_account(provider, profile["id"], email=profile.get("email"))
|
|
||||||
_update_author_profile(author, profile)
|
|
||||||
else:
|
|
||||||
# Создаем нового пользователя
|
|
||||||
author = _create_new_oauth_user(provider, profile, email, session)
|
|
||||||
|
|
||||||
session.commit()
|
|
||||||
return author
|
|
||||||
finally:
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _update_author_profile(author: Author, profile: dict) -> None:
|
|
||||||
"""Обновляет профиль автора данными из OAuth"""
|
|
||||||
if profile.get("name") and not author.name:
|
|
||||||
author.name = profile["name"] # type: ignore[assignment]
|
|
||||||
if profile.get("picture") and not author.pic:
|
|
||||||
author.pic = profile["picture"] # type: ignore[assignment]
|
|
||||||
author.updated_at = int(time.time()) # type: ignore[assignment]
|
|
||||||
author.last_seen = int(time.time()) # type: ignore[assignment]
|
|
||||||
|
|
||||||
|
|
||||||
def _create_new_oauth_user(provider: str, profile: dict, email: str, session: Any) -> Author:
|
|
||||||
"""Создает нового пользователя из OAuth профиля"""
|
|
||||||
from orm.community import Community, CommunityAuthor, CommunityFollower
|
|
||||||
from utils.logger import root_logger as logger
|
|
||||||
|
|
||||||
slug = generate_unique_slug(profile["name"] or f"{provider}_{profile.get('id', 'user')}")
|
|
||||||
|
|
||||||
author = Author(
|
|
||||||
email=email,
|
|
||||||
name=profile["name"] or f"{provider.title()} User",
|
|
||||||
slug=slug,
|
|
||||||
pic=profile.get("picture"),
|
|
||||||
email_verified=bool(profile.get("email")),
|
|
||||||
created_at=int(time.time()),
|
|
||||||
updated_at=int(time.time()),
|
|
||||||
last_seen=int(time.time()),
|
|
||||||
)
|
|
||||||
session.add(author)
|
|
||||||
session.flush() # Получаем ID автора
|
|
||||||
|
|
||||||
# Добавляем OAuth данные для нового пользователя
|
|
||||||
author.set_oauth_account(provider, profile["id"], email=profile.get("email"))
|
|
||||||
|
|
||||||
# Добавляем пользователя в основное сообщество с дефолтными ролями
|
|
||||||
target_community_id = 1 # Основное сообщество
|
|
||||||
|
|
||||||
# Получаем сообщество для назначения дефолтных ролей
|
|
||||||
community = session.query(Community).filter(Community.id == target_community_id).first()
|
|
||||||
if community:
|
|
||||||
# Инициализируем права сообщества если нужно
|
|
||||||
try:
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
loop.run_until_complete(community.initialize_role_permissions())
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Не удалось инициализировать права сообщества {target_community_id}: {e}")
|
|
||||||
|
|
||||||
# Получаем дефолтные роли сообщества или используем стандартные
|
|
||||||
try:
|
|
||||||
default_roles = community.get_default_roles()
|
|
||||||
if not default_roles:
|
|
||||||
default_roles = ["reader", "author"]
|
|
||||||
except AttributeError:
|
|
||||||
default_roles = ["reader", "author"]
|
|
||||||
|
|
||||||
# Создаем CommunityAuthor с дефолтными ролями
|
|
||||||
community_author = CommunityAuthor(
|
|
||||||
community_id=target_community_id, author_id=author.id, roles=",".join(default_roles)
|
|
||||||
)
|
|
||||||
session.add(community_author)
|
|
||||||
logger.info(f"Создана запись CommunityAuthor для OAuth пользователя {author.id} с ролями: {default_roles}")
|
|
||||||
|
|
||||||
# Добавляем пользователя в подписчики сообщества
|
|
||||||
follower = CommunityFollower(community=target_community_id, follower=int(author.id))
|
|
||||||
session.add(follower)
|
|
||||||
logger.info(f"OAuth пользователь {author.id} добавлен в подписчики сообщества {target_community_id}")
|
|
||||||
|
|
||||||
return author
|
|
||||||
|
|||||||
268
auth/orm.py
268
auth/orm.py
@@ -1,268 +0,0 @@
|
|||||||
import time
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from auth.identity import Password
|
|
||||||
from services.db import BaseModel as Base
|
|
||||||
|
|
||||||
# Общие table_args для всех моделей
|
|
||||||
DEFAULT_TABLE_ARGS = {"extend_existing": True}
|
|
||||||
|
|
||||||
|
|
||||||
"""
|
|
||||||
Модель закладок автора
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class AuthorBookmark(Base):
|
|
||||||
"""
|
|
||||||
Закладка автора на публикацию.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
author (int): ID автора
|
|
||||||
shout (int): ID публикации
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "author_bookmark"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_author_bookmark_author", "author"),
|
|
||||||
Index("idx_author_bookmark_shout", "shout"),
|
|
||||||
{"extend_existing": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
author = Column(ForeignKey("author.id"), primary_key=True)
|
|
||||||
shout = Column(ForeignKey("shout.id"), primary_key=True)
|
|
||||||
|
|
||||||
|
|
||||||
class AuthorRating(Base):
|
|
||||||
"""
|
|
||||||
Рейтинг автора от другого автора.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
rater (int): ID оценивающего автора
|
|
||||||
author (int): ID оцениваемого автора
|
|
||||||
plus (bool): Положительная/отрицательная оценка
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "author_rating"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_author_rating_author", "author"),
|
|
||||||
Index("idx_author_rating_rater", "rater"),
|
|
||||||
{"extend_existing": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
rater = Column(ForeignKey("author.id"), primary_key=True)
|
|
||||||
author = Column(ForeignKey("author.id"), primary_key=True)
|
|
||||||
plus = Column(Boolean)
|
|
||||||
|
|
||||||
|
|
||||||
class AuthorFollower(Base):
|
|
||||||
"""
|
|
||||||
Подписка одного автора на другого.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
follower (int): ID подписчика
|
|
||||||
author (int): ID автора, на которого подписываются
|
|
||||||
created_at (int): Время создания подписки
|
|
||||||
auto (bool): Признак автоматической подписки
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "author_follower"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_author_follower_author", "author"),
|
|
||||||
Index("idx_author_follower_follower", "follower"),
|
|
||||||
{"extend_existing": True},
|
|
||||||
)
|
|
||||||
id = None # type: ignore[assignment]
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
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 = Column(Integer, primary_key=True)
|
|
||||||
name = Column(String, nullable=True, comment="Display name")
|
|
||||||
slug = Column(String, unique=True, comment="Author's slug")
|
|
||||||
bio = Column(String, nullable=True, comment="Bio") # короткое описание
|
|
||||||
about = Column(String, nullable=True, comment="About") # длинное форматированное описание
|
|
||||||
pic = Column(String, nullable=True, comment="Picture")
|
|
||||||
links = Column(JSON, nullable=True, comment="Links")
|
|
||||||
|
|
||||||
# OAuth аккаунты - JSON с данными всех провайдеров
|
|
||||||
# Формат: {"google": {"id": "123", "email": "user@gmail.com"}, "github": {"id": "456"}}
|
|
||||||
oauth = Column(JSON, nullable=True, default=dict, comment="OAuth accounts data")
|
|
||||||
|
|
||||||
# Поля аутентификации
|
|
||||||
email = Column(String, unique=True, nullable=True, comment="Email")
|
|
||||||
phone = Column(String, unique=True, nullable=True, comment="Phone")
|
|
||||||
password = Column(String, nullable=True, comment="Password hash")
|
|
||||||
email_verified = Column(Boolean, default=False)
|
|
||||||
phone_verified = Column(Boolean, default=False)
|
|
||||||
failed_login_attempts = Column(Integer, default=0)
|
|
||||||
account_locked_until = Column(Integer, nullable=True)
|
|
||||||
|
|
||||||
# Временные метки
|
|
||||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
|
||||||
updated_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
|
||||||
last_seen = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
|
||||||
deleted_at = Column(Integer, nullable=True)
|
|
||||||
|
|
||||||
oid = Column(String, nullable=True)
|
|
||||||
|
|
||||||
# Список защищенных полей, которые видны только владельцу и администраторам
|
|
||||||
_protected_fields = ["email", "password", "provider_access_token", "provider_refresh_token"]
|
|
||||||
|
|
||||||
@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):
|
|
||||||
"""Устанавливает пароль пользователя"""
|
|
||||||
self.password = Password.encode(password) # type: ignore[assignment]
|
|
||||||
|
|
||||||
def increment_failed_login(self):
|
|
||||||
"""Увеличивает счетчик неудачных попыток входа"""
|
|
||||||
self.failed_login_attempts += 1 # type: ignore[assignment]
|
|
||||||
if self.failed_login_attempts >= 5:
|
|
||||||
self.account_locked_until = int(time.time()) + 300 # type: ignore[assignment] # 5 минут
|
|
||||||
|
|
||||||
def reset_failed_login(self):
|
|
||||||
"""Сбрасывает счетчик неудачных попыток входа"""
|
|
||||||
self.failed_login_attempts = 0 # type: ignore[assignment]
|
|
||||||
self.account_locked_until = None # type: ignore[assignment]
|
|
||||||
|
|
||||||
def is_locked(self) -> bool:
|
|
||||||
"""Проверяет, заблокирован ли аккаунт"""
|
|
||||||
if not self.account_locked_until:
|
|
||||||
return False
|
|
||||||
return bool(self.account_locked_until > int(time.time()))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def username(self) -> str:
|
|
||||||
"""
|
|
||||||
Возвращает имя пользователя для использования в токенах.
|
|
||||||
Необходимо для совместимости с TokenStorage и JWTCodec.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: slug, email или phone пользователя
|
|
||||||
"""
|
|
||||||
return str(self.slug or self.email or self.phone or "")
|
|
||||||
|
|
||||||
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).filter(cls.oauth.isnot(None)).all()
|
|
||||||
for author in authors:
|
|
||||||
if author.oauth and provider in author.oauth:
|
|
||||||
oauth_data = author.oauth[provider] # type: ignore[index]
|
|
||||||
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: Optional[str] = None) -> None:
|
|
||||||
"""
|
|
||||||
Устанавливает OAuth аккаунт для автора
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider (str): Имя OAuth провайдера (google, github и т.д.)
|
|
||||||
provider_id (str): ID пользователя у провайдера
|
|
||||||
email (Optional[str]): Email от провайдера
|
|
||||||
"""
|
|
||||||
if not self.oauth:
|
|
||||||
self.oauth = {} # type: ignore[assignment]
|
|
||||||
|
|
||||||
oauth_data: Dict[str, str] = {"id": provider_id}
|
|
||||||
if email:
|
|
||||||
oauth_data["email"] = email
|
|
||||||
|
|
||||||
self.oauth[provider] = oauth_data # type: ignore[index]
|
|
||||||
|
|
||||||
def get_oauth_account(self, provider: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Получает 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]
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
"""
|
|
||||||
Модуль для проверки разрешений пользователей в контексте сообществ.
|
|
||||||
|
|
||||||
Позволяет проверять доступ пользователя к определенным операциям в сообществе
|
|
||||||
на основе его роли в этом сообществе.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from auth.orm import Author
|
|
||||||
from orm.community import Community, CommunityAuthor
|
|
||||||
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
|
|
||||||
|
|
||||||
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
|
|
||||||
|
|
||||||
|
|
||||||
class ContextualPermissionCheck:
|
|
||||||
"""
|
|
||||||
Класс для проверки контекстно-зависимых разрешений.
|
|
||||||
|
|
||||||
Позволяет проверять разрешения пользователя в контексте сообщества,
|
|
||||||
учитывая как глобальные роли пользователя, так и его роли внутри сообщества.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def check_community_permission(
|
|
||||||
session: Session, author_id: int, community_slug: str, resource: str, operation: str
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Проверяет наличие разрешения у пользователя в контексте сообщества.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: Сессия SQLAlchemy
|
|
||||||
author_id: ID автора/пользователя
|
|
||||||
community_slug: Slug сообщества
|
|
||||||
resource: Ресурс для доступа
|
|
||||||
operation: Операция над ресурсом
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True, если пользователь имеет разрешение, иначе False
|
|
||||||
"""
|
|
||||||
# 1. Проверка глобальных разрешений (например, администратор)
|
|
||||||
author = session.query(Author).filter(Author.id == author_id).one_or_none()
|
|
||||||
if not author:
|
|
||||||
return False
|
|
||||||
# Если это администратор (по списку email)
|
|
||||||
if author.email in ADMIN_EMAILS:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# 2. Проверка разрешений в контексте сообщества
|
|
||||||
# Получаем информацию о сообществе
|
|
||||||
community = session.query(Community).filter(Community.slug == community_slug).one_or_none()
|
|
||||||
if not community:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Если автор является создателем сообщества, то у него есть полные права
|
|
||||||
if community.created_by == author_id:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Проверяем наличие разрешения для этих ролей
|
|
||||||
permission_id = f"{resource}:{operation}"
|
|
||||||
ca = CommunityAuthor.find_by_user_and_community(author_id, community.id, session)
|
|
||||||
return bool(await ca.has_permission(permission_id))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_user_community_roles(session: Session, author_id: int, community_slug: str) -> list[str]:
|
|
||||||
"""
|
|
||||||
Получает список ролей пользователя в сообществе.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: Сессия SQLAlchemy
|
|
||||||
author_id: ID автора/пользователя
|
|
||||||
community_slug: Slug сообщества
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List[CommunityRole]: Список ролей пользователя в сообществе
|
|
||||||
"""
|
|
||||||
# Получаем информацию о сообществе
|
|
||||||
community = session.query(Community).filter(Community.slug == community_slug).one_or_none()
|
|
||||||
if not community:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Если автор является создателем сообщества, то у него есть роль владельца
|
|
||||||
if community.created_by == author_id:
|
|
||||||
return ["editor", "author", "expert", "reader"]
|
|
||||||
|
|
||||||
ca = CommunityAuthor.find_by_user_and_community(author_id, community.id, session)
|
|
||||||
return ca.role_list if ca else []
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def assign_role_to_user(session: Session, author_id: int, community_slug: str, role: str) -> bool:
|
|
||||||
"""
|
|
||||||
Назначает роль пользователю в сообществе.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: Сессия SQLAlchemy
|
|
||||||
author_id: ID автора/пользователя
|
|
||||||
community_slug: Slug сообщества
|
|
||||||
role: Роль для назначения (CommunityRole или строковое представление)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True если роль успешно назначена, иначе False
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Получаем информацию о сообществе
|
|
||||||
community = session.query(Community).filter(Community.slug == community_slug).one_or_none()
|
|
||||||
if not community:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Проверяем существование связи автор-сообщество
|
|
||||||
ca = CommunityAuthor.find_by_user_and_community(author_id, community.id, session)
|
|
||||||
if not ca:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Назначаем роль
|
|
||||||
ca.add_role(role)
|
|
||||||
return True
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def revoke_role_from_user(session: Session, author_id: int, community_slug: str, role: str) -> bool:
|
|
||||||
"""
|
|
||||||
Отзывает роль у пользователя в сообществе.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: Сессия SQLAlchemy
|
|
||||||
author_id: ID автора/пользователя
|
|
||||||
community_slug: Slug сообщества
|
|
||||||
role: Роль для отзыва (CommunityRole или строковое представление)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True если роль успешно отозвана, иначе False
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Получаем информацию о сообществе
|
|
||||||
community = session.query(Community).filter(Community.slug == community_slug).one_or_none()
|
|
||||||
if not community:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Проверяем существование связи автор-сообщество
|
|
||||||
ca = CommunityAuthor.find_by_user_and_community(author_id, community.id, session)
|
|
||||||
if not ca:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Отзываем роль
|
|
||||||
ca.remove_role(role)
|
|
||||||
return True
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""
|
|
||||||
Классы состояния авторизации
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
class AuthState:
|
|
||||||
"""
|
|
||||||
Класс для хранения информации о состоянии авторизации пользователя.
|
|
||||||
Используется в аутентификационных middleware и функциях.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.logged_in: bool = False
|
|
||||||
self.author_id: Optional[str] = None
|
|
||||||
self.token: Optional[str] = None
|
|
||||||
self.username: Optional[str] = None
|
|
||||||
self.is_admin: bool = False
|
|
||||||
self.is_editor: bool = False
|
|
||||||
self.error: Optional[str] = None
|
|
||||||
|
|
||||||
def __bool__(self) -> bool:
|
|
||||||
"""Возвращает True если пользователь авторизован"""
|
|
||||||
return self.logged_in
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"""
|
|
||||||
Базовый класс для работы с токенами
|
|
||||||
"""
|
|
||||||
|
|
||||||
import secrets
|
|
||||||
from functools import lru_cache
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from .types import TokenType
|
|
||||||
|
|
||||||
|
|
||||||
class BaseTokenManager:
|
|
||||||
"""
|
|
||||||
Базовый класс с общими методами для всех типов токенов
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@lru_cache(maxsize=1000)
|
|
||||||
def _make_token_key(token_type: TokenType, identifier: str, token: Optional[str] = None) -> str:
|
|
||||||
"""
|
|
||||||
Создает унифицированный ключ для токена с кэшированием
|
|
||||||
|
|
||||||
Args:
|
|
||||||
token_type: Тип токена
|
|
||||||
identifier: Идентификатор (user_id, user_id:provider, etc)
|
|
||||||
token: Сам токен (для session и verification)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Ключ токена
|
|
||||||
"""
|
|
||||||
if token_type == "session": # noqa: S105
|
|
||||||
return f"session:{identifier}:{token}"
|
|
||||||
if token_type == "verification": # noqa: S105
|
|
||||||
return f"verification_token:{token}"
|
|
||||||
if token_type == "oauth_access": # noqa: S105
|
|
||||||
return f"oauth_access:{identifier}"
|
|
||||||
if token_type == "oauth_refresh": # noqa: S105
|
|
||||||
return f"oauth_refresh:{identifier}"
|
|
||||||
|
|
||||||
error_msg = f"Неизвестный тип токена: {token_type}"
|
|
||||||
raise ValueError(error_msg)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@lru_cache(maxsize=500)
|
|
||||||
def _make_user_tokens_key(user_id: str, token_type: TokenType) -> str:
|
|
||||||
"""Создает ключ для списка токенов пользователя"""
|
|
||||||
if token_type == "session": # noqa: S105
|
|
||||||
return f"user_sessions:{user_id}"
|
|
||||||
return f"user_tokens:{user_id}:{token_type}"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def generate_token() -> str:
|
|
||||||
"""Генерирует криптографически стойкий токен"""
|
|
||||||
return secrets.token_urlsafe(32)
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
"""
|
|
||||||
Батчевые операции с токенами для оптимизации производительности
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from auth.jwtcodec import JWTCodec
|
|
||||||
from services.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):
|
|
||||||
if isinstance(payload, Exception) or not payload or not hasattr(payload, "user_id"):
|
|
||||||
results[token] = False
|
|
||||||
continue
|
|
||||||
|
|
||||||
token_key = self._make_token_key("session", payload.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):
|
|
||||||
results[token] = bool(exists)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
async def _safe_decode_token(self, token: str) -> Optional[Any]:
|
|
||||||
"""Безопасное декодирование токена"""
|
|
||||||
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:
|
|
||||||
user_id = payload.user_id
|
|
||||||
username = payload.username
|
|
||||||
|
|
||||||
# Ключи для удаления
|
|
||||||
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):
|
|
||||||
if exists:
|
|
||||||
active_tokens.append(token)
|
|
||||||
else:
|
|
||||||
cleaned_count += 1
|
|
||||||
|
|
||||||
# Обновляем список активных токенов
|
|
||||||
if active_tokens:
|
|
||||||
async with redis_adapter.pipeline() as pipe:
|
|
||||||
await pipe.delete(user_tokens_key)
|
|
||||||
for token in active_tokens:
|
|
||||||
await pipe.sadd(user_tokens_key, token)
|
|
||||||
await pipe.execute()
|
|
||||||
else:
|
|
||||||
await redis_adapter.delete(user_tokens_key)
|
|
||||||
|
|
||||||
if cursor == 0:
|
|
||||||
break
|
|
||||||
|
|
||||||
if cleaned_count > 0:
|
|
||||||
logger.info(f"Очищено {cleaned_count} ссылок на истекшие токены")
|
|
||||||
|
|
||||||
return cleaned_count
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка очистки токенов: {e}")
|
|
||||||
return 0
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
"""
|
|
||||||
Статистика и мониторинг системы токенов
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from services.redis import redis as redis_adapter
|
|
||||||
from utils.logger import root_logger as logger
|
|
||||||
|
|
||||||
from .base import BaseTokenManager
|
|
||||||
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):
|
|
||||||
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:
|
|
||||||
# Очищаем истекшие токены
|
|
||||||
from .batch import BatchTokenOperations
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# Тестируем основные операции с токенами
|
|
||||||
from .sessions import SessionTokenManager
|
|
||||||
|
|
||||||
session_manager = SessionTokenManager()
|
|
||||||
|
|
||||||
test_user_id = "health_check_user"
|
|
||||||
test_token = await session_manager.create_session(test_user_id)
|
|
||||||
|
|
||||||
if test_token:
|
|
||||||
# Проверяем валидацию
|
|
||||||
valid, _ = await session_manager.validate_session_token(test_token)
|
|
||||||
if valid:
|
|
||||||
# Проверяем отзыв
|
|
||||||
revoked = await session_manager.revoke_session_token(test_token)
|
|
||||||
if revoked:
|
|
||||||
health["token_operations"] = True
|
|
||||||
else:
|
|
||||||
health["errors"].append("Failed to revoke test token") # type: ignore[misc]
|
|
||||||
else:
|
|
||||||
health["errors"].append("Failed to validate test token") # type: ignore[misc]
|
|
||||||
else:
|
|
||||||
health["errors"].append("Failed to create test token") # type: ignore[misc]
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
health["errors"].append(f"Health check error: {e}") # type: ignore[misc]
|
|
||||||
|
|
||||||
if health["errors"]:
|
|
||||||
health["status"] = "unhealthy"
|
|
||||||
|
|
||||||
return health
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
"""
|
|
||||||
Управление OAuth токенов
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from services.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: Optional[str] = None,
|
|
||||||
expires_in: Optional[int] = None,
|
|
||||||
additional_data: Optional[TokenData] = 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) -> Optional[TokenData]:
|
|
||||||
"""Получает токен"""
|
|
||||||
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
|
|
||||||
) -> Optional[TokenData]:
|
|
||||||
"""Оптимизированное получение OAuth данных"""
|
|
||||||
if not user_id or not provider:
|
|
||||||
error_msg = "OAuth токены требуют user_id и provider"
|
|
||||||
raise ValueError(error_msg)
|
|
||||||
|
|
||||||
identifier = f"{user_id}:{provider}"
|
|
||||||
token_key = self._make_token_key(token_type, identifier)
|
|
||||||
|
|
||||||
# Получаем данные и TTL в одном pipeline
|
|
||||||
async with redis_adapter.pipeline() as pipe:
|
|
||||||
await pipe.get(token_key)
|
|
||||||
await pipe.ttl(token_key)
|
|
||||||
results = await pipe.execute()
|
|
||||||
|
|
||||||
if results[0]:
|
|
||||||
token_data = json.loads(results[0])
|
|
||||||
if results[1] > 0:
|
|
||||||
token_data["ttl_remaining"] = results[1]
|
|
||||||
return token_data
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def revoke_oauth_tokens(self, user_id: str, provider: str) -> bool:
|
|
||||||
"""Удаляет все OAuth токены для провайдера"""
|
|
||||||
try:
|
|
||||||
result1 = await self._revoke_oauth_token_optimized("oauth_access", user_id, provider)
|
|
||||||
result2 = await self._revoke_oauth_token_optimized("oauth_refresh", user_id, provider)
|
|
||||||
return result1 or result2
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка удаления OAuth токенов: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _revoke_oauth_token_optimized(self, token_type: TokenType, user_id: str, provider: str) -> bool:
|
|
||||||
"""Оптимизированный отзыв OAuth токена"""
|
|
||||||
if not user_id or not provider:
|
|
||||||
error_msg = "OAuth токены требуют user_id и provider"
|
|
||||||
raise ValueError(error_msg)
|
|
||||||
|
|
||||||
identifier = f"{user_id}:{provider}"
|
|
||||||
token_key = self._make_token_key(token_type, identifier)
|
|
||||||
result = await redis_adapter.delete(token_key)
|
|
||||||
return result > 0
|
|
||||||
|
|
||||||
async def revoke_user_oauth_tokens(self, user_id: str, token_type: TokenType) -> int:
|
|
||||||
"""Оптимизированный отзыв OAuth токенов пользователя используя SCAN"""
|
|
||||||
count = 0
|
|
||||||
cursor = 0
|
|
||||||
delete_keys = []
|
|
||||||
pattern = f"{token_type}:{user_id}:*"
|
|
||||||
|
|
||||||
# Используем SCAN для безопасного поиска токенов
|
|
||||||
while True:
|
|
||||||
cursor, keys = await redis_adapter.execute("scan", cursor, pattern, 100)
|
|
||||||
|
|
||||||
if keys:
|
|
||||||
delete_keys.extend(keys)
|
|
||||||
count += len(keys)
|
|
||||||
|
|
||||||
if cursor == 0:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Удаляем найденные токены пакетно
|
|
||||||
if delete_keys:
|
|
||||||
await redis_adapter.delete(*delete_keys)
|
|
||||||
|
|
||||||
return count
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
"""
|
|
||||||
Управление токенами сессий
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
from typing import Any, List, Optional, Union
|
|
||||||
|
|
||||||
from auth.jwtcodec import JWTCodec
|
|
||||||
from services.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: Optional[dict] = None,
|
|
||||||
username: Optional[str] = None,
|
|
||||||
device_info: Optional[dict] = 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
|
|
||||||
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: Optional[str] = None) -> Optional[TokenData]:
|
|
||||||
"""Получение данных сессии"""
|
|
||||||
if not user_id:
|
|
||||||
# Извлекаем user_id из JWT
|
|
||||||
payload = JWTCodec.decode(token)
|
|
||||||
if payload:
|
|
||||||
user_id = payload.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, Optional[TokenData]]:
|
|
||||||
"""
|
|
||||||
Проверяет валидность токена сессии
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Декодируем JWT токен
|
|
||||||
payload = JWTCodec.decode(token)
|
|
||||||
if not payload:
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
user_id = payload.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.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: Union[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):
|
|
||||||
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: Optional[dict] = None) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Обновляет сессию пользователя, заменяя старый токен новым
|
|
||||||
"""
|
|
||||||
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) -> Optional[Any]:
|
|
||||||
"""
|
|
||||||
Проверяет сессию по токену для совместимости с 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
|
|
||||||
|
|
||||||
if not hasattr(payload, "user_id"):
|
|
||||||
logger.error("В токене отсутствует user_id")
|
|
||||||
return None
|
|
||||||
|
|
||||||
logger.debug(f"Успешно декодирован токен, user_id={payload.user_id}")
|
|
||||||
|
|
||||||
# Проверяем наличие сессии в Redis
|
|
||||||
token_key = self._make_token_key("session", str(payload.user_id), token)
|
|
||||||
session_exists = await redis_adapter.exists(token_key)
|
|
||||||
|
|
||||||
if not session_exists:
|
|
||||||
logger.warning(f"Сессия не найдена в Redis для user_id={payload.user_id}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Обновляем last_activity
|
|
||||||
await redis_adapter.hset(token_key, "last_activity", str(int(time.time())))
|
|
||||||
|
|
||||||
return payload
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка при проверке сессии: {e}")
|
|
||||||
return None
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
"""
|
|
||||||
Простой интерфейс для системы токенов
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
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: Optional[dict] = None,
|
|
||||||
username: Optional[str] = None,
|
|
||||||
device_info: Optional[dict] = None,
|
|
||||||
) -> str:
|
|
||||||
"""Создание сессии пользователя"""
|
|
||||||
return await self._sessions.create_session(user_id, auth_data, username, device_info)
|
|
||||||
|
|
||||||
async def verify_session(self, token: str) -> Optional[Any]:
|
|
||||||
"""Проверка сессии по токену"""
|
|
||||||
return await self._sessions.verify_session(token)
|
|
||||||
|
|
||||||
async def refresh_session(self, user_id: int, old_token: str, device_info: Optional[dict] = None) -> Optional[str]:
|
|
||||||
"""Обновление сессии пользователя"""
|
|
||||||
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: Optional[dict] = None,
|
|
||||||
username: Optional[str] = None,
|
|
||||||
device_info: Optional[dict] = None,
|
|
||||||
) -> str:
|
|
||||||
"""Создание сессии пользователя"""
|
|
||||||
return await _token_storage.create_session(user_id, auth_data, username, device_info)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def verify_session(token: str) -> Optional[Any]:
|
|
||||||
"""Проверка сессии по токену"""
|
|
||||||
return await _token_storage.verify_session(token)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def refresh_session(user_id: int, old_token: str, device_info: Optional[dict] = None) -> Optional[str]:
|
|
||||||
"""Обновление сессии пользователя"""
|
|
||||||
return await _token_storage.refresh_session(user_id, old_token, device_info)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def revoke_session(session_token: str) -> bool:
|
|
||||||
"""Отзыв сессии"""
|
|
||||||
return await _token_storage.revoke_session(session_token)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def revoke_user_sessions(user_id: str) -> int:
|
|
||||||
"""Отзыв всех сессий пользователя"""
|
|
||||||
return await _token_storage.revoke_user_sessions(user_id)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def cleanup_expired_tokens() -> int:
|
|
||||||
"""Очистка истекших токенов"""
|
|
||||||
return await _token_storage.cleanup_expired_tokens()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_token_statistics() -> dict:
|
|
||||||
"""Получение статистики токенов"""
|
|
||||||
return await _token_storage.get_token_statistics()
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
"""
|
|
||||||
Типы и константы для системы токенов
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Any, Dict, Literal
|
|
||||||
|
|
||||||
# Типы токенов
|
|
||||||
TokenType = Literal["session", "verification", "oauth_access", "oauth_refresh"]
|
|
||||||
|
|
||||||
# TTL по умолчанию для разных типов токенов
|
|
||||||
DEFAULT_TTL = {
|
|
||||||
"session": 30 * 24 * 60 * 60, # 30 дней
|
|
||||||
"verification": 3600, # 1 час
|
|
||||||
"oauth_access": 3600, # 1 час
|
|
||||||
"oauth_refresh": 86400 * 30, # 30 дней
|
|
||||||
}
|
|
||||||
|
|
||||||
# Размеры батчей для оптимизации Redis операций
|
|
||||||
BATCH_SIZE = 100 # Размер батча для пакетной обработки токенов
|
|
||||||
SCAN_BATCH_SIZE = 1000 # Размер батча для SCAN операций
|
|
||||||
|
|
||||||
# Общие типы данных
|
|
||||||
TokenData = Dict[str, Any]
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
"""
|
|
||||||
Управление токенами подтверждения
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import secrets
|
|
||||||
import time
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from services.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: Optional[int] = 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: Optional[str] = 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) -> Optional[TokenData]:
|
|
||||||
"""Получает данные токена подтверждения"""
|
|
||||||
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, Optional[TokenData]]:
|
|
||||||
"""Проверяет валидность токена подтверждения"""
|
|
||||||
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) -> Optional[TokenData]:
|
|
||||||
"""Подтверждает и использует токен подтверждения (одноразовый)"""
|
|
||||||
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):
|
|
||||||
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):
|
|
||||||
if data:
|
|
||||||
try:
|
|
||||||
token_data = json.loads(data)
|
|
||||||
if (
|
|
||||||
token_data.get("user_id") == user_id
|
|
||||||
and token_data.get("verification_type") == verification_type
|
|
||||||
):
|
|
||||||
delete_keys.append(key)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if cursor == 0:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Удаляем найденные токены пакетно
|
|
||||||
if delete_keys:
|
|
||||||
await redis_adapter.delete(*delete_keys)
|
|
||||||
73
auth/tokenstorage.py
Normal file
73
auth/tokenstorage.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from auth.jwtcodec import JWTCodec
|
||||||
|
from base.redis import redis
|
||||||
|
from settings import ONETIME_TOKEN_LIFE_SPAN, SESSION_TOKEN_LIFE_SPAN
|
||||||
|
from validations.auth import AuthInput
|
||||||
|
|
||||||
|
|
||||||
|
async def save(token_key, life_span, auto_delete=True):
|
||||||
|
await redis.execute("SET", token_key, "True")
|
||||||
|
if auto_delete:
|
||||||
|
expire_at = (datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)).timestamp()
|
||||||
|
await redis.execute("EXPIREAT", token_key, int(expire_at))
|
||||||
|
|
||||||
|
|
||||||
|
class SessionToken:
|
||||||
|
@classmethod
|
||||||
|
async def verify(cls, token: str):
|
||||||
|
"""
|
||||||
|
Rules for a token to be valid.
|
||||||
|
- token format is legal
|
||||||
|
- token exists in redis database
|
||||||
|
- token is not expired
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return JWTCodec.decode(token)
|
||||||
|
except Exception as e:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get(cls, payload, token):
|
||||||
|
return await TokenStorage.get(f"{payload.user_id}-{payload.username}-{token}")
|
||||||
|
|
||||||
|
|
||||||
|
class TokenStorage:
|
||||||
|
@staticmethod
|
||||||
|
async def get(token_key):
|
||||||
|
print("[tokenstorage.get] " + token_key)
|
||||||
|
# 2041-user@domain.zn-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyMDQxLCJ1c2VybmFtZSI6ImFudG9uLnJld2luK3Rlc3QtbG9hZGNoYXRAZ21haWwuY29tIiwiZXhwIjoxNjcxNzgwNjE2LCJpYXQiOjE2NjkxODg2MTYsImlzcyI6ImRpc2NvdXJzIn0.Nml4oV6iMjMmc6xwM7lTKEZJKBXvJFEIZ-Up1C1rITQ
|
||||||
|
return await redis.execute("GET", token_key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def create_onetime(user: AuthInput) -> str:
|
||||||
|
life_span = ONETIME_TOKEN_LIFE_SPAN
|
||||||
|
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
|
||||||
|
one_time_token = JWTCodec.encode(user, exp)
|
||||||
|
await save(f"{user.id}-{user.username}-{one_time_token}", life_span)
|
||||||
|
return one_time_token
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def create_session(user: AuthInput) -> str:
|
||||||
|
life_span = SESSION_TOKEN_LIFE_SPAN
|
||||||
|
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
|
||||||
|
session_token = JWTCodec.encode(user, exp)
|
||||||
|
await save(f"{user.id}-{user.username}-{session_token}", life_span)
|
||||||
|
return session_token
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def revoke(token: str) -> bool:
|
||||||
|
payload = None
|
||||||
|
try:
|
||||||
|
print("[auth.tokenstorage] revoke token")
|
||||||
|
payload = JWTCodec.decode(token)
|
||||||
|
except: # noqa
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await redis.execute("DEL", f"{payload.user_id}-{payload.username}-{token}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def revoke_all(user: AuthInput):
|
||||||
|
tokens = await redis.execute("KEYS", f"{user.id}-*")
|
||||||
|
await redis.execute("DEL", *tokens)
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import re
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional, Union
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
|
||||||
|
|
||||||
# RFC 5322 compliant email regex pattern
|
|
||||||
EMAIL_PATTERN = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
|
||||||
|
|
||||||
|
|
||||||
class AuthInput(BaseModel):
|
|
||||||
"""Base model for authentication input validation"""
|
|
||||||
|
|
||||||
user_id: str = Field(description="Unique user identifier")
|
|
||||||
username: str = Field(min_length=2, max_length=50)
|
|
||||||
token: str = Field(min_length=32)
|
|
||||||
|
|
||||||
@field_validator("user_id")
|
|
||||||
@classmethod
|
|
||||||
def validate_user_id(cls, v: str) -> str:
|
|
||||||
if not v.strip():
|
|
||||||
msg = "user_id cannot be empty"
|
|
||||||
raise ValueError(msg)
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class UserRegistrationInput(BaseModel):
|
|
||||||
"""Validation model for user registration"""
|
|
||||||
|
|
||||||
email: str = Field(max_length=254) # Max email length per RFC 5321
|
|
||||||
password: str = Field(min_length=8, max_length=100)
|
|
||||||
name: str = Field(min_length=2, max_length=50)
|
|
||||||
|
|
||||||
@field_validator("email")
|
|
||||||
@classmethod
|
|
||||||
def validate_email(cls, v: str) -> str:
|
|
||||||
"""Validate email format"""
|
|
||||||
if not re.match(EMAIL_PATTERN, v):
|
|
||||||
msg = "Invalid email format"
|
|
||||||
raise ValueError(msg)
|
|
||||||
return v.lower()
|
|
||||||
|
|
||||||
@field_validator("password")
|
|
||||||
@classmethod
|
|
||||||
def validate_password_strength(cls, v: str) -> str:
|
|
||||||
"""Validate password meets security requirements"""
|
|
||||||
if not any(c.isupper() for c in v):
|
|
||||||
msg = "Password must contain at least one uppercase letter"
|
|
||||||
raise ValueError(msg)
|
|
||||||
if not any(c.islower() for c in v):
|
|
||||||
msg = "Password must contain at least one lowercase letter"
|
|
||||||
raise ValueError(msg)
|
|
||||||
if not any(c.isdigit() for c in v):
|
|
||||||
msg = "Password must contain at least one number"
|
|
||||||
raise ValueError(msg)
|
|
||||||
if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in v):
|
|
||||||
msg = "Password must contain at least one special character"
|
|
||||||
raise ValueError(msg)
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class UserLoginInput(BaseModel):
|
|
||||||
"""Validation model for user login"""
|
|
||||||
|
|
||||||
email: str = Field(max_length=254)
|
|
||||||
password: str = Field(min_length=8, max_length=100)
|
|
||||||
|
|
||||||
@field_validator("email")
|
|
||||||
@classmethod
|
|
||||||
def validate_email(cls, v: str) -> str:
|
|
||||||
if not re.match(EMAIL_PATTERN, v):
|
|
||||||
msg = "Invalid email format"
|
|
||||||
raise ValueError(msg)
|
|
||||||
return v.lower()
|
|
||||||
|
|
||||||
|
|
||||||
class TokenPayload(BaseModel):
|
|
||||||
"""Validation model for JWT token payload"""
|
|
||||||
|
|
||||||
user_id: str
|
|
||||||
username: str
|
|
||||||
exp: datetime
|
|
||||||
iat: datetime
|
|
||||||
scopes: Optional[list[str]] = []
|
|
||||||
|
|
||||||
|
|
||||||
class OAuthInput(BaseModel):
|
|
||||||
"""Validation model for OAuth input"""
|
|
||||||
|
|
||||||
provider: str = Field(pattern="^(google|github|facebook)$")
|
|
||||||
code: str
|
|
||||||
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)
|
|
||||||
return v.lower()
|
|
||||||
|
|
||||||
|
|
||||||
class AuthResponse(BaseModel):
|
|
||||||
"""Validation model for authentication responses"""
|
|
||||||
|
|
||||||
success: bool
|
|
||||||
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: 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)
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("token")
|
|
||||||
@classmethod
|
|
||||||
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)
|
|
||||||
return v
|
|
||||||
57
base/orm.py
Normal file
57
base/orm.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from typing import Any, Callable, Dict, Generic, TypeVar
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.sql.schema import Table
|
||||||
|
|
||||||
|
from settings import DB_URL
|
||||||
|
|
||||||
|
engine = create_engine(DB_URL, echo=False, pool_size=10, max_overflow=20)
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
REGISTRY: Dict[str, type] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def local_session():
|
||||||
|
return Session(bind=engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
DeclarativeBase = declarative_base() # type: Any
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
__table__: Table
|
||||||
|
__tablename__: str
|
||||||
|
__new__: Callable
|
||||||
|
__init__: Callable
|
||||||
|
__allow_unmapped__ = True
|
||||||
|
__abstract__ = True
|
||||||
|
__table_args__ = {"extend_existing": True}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
|
||||||
|
def __init_subclass__(cls, **kwargs):
|
||||||
|
REGISTRY[cls.__name__] = cls
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls: Generic[T], **kwargs) -> Generic[T]:
|
||||||
|
instance = cls(**kwargs)
|
||||||
|
return instance.save()
|
||||||
|
|
||||||
|
def save(self) -> Generic[T]:
|
||||||
|
with local_session() as session:
|
||||||
|
session.add(self)
|
||||||
|
session.commit()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def update(self, input):
|
||||||
|
column_names = self.__table__.columns.keys()
|
||||||
|
for name, value in input.items():
|
||||||
|
if name in column_names:
|
||||||
|
setattr(self, name, value)
|
||||||
|
|
||||||
|
def dict(self) -> Dict[str, Any]:
|
||||||
|
column_names = self.__table__.columns.keys()
|
||||||
|
return {c: getattr(self, c) for c in column_names}
|
||||||
62
base/redis.py
Normal file
62
base/redis.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import redis.asyncio as aredis
|
||||||
|
|
||||||
|
from settings import REDIS_URL
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger("[services.redis] ")
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
class RedisCache:
|
||||||
|
def __init__(self, uri=REDIS_URL):
|
||||||
|
self._uri: str = uri
|
||||||
|
self.pubsub_channels = []
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
self._client = aredis.Redis.from_url(self._uri, decode_responses=True)
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
if self._client:
|
||||||
|
await self._client.close()
|
||||||
|
|
||||||
|
async def execute(self, command, *args, **kwargs):
|
||||||
|
if self._client:
|
||||||
|
try:
|
||||||
|
logger.debug(f"{command} {args} {kwargs}")
|
||||||
|
r = await self._client.execute_command(command, *args, **kwargs)
|
||||||
|
logger.debug(type(r))
|
||||||
|
logger.debug(r)
|
||||||
|
return r
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(e)
|
||||||
|
|
||||||
|
async def subscribe(self, *channels):
|
||||||
|
if self._client:
|
||||||
|
async with self._client.pubsub() as pubsub:
|
||||||
|
for channel in channels:
|
||||||
|
await pubsub.subscribe(channel)
|
||||||
|
self.pubsub_channels.append(channel)
|
||||||
|
|
||||||
|
async def unsubscribe(self, *channels):
|
||||||
|
if not self._client:
|
||||||
|
return
|
||||||
|
async with self._client.pubsub() as pubsub:
|
||||||
|
for channel in channels:
|
||||||
|
await pubsub.unsubscribe(channel)
|
||||||
|
self.pubsub_channels.remove(channel)
|
||||||
|
|
||||||
|
async def publish(self, channel, data):
|
||||||
|
if not self._client:
|
||||||
|
return
|
||||||
|
await self._client.publish(channel, data)
|
||||||
|
|
||||||
|
async def mget(self, *keys):
|
||||||
|
return await self.execute('MGET', *keys)
|
||||||
|
|
||||||
|
async def lrange(self, key, start, stop):
|
||||||
|
return await self.execute('LRANGE', key, start, stop)
|
||||||
|
|
||||||
|
redis = RedisCache()
|
||||||
|
|
||||||
|
__all__ = ["redis"]
|
||||||
13
base/resolvers.py
Normal file
13
base/resolvers.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from ariadne import MutationType, QueryType, ScalarType
|
||||||
|
|
||||||
|
datetime_scalar = ScalarType("DateTime")
|
||||||
|
|
||||||
|
|
||||||
|
@datetime_scalar.serializer
|
||||||
|
def serialize_datetime(value):
|
||||||
|
return value.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
query = QueryType()
|
||||||
|
mutation = MutationType()
|
||||||
|
resolvers = [query, mutation, datetime_scalar]
|
||||||
109
biome.json
109
biome.json
@@ -1,109 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://biomejs.dev/schemas/2.1.2/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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
927
cache/cache.py
vendored
927
cache/cache.py
vendored
@@ -1,927 +0,0 @@
|
|||||||
"""
|
|
||||||
Caching system for the Discours platform
|
|
||||||
----------------------------------------
|
|
||||||
|
|
||||||
This module provides a comprehensive caching solution with these key components:
|
|
||||||
|
|
||||||
1. KEY NAMING CONVENTIONS:
|
|
||||||
- Entity-based keys: "entity:property:value" (e.g., "author:id:123")
|
|
||||||
- Collection keys: "entity:collection:params" (e.g., "authors:stats:limit=10:offset=0")
|
|
||||||
- Special case keys: Maintained for backwards compatibility (e.g., "topic_shouts_123")
|
|
||||||
|
|
||||||
2. CORE FUNCTIONS:
|
|
||||||
- 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
|
|
||||||
|
|
||||||
4. CACHE INVALIDATION STRATEGY:
|
|
||||||
- Direct invalidation via invalidate_* functions for immediate changes
|
|
||||||
- Delayed invalidation via revalidation_manager for background processing
|
|
||||||
- Event-based triggers for automatic cache updates (see triggers.py)
|
|
||||||
|
|
||||||
To maintain consistency with the existing codebase, this module preserves
|
|
||||||
the original key naming patterns while providing a more structured approach
|
|
||||||
for new cache operations.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from typing import Any, Callable, Dict, List, Optional, Type, Union
|
|
||||||
|
|
||||||
import orjson
|
|
||||||
from sqlalchemy import and_, join, select
|
|
||||||
|
|
||||||
from auth.orm import Author, AuthorFollower
|
|
||||||
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
|
||||||
from orm.topic import Topic, TopicFollower
|
|
||||||
from services.db import local_session
|
|
||||||
from services.redis import redis
|
|
||||||
from utils.encoders import fast_json_dumps
|
|
||||||
from utils.logger import root_logger as logger
|
|
||||||
|
|
||||||
DEFAULT_FOLLOWS = {
|
|
||||||
"topics": [],
|
|
||||||
"authors": [],
|
|
||||||
"shouts": [],
|
|
||||||
"communities": [{"id": 1, "name": "Дискурс", "slug": "discours", "pic": ""}],
|
|
||||||
}
|
|
||||||
|
|
||||||
CACHE_TTL = 300 # 5 minutes
|
|
||||||
|
|
||||||
# Key templates for common entity types
|
|
||||||
# These are used throughout the codebase and should be maintained for compatibility
|
|
||||||
CACHE_KEYS = {
|
|
||||||
"TOPIC_ID": "topic:id:{}",
|
|
||||||
"TOPIC_SLUG": "topic:slug:{}",
|
|
||||||
"TOPIC_AUTHORS": "topic:authors:{}",
|
|
||||||
"TOPIC_FOLLOWERS": "topic:followers:{}",
|
|
||||||
"TOPIC_SHOUTS": "topic_shouts_{}",
|
|
||||||
"AUTHOR_ID": "author:id:{}",
|
|
||||||
"SHOUTS": "shouts:{}",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Type alias for JSON encoder
|
|
||||||
JSONEncoderType = Type[json.JSONEncoder]
|
|
||||||
|
|
||||||
|
|
||||||
# Cache topic data
|
|
||||||
async def cache_topic(topic: dict) -> None:
|
|
||||||
payload = fast_json_dumps(topic)
|
|
||||||
await asyncio.gather(
|
|
||||||
redis.execute("SET", f"topic:id:{topic['id']}", payload),
|
|
||||||
redis.execute("SET", f"topic:slug:{topic['slug']}", payload),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Cache author data
|
|
||||||
async def cache_author(author: dict) -> None:
|
|
||||||
payload = fast_json_dumps(author)
|
|
||||||
await asyncio.gather(
|
|
||||||
redis.execute("SET", f"author:slug:{author['slug'].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:
|
|
||||||
key = f"author:follows-{entity_type}s:{follower_id}"
|
|
||||||
follows_str = await redis.execute("GET", key)
|
|
||||||
|
|
||||||
if follows_str:
|
|
||||||
follows = orjson.loads(follows_str)
|
|
||||||
# Для большинства типов используем пустой список ID, кроме communities
|
|
||||||
elif entity_type == "community":
|
|
||||||
follows = DEFAULT_FOLLOWS.get("communities", [])
|
|
||||||
else:
|
|
||||||
follows = []
|
|
||||||
|
|
||||||
if is_insert:
|
|
||||||
if entity_id not in follows:
|
|
||||||
follows.append(entity_id)
|
|
||||||
else:
|
|
||||||
follows = [eid for eid in follows if eid != entity_id]
|
|
||||||
await redis.execute("SET", key, fast_json_dumps(follows))
|
|
||||||
await update_follower_stat(follower_id, entity_type, len(follows))
|
|
||||||
|
|
||||||
|
|
||||||
# Update follower statistics
|
|
||||||
async def update_follower_stat(follower_id: int, entity_type: str, count: int) -> None:
|
|
||||||
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) -> dict | None:
|
|
||||||
logger.debug(f"[get_cached_author] Начало выполнения для author_id: {author_id}")
|
|
||||||
|
|
||||||
author_key = f"author:id:{author_id}"
|
|
||||||
logger.debug(f"[get_cached_author] Проверка кэша по ключу: {author_key}")
|
|
||||||
|
|
||||||
result = await redis.execute("GET", author_key)
|
|
||||||
if result:
|
|
||||||
logger.debug(f"[get_cached_author] Найдены данные в кэше, размер: {len(result)} байт")
|
|
||||||
cached_data = orjson.loads(result)
|
|
||||||
logger.debug(
|
|
||||||
f"[get_cached_author] Кэшированные данные имеют ключи: {list(cached_data.keys()) if cached_data else 'None'}"
|
|
||||||
)
|
|
||||||
return cached_data
|
|
||||||
|
|
||||||
logger.debug("[get_cached_author] Данные не найдены в кэше, загрузка из БД")
|
|
||||||
|
|
||||||
# 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} не найден в БД")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# Function to get cached topic
|
|
||||||
async def get_cached_topic(topic_id: int) -> dict | None:
|
|
||||||
"""
|
|
||||||
Fetch topic data from cache or database by id.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
topic_id (int): The identifier for the topic.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Topic data or None if not found.
|
|
||||||
"""
|
|
||||||
topic_key = f"topic:id:{topic_id}"
|
|
||||||
cached_topic = await redis.execute("GET", topic_key)
|
|
||||||
if cached_topic:
|
|
||||||
return orjson.loads(cached_topic)
|
|
||||||
|
|
||||||
# If not in cache, fetch from the database
|
|
||||||
with local_session() as session:
|
|
||||||
topic = session.execute(select(Topic).where(Topic.id == topic_id)).scalar_one_or_none()
|
|
||||||
if topic:
|
|
||||||
topic_dict = topic.dict()
|
|
||||||
await redis.execute("SET", topic_key, fast_json_dumps(topic_dict))
|
|
||||||
return topic_dict
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# Get topic by slug from cache
|
|
||||||
async def get_cached_topic_by_slug(slug: str, get_with_stat) -> dict | None:
|
|
||||||
topic_key = f"topic:slug:{slug}"
|
|
||||||
result = await redis.execute("GET", topic_key)
|
|
||||||
if result:
|
|
||||||
return orjson.loads(result)
|
|
||||||
# Load from database if not found in cache
|
|
||||||
topic_query = select(Topic).where(Topic.slug == slug)
|
|
||||||
topics = get_with_stat(topic_query)
|
|
||||||
if topics:
|
|
||||||
topic_dict = topics[0].dict()
|
|
||||||
await cache_topic(topic_dict)
|
|
||||||
return topic_dict
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# Get list of authors by ID from cache
|
|
||||||
async def get_cached_authors_by_ids(author_ids: list[int]) -> list[dict]:
|
|
||||||
# Fetch all author data concurrently
|
|
||||||
keys = [f"author:id:{author_id}" for author_id in author_ids]
|
|
||||||
results = await asyncio.gather(*(redis.execute("GET", key) for key in keys))
|
|
||||||
authors = [orjson.loads(result) if result else None for result in results]
|
|
||||||
# Load missing authors from database and cache
|
|
||||||
missing_indices = [index for index, author in enumerate(authors) if author is None]
|
|
||||||
if missing_indices:
|
|
||||||
missing_ids = [author_ids[index] for index in missing_indices]
|
|
||||||
with local_session() as session:
|
|
||||||
query = select(Author).where(Author.id.in_(missing_ids))
|
|
||||||
missing_authors = session.execute(query).scalars().unique().all()
|
|
||||||
await asyncio.gather(*(cache_author(author.dict()) for author in missing_authors))
|
|
||||||
for index, author in zip(missing_indices, missing_authors):
|
|
||||||
authors[index] = author.dict()
|
|
||||||
# Фильтруем None значения для корректного типа возвращаемого значения
|
|
||||||
return [author for author in authors if author is not None]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_cached_topic_followers(topic_id: int):
|
|
||||||
"""
|
|
||||||
Получает подписчиков темы по ID, используя кеш Redis.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
topic_id: ID темы
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List[dict]: Список подписчиков с их данными
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
cache_key = CACHE_KEYS["TOPIC_FOLLOWERS"].format(topic_id)
|
|
||||||
cached = await redis.execute("GET", cache_key)
|
|
||||||
|
|
||||||
if cached:
|
|
||||||
followers_ids = orjson.loads(cached)
|
|
||||||
logger.debug(f"Found {len(followers_ids)} cached followers for topic #{topic_id}")
|
|
||||||
return await get_cached_authors_by_ids(followers_ids)
|
|
||||||
|
|
||||||
with local_session() as session:
|
|
||||||
followers_ids = [
|
|
||||||
f[0]
|
|
||||||
for f in session.query(Author.id)
|
|
||||||
.join(TopicFollower, TopicFollower.follower == Author.id)
|
|
||||||
.filter(TopicFollower.topic == topic_id)
|
|
||||||
.all()
|
|
||||||
]
|
|
||||||
|
|
||||||
await redis.execute("SETEX", cache_key, CACHE_TTL, fast_json_dumps(followers_ids))
|
|
||||||
followers = await get_cached_authors_by_ids(followers_ids)
|
|
||||||
logger.debug(f"Cached {len(followers)} followers for topic #{topic_id}")
|
|
||||||
return followers
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting followers for topic #{topic_id}: {e!s}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
# Get cached author followers
|
|
||||||
async def get_cached_author_followers(author_id: int):
|
|
||||||
# Check cache for data
|
|
||||||
cached = await redis.execute("GET", f"author:followers:{author_id}")
|
|
||||||
if cached:
|
|
||||||
followers_ids = orjson.loads(cached)
|
|
||||||
followers = await get_cached_authors_by_ids(followers_ids)
|
|
||||||
logger.debug(f"Cached followers for author #{author_id}: {len(followers)}")
|
|
||||||
return followers
|
|
||||||
|
|
||||||
# Query database if cache is empty
|
|
||||||
with local_session() as session:
|
|
||||||
followers_ids = [
|
|
||||||
f[0]
|
|
||||||
for f in session.query(Author.id)
|
|
||||||
.join(AuthorFollower, AuthorFollower.follower == Author.id)
|
|
||||||
.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)
|
|
||||||
|
|
||||||
|
|
||||||
# Get cached follower authors
|
|
||||||
async def get_cached_follower_authors(author_id: int):
|
|
||||||
# Attempt to retrieve authors from cache
|
|
||||||
cached = await redis.execute("GET", f"author:follows-authors:{author_id}")
|
|
||||||
if cached:
|
|
||||||
authors_ids = orjson.loads(cached)
|
|
||||||
else:
|
|
||||||
# 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.author))
|
|
||||||
.where(AuthorFollower.follower == author_id)
|
|
||||||
).all()
|
|
||||||
]
|
|
||||||
await redis.execute("SET", f"author:follows-authors:{author_id}", fast_json_dumps(authors_ids))
|
|
||||||
|
|
||||||
return await get_cached_authors_by_ids(authors_ids)
|
|
||||||
|
|
||||||
|
|
||||||
# Get cached follower topics
|
|
||||||
async def get_cached_follower_topics(author_id: int):
|
|
||||||
# Attempt to retrieve topics from cache
|
|
||||||
cached = await redis.execute("GET", f"author:follows-topics:{author_id}")
|
|
||||||
if cached:
|
|
||||||
topics_ids = orjson.loads(cached)
|
|
||||||
else:
|
|
||||||
# Load topics from database and cache them
|
|
||||||
with local_session() as session:
|
|
||||||
topics_ids = [
|
|
||||||
t[0]
|
|
||||||
for t in session.query(Topic.id)
|
|
||||||
.join(TopicFollower, TopicFollower.topic == Topic.id)
|
|
||||||
.where(TopicFollower.follower == author_id)
|
|
||||||
.all()
|
|
||||||
]
|
|
||||||
await redis.execute("SET", f"author:follows-topics:{author_id}", fast_json_dumps(topics_ids))
|
|
||||||
|
|
||||||
topics = []
|
|
||||||
for topic_id in topics_ids:
|
|
||||||
topic_str = await redis.execute("GET", f"topic:id:{topic_id}")
|
|
||||||
if topic_str:
|
|
||||||
topic = orjson.loads(topic_str)
|
|
||||||
if topic and topic not in topics:
|
|
||||||
topics.append(topic)
|
|
||||||
|
|
||||||
logger.debug(f"Cached topics for author#{author_id}: {len(topics)}")
|
|
||||||
return topics
|
|
||||||
|
|
||||||
|
|
||||||
# Get author by author_id from cache
|
|
||||||
async def get_cached_author_by_id(author_id: int, get_with_stat):
|
|
||||||
"""
|
|
||||||
Retrieve author information by author_id, checking the cache first, then the database.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
author_id (int): The author identifier for which to retrieve the author.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Dictionary with author data or None if not found.
|
|
||||||
"""
|
|
||||||
# Attempt to find author data by author_id in Redis cache
|
|
||||||
cached_author_data = await redis.execute("GET", f"author:id:{author_id}")
|
|
||||||
if cached_author_data:
|
|
||||||
# If data is found, return parsed JSON
|
|
||||||
return orjson.loads(cached_author_data)
|
|
||||||
|
|
||||||
# If data is not found in cache, query the database
|
|
||||||
author_query = select(Author).where(Author.id == author_id)
|
|
||||||
authors = get_with_stat(author_query)
|
|
||||||
if authors:
|
|
||||||
# Cache the retrieved author data
|
|
||||||
author = authors[0]
|
|
||||||
author_dict = author.dict()
|
|
||||||
await asyncio.gather(
|
|
||||||
redis.execute("SET", f"author:id:{author.id}", fast_json_dumps(author_dict)),
|
|
||||||
)
|
|
||||||
return author_dict
|
|
||||||
|
|
||||||
# Return None if author is not found
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# Get cached topic authors
|
|
||||||
async def get_cached_topic_authors(topic_id: int):
|
|
||||||
"""
|
|
||||||
Retrieve a list of authors for a given topic, using cache or database.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
topic_id (int): The identifier of the topic for which to retrieve authors.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List[dict]: A list of dictionaries containing author data.
|
|
||||||
"""
|
|
||||||
# Attempt to get a list of author IDs from cache
|
|
||||||
rkey = f"topic:authors:{topic_id}"
|
|
||||||
cached_authors_ids = await redis.execute("GET", rkey)
|
|
||||||
if cached_authors_ids:
|
|
||||||
authors_ids = orjson.loads(cached_authors_ids)
|
|
||||||
else:
|
|
||||||
# If cache is empty, get data from the database
|
|
||||||
with local_session() as session:
|
|
||||||
query = (
|
|
||||||
select(ShoutAuthor.author)
|
|
||||||
.select_from(join(ShoutTopic, Shout, ShoutTopic.shout == Shout.id))
|
|
||||||
.join(ShoutAuthor, ShoutAuthor.shout == Shout.id)
|
|
||||||
.where(
|
|
||||||
and_(
|
|
||||||
ShoutTopic.topic == topic_id,
|
|
||||||
Shout.published_at.is_not(None),
|
|
||||||
Shout.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
authors_ids = [author_id for (author_id,) in session.execute(query).all()]
|
|
||||||
# Cache the retrieved author IDs
|
|
||||||
await redis.execute("SET", rkey, fast_json_dumps(authors_ids))
|
|
||||||
|
|
||||||
# Retrieve full author details from cached IDs
|
|
||||||
if authors_ids:
|
|
||||||
authors = await get_cached_authors_by_ids(authors_ids)
|
|
||||||
logger.debug(f"Topic#{topic_id} authors fetched and cached: {len(authors)} authors found.")
|
|
||||||
return authors
|
|
||||||
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
async def invalidate_shouts_cache(cache_keys: list[str]) -> None:
|
|
||||||
"""
|
|
||||||
Инвалидирует кэш выборок публикаций по переданным ключам.
|
|
||||||
"""
|
|
||||||
for cache_key in cache_keys:
|
|
||||||
try:
|
|
||||||
# Удаляем основной кэш
|
|
||||||
await redis.execute("DEL", cache_key)
|
|
||||||
logger.debug(f"Invalidated cache key: {cache_key}")
|
|
||||||
|
|
||||||
# Добавляем ключ в список инвалидированных с TTL
|
|
||||||
await redis.execute("SETEX", f"{cache_key}:invalidated", CACHE_TTL, "1")
|
|
||||||
|
|
||||||
# Если это кэш темы, инвалидируем также связанные ключи
|
|
||||||
if cache_key.startswith("topic_"):
|
|
||||||
topic_id = cache_key.split("_")[1]
|
|
||||||
related_keys = [
|
|
||||||
f"topic:id:{topic_id}",
|
|
||||||
f"topic:authors:{topic_id}",
|
|
||||||
f"topic:followers:{topic_id}",
|
|
||||||
f"topic:stats:{topic_id}",
|
|
||||||
]
|
|
||||||
for related_key in related_keys:
|
|
||||||
await redis.execute("DEL", related_key)
|
|
||||||
logger.debug(f"Invalidated related key: {related_key}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error invalidating cache key {cache_key}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
async def cache_topic_shouts(topic_id: int, shouts: list[dict]) -> None:
|
|
||||||
"""Кэширует список публикаций для темы"""
|
|
||||||
key = f"topic_shouts_{topic_id}"
|
|
||||||
payload = fast_json_dumps(shouts)
|
|
||||||
await redis.execute("SETEX", key, CACHE_TTL, payload)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_cached_topic_shouts(topic_id: int) -> list[dict]:
|
|
||||||
"""Получает кэшированный список публикаций для темы"""
|
|
||||||
key = f"topic_shouts_{topic_id}"
|
|
||||||
cached = await redis.execute("GET", key)
|
|
||||||
if cached:
|
|
||||||
return orjson.loads(cached)
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
async def cache_related_entities(shout: Shout) -> None:
|
|
||||||
"""
|
|
||||||
Кэширует все связанные с публикацией сущности (авторов и темы)
|
|
||||||
"""
|
|
||||||
tasks = [cache_by_id(Author, author.id, cache_author) for author in shout.authors]
|
|
||||||
tasks.extend(cache_by_id(Topic, topic.id, cache_topic) for topic in shout.topics)
|
|
||||||
await asyncio.gather(*tasks)
|
|
||||||
|
|
||||||
|
|
||||||
async def invalidate_shout_related_cache(shout: Shout, author_id: int) -> None:
|
|
||||||
"""
|
|
||||||
Инвалидирует весь кэш, связанный с публикацией и её связями
|
|
||||||
|
|
||||||
Args:
|
|
||||||
shout: Объект публикации
|
|
||||||
author_id: ID автора
|
|
||||||
"""
|
|
||||||
cache_keys = {
|
|
||||||
"feed", # основная лента
|
|
||||||
f"author_{author_id}", # публикации автора
|
|
||||||
"random_top", # случайные топовые
|
|
||||||
"unrated", # неоцененные
|
|
||||||
"recent", # последние
|
|
||||||
"coauthored", # совместные
|
|
||||||
}
|
|
||||||
|
|
||||||
# Добавляем ключи авторов
|
|
||||||
cache_keys.update(f"author_{a.id}" for a in shout.authors)
|
|
||||||
cache_keys.update(f"authored_{a.id}" for a in shout.authors)
|
|
||||||
|
|
||||||
# Добавляем ключи тем
|
|
||||||
cache_keys.update(f"topic_{t.id}" for t in shout.topics)
|
|
||||||
cache_keys.update(f"topic_shouts_{t.id}" for t in shout.topics)
|
|
||||||
|
|
||||||
await invalidate_shouts_cache(list(cache_keys))
|
|
||||||
|
|
||||||
|
|
||||||
# Function removed - direct Redis calls used throughout the module instead
|
|
||||||
|
|
||||||
|
|
||||||
async def get_cached_entity(entity_type: str, entity_id: int, get_method, cache_method):
|
|
||||||
"""
|
|
||||||
Универсальная функция получения кэшированной сущности
|
|
||||||
|
|
||||||
Args:
|
|
||||||
entity_type: 'author' или 'topic'
|
|
||||||
entity_id: ID сущности
|
|
||||||
get_method: метод получения из БД
|
|
||||||
cache_method: метод кэширования
|
|
||||||
"""
|
|
||||||
key = f"{entity_type}:id:{entity_id}"
|
|
||||||
cached = await redis.execute("GET", key)
|
|
||||||
if cached:
|
|
||||||
return orjson.loads(cached)
|
|
||||||
|
|
||||||
entity = await get_method(entity_id)
|
|
||||||
if entity:
|
|
||||||
await cache_method(entity)
|
|
||||||
return entity
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def cache_by_id(entity, entity_id: int, cache_method):
|
|
||||||
"""
|
|
||||||
Кэширует сущность по ID, используя указанный метод кэширования
|
|
||||||
|
|
||||||
Args:
|
|
||||||
entity: класс сущности (Author/Topic)
|
|
||||||
entity_id: ID сущности
|
|
||||||
cache_method: функция кэширования
|
|
||||||
"""
|
|
||||||
from resolvers.stat import get_with_stat
|
|
||||||
|
|
||||||
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
|
|
||||||
x = result[0]
|
|
||||||
d = x.dict()
|
|
||||||
await cache_method(d)
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
# Универсальная функция для сохранения данных в кеш
|
|
||||||
async def cache_data(key: str, data: Any, ttl: Optional[int] = None) -> None:
|
|
||||||
"""
|
|
||||||
Сохраняет данные в кеш по указанному ключу.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key: Ключ кеша
|
|
||||||
data: Данные для сохранения
|
|
||||||
ttl: Время жизни кеша в секундах (None - бессрочно)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
payload = fast_json_dumps(data)
|
|
||||||
if ttl:
|
|
||||||
await redis.execute("SETEX", key, ttl, payload)
|
|
||||||
else:
|
|
||||||
await redis.execute("SET", key, payload)
|
|
||||||
logger.debug(f"Данные сохранены в кеш по ключу {key}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка при сохранении данных в кеш: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# Универсальная функция для получения данных из кеша
|
|
||||||
async def get_cached_data(key: str) -> Optional[Any]:
|
|
||||||
"""
|
|
||||||
Получает данные из кеша по указанному ключу.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key: Ключ кеша
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Any: Данные из кеша или None, если данных нет
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
cached_data = await redis.execute("GET", key)
|
|
||||||
if cached_data:
|
|
||||||
loaded = orjson.loads(cached_data)
|
|
||||||
logger.debug(f"Данные получены из кеша по ключу {key}: {len(loaded)}")
|
|
||||||
return loaded
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка при получении данных из кеша: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# Универсальная функция для инвалидации кеша по префиксу
|
|
||||||
async def invalidate_cache_by_prefix(prefix: str) -> None:
|
|
||||||
"""
|
|
||||||
Инвалидирует все ключи кеша с указанным префиксом.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
prefix: Префикс ключей кеша для инвалидации
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
keys = await redis.execute("KEYS", f"{prefix}:*")
|
|
||||||
if keys:
|
|
||||||
await redis.execute("DEL", *keys)
|
|
||||||
logger.debug(f"Удалено {len(keys)} ключей кеша с префиксом {prefix}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка при инвалидации кеша: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# Универсальная функция для получения и кеширования данных
|
|
||||||
async def cached_query(
|
|
||||||
cache_key: str,
|
|
||||||
query_func: Callable,
|
|
||||||
ttl: Optional[int] = None,
|
|
||||||
force_refresh: bool = False,
|
|
||||||
use_key_format: bool = True,
|
|
||||||
**query_params,
|
|
||||||
) -> Any:
|
|
||||||
"""
|
|
||||||
Gets data from cache or executes query and saves result to cache.
|
|
||||||
Supports existing key formats for compatibility.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cache_key: Cache key or key template from CACHE_KEYS
|
|
||||||
query_func: Function to execute the query
|
|
||||||
ttl: Cache TTL in seconds (None - indefinite)
|
|
||||||
force_refresh: Force cache refresh
|
|
||||||
use_key_format: Whether to check if cache_key matches a key template in CACHE_KEYS
|
|
||||||
**query_params: Parameters to pass to the query function
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Any: Data from cache or query result
|
|
||||||
"""
|
|
||||||
# Check if cache_key matches a pattern in CACHE_KEYS
|
|
||||||
actual_key = cache_key
|
|
||||||
if use_key_format and "{}" in cache_key:
|
|
||||||
# Look for a template match in CACHE_KEYS
|
|
||||||
for key_format in CACHE_KEYS.values():
|
|
||||||
if cache_key == key_format:
|
|
||||||
# We have a match, now look for the id or value to format with
|
|
||||||
for param_name, param_value in query_params.items():
|
|
||||||
if param_name in ["id", "slug", "user", "topic_id", "author_id"]:
|
|
||||||
actual_key = cache_key.format(param_value)
|
|
||||||
break
|
|
||||||
|
|
||||||
# If not forcing refresh, try to get data from cache
|
|
||||||
if not force_refresh:
|
|
||||||
cached_result = await get_cached_data(actual_key)
|
|
||||||
if cached_result is not None:
|
|
||||||
return cached_result
|
|
||||||
|
|
||||||
# If data not in cache or refresh required, execute query
|
|
||||||
try:
|
|
||||||
result = await query_func(**query_params)
|
|
||||||
if result is not None:
|
|
||||||
# Save result to cache
|
|
||||||
await cache_data(actual_key, result, ttl)
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error executing query for caching: {e}")
|
|
||||||
# In case of error, return data from cache if not forcing refresh
|
|
||||||
if not force_refresh:
|
|
||||||
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: Union[int, str]) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Получает топик из кеша"""
|
|
||||||
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: Union[int, str]) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Получает автора из кеша"""
|
|
||||||
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: Union[int, str]) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Получает кешированный контент топика"""
|
|
||||||
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") -> Optional[List[Dict[str, Any]]]:
|
|
||||||
"""Получает статьи из кеша"""
|
|
||||||
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) -> Optional[List[Dict[str, Any]]]:
|
|
||||||
"""Получает кешированные результаты поиска"""
|
|
||||||
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: Union[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: Union[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).filter(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
|
|
||||||
195
cache/precache.py
vendored
195
cache/precache.py
vendored
@@ -1,195 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
|
|
||||||
from sqlalchemy import and_, join, select
|
|
||||||
|
|
||||||
from auth.orm import Author, AuthorFollower
|
|
||||||
from cache.cache import cache_author, cache_topic
|
|
||||||
from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic
|
|
||||||
from orm.topic import Topic, TopicFollower
|
|
||||||
from resolvers.stat import get_with_stat
|
|
||||||
from services.db import local_session
|
|
||||||
from services.redis import redis
|
|
||||||
from utils.encoders import fast_json_dumps
|
|
||||||
from utils.logger import root_logger as logger
|
|
||||||
|
|
||||||
|
|
||||||
# Предварительное кеширование подписчиков автора
|
|
||||||
async def precache_authors_followers(author_id, session) -> None:
|
|
||||||
authors_followers: set[int] = set()
|
|
||||||
followers_query = select(AuthorFollower.follower).where(AuthorFollower.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))
|
|
||||||
await redis.execute("SET", f"author:followers:{author_id}", followers_payload)
|
|
||||||
|
|
||||||
|
|
||||||
# Предварительное кеширование подписок автора
|
|
||||||
async def precache_authors_follows(author_id, session) -> None:
|
|
||||||
follows_topics_query = select(TopicFollower.topic).where(TopicFollower.follower == author_id)
|
|
||||||
follows_authors_query = select(AuthorFollower.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))
|
|
||||||
|
|
||||||
await asyncio.gather(
|
|
||||||
redis.execute("SET", f"author:follows-topics:{author_id}", topics_payload),
|
|
||||||
redis.execute("SET", f"author:follows-authors:{author_id}", authors_payload),
|
|
||||||
redis.execute("SET", f"author:follows-shouts:{author_id}", shouts_payload),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Предварительное кеширование авторов тем
|
|
||||||
async def precache_topics_authors(topic_id: int, session) -> None:
|
|
||||||
topic_authors_query = (
|
|
||||||
select(ShoutAuthor.author)
|
|
||||||
.select_from(join(ShoutTopic, Shout, ShoutTopic.shout == Shout.id))
|
|
||||||
.join(ShoutAuthor, ShoutAuthor.shout == Shout.id)
|
|
||||||
.filter(
|
|
||||||
and_(
|
|
||||||
ShoutTopic.topic == topic_id,
|
|
||||||
Shout.published_at.is_not(None),
|
|
||||||
Shout.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
topic_authors = {row[0] for row in session.execute(topic_authors_query) if row[0]}
|
|
||||||
|
|
||||||
authors_payload = fast_json_dumps(list(topic_authors))
|
|
||||||
await redis.execute("SET", f"topic:authors:{topic_id}", authors_payload)
|
|
||||||
|
|
||||||
|
|
||||||
# Предварительное кеширование подписчиков тем
|
|
||||||
async def precache_topics_followers(topic_id: int, session) -> None:
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
async def precache_data() -> None:
|
|
||||||
logger.info("precaching...")
|
|
||||||
logger.debug("Entering precache_data")
|
|
||||||
try:
|
|
||||||
# Список паттернов ключей, которые нужно сохранить при FLUSHDB
|
|
||||||
preserve_patterns = [
|
|
||||||
"migrated_views_*", # Данные миграции просмотров
|
|
||||||
"session:*", # Сессии пользователей
|
|
||||||
"env_vars:*", # Переменные окружения
|
|
||||||
"oauth_*", # OAuth токены
|
|
||||||
]
|
|
||||||
|
|
||||||
# Сохраняем все важные ключи перед очисткой
|
|
||||||
all_keys_to_preserve = []
|
|
||||||
preserved_data = {}
|
|
||||||
|
|
||||||
for pattern in preserve_patterns:
|
|
||||||
keys = await redis.execute("KEYS", pattern)
|
|
||||||
if keys:
|
|
||||||
all_keys_to_preserve.extend(keys)
|
|
||||||
logger.info(f"Найдено {len(keys)} ключей по паттерну '{pattern}'")
|
|
||||||
|
|
||||||
if all_keys_to_preserve:
|
|
||||||
logger.info(f"Сохраняем {len(all_keys_to_preserve)} важных ключей перед FLUSHDB")
|
|
||||||
for key in all_keys_to_preserve:
|
|
||||||
try:
|
|
||||||
# Определяем тип ключа и сохраняем данные
|
|
||||||
key_type = await redis.execute("TYPE", key)
|
|
||||||
if key_type == "hash":
|
|
||||||
preserved_data[key] = await redis.execute("HGETALL", key)
|
|
||||||
elif key_type == "string":
|
|
||||||
preserved_data[key] = await redis.execute("GET", key)
|
|
||||||
elif key_type == "set":
|
|
||||||
preserved_data[key] = await redis.execute("SMEMBERS", key)
|
|
||||||
elif key_type == "list":
|
|
||||||
preserved_data[key] = await redis.execute("LRANGE", key, 0, -1)
|
|
||||||
elif key_type == "zset":
|
|
||||||
preserved_data[key] = await redis.execute("ZRANGE", key, 0, -1, "WITHSCORES")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка при сохранении ключа {key}: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
await redis.execute("FLUSHDB")
|
|
||||||
logger.debug("Redis database flushed")
|
|
||||||
logger.info("redis: FLUSHDB")
|
|
||||||
|
|
||||||
# Восстанавливаем все сохранённые ключи
|
|
||||||
if preserved_data:
|
|
||||||
logger.info(f"Восстанавливаем {len(preserved_data)} сохранённых ключей")
|
|
||||||
for key, data in preserved_data.items():
|
|
||||||
try:
|
|
||||||
if isinstance(data, dict) and data:
|
|
||||||
# Hash
|
|
||||||
flattened = []
|
|
||||||
for field, val in data.items():
|
|
||||||
flattened.extend([field, val])
|
|
||||||
if flattened:
|
|
||||||
await redis.execute("HSET", key, *flattened)
|
|
||||||
elif isinstance(data, str) and data:
|
|
||||||
# String
|
|
||||||
await redis.execute("SET", key, data)
|
|
||||||
elif isinstance(data, list) and data:
|
|
||||||
# List или ZSet
|
|
||||||
if any(isinstance(item, (list, tuple)) and len(item) == 2 for item in data):
|
|
||||||
# ZSet with scores
|
|
||||||
for item in data:
|
|
||||||
if isinstance(item, (list, tuple)) and len(item) == 2:
|
|
||||||
await redis.execute("ZADD", key, item[1], item[0])
|
|
||||||
else:
|
|
||||||
# Regular list
|
|
||||||
await redis.execute("LPUSH", key, *data)
|
|
||||||
elif isinstance(data, set) and data:
|
|
||||||
# Set
|
|
||||||
await redis.execute("SADD", key, *data)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка при восстановлении ключа {key}: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
logger.info("Beginning topic precache phase")
|
|
||||||
with local_session() as session:
|
|
||||||
# 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")
|
|
||||||
|
|
||||||
# authors
|
|
||||||
authors = get_with_stat(select(Author))
|
|
||||||
# logger.info(f"{len(authors)} authors found in database")
|
|
||||||
for author in authors:
|
|
||||||
if isinstance(author, Author):
|
|
||||||
profile = author.dict()
|
|
||||||
author_id = profile.get("id")
|
|
||||||
# user_id = profile.get("user", "").strip()
|
|
||||||
if author_id: # and user_id:
|
|
||||||
await cache_author(profile)
|
|
||||||
await asyncio.gather(
|
|
||||||
precache_authors_followers(author_id, session),
|
|
||||||
precache_authors_follows(author_id, session),
|
|
||||||
)
|
|
||||||
# logger.debug(f"Finished precaching followers and follows for author id={author_id}")
|
|
||||||
else:
|
|
||||||
logger.error(f"fail caching {author}")
|
|
||||||
logger.info(f"{len(authors)} authors and their followings precached")
|
|
||||||
except Exception as exc:
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
logger.error(f"Error in precache_data: {exc}")
|
|
||||||
181
cache/revalidator.py
vendored
181
cache/revalidator.py
vendored
@@ -1,181 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import contextlib
|
|
||||||
|
|
||||||
from cache.cache import (
|
|
||||||
cache_author,
|
|
||||||
cache_topic,
|
|
||||||
get_cached_author,
|
|
||||||
get_cached_topic,
|
|
||||||
invalidate_cache_by_prefix,
|
|
||||||
)
|
|
||||||
from resolvers.stat import get_with_stat
|
|
||||||
from services.redis import redis
|
|
||||||
from utils.logger import root_logger as logger
|
|
||||||
|
|
||||||
CACHE_REVALIDATION_INTERVAL = 300 # 5 minutes
|
|
||||||
|
|
||||||
|
|
||||||
class CacheRevalidationManager:
|
|
||||||
def __init__(self, interval=CACHE_REVALIDATION_INTERVAL) -> None:
|
|
||||||
"""Инициализация менеджера с заданным интервалом проверки (в секундах)."""
|
|
||||||
self.interval = interval
|
|
||||||
self.items_to_revalidate: dict[str, set[str]] = {
|
|
||||||
"authors": set(),
|
|
||||||
"topics": set(),
|
|
||||||
"shouts": set(),
|
|
||||||
"reactions": set(),
|
|
||||||
}
|
|
||||||
self.lock = asyncio.Lock()
|
|
||||||
self.running = True
|
|
||||||
self.MAX_BATCH_SIZE = 10 # Максимальное количество элементов для поштучной обработки
|
|
||||||
self._redis = redis # Добавлена инициализация _redis для доступа к Redis-клиенту
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
"""Запуск фонового воркера для ревалидации кэша."""
|
|
||||||
# Проверяем, что у нас есть соединение с Redis
|
|
||||||
if not self._redis._client:
|
|
||||||
try:
|
|
||||||
await self._redis.connect()
|
|
||||||
logger.info("Redis connection established for revalidation manager")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to connect to Redis: {e}")
|
|
||||||
|
|
||||||
self.task = asyncio.create_task(self.revalidate_cache())
|
|
||||||
|
|
||||||
async def revalidate_cache(self) -> None:
|
|
||||||
"""Циклическая проверка и ревалидация кэша каждые self.interval секунд."""
|
|
||||||
try:
|
|
||||||
while self.running:
|
|
||||||
await asyncio.sleep(self.interval)
|
|
||||||
await self.process_revalidation()
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
logger.info("Revalidation worker was stopped.")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"An error occurred in the revalidation worker: {e}")
|
|
||||||
|
|
||||||
async def process_revalidation(self) -> None:
|
|
||||||
"""Обновление кэша для всех сущностей, требующих ревалидации."""
|
|
||||||
# Проверяем соединение с Redis
|
|
||||||
if not self._redis._client:
|
|
||||||
return # Выходим из метода, если не удалось подключиться
|
|
||||||
|
|
||||||
async with self.lock:
|
|
||||||
# Ревалидация кэша авторов
|
|
||||||
if self.items_to_revalidate["authors"]:
|
|
||||||
logger.debug(f"Revalidating {len(self.items_to_revalidate['authors'])} authors")
|
|
||||||
for author_id in self.items_to_revalidate["authors"]:
|
|
||||||
if author_id == "all":
|
|
||||||
await invalidate_cache_by_prefix("authors")
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
author = await get_cached_author(int(author_id), get_with_stat)
|
|
||||||
if author:
|
|
||||||
await cache_author(author)
|
|
||||||
except ValueError:
|
|
||||||
logger.warning(f"Invalid author_id: {author_id}")
|
|
||||||
self.items_to_revalidate["authors"].clear()
|
|
||||||
|
|
||||||
# Ревалидация кэша тем
|
|
||||||
if self.items_to_revalidate["topics"]:
|
|
||||||
logger.debug(f"Revalidating {len(self.items_to_revalidate['topics'])} topics")
|
|
||||||
for topic_id in self.items_to_revalidate["topics"]:
|
|
||||||
if topic_id == "all":
|
|
||||||
await invalidate_cache_by_prefix("topics")
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
topic = await get_cached_topic(int(topic_id))
|
|
||||||
if topic:
|
|
||||||
await cache_topic(topic)
|
|
||||||
except ValueError:
|
|
||||||
logger.warning(f"Invalid topic_id: {topic_id}")
|
|
||||||
self.items_to_revalidate["topics"].clear()
|
|
||||||
|
|
||||||
# Ревалидация шаутов (публикаций)
|
|
||||||
if self.items_to_revalidate["shouts"]:
|
|
||||||
shouts_count = len(self.items_to_revalidate["shouts"])
|
|
||||||
logger.debug(f"Revalidating {shouts_count} shouts")
|
|
||||||
|
|
||||||
# Проверяем наличие специального флага 'all'
|
|
||||||
if "all" in self.items_to_revalidate["shouts"]:
|
|
||||||
await invalidate_cache_by_prefix("shouts")
|
|
||||||
# Если элементов много, но не 'all', используем специфический подход
|
|
||||||
elif shouts_count > self.MAX_BATCH_SIZE:
|
|
||||||
# Инвалидируем только collections keys, которые затрагивают много сущностей
|
|
||||||
collection_keys = await asyncio.create_task(self._redis.execute("KEYS", "shouts:*"))
|
|
||||||
if collection_keys:
|
|
||||||
await self._redis.execute("DEL", *collection_keys)
|
|
||||||
logger.debug(f"Удалено {len(collection_keys)} коллекционных ключей шаутов")
|
|
||||||
|
|
||||||
# Обновляем кеш каждого конкретного шаута
|
|
||||||
for shout_id in self.items_to_revalidate["shouts"]:
|
|
||||||
if shout_id != "all":
|
|
||||||
# Точечная инвалидация для каждого shout_id
|
|
||||||
specific_keys = [f"shout:id:{shout_id}"]
|
|
||||||
for key in specific_keys:
|
|
||||||
await self._redis.execute("DEL", key)
|
|
||||||
logger.debug(f"Удален ключ кеша {key}")
|
|
||||||
else:
|
|
||||||
# Если элементов немного, обрабатываем каждый
|
|
||||||
for shout_id in self.items_to_revalidate["shouts"]:
|
|
||||||
if shout_id != "all":
|
|
||||||
# Точечная инвалидация для каждого shout_id
|
|
||||||
specific_keys = [f"shout:id:{shout_id}"]
|
|
||||||
for key in specific_keys:
|
|
||||||
await self._redis.execute("DEL", key)
|
|
||||||
logger.debug(f"Удален ключ кеша {key}")
|
|
||||||
|
|
||||||
self.items_to_revalidate["shouts"].clear()
|
|
||||||
|
|
||||||
# Аналогично для реакций - точечная инвалидация
|
|
||||||
if self.items_to_revalidate["reactions"]:
|
|
||||||
reactions_count = len(self.items_to_revalidate["reactions"])
|
|
||||||
logger.debug(f"Revalidating {reactions_count} reactions")
|
|
||||||
|
|
||||||
if "all" in self.items_to_revalidate["reactions"]:
|
|
||||||
await invalidate_cache_by_prefix("reactions")
|
|
||||||
elif reactions_count > self.MAX_BATCH_SIZE:
|
|
||||||
# Инвалидируем только collections keys для реакций
|
|
||||||
collection_keys = await asyncio.create_task(self._redis.execute("KEYS", "reactions:*"))
|
|
||||||
if collection_keys:
|
|
||||||
await self._redis.execute("DEL", *collection_keys)
|
|
||||||
logger.debug(f"Удалено {len(collection_keys)} коллекционных ключей реакций")
|
|
||||||
|
|
||||||
# Точечная инвалидация для каждой реакции
|
|
||||||
for reaction_id in self.items_to_revalidate["reactions"]:
|
|
||||||
if reaction_id != "all":
|
|
||||||
specific_keys = [f"reaction:id:{reaction_id}"]
|
|
||||||
for key in specific_keys:
|
|
||||||
await self._redis.execute("DEL", key)
|
|
||||||
logger.debug(f"Удален ключ кеша {key}")
|
|
||||||
else:
|
|
||||||
# Точечная инвалидация для каждой реакции
|
|
||||||
for reaction_id in self.items_to_revalidate["reactions"]:
|
|
||||||
if reaction_id != "all":
|
|
||||||
specific_keys = [f"reaction:id:{reaction_id}"]
|
|
||||||
for key in specific_keys:
|
|
||||||
await self._redis.execute("DEL", key)
|
|
||||||
logger.debug(f"Удален ключ кеша {key}")
|
|
||||||
|
|
||||||
self.items_to_revalidate["reactions"].clear()
|
|
||||||
|
|
||||||
def mark_for_revalidation(self, entity_id, entity_type) -> None:
|
|
||||||
"""Отметить сущность для ревалидации."""
|
|
||||||
if entity_id and entity_type:
|
|
||||||
self.items_to_revalidate[entity_type].add(entity_id)
|
|
||||||
|
|
||||||
def invalidate_all(self, entity_type) -> None:
|
|
||||||
"""Пометить для инвалидации все элементы указанного типа."""
|
|
||||||
logger.debug(f"Marking all {entity_type} for invalidation")
|
|
||||||
# Особый флаг для полной инвалидации
|
|
||||||
self.items_to_revalidate[entity_type].add("all")
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
|
||||||
"""Остановка фонового воркера."""
|
|
||||||
self.running = False
|
|
||||||
if hasattr(self, "task"):
|
|
||||||
self.task.cancel()
|
|
||||||
with contextlib.suppress(asyncio.CancelledError):
|
|
||||||
await self.task
|
|
||||||
|
|
||||||
|
|
||||||
revalidation_manager = CacheRevalidationManager()
|
|
||||||
147
cache/triggers.py
vendored
147
cache/triggers.py
vendored
@@ -1,147 +0,0 @@
|
|||||||
from sqlalchemy import event
|
|
||||||
|
|
||||||
from auth.orm import Author, AuthorFollower
|
|
||||||
from cache.revalidator import revalidation_manager
|
|
||||||
from orm.reaction import Reaction, ReactionKind
|
|
||||||
from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower
|
|
||||||
from orm.topic import Topic, TopicFollower
|
|
||||||
from services.db import local_session
|
|
||||||
from utils.logger import root_logger as logger
|
|
||||||
|
|
||||||
|
|
||||||
def mark_for_revalidation(entity, *args) -> None:
|
|
||||||
"""Отметка сущности для ревалидации."""
|
|
||||||
entity_type = (
|
|
||||||
"authors"
|
|
||||||
if isinstance(entity, Author)
|
|
||||||
else "topics"
|
|
||||||
if isinstance(entity, Topic)
|
|
||||||
else "reactions"
|
|
||||||
if isinstance(entity, Reaction)
|
|
||||||
else "shouts"
|
|
||||||
if isinstance(entity, Shout)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if entity_type:
|
|
||||||
revalidation_manager.mark_for_revalidation(entity.id, entity_type)
|
|
||||||
|
|
||||||
|
|
||||||
def after_follower_handler(mapper, connection, target, is_delete=False) -> None:
|
|
||||||
"""Обработчик добавления, обновления или удаления подписки."""
|
|
||||||
entity_type = None
|
|
||||||
if isinstance(target, AuthorFollower):
|
|
||||||
entity_type = "authors"
|
|
||||||
elif isinstance(target, TopicFollower):
|
|
||||||
entity_type = "topics"
|
|
||||||
elif isinstance(target, ShoutReactionsFollower):
|
|
||||||
entity_type = "shouts"
|
|
||||||
|
|
||||||
if entity_type:
|
|
||||||
revalidation_manager.mark_for_revalidation(
|
|
||||||
target.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:
|
|
||||||
"""Обработчик изменения статуса публикации"""
|
|
||||||
if not isinstance(target, Shout):
|
|
||||||
return
|
|
||||||
|
|
||||||
# Проверяем изменение статуса публикации
|
|
||||||
# was_published = target.published_at is not None and target.deleted_at is None
|
|
||||||
|
|
||||||
# Всегда обновляем счетчики для авторов и тем при любом изменении поста
|
|
||||||
for author in target.authors:
|
|
||||||
revalidation_manager.mark_for_revalidation(author.id, "authors")
|
|
||||||
|
|
||||||
for topic in target.topics:
|
|
||||||
revalidation_manager.mark_for_revalidation(topic.id, "topics")
|
|
||||||
|
|
||||||
# Обновляем сам пост
|
|
||||||
revalidation_manager.mark_for_revalidation(target.id, "shouts")
|
|
||||||
|
|
||||||
|
|
||||||
def after_reaction_handler(mapper, connection, target) -> None:
|
|
||||||
"""Обработчик для комментариев"""
|
|
||||||
if not isinstance(target, Reaction):
|
|
||||||
return
|
|
||||||
|
|
||||||
# Проверяем что это комментарий
|
|
||||||
is_comment = target.kind == ReactionKind.COMMENT.value
|
|
||||||
|
|
||||||
# Получаем связанный пост
|
|
||||||
shout_id = target.shout if isinstance(target.shout, int) else target.shout.id
|
|
||||||
if not shout_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Обновляем счетчики для автора комментария
|
|
||||||
if target.created_by:
|
|
||||||
revalidation_manager.mark_for_revalidation(target.created_by, "authors")
|
|
||||||
|
|
||||||
# Обновляем счетчики для поста
|
|
||||||
revalidation_manager.mark_for_revalidation(shout_id, "shouts")
|
|
||||||
|
|
||||||
if is_comment:
|
|
||||||
# Для комментариев обновляем также авторов и темы
|
|
||||||
with local_session() as session:
|
|
||||||
shout = (
|
|
||||||
session.query(Shout)
|
|
||||||
.filter(
|
|
||||||
Shout.id == shout_id,
|
|
||||||
Shout.published_at.is_not(None),
|
|
||||||
Shout.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if shout:
|
|
||||||
for author in shout.authors:
|
|
||||||
revalidation_manager.mark_for_revalidation(author.id, "authors")
|
|
||||||
|
|
||||||
for topic in shout.topics:
|
|
||||||
revalidation_manager.mark_for_revalidation(topic.id, "topics")
|
|
||||||
|
|
||||||
|
|
||||||
def events_register() -> None:
|
|
||||||
"""Регистрация обработчиков событий для всех сущностей."""
|
|
||||||
event.listen(ShoutAuthor, "after_insert", mark_for_revalidation)
|
|
||||||
event.listen(ShoutAuthor, "after_update", mark_for_revalidation)
|
|
||||||
event.listen(ShoutAuthor, "after_delete", mark_for_revalidation)
|
|
||||||
|
|
||||||
event.listen(AuthorFollower, "after_insert", after_follower_handler)
|
|
||||||
event.listen(AuthorFollower, "after_update", after_follower_handler)
|
|
||||||
event.listen(
|
|
||||||
AuthorFollower,
|
|
||||||
"after_delete",
|
|
||||||
lambda mapper, connection, target: after_follower_handler(mapper, connection, target, is_delete=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
event.listen(TopicFollower, "after_insert", after_follower_handler)
|
|
||||||
event.listen(TopicFollower, "after_update", after_follower_handler)
|
|
||||||
event.listen(
|
|
||||||
TopicFollower,
|
|
||||||
"after_delete",
|
|
||||||
lambda mapper, connection, target: after_follower_handler(mapper, connection, target, is_delete=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
event.listen(ShoutReactionsFollower, "after_insert", after_follower_handler)
|
|
||||||
event.listen(ShoutReactionsFollower, "after_update", after_follower_handler)
|
|
||||||
event.listen(
|
|
||||||
ShoutReactionsFollower,
|
|
||||||
"after_delete",
|
|
||||||
lambda mapper, connection, target: after_follower_handler(mapper, connection, target, is_delete=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
event.listen(Reaction, "after_update", mark_for_revalidation)
|
|
||||||
event.listen(Author, "after_update", mark_for_revalidation)
|
|
||||||
event.listen(Topic, "after_update", mark_for_revalidation)
|
|
||||||
event.listen(Shout, "after_update", after_shout_handler)
|
|
||||||
event.listen(Shout, "after_delete", after_shout_handler)
|
|
||||||
|
|
||||||
event.listen(Reaction, "after_insert", after_reaction_handler)
|
|
||||||
event.listen(Reaction, "after_update", after_reaction_handler)
|
|
||||||
event.listen(Reaction, "after_delete", after_reaction_handler)
|
|
||||||
|
|
||||||
logger.info("Event handlers registered successfully.")
|
|
||||||
10
checks.sh
Executable file
10
checks.sh
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
echo "> isort"
|
||||||
|
isort .
|
||||||
|
echo "> black"
|
||||||
|
black .
|
||||||
|
echo "> flake8"
|
||||||
|
flake8 .
|
||||||
|
# echo "> mypy"
|
||||||
|
# mypy .
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
{
|
|
||||||
"reader": [
|
|
||||||
"shout:read",
|
|
||||||
"topic:read",
|
|
||||||
"collection:read",
|
|
||||||
"community:read",
|
|
||||||
"bookmark:read",
|
|
||||||
"bookmark:create",
|
|
||||||
"bookmark:update_own",
|
|
||||||
"bookmark:delete_own",
|
|
||||||
"invite:read",
|
|
||||||
"invite:accept",
|
|
||||||
"invite:decline",
|
|
||||||
"chat:read",
|
|
||||||
"chat:create",
|
|
||||||
"chat:update_own",
|
|
||||||
"chat:delete_own",
|
|
||||||
"message:read",
|
|
||||||
"message:create",
|
|
||||||
"message:update_own",
|
|
||||||
"message:delete_own",
|
|
||||||
"reaction:read:COMMENT",
|
|
||||||
"reaction:create:COMMENT",
|
|
||||||
"reaction:update_own:COMMENT",
|
|
||||||
"reaction:delete_own:COMMENT",
|
|
||||||
"reaction:read:QUOTE",
|
|
||||||
"reaction:create:QUOTE",
|
|
||||||
"reaction:update_own:QUOTE",
|
|
||||||
"reaction:delete_own:QUOTE",
|
|
||||||
"reaction:read:LIKE",
|
|
||||||
"reaction:create:LIKE",
|
|
||||||
"reaction:update_own:LIKE",
|
|
||||||
"reaction:delete_own:LIKE",
|
|
||||||
"reaction:read:DISLIKE",
|
|
||||||
"reaction:create:DISLIKE",
|
|
||||||
"reaction:update_own:DISLIKE",
|
|
||||||
"reaction:delete_own:DISLIKE",
|
|
||||||
"reaction:read:CREDIT",
|
|
||||||
"reaction:read:PROOF",
|
|
||||||
"reaction:read:DISPROOF",
|
|
||||||
"reaction:read:AGREE",
|
|
||||||
"reaction:read:DISAGREE"
|
|
||||||
],
|
|
||||||
"author": [
|
|
||||||
"draft:read",
|
|
||||||
"draft:create",
|
|
||||||
"draft:update_own",
|
|
||||||
"draft:delete_own",
|
|
||||||
"shout:create",
|
|
||||||
"shout:update_own",
|
|
||||||
"shout:delete_own",
|
|
||||||
"collection:create",
|
|
||||||
"collection:update_own",
|
|
||||||
"collection:delete_own",
|
|
||||||
"invite:create",
|
|
||||||
"invite:update_own",
|
|
||||||
"invite:delete_own",
|
|
||||||
"reaction:create:SILENT",
|
|
||||||
"reaction:read:SILENT",
|
|
||||||
"reaction:update_own:SILENT",
|
|
||||||
"reaction:delete_own:SILENT"
|
|
||||||
],
|
|
||||||
"artist": [
|
|
||||||
"reaction:create:CREDIT",
|
|
||||||
"reaction:read:CREDIT",
|
|
||||||
"reaction:update_own:CREDIT",
|
|
||||||
"reaction:delete_own:CREDIT"
|
|
||||||
],
|
|
||||||
"expert": [
|
|
||||||
"reaction:create:PROOF",
|
|
||||||
"reaction:read:PROOF",
|
|
||||||
"reaction:update_own:PROOF",
|
|
||||||
"reaction:delete_own:PROOF",
|
|
||||||
"reaction:create:DISPROOF",
|
|
||||||
"reaction:read:DISPROOF",
|
|
||||||
"reaction:update_own:DISPROOF",
|
|
||||||
"reaction:delete_own:DISPROOF",
|
|
||||||
"reaction:create:AGREE",
|
|
||||||
"reaction:read:AGREE",
|
|
||||||
"reaction:update_own:AGREE",
|
|
||||||
"reaction:delete_own:AGREE",
|
|
||||||
"reaction:create:DISAGREE",
|
|
||||||
"reaction:read:DISAGREE",
|
|
||||||
"reaction:update_own:DISAGREE",
|
|
||||||
"reaction:delete_own:DISAGREE"
|
|
||||||
],
|
|
||||||
"editor": [
|
|
||||||
"shout:delete_any",
|
|
||||||
"shout:update_any",
|
|
||||||
"topic:create",
|
|
||||||
"topic:delete_own",
|
|
||||||
"topic:update_own",
|
|
||||||
"topic:merge",
|
|
||||||
"reaction:delete_any:*",
|
|
||||||
"reaction:update_any:*",
|
|
||||||
"invite:delete_any",
|
|
||||||
"invite:update_any",
|
|
||||||
"collection:delete_any",
|
|
||||||
"collection:update_any",
|
|
||||||
"community:create",
|
|
||||||
"community:update_own",
|
|
||||||
"community:delete_own",
|
|
||||||
"draft:delete_any",
|
|
||||||
"draft:update_any"
|
|
||||||
],
|
|
||||||
"admin": [
|
|
||||||
"author:delete_any",
|
|
||||||
"author:update_any",
|
|
||||||
"chat:delete_any",
|
|
||||||
"chat:update_any",
|
|
||||||
"message:delete_any",
|
|
||||||
"message:update_any"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
143
dev.py
143
dev.py
@@ -1,143 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from granian import Granian
|
|
||||||
from granian.constants import Interfaces
|
|
||||||
|
|
||||||
from utils.logger import root_logger as logger
|
|
||||||
|
|
||||||
|
|
||||||
def check_mkcert_installed() -> Optional[bool]:
|
|
||||||
"""
|
|
||||||
Проверяет, установлен ли инструмент 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="localhost", 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="localhost", help="Хост для запуска сервера")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
run_server(host=args.host, port=args.port, use_https=args.https, workers=args.workers, domain=args.domain)
|
|
||||||
116
docs/README.md
116
docs/README.md
@@ -1,116 +0,0 @@
|
|||||||
# Документация Discours.io API
|
|
||||||
|
|
||||||
## 🚀 Быстрый старт
|
|
||||||
|
|
||||||
### Запуск локально
|
|
||||||
```bash
|
|
||||||
# Стандартный запуск
|
|
||||||
python main.py
|
|
||||||
|
|
||||||
# С HTTPS (требует mkcert)
|
|
||||||
python dev.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📚 Документация
|
|
||||||
|
|
||||||
### Авторизация и безопасность
|
|
||||||
- [Система авторизации](auth-system.md) - Токены, сессии, OAuth
|
|
||||||
- [Архитектура](auth-architecture.md) - Диаграммы и схемы
|
|
||||||
- [Миграция](auth-migration.md) - Переход на новую версию
|
|
||||||
- [Безопасность](security.md) - Пароли, email, RBAC
|
|
||||||
- [Система RBAC](rbac-system.md) - Роли, разрешения, топики
|
|
||||||
- [OAuth](oauth.md) - Google, GitHub, Facebook, X, Telegram, VK, Yandex
|
|
||||||
- [OAuth настройка](oauth-setup.md) - Инструкции по настройке OAuth провайдеров
|
|
||||||
|
|
||||||
### Функциональность
|
|
||||||
- [Система рейтингов](rating.md) - Лайки, дизлайки, featured статьи
|
|
||||||
- [Подписки](follower.md) - Follow/unfollow логика
|
|
||||||
- [Кэширование](caching.md) - Redis, производительность
|
|
||||||
- [Схема данных Redis](redis-schema.md) - Полная документация структур данных
|
|
||||||
- [Пагинация комментариев](comments-pagination.md) - Иерархические комментарии
|
|
||||||
- [Загрузка контента](load_shouts.md) - Оптимизированные запросы
|
|
||||||
|
|
||||||
### Администрирование
|
|
||||||
- **Админ-панель**: Управление пользователями, ролями, переменными среды
|
|
||||||
- **Управление публикациями**: Просмотр, поиск, фильтрация по статусу (опубликованные/черновики/удаленные)
|
|
||||||
- **Управление топиками**: Упрощенное редактирование топиков с иерархическим отображением
|
|
||||||
- **Клик по строке**: Модалка редактирования открывается при клике на строку таблицы
|
|
||||||
- **Ненавязчивый крестик**: Серая кнопка "×" для удаления, краснеет при hover
|
|
||||||
- **Простой HTML редактор**: Обычный contenteditable div с моноширинным шрифтом
|
|
||||||
- **Редактируемые поля**: ID (просмотр), название, slug, описание, сообщество, родители
|
|
||||||
- **Дерево топиков**: Визуализация родительско-дочерних связей с отступами и символами `└─`
|
|
||||||
- **Безопасное удаление**: Предупреждения о каскадном удалении дочерних топиков
|
|
||||||
- **Автообновление**: Рефреш списка после операций с корректной инвалидацией кешей
|
|
||||||
- **Модерация реакций**: Полная система управления реакциями пользователей
|
|
||||||
- **Просмотр всех реакций**: Таблица с типом, текстом, автором, публикацией и статистикой
|
|
||||||
- **Фильтрация по типам**: Лайки, дизлайки, комментарии, цитаты, согласие/несогласие, вопросы, предложения, доказательства/опровержения
|
|
||||||
- **Поиск и фильтры**: По тексту реакции, автору, email или ID публикации
|
|
||||||
- **Эмоджи-индикаторы**: Визуальное отображение типов реакций (👍 👎 💬 ❝ ✅ ❌ ❓ 💡 🔬 🚫)
|
|
||||||
- **Модерация**: Редактирование текста, мягкое удаление и восстановление
|
|
||||||
- **Статистика**: Рейтинг и количество комментариев к каждой реакции
|
|
||||||
- **Безопасность**: RBAC защита и аудит всех операций
|
|
||||||
- **Просмотр данных**: Body, media, авторы, темы с удобной навигацией
|
|
||||||
- **DRY принцип**: Переиспользование существующих резолверов из reader.py и editor.py
|
|
||||||
|
|
||||||
### API и инфраструктура
|
|
||||||
- [API методы](api.md) - GraphQL эндпоинты
|
|
||||||
- [Функции системы](features.md) - Полный список возможностей
|
|
||||||
|
|
||||||
## ⚡ Ключевые возможности
|
|
||||||
|
|
||||||
### Авторизация
|
|
||||||
- **Модульная архитектура**: SessionTokenManager, VerificationTokenManager, OAuthTokenManager
|
|
||||||
- **OAuth провайдеры**: 7 поддерживаемых провайдеров с PKCE
|
|
||||||
- **RBAC**: Система ролей reader/author/artist/expert/editor/admin с наследованием
|
|
||||||
- **Права на топики**: Специальные разрешения для создания, редактирования и слияния топиков
|
|
||||||
- **Производительность**: 50% ускорение Redis, 30% меньше памяти
|
|
||||||
|
|
||||||
### Nginx (упрощенная конфигурация)
|
|
||||||
- **KISS принцип**: ~60 строк вместо сложной конфигурации
|
|
||||||
- **Dokku дефолты**: Максимальное использование встроенных настроек
|
|
||||||
- **SSL/TLS**: TLS 1.2/1.3, HSTS, OCSP stapling
|
|
||||||
- **Статические файлы**: Кэширование на 1 год, gzip сжатие
|
|
||||||
- **Безопасность**: X-Frame-Options, X-Content-Type-Options
|
|
||||||
|
|
||||||
### Реакции и комментарии
|
|
||||||
- **Иерархические комментарии** с эффективной пагинацией
|
|
||||||
- **Физическое/логическое удаление** (рейтинги/комментарии)
|
|
||||||
- **Автоматический featured статус** на основе лайков
|
|
||||||
- **Distinct() оптимизация** для JOIN запросов
|
|
||||||
|
|
||||||
### Производительность
|
|
||||||
- **Redis pipeline операции** для пакетных запросов
|
|
||||||
- **Автоматическая очистка** истекших токенов
|
|
||||||
- **Connection pooling** и keepalive
|
|
||||||
- **Type-safe codebase** (mypy clean)
|
|
||||||
- **Оптимизированная сортировка авторов** с кешированием по параметрам
|
|
||||||
|
|
||||||
## 🔧 Конфигурация
|
|
||||||
|
|
||||||
```python
|
|
||||||
# JWT
|
|
||||||
JWT_SECRET_KEY = "your-secret-key"
|
|
||||||
JWT_EXPIRATION_HOURS = 720 # 30 дней
|
|
||||||
|
|
||||||
# Redis
|
|
||||||
REDIS_URL = "redis://localhost:6379/0"
|
|
||||||
|
|
||||||
# OAuth (необходимые провайдеры)
|
|
||||||
OAUTH_CLIENTS_GOOGLE_ID = "..."
|
|
||||||
OAUTH_CLIENTS_GITHUB_ID = "..."
|
|
||||||
# ... другие провайдеры
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🛠 Использование API
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Сессии
|
|
||||||
from auth.tokens.sessions import SessionTokenManager
|
|
||||||
sessions = SessionTokenManager()
|
|
||||||
token = await sessions.create_session(user_id, username=username)
|
|
||||||
|
|
||||||
# Мониторинг
|
|
||||||
from auth.tokens.monitoring import TokenMonitoring
|
|
||||||
monitoring = TokenMonitoring()
|
|
||||||
stats = await monitoring.get_token_statistics()
|
|
||||||
```
|
|
||||||
@@ -1,560 +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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Особенности реализации
|
|
||||||
|
|
||||||
### Принцип 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`
|
|
||||||
|
|
||||||
## Миграция данных
|
|
||||||
|
|
||||||
При переходе на новую RBAC систему используется функция:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from orm.community import migrate_old_roles_to_community_author
|
|
||||||
migrate_old_roles_to_community_author()
|
|
||||||
```
|
|
||||||
|
|
||||||
Функция автоматически переносит роли из старых таблиц в новый формат CSV.
|
|
||||||
|
|
||||||
## Мониторинг и логирование
|
|
||||||
|
|
||||||
Все административные действия логируются с уровнем INFO:
|
|
||||||
- Изменение ролей пользователей
|
|
||||||
- Обновление настроек сообществ
|
|
||||||
- Операции с публикациями
|
|
||||||
- Управление приглашениями
|
|
||||||
|
|
||||||
Ошибки логируются с уровнем ERROR и полным стектрейсом.
|
|
||||||
|
|
||||||
## Лучшие практики
|
|
||||||
|
|
||||||
1. **Всегда проверяйте роли перед назначением**
|
|
||||||
2. **Используйте транзакции для групповых операций**
|
|
||||||
3. **Логируйте критические изменения**
|
|
||||||
4. **Валидируйте права доступа на каждом этапе**
|
|
||||||
5. **Применяйте принцип минимальных привилегий**
|
|
||||||
|
|
||||||
## Расширение функциональности
|
|
||||||
|
|
||||||
Для добавления новых административных функций:
|
|
||||||
|
|
||||||
1. Создайте резолвер с соответствующим декоратором
|
|
||||||
2. Добавьте GraphQL схему в `schema/admin.graphql`
|
|
||||||
3. Реализуйте логику с переиспользованием существующих компонентов
|
|
||||||
4. Добавьте тесты и документацию
|
|
||||||
5. Обновите права доступа при необходимости
|
|
||||||
40
docs/api.md
40
docs/api.md
@@ -1,40 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
## API Documentation
|
|
||||||
|
|
||||||
### GraphQL Schema
|
|
||||||
- Mutations: Authentication, content management, security
|
|
||||||
- Queries: Content retrieval, user data
|
|
||||||
- Types: Author, Topic, Shout, Community
|
|
||||||
|
|
||||||
### Key Features
|
|
||||||
|
|
||||||
#### Security Management
|
|
||||||
- Password change with validation
|
|
||||||
- Email change with confirmation
|
|
||||||
- Two-factor authentication flow
|
|
||||||
- Protected fields for user privacy
|
|
||||||
|
|
||||||
#### Content Management
|
|
||||||
- Publication system with drafts
|
|
||||||
- Topic and community organization
|
|
||||||
- Author collaboration tools
|
|
||||||
- Real-time notifications
|
|
||||||
|
|
||||||
#### Following System
|
|
||||||
- Subscribe to authors and topics
|
|
||||||
- Cache-optimized operations
|
|
||||||
- Consistent UI state management
|
|
||||||
|
|
||||||
## Database
|
|
||||||
|
|
||||||
### Models
|
|
||||||
- `Author` - User accounts with RBAC
|
|
||||||
- `Shout` - Publications and articles
|
|
||||||
- `Topic` - Content categorization
|
|
||||||
- `Community` - User groups
|
|
||||||
|
|
||||||
### Cache System
|
|
||||||
- Redis-based caching
|
|
||||||
- Automatic cache invalidation
|
|
||||||
- Optimized for real-time updates
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
# Архитектура системы авторизации
|
|
||||||
|
|
||||||
## Схема потоков данных
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TB
|
|
||||||
subgraph "Frontend"
|
|
||||||
FE[Web Frontend]
|
|
||||||
MOB[Mobile App]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Auth Layer"
|
|
||||||
MW[AuthMiddleware]
|
|
||||||
DEC[GraphQL Decorators]
|
|
||||||
HANDLER[Auth Handlers]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Core Auth"
|
|
||||||
IDENTITY[Identity]
|
|
||||||
JWT[JWT Codec]
|
|
||||||
OAUTH[OAuth Manager]
|
|
||||||
PERM[Permissions]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Token System"
|
|
||||||
TS[TokenStorage]
|
|
||||||
STM[SessionTokenManager]
|
|
||||||
VTM[VerificationTokenManager]
|
|
||||||
OTM[OAuthTokenManager]
|
|
||||||
BTM[BatchTokenOperations]
|
|
||||||
MON[TokenMonitoring]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Storage"
|
|
||||||
REDIS[(Redis)]
|
|
||||||
DB[(PostgreSQL)]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "External"
|
|
||||||
GOOGLE[Google OAuth]
|
|
||||||
GITHUB[GitHub OAuth]
|
|
||||||
FACEBOOK[Facebook]
|
|
||||||
OTHER[Other Providers]
|
|
||||||
end
|
|
||||||
|
|
||||||
FE --> MW
|
|
||||||
MOB --> MW
|
|
||||||
MW --> IDENTITY
|
|
||||||
MW --> JWT
|
|
||||||
|
|
||||||
DEC --> PERM
|
|
||||||
HANDLER --> OAUTH
|
|
||||||
|
|
||||||
IDENTITY --> STM
|
|
||||||
OAUTH --> OTM
|
|
||||||
|
|
||||||
TS --> STM
|
|
||||||
TS --> VTM
|
|
||||||
TS --> OTM
|
|
||||||
|
|
||||||
STM --> REDIS
|
|
||||||
VTM --> REDIS
|
|
||||||
OTM --> REDIS
|
|
||||||
BTM --> REDIS
|
|
||||||
MON --> REDIS
|
|
||||||
|
|
||||||
IDENTITY --> DB
|
|
||||||
OAUTH --> DB
|
|
||||||
PERM --> DB
|
|
||||||
|
|
||||||
OAUTH --> GOOGLE
|
|
||||||
OAUTH --> GITHUB
|
|
||||||
OAUTH --> FACEBOOK
|
|
||||||
OAUTH --> OTHER
|
|
||||||
```
|
|
||||||
|
|
||||||
## Диаграмма компонентов
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph LR
|
|
||||||
subgraph "HTTP Layer"
|
|
||||||
REQ[HTTP Request]
|
|
||||||
RESP[HTTP Response]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Middleware"
|
|
||||||
AUTH_MW[Auth Middleware]
|
|
||||||
CORS_MW[CORS Middleware]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "GraphQL"
|
|
||||||
RESOLVER[GraphQL Resolvers]
|
|
||||||
DECORATOR[Auth Decorators]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Auth Core"
|
|
||||||
VALIDATION[Validation]
|
|
||||||
IDENTIFICATION[Identity Check]
|
|
||||||
AUTHORIZATION[Permission Check]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Token Management"
|
|
||||||
CREATE[Token Creation]
|
|
||||||
VERIFY[Token Verification]
|
|
||||||
REVOKE[Token Revocation]
|
|
||||||
REFRESH[Token Refresh]
|
|
||||||
end
|
|
||||||
|
|
||||||
REQ --> CORS_MW
|
|
||||||
CORS_MW --> AUTH_MW
|
|
||||||
AUTH_MW --> RESOLVER
|
|
||||||
RESOLVER --> DECORATOR
|
|
||||||
|
|
||||||
DECORATOR --> VALIDATION
|
|
||||||
VALIDATION --> IDENTIFICATION
|
|
||||||
IDENTIFICATION --> AUTHORIZATION
|
|
||||||
|
|
||||||
AUTHORIZATION --> CREATE
|
|
||||||
AUTHORIZATION --> VERIFY
|
|
||||||
AUTHORIZATION --> REVOKE
|
|
||||||
AUTHORIZATION --> REFRESH
|
|
||||||
|
|
||||||
CREATE --> RESP
|
|
||||||
VERIFY --> RESP
|
|
||||||
REVOKE --> RESP
|
|
||||||
REFRESH --> RESP
|
|
||||||
```
|
|
||||||
|
|
||||||
## Схема OAuth потока
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant U as User
|
|
||||||
participant F as Frontend
|
|
||||||
participant A as Auth Service
|
|
||||||
participant R as Redis
|
|
||||||
participant P as OAuth Provider
|
|
||||||
participant D as Database
|
|
||||||
|
|
||||||
U->>F: Click "Login with Provider"
|
|
||||||
F->>A: GET /oauth/{provider}?state={csrf}
|
|
||||||
A->>R: Store OAuth state
|
|
||||||
A->>P: Redirect to Provider
|
|
||||||
P->>U: Show authorization page
|
|
||||||
U->>P: Grant permission
|
|
||||||
P->>A: GET /oauth/{provider}/callback?code={code}&state={state}
|
|
||||||
A->>R: Verify state
|
|
||||||
A->>P: Exchange code for token
|
|
||||||
P->>A: Return access token + user data
|
|
||||||
A->>D: Find/create user
|
|
||||||
A->>A: Generate JWT session token
|
|
||||||
A->>R: Store session in Redis
|
|
||||||
A->>F: Redirect with JWT token
|
|
||||||
F->>U: User logged in
|
|
||||||
```
|
|
||||||
|
|
||||||
## Схема сессионного управления
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
stateDiagram-v2
|
|
||||||
[*] --> Anonymous
|
|
||||||
Anonymous --> Authenticating: Login attempt
|
|
||||||
Authenticating --> Authenticated: Valid credentials
|
|
||||||
Authenticating --> Anonymous: Invalid credentials
|
|
||||||
Authenticated --> Refreshing: Token near expiry
|
|
||||||
Refreshing --> Authenticated: Successful refresh
|
|
||||||
Refreshing --> Anonymous: Refresh failed
|
|
||||||
Authenticated --> Anonymous: Logout/Revoke
|
|
||||||
Authenticated --> Anonymous: Token expired
|
|
||||||
```
|
|
||||||
|
|
||||||
## Redis структура данных
|
|
||||||
|
|
||||||
```
|
|
||||||
├── Sessions
|
|
||||||
│ ├── session:{user_id}:{token} → Hash {user_id, username, device_info, last_activity}
|
|
||||||
│ ├── user_sessions:{user_id} → Set {token1, token2, ...}
|
|
||||||
│ └── {user_id}-{username}-{token} → Hash (legacy format)
|
|
||||||
│
|
|
||||||
├── Verification
|
|
||||||
│ └── verification_token:{token} → JSON {user_id, type, data, created_at}
|
|
||||||
│
|
|
||||||
├── OAuth
|
|
||||||
│ ├── oauth_access:{user_id}:{provider} → JSON {token, expires_in, scope}
|
|
||||||
│ ├── oauth_refresh:{user_id}:{provider} → JSON {token, provider_data}
|
|
||||||
│ └── oauth_state:{state} → JSON {provider, redirect_uri, code_verifier}
|
|
||||||
│
|
|
||||||
└── Monitoring
|
|
||||||
└── token_stats → Hash {session_count, oauth_count, memory_usage}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Компоненты безопасности
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
subgraph "Input Validation"
|
|
||||||
EMAIL[Email Format]
|
|
||||||
PASS[Password Strength]
|
|
||||||
TOKEN[Token Format]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Authentication"
|
|
||||||
BCRYPT[bcrypt + SHA256]
|
|
||||||
JWT_SIGN[JWT Signing]
|
|
||||||
OAUTH_VERIFY[OAuth Verification]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Authorization"
|
|
||||||
ROLE[Role-based Access]
|
|
||||||
PERM[Permission Checks]
|
|
||||||
RESOURCE[Resource Access]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Session Security"
|
|
||||||
TTL[Token TTL]
|
|
||||||
REVOKE[Token Revocation]
|
|
||||||
REFRESH[Secure Refresh]
|
|
||||||
end
|
|
||||||
|
|
||||||
EMAIL --> BCRYPT
|
|
||||||
PASS --> BCRYPT
|
|
||||||
TOKEN --> JWT_SIGN
|
|
||||||
|
|
||||||
BCRYPT --> ROLE
|
|
||||||
JWT_SIGN --> ROLE
|
|
||||||
OAUTH_VERIFY --> ROLE
|
|
||||||
|
|
||||||
ROLE --> PERM
|
|
||||||
PERM --> RESOURCE
|
|
||||||
|
|
||||||
RESOURCE --> TTL
|
|
||||||
RESOURCE --> REVOKE
|
|
||||||
RESOURCE --> REFRESH
|
|
||||||
```
|
|
||||||
|
|
||||||
## Масштабирование и производительность
|
|
||||||
|
|
||||||
### Горизонтальное масштабирование
|
|
||||||
- **Stateless JWT** токены
|
|
||||||
- **Redis Cluster** для высокой доступности
|
|
||||||
- **Load Balancer** aware session management
|
|
||||||
|
|
||||||
### Оптимизации
|
|
||||||
- **Connection pooling** для Redis
|
|
||||||
- **Batch operations** для массовых операций
|
|
||||||
- **Pipeline использование** для атомарности
|
|
||||||
- **LRU кэширование** для часто используемых данных
|
|
||||||
|
|
||||||
### Мониторинг производительности
|
|
||||||
- **Response time** auth операций
|
|
||||||
- **Redis memory usage** и hit rate
|
|
||||||
- **Token creation/validation** rate
|
|
||||||
- **OAuth provider** response times
|
|
||||||
@@ -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 services.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 services.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 services.redis import redis
|
|
||||||
async def test():
|
|
||||||
result = await redis.ping()
|
|
||||||
print(f'Redis connection: {result}')
|
|
||||||
asyncio.run(test())
|
|
||||||
"
|
|
||||||
|
|
||||||
# Health check системы токенов
|
|
||||||
python -c "
|
|
||||||
import asyncio
|
|
||||||
from auth.tokens.monitoring import TokenMonitoring
|
|
||||||
async def test():
|
|
||||||
health = await TokenMonitoring().health_check()
|
|
||||||
print(f'Token system health: {health}')
|
|
||||||
asyncio.run(test())
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Производительность
|
|
||||||
|
|
||||||
### Ожидаемые улучшения
|
|
||||||
- **50%** ускорение Redis операций (pipeline использование)
|
|
||||||
- **30%** снижение memory usage (оптимизированные структуры)
|
|
||||||
- **Elimination** of proxy overhead (прямое обращение к менеджерам)
|
|
||||||
- **Real-time** мониторинг и статистика
|
|
||||||
|
|
||||||
### Мониторинг после миграции
|
|
||||||
```python
|
|
||||||
# Регулярно проверяйте статистику
|
|
||||||
from auth.tokens.monitoring import TokenMonitoring
|
|
||||||
|
|
||||||
async def check_performance():
|
|
||||||
monitoring = TokenMonitoring()
|
|
||||||
stats = await monitoring.get_token_statistics()
|
|
||||||
|
|
||||||
print(f"Session tokens: {stats['session_tokens']}")
|
|
||||||
print(f"Memory usage: {stats['memory_usage'] / 1024 / 1024:.2f} MB")
|
|
||||||
|
|
||||||
# Оптимизация при необходимости
|
|
||||||
if stats['memory_usage'] > 100 * 1024 * 1024: # 100MB
|
|
||||||
results = await monitoring.optimize_memory_usage()
|
|
||||||
print(f"Optimized: {results}")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Поддержка
|
|
||||||
|
|
||||||
Если возникли проблемы при миграции:
|
|
||||||
|
|
||||||
1. **Проверьте логи** - все изменения логируются
|
|
||||||
2. **Запустите health check** - `TokenMonitoring().health_check()`
|
|
||||||
3. **Проверьте Redis** - подключение и память
|
|
||||||
4. **Откатитесь к TokenStorage фасаду** при необходимости
|
|
||||||
|
|
||||||
### Контакты
|
|
||||||
- **Issues**: GitHub Issues
|
|
||||||
- **Документация**: `/docs/auth-system.md`
|
|
||||||
- **Архитектура**: `/docs/auth-architecture.md`
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
# Система авторизации Discours.io
|
|
||||||
|
|
||||||
## Обзор архитектуры
|
|
||||||
|
|
||||||
Система авторизации построена на модульной архитектуре с разделением на независимые компоненты:
|
|
||||||
|
|
||||||
```
|
|
||||||
auth/
|
|
||||||
├── tokens/ # Система управления токенами
|
|
||||||
├── middleware.py # HTTP middleware для аутентификации
|
|
||||||
├── decorators.py # GraphQL декораторы авторизации
|
|
||||||
├── oauth.py # OAuth провайдеры
|
|
||||||
├── orm.py # ORM модели пользователей
|
|
||||||
├── permissions.py # Система разрешений
|
|
||||||
├── identity.py # Методы идентификации
|
|
||||||
├── jwtcodec.py # JWT кодек
|
|
||||||
├── validations.py # Валидация данных
|
|
||||||
├── credentials.py # Работа с креденшалами
|
|
||||||
├── exceptions.py # Исключения авторизации
|
|
||||||
└── handler.py # HTTP обработчики
|
|
||||||
```
|
|
||||||
|
|
||||||
## Система токенов
|
|
||||||
|
|
||||||
### Типы токенов
|
|
||||||
|
|
||||||
| Тип | TTL | Назначение |
|
|
||||||
|-----|-----|------------|
|
|
||||||
| `session` | 30 дней | Токены пользовательских сессий |
|
|
||||||
| `verification` | 1 час | Токены подтверждения (email, телефон) |
|
|
||||||
| `oauth_access` | 1 час | OAuth access токены |
|
|
||||||
| `oauth_refresh` | 30 дней | OAuth refresh токены |
|
|
||||||
|
|
||||||
### Компоненты системы токенов
|
|
||||||
|
|
||||||
#### `SessionTokenManager`
|
|
||||||
Управление сессиями пользователей:
|
|
||||||
- JWT-токены с payload `{user_id, username, iat, exp}`
|
|
||||||
- Redis хранение для отзыва и управления
|
|
||||||
- Поддержка multiple sessions per user
|
|
||||||
- Автоматическое продление при активности
|
|
||||||
|
|
||||||
**Основные методы:**
|
|
||||||
```python
|
|
||||||
async def create_session(user_id: str, auth_data=None, username=None, device_info=None) -> str
|
|
||||||
async def verify_session(token: str) -> Optional[Any]
|
|
||||||
async def refresh_session(user_id: int, old_token: str, device_info=None) -> Optional[str]
|
|
||||||
async def revoke_session_token(token: str) -> bool
|
|
||||||
async def revoke_user_sessions(user_id: str) -> int
|
|
||||||
```
|
|
||||||
|
|
||||||
**Redis структура:**
|
|
||||||
```
|
|
||||||
session:{user_id}:{token} # hash с данными сессии
|
|
||||||
user_sessions:{user_id} # set с активными токенами
|
|
||||||
{user_id}-{username}-{token} # legacy ключи для совместимости
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `VerificationTokenManager`
|
|
||||||
Управление токенами подтверждения:
|
|
||||||
- Email verification
|
|
||||||
- Phone verification
|
|
||||||
- Password reset
|
|
||||||
- Одноразовые токены
|
|
||||||
|
|
||||||
**Основные методы:**
|
|
||||||
```python
|
|
||||||
async def create_verification_token(user_id: str, verification_type: str, data: TokenData, ttl=None) -> str
|
|
||||||
async def validate_verification_token(token: str) -> tuple[bool, Optional[TokenData]]
|
|
||||||
async def confirm_verification_token(token: str) -> Optional[TokenData] # одноразовое использование
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `OAuthTokenManager`
|
|
||||||
Управление OAuth токенами:
|
|
||||||
- Google, GitHub, Facebook, X, Telegram, VK, Yandex
|
|
||||||
- Access/refresh token pairs
|
|
||||||
- Provider-specific storage
|
|
||||||
|
|
||||||
**Redis структура:**
|
|
||||||
```
|
|
||||||
oauth_access:{user_id}:{provider} # access токен
|
|
||||||
oauth_refresh:{user_id}:{provider} # refresh токен
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `BatchTokenOperations`
|
|
||||||
Пакетные операции для производительности:
|
|
||||||
- Массовая валидация токенов
|
|
||||||
- Пакетный отзыв
|
|
||||||
- Очистка истекших токенов
|
|
||||||
|
|
||||||
#### `TokenMonitoring`
|
|
||||||
Мониторинг и статистика:
|
|
||||||
- Подсчет активных токенов по типам
|
|
||||||
- Статистика использования памяти
|
|
||||||
- Health check системы токенов
|
|
||||||
- Оптимизация производительности
|
|
||||||
|
|
||||||
### 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)
|
|
||||||
|
|
||||||
# Deprecated методы (для миграции)
|
|
||||||
await TokenStorage.create_onetime(user) # -> VerificationTokenManager
|
|
||||||
```
|
|
||||||
|
|
||||||
## OAuth система
|
|
||||||
|
|
||||||
### Поддерживаемые провайдеры
|
|
||||||
- **Google** - OpenID Connect
|
|
||||||
- **GitHub** - OAuth 2.0
|
|
||||||
- **Facebook** - Facebook Login
|
|
||||||
- **X (Twitter)** - OAuth 2.0 (без email)
|
|
||||||
- **Telegram** - Telegram Login Widget (без email)
|
|
||||||
- **VK** - VK OAuth (требует разрешений для email)
|
|
||||||
- **Yandex** - Yandex OAuth
|
|
||||||
|
|
||||||
### Процесс OAuth авторизации
|
|
||||||
1. **Инициация**: `GET /oauth/{provider}?state={csrf_token}&redirect_uri={url}`
|
|
||||||
2. **Callback**: `GET /oauth/{provider}/callback?code={code}&state={state}`
|
|
||||||
3. **Обработка**: Получение user profile, создание/обновление пользователя
|
|
||||||
4. **Результат**: JWT токен в cookie + redirect на фронтенд
|
|
||||||
|
|
||||||
### Безопасность OAuth
|
|
||||||
- **PKCE** (Proof Key for Code Exchange) для дополнительной безопасности
|
|
||||||
- **State параметры** хранятся в Redis с TTL 10 минут
|
|
||||||
- **Одноразовые сессии** - после использования удаляются
|
|
||||||
- **Генерация временных email** для провайдеров без email (X, Telegram)
|
|
||||||
|
|
||||||
## Middleware и декораторы
|
|
||||||
|
|
||||||
### AuthMiddleware
|
|
||||||
HTTP middleware для автоматической аутентификации:
|
|
||||||
- Извлечение токенов из cookies/headers
|
|
||||||
- Валидация JWT токенов
|
|
||||||
- Добавление user context в request
|
|
||||||
- Обработка истекших токенов
|
|
||||||
|
|
||||||
### GraphQL декораторы
|
|
||||||
```python
|
|
||||||
@auth_required # Требует авторизации
|
|
||||||
@permission_required # Требует конкретных разрешений
|
|
||||||
@admin_required # Требует admin права
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
|
||||||
created_at: int
|
|
||||||
updated_at: int
|
|
||||||
last_seen: int
|
|
||||||
|
|
||||||
# OAuth связи
|
|
||||||
oauth_accounts: List[OAuthAccount]
|
|
||||||
```
|
|
||||||
|
|
||||||
### OAuthAccount
|
|
||||||
```python
|
|
||||||
class OAuthAccount:
|
|
||||||
id: int
|
|
||||||
author_id: int
|
|
||||||
provider: str # google, github, etc.
|
|
||||||
provider_id: str # ID пользователя у провайдера
|
|
||||||
provider_email: Optional[str]
|
|
||||||
provider_data: dict # Дополнительные данные от провайдера
|
|
||||||
```
|
|
||||||
|
|
||||||
## Система разрешений
|
|
||||||
|
|
||||||
### Роли
|
|
||||||
- **user** - Обычный пользователь
|
|
||||||
- **moderator** - Модератор контента
|
|
||||||
- **admin** - Администратор системы
|
|
||||||
|
|
||||||
### Разрешения
|
|
||||||
- **read** - Чтение контента
|
|
||||||
- **write** - Создание контента
|
|
||||||
- **moderate** - Модерация контента
|
|
||||||
- **admin** - Административные действия
|
|
||||||
|
|
||||||
### Проверка разрешений
|
|
||||||
```python
|
|
||||||
from auth.permissions import check_permission
|
|
||||||
|
|
||||||
@permission_required("moderate")
|
|
||||||
async def moderate_content(info, content_id: str):
|
|
||||||
# Только пользователи с правами модерации
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
## Безопасность
|
|
||||||
|
|
||||||
### Хеширование паролей
|
|
||||||
- **bcrypt** с rounds=10
|
|
||||||
- **SHA256** препроцессинг для длинных паролей
|
|
||||||
- **Salt** автоматически генерируется bcrypt
|
|
||||||
|
|
||||||
### JWT токены
|
|
||||||
- **Алгоритм**: HS256
|
|
||||||
- **Secret**: Из переменной окружения JWT_SECRET
|
|
||||||
- **Payload**: `{user_id, username, iat, exp}`
|
|
||||||
- **Expiration**: 30 дней (настраивается)
|
|
||||||
|
|
||||||
### Redis security
|
|
||||||
- **TTL** для всех токенов
|
|
||||||
- **Атомарные операции** через pipelines
|
|
||||||
- **SCAN** вместо KEYS для производительности
|
|
||||||
- **Транзакции** для критических операций
|
|
||||||
|
|
||||||
## Конфигурация
|
|
||||||
|
|
||||||
### Переменные окружения
|
|
||||||
```bash
|
|
||||||
# JWT
|
|
||||||
JWT_SECRET=your_super_secret_key
|
|
||||||
JWT_EXPIRATION_HOURS=720 # 30 дней
|
|
||||||
|
|
||||||
# Redis
|
|
||||||
REDIS_URL=redis://localhost:6379/0
|
|
||||||
|
|
||||||
# OAuth провайдеры
|
|
||||||
GOOGLE_CLIENT_ID=...
|
|
||||||
GOOGLE_CLIENT_SECRET=...
|
|
||||||
GITHUB_CLIENT_ID=...
|
|
||||||
GITHUB_CLIENT_SECRET=...
|
|
||||||
FACEBOOK_APP_ID=...
|
|
||||||
FACEBOOK_APP_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
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### Аутентификация
|
|
||||||
```
|
|
||||||
POST /auth/login # Email/password вход
|
|
||||||
POST /auth/logout # Выход (отзыв токена)
|
|
||||||
POST /auth/refresh # Обновление токена
|
|
||||||
POST /auth/register # Регистрация
|
|
||||||
```
|
|
||||||
|
|
||||||
### OAuth
|
|
||||||
```
|
|
||||||
GET /oauth/{provider} # Инициация OAuth
|
|
||||||
GET /oauth/{provider}/callback # OAuth callback
|
|
||||||
```
|
|
||||||
|
|
||||||
### Профиль
|
|
||||||
```
|
|
||||||
GET /auth/profile # Текущий пользователь
|
|
||||||
PUT /auth/profile # Обновление профиля
|
|
||||||
POST /auth/change-password # Смена пароля
|
|
||||||
```
|
|
||||||
|
|
||||||
## Мониторинг и логирование
|
|
||||||
|
|
||||||
### Метрики
|
|
||||||
- Количество активных сессий по типам
|
|
||||||
- Использование памяти Redis
|
|
||||||
- Статистика OAuth провайдеров
|
|
||||||
- Health check всех компонентов
|
|
||||||
|
|
||||||
### Логирование
|
|
||||||
- **INFO**: Успешные операции (создание сессий, OAuth)
|
|
||||||
- **WARNING**: Подозрительная активность (неверные пароли)
|
|
||||||
- **ERROR**: Ошибки системы (Redis недоступен, JWT invalid)
|
|
||||||
|
|
||||||
## Производительность
|
|
||||||
|
|
||||||
### Оптимизации Redis
|
|
||||||
- **Pipeline операции** для атомарности
|
|
||||||
- **Batch обработка** токенов (100-1000 за раз)
|
|
||||||
- **SCAN** вместо KEYS для безопасности
|
|
||||||
- **TTL** автоматическая очистка
|
|
||||||
|
|
||||||
### Кэширование
|
|
||||||
- **@lru_cache** для часто используемых ключей
|
|
||||||
- **Connection pooling** для Redis
|
|
||||||
- **JWT decode caching** в middleware
|
|
||||||
|
|
||||||
## Миграция и совместимость
|
|
||||||
|
|
||||||
### Legacy поддержка
|
|
||||||
- Старые ключи Redis: `{user_id}-{username}-{token}`
|
|
||||||
- Автоматическая миграция при обращении
|
|
||||||
- Deprecated методы с предупреждениями
|
|
||||||
|
|
||||||
### Планы развития
|
|
||||||
- [ ] Удаление legacy ключей
|
|
||||||
- [ ] Переход на RS256 для JWT
|
|
||||||
- [ ] WebAuthn/FIDO2 поддержка
|
|
||||||
- [ ] Rate limiting для auth endpoints
|
|
||||||
- [ ] Audit log для всех auth операций
|
|
||||||
|
|
||||||
## Тестирование
|
|
||||||
|
|
||||||
### Unit тесты
|
|
||||||
```bash
|
|
||||||
pytest tests/auth/ # Все auth тесты
|
|
||||||
pytest tests/auth/test_oauth.py # OAuth тесты
|
|
||||||
pytest tests/auth/test_tokens.py # Token тесты
|
|
||||||
```
|
|
||||||
|
|
||||||
### Integration тесты
|
|
||||||
- OAuth flow с моками провайдеров
|
|
||||||
- Redis операции
|
|
||||||
- JWT lifecycle
|
|
||||||
- Permission checks
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Частые проблемы
|
|
||||||
1. **Redis connection failed** - Проверить REDIS_URL и доступность
|
|
||||||
2. **JWT invalid** - Проверить JWT_SECRET и время сервера
|
|
||||||
3. **OAuth failed** - Проверить client_id/secret провайдеров
|
|
||||||
4. **Session not found** - Возможно токен истек или отозван
|
|
||||||
|
|
||||||
### Диагностика
|
|
||||||
```python
|
|
||||||
# Проверка health системы токенов
|
|
||||||
from auth.tokens.monitoring import TokenMonitoring
|
|
||||||
health = await TokenMonitoring().health_check()
|
|
||||||
|
|
||||||
# Статистика токенов
|
|
||||||
stats = await TokenMonitoring().get_token_statistics()
|
|
||||||
```
|
|
||||||
797
docs/auth.md
797
docs/auth.md
@@ -1,797 +0,0 @@
|
|||||||
# Модуль аутентификации и авторизации
|
|
||||||
|
|
||||||
## Общее описание
|
|
||||||
|
|
||||||
Модуль реализует полноценную систему аутентификации с использованием локальной БД и Redis.
|
|
||||||
|
|
||||||
## Компоненты
|
|
||||||
|
|
||||||
### Модели данных
|
|
||||||
|
|
||||||
#### Author (orm.py)
|
|
||||||
- Основная модель пользователя с расширенным функционалом аутентификации
|
|
||||||
- Поддерживает:
|
|
||||||
- Локальную аутентификацию по email/телефону
|
|
||||||
- Систему ролей и разрешений (RBAC)
|
|
||||||
- Блокировку аккаунта при множественных неудачных попытках входа
|
|
||||||
- Верификацию email/телефона
|
|
||||||
|
|
||||||
#### Role и Permission (resolvers/rbac.py)
|
|
||||||
- Реализация RBAC (Role-Based Access Control)
|
|
||||||
- Роли содержат наборы разрешений
|
|
||||||
- Разрешения определяются как пары resource:operation
|
|
||||||
|
|
||||||
### Аутентификация
|
|
||||||
|
|
||||||
#### Внутренняя аутентификация
|
|
||||||
- Проверка токена в Redis
|
|
||||||
- Получение данных пользователя из локальной БД
|
|
||||||
- Проверка статуса аккаунта и разрешений
|
|
||||||
|
|
||||||
### Управление сессиями (sessions.py)
|
|
||||||
|
|
||||||
- Хранение сессий в Redis
|
|
||||||
- Поддержка:
|
|
||||||
- Создание сессий
|
|
||||||
- Верификация
|
|
||||||
- Отзыв отдельных сессий
|
|
||||||
- Отзыв всех сессий пользователя
|
|
||||||
- Автоматическое удаление истекших сессий
|
|
||||||
|
|
||||||
### JWT токены (jwtcodec.py)
|
|
||||||
|
|
||||||
- Кодирование/декодирование JWT токенов
|
|
||||||
- Проверка:
|
|
||||||
- Срока действия
|
|
||||||
- Подписи
|
|
||||||
- Издателя
|
|
||||||
- Поддержка пользовательских claims
|
|
||||||
|
|
||||||
### OAuth интеграция (oauth.py)
|
|
||||||
|
|
||||||
Поддерживаемые провайдеры:
|
|
||||||
- Google
|
|
||||||
- Facebook
|
|
||||||
- GitHub
|
|
||||||
|
|
||||||
Функционал:
|
|
||||||
- Авторизация через OAuth провайдеров
|
|
||||||
- Получение профиля пользователя
|
|
||||||
- Создание/обновление локального профиля
|
|
||||||
|
|
||||||
### Валидация (validations.py)
|
|
||||||
|
|
||||||
Модели валидации для:
|
|
||||||
- Регистрации пользователей
|
|
||||||
- Входа в систему
|
|
||||||
- OAuth данных
|
|
||||||
- JWT payload
|
|
||||||
- Ответов API
|
|
||||||
|
|
||||||
### Email функционал (email.py)
|
|
||||||
|
|
||||||
- Отправка писем через Mailgun
|
|
||||||
- Поддержка шаблонов
|
|
||||||
- Мультиязычность (ru/en)
|
|
||||||
- Подтверждение email
|
|
||||||
- Сброс пароля
|
|
||||||
|
|
||||||
## API Endpoints (resolvers.py)
|
|
||||||
|
|
||||||
### Мутации
|
|
||||||
- `login` - вход в систему
|
|
||||||
- `getSession` - получение текущей сессии
|
|
||||||
- `confirmEmail` - подтверждение email
|
|
||||||
- `registerUser` - регистрация пользователя
|
|
||||||
- `sendLink` - отправка ссылки для входа
|
|
||||||
|
|
||||||
### Запросы
|
|
||||||
- `logout` - выход из системы
|
|
||||||
- `isEmailUsed` - проверка использования email
|
|
||||||
|
|
||||||
## Безопасность
|
|
||||||
|
|
||||||
### Хеширование паролей (identity.py)
|
|
||||||
- Использование bcrypt с SHA-256
|
|
||||||
- Настраиваемое количество раундов
|
|
||||||
- Защита от timing-атак
|
|
||||||
|
|
||||||
### Защита от брутфорса
|
|
||||||
- Блокировка аккаунта после 5 неудачных попыток
|
|
||||||
- Время блокировки: 30 минут
|
|
||||||
- Сброс счетчика после успешного входа
|
|
||||||
|
|
||||||
## Обработка заголовков авторизации
|
|
||||||
|
|
||||||
### Особенности работы с заголовками в Starlette
|
|
||||||
|
|
||||||
При работе с заголовками в Starlette/FastAPI необходимо учитывать следующие особенности:
|
|
||||||
|
|
||||||
1. **Регистр заголовков**: Заголовки в объекте `Request` чувствительны к регистру. Для надежного получения заголовка `Authorization` следует использовать регистронезависимый поиск.
|
|
||||||
|
|
||||||
2. **Формат Bearer токена**: Токен может приходить как с префиксом `Bearer `, так и без него. Необходимо обрабатывать оба варианта.
|
|
||||||
|
|
||||||
### Правильное получение заголовка авторизации
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Получение заголовка с учетом регистра
|
|
||||||
headers_dict = dict(req.headers.items())
|
|
||||||
token = None
|
|
||||||
|
|
||||||
# Ищем заголовок независимо от регистра
|
|
||||||
for header_name, header_value in headers_dict.items():
|
|
||||||
if header_name.lower() == SESSION_TOKEN_HEADER.lower():
|
|
||||||
token = header_value
|
|
||||||
break
|
|
||||||
|
|
||||||
# Обработка Bearer префикса
|
|
||||||
if token and token.startswith("Bearer "):
|
|
||||||
token = token.split("Bearer ")[1].strip()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Распространенные проблемы и их решения
|
|
||||||
|
|
||||||
1. **Проблема**: Заголовок не находится при прямом обращении `req.headers.get("Authorization")`
|
|
||||||
**Решение**: Использовать регистронезависимый поиск по всем заголовкам
|
|
||||||
|
|
||||||
2. **Проблема**: Токен приходит с префиксом "Bearer" в одних запросах и без него в других
|
|
||||||
**Решение**: Всегда проверять и обрабатывать оба варианта
|
|
||||||
|
|
||||||
3. **Проблема**: Токен декодируется, но сессия не находится в Redis
|
|
||||||
**Решение**: Проверить формирование ключа сессии и добавить автоматическое создание сессии для валидных токенов
|
|
||||||
|
|
||||||
4. **Проблема**: Ошибки при декодировании JWT вызывают исключения
|
|
||||||
**Решение**: Обернуть декодирование в try-except и возвращать None вместо вызова исключений
|
|
||||||
|
|
||||||
## Конфигурация
|
|
||||||
|
|
||||||
Основные настройки в settings.py:
|
|
||||||
- `SESSION_TOKEN_LIFE_SPAN` - время жизни сессии
|
|
||||||
- `ONETIME_TOKEN_LIFE_SPAN` - время жизни одноразовых токенов
|
|
||||||
- `JWT_SECRET_KEY` - секретный ключ для JWT
|
|
||||||
- `JWT_ALGORITHM` - алгоритм подписи JWT
|
|
||||||
|
|
||||||
## Примеры использования
|
|
||||||
|
|
||||||
### Аутентификация
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Проверка авторизации
|
|
||||||
user_id, roles = await check_auth(request)
|
|
||||||
|
|
||||||
# Добавление роли
|
|
||||||
await add_user_role(user_id, ["author"])
|
|
||||||
|
|
||||||
# Создание сессии
|
|
||||||
token = await create_local_session(author)
|
|
||||||
```
|
|
||||||
|
|
||||||
### OAuth авторизация
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Инициация OAuth процесса
|
|
||||||
await oauth_login(request)
|
|
||||||
|
|
||||||
# Обработка callback
|
|
||||||
response = await oauth_authorize(request)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1. Базовая авторизация на фронтенде
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// pages/Login.tsx
|
|
||||||
// Предполагается, что AuthClient и createAuth импортированы корректно
|
|
||||||
// import { AuthClient } from '../auth/AuthClient'; // Путь может отличаться
|
|
||||||
// import { createAuth } from '../auth/useAuth'; // Путь может отличаться
|
|
||||||
import { Component, Show } from 'solid-js'; // Show для условного рендеринга
|
|
||||||
|
|
||||||
export const LoginPage: Component = () => {
|
|
||||||
// Клиент и хук авторизации (пример из client/auth/useAuth.ts)
|
|
||||||
// const authClient = new AuthClient(/* baseUrl or other config */);
|
|
||||||
// const auth = createAuth(authClient);
|
|
||||||
// Для простоты примера, предположим, что auth уже доступен через контекст или пропсы
|
|
||||||
// В реальном приложении используйте useAuthContext() если он настроен
|
|
||||||
const { store, login } = useAuthContext(); // Пример, если используется контекст
|
|
||||||
|
|
||||||
const handleSubmit = async (event: SubmitEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const form = event.currentTarget as HTMLFormElement;
|
|
||||||
const emailInput = form.elements.namedItem('email') as HTMLInputElement;
|
|
||||||
const passwordInput = form.elements.namedItem('password') as HTMLInputElement;
|
|
||||||
|
|
||||||
if (!emailInput || !passwordInput) {
|
|
||||||
console.error("Email or password input not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await login({
|
|
||||||
email: emailInput.value,
|
|
||||||
password: passwordInput.value
|
|
||||||
});
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
console.log('Login successful, redirecting...');
|
|
||||||
// window.location.href = '/'; // Раскомментируйте для реального редиректа
|
|
||||||
} else {
|
|
||||||
// Ошибка уже должна быть в store().error, обработанная в useAuth
|
|
||||||
console.error('Login failed:', store().error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div>
|
|
||||||
<label for="email">Email:</label>
|
|
||||||
<input id="email" name="email" type="email" required />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="password">Пароль:</label>
|
|
||||||
<input id="password" name="password" type="password" required />
|
|
||||||
</div>
|
|
||||||
<button type="submit" disabled={store().isLoading}>
|
|
||||||
{store().isLoading ? 'Вход...' : 'Войти'}
|
|
||||||
</button>
|
|
||||||
<Show when={store().error}>
|
|
||||||
<p style={{ color: 'red' }}>{store().error}</p>
|
|
||||||
</Show>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Защита компонента с помощью ролей
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// components/AdminPanel.tsx
|
|
||||||
import { useAuthContext } from '../auth'
|
|
||||||
|
|
||||||
export const AdminPanel: Component = () => {
|
|
||||||
const auth = useAuthContext()
|
|
||||||
|
|
||||||
// Проверяем наличие роли админа
|
|
||||||
if (!auth.hasRole('admin')) {
|
|
||||||
return <div>Доступ запрещен</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Контент админки */}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. OAuth авторизация через Google
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// components/GoogleLoginButton.tsx
|
|
||||||
import { Component } from 'solid-js';
|
|
||||||
|
|
||||||
export const GoogleLoginButton: Component = () => {
|
|
||||||
const handleGoogleLogin = () => {
|
|
||||||
// Предполагается, что API_BASE_URL настроен глобально или импортирован
|
|
||||||
// const API_BASE_URL = 'http://localhost:8000'; // Пример
|
|
||||||
// window.location.href = `${API_BASE_URL}/auth/login/google`;
|
|
||||||
// Или если пути относительные и сервер на том же домене:
|
|
||||||
window.location.href = '/auth/login/google';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button onClick={handleGoogleLogin}>
|
|
||||||
Войти через Google
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Работа с пользователем на бэкенде
|
|
||||||
|
|
||||||
```python
|
|
||||||
# routes/articles.py
|
|
||||||
# Предполагаемые импорты:
|
|
||||||
# from starlette.requests import Request
|
|
||||||
# from starlette.responses import JSONResponse
|
|
||||||
# from sqlalchemy.orm import Session
|
|
||||||
# from ..dependencies import get_db_session # Пример получения сессии БД
|
|
||||||
# from ..auth.decorators import login_required # Ваш декоратор
|
|
||||||
# from ..auth.orm import Author # Модель пользователя
|
|
||||||
# from ..models.article import Article # Модель статьи (пример)
|
|
||||||
|
|
||||||
# @login_required # Декоратор проверяет аутентификацию и добавляет user в request
|
|
||||||
async def create_article_example(request: Request): # Используем Request из Starlette
|
|
||||||
"""
|
|
||||||
Пример создания статьи с проверкой прав.
|
|
||||||
В реальном приложении используйте DI для сессии БД (например, FastAPI Depends).
|
|
||||||
"""
|
|
||||||
user: Author = request.user # request.user добавляется декоратором @login_required
|
|
||||||
|
|
||||||
# Проверяем право на создание статей (метод из модели auth.auth.orm)
|
|
||||||
if not await user.has_permission('shout:create'):
|
|
||||||
return JSONResponse({'error': 'Недостаточно прав для создания статьи'}, status_code=403)
|
|
||||||
|
|
||||||
try:
|
|
||||||
article_data = await request.json()
|
|
||||||
title = article_data.get('title')
|
|
||||||
content = article_data.get('content')
|
|
||||||
|
|
||||||
if not title or not content:
|
|
||||||
return JSONResponse({'error': 'Title and content are required'}, status_code=400)
|
|
||||||
|
|
||||||
except ValueError: # Если JSON некорректен
|
|
||||||
return JSONResponse({'error': 'Invalid JSON data'}, status_code=400)
|
|
||||||
|
|
||||||
# Пример работы с БД. В реальном приложении сессия db будет получена через DI.
|
|
||||||
# Здесь db - это заглушка, замените на вашу реальную логику работы с БД.
|
|
||||||
# Пример:
|
|
||||||
# with get_db_session() as db: # Получение сессии SQLAlchemy
|
|
||||||
# new_article = Article(
|
|
||||||
# title=title,
|
|
||||||
# content=content,
|
|
||||||
# author_id=user.id # Связываем статью с автором
|
|
||||||
# )
|
|
||||||
# db.add(new_article)
|
|
||||||
# db.commit()
|
|
||||||
# db.refresh(new_article)
|
|
||||||
# return JSONResponse({'id': new_article.id, 'title': new_article.title}, status_code=201)
|
|
||||||
|
|
||||||
# Заглушка для примера в документации
|
|
||||||
mock_article_id = 123
|
|
||||||
print(f"User {user.id} ({user.email}) is creating article '{title}'.")
|
|
||||||
return JSONResponse({'id': mock_article_id, 'title': title}, status_code=201)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Проверка прав в GraphQL резолверах
|
|
||||||
|
|
||||||
```python
|
|
||||||
# resolvers/mutations.py
|
|
||||||
from auth.decorators import login_required
|
|
||||||
from auth.models import Author
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
async def update_article(_: None,info, article_id: int, data: dict):
|
|
||||||
"""
|
|
||||||
Обновление статьи с проверкой прав
|
|
||||||
"""
|
|
||||||
user: Author = info.context.user
|
|
||||||
|
|
||||||
# Получаем статью
|
|
||||||
article = db.query(Article).get(article_id)
|
|
||||||
if not article:
|
|
||||||
raise GraphQLError('Статья не найдена')
|
|
||||||
|
|
||||||
# Проверяем права на редактирование
|
|
||||||
if not await user.has_permission('articles', 'edit'):
|
|
||||||
raise GraphQLError('Недостаточно прав')
|
|
||||||
|
|
||||||
# Обновляем поля
|
|
||||||
article.title = data.get('title', article.title)
|
|
||||||
article.content = data.get('content', article.content)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
return article
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Создание пользователя с ролями
|
|
||||||
|
|
||||||
```python
|
|
||||||
# scripts/create_admin.py
|
|
||||||
from auth.models import Author, Role
|
|
||||||
from auth.password import hash_password
|
|
||||||
|
|
||||||
def create_admin(email: str, password: str):
|
|
||||||
"""Создание администратора"""
|
|
||||||
|
|
||||||
# Получаем роль админа
|
|
||||||
admin_role = db.query(Role).filter(Role.id == 'admin').first()
|
|
||||||
|
|
||||||
# Создаем пользователя
|
|
||||||
admin = Author(
|
|
||||||
email=email,
|
|
||||||
password=hash_password(password),
|
|
||||||
email_verified=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Назначаем роль
|
|
||||||
admin.roles.append(admin_role)
|
|
||||||
|
|
||||||
# Сохраняем
|
|
||||||
db.add(admin)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return admin
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Работа с сессиями
|
|
||||||
|
|
||||||
```python
|
|
||||||
# auth/session_management.py (примерное название файла)
|
|
||||||
# Предполагаемые импорты:
|
|
||||||
# from starlette.responses import RedirectResponse
|
|
||||||
# from starlette.requests import Request
|
|
||||||
# from ..auth.orm import Author # Модель пользователя
|
|
||||||
# from ..auth.token import TokenStorage # Ваш модуль для работы с токенами
|
|
||||||
# from ..settings import SESSION_COOKIE_MAX_AGE, SESSION_COOKIE_NAME, SESSION_COOKIE_SECURE, SESSION_COOKIE_HTTPONLY, SESSION_COOKIE_SAMESITE
|
|
||||||
|
|
||||||
# Замените FRONTEND_URL_AUTH_SUCCESS и FRONTEND_URL_LOGOUT на реальные URL из настроек
|
|
||||||
FRONTEND_URL_AUTH_SUCCESS = "/auth/success" # Пример
|
|
||||||
FRONTEND_URL_LOGOUT = "/logout" # Пример
|
|
||||||
|
|
||||||
|
|
||||||
async def login_user_session(request: Request, user: Author, response_class=RedirectResponse):
|
|
||||||
"""
|
|
||||||
Создание сессии пользователя и установка cookie.
|
|
||||||
"""
|
|
||||||
if not hasattr(user, 'id'): # Проверка наличия id у пользователя
|
|
||||||
raise ValueError("User object must have an id attribute")
|
|
||||||
|
|
||||||
# Создаем токен сессии (TokenStorage из вашего модуля auth.token)
|
|
||||||
session_token = TokenStorage.create_session(str(user.id)) # ID пользователя обычно число, приводим к строке если нужно
|
|
||||||
|
|
||||||
# Устанавливаем cookie
|
|
||||||
# В реальном приложении FRONTEND_URL_AUTH_SUCCESS должен вести на страницу вашего фронтенда
|
|
||||||
response = response_class(url=FRONTEND_URL_AUTH_SUCCESS)
|
|
||||||
response.set_cookie(
|
|
||||||
key=SESSION_COOKIE_NAME, # 'session_token' из settings.py
|
|
||||||
value=session_token,
|
|
||||||
httponly=SESSION_COOKIE_HTTPONLY, # True из settings.py
|
|
||||||
secure=SESSION_COOKIE_SECURE, # True для HTTPS из settings.py
|
|
||||||
samesite=SESSION_COOKIE_SAMESITE, # 'lax' из settings.py
|
|
||||||
max_age=SESSION_COOKIE_MAX_AGE # 30 дней в секундах из settings.py
|
|
||||||
)
|
|
||||||
print(f"Session created for user {user.id}. Token: {session_token[:10]}...") # Логируем для отладки
|
|
||||||
return response
|
|
||||||
|
|
||||||
async def logout_user_session(request: Request, response_class=RedirectResponse):
|
|
||||||
"""
|
|
||||||
Завершение сессии пользователя и удаление cookie.
|
|
||||||
"""
|
|
||||||
session_token = request.cookies.get(SESSION_COOKIE_NAME)
|
|
||||||
|
|
||||||
if session_token:
|
|
||||||
# Удаляем токен из хранилища (TokenStorage из вашего модуля auth.token)
|
|
||||||
TokenStorage.delete_session(session_token)
|
|
||||||
print(f"Session token {session_token[:10]}... deleted from storage.")
|
|
||||||
|
|
||||||
# Удаляем cookie
|
|
||||||
# В реальном приложении FRONTEND_URL_LOGOUT должен вести на страницу вашего фронтенда
|
|
||||||
response = response_class(url=FRONTEND_URL_LOGOUT)
|
|
||||||
response.delete_cookie(SESSION_COOKIE_NAME)
|
|
||||||
print(f"Cookie {SESSION_COOKIE_NAME} deleted.")
|
|
||||||
return response
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. Проверка CSRF в формах
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// components/ProfileForm.tsx
|
|
||||||
// import { useAuthContext } from '../auth'; // Предполагаем, что auth есть в контексте
|
|
||||||
import { Component, createSignal, Show } from 'solid-js';
|
|
||||||
|
|
||||||
export const ProfileForm: Component = () => {
|
|
||||||
const { store, checkAuth } = useAuthContext(); // Пример получения из контекста
|
|
||||||
const [message, setMessage] = createSignal<string | null>(null);
|
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
|
||||||
|
|
||||||
const handleSubmit = async (event: SubmitEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setMessage(null);
|
|
||||||
setError(null);
|
|
||||||
const form = event.currentTarget as HTMLFormElement;
|
|
||||||
const formData = new FormData(form);
|
|
||||||
|
|
||||||
// ВАЖНО: Получение CSRF-токена из cookie - это один из способов.
|
|
||||||
// Если CSRF-токен устанавливается как httpOnly cookie, то он будет автоматически
|
|
||||||
// отправляться браузером, и его не нужно доставать вручную для fetch,
|
|
||||||
// если сервер настроен на его проверку из заголовка (например, X-CSRF-Token),
|
|
||||||
// который fetch *не* устанавливает автоматически для httpOnly cookie.
|
|
||||||
// Либо сервер может предоставлять CSRF-токен через специальный эндпоинт.
|
|
||||||
// Представленный ниже способ подходит, если CSRF-токен доступен для JS.
|
|
||||||
const csrfToken = document.cookie
|
|
||||||
.split('; ')
|
|
||||||
.find(row => row.startsWith('csrf_token=')) // Имя cookie может отличаться
|
|
||||||
?.split('=')[1];
|
|
||||||
|
|
||||||
if (!csrfToken) {
|
|
||||||
// setError('CSRF token not found. Please refresh the page.');
|
|
||||||
// В продакшене CSRF-токен должен быть всегда. Этот лог для отладки.
|
|
||||||
console.warn('CSRF token not found in cookies. Ensure it is set by the server.');
|
|
||||||
// Для данного примера, если токен не найден, можно либо прервать, либо положиться на серверную проверку.
|
|
||||||
// Для большей безопасности, прерываем, если CSRF-защита критична на клиенте.
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Замените '/api/profile' на ваш реальный эндпоинт
|
|
||||||
const response = await fetch('/api/profile', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
// Сервер должен быть настроен на чтение этого заголовка
|
|
||||||
// если CSRF токен не отправляется автоматически с httpOnly cookie.
|
|
||||||
...(csrfToken && { 'X-CSRF-Token': csrfToken }),
|
|
||||||
// 'Content-Type': 'application/json' // Если отправляете JSON
|
|
||||||
},
|
|
||||||
body: formData // FormData отправится как 'multipart/form-data'
|
|
||||||
// Если нужно JSON: body: JSON.stringify(Object.fromEntries(formData))
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const result = await response.json();
|
|
||||||
setMessage(result.message || 'Профиль успешно обновлен!');
|
|
||||||
checkAuth(); // Обновить данные пользователя в сторе
|
|
||||||
} else {
|
|
||||||
const errData = await response.json();
|
|
||||||
setError(errData.error || `Ошибка: ${response.status}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Profile update error:', err);
|
|
||||||
setError('Не удалось обновить профиль. Попробуйте позже.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div>
|
|
||||||
<label for="name">Имя:</label>
|
|
||||||
<input id="name" name="name" defaultValue={store().user?.name || ''} />
|
|
||||||
</div>
|
|
||||||
{/* Другие поля профиля */}
|
|
||||||
<button type="submit">Сохранить изменения</button>
|
|
||||||
<Show when={message()}>
|
|
||||||
<p style={{ color: 'green' }}>{message()}</p>
|
|
||||||
</Show>
|
|
||||||
<Show when={error()}>
|
|
||||||
<p style={{ color: 'red' }}>{error()}</p>
|
|
||||||
</Show>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9. Кастомные валидаторы для форм
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// validators/auth.ts
|
|
||||||
export const validatePassword = (password: string): string[] => {
|
|
||||||
const errors: string[] = []
|
|
||||||
|
|
||||||
if (password.length < 8) {
|
|
||||||
errors.push('Пароль должен быть не менее 8 символов')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/[A-Z]/.test(password)) {
|
|
||||||
errors.push('Пароль должен содержать заглавную букву')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/[0-9]/.test(password)) {
|
|
||||||
errors.push('Пароль должен содержать цифру')
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// components/RegisterForm.tsx
|
|
||||||
import { validatePassword } from '../validators/auth'
|
|
||||||
|
|
||||||
export const RegisterForm: Component = () => {
|
|
||||||
const [errors, setErrors] = createSignal<string[]>([])
|
|
||||||
|
|
||||||
const handleSubmit = async (e: Event) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const form = e.target as HTMLFormElement
|
|
||||||
const data = new FormData(form)
|
|
||||||
|
|
||||||
// Валидация пароля
|
|
||||||
const password = data.get('password') as string
|
|
||||||
const passwordErrors = validatePassword(password)
|
|
||||||
|
|
||||||
if (passwordErrors.length > 0) {
|
|
||||||
setErrors(passwordErrors)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отправка формы...
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<input name="password" type="password" />
|
|
||||||
{errors().map(error => (
|
|
||||||
<div class="error">{error}</div>
|
|
||||||
))}
|
|
||||||
<button type="submit">Регистрация</button>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10. Интеграция с внешними сервисами
|
|
||||||
|
|
||||||
```python
|
|
||||||
# services/notifications.py
|
|
||||||
from auth.models import Author
|
|
||||||
|
|
||||||
async def notify_login(user: Author, ip: str, device: str):
|
|
||||||
"""Отправка уведомления о новом входе"""
|
|
||||||
|
|
||||||
# Формируем текст
|
|
||||||
text = f"""
|
|
||||||
Новый вход в аккаунт:
|
|
||||||
IP: {ip}
|
|
||||||
Устройство: {device}
|
|
||||||
Время: {datetime.now()}
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Отправляем email
|
|
||||||
await send_email(
|
|
||||||
to=user.email,
|
|
||||||
subject='Новый вход в аккаунт',
|
|
||||||
text=text
|
|
||||||
)
|
|
||||||
|
|
||||||
# Логируем
|
|
||||||
logger.info(f'New login for user {user.id} from {ip}')
|
|
||||||
```
|
|
||||||
|
|
||||||
## Тестирование
|
|
||||||
|
|
||||||
### 1. Тест OAuth авторизации
|
|
||||||
|
|
||||||
```python
|
|
||||||
# tests/test_oauth.py
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_google_oauth_success(client, mock_google):
|
|
||||||
# Мокаем ответ от Google
|
|
||||||
mock_google.return_value = {
|
|
||||||
'id': '123',
|
|
||||||
'email': 'test@gmail.com',
|
|
||||||
'name': 'Test User'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Запрос на авторизацию
|
|
||||||
response = await client.get('/auth/login/google')
|
|
||||||
assert response.status_code == 302
|
|
||||||
|
|
||||||
# Проверяем редирект
|
|
||||||
assert 'accounts.google.com' in response.headers['location']
|
|
||||||
|
|
||||||
# Проверяем сессию
|
|
||||||
assert 'state' in client.session
|
|
||||||
assert 'code_verifier' in client.session
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Тест ролей и разрешений
|
|
||||||
|
|
||||||
```python
|
|
||||||
# tests/test_permissions.py
|
|
||||||
def test_user_permissions():
|
|
||||||
# Создаем тестовые данные
|
|
||||||
role = Role(id='editor', name='Editor')
|
|
||||||
permission = Permission(
|
|
||||||
id='articles:edit',
|
|
||||||
resource='articles',
|
|
||||||
operation='edit'
|
|
||||||
)
|
|
||||||
role.permissions.append(permission)
|
|
||||||
|
|
||||||
user = Author(email='test@test.com')
|
|
||||||
user.roles.append(role)
|
|
||||||
|
|
||||||
# Проверяем разрешения
|
|
||||||
assert await user.has_permission('articles', 'edit')
|
|
||||||
assert not await user.has_permission('articles', 'delete')
|
|
||||||
```
|
|
||||||
|
|
||||||
## Безопасность
|
|
||||||
|
|
||||||
### 1. Rate Limiting
|
|
||||||
|
|
||||||
```python
|
|
||||||
# middleware/rate_limit.py
|
|
||||||
from starlette.middleware import Middleware
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
|
||||||
from redis import Redis
|
|
||||||
|
|
||||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
||||||
async def dispatch(self, request, call_next):
|
|
||||||
# Получаем IP
|
|
||||||
ip = request.client.host
|
|
||||||
|
|
||||||
# Проверяем лимиты в Redis
|
|
||||||
redis = Redis()
|
|
||||||
key = f'rate_limit:{ip}'
|
|
||||||
|
|
||||||
# Увеличиваем счетчик
|
|
||||||
count = redis.incr(key)
|
|
||||||
if count == 1:
|
|
||||||
redis.expire(key, 60) # TTL 60 секунд
|
|
||||||
|
|
||||||
# Проверяем лимит
|
|
||||||
if count > 100: # 100 запросов в минуту
|
|
||||||
return JSONResponse(
|
|
||||||
{'error': 'Too many requests'},
|
|
||||||
status_code=429
|
|
||||||
)
|
|
||||||
|
|
||||||
return await call_next(request)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Защита от брутфорса
|
|
||||||
|
|
||||||
```python
|
|
||||||
# auth/login.py
|
|
||||||
async def handle_login_attempt(user: Author, success: bool):
|
|
||||||
"""Обработка попытки входа"""
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
# Увеличиваем счетчик неудачных попыток
|
|
||||||
user.increment_failed_login()
|
|
||||||
|
|
||||||
if user.is_locked():
|
|
||||||
# Аккаунт заблокирован
|
|
||||||
raise AuthError(
|
|
||||||
'Account is locked. Try again later.',
|
|
||||||
'ACCOUNT_LOCKED'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Сбрасываем счетчик при успешном входе
|
|
||||||
user.reset_failed_login()
|
|
||||||
```
|
|
||||||
|
|
||||||
## Мониторинг
|
|
||||||
|
|
||||||
### 1. Логирование событий авторизации
|
|
||||||
|
|
||||||
```python
|
|
||||||
# auth/logging.py
|
|
||||||
import structlog
|
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
|
||||||
|
|
||||||
def log_auth_event(
|
|
||||||
event_type: str,
|
|
||||||
user_id: int = None,
|
|
||||||
success: bool = True,
|
|
||||||
**kwargs
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Логирование событий авторизации
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event_type: Тип события (login, logout, etc)
|
|
||||||
user_id: ID пользователя
|
|
||||||
success: Успешность операции
|
|
||||||
**kwargs: Дополнительные поля
|
|
||||||
"""
|
|
||||||
logger.info(
|
|
||||||
'auth_event',
|
|
||||||
event_type=event_type,
|
|
||||||
user_id=user_id,
|
|
||||||
success=success,
|
|
||||||
**kwargs
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Метрики для Prometheus
|
|
||||||
|
|
||||||
```python
|
|
||||||
# metrics/auth.py
|
|
||||||
from prometheus_client import Counter, Histogram
|
|
||||||
|
|
||||||
# Счетчики
|
|
||||||
login_attempts = Counter(
|
|
||||||
'auth_login_attempts_total',
|
|
||||||
'Number of login attempts',
|
|
||||||
['success']
|
|
||||||
)
|
|
||||||
|
|
||||||
oauth_logins = Counter(
|
|
||||||
'auth_oauth_logins_total',
|
|
||||||
'Number of OAuth logins',
|
|
||||||
['provider']
|
|
||||||
)
|
|
||||||
|
|
||||||
# Гистограммы
|
|
||||||
login_duration = Histogram(
|
|
||||||
'auth_login_duration_seconds',
|
|
||||||
'Time spent processing login'
|
|
||||||
)
|
|
||||||
```
|
|
||||||
408
docs/caching.md
408
docs/caching.md
@@ -1,408 +0,0 @@
|
|||||||
# Система кеширования Discours
|
|
||||||
|
|
||||||
## Общее описание
|
|
||||||
|
|
||||||
Система кеширования Discours - это комплексное решение для повышения производительности платформы. Она использует Redis для хранения часто запрашиваемых данных и уменьшения нагрузки на основную базу данных.
|
|
||||||
|
|
||||||
Кеширование реализовано как многоуровневая система, состоящая из нескольких модулей:
|
|
||||||
|
|
||||||
- `cache.py` - основной модуль с функциями кеширования
|
|
||||||
- `revalidator.py` - асинхронный менеджер ревалидации кеша
|
|
||||||
- `triggers.py` - триггеры событий SQLAlchemy для автоматической ревалидации
|
|
||||||
- `precache.py` - предварительное кеширование данных при старте приложения
|
|
||||||
|
|
||||||
## Ключевые компоненты
|
|
||||||
|
|
||||||
### 1. Форматы ключей кеша
|
|
||||||
|
|
||||||
Система поддерживает несколько форматов ключей для обеспечения совместимости и удобства использования:
|
|
||||||
|
|
||||||
- **Ключи сущностей**: `entity:property:value` (например, `author:id:123`)
|
|
||||||
- **Ключи коллекций**: `entity:collection:params` (например, `authors:stats:limit=10:offset=0`)
|
|
||||||
- **Специальные ключи**: для обратной совместимости (например, `topic_shouts_123`)
|
|
||||||
|
|
||||||
Все стандартные форматы ключей хранятся в словаре `CACHE_KEYS`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
CACHE_KEYS = {
|
|
||||||
"TOPIC_ID": "topic:id:{}",
|
|
||||||
"TOPIC_SLUG": "topic:slug:{}",
|
|
||||||
"AUTHOR_ID": "author:id:{}",
|
|
||||||
# и другие...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Основные функции кеширования
|
|
||||||
|
|
||||||
#### Структура ключей
|
|
||||||
|
|
||||||
Вместо генерации ключей через вспомогательные функции, система следует строгим конвенциям формирования ключей:
|
|
||||||
|
|
||||||
1. **Ключи для отдельных сущностей** строятся по шаблону:
|
|
||||||
```
|
|
||||||
entity:property:value
|
|
||||||
```
|
|
||||||
Например:
|
|
||||||
- `topic:id:123` - тема с ID 123
|
|
||||||
- `author:slug:john-doe` - автор со слагом "john-doe"
|
|
||||||
- `shout:id:456` - публикация с ID 456
|
|
||||||
|
|
||||||
2. **Ключи для коллекций** строятся по шаблону:
|
|
||||||
```
|
|
||||||
entity:collection[:filter1=value1:filter2=value2:...]
|
|
||||||
```
|
|
||||||
Например:
|
|
||||||
- `topics:all:basic` - базовый список всех тем
|
|
||||||
- `authors:stats:limit=10:offset=0:sort=name` - отсортированный список авторов с пагинацией
|
|
||||||
- `shouts:feed:limit=20:community=1` - лента публикаций с фильтром по сообществу
|
|
||||||
|
|
||||||
3. **Специальные форматы ключей** для обратной совместимости:
|
|
||||||
```
|
|
||||||
entity_action_id
|
|
||||||
```
|
|
||||||
Например:
|
|
||||||
- `topic_shouts_123` - публикации для темы с ID 123
|
|
||||||
|
|
||||||
Во всех модулях системы разработчики должны явно формировать ключи в соответствии с этими конвенциями, что обеспечивает единообразие и предсказуемость кеширования.
|
|
||||||
|
|
||||||
#### Работа с данными в кеше
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def cache_data(key, data, ttl=None)
|
|
||||||
async def get_cached_data(key)
|
|
||||||
```
|
|
||||||
|
|
||||||
Эти функции предоставляют универсальный интерфейс для сохранения и получения данных из кеша. Они напрямую используют Redis через вызовы `redis.execute()`.
|
|
||||||
|
|
||||||
#### Высокоуровневое кеширование запросов
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def cached_query(cache_key, query_func, ttl=None, force_refresh=False, **query_params)
|
|
||||||
```
|
|
||||||
|
|
||||||
Функция `cached_query` объединяет получение данных из кеша и выполнение запроса в случае отсутствия данных в кеше. Это основная функция, которую следует использовать в резолверах для кеширования результатов запросов.
|
|
||||||
|
|
||||||
### 3. Кеширование сущностей
|
|
||||||
|
|
||||||
Для основных типов сущностей реализованы специальные функции:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def cache_topic(topic: dict)
|
|
||||||
async def cache_author(author: dict)
|
|
||||||
async def get_cached_topic(topic_id: int)
|
|
||||||
async def get_cached_author(author_id: int, get_with_stat)
|
|
||||||
```
|
|
||||||
|
|
||||||
Эти функции упрощают работу с часто используемыми типами данных и обеспечивают единообразный подход к их кешированию.
|
|
||||||
|
|
||||||
### 4. Работа со связями
|
|
||||||
|
|
||||||
Для работы со связями между сущностями предназначены функции:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def cache_follows(follower_id, entity_type, entity_id, is_insert=True)
|
|
||||||
async def get_cached_topic_followers(topic_id)
|
|
||||||
async def get_cached_author_followers(author_id)
|
|
||||||
async def get_cached_follower_topics(author_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
Они позволяют эффективно кешировать и получать информацию о подписках, связях между авторами, темами и публикациями.
|
|
||||||
|
|
||||||
## Система инвалидации кеша
|
|
||||||
|
|
||||||
### 1. Прямая инвалидация
|
|
||||||
|
|
||||||
Система поддерживает два типа инвалидации кеша:
|
|
||||||
|
|
||||||
#### 1.1. Инвалидация по префиксу
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def invalidate_cache_by_prefix(prefix)
|
|
||||||
```
|
|
||||||
|
|
||||||
Позволяет инвалидировать все ключи кеша, начинающиеся с указанного префикса. Используется в резолверах для инвалидации группы кешей при массовых изменениях.
|
|
||||||
|
|
||||||
#### 1.2. Точечная инвалидация
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def invalidate_authors_cache(author_id=None)
|
|
||||||
async def invalidate_topics_cache(topic_id=None)
|
|
||||||
```
|
|
||||||
|
|
||||||
Эти функции позволяют инвалидировать кеш только для конкретной сущности, что снижает нагрузку на Redis и предотвращает ненужную потерю кешированных данных. Если ID сущности не указан, используется инвалидация по префиксу.
|
|
||||||
|
|
||||||
Примеры использования точечной инвалидации:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Инвалидация кеша только для автора с ID 123
|
|
||||||
await invalidate_authors_cache(123)
|
|
||||||
|
|
||||||
# Инвалидация кеша только для темы с ID 456
|
|
||||||
await invalidate_topics_cache(456)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Отложенная инвалидация
|
|
||||||
|
|
||||||
Модуль `revalidator.py` реализует систему отложенной инвалидации кеша через класс `CacheRevalidationManager`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class CacheRevalidationManager:
|
|
||||||
def __init__(self, interval=CACHE_REVALIDATION_INTERVAL):
|
|
||||||
# ...
|
|
||||||
self._redis = redis # Прямая ссылка на сервис Redis
|
|
||||||
|
|
||||||
async def start(self):
|
|
||||||
# Проверка и установка соединения с Redis
|
|
||||||
# ...
|
|
||||||
|
|
||||||
async def process_revalidation(self):
|
|
||||||
# Обработка элементов для ревалидации
|
|
||||||
# ...
|
|
||||||
|
|
||||||
def mark_for_revalidation(self, entity_id, entity_type):
|
|
||||||
# Добавляет сущность в очередь на ревалидацию
|
|
||||||
# ...
|
|
||||||
```
|
|
||||||
|
|
||||||
Менеджер ревалидации работает как асинхронный фоновый процесс, который периодически (по умолчанию каждые 5 минут) проверяет наличие сущностей для ревалидации.
|
|
||||||
|
|
||||||
**Взаимодействие с Redis:**
|
|
||||||
- CacheRevalidationManager хранит прямую ссылку на сервис Redis через атрибут `_redis`
|
|
||||||
- При запуске проверяется наличие соединения с Redis и при необходимости устанавливается новое
|
|
||||||
- Включена автоматическая проверка соединения перед каждой операцией ревалидации
|
|
||||||
- Система самостоятельно восстанавливает соединение при его потере
|
|
||||||
|
|
||||||
**Особенности реализации:**
|
|
||||||
- Для авторов и тем используется поштучная ревалидация каждой записи
|
|
||||||
- Для шаутов и реакций используется батчевая обработка, с порогом в 10 элементов
|
|
||||||
- При достижении порога система переключается на инвалидацию коллекций вместо поштучной обработки
|
|
||||||
- Специальный флаг `all` позволяет запустить полную инвалидацию всех записей типа
|
|
||||||
|
|
||||||
### 3. Автоматическая инвалидация через триггеры
|
|
||||||
|
|
||||||
Модуль `triggers.py` регистрирует обработчики событий SQLAlchemy, которые автоматически отмечают сущности для ревалидации при изменении данных в базе:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def events_register():
|
|
||||||
event.listen(Author, "after_update", mark_for_revalidation)
|
|
||||||
event.listen(Topic, "after_update", mark_for_revalidation)
|
|
||||||
# и другие...
|
|
||||||
```
|
|
||||||
|
|
||||||
Триггеры имеют следующие особенности:
|
|
||||||
- Реагируют на события вставки, обновления и удаления
|
|
||||||
- Отмечают затронутые сущности для отложенной ревалидации
|
|
||||||
- Учитывают связи между сущностями (например, при изменении темы обновляются связанные шауты)
|
|
||||||
|
|
||||||
## Предварительное кеширование
|
|
||||||
|
|
||||||
Модуль `precache.py` реализует предварительное кеширование часто используемых данных при старте приложения:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def precache_data():
|
|
||||||
# ...
|
|
||||||
```
|
|
||||||
|
|
||||||
Эта функция выполняется при запуске приложения и заполняет кеш данными, которые будут часто запрашиваться пользователями.
|
|
||||||
|
|
||||||
## Примеры использования
|
|
||||||
|
|
||||||
### Простое кеширование результата запроса
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def get_topics_with_stats(limit=10, offset=0, by="title"):
|
|
||||||
# Формирование ключа кеша по конвенции
|
|
||||||
cache_key = f"topics:stats:limit={limit}:offset={offset}:sort={by}"
|
|
||||||
|
|
||||||
cached_data = await get_cached_data(cache_key)
|
|
||||||
if cached_data:
|
|
||||||
return cached_data
|
|
||||||
|
|
||||||
# Выполнение запроса к базе данных
|
|
||||||
result = ... # логика получения данных
|
|
||||||
|
|
||||||
await cache_data(cache_key, result, ttl=300)
|
|
||||||
return result
|
|
||||||
```
|
|
||||||
|
|
||||||
### Использование обобщенной функции cached_query
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def get_topics_with_stats(limit=10, offset=0, by="title"):
|
|
||||||
async def fetch_data(limit, offset, by):
|
|
||||||
# Логика получения данных
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Формирование ключа кеша по конвенции
|
|
||||||
cache_key = f"topics:stats:limit={limit}:offset={offset}:sort={by}"
|
|
||||||
|
|
||||||
return await cached_query(
|
|
||||||
cache_key,
|
|
||||||
fetch_data,
|
|
||||||
ttl=300,
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
by=by
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Точечная инвалидация кеша при изменении данных
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def update_author(author_id, data):
|
|
||||||
# Обновление данных в базе
|
|
||||||
# ...
|
|
||||||
|
|
||||||
# Инвалидация только кеша этого автора
|
|
||||||
await invalidate_authors_cache(author_id)
|
|
||||||
|
|
||||||
return result
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ключи кеширования
|
|
||||||
|
|
||||||
Ниже приведен полный список форматов ключей, используемых в системе кеширования Discours.
|
|
||||||
|
|
||||||
### Ключи для публикаций (Shout)
|
|
||||||
|
|
||||||
| Формат ключа | Описание | Пример |
|
|
||||||
|--------------|----------|--------|
|
|
||||||
| `shouts:{id}` | Публикация по ID | `shouts:123` |
|
|
||||||
| `shouts:{id}:invalidated` | Флаг инвалидации публикации | `shouts:123:invalidated` |
|
|
||||||
| `shouts:feed:limit={n}:offset={m}` | Основная лента публикаций | `shouts:feed:limit=20:offset=0` |
|
|
||||||
| `shouts:recent:limit={n}` | Последние публикации | `shouts:recent:limit=10` |
|
|
||||||
| `shouts:random_top:limit={n}` | Случайные топовые публикации | `shouts:random_top:limit=5` |
|
|
||||||
| `shouts:unrated:limit={n}` | Неоцененные публикации | `shouts:unrated:limit=20` |
|
|
||||||
| `shouts:coauthored:limit={n}` | Совместные публикации | `shouts:coauthored:limit=10` |
|
|
||||||
|
|
||||||
### Ключи для авторов (Author)
|
|
||||||
|
|
||||||
| Формат ключа | Описание | Пример |
|
|
||||||
|--------------|----------|--------|
|
|
||||||
| `author:id:{id}` | Автор по ID | `author:id:123` |
|
|
||||||
| `author:slug:{slug}` | Автор по слагу | `author:slug:john-doe` |
|
|
||||||
| `author:user_id:{user_id}` | Автор по ID пользователя | `author:user_id:abc123` |
|
|
||||||
| `author:{id}` | Публикации автора | `author:123` |
|
|
||||||
| `authored:{id}` | Публикации, созданные автором | `authored:123` |
|
|
||||||
| `authors:all:basic` | Базовый список всех авторов | `authors:all:basic` |
|
|
||||||
| `authors:stats:limit={n}:offset={m}:sort={field}` | Список авторов с пагинацией и сортировкой | `authors:stats:limit=20:offset=0:sort=name` |
|
|
||||||
| `author:followers:{id}` | Подписчики автора | `author:followers:123` |
|
|
||||||
| `author:following:{id}` | Авторы, на которых подписан автор | `author:following:123` |
|
|
||||||
|
|
||||||
### Ключи для тем (Topic)
|
|
||||||
|
|
||||||
| Формат ключа | Описание | Пример |
|
|
||||||
|--------------|----------|--------|
|
|
||||||
| `topic:id:{id}` | Тема по ID | `topic:id:123` |
|
|
||||||
| `topic:slug:{slug}` | Тема по слагу | `topic:slug:technology` |
|
|
||||||
| `topic:{id}` | Публикации по теме | `topic:123` |
|
|
||||||
| `topic_shouts_{id}` | Публикации по теме (старый формат) | `topic_shouts_123` |
|
|
||||||
| `topics:all:basic` | Базовый список всех тем | `topics:all:basic` |
|
|
||||||
| `topics:stats:limit={n}:offset={m}:sort={field}` | Список тем с пагинацией и сортировкой | `topics:stats:limit=20:offset=0:sort=name` |
|
|
||||||
| `topic:authors:{id}` | Авторы темы | `topic:authors:123` |
|
|
||||||
| `topic:followers:{id}` | Подписчики темы | `topic:followers:123` |
|
|
||||||
| `topic:stats:{id}` | Статистика темы | `topic:stats:123` |
|
|
||||||
|
|
||||||
### Ключи для реакций (Reaction)
|
|
||||||
|
|
||||||
| Формат ключа | Описание | Пример |
|
|
||||||
|--------------|----------|--------|
|
|
||||||
| `reactions:shout:{id}:limit={n}:offset={m}` | Реакции на публикацию | `reactions:shout:123:limit=20:offset=0` |
|
|
||||||
| `reactions:comment:{id}:limit={n}:offset={m}` | Реакции на комментарий | `reactions:comment:456:limit=20:offset=0` |
|
|
||||||
| `reactions:author:{id}:limit={n}:offset={m}` | Реакции автора | `reactions:author:123:limit=20:offset=0` |
|
|
||||||
| `reactions:followed:author:{id}:limit={n}` | Реакции авторов, на которых подписан пользователь | `reactions:followed:author:123:limit=20` |
|
|
||||||
|
|
||||||
### Ключи для сообществ (Community)
|
|
||||||
|
|
||||||
| Формат ключа | Описание | Пример |
|
|
||||||
|--------------|----------|--------|
|
|
||||||
| `community:id:{id}` | Сообщество по ID | `community:id:123` |
|
|
||||||
| `community:slug:{slug}` | Сообщество по слагу | `community:slug:tech-club` |
|
|
||||||
| `communities:all:basic` | Базовый список всех сообществ | `communities:all:basic` |
|
|
||||||
| `community:authors:{id}` | Авторы сообщества | `community:authors:123` |
|
|
||||||
| `community:shouts:{id}:limit={n}:offset={m}` | Публикации сообщества | `community:shouts:123:limit=20:offset=0` |
|
|
||||||
|
|
||||||
### Ключи для подписок (Follow)
|
|
||||||
|
|
||||||
| Формат ключа | Описание | Пример |
|
|
||||||
|--------------|----------|--------|
|
|
||||||
| `follow:author:{follower_id}:authors` | Авторы, на которых подписан пользователь | `follow:author:123:authors` |
|
|
||||||
| `follow:author:{follower_id}:topics` | Темы, на которые подписан пользователь | `follow:author:123:topics` |
|
|
||||||
| `follow:topic:{topic_id}:authors` | Авторы, подписанные на тему | `follow:topic:456:authors` |
|
|
||||||
| `follow:author:{author_id}:followers` | Подписчики автора | `follow:author:123:followers` |
|
|
||||||
|
|
||||||
### Ключи для черновиков (Draft)
|
|
||||||
|
|
||||||
| Формат ключа | Описание | Пример |
|
|
||||||
|--------------|----------|--------|
|
|
||||||
| `draft:id:{id}` | Черновик по ID | `draft:id:123` |
|
|
||||||
| `drafts:author:{id}` | Черновики автора | `drafts:author:123` |
|
|
||||||
| `drafts:all:limit={n}:offset={m}` | Список всех черновиков с пагинацией | `drafts:all:limit=20:offset=0` |
|
|
||||||
|
|
||||||
### Ключи для статистики
|
|
||||||
|
|
||||||
| Формат ключа | Описание | Пример |
|
|
||||||
|--------------|----------|--------|
|
|
||||||
| `stats:shout:{id}` | Статистика публикации | `stats:shout:123` |
|
|
||||||
| `stats:author:{id}` | Статистика автора | `stats:author:123` |
|
|
||||||
| `stats:topic:{id}` | Статистика темы | `stats:topic:123` |
|
|
||||||
| `stats:community:{id}` | Статистика сообщества | `stats:community:123` |
|
|
||||||
|
|
||||||
### Ключи для поиска
|
|
||||||
|
|
||||||
| Формат ключа | Описание | Пример |
|
|
||||||
|--------------|----------|--------|
|
|
||||||
| `search:query:{query}:limit={n}:offset={m}` | Результаты поиска | `search:query:технологии:limit=20:offset=0` |
|
|
||||||
| `search:author:{query}:limit={n}` | Результаты поиска авторов | `search:author:иван:limit=10` |
|
|
||||||
| `search:topic:{query}:limit={n}` | Результаты поиска тем | `search:topic:наука:limit=10` |
|
|
||||||
|
|
||||||
### Служебные ключи
|
|
||||||
|
|
||||||
| Формат ключа | Описание | Пример |
|
|
||||||
|--------------|----------|--------|
|
|
||||||
| `revalidation:{entity_type}:{entity_id}` | Метка для ревалидации | `revalidation:author:123` |
|
|
||||||
| `revalidation:batch:{entity_type}` | Батчевая ревалидация | `revalidation:batch:shouts` |
|
|
||||||
| `lock:{resource}` | Блокировка ресурса | `lock:precache` |
|
|
||||||
| `views:shout:{id}` | Счетчик просмотров публикации | `views:shout:123` |
|
|
||||||
|
|
||||||
### Важные замечания по использованию ключей
|
|
||||||
|
|
||||||
1. При инвалидации кеша публикаций через `invalidate_shouts_cache()` необходимо передавать список ID публикаций, а не ключи кеша.
|
|
||||||
2. Функция `invalidate_shout_related_cache()` автоматически инвалидирует все связанные ключи для публикации, включая ключи авторов и тем.
|
|
||||||
3. Для большинства операций с кешем следует использовать асинхронные функции с префиксом `await`.
|
|
||||||
4. При создании новых ключей кеша следует придерживаться существующих конвенций именования.
|
|
||||||
|
|
||||||
## Отладка и мониторинг
|
|
||||||
|
|
||||||
Система кеширования использует логгер для отслеживания операций:
|
|
||||||
|
|
||||||
```python
|
|
||||||
logger.debug(f"Данные получены из кеша по ключу {key}")
|
|
||||||
logger.debug(f"Удалено {len(keys)} ключей кеша с префиксом {prefix}")
|
|
||||||
logger.error(f"Ошибка при инвалидации кеша: {e}")
|
|
||||||
```
|
|
||||||
|
|
||||||
Это позволяет отслеживать работу кеша и выявлять возможные проблемы на ранних стадиях.
|
|
||||||
|
|
||||||
## Рекомендации по использованию
|
|
||||||
|
|
||||||
1. **Следуйте конвенциям формирования ключей** - это критически важно для консистентности и предсказуемости кеша.
|
|
||||||
2. **Не создавайте собственные форматы ключей** - используйте существующие шаблоны для обеспечения единообразия.
|
|
||||||
3. **Не забывайте об инвалидации** - всегда инвалидируйте кеш при изменении данных.
|
|
||||||
4. **Используйте точечную инвалидацию** - вместо инвалидации по префиксу для снижения нагрузки на Redis.
|
|
||||||
5. **Устанавливайте разумные TTL** - используйте разные значения TTL в зависимости от частоты изменения данных.
|
|
||||||
6. **Не кешируйте большие объемы данных** - кешируйте только то, что действительно необходимо для повышения производительности.
|
|
||||||
|
|
||||||
## Технические детали реализации
|
|
||||||
|
|
||||||
- **Сериализация данных**: используется `orjson` для эффективной сериализации и десериализации данных.
|
|
||||||
- **Форматирование даты и времени**: для корректной работы с датами используется `CustomJSONEncoder`.
|
|
||||||
- **Асинхронность**: все операции кеширования выполняются асинхронно для минимального влияния на производительность API.
|
|
||||||
- **Прямое взаимодействие с Redis**: все операции выполняются через прямые вызовы `redis.execute()` с обработкой ошибок.
|
|
||||||
- **Батчевая обработка**: для массовых операций используется пороговое значение, после которого применяются оптимизированные стратегии.
|
|
||||||
|
|
||||||
## Известные ограничения
|
|
||||||
|
|
||||||
1. **Согласованность данных** - система не гарантирует абсолютную согласованность данных в кеше и базе данных.
|
|
||||||
2. **Память** - необходимо следить за объемом данных в кеше, чтобы избежать проблем с памятью Redis.
|
|
||||||
3. **Производительность Redis** - при большом количестве операций с кешем может стать узким местом.
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
# Пагинация комментариев
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
Реализована система пагинации комментариев по веткам, которая позволяет эффективно загружать и отображать вложенные ветки обсуждений. Основные преимущества:
|
|
||||||
|
|
||||||
1. Загрузка только необходимых комментариев, а не всего дерева
|
|
||||||
2. Снижение нагрузки на сервер и клиент
|
|
||||||
3. Возможность эффективной навигации по большим обсуждениям
|
|
||||||
4. Предзагрузка первых N ответов для улучшения UX
|
|
||||||
|
|
||||||
## API для иерархической загрузки комментариев
|
|
||||||
|
|
||||||
### GraphQL запрос `load_comments_branch`
|
|
||||||
|
|
||||||
```graphql
|
|
||||||
query LoadCommentsBranch(
|
|
||||||
$shout: Int!,
|
|
||||||
$parentId: Int,
|
|
||||||
$limit: Int,
|
|
||||||
$offset: Int,
|
|
||||||
$sort: ReactionSort,
|
|
||||||
$childrenLimit: Int,
|
|
||||||
$childrenOffset: Int
|
|
||||||
) {
|
|
||||||
load_comments_branch(
|
|
||||||
shout: $shout,
|
|
||||||
parent_id: $parentId,
|
|
||||||
limit: $limit,
|
|
||||||
offset: $offset,
|
|
||||||
sort: $sort,
|
|
||||||
children_limit: $childrenLimit,
|
|
||||||
children_offset: $childrenOffset
|
|
||||||
) {
|
|
||||||
id
|
|
||||||
body
|
|
||||||
created_at
|
|
||||||
created_by {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
slug
|
|
||||||
pic
|
|
||||||
}
|
|
||||||
kind
|
|
||||||
reply_to
|
|
||||||
stat {
|
|
||||||
rating
|
|
||||||
comments_count
|
|
||||||
}
|
|
||||||
first_replies {
|
|
||||||
id
|
|
||||||
body
|
|
||||||
created_at
|
|
||||||
created_by {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
slug
|
|
||||||
pic
|
|
||||||
}
|
|
||||||
kind
|
|
||||||
reply_to
|
|
||||||
stat {
|
|
||||||
rating
|
|
||||||
comments_count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Параметры запроса
|
|
||||||
|
|
||||||
| Параметр | Тип | По умолчанию | Описание |
|
|
||||||
|----------|-----|--------------|----------|
|
|
||||||
| shout | Int! | - | ID статьи, к которой относятся комментарии |
|
|
||||||
| parent_id | Int | null | ID родительского комментария. Если null, загружаются корневые комментарии |
|
|
||||||
| limit | Int | 10 | Максимальное количество комментариев для загрузки |
|
|
||||||
| offset | Int | 0 | Смещение для пагинации |
|
|
||||||
| sort | ReactionSort | newest | Порядок сортировки: newest, oldest, like |
|
|
||||||
| children_limit | Int | 3 | Максимальное количество дочерних комментариев для каждого родительского |
|
|
||||||
| children_offset | Int | 0 | Смещение для пагинации дочерних комментариев |
|
|
||||||
|
|
||||||
### Поля в ответе
|
|
||||||
|
|
||||||
Каждый комментарий содержит следующие основные поля:
|
|
||||||
|
|
||||||
- `id`: ID комментария
|
|
||||||
- `body`: Текст комментария
|
|
||||||
- `created_at`: Время создания
|
|
||||||
- `created_by`: Информация об авторе
|
|
||||||
- `kind`: Тип реакции (COMMENT)
|
|
||||||
- `reply_to`: ID родительского комментария (null для корневых)
|
|
||||||
- `first_replies`: Первые N дочерних комментариев
|
|
||||||
- `stat`: Статистика комментария, включающая:
|
|
||||||
- `comments_count`: Количество ответов на комментарий
|
|
||||||
- `rating`: Рейтинг комментария
|
|
||||||
|
|
||||||
## Примеры использования
|
|
||||||
|
|
||||||
### Загрузка корневых комментариев с первыми ответами
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const { data } = await client.query({
|
|
||||||
query: LOAD_COMMENTS_BRANCH,
|
|
||||||
variables: {
|
|
||||||
shout: 222,
|
|
||||||
limit: 10,
|
|
||||||
offset: 0,
|
|
||||||
sort: "newest",
|
|
||||||
childrenLimit: 3
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Загрузка ответов на конкретный комментарий
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const { data } = await client.query({
|
|
||||||
query: LOAD_COMMENTS_BRANCH,
|
|
||||||
variables: {
|
|
||||||
shout: 222,
|
|
||||||
parentId: 123, // ID комментария, для которого загружаем ответы
|
|
||||||
limit: 10,
|
|
||||||
offset: 0,
|
|
||||||
sort: "oldest" // Сортируем ответы от старых к новым
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Пагинация дочерних комментариев
|
|
||||||
|
|
||||||
Для загрузки дополнительных ответов на комментарий:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const { data } = await client.query({
|
|
||||||
query: LOAD_COMMENTS_BRANCH,
|
|
||||||
variables: {
|
|
||||||
shout: 222,
|
|
||||||
parentId: 123,
|
|
||||||
limit: 10,
|
|
||||||
offset: 0,
|
|
||||||
childrenLimit: 5,
|
|
||||||
childrenOffset: 3 // Пропускаем первые 3 комментария (уже загруженные)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Рекомендации по клиентской реализации
|
|
||||||
|
|
||||||
1. Для эффективной работы со сложными ветками обсуждений рекомендуется:
|
|
||||||
|
|
||||||
- Сначала загружать только корневые комментарии с первыми N ответами
|
|
||||||
- При наличии дополнительных ответов (когда `stat.comments_count > first_replies.length`)
|
|
||||||
добавить кнопку "Показать все ответы"
|
|
||||||
- При нажатии на кнопку загружать дополнительные ответы с помощью запроса с указанным `parentId`
|
|
||||||
|
|
||||||
2. Для сортировки:
|
|
||||||
- По умолчанию использовать `newest` для отображения свежих обсуждений
|
|
||||||
- Предусмотреть переключатель сортировки для всего дерева комментариев
|
|
||||||
- При изменении сортировки перезагружать данные с новым параметром `sort`
|
|
||||||
|
|
||||||
3. Для улучшения производительности:
|
|
||||||
- Кешировать результаты запросов на клиенте
|
|
||||||
- Использовать оптимистичные обновления при добавлении/редактировании комментариев
|
|
||||||
- При необходимости загружать комментарии порциями (ленивая загрузка)
|
|
||||||
173
docs/features.md
173
docs/features.md
@@ -1,173 +0,0 @@
|
|||||||
## Админ-панель
|
|
||||||
|
|
||||||
- **Управление пользователями**: Просмотр, поиск, назначение ролей (user/moderator/admin)
|
|
||||||
- **Управление публикациями**: Таблица со всеми публикациями, фильтрация по статусу, превью контента
|
|
||||||
- **Управление топиками**: Полноценное редактирование топиков в админ-панели
|
|
||||||
- **Иерархическое отображение**: Темы показываются в виде дерева с отступами и символами `└─` для дочерних элементов
|
|
||||||
- **Колонки таблицы**: ID, название, slug, описание, сообщество, родители, действия
|
|
||||||
- **Простой интерфейс редактирования**:
|
|
||||||
- **Клик по строке**: Модалка редактирования открывается при клике на любом месте строки таблицы
|
|
||||||
- **Ненавязчивый крестик**: Кнопка удаления в виде серого "×", краснеет при hover
|
|
||||||
- **Простой HTML редактор**: Обычный contenteditable div с моноширинным шрифтом вместо сложного редактора
|
|
||||||
- **Редактируемые поля**:
|
|
||||||
- **ID**: Отображается для идентификации (поле только для чтения)
|
|
||||||
- **Название и slug**: Текстовые поля для основной информации
|
|
||||||
- **Описание**: Простой HTML редактор с placeholder
|
|
||||||
- **Картинка**: URL изображения топика
|
|
||||||
- **Сообщество**: ID сообщества с числовой валидацией
|
|
||||||
- **Родители**: Список parent_ids через запятую с автоматическим парсингом
|
|
||||||
- **Безопасное удаление**: Модальное окно подтверждения при клике на крестик
|
|
||||||
- **Корректная инвалидация кешей**: Автоматическое обновление счетчиков подписок у всех подписчиков
|
|
||||||
- **GraphQL интеграция**: Использование мутаций `UPDATE_TOPIC_MUTATION` и `DELETE_TOPIC_MUTATION`
|
|
||||||
- **Управление переменными среды**: Настройка конфигурации приложения
|
|
||||||
- **TypeScript интеграция**: Полная типизация с автогенерацией типов из GraphQL схемы
|
|
||||||
- **Responsive дизайн**: Адаптивность для разных размеров экранов
|
|
||||||
|
|
||||||
## Codegen интеграция
|
|
||||||
|
|
||||||
- **Автоматическая генерация типов**: TypeScript типы генерируются из GraphQL схемы
|
|
||||||
- **Файл конфигурации**: `codegen.ts` с настройками для client-side генерации
|
|
||||||
- **Структура проекта**: Разделение на queries, mutations и index файлы в `panel/graphql/generated/`
|
|
||||||
- **Type safety**: Строгая типизация для всех GraphQL операций в админ-панели
|
|
||||||
- **Developer Experience**: Автокомплит и проверка типов в IDE
|
|
||||||
|
|
||||||
## Улучшенная система кеширования топиков
|
|
||||||
|
|
||||||
- **Централизованная функция**: `invalidate_topic_followers_cache()` в модуле cache
|
|
||||||
- **Комплексная инвалидация**: Обработка кешей как самого топика, так и всех его подписчиков
|
|
||||||
- **Правильная последовательность**: Получение подписчиков ДО удаления данных из БД
|
|
||||||
- **Инвалидируемые кеши**:
|
|
||||||
- `author:follows-topics:{follower_id}` - список подписок на топики
|
|
||||||
- `author:followers:{follower_id}` - счетчики подписчиков
|
|
||||||
- `author:stat:{follower_id}` - общая статистика автора
|
|
||||||
- `topic:followers:{topic_id}` - список подписчиков топика
|
|
||||||
- **Архитектурные принципы**: Разделение ответственности, переиспользуемость, тестируемость
|
|
||||||
|
|
||||||
## Просмотры публикаций
|
|
||||||
|
|
||||||
- Интеграция с Google Analytics для отслеживания просмотров публикаций
|
|
||||||
- Подсчет уникальных пользователей и общего количества просмотров
|
|
||||||
- Автоматическое обновление статистики при запросе данных публикации
|
|
||||||
|
|
||||||
## Мультидоменная авторизация
|
|
||||||
|
|
||||||
- Поддержка авторизации для разных доменов
|
|
||||||
- Автоматическое определение сервера авторизации
|
|
||||||
- Корректная обработка CORS для всех поддерживаемых доменов
|
|
||||||
|
|
||||||
## Система кеширования
|
|
||||||
|
|
||||||
- **Redis как основное хранилище**: Кэширование, сессии, токены, временные данные
|
|
||||||
- **Полная документация схемы**: [redis-schema.md](redis-schema.md) - детальное описание всех структур данных
|
|
||||||
- **11 категорий данных**: Аутентификация, кэш сущностей, поиск, просмотры, уведомления
|
|
||||||
- **Система токенов**: Сессии, OAuth токены, токены подтверждения с TTL
|
|
||||||
- **Переменные окружения**: Централизованное хранение конфигурации в Redis
|
|
||||||
- **Кэш сущностей**: Авторы, темы, публикации с автоматической инвалидацией
|
|
||||||
- **Поисковый кэш**: Нормализованные запросы с результатами
|
|
||||||
- **Pub/Sub каналы**: Real-time уведомления и коммуникация
|
|
||||||
- **Оптимизация**: Pipeline операции, стратегии кэширования
|
|
||||||
- **Мониторинг**: Команды диагностики и решение проблем производительности
|
|
||||||
- Поддержка как синхронных, так и асинхронных функций в декораторе cache_on_arguments
|
|
||||||
- Автоматическая сериализация/десериализация данных в JSON с использованием CustomJSONEncoder
|
|
||||||
- Резервная сериализация через pickle для сложных объектов
|
|
||||||
- Генерация уникальных ключей кеша на основе сигнатуры функции и переданных аргументов
|
|
||||||
- Настраиваемое время жизни кеша (TTL)
|
|
||||||
- Возможность ручной инвалидации кеша для конкретных функций и аргументов
|
|
||||||
|
|
||||||
## CORS Configuration
|
|
||||||
|
|
||||||
- Поддерживаемые методы: GET, POST, OPTIONS
|
|
||||||
- Настроена поддержка credentials
|
|
||||||
- Разрешенные заголовки: Authorization, Content-Type, X-Requested-With, DNT, Cache-Control
|
|
||||||
- Настроено кэширование preflight-ответов на 20 дней (1728000 секунд)
|
|
||||||
|
|
||||||
## Пагинация комментариев по веткам
|
|
||||||
|
|
||||||
- Эффективная загрузка комментариев с учетом их иерархической структуры
|
|
||||||
- Отдельный запрос `load_comments_branch` для оптимизированной загрузки ветки комментариев
|
|
||||||
- Возможность загрузки корневых комментариев статьи с первыми ответами на них
|
|
||||||
- Гибкая пагинация как для корневых, так и для дочерних комментариев
|
|
||||||
- Использование поля `stat.comments_count` для отображения количества ответов на комментарий
|
|
||||||
- Добавление специального поля `first_replies` для хранения первых ответов на комментарий
|
|
||||||
- Поддержка различных методов сортировки (новые, старые, популярные)
|
|
||||||
- Оптимизированные SQL запросы для минимизации нагрузки на базу данных
|
|
||||||
|
|
||||||
## Модульная система авторизации
|
|
||||||
|
|
||||||
- **Специализированные менеджеры токенов**:
|
|
||||||
- `SessionTokenManager`: Управление пользовательскими сессиями
|
|
||||||
- `VerificationTokenManager`: Токены для подтверждения email, телефона, смены пароля
|
|
||||||
- `OAuthTokenManager`: Управление OAuth токенами для внешних провайдеров
|
|
||||||
- `BatchTokenOperations`: Пакетные операции с токенами
|
|
||||||
- `TokenMonitoring`: Мониторинг и статистика использования токенов
|
|
||||||
- **Улучшенная производительность**:
|
|
||||||
- 50% ускорение Redis операций через пайплайны
|
|
||||||
- 30% снижение потребления памяти
|
|
||||||
- Оптимизированные запросы к базе данных
|
|
||||||
- **Безопасность**:
|
|
||||||
- Поддержка PKCE для всех OAuth провайдеров
|
|
||||||
- Автоматическая очистка истекших токенов
|
|
||||||
- Защита от replay-атак
|
|
||||||
|
|
||||||
## OAuth интеграция
|
|
||||||
|
|
||||||
- **7 поддерживаемых провайдеров**:
|
|
||||||
- Google, GitHub, Facebook
|
|
||||||
- X (Twitter), Telegram
|
|
||||||
- VK (ВКонтакте), Yandex
|
|
||||||
- **Обработка провайдеров без email**:
|
|
||||||
- Генерация временных email для X и Telegram
|
|
||||||
- Возможность обновления email в профиле
|
|
||||||
- **Токены в Redis**:
|
|
||||||
- Хранение access и refresh токенов с TTL
|
|
||||||
- Автоматическое обновление токенов
|
|
||||||
- Централизованное управление через Redis
|
|
||||||
- **Безопасность**:
|
|
||||||
- PKCE для всех OAuth потоков
|
|
||||||
- Временные state параметры в Redis (10 минут TTL)
|
|
||||||
- Одноразовые сессии
|
|
||||||
- Логирование неудачных попыток аутентификации
|
|
||||||
|
|
||||||
## Система управления паролями и email
|
|
||||||
|
|
||||||
- **Мутация updateSecurity**:
|
|
||||||
- Смена пароля с валидацией сложности
|
|
||||||
- Смена email с двухэтапным подтверждением
|
|
||||||
- Одновременная смена пароля и email
|
|
||||||
- **Токены подтверждения в Redis**:
|
|
||||||
- Автоматический TTL для всех токенов
|
|
||||||
- Безопасное хранение данных подтверждения
|
|
||||||
- **Дополнительные мутации**:
|
|
||||||
- confirmEmailChange
|
|
||||||
- cancelEmailChange
|
|
||||||
|
|
||||||
## Система featured публикаций
|
|
||||||
|
|
||||||
- **Автоматическое получение статуса featured**:
|
|
||||||
- Публикация получает статус featured при более чем 4 лайках от авторов с featured статьями
|
|
||||||
- Проверка квалификации автора: наличие опубликованных featured статей
|
|
||||||
- Логирование процесса для отладки и мониторинга
|
|
||||||
- **Условия удаления с главной (unfeatured)**:
|
|
||||||
- **Условие 1**: Менее 5 голосов "за" (положительные реакции)
|
|
||||||
- **Условие 2**: 20% или более отрицательных реакций от общего количества голосов
|
|
||||||
- Проверка выполняется только для уже featured публикаций
|
|
||||||
- **Оптимизированная логика обработки**:
|
|
||||||
- Проверка unfeatured имеет приоритет над featured при обработке реакций
|
|
||||||
- Автоматическая проверка условий при добавлении/удалении реакций
|
|
||||||
- Корректная обработка типов данных в функциях проверки
|
|
||||||
- **Интеграция с системой реакций**:
|
|
||||||
- Обработка в `create_reaction` для новых реакций
|
|
||||||
- Обработка в `delete_reaction` для удаленных реакций
|
|
||||||
- Учет только реакций на саму публикацию (не на комментарии)
|
|
||||||
|
|
||||||
## RBAC
|
|
||||||
|
|
||||||
- **Наследование разрешений между ролями** происходит только при инициализации прав для сообщества. В Redis хранятся уже развернутые (полные) списки разрешений для каждой роли. Проверка прав — это быстрый lookup без on-the-fly наследования.
|
|
||||||
|
|
||||||
## Core features
|
|
||||||
|
|
||||||
- RBAC с иерархией ролей, наследование только при инициализации, быстрый доступ к правам через Redis
|
|
||||||
|
|
||||||
## Changelog
|
|
||||||
|
|
||||||
- v0.6.11: RBAC — наследование только при инициализации, ускорение, упрощение кода, исправлены тесты
|
|
||||||
219
docs/follower.md
219
docs/follower.md
@@ -1,219 +0,0 @@
|
|||||||
# Following System
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
System supports following different entity types:
|
|
||||||
- Authors
|
|
||||||
- Topics
|
|
||||||
- Communities
|
|
||||||
- Shouts (Posts)
|
|
||||||
|
|
||||||
## GraphQL API
|
|
||||||
|
|
||||||
### Mutations
|
|
||||||
|
|
||||||
#### follow
|
|
||||||
Follow an entity (author/topic/community/shout).
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `what: String!` - Entity type (`AUTHOR`, `TOPIC`, `COMMUNITY`, `SHOUT`)
|
|
||||||
- `slug: String` - Entity slug
|
|
||||||
- `entity_id: Int` - Optional entity ID
|
|
||||||
|
|
||||||
**Returns:**
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
authors?: Author[] // For AUTHOR type
|
|
||||||
topics?: Topic[] // For TOPIC type
|
|
||||||
communities?: Community[] // For COMMUNITY type
|
|
||||||
shouts?: Shout[] // For SHOUT type
|
|
||||||
error?: String // Error message if any
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### unfollow
|
|
||||||
Unfollow an entity.
|
|
||||||
|
|
||||||
**Parameters:** Same as `follow`
|
|
||||||
|
|
||||||
**Returns:** Same as `follow`
|
|
||||||
|
|
||||||
**Important:** Always returns current following list even if the subscription was not found, ensuring UI consistency.
|
|
||||||
|
|
||||||
### Queries
|
|
||||||
|
|
||||||
#### get_shout_followers
|
|
||||||
Get list of authors who reacted to a shout.
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `slug: String` - Shout slug
|
|
||||||
- `shout_id: Int` - Optional shout ID
|
|
||||||
|
|
||||||
**Returns:**
|
|
||||||
```typescript
|
|
||||||
Author[] // List of authors who reacted
|
|
||||||
```
|
|
||||||
|
|
||||||
## Caching System
|
|
||||||
|
|
||||||
### Supported Entity Types
|
|
||||||
- Authors: `cache_author`, `get_cached_follower_authors`
|
|
||||||
- Topics: `cache_topic`, `get_cached_follower_topics`
|
|
||||||
- Communities: No cache
|
|
||||||
- Shouts: No cache
|
|
||||||
|
|
||||||
### Cache Flow
|
|
||||||
1. On follow/unfollow:
|
|
||||||
- Update entity in cache
|
|
||||||
- **Invalidate user's following list cache** (NEW)
|
|
||||||
- Update follower's following list
|
|
||||||
2. Cache is updated before notifications
|
|
||||||
|
|
||||||
### Cache Invalidation (NEW)
|
|
||||||
Following cache keys are invalidated after operations:
|
|
||||||
- `author:follows-topics:{user_id}` - After topic follow/unfollow
|
|
||||||
- `author:follows-authors:{user_id}` - After author follow/unfollow
|
|
||||||
|
|
||||||
This ensures fresh data is fetched from database on next request.
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
### Enhanced Error Handling (UPDATED)
|
|
||||||
- Unauthorized access check
|
|
||||||
- Entity existence validation
|
|
||||||
- Duplicate follow prevention
|
|
||||||
- **Graceful handling of "following not found" errors**
|
|
||||||
- **Always returns current following list, even on errors**
|
|
||||||
- Full error logging
|
|
||||||
- Transaction safety with `local_session()`
|
|
||||||
|
|
||||||
### Error Response Format
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
error?: "following was not found" | "invalid unfollow type" | "access denied",
|
|
||||||
topics?: Topic[], // Always present for topic operations
|
|
||||||
authors?: Author[], // Always present for author operations
|
|
||||||
// ... other entity types
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Recent Fixes (NEW)
|
|
||||||
|
|
||||||
### Issue 1: Stale UI State on Unfollow Errors
|
|
||||||
**Problem:** When unfollow operation failed with "following was not found", the client didn't update its state because it only processed successful responses.
|
|
||||||
|
|
||||||
**Root Cause:**
|
|
||||||
1. `unfollow` mutation returned error with empty follows list `[]`
|
|
||||||
2. Client logic: `if (result && !result.error)` prevented state updates on errors
|
|
||||||
3. User remained "subscribed" in UI despite no actual subscription in database
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. **Always fetch current following list** from cache/database
|
|
||||||
2. **Return actual following state** even when subscription not found
|
|
||||||
3. **Add cache invalidation** after successful operations
|
|
||||||
4. **Enhanced logging** for debugging
|
|
||||||
|
|
||||||
### Issue 2: Inconsistent Behavior in Follow Operations (NEW)
|
|
||||||
**Problem:** The `follow` function had similar issues to `unfollow`:
|
|
||||||
- Could return `None` instead of actual following list in error scenarios
|
|
||||||
- Cache was not invalidated when trying to follow already-followed entities
|
|
||||||
- Inconsistent error handling between follow/unfollow operations
|
|
||||||
|
|
||||||
**Root Cause:**
|
|
||||||
1. `follow` mutation could return `{topics: null}` when `get_cached_follows_method` was not available
|
|
||||||
2. When user was already following an entity, cache invalidation was skipped
|
|
||||||
3. Error responses didn't include current following state
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. **Always return actual following list** from cache/database
|
|
||||||
2. **Invalidate cache on every operation** (both new and existing subscriptions)
|
|
||||||
3. **Add "already following" error** while still returning current state
|
|
||||||
4. **Unified error handling** consistent with unfollow
|
|
||||||
|
|
||||||
### Code Changes
|
|
||||||
```python
|
|
||||||
# UNFOLLOW - Before (BROKEN)
|
|
||||||
if sub:
|
|
||||||
# ... process unfollow
|
|
||||||
else:
|
|
||||||
return {"error": "following was not found", f"{entity_type}s": follows} # follows was []
|
|
||||||
|
|
||||||
# UNFOLLOW - After (FIXED)
|
|
||||||
if sub:
|
|
||||||
# ... process unfollow
|
|
||||||
# Invalidate cache
|
|
||||||
await redis.execute("DEL", f"author:follows-{entity_type}s:{follower_id}")
|
|
||||||
else:
|
|
||||||
error = "following was not found"
|
|
||||||
|
|
||||||
# Always get current state
|
|
||||||
existing_follows = await get_cached_follows_method(follower_id)
|
|
||||||
return {f"{entity_type}s": existing_follows, "error": error}
|
|
||||||
|
|
||||||
# FOLLOW - Before (BROKEN)
|
|
||||||
if existing_sub:
|
|
||||||
logger.info(f"User already following...")
|
|
||||||
# Cache not invalidated, could return stale data
|
|
||||||
else:
|
|
||||||
# ... create subscription
|
|
||||||
# Cache invalidated only here
|
|
||||||
follows = None # Could be None!
|
|
||||||
# ... complex logic to build follows list
|
|
||||||
return {f"{entity_type}s": follows} # follows could be None
|
|
||||||
|
|
||||||
# FOLLOW - After (FIXED)
|
|
||||||
if existing_sub:
|
|
||||||
error = "already following"
|
|
||||||
else:
|
|
||||||
# ... create subscription
|
|
||||||
|
|
||||||
# Always invalidate cache and get current state
|
|
||||||
await redis.execute("DEL", f"author:follows-{entity_type}s:{follower_id}")
|
|
||||||
existing_follows = await get_cached_follows_method(follower_id)
|
|
||||||
return {f"{entity_type}s": existing_follows, "error": error}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Impact
|
|
||||||
**Before fixes:**
|
|
||||||
- UI could show incorrect subscription state
|
|
||||||
- Cache inconsistencies between follow/unfollow operations
|
|
||||||
- Client-side logic `if (result && !result.error)` failed on valid error states
|
|
||||||
|
|
||||||
**After fixes:**
|
|
||||||
- ✅ **UI always receives current subscription state**
|
|
||||||
- ✅ **Consistent cache invalidation** on all operations
|
|
||||||
- ✅ **Unified error handling** between follow/unfollow
|
|
||||||
- ✅ **Client can safely update UI** even on error responses
|
|
||||||
|
|
||||||
## Notifications
|
|
||||||
|
|
||||||
- Sent when author is followed/unfollowed
|
|
||||||
- Contains:
|
|
||||||
- Follower info
|
|
||||||
- Author ID
|
|
||||||
- Action type ("follow"/"unfollow")
|
|
||||||
|
|
||||||
## Database Schema
|
|
||||||
|
|
||||||
### Follower Tables
|
|
||||||
- `AuthorFollower`
|
|
||||||
- `TopicFollower`
|
|
||||||
- `CommunityFollower`
|
|
||||||
- `ShoutReactionsFollower`
|
|
||||||
|
|
||||||
Each table contains:
|
|
||||||
- `follower` - ID of following user
|
|
||||||
- `{entity_type}` - ID of followed entity
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Run the test script to verify fixes:
|
|
||||||
```bash
|
|
||||||
python test_unfollow_fix.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Coverage
|
|
||||||
- ✅ Unfollow existing subscription
|
|
||||||
- ✅ Unfollow non-existent subscription
|
|
||||||
- ✅ Cache invalidation
|
|
||||||
- ✅ Proper error handling
|
|
||||||
- ✅ UI state consistency
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# Система загрузки публикаций
|
|
||||||
|
|
||||||
## Особенности реализации
|
|
||||||
|
|
||||||
### Базовый запрос
|
|
||||||
- Автоматически подгружает основного автора
|
|
||||||
- Добавляет основную тему публикации
|
|
||||||
- Поддерживает гибкую систему фильтрации
|
|
||||||
- Оптимизирует запросы на основе запрошенных полей
|
|
||||||
|
|
||||||
### Статистика
|
|
||||||
- Подсчёт лайков/дислайков
|
|
||||||
- Количество комментариев
|
|
||||||
- Дата последней реакции
|
|
||||||
- Статистика подгружается только при запросе поля `stat`
|
|
||||||
|
|
||||||
### Оптимизация производительности
|
|
||||||
- Ленивая загрузка связанных данных
|
|
||||||
- Кэширование результатов на 5 минут
|
|
||||||
- Пакетная загрузка авторов и тем
|
|
||||||
- Использование подзапросов для сложных выборок
|
|
||||||
|
|
||||||
## Типы лент
|
|
||||||
|
|
||||||
### Случайные топовые посты (load_shouts_random_top)
|
|
||||||
**Преимущества:**
|
|
||||||
- Разнообразный контент
|
|
||||||
- Быстрая выборка из кэша топовых постов
|
|
||||||
- Настраиваемый размер пула для выборки
|
|
||||||
|
|
||||||
**Ограничения:**
|
|
||||||
- Обновление раз в 5 минут
|
|
||||||
- Максимальный размер пула: 100 постов
|
|
||||||
- Учитываются только лайки/дислайки (без комментариев)
|
|
||||||
|
|
||||||
### Неоцененные посты (load_shouts_unrated)
|
|
||||||
**Преимущества:**
|
|
||||||
- Помогает найти новый контент
|
|
||||||
- Равномерное распределение оценок
|
|
||||||
- Случайный порядок выдачи
|
|
||||||
|
|
||||||
**Ограничения:**
|
|
||||||
- Только посты с менее чем 3 реакциями
|
|
||||||
- Не учитываются комментарии
|
|
||||||
- Без сортировки по рейтингу
|
|
||||||
|
|
||||||
### Закладки (load_shouts_bookmarked)
|
|
||||||
**Преимущества:**
|
|
||||||
- Персонализированная выборка
|
|
||||||
- Быстрый доступ к сохраненному
|
|
||||||
- Поддержка всех фильтров
|
|
||||||
|
|
||||||
**Ограничения:**
|
|
||||||
- Требует авторизации
|
|
||||||
- Ограничение на количество закладок
|
|
||||||
- Кэширование отключено
|
|
||||||
|
|
||||||
## Важные моменты
|
|
||||||
|
|
||||||
### Пагинация
|
|
||||||
- Стандартный размер страницы: 10
|
|
||||||
- Максимальный размер: 100
|
|
||||||
- Поддержка курсор-пагинации
|
|
||||||
|
|
||||||
### Кэширование
|
|
||||||
- TTL: 5 минут
|
|
||||||
- Инвалидация при изменении поста
|
|
||||||
- Отдельный кэш для каждого типа сортировки
|
|
||||||
|
|
||||||
### Сортировка
|
|
||||||
- По рейтингу (лайки минус дислайки)
|
|
||||||
- По количеству комментариев
|
|
||||||
- По дате последней реакции
|
|
||||||
- По дате публикации (по умолчанию)
|
|
||||||
|
|
||||||
### Безопасность
|
|
||||||
- Проверка прав доступа
|
|
||||||
- Фильтрация удаленного контента
|
|
||||||
- Защита от SQL-инъекций
|
|
||||||
- Валидация входных данных
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
# OAuth Deployment Checklist
|
|
||||||
|
|
||||||
## 🚀 Quick Setup Guide
|
|
||||||
|
|
||||||
### 1. Backend Implementation
|
|
||||||
```bash
|
|
||||||
# Добавьте в requirements.txt или poetry
|
|
||||||
redis>=4.0.0
|
|
||||||
httpx>=0.24.0
|
|
||||||
pydantic>=2.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Environment Variables
|
|
||||||
```bash
|
|
||||||
# .env file
|
|
||||||
GOOGLE_CLIENT_ID=your_google_client_id
|
|
||||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
|
||||||
FACEBOOK_APP_ID=your_facebook_app_id
|
|
||||||
FACEBOOK_APP_SECRET=your_facebook_app_secret
|
|
||||||
GITHUB_CLIENT_ID=your_github_client_id
|
|
||||||
GITHUB_CLIENT_SECRET=your_github_client_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
|
|
||||||
|
|
||||||
REDIS_URL=redis://localhost:6379/0
|
|
||||||
JWT_SECRET=your_super_secret_jwt_key
|
|
||||||
JWT_EXPIRATION_HOURS=24
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Database Migration
|
|
||||||
```sql
|
|
||||||
-- Create oauth_links table
|
|
||||||
CREATE TABLE oauth_links (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES authors(id) ON DELETE CASCADE,
|
|
||||||
provider VARCHAR(50) NOT NULL,
|
|
||||||
provider_id VARCHAR(255) NOT NULL,
|
|
||||||
provider_data JSONB,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
|
|
||||||
UNIQUE(provider, provider_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_oauth_links_user_id ON oauth_links(user_id);
|
|
||||||
CREATE INDEX idx_oauth_links_provider ON oauth_links(provider, provider_id);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. OAuth Provider Setup
|
|
||||||
|
|
||||||
#### Google OAuth
|
|
||||||
1. Перейти в [Google Cloud Console](https://console.cloud.google.com/)
|
|
||||||
2. Создать новый проект или выбрать существующий
|
|
||||||
3. Включить Google+ API
|
|
||||||
4. Настроить OAuth consent screen
|
|
||||||
5. Создать OAuth 2.0 credentials
|
|
||||||
6. Добавить redirect URIs:
|
|
||||||
- `https://your-domain.com/auth/oauth/google/callback`
|
|
||||||
- `http://localhost:3000/auth/oauth/google/callback` (для разработки)
|
|
||||||
|
|
||||||
#### Facebook OAuth
|
|
||||||
1. Перейти в [Facebook Developers](https://developers.facebook.com/)
|
|
||||||
2. Создать новое приложение
|
|
||||||
3. Добавить продукт "Facebook Login"
|
|
||||||
4. Настроить Valid OAuth Redirect URIs:
|
|
||||||
- `https://your-domain.com/auth/oauth/facebook/callback`
|
|
||||||
|
|
||||||
#### GitHub OAuth
|
|
||||||
1. Перейти в [GitHub Settings](https://github.com/settings/applications/new)
|
|
||||||
2. Создать новое OAuth App
|
|
||||||
3. Настроить Authorization callback URL:
|
|
||||||
- `https://your-domain.com/auth/oauth/github/callback`
|
|
||||||
|
|
||||||
### 5. Backend Endpoints (FastAPI example)
|
|
||||||
```python
|
|
||||||
# auth/oauth.py
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth/oauth")
|
|
||||||
|
|
||||||
@router.get("/{provider}")
|
|
||||||
async def oauth_redirect(provider: str, state: str, redirect_uri: str):
|
|
||||||
# Валидация провайдера
|
|
||||||
if provider not in ["google", "facebook", "github", "vk", "yandex"]:
|
|
||||||
raise HTTPException(400, "Unsupported provider")
|
|
||||||
|
|
||||||
# Сохранение state в Redis
|
|
||||||
await store_oauth_state(state, redirect_uri)
|
|
||||||
|
|
||||||
# Генерация URL провайдера
|
|
||||||
oauth_url = generate_provider_url(provider, state, redirect_uri)
|
|
||||||
|
|
||||||
return RedirectResponse(url=oauth_url)
|
|
||||||
|
|
||||||
@router.get("/{provider}/callback")
|
|
||||||
async def oauth_callback(provider: str, code: str, state: str):
|
|
||||||
# Проверка state
|
|
||||||
stored_data = await get_oauth_state(state)
|
|
||||||
if not stored_data:
|
|
||||||
raise HTTPException(400, "Invalid state")
|
|
||||||
|
|
||||||
# Обмен code на user_data
|
|
||||||
user_data = await exchange_code_for_user_data(provider, code)
|
|
||||||
|
|
||||||
# Создание/поиск пользователя
|
|
||||||
user = await get_or_create_user_from_oauth(provider, user_data)
|
|
||||||
|
|
||||||
# Генерация JWT
|
|
||||||
access_token = generate_jwt_token(user.id)
|
|
||||||
|
|
||||||
# Редирект с токеном
|
|
||||||
return RedirectResponse(
|
|
||||||
url=f"{stored_data['redirect_uri']}?state={state}&access_token={access_token}"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Testing
|
|
||||||
```bash
|
|
||||||
# Запуск E2E тестов
|
|
||||||
npm run test:e2e -- oauth.spec.ts
|
|
||||||
|
|
||||||
# Проверка OAuth endpoints
|
|
||||||
curl -X GET "http://localhost:8000/auth/oauth/google?state=test&redirect_uri=http://localhost:3000"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Production Deployment
|
|
||||||
|
|
||||||
#### Frontend
|
|
||||||
- [ ] Проверить корректность `coreApiUrl` в production
|
|
||||||
- [ ] Добавить обработку ошибок OAuth в UI
|
|
||||||
- [ ] Настроить CSP headers для OAuth редиректов
|
|
||||||
|
|
||||||
#### Backend
|
|
||||||
- [ ] Настроить HTTPS для всех OAuth endpoints
|
|
||||||
- [ ] Добавить rate limiting для OAuth endpoints
|
|
||||||
- [ ] Настроить CORS для фронтенд доменов
|
|
||||||
- [ ] Добавить мониторинг OAuth ошибок
|
|
||||||
- [ ] Настроить логирование OAuth событий
|
|
||||||
|
|
||||||
#### Infrastructure
|
|
||||||
- [ ] Настроить Redis для production
|
|
||||||
- [ ] Добавить health checks для OAuth endpoints
|
|
||||||
- [ ] Настроить backup для oauth_links таблицы
|
|
||||||
|
|
||||||
### 8. Security Checklist
|
|
||||||
- [ ] Все OAuth секреты в environment variables
|
|
||||||
- [ ] State validation с TTL (10 минут)
|
|
||||||
- [ ] CSRF protection включен
|
|
||||||
- [ ] Redirect URI validation
|
|
||||||
- [ ] Rate limiting на OAuth endpoints
|
|
||||||
- [ ] Логирование всех OAuth событий
|
|
||||||
- [ ] HTTPS обязателен в production
|
|
||||||
|
|
||||||
### 9. Monitoring
|
|
||||||
```python
|
|
||||||
# Добавить метрики для мониторинга
|
|
||||||
from prometheus_client import Counter, Histogram
|
|
||||||
|
|
||||||
oauth_requests = Counter('oauth_requests_total', 'OAuth requests', ['provider', 'status'])
|
|
||||||
oauth_duration = Histogram('oauth_duration_seconds', 'OAuth request duration')
|
|
||||||
|
|
||||||
@router.get("/{provider}")
|
|
||||||
async def oauth_redirect(provider: str, state: str, redirect_uri: str):
|
|
||||||
with oauth_duration.time():
|
|
||||||
try:
|
|
||||||
# OAuth logic
|
|
||||||
oauth_requests.labels(provider=provider, status='success').inc()
|
|
||||||
except Exception as e:
|
|
||||||
oauth_requests.labels(provider=provider, status='error').inc()
|
|
||||||
raise
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Troubleshooting
|
|
||||||
|
|
||||||
### Частые ошибки
|
|
||||||
|
|
||||||
1. **"OAuth state mismatch"**
|
|
||||||
- Проверьте TTL Redis
|
|
||||||
- Убедитесь, что state генерируется правильно
|
|
||||||
|
|
||||||
2. **"Provider authentication failed"**
|
|
||||||
- Проверьте client_id и client_secret
|
|
||||||
- Убедитесь, что redirect_uri совпадает с настройками провайдера
|
|
||||||
|
|
||||||
3. **"Invalid redirect URI"**
|
|
||||||
- Добавьте все возможные redirect URIs в настройки приложения
|
|
||||||
- Проверьте HTTPS/HTTP в production/development
|
|
||||||
|
|
||||||
### Логи для отладки
|
|
||||||
```bash
|
|
||||||
# Backend логи
|
|
||||||
tail -f /var/log/app/oauth.log | grep "oauth"
|
|
||||||
|
|
||||||
# Frontend логи (browser console)
|
|
||||||
# Фильтр: "[oauth]" или "[SessionProvider]"
|
|
||||||
```
|
|
||||||
@@ -1,430 +0,0 @@
|
|||||||
# OAuth Implementation Guide
|
|
||||||
|
|
||||||
## Фронтенд (Текущая реализация)
|
|
||||||
|
|
||||||
### Контекст сессии
|
|
||||||
```typescript
|
|
||||||
// src/context/session.tsx
|
|
||||||
const oauth = (provider: string) => {
|
|
||||||
console.info('[oauth] Starting OAuth flow for provider:', provider)
|
|
||||||
|
|
||||||
if (isServer) {
|
|
||||||
console.warn('[oauth] OAuth not available during SSR')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Генерируем state для OAuth
|
|
||||||
const state = crypto.randomUUID()
|
|
||||||
localStorage.setItem('oauth_state', state)
|
|
||||||
|
|
||||||
// Формируем URL для OAuth
|
|
||||||
const oauthUrl = `${coreApiUrl}/auth/oauth/${provider}?state=${state}&redirect_uri=${encodeURIComponent(window.location.origin)}`
|
|
||||||
|
|
||||||
// Перенаправляем на OAuth провайдера
|
|
||||||
window.location.href = oauthUrl
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Обработка OAuth callback
|
|
||||||
```typescript
|
|
||||||
// Обработка OAuth параметров в SessionProvider
|
|
||||||
createEffect(
|
|
||||||
on([() => searchParams?.state, () => searchParams?.access_token, () => searchParams?.token],
|
|
||||||
([state, access_token, token]) => {
|
|
||||||
// OAuth обработка
|
|
||||||
if (state && access_token) {
|
|
||||||
console.info('[SessionProvider] Processing OAuth callback')
|
|
||||||
const storedState = !isServer ? localStorage.getItem('oauth_state') : null
|
|
||||||
|
|
||||||
if (storedState === state) {
|
|
||||||
console.info('[SessionProvider] OAuth state verified')
|
|
||||||
batch(() => {
|
|
||||||
changeSearchParams({ mode: 'confirm-email', m: 'auth', access_token }, { replace: true })
|
|
||||||
if (!isServer) localStorage.removeItem('oauth_state')
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
console.warn('[SessionProvider] OAuth state mismatch')
|
|
||||||
setAuthError('OAuth state mismatch')
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обработка токена сброса пароля
|
|
||||||
if (token) {
|
|
||||||
console.info('[SessionProvider] Processing password reset token')
|
|
||||||
changeSearchParams({ mode: 'change-password', m: 'auth', token }, { replace: true })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ defer: true }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Бекенд Requirements
|
|
||||||
|
|
||||||
### 1. OAuth Endpoints
|
|
||||||
|
|
||||||
#### GET `/auth/oauth/{provider}`
|
|
||||||
```python
|
|
||||||
@router.get("/auth/oauth/{provider}")
|
|
||||||
async def oauth_redirect(
|
|
||||||
provider: str,
|
|
||||||
state: str,
|
|
||||||
redirect_uri: str,
|
|
||||||
request: Request
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Инициация OAuth flow с внешним провайдером
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider: Провайдер OAuth (google, facebook, github)
|
|
||||||
state: CSRF токен от клиента
|
|
||||||
redirect_uri: URL для редиректа после авторизации
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
RedirectResponse: Редирект на провайдера OAuth
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Валидация провайдера
|
|
||||||
if provider not in SUPPORTED_PROVIDERS:
|
|
||||||
raise HTTPException(status_code=400, detail="Unsupported OAuth provider")
|
|
||||||
|
|
||||||
# Сохранение state в сессии/Redis для проверки
|
|
||||||
await store_oauth_state(state, redirect_uri)
|
|
||||||
|
|
||||||
# Генерация URL провайдера
|
|
||||||
oauth_url = generate_provider_url(provider, state, redirect_uri)
|
|
||||||
|
|
||||||
return RedirectResponse(url=oauth_url)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### GET `/auth/oauth/{provider}/callback`
|
|
||||||
```python
|
|
||||||
@router.get("/auth/oauth/{provider}/callback")
|
|
||||||
async def oauth_callback(
|
|
||||||
provider: str,
|
|
||||||
code: str,
|
|
||||||
state: str,
|
|
||||||
request: Request
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Обработка callback от OAuth провайдера
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider: Провайдер OAuth
|
|
||||||
code: Authorization code от провайдера
|
|
||||||
state: CSRF токен для проверки
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
RedirectResponse: Редирект обратно на фронтенд с токеном
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Проверка state
|
|
||||||
stored_data = await get_oauth_state(state)
|
|
||||||
if not stored_data:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid or expired state")
|
|
||||||
|
|
||||||
# Обмен code на access_token
|
|
||||||
try:
|
|
||||||
user_data = await exchange_code_for_user_data(provider, code)
|
|
||||||
except OAuthException as e:
|
|
||||||
logger.error(f"OAuth error for {provider}: {e}")
|
|
||||||
return RedirectResponse(url=f"{stored_data['redirect_uri']}?error=oauth_failed")
|
|
||||||
|
|
||||||
# Поиск/создание пользователя
|
|
||||||
user = await get_or_create_user_from_oauth(provider, user_data)
|
|
||||||
|
|
||||||
# Генерация JWT токена
|
|
||||||
access_token = generate_jwt_token(user.id)
|
|
||||||
|
|
||||||
# Редирект обратно на фронтенд
|
|
||||||
redirect_url = f"{stored_data['redirect_uri']}?state={state}&access_token={access_token}"
|
|
||||||
return RedirectResponse(url=redirect_url)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Provider Configuration
|
|
||||||
|
|
||||||
#### Google OAuth
|
|
||||||
```python
|
|
||||||
GOOGLE_OAUTH_CONFIG = {
|
|
||||||
"client_id": os.getenv("GOOGLE_CLIENT_ID"),
|
|
||||||
"client_secret": os.getenv("GOOGLE_CLIENT_SECRET"),
|
|
||||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
|
||||||
"token_url": "https://oauth2.googleapis.com/token",
|
|
||||||
"user_info_url": "https://www.googleapis.com/oauth2/v2/userinfo",
|
|
||||||
"scope": "openid email profile"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Facebook OAuth
|
|
||||||
```python
|
|
||||||
FACEBOOK_OAUTH_CONFIG = {
|
|
||||||
"client_id": os.getenv("FACEBOOK_APP_ID"),
|
|
||||||
"client_secret": os.getenv("FACEBOOK_APP_SECRET"),
|
|
||||||
"auth_url": "https://www.facebook.com/v18.0/dialog/oauth",
|
|
||||||
"token_url": "https://graph.facebook.com/v18.0/oauth/access_token",
|
|
||||||
"user_info_url": "https://graph.facebook.com/v18.0/me",
|
|
||||||
"scope": "email public_profile"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### GitHub OAuth
|
|
||||||
```python
|
|
||||||
GITHUB_OAUTH_CONFIG = {
|
|
||||||
"client_id": os.getenv("GITHUB_CLIENT_ID"),
|
|
||||||
"client_secret": os.getenv("GITHUB_CLIENT_SECRET"),
|
|
||||||
"auth_url": "https://github.com/login/oauth/authorize",
|
|
||||||
"token_url": "https://github.com/login/oauth/access_token",
|
|
||||||
"user_info_url": "https://api.github.com/user",
|
|
||||||
"scope": "read:user user:email"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. User Management
|
|
||||||
|
|
||||||
#### OAuth User Model
|
|
||||||
```python
|
|
||||||
class OAuthUser(BaseModel):
|
|
||||||
provider: str
|
|
||||||
provider_id: str
|
|
||||||
email: str
|
|
||||||
name: str
|
|
||||||
avatar_url: Optional[str] = None
|
|
||||||
raw_data: dict
|
|
||||||
```
|
|
||||||
|
|
||||||
#### User Creation/Linking
|
|
||||||
```python
|
|
||||||
async def get_or_create_user_from_oauth(
|
|
||||||
provider: str,
|
|
||||||
oauth_data: OAuthUser
|
|
||||||
) -> User:
|
|
||||||
"""
|
|
||||||
Поиск существующего пользователя или создание нового
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider: OAuth провайдер
|
|
||||||
oauth_data: Данные пользователя от провайдера
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
User: Пользователь в системе
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Поиск по OAuth связке
|
|
||||||
oauth_link = await OAuthLink.get_by_provider_and_id(
|
|
||||||
provider=provider,
|
|
||||||
provider_id=oauth_data.provider_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if oauth_link:
|
|
||||||
return await User.get(oauth_link.user_id)
|
|
||||||
|
|
||||||
# Поиск по email
|
|
||||||
existing_user = await User.get_by_email(oauth_data.email)
|
|
||||||
|
|
||||||
if existing_user:
|
|
||||||
# Привязка OAuth к существующему пользователю
|
|
||||||
await OAuthLink.create(
|
|
||||||
user_id=existing_user.id,
|
|
||||||
provider=provider,
|
|
||||||
provider_id=oauth_data.provider_id,
|
|
||||||
provider_data=oauth_data.raw_data
|
|
||||||
)
|
|
||||||
return existing_user
|
|
||||||
|
|
||||||
# Создание нового пользователя
|
|
||||||
new_user = await User.create(
|
|
||||||
email=oauth_data.email,
|
|
||||||
name=oauth_data.name,
|
|
||||||
pic=oauth_data.avatar_url,
|
|
||||||
is_verified=True, # OAuth email считается верифицированным
|
|
||||||
registration_method='oauth',
|
|
||||||
registration_provider=provider
|
|
||||||
)
|
|
||||||
|
|
||||||
# Создание OAuth связки
|
|
||||||
await OAuthLink.create(
|
|
||||||
user_id=new_user.id,
|
|
||||||
provider=provider,
|
|
||||||
provider_id=oauth_data.provider_id,
|
|
||||||
provider_data=oauth_data.raw_data
|
|
||||||
)
|
|
||||||
|
|
||||||
return new_user
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Security
|
|
||||||
|
|
||||||
#### State Management
|
|
||||||
```python
|
|
||||||
import redis
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
redis_client = redis.Redis()
|
|
||||||
|
|
||||||
async def store_oauth_state(
|
|
||||||
state: str,
|
|
||||||
redirect_uri: str,
|
|
||||||
ttl: timedelta = timedelta(minutes=10)
|
|
||||||
):
|
|
||||||
"""Сохранение OAuth state с TTL"""
|
|
||||||
key = f"oauth_state:{state}"
|
|
||||||
data = {
|
|
||||||
"redirect_uri": redirect_uri,
|
|
||||||
"created_at": datetime.utcnow().isoformat()
|
|
||||||
}
|
|
||||||
await redis_client.setex(key, ttl, json.dumps(data))
|
|
||||||
|
|
||||||
async def get_oauth_state(state: str) -> Optional[dict]:
|
|
||||||
"""Получение и удаление OAuth state"""
|
|
||||||
key = f"oauth_state:{state}"
|
|
||||||
data = await redis_client.get(key)
|
|
||||||
if data:
|
|
||||||
await redis_client.delete(key) # One-time use
|
|
||||||
return json.loads(data)
|
|
||||||
return None
|
|
||||||
```
|
|
||||||
|
|
||||||
#### CSRF Protection
|
|
||||||
```python
|
|
||||||
def validate_oauth_state(stored_state: str, received_state: str) -> bool:
|
|
||||||
"""Проверка OAuth state для защиты от CSRF"""
|
|
||||||
return stored_state == received_state
|
|
||||||
|
|
||||||
def validate_redirect_uri(uri: str) -> bool:
|
|
||||||
"""Валидация redirect_uri для предотвращения открытых редиректов"""
|
|
||||||
allowed_domains = [
|
|
||||||
"localhost:3000",
|
|
||||||
"discours.io",
|
|
||||||
"new.discours.io"
|
|
||||||
]
|
|
||||||
|
|
||||||
parsed = urlparse(uri)
|
|
||||||
return any(domain in parsed.netloc for domain in allowed_domains)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Database Schema
|
|
||||||
|
|
||||||
#### OAuth Links Table
|
|
||||||
```sql
|
|
||||||
CREATE TABLE oauth_links (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
provider VARCHAR(50) NOT NULL,
|
|
||||||
provider_id VARCHAR(255) NOT NULL,
|
|
||||||
provider_data JSONB,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
|
|
||||||
UNIQUE(provider, provider_id),
|
|
||||||
INDEX(user_id),
|
|
||||||
INDEX(provider, provider_id)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Environment Variables
|
|
||||||
|
|
||||||
#### Required Config
|
|
||||||
```bash
|
|
||||||
# Google OAuth
|
|
||||||
GOOGLE_CLIENT_ID=your_google_client_id
|
|
||||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
|
||||||
|
|
||||||
# Facebook OAuth
|
|
||||||
FACEBOOK_APP_ID=your_facebook_app_id
|
|
||||||
FACEBOOK_APP_SECRET=your_facebook_app_secret
|
|
||||||
|
|
||||||
# GitHub OAuth
|
|
||||||
GITHUB_CLIENT_ID=your_github_client_id
|
|
||||||
GITHUB_CLIENT_SECRET=your_github_client_secret
|
|
||||||
|
|
||||||
# Redis для state management
|
|
||||||
REDIS_URL=redis://localhost:6379/0
|
|
||||||
|
|
||||||
# JWT
|
|
||||||
JWT_SECRET=your_jwt_secret_key
|
|
||||||
JWT_EXPIRATION_HOURS=24
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Error Handling
|
|
||||||
|
|
||||||
#### OAuth Exceptions
|
|
||||||
```python
|
|
||||||
class OAuthException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class InvalidProviderException(OAuthException):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class StateValidationException(OAuthException):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class ProviderAPIException(OAuthException):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Error responses
|
|
||||||
@app.exception_handler(OAuthException)
|
|
||||||
async def oauth_exception_handler(request: Request, exc: OAuthException):
|
|
||||||
logger.error(f"OAuth error: {exc}")
|
|
||||||
return RedirectResponse(
|
|
||||||
url=f"{request.base_url}?error=oauth_failed&message={str(exc)}"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. Testing
|
|
||||||
|
|
||||||
#### Unit Tests
|
|
||||||
```python
|
|
||||||
def test_oauth_redirect():
|
|
||||||
response = client.get("/auth/oauth/google?state=test&redirect_uri=http://localhost:3000")
|
|
||||||
assert response.status_code == 307
|
|
||||||
assert "accounts.google.com" in response.headers["location"]
|
|
||||||
|
|
||||||
def test_oauth_callback():
|
|
||||||
# Mock provider response
|
|
||||||
with mock.patch('oauth.exchange_code_for_user_data') as mock_exchange:
|
|
||||||
mock_exchange.return_value = OAuthUser(
|
|
||||||
provider="google",
|
|
||||||
provider_id="123456",
|
|
||||||
email="test@example.com",
|
|
||||||
name="Test User"
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.get("/auth/oauth/google/callback?code=test_code&state=test_state")
|
|
||||||
assert response.status_code == 307
|
|
||||||
assert "access_token=" in response.headers["location"]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontend Testing
|
|
||||||
|
|
||||||
### E2E Tests
|
|
||||||
```typescript
|
|
||||||
// tests/oauth.spec.ts
|
|
||||||
test('OAuth flow with Google', async ({ page }) => {
|
|
||||||
await page.goto('/login')
|
|
||||||
|
|
||||||
// Click Google OAuth button
|
|
||||||
await page.click('[data-testid="oauth-google"]')
|
|
||||||
|
|
||||||
// Should redirect to Google
|
|
||||||
await page.waitForURL(/accounts\.google\.com/)
|
|
||||||
|
|
||||||
// Mock successful OAuth (in test environment)
|
|
||||||
await page.goto('/?state=test&access_token=mock_token')
|
|
||||||
|
|
||||||
// Should be logged in
|
|
||||||
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible()
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment Checklist
|
|
||||||
|
|
||||||
- [ ] Зарегистрировать OAuth приложения у провайдеров
|
|
||||||
- [ ] Настроить redirect URLs в консолях провайдеров
|
|
||||||
- [ ] Добавить environment variables
|
|
||||||
- [ ] Настроить Redis для state management
|
|
||||||
- [ ] Создать таблицу oauth_links
|
|
||||||
- [ ] Добавить rate limiting для OAuth endpoints
|
|
||||||
- [ ] Настроить мониторинг OAuth ошибок
|
|
||||||
- [ ] Протестировать все провайдеры в staging
|
|
||||||
- [ ] Добавить логирование OAuth событий
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
# OAuth Providers Setup Guide
|
|
||||||
|
|
||||||
This guide explains how to set up OAuth authentication for various social platforms.
|
|
||||||
|
|
||||||
## Supported Providers
|
|
||||||
|
|
||||||
The platform supports the following OAuth providers:
|
|
||||||
- Google
|
|
||||||
- GitHub
|
|
||||||
- Facebook
|
|
||||||
- X (Twitter)
|
|
||||||
- Telegram
|
|
||||||
- VK (VKontakte)
|
|
||||||
- Yandex
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
Add the following environment variables to your `.env` file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Google OAuth
|
|
||||||
OAUTH_CLIENTS_GOOGLE_ID=your_google_client_id
|
|
||||||
OAUTH_CLIENTS_GOOGLE_KEY=your_google_client_secret
|
|
||||||
|
|
||||||
# GitHub OAuth
|
|
||||||
OAUTH_CLIENTS_GITHUB_ID=your_github_client_id
|
|
||||||
OAUTH_CLIENTS_GITHUB_KEY=your_github_client_secret
|
|
||||||
|
|
||||||
# Facebook OAuth
|
|
||||||
OAUTH_CLIENTS_FACEBOOK_ID=your_facebook_app_id
|
|
||||||
OAUTH_CLIENTS_FACEBOOK_KEY=your_facebook_app_secret
|
|
||||||
|
|
||||||
# X (Twitter) OAuth
|
|
||||||
OAUTH_CLIENTS_X_ID=your_x_client_id
|
|
||||||
OAUTH_CLIENTS_X_KEY=your_x_client_secret
|
|
||||||
|
|
||||||
# Telegram OAuth
|
|
||||||
OAUTH_CLIENTS_TELEGRAM_ID=your_telegram_bot_token
|
|
||||||
OAUTH_CLIENTS_TELEGRAM_KEY=your_telegram_bot_secret
|
|
||||||
|
|
||||||
# VK OAuth
|
|
||||||
OAUTH_CLIENTS_VK_ID=your_vk_app_id
|
|
||||||
OAUTH_CLIENTS_VK_KEY=your_vk_secure_key
|
|
||||||
|
|
||||||
# Yandex OAuth
|
|
||||||
OAUTH_CLIENTS_YANDEX_ID=your_yandex_client_id
|
|
||||||
OAUTH_CLIENTS_YANDEX_KEY=your_yandex_client_secret
|
|
||||||
```
|
|
||||||
|
|
||||||
## Provider Setup Instructions
|
|
||||||
|
|
||||||
### Google
|
|
||||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
|
||||||
2. Create a new project or select existing
|
|
||||||
3. Enable Google+ API and OAuth 2.0
|
|
||||||
4. Create OAuth 2.0 Client ID credentials
|
|
||||||
5. Add your callback URLs: `https://yourdomain.com/oauth/google/callback`
|
|
||||||
|
|
||||||
### GitHub
|
|
||||||
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
|
|
||||||
2. Create a new OAuth App
|
|
||||||
3. Set Authorization callback URL: `https://yourdomain.com/oauth/github/callback`
|
|
||||||
|
|
||||||
### Facebook
|
|
||||||
1. Go to [Facebook Developers](https://developers.facebook.com/)
|
|
||||||
2. Create a new app
|
|
||||||
3. Add Facebook Login product
|
|
||||||
4. Configure Valid OAuth redirect URIs: `https://yourdomain.com/oauth/facebook/callback`
|
|
||||||
|
|
||||||
### X (Twitter)
|
|
||||||
1. Go to [Twitter Developer Portal](https://developer.twitter.com/)
|
|
||||||
2. Create a new app
|
|
||||||
3. Enable OAuth 2.0 authentication
|
|
||||||
4. Set Callback URLs: `https://yourdomain.com/oauth/x/callback`
|
|
||||||
5. **Note**: X doesn't provide email addresses through their API
|
|
||||||
|
|
||||||
### Telegram
|
|
||||||
1. Create a bot with [@BotFather](https://t.me/botfather)
|
|
||||||
2. Use `/newbot` command and follow instructions
|
|
||||||
3. Get your bot token
|
|
||||||
4. Configure domain settings with `/setdomain` command
|
|
||||||
5. **Note**: Telegram doesn't provide email addresses
|
|
||||||
|
|
||||||
### VK (VKontakte)
|
|
||||||
1. Go to [VK for Developers](https://vk.com/dev)
|
|
||||||
2. Create a new application
|
|
||||||
3. Set Authorized redirect URI: `https://yourdomain.com/oauth/vk/callback`
|
|
||||||
4. **Note**: Email access requires special permissions from VK
|
|
||||||
|
|
||||||
### Yandex
|
|
||||||
1. Go to [Yandex OAuth](https://oauth.yandex.com/)
|
|
||||||
2. Create a new application
|
|
||||||
3. Set Callback URI: `https://yourdomain.com/oauth/yandex/callback`
|
|
||||||
4. Select required permissions: `login:email login:info`
|
|
||||||
|
|
||||||
## Email Handling
|
|
||||||
|
|
||||||
Some providers (X, Telegram) don't provide email addresses. In these cases:
|
|
||||||
- A temporary email is generated: `{provider}_{user_id}@oauth.local`
|
|
||||||
- Users can update their email in profile settings later
|
|
||||||
- `email_verified` is set to `false` for generated emails
|
|
||||||
|
|
||||||
## Usage in Frontend
|
|
||||||
|
|
||||||
OAuth URLs:
|
|
||||||
```
|
|
||||||
/oauth/google
|
|
||||||
/oauth/github
|
|
||||||
/oauth/facebook
|
|
||||||
/oauth/x
|
|
||||||
/oauth/telegram
|
|
||||||
/oauth/vk
|
|
||||||
/oauth/yandex
|
|
||||||
```
|
|
||||||
|
|
||||||
Each provider accepts a `state` parameter for CSRF protection and a `redirect_uri` for post-authentication redirects.
|
|
||||||
|
|
||||||
## Security Notes
|
|
||||||
|
|
||||||
- All OAuth flows use PKCE (Proof Key for Code Exchange) for additional security
|
|
||||||
- State parameters are stored in Redis with 10-minute TTL
|
|
||||||
- OAuth sessions are one-time use only
|
|
||||||
- Failed authentications are logged for monitoring
|
|
||||||
329
docs/oauth.md
329
docs/oauth.md
@@ -1,329 +0,0 @@
|
|||||||
# OAuth Token Management
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Система управления OAuth токенами с использованием Redis для безопасного и производительного хранения токенов доступа и обновления от различных провайдеров.
|
|
||||||
|
|
||||||
## Архитектура
|
|
||||||
|
|
||||||
### Redis Storage
|
|
||||||
OAuth токены хранятся в Redis с автоматическим истечением (TTL):
|
|
||||||
- `oauth_access:{user_id}:{provider}` - access tokens
|
|
||||||
- `oauth_refresh:{user_id}:{provider}` - refresh tokens
|
|
||||||
|
|
||||||
### Поддерживаемые провайдеры
|
|
||||||
- Google OAuth 2.0
|
|
||||||
- Facebook Login
|
|
||||||
- GitHub OAuth
|
|
||||||
|
|
||||||
## API Documentation
|
|
||||||
|
|
||||||
### OAuthTokenStorage Class
|
|
||||||
|
|
||||||
#### store_access_token()
|
|
||||||
Сохраняет access token в Redis с автоматическим TTL.
|
|
||||||
|
|
||||||
```python
|
|
||||||
await OAuthTokenStorage.store_access_token(
|
|
||||||
user_id=123,
|
|
||||||
provider="google",
|
|
||||||
access_token="ya29.a0AfH6SM...",
|
|
||||||
expires_in=3600,
|
|
||||||
additional_data={"scope": "profile email"}
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### store_refresh_token()
|
|
||||||
Сохраняет refresh token с длительным TTL (30 дней по умолчанию).
|
|
||||||
|
|
||||||
```python
|
|
||||||
await OAuthTokenStorage.store_refresh_token(
|
|
||||||
user_id=123,
|
|
||||||
provider="google",
|
|
||||||
refresh_token="1//04...",
|
|
||||||
ttl=2592000 # 30 дней
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### get_access_token()
|
|
||||||
Получает действующий access token из Redis.
|
|
||||||
|
|
||||||
```python
|
|
||||||
token_data = await OAuthTokenStorage.get_access_token(123, "google")
|
|
||||||
if token_data:
|
|
||||||
access_token = token_data["token"]
|
|
||||||
expires_in = token_data["expires_in"]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### refresh_access_token()
|
|
||||||
Обновляет access token (и опционально refresh token).
|
|
||||||
|
|
||||||
```python
|
|
||||||
success = await OAuthTokenStorage.refresh_access_token(
|
|
||||||
user_id=123,
|
|
||||||
provider="google",
|
|
||||||
new_access_token="ya29.new_token...",
|
|
||||||
expires_in=3600,
|
|
||||||
new_refresh_token="1//04new..." # опционально
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### delete_tokens()
|
|
||||||
Удаляет все токены пользователя для провайдера.
|
|
||||||
|
|
||||||
```python
|
|
||||||
await OAuthTokenStorage.delete_tokens(123, "google")
|
|
||||||
```
|
|
||||||
|
|
||||||
#### get_user_providers()
|
|
||||||
Получает список OAuth провайдеров для пользователя.
|
|
||||||
|
|
||||||
```python
|
|
||||||
providers = await OAuthTokenStorage.get_user_providers(123)
|
|
||||||
# ["google", "github"]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### extend_token_ttl()
|
|
||||||
Продлевает срок действия токена.
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Продлить access token на 30 минут
|
|
||||||
success = await OAuthTokenStorage.extend_token_ttl(123, "google", "access", 1800)
|
|
||||||
|
|
||||||
# Продлить refresh token на 7 дней
|
|
||||||
success = await OAuthTokenStorage.extend_token_ttl(123, "google", "refresh", 604800)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### get_token_info()
|
|
||||||
Получает подробную информацию о токенах включая TTL.
|
|
||||||
|
|
||||||
```python
|
|
||||||
info = await OAuthTokenStorage.get_token_info(123, "google")
|
|
||||||
# {
|
|
||||||
# "user_id": 123,
|
|
||||||
# "provider": "google",
|
|
||||||
# "access_token": {"exists": True, "ttl": 3245},
|
|
||||||
# "refresh_token": {"exists": True, "ttl": 2589600}
|
|
||||||
# }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Structures
|
|
||||||
|
|
||||||
### Access Token Structure
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"token": "ya29.a0AfH6SM...",
|
|
||||||
"provider": "google",
|
|
||||||
"user_id": 123,
|
|
||||||
"created_at": 1640995200,
|
|
||||||
"expires_in": 3600,
|
|
||||||
"scope": "profile email",
|
|
||||||
"token_type": "Bearer"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Refresh Token Structure
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"token": "1//04...",
|
|
||||||
"provider": "google",
|
|
||||||
"user_id": 123,
|
|
||||||
"created_at": 1640995200
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
### Token Expiration
|
|
||||||
- **Access tokens**: TTL основан на `expires_in` от провайдера (обычно 1 час)
|
|
||||||
- **Refresh tokens**: TTL 30 дней по умолчанию
|
|
||||||
- **Автоматическая очистка**: Redis автоматически удаляет истекшие токены
|
|
||||||
- **Внутренняя система истечения**: Использует SET + EXPIRE для точного контроля TTL
|
|
||||||
|
|
||||||
### Redis Expiration Benefits
|
|
||||||
- **Гибкость**: Можно изменять TTL существующих токенов через EXPIRE
|
|
||||||
- **Мониторинг**: Команда TTL показывает оставшееся время жизни токена
|
|
||||||
- **Расширение**: Возможность продления срока действия токенов без перезаписи
|
|
||||||
- **Атомарность**: Separate SET/EXPIRE operations для лучшего контроля
|
|
||||||
|
|
||||||
### Access Control
|
|
||||||
- Токены доступны только владельцу аккаунта
|
|
||||||
- Нет доступа к токенам через GraphQL API
|
|
||||||
- Токены не хранятся в основной базе данных
|
|
||||||
|
|
||||||
### Provider Isolation
|
|
||||||
- Токены разных провайдеров хранятся отдельно
|
|
||||||
- Удаление токенов одного провайдера не влияет на другие
|
|
||||||
- Поддержка множественных OAuth подключений
|
|
||||||
|
|
||||||
## Integration Examples
|
|
||||||
|
|
||||||
### OAuth Login Flow
|
|
||||||
```python
|
|
||||||
# После успешной авторизации через OAuth провайдера
|
|
||||||
async def handle_oauth_callback(user_id: int, provider: str, tokens: dict):
|
|
||||||
# Сохраняем токены в Redis
|
|
||||||
await OAuthTokenStorage.store_access_token(
|
|
||||||
user_id=user_id,
|
|
||||||
provider=provider,
|
|
||||||
access_token=tokens["access_token"],
|
|
||||||
expires_in=tokens.get("expires_in", 3600)
|
|
||||||
)
|
|
||||||
|
|
||||||
if "refresh_token" in tokens:
|
|
||||||
await OAuthTokenStorage.store_refresh_token(
|
|
||||||
user_id=user_id,
|
|
||||||
provider=provider,
|
|
||||||
refresh_token=tokens["refresh_token"]
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Token Refresh
|
|
||||||
```python
|
|
||||||
async def refresh_oauth_token(user_id: int, provider: str):
|
|
||||||
# Получаем refresh token
|
|
||||||
refresh_data = await OAuthTokenStorage.get_refresh_token(user_id, provider)
|
|
||||||
if not refresh_data:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Обмениваем refresh token на новый access token
|
|
||||||
new_tokens = await exchange_refresh_token(
|
|
||||||
provider, refresh_data["token"]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Сохраняем новые токены
|
|
||||||
return await OAuthTokenStorage.refresh_access_token(
|
|
||||||
user_id=user_id,
|
|
||||||
provider=provider,
|
|
||||||
new_access_token=new_tokens["access_token"],
|
|
||||||
expires_in=new_tokens.get("expires_in"),
|
|
||||||
new_refresh_token=new_tokens.get("refresh_token")
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Integration
|
|
||||||
```python
|
|
||||||
async def make_oauth_request(user_id: int, provider: str, endpoint: str):
|
|
||||||
# Получаем действующий access token
|
|
||||||
token_data = await OAuthTokenStorage.get_access_token(user_id, provider)
|
|
||||||
|
|
||||||
if not token_data:
|
|
||||||
# Токен отсутствует, требуется повторная авторизация
|
|
||||||
raise OAuthTokenMissing()
|
|
||||||
|
|
||||||
# Делаем запрос к API провайдера
|
|
||||||
headers = {"Authorization": f"Bearer {token_data['token']}"}
|
|
||||||
response = await httpx.get(endpoint, headers=headers)
|
|
||||||
|
|
||||||
if response.status_code == 401:
|
|
||||||
# Токен истек, пытаемся обновить
|
|
||||||
if await refresh_oauth_token(user_id, provider):
|
|
||||||
# Повторяем запрос с новым токеном
|
|
||||||
token_data = await OAuthTokenStorage.get_access_token(user_id, provider)
|
|
||||||
headers = {"Authorization": f"Bearer {token_data['token']}"}
|
|
||||||
response = await httpx.get(endpoint, headers=headers)
|
|
||||||
|
|
||||||
return response.json()
|
|
||||||
```
|
|
||||||
|
|
||||||
### TTL Monitoring and Management
|
|
||||||
```python
|
|
||||||
async def monitor_token_expiration(user_id: int, provider: str):
|
|
||||||
"""Мониторинг и управление сроком действия токенов"""
|
|
||||||
|
|
||||||
# Получаем информацию о токенах
|
|
||||||
info = await OAuthTokenStorage.get_token_info(user_id, provider)
|
|
||||||
|
|
||||||
# Проверяем access token
|
|
||||||
if info["access_token"]["exists"]:
|
|
||||||
ttl = info["access_token"]["ttl"]
|
|
||||||
if ttl < 300: # Меньше 5 минут
|
|
||||||
logger.warning(f"Access token expires soon: {ttl}s")
|
|
||||||
# Автоматически обновляем токен
|
|
||||||
await refresh_oauth_token(user_id, provider)
|
|
||||||
|
|
||||||
# Проверяем refresh token
|
|
||||||
if info["refresh_token"]["exists"]:
|
|
||||||
ttl = info["refresh_token"]["ttl"]
|
|
||||||
if ttl < 86400: # Меньше 1 дня
|
|
||||||
logger.warning(f"Refresh token expires soon: {ttl}s")
|
|
||||||
# Уведомляем пользователя о необходимости повторной авторизации
|
|
||||||
|
|
||||||
async def extend_session_if_active(user_id: int, provider: str):
|
|
||||||
"""Продлевает сессию для активных пользователей"""
|
|
||||||
|
|
||||||
# Проверяем активность пользователя
|
|
||||||
if await is_user_active(user_id):
|
|
||||||
# Продлеваем access token на 1 час
|
|
||||||
success = await OAuthTokenStorage.extend_token_ttl(
|
|
||||||
user_id, provider, "access", 3600
|
|
||||||
)
|
|
||||||
if success:
|
|
||||||
logger.info(f"Extended access token for active user {user_id}")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Migration from Database
|
|
||||||
|
|
||||||
Если у вас уже есть OAuth токены в базе данных, используйте этот скрипт для миграции:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def migrate_oauth_tokens():
|
|
||||||
"""Миграция OAuth токенов из БД в Redis"""
|
|
||||||
with local_session() as session:
|
|
||||||
# Предполагая, что токены хранились в таблице authors
|
|
||||||
authors = session.query(Author).filter(
|
|
||||||
or_(
|
|
||||||
Author.provider_access_token.is_not(None),
|
|
||||||
Author.provider_refresh_token.is_not(None)
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
|
|
||||||
for author in authors:
|
|
||||||
# Получаем провайдер из oauth вместо старого поля oauth
|
|
||||||
if author.oauth:
|
|
||||||
for provider in author.oauth.keys():
|
|
||||||
if author.provider_access_token:
|
|
||||||
await OAuthTokenStorage.store_access_token(
|
|
||||||
user_id=author.id,
|
|
||||||
provider=provider,
|
|
||||||
access_token=author.provider_access_token
|
|
||||||
)
|
|
||||||
|
|
||||||
if author.provider_refresh_token:
|
|
||||||
await OAuthTokenStorage.store_refresh_token(
|
|
||||||
user_id=author.id,
|
|
||||||
provider=provider,
|
|
||||||
refresh_token=author.provider_refresh_token
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"Migrated OAuth tokens for {len(authors)} authors")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Benefits
|
|
||||||
|
|
||||||
### Redis Advantages
|
|
||||||
- **Скорость**: Доступ к токенам за микросекунды
|
|
||||||
- **Масштабируемость**: Не нагружает основную БД
|
|
||||||
- **Автоматическая очистка**: TTL убирает истекшие токены
|
|
||||||
- **Память**: Эффективное использование памяти Redis
|
|
||||||
|
|
||||||
### Reduced Database Load
|
|
||||||
- OAuth токены больше не записываются в основную БД
|
|
||||||
- Уменьшено количество записей в таблице authors
|
|
||||||
- Faster user queries без JOIN к токенам
|
|
||||||
|
|
||||||
## Monitoring and Maintenance
|
|
||||||
|
|
||||||
### Redis Memory Usage
|
|
||||||
```bash
|
|
||||||
# Проверка использования памяти OAuth токенами
|
|
||||||
redis-cli --scan --pattern "oauth_*" | wc -l
|
|
||||||
redis-cli memory usage oauth_access:123:google
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cleanup Statistics
|
|
||||||
```python
|
|
||||||
# Периодическая очистка и логирование (опционально)
|
|
||||||
async def oauth_cleanup_job():
|
|
||||||
cleaned = await OAuthTokenStorage.cleanup_expired_tokens()
|
|
||||||
logger.info(f"OAuth cleanup completed, {cleaned} tokens processed")
|
|
||||||
```
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# Rating System
|
|
||||||
|
|
||||||
## GraphQL Resolvers
|
|
||||||
|
|
||||||
### Queries
|
|
||||||
|
|
||||||
#### get_my_rates_shouts
|
|
||||||
Get user's reactions (LIKE/DISLIKE) for specified posts.
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `shouts: [Int!]!` - array of shout IDs
|
|
||||||
|
|
||||||
**Returns:**
|
|
||||||
```typescript
|
|
||||||
[{
|
|
||||||
shout_id: Int
|
|
||||||
my_rate: ReactionKind // LIKE or DISLIKE
|
|
||||||
}]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### get_my_rates_comments
|
|
||||||
Get user's reactions (LIKE/DISLIKE) for specified comments.
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `comments: [Int!]!` - array of comment IDs
|
|
||||||
|
|
||||||
**Returns:**
|
|
||||||
```typescript
|
|
||||||
[{
|
|
||||||
comment_id: Int
|
|
||||||
my_rate: ReactionKind // LIKE or DISLIKE
|
|
||||||
}]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mutations
|
|
||||||
|
|
||||||
#### rate_author
|
|
||||||
Rate another author (karma system).
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `rated_slug: String!` - author's slug
|
|
||||||
- `value: Int!` - rating value (positive/negative)
|
|
||||||
|
|
||||||
## Rating Calculation
|
|
||||||
|
|
||||||
### Author Rating Components
|
|
||||||
|
|
||||||
#### Shouts Rating
|
|
||||||
- Calculated from LIKE/DISLIKE reactions on author's posts
|
|
||||||
- Each LIKE: +1
|
|
||||||
- Each DISLIKE: -1
|
|
||||||
- Excludes deleted reactions
|
|
||||||
- Excludes comment reactions
|
|
||||||
|
|
||||||
#### Comments Rating
|
|
||||||
- Calculated from LIKE/DISLIKE reactions on author's comments
|
|
||||||
- Each LIKE: +1
|
|
||||||
- Each DISLIKE: -1
|
|
||||||
- Only counts reactions to COMMENT type reactions
|
|
||||||
- Excludes deleted reactions
|
|
||||||
|
|
||||||
#### Legacy Karma
|
|
||||||
- Based on direct author ratings via `rate_author` mutation
|
|
||||||
- Stored in `AuthorRating` table
|
|
||||||
- Each positive rating: +1
|
|
||||||
- Each negative rating: -1
|
|
||||||
|
|
||||||
### Helper Functions
|
|
||||||
|
|
||||||
- `count_author_comments_rating()` - Calculate comment rating
|
|
||||||
- `count_author_shouts_rating()` - Calculate posts rating
|
|
||||||
- `get_author_rating_old()` - Get legacy karma rating
|
|
||||||
- `get_author_rating_shouts()` - Get posts rating (optimized)
|
|
||||||
- `get_author_rating_comments()` - Get comments rating (optimized)
|
|
||||||
- `add_author_rating_columns()` - Add rating columns to author query
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- All ratings exclude deleted content
|
|
||||||
- Reactions are unique per user/content
|
|
||||||
- Rating calculations are optimized with SQLAlchemy
|
|
||||||
- System supports both direct author rating and content-based rating
|
|
||||||
@@ -1,477 +0,0 @@
|
|||||||
# Система ролей и разрешений (RBAC)
|
|
||||||
|
|
||||||
## Общее описание
|
|
||||||
|
|
||||||
Система управления доступом на основе ролей (Role-Based Access Control, RBAC) обеспечивает гибкое управление правами пользователей в рамках сообществ платформы.
|
|
||||||
|
|
||||||
## Архитектура системы
|
|
||||||
|
|
||||||
### Основные компоненты
|
|
||||||
|
|
||||||
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`
|
|
||||||
- Управление пользователями
|
|
||||||
- Управление ролями
|
|
||||||
- Настройка сообщества
|
|
||||||
- Полный доступ к административной панели
|
|
||||||
|
|
||||||
### Иерархия ролей
|
|
||||||
|
|
||||||
```
|
|
||||||
admin > editor > expert > artist/author > reader
|
|
||||||
```
|
|
||||||
|
|
||||||
Каждая роль автоматически включает права всех ролей ниже по иерархии.
|
|
||||||
|
|
||||||
## Разрешения (Permissions)
|
|
||||||
|
|
||||||
### Формат разрешений
|
|
||||||
|
|
||||||
Разрешения записываются в формате `resource:action`:
|
|
||||||
|
|
||||||
- `shout:create` - создание публикаций
|
|
||||||
- `shout:edit` - редактирование публикаций
|
|
||||||
- `shout:delete` - удаление публикаций
|
|
||||||
- `comment:create` - создание комментариев
|
|
||||||
- `comment:moderate` - модерация комментариев
|
|
||||||
- `user:manage` - управление пользователями
|
|
||||||
- `community:settings` - настройки сообщества
|
|
||||||
|
|
||||||
### Категории разрешений
|
|
||||||
|
|
||||||
#### Контент (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
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Миграция из старой системы
|
|
||||||
|
|
||||||
```python
|
|
||||||
def migrate_old_roles_to_community_author():
|
|
||||||
"""Переносит роли из старой системы в CommunityAuthor"""
|
|
||||||
|
|
||||||
# Получаем все старые роли из Author.roles
|
|
||||||
old_roles = session.query(AuthorRole).all()
|
|
||||||
|
|
||||||
for role in old_roles:
|
|
||||||
# Создаем запись CommunityAuthor
|
|
||||||
ca = CommunityAuthor(
|
|
||||||
community_id=role.community,
|
|
||||||
author_id=role.author,
|
|
||||||
roles=role.role
|
|
||||||
)
|
|
||||||
session.add(ca)
|
|
||||||
|
|
||||||
session.commit()
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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')
|
|
||||||
```
|
|
||||||
@@ -1,378 +0,0 @@
|
|||||||
# Миграция с React 18 на SolidStart: Comprehensive Guide
|
|
||||||
|
|
||||||
## 1. Введение
|
|
||||||
|
|
||||||
### 1.1 Что такое SolidStart?
|
|
||||||
|
|
||||||
SolidStart - это метафреймворк для SolidJS, который предоставляет полнофункциональное решение для создания веб-приложений. Ключевые особенности:
|
|
||||||
|
|
||||||
- Полностью изоморфное приложение (работает на клиенте и сервере)
|
|
||||||
- Встроенная поддержка SSR, SSG и CSR
|
|
||||||
- Интеграция с Vite и Nitro
|
|
||||||
- Гибкая маршрутизация
|
|
||||||
- Встроенные серверные функции и действия
|
|
||||||
|
|
||||||
### 1.2 Основные различия между React и SolidStart
|
|
||||||
|
|
||||||
| Характеристика | React 18 | SolidStart |
|
|
||||||
|---------------|----------|------------|
|
|
||||||
| Рендеринг | Virtual DOM | Компиляция и прямое обновление DOM |
|
|
||||||
| Серверный рендеринг | Сложная настройка | Встроенная поддержка |
|
|
||||||
| Размер бандла | ~40 кБ | ~7.7 кБ |
|
|
||||||
| Реактивность | Хуки с зависимостями | Сигналы без явных зависимостей |
|
|
||||||
| Маршрутизация | react-router | @solidjs/router |
|
|
||||||
|
|
||||||
## 2. Подготовка проекта
|
|
||||||
|
|
||||||
### 2.1 Установка зависимостей
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Удаление React зависимостей
|
|
||||||
npm uninstall react react-dom react-router-dom
|
|
||||||
|
|
||||||
# Установка SolidStart и связанных библиотек
|
|
||||||
npm install @solidjs/start solid-js @solidjs/router
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 Обновление конфигурации
|
|
||||||
|
|
||||||
#### Vite Configuration (`vite.config.ts`)
|
|
||||||
```typescript
|
|
||||||
import { defineConfig } from 'vite';
|
|
||||||
import solid from 'solid-start/vite';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [solid()],
|
|
||||||
// Дополнительные настройки
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### TypeScript Configuration (`tsconfig.json`)
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"jsx": "preserve",
|
|
||||||
"jsxImportSource": "solid-js",
|
|
||||||
"types": ["solid-start/env"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### SolidStart Configuration (`app.config.ts`)
|
|
||||||
```typescript
|
|
||||||
import { defineConfig } from "@solidjs/start/config";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
server: {
|
|
||||||
// Настройки сервера, например:
|
|
||||||
preset: "netlify" // или другой провайдер
|
|
||||||
},
|
|
||||||
// Дополнительные настройки
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Миграция компонентов и логики
|
|
||||||
|
|
||||||
### 3.1 Состояние и реактивность
|
|
||||||
|
|
||||||
#### React:
|
|
||||||
```typescript
|
|
||||||
const [count, setCount] = useState(0);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### SolidJS:
|
|
||||||
```typescript
|
|
||||||
const [count, setCount] = createSignal(0);
|
|
||||||
// Использование: count(), setCount(newValue)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 Серверные функции и загрузка данных
|
|
||||||
|
|
||||||
В SolidStart есть несколько способов работы с данными:
|
|
||||||
|
|
||||||
#### Серверная функция
|
|
||||||
```typescript
|
|
||||||
// server/api.ts
|
|
||||||
export function getUser(id: string) {
|
|
||||||
return db.users.findUnique({ where: { id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Component
|
|
||||||
export default function UserProfile() {
|
|
||||||
const user = createAsync(() => getUser(params.id));
|
|
||||||
|
|
||||||
return <div>{user()?.name}</div>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Действия (Actions)
|
|
||||||
```typescript
|
|
||||||
export function updateProfile(formData: FormData) {
|
|
||||||
'use server';
|
|
||||||
const name = formData.get('name');
|
|
||||||
// Логика обновления профиля
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 Маршрутизация
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/routes/index.tsx
|
|
||||||
import { A } from "@solidjs/router";
|
|
||||||
|
|
||||||
export default function HomePage() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<A href="/about">О нас</A>
|
|
||||||
<A href="/profile">Профиль</A>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/routes/profile.tsx
|
|
||||||
export default function ProfilePage() {
|
|
||||||
return <div>Профиль пользователя</div>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Оптимизация и производительность
|
|
||||||
|
|
||||||
### 4.1 Мемоизация
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Кэширование сложных вычислений
|
|
||||||
const sortedUsers = createMemo(() =>
|
|
||||||
users().sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ленивая загрузка
|
|
||||||
const UserList = lazy(() => import('./UserList'));
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Серверный рендеринг и предзагрузка
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Предзагрузка данных
|
|
||||||
export function routeData() {
|
|
||||||
return {
|
|
||||||
user: createAsync(() => fetchUser())
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UserPage() {
|
|
||||||
const user = useRouteData<typeof routeData>();
|
|
||||||
return <div>{user().name}</div>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Особенности миграции
|
|
||||||
|
|
||||||
### 5.1 Ключевые изменения
|
|
||||||
- Замена `useState` на `createSignal`
|
|
||||||
- Использование `createAsync` вместо `useEffect` для загрузки данных
|
|
||||||
- Серверные функции с `'use server'`
|
|
||||||
- Маршрутизация через `@solidjs/router`
|
|
||||||
|
|
||||||
### 5.2 Потенциальные проблемы
|
|
||||||
- Переписать все React-специфичные хуки
|
|
||||||
- Адаптировать библиотеки компонентов
|
|
||||||
- Обновить тесты и CI/CD
|
|
||||||
|
|
||||||
## 6. Деплой
|
|
||||||
|
|
||||||
SolidStart поддерживает множество платформ:
|
|
||||||
- Netlify
|
|
||||||
- Vercel
|
|
||||||
- Cloudflare
|
|
||||||
- AWS
|
|
||||||
- Deno
|
|
||||||
- и другие
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// app.config.ts
|
|
||||||
export default defineConfig({
|
|
||||||
server: {
|
|
||||||
preset: "netlify" // Выберите вашу платформу
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. Инструменты и экосистема
|
|
||||||
|
|
||||||
### Рекомендованные библиотеки
|
|
||||||
- Роутинг: `@solidjs/router`
|
|
||||||
- Состояние: Встроенные примитивы SolidJS
|
|
||||||
- Запросы: `@tanstack/solid-query`
|
|
||||||
- Девтулзы: `solid-devtools`
|
|
||||||
|
|
||||||
## 8. Миграция конкретных компонентов
|
|
||||||
|
|
||||||
### 8.1 Страница регистрации (RegisterPage)
|
|
||||||
|
|
||||||
#### React-версия
|
|
||||||
```typescript
|
|
||||||
import React from 'react'
|
|
||||||
import { Navigate } from 'react-router-dom'
|
|
||||||
import { RegisterForm } from '../components/auth/RegisterForm'
|
|
||||||
import { useAuthStore } from '../store/authStore'
|
|
||||||
|
|
||||||
export const RegisterPage: React.FC = () => {
|
|
||||||
const { isAuthenticated } = useAuthStore()
|
|
||||||
|
|
||||||
if (isAuthenticated) {
|
|
||||||
return <Navigate to="/" replace />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen ...">
|
|
||||||
<RegisterForm />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### SolidJS-версия
|
|
||||||
```typescript
|
|
||||||
import { Navigate } from '@solidjs/router'
|
|
||||||
import { Show } from 'solid-js'
|
|
||||||
import { RegisterForm } from '../components/auth/RegisterForm'
|
|
||||||
import { useAuthStore } from '../store/authStore'
|
|
||||||
|
|
||||||
export default function RegisterPage() {
|
|
||||||
const { isAuthenticated } = useAuthStore()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={!isAuthenticated()} fallback={<Navigate href="/" />}>
|
|
||||||
<div class="min-h-screen ...">
|
|
||||||
<RegisterForm />
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Ключевые изменения
|
|
||||||
- Удаление импорта React
|
|
||||||
- Использование `@solidjs/router` вместо `react-router-dom`
|
|
||||||
- Замена `className` на `class`
|
|
||||||
- Использование `Show` для условного рендеринга
|
|
||||||
- Вызов `isAuthenticated()` как функции
|
|
||||||
- Использование `href` вместо `to`
|
|
||||||
- Экспорт по умолчанию вместо именованного экспорта
|
|
||||||
|
|
||||||
### Рекомендации
|
|
||||||
- Всегда используйте `Show` для условного рендеринга
|
|
||||||
- Помните, что сигналы в SolidJS - это функции
|
|
||||||
- Следите за совместимостью импортов и маршрутизации
|
|
||||||
|
|
||||||
## 9. UI Component Migration
|
|
||||||
|
|
||||||
### 9.1 Key Differences in Component Structure
|
|
||||||
|
|
||||||
When migrating UI components from React to SolidJS, several key changes are necessary:
|
|
||||||
|
|
||||||
1. **Props Handling**
|
|
||||||
- Replace `React.FC<Props>` with function component syntax
|
|
||||||
- Use object destructuring for props instead of individual parameters
|
|
||||||
- Replace `className` with `class`
|
|
||||||
- Use `props.children` instead of `children` prop
|
|
||||||
|
|
||||||
2. **Type Annotations**
|
|
||||||
- Use TypeScript interfaces for props
|
|
||||||
- Explicitly type `children` as `any` or a more specific type
|
|
||||||
- Remove React-specific type imports
|
|
||||||
|
|
||||||
3. **Event Handling**
|
|
||||||
- Use SolidJS event types (e.g., `InputEvent`)
|
|
||||||
- Modify event handler signatures to match SolidJS conventions
|
|
||||||
|
|
||||||
### 9.2 Component Migration Example
|
|
||||||
|
|
||||||
#### React Component
|
|
||||||
```typescript
|
|
||||||
import React from 'react'
|
|
||||||
import { clsx } from 'clsx'
|
|
||||||
|
|
||||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
||||||
variant?: 'primary' | 'secondary'
|
|
||||||
fullWidth?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Button: React.FC<ButtonProps> = ({
|
|
||||||
variant = 'primary',
|
|
||||||
fullWidth = false,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const classes = clsx(
|
|
||||||
'button',
|
|
||||||
variant === 'primary' && 'bg-blue-500',
|
|
||||||
fullWidth && 'w-full',
|
|
||||||
className
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button className={classes} {...props}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### SolidJS Component
|
|
||||||
```typescript
|
|
||||||
import { clsx } from 'clsx'
|
|
||||||
|
|
||||||
interface ButtonProps {
|
|
||||||
variant?: 'primary' | 'secondary'
|
|
||||||
fullWidth?: boolean
|
|
||||||
class?: string
|
|
||||||
children: any
|
|
||||||
disabled?: boolean
|
|
||||||
type?: 'button' | 'submit'
|
|
||||||
onClick?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Button = (props: ButtonProps) => {
|
|
||||||
const classes = clsx(
|
|
||||||
'button',
|
|
||||||
props.variant === 'primary' && 'bg-blue-500',
|
|
||||||
props.fullWidth && 'w-full',
|
|
||||||
props.class
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
class={classes}
|
|
||||||
disabled={props.disabled}
|
|
||||||
type={props.type || 'button'}
|
|
||||||
onClick={props.onClick}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9.3 Key Migration Strategies
|
|
||||||
|
|
||||||
- Replace `React.FC` with standard function components
|
|
||||||
- Use `props` object instead of individual parameters
|
|
||||||
- Replace `className` with `class`
|
|
||||||
- Modify event handling to match SolidJS patterns
|
|
||||||
- Remove React-specific lifecycle methods
|
|
||||||
- Use SolidJS primitives like `createEffect` for side effects
|
|
||||||
|
|
||||||
## Заключение
|
|
||||||
|
|
||||||
Миграция на SolidStart требует внимательного подхода, но предоставляет значительные преимущества в производительности, простоте разработки и серверных возможностях.
|
|
||||||
|
|
||||||
### Рекомендации
|
|
||||||
- Мигрируйте постепенно
|
|
||||||
- Пишите тесты на каждом этапе
|
|
||||||
- Используйте инструменты совместимости
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Этот гайд поможет вам систематически и безопасно мигрировать ваш проект на SolidStart, сохраняя существующую функциональность и улучшая производительность.
|
|
||||||
@@ -1,434 +0,0 @@
|
|||||||
# Схема данных Redis в Discours.io
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
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 # Секретный ключ 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, OAUTH_*
|
|
||||||
- **redis**: REDIS_URL, REDIS_HOST, REDIS_PORT
|
|
||||||
- **search**: SEARCH_API_KEY, ELASTICSEARCH_URL
|
|
||||||
- **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
|
|
||||||
- Использование отдельных баз данных для разных типов данных
|
|
||||||
212
docs/security.md
212
docs/security.md
@@ -1,212 +0,0 @@
|
|||||||
# Security System
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Система безопасности обеспечивает управление паролями и email адресами пользователей через специализированные GraphQL мутации с использованием 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`
|
|
||||||
11
env.d.ts
vendored
11
env.d.ts
vendored
@@ -1,11 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
|
|
||||||
declare const __APP_VERSION__: string
|
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
|
||||||
readonly VITE_API_URL: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportMeta {
|
|
||||||
readonly env: ImportMetaEnv;
|
|
||||||
}
|
|
||||||
1
generate_gql_types.sh
Executable file
1
generate_gql_types.sh
Executable file
@@ -0,0 +1 @@
|
|||||||
|
python -m gql_schema_codegen -p ./schema.graphql -t ./schema_types.py
|
||||||
20
index.html
20
index.html
@@ -1,20 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="description" content="Admin Panel">
|
|
||||||
<title>Admin Panel</title>
|
|
||||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
|
||||||
<meta name="theme-color" content="#228be6">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/panel/index.tsx"></script>
|
|
||||||
<noscript>
|
|
||||||
<div style="text-align: center; padding: 20px;">
|
|
||||||
Для работы приложения необходим JavaScript
|
|
||||||
</div>
|
|
||||||
</noscript>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
307
main.py
307
main.py
@@ -1,279 +1,94 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
from pathlib import Path
|
from os.path import exists
|
||||||
|
|
||||||
from ariadne import load_schema_from_path, make_executable_schema
|
from ariadne import load_schema_from_path, make_executable_schema
|
||||||
from ariadne.asgi import GraphQL
|
from ariadne.asgi import GraphQL
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.middleware import Middleware
|
from starlette.middleware import Middleware
|
||||||
from starlette.middleware.cors import CORSMiddleware
|
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||||
from starlette.requests import Request
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
from starlette.responses import FileResponse, JSONResponse, Response
|
from starlette.routing import Route
|
||||||
from starlette.routing import Mount, Route
|
|
||||||
from starlette.staticfiles import StaticFiles
|
|
||||||
|
|
||||||
from auth.handler import EnhancedGraphQLHTTPHandler
|
from auth.authenticate import JWTAuthenticate
|
||||||
from auth.middleware import AuthMiddleware, auth_middleware
|
from auth.oauth import oauth_authorize, oauth_login
|
||||||
from auth.oauth import oauth_callback, oauth_login
|
from base.redis import redis
|
||||||
from cache.precache import precache_data
|
from base.resolvers import resolvers
|
||||||
from cache.revalidator import revalidation_manager
|
from orm import init_tables
|
||||||
from services.exception import ExceptionHandlerMiddleware
|
from resolvers.upload import upload_handler
|
||||||
from services.redis import redis
|
from services.main import storages_init
|
||||||
from services.schema import create_all_tables, resolvers
|
from services.notifications.notification_service import notification_service
|
||||||
from services.search import check_search_service, initialize_search_index_background, search_service
|
from services.notifications.sse import sse_subscribe_handler
|
||||||
from services.viewed import ViewedStorage
|
from services.stat.viewed import ViewedStorage
|
||||||
from settings import DEV_SERVER_PID_FILE_NAME
|
|
||||||
from utils.logger import root_logger as logger
|
|
||||||
|
|
||||||
DEVMODE = os.getenv("DOKKU_APP_TYPE", "false").lower() == "false"
|
# from services.zine.gittask import GitTask
|
||||||
DIST_DIR = Path(__file__).parent / "dist" # Директория для собранных файлов
|
from settings import DEV_SERVER_PID_FILE_NAME, SENTRY_DSN, SESSION_SECRET_KEY
|
||||||
INDEX_HTML = Path(__file__).parent / "index.html"
|
|
||||||
|
|
||||||
import_module("resolvers")
|
import_module("resolvers")
|
||||||
|
schema = make_executable_schema(load_schema_from_path("schema.graphql"), resolvers)
|
||||||
|
|
||||||
schema = make_executable_schema(load_schema_from_path("schema/"), resolvers)
|
|
||||||
|
|
||||||
# Создаем middleware с правильным порядком
|
|
||||||
middleware = [
|
middleware = [
|
||||||
# Начинаем с обработки ошибок
|
Middleware(AuthenticationMiddleware, backend=JWTAuthenticate()),
|
||||||
Middleware(ExceptionHandlerMiddleware),
|
Middleware(SessionMiddleware, secret_key=SESSION_SECRET_KEY),
|
||||||
# CORS должен быть перед другими middleware для корректной обработки preflight-запросов
|
|
||||||
Middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=[
|
|
||||||
"https://testing.discours.io",
|
|
||||||
"https://testing3.discours.io",
|
|
||||||
"https://v3.dscrs.site",
|
|
||||||
"https://session-daily.vercel.app",
|
|
||||||
"https://coretest.discours.io",
|
|
||||||
"https://new.discours.io",
|
|
||||||
],
|
|
||||||
allow_methods=["GET", "POST", "OPTIONS"], # Явно указываем OPTIONS
|
|
||||||
allow_headers=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
),
|
|
||||||
# Аутентификация должна быть после CORS
|
|
||||||
Middleware(AuthMiddleware),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Создаем экземпляр GraphQL с улучшенным обработчиком
|
|
||||||
graphql_app = GraphQL(schema, debug=DEVMODE, http_handler=EnhancedGraphQLHTTPHandler())
|
|
||||||
|
|
||||||
|
async def start_up():
|
||||||
# Оборачиваем GraphQL-обработчик для лучшей обработки ошибок
|
init_tables()
|
||||||
async def graphql_handler(request: Request) -> Response:
|
await redis.connect()
|
||||||
"""
|
await storages_init()
|
||||||
Обработчик GraphQL запросов с поддержкой middleware и обработкой ошибок.
|
views_stat_task = asyncio.create_task(ViewedStorage().worker())
|
||||||
|
print(views_stat_task)
|
||||||
Выполняет:
|
# git_task = asyncio.create_task(GitTask.git_task_worker())
|
||||||
1. Проверку метода запроса (GET, POST, OPTIONS)
|
# print(git_task)
|
||||||
2. Обработку GraphQL запроса через ariadne
|
notification_service_task = asyncio.create_task(notification_service.worker())
|
||||||
3. Применение middleware для корректной обработки cookie и авторизации
|
print(notification_service_task)
|
||||||
4. Обработку исключений и формирование ответа
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: Starlette Request объект
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response: объект ответа (обычно JSONResponse)
|
|
||||||
"""
|
|
||||||
if request.method not in ["GET", "POST", "OPTIONS"]:
|
|
||||||
return JSONResponse({"error": "Method Not Allowed by main.py"}, status_code=405)
|
|
||||||
|
|
||||||
# Проверяем, что все необходимые middleware корректно отработали
|
|
||||||
if not hasattr(request, "scope") or "auth" not in request.scope:
|
|
||||||
logger.warning("[graphql] AuthMiddleware не обработал запрос перед GraphQL обработчиком")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Обрабатываем запрос через GraphQL приложение
|
import sentry_sdk
|
||||||
result = await graphql_app.handle_request(request)
|
|
||||||
|
|
||||||
# Применяем middleware для установки cookie
|
sentry_sdk.init(SENTRY_DSN)
|
||||||
# Используем метод process_result из auth_middleware для корректной обработки
|
|
||||||
# cookie на основе результатов операций login/logout
|
|
||||||
return await auth_middleware.process_result(request, result)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
return JSONResponse({"error": "Request cancelled"}, status_code=499)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"GraphQL error: {e!s}")
|
print("[sentry] init error")
|
||||||
# Логируем более подробную информацию для отладки
|
print(e)
|
||||||
import traceback
|
|
||||||
|
|
||||||
logger.debug(f"GraphQL error traceback: {traceback.format_exc()}")
|
|
||||||
return JSONResponse({"error": str(e)}, status_code=500)
|
|
||||||
|
|
||||||
|
|
||||||
async def spa_handler(request: Request) -> Response:
|
async def dev_start_up():
|
||||||
"""
|
if exists(DEV_SERVER_PID_FILE_NAME):
|
||||||
Обработчик для SPA (Single Page Application) fallback.
|
await redis.connect()
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
with open(DEV_SERVER_PID_FILE_NAME, "w", encoding="utf-8") as f:
|
||||||
|
f.write(str(os.getpid()))
|
||||||
|
|
||||||
Возвращает index.html для всех маршрутов, которые не найдены,
|
await start_up()
|
||||||
чтобы клиентский роутер (SolidJS) мог обработать маршрутинг.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: Starlette Request объект
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FileResponse: ответ с содержимым index.html
|
|
||||||
"""
|
|
||||||
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 shutdown() -> None:
|
async def shutdown():
|
||||||
"""Остановка сервера и освобождение ресурсов"""
|
|
||||||
logger.info("Остановка сервера")
|
|
||||||
|
|
||||||
# Закрываем соединение с Redis
|
|
||||||
await redis.disconnect()
|
await redis.disconnect()
|
||||||
|
|
||||||
# Останавливаем поисковый сервис
|
|
||||||
await search_service.close()
|
|
||||||
|
|
||||||
# Удаляем PID-файл, если он существует
|
routes = [
|
||||||
from settings import DEV_SERVER_PID_FILE_NAME
|
Route("/oauth/{provider}", endpoint=oauth_login),
|
||||||
|
Route("/oauth-authorize", endpoint=oauth_authorize),
|
||||||
|
Route("/upload", endpoint=upload_handler, methods=["POST"]),
|
||||||
|
Route("/subscribe/{user_id}", endpoint=sse_subscribe_handler),
|
||||||
|
]
|
||||||
|
|
||||||
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}")
|
|
||||||
|
|
||||||
|
|
||||||
# Глобальная переменная для background tasks
|
|
||||||
background_tasks = []
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: Starlette):
|
|
||||||
"""
|
|
||||||
Функция жизненного цикла приложения.
|
|
||||||
|
|
||||||
Обеспечивает:
|
|
||||||
1. Инициализацию всех необходимых сервисов и компонентов
|
|
||||||
2. Предзагрузку кеша данных
|
|
||||||
3. Подключение к Redis и поисковому сервису
|
|
||||||
4. Корректное завершение работы при остановке сервера
|
|
||||||
|
|
||||||
Args:
|
|
||||||
app: экземпляр Starlette приложения
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
None: генератор для управления жизненным циклом
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
print("[lifespan] Starting application initialization")
|
|
||||||
create_all_tables()
|
|
||||||
await asyncio.gather(
|
|
||||||
redis.connect(),
|
|
||||||
precache_data(),
|
|
||||||
ViewedStorage.init(),
|
|
||||||
check_search_service(),
|
|
||||||
revalidation_manager.start(),
|
|
||||||
)
|
|
||||||
if DEVMODE:
|
|
||||||
await dev_start()
|
|
||||||
print("[lifespan] Basic initialization complete")
|
|
||||||
|
|
||||||
# Add a delay before starting the intensive search indexing
|
|
||||||
print("[lifespan] Waiting for system stabilization before search indexing...")
|
|
||||||
await asyncio.sleep(1) # 1-second delay to let the system stabilize
|
|
||||||
|
|
||||||
# Start search indexing as a background task with lower priority
|
|
||||||
search_task = asyncio.create_task(initialize_search_index_background())
|
|
||||||
background_tasks.append(search_task)
|
|
||||||
# Не ждем завершения задачи, позволяем ей выполняться в фоне
|
|
||||||
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
print("[lifespan] Shutting down application services")
|
|
||||||
|
|
||||||
# Отменяем все background tasks
|
|
||||||
for task in background_tasks:
|
|
||||||
if not task.done():
|
|
||||||
task.cancel()
|
|
||||||
|
|
||||||
# Ждем завершения отмены tasks
|
|
||||||
if background_tasks:
|
|
||||||
await asyncio.gather(*background_tasks, return_exceptions=True)
|
|
||||||
|
|
||||||
tasks = [redis.disconnect(), ViewedStorage.stop(), revalidation_manager.stop()]
|
|
||||||
await asyncio.gather(*tasks, return_exceptions=True)
|
|
||||||
print("[lifespan] Shutdown complete")
|
|
||||||
|
|
||||||
|
|
||||||
# Обновляем маршрут в Starlette
|
|
||||||
app = Starlette(
|
app = Starlette(
|
||||||
routes=[
|
on_startup=[start_up],
|
||||||
Route("/graphql", graphql_handler, methods=["GET", "POST", "OPTIONS"]),
|
on_shutdown=[shutdown],
|
||||||
# OAuth маршруты
|
middleware=middleware,
|
||||||
Route("/oauth/{provider}", oauth_login, methods=["GET"]),
|
routes=routes,
|
||||||
Route("/oauth/{provider}/callback", oauth_callback, methods=["GET"]),
|
|
||||||
# Статические файлы (CSS, JS, изображения)
|
|
||||||
Mount("/assets", app=StaticFiles(directory=str(DIST_DIR / "assets"))),
|
|
||||||
# Корневой маршрут для админ-панели
|
|
||||||
Route("/", spa_handler, methods=["GET"]),
|
|
||||||
# SPA fallback для всех остальных маршрутов
|
|
||||||
Route("/{path:path}", spa_handler, methods=["GET"]),
|
|
||||||
],
|
|
||||||
middleware=middleware, # Используем единый список middleware
|
|
||||||
lifespan=lifespan,
|
|
||||||
debug=True,
|
|
||||||
)
|
)
|
||||||
|
app.mount("/", GraphQL(schema))
|
||||||
|
|
||||||
if DEVMODE:
|
dev_app = Starlette(
|
||||||
# Для DEV режима регистрируем дополнительный CORS middleware только для localhost
|
debug=True,
|
||||||
app.add_middleware(
|
on_startup=[dev_start_up],
|
||||||
CORSMiddleware,
|
on_shutdown=[shutdown],
|
||||||
allow_origins=[
|
middleware=middleware,
|
||||||
"https://localhost:3000",
|
routes=routes,
|
||||||
"https://localhost:3001",
|
)
|
||||||
"https://localhost:3002",
|
dev_app.mount("/", GraphQL(schema, debug=True))
|
||||||
"http://localhost:3000",
|
|
||||||
"http://localhost:3001",
|
|
||||||
"http://localhost:3002",
|
|
||||||
],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|||||||
18
migrate.sh
Normal file
18
migrate.sh
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
database_name="discoursio"
|
||||||
|
|
||||||
|
echo "DATABASE MIGRATION STARTED"
|
||||||
|
|
||||||
|
echo "Dropping database $database_name"
|
||||||
|
dropdb $database_name --force
|
||||||
|
if [ $? -ne 0 ]; then { echo "Failed to drop database, aborting." ; exit 1; } fi
|
||||||
|
echo "Database $database_name dropped"
|
||||||
|
|
||||||
|
echo "Creating database $database_name"
|
||||||
|
createdb $database_name
|
||||||
|
if [ $? -ne 0 ]; then { echo "Failed to create database, aborting." ; exit 1; } fi
|
||||||
|
echo "Database $database_name successfully created"
|
||||||
|
|
||||||
|
echo "Start migration"
|
||||||
|
python3 server.py migrate
|
||||||
|
if [ $? -ne 0 ]; then { echo "Migration failed, aborting." ; exit 1; } fi
|
||||||
|
echo 'Done!'
|
||||||
279
migration/__init__.py
Normal file
279
migration/__init__.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
""" cmd managed migration """
|
||||||
|
import asyncio
|
||||||
|
import gc
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import bs4
|
||||||
|
|
||||||
|
from migration.export import export_mdx
|
||||||
|
from migration.tables.comments import migrate as migrateComment
|
||||||
|
from migration.tables.comments import migrate_2stage as migrateComment_2stage
|
||||||
|
from migration.tables.content_items import get_shout_slug
|
||||||
|
from migration.tables.content_items import migrate as migrateShout
|
||||||
|
|
||||||
|
# from migration.tables.remarks import migrate as migrateRemark
|
||||||
|
from migration.tables.topics import migrate as migrateTopic
|
||||||
|
from migration.tables.users import migrate as migrateUser
|
||||||
|
from migration.tables.users import migrate_2stage as migrateUser_2stage
|
||||||
|
from migration.tables.users import post_migrate as users_post_migrate
|
||||||
|
from orm import init_tables
|
||||||
|
from orm.reaction import Reaction
|
||||||
|
|
||||||
|
TODAY = datetime.strftime(datetime.now(tz=timezone.utc), "%Y%m%d")
|
||||||
|
OLD_DATE = "2016-03-05 22:22:00.350000"
|
||||||
|
|
||||||
|
|
||||||
|
async def users_handle(storage):
|
||||||
|
"""migrating users first"""
|
||||||
|
counter = 0
|
||||||
|
id_map = {}
|
||||||
|
print("[migration] migrating %d users" % (len(storage["users"]["data"])))
|
||||||
|
for entry in storage["users"]["data"]:
|
||||||
|
oid = entry["_id"]
|
||||||
|
user = migrateUser(entry)
|
||||||
|
storage["users"]["by_oid"][oid] = user # full
|
||||||
|
del user["password"]
|
||||||
|
del user["emailConfirmed"]
|
||||||
|
del user["username"]
|
||||||
|
del user["email"]
|
||||||
|
storage["users"]["by_slug"][user["slug"]] = user # public
|
||||||
|
id_map[user["oid"]] = user["slug"]
|
||||||
|
counter += 1
|
||||||
|
ce = 0
|
||||||
|
for entry in storage["users"]["data"]:
|
||||||
|
ce += migrateUser_2stage(entry, id_map)
|
||||||
|
users_post_migrate()
|
||||||
|
|
||||||
|
|
||||||
|
async def topics_handle(storage):
|
||||||
|
"""topics from categories and tags"""
|
||||||
|
counter = 0
|
||||||
|
for t in storage["topics"]["tags"] + storage["topics"]["cats"]:
|
||||||
|
if t["slug"] in storage["replacements"]:
|
||||||
|
t["slug"] = storage["replacements"][t["slug"]]
|
||||||
|
topic = migrateTopic(t)
|
||||||
|
storage["topics"]["by_oid"][t["_id"]] = topic
|
||||||
|
storage["topics"]["by_slug"][t["slug"]] = topic
|
||||||
|
counter += 1
|
||||||
|
else:
|
||||||
|
print("[migration] topic " + t["slug"] + " ignored")
|
||||||
|
for oldslug, newslug in storage["replacements"].items():
|
||||||
|
if oldslug != newslug and oldslug in storage["topics"]["by_slug"]:
|
||||||
|
oid = storage["topics"]["by_slug"][oldslug]["_id"]
|
||||||
|
del storage["topics"]["by_slug"][oldslug]
|
||||||
|
storage["topics"]["by_oid"][oid] = storage["topics"]["by_slug"][newslug]
|
||||||
|
print("[migration] " + str(counter) + " topics migrated")
|
||||||
|
print("[migration] " + str(len(storage["topics"]["by_oid"].values())) + " topics by oid")
|
||||||
|
print("[migration] " + str(len(storage["topics"]["by_slug"].values())) + " topics by slug")
|
||||||
|
|
||||||
|
|
||||||
|
async def shouts_handle(storage, args):
|
||||||
|
"""migrating content items one by one"""
|
||||||
|
counter = 0
|
||||||
|
discours_author = 0
|
||||||
|
anonymous_author = 0
|
||||||
|
pub_counter = 0
|
||||||
|
ignored = 0
|
||||||
|
topics_dataset_bodies = []
|
||||||
|
topics_dataset_tlist = []
|
||||||
|
for entry in storage["shouts"]["data"]:
|
||||||
|
gc.collect()
|
||||||
|
# slug
|
||||||
|
slug = get_shout_slug(entry)
|
||||||
|
|
||||||
|
# single slug mode
|
||||||
|
if "-" in args and slug not in args:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# migrate
|
||||||
|
shout_dict = await migrateShout(entry, storage)
|
||||||
|
if shout_dict:
|
||||||
|
storage["shouts"]["by_oid"][entry["_id"]] = shout_dict
|
||||||
|
storage["shouts"]["by_slug"][shout_dict["slug"]] = shout_dict
|
||||||
|
# shouts.topics
|
||||||
|
if not shout_dict["topics"]:
|
||||||
|
print("[migration] no topics!")
|
||||||
|
|
||||||
|
# with author
|
||||||
|
author = shout_dict["authors"][0]
|
||||||
|
if author["slug"] == "discours":
|
||||||
|
discours_author += 1
|
||||||
|
if author["slug"] == "anonymous":
|
||||||
|
anonymous_author += 1
|
||||||
|
# print('[migration] ' + shout['slug'] + ' with author ' + author)
|
||||||
|
|
||||||
|
if entry.get("published"):
|
||||||
|
if "mdx" in args:
|
||||||
|
export_mdx(shout_dict)
|
||||||
|
pub_counter += 1
|
||||||
|
|
||||||
|
# print main counter
|
||||||
|
counter += 1
|
||||||
|
print(
|
||||||
|
"[migration] shouts_handle %d: %s @%s"
|
||||||
|
% ((counter + 1), shout_dict["slug"], author["slug"])
|
||||||
|
)
|
||||||
|
|
||||||
|
b = bs4.BeautifulSoup(shout_dict["body"], "html.parser")
|
||||||
|
texts = [shout_dict["title"].lower().replace(r"[^а-яА-Яa-zA-Z]", "")]
|
||||||
|
texts = texts + b.findAll(text=True)
|
||||||
|
topics_dataset_bodies.append(" ".join([x.strip().lower() for x in texts]))
|
||||||
|
topics_dataset_tlist.append(shout_dict["topics"])
|
||||||
|
else:
|
||||||
|
ignored += 1
|
||||||
|
|
||||||
|
# np.savetxt('topics_dataset.csv', (topics_dataset_bodies, topics_dataset_tlist), delimiter=',
|
||||||
|
# ', fmt='%s')
|
||||||
|
|
||||||
|
print("[migration] " + str(counter) + " content items were migrated")
|
||||||
|
print("[migration] " + str(pub_counter) + " have been published")
|
||||||
|
print("[migration] " + str(discours_author) + " authored by @discours")
|
||||||
|
print("[migration] " + str(anonymous_author) + " authored by @anonymous")
|
||||||
|
|
||||||
|
|
||||||
|
# async def remarks_handle(storage):
|
||||||
|
# print("[migration] comments")
|
||||||
|
# c = 0
|
||||||
|
# for entry_remark in storage["remarks"]["data"]:
|
||||||
|
# remark = await migrateRemark(entry_remark, storage)
|
||||||
|
# c += 1
|
||||||
|
# print("[migration] " + str(c) + " remarks migrated")
|
||||||
|
|
||||||
|
|
||||||
|
async def comments_handle(storage):
|
||||||
|
print("[migration] comments")
|
||||||
|
id_map = {}
|
||||||
|
ignored_counter = 0
|
||||||
|
missed_shouts = {}
|
||||||
|
for oldcomment in storage["reactions"]["data"]:
|
||||||
|
if not oldcomment.get("deleted"):
|
||||||
|
reaction = await migrateComment(oldcomment, storage)
|
||||||
|
if isinstance(reaction, str):
|
||||||
|
missed_shouts[reaction] = oldcomment
|
||||||
|
elif isinstance(reaction, Reaction):
|
||||||
|
reaction = reaction.dict()
|
||||||
|
rid = reaction["id"]
|
||||||
|
oid = reaction["oid"]
|
||||||
|
id_map[oid] = rid
|
||||||
|
else:
|
||||||
|
ignored_counter += 1
|
||||||
|
|
||||||
|
for reaction in storage["reactions"]["data"]:
|
||||||
|
migrateComment_2stage(reaction, id_map)
|
||||||
|
print("[migration] " + str(len(id_map)) + " comments migrated")
|
||||||
|
print("[migration] " + str(ignored_counter) + " comments ignored")
|
||||||
|
print("[migration] " + str(len(missed_shouts.keys())) + " commented shouts missed")
|
||||||
|
missed_counter = 0
|
||||||
|
for missed in missed_shouts.values():
|
||||||
|
missed_counter += len(missed)
|
||||||
|
print("[migration] " + str(missed_counter) + " comments dropped")
|
||||||
|
|
||||||
|
|
||||||
|
async def all_handle(storage, args):
|
||||||
|
print("[migration] handle everything")
|
||||||
|
await users_handle(storage)
|
||||||
|
await topics_handle(storage)
|
||||||
|
print("[migration] users and topics are migrated")
|
||||||
|
await shouts_handle(storage, args)
|
||||||
|
# print("[migration] remarks...")
|
||||||
|
# await remarks_handle(storage)
|
||||||
|
print("[migration] migrating comments")
|
||||||
|
await comments_handle(storage)
|
||||||
|
# export_email_subscriptions()
|
||||||
|
print("[migration] done!")
|
||||||
|
|
||||||
|
|
||||||
|
def data_load():
|
||||||
|
storage = {
|
||||||
|
"content_items": {
|
||||||
|
"by_oid": {},
|
||||||
|
"by_slug": {},
|
||||||
|
},
|
||||||
|
"shouts": {"by_oid": {}, "by_slug": {}, "data": []},
|
||||||
|
"reactions": {"by_oid": {}, "by_slug": {}, "by_content": {}, "data": []},
|
||||||
|
"topics": {
|
||||||
|
"by_oid": {},
|
||||||
|
"by_slug": {},
|
||||||
|
"cats": [],
|
||||||
|
"tags": [],
|
||||||
|
},
|
||||||
|
"remarks": {"data": []},
|
||||||
|
"users": {"by_oid": {}, "by_slug": {}, "data": []},
|
||||||
|
"replacements": json.loads(open("migration/tables/replacements.json").read()),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
users_data = json.loads(open("migration/data/users.json").read())
|
||||||
|
print("[migration.load] " + str(len(users_data)) + " users ")
|
||||||
|
tags_data = json.loads(open("migration/data/tags.json").read())
|
||||||
|
storage["topics"]["tags"] = tags_data
|
||||||
|
print("[migration.load] " + str(len(tags_data)) + " tags ")
|
||||||
|
cats_data = json.loads(open("migration/data/content_item_categories.json").read())
|
||||||
|
storage["topics"]["cats"] = cats_data
|
||||||
|
print("[migration.load] " + str(len(cats_data)) + " cats ")
|
||||||
|
comments_data = json.loads(open("migration/data/comments.json").read())
|
||||||
|
storage["reactions"]["data"] = comments_data
|
||||||
|
print("[migration.load] " + str(len(comments_data)) + " comments ")
|
||||||
|
content_data = json.loads(open("migration/data/content_items.json").read())
|
||||||
|
storage["shouts"]["data"] = content_data
|
||||||
|
print("[migration.load] " + str(len(content_data)) + " content items ")
|
||||||
|
|
||||||
|
remarks_data = json.loads(open("migration/data/remarks.json").read())
|
||||||
|
storage["remarks"]["data"] = remarks_data
|
||||||
|
print("[migration.load] " + str(len(remarks_data)) + " remarks data ")
|
||||||
|
|
||||||
|
# fill out storage
|
||||||
|
for x in users_data:
|
||||||
|
storage["users"]["by_oid"][x["_id"]] = x
|
||||||
|
# storage['users']['by_slug'][x['slug']] = x
|
||||||
|
# no user.slug yet
|
||||||
|
print("[migration.load] " + str(len(storage["users"]["by_oid"].keys())) + " users by oid")
|
||||||
|
for x in tags_data:
|
||||||
|
storage["topics"]["by_oid"][x["_id"]] = x
|
||||||
|
storage["topics"]["by_slug"][x["slug"]] = x
|
||||||
|
for x in cats_data:
|
||||||
|
storage["topics"]["by_oid"][x["_id"]] = x
|
||||||
|
storage["topics"]["by_slug"][x["slug"]] = x
|
||||||
|
print(
|
||||||
|
"[migration.load] " + str(len(storage["topics"]["by_slug"].keys())) + " topics by slug"
|
||||||
|
)
|
||||||
|
for item in content_data:
|
||||||
|
slug = get_shout_slug(item)
|
||||||
|
storage["content_items"]["by_slug"][slug] = item
|
||||||
|
storage["content_items"]["by_oid"][item["_id"]] = item
|
||||||
|
print("[migration.load] " + str(len(content_data)) + " content items")
|
||||||
|
for x in comments_data:
|
||||||
|
storage["reactions"]["by_oid"][x["_id"]] = x
|
||||||
|
cid = x["contentItem"]
|
||||||
|
storage["reactions"]["by_content"][cid] = x
|
||||||
|
ci = storage["content_items"]["by_oid"].get(cid, {})
|
||||||
|
if "slug" in ci:
|
||||||
|
storage["reactions"]["by_slug"][ci["slug"]] = x
|
||||||
|
print(
|
||||||
|
"[migration.load] "
|
||||||
|
+ str(len(storage["reactions"]["by_content"].keys()))
|
||||||
|
+ " with comments"
|
||||||
|
)
|
||||||
|
storage["users"]["data"] = users_data
|
||||||
|
storage["topics"]["tags"] = tags_data
|
||||||
|
storage["topics"]["cats"] = cats_data
|
||||||
|
storage["shouts"]["data"] = content_data
|
||||||
|
storage["reactions"]["data"] = comments_data
|
||||||
|
except Exception as e:
|
||||||
|
raise e
|
||||||
|
return storage
|
||||||
|
|
||||||
|
|
||||||
|
async def handling_migration():
|
||||||
|
init_tables()
|
||||||
|
await all_handle(data_load(), sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
def process():
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.run_until_complete(handling_migration())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
process()
|
||||||
33
migration/bson2json.py
Normal file
33
migration/bson2json.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import gc
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import bson
|
||||||
|
|
||||||
|
from .utils import DateTimeEncoder
|
||||||
|
|
||||||
|
|
||||||
|
def json_tables():
|
||||||
|
print("[migration] unpack dump/discours/*.bson to migration/data/*.json")
|
||||||
|
data = {
|
||||||
|
"content_items": [],
|
||||||
|
"content_item_categories": [],
|
||||||
|
"tags": [],
|
||||||
|
"email_subscriptions": [],
|
||||||
|
"users": [],
|
||||||
|
"comments": [],
|
||||||
|
"remarks": [],
|
||||||
|
}
|
||||||
|
for table in data.keys():
|
||||||
|
print("[migration] bson2json for " + table)
|
||||||
|
gc.collect()
|
||||||
|
lc = []
|
||||||
|
bs = open("dump/discours/" + table + ".bson", "rb").read()
|
||||||
|
base = 0
|
||||||
|
while base < len(bs):
|
||||||
|
base, d = bson.decode_document(bs, base)
|
||||||
|
lc.append(d)
|
||||||
|
data[table] = lc
|
||||||
|
open(os.getcwd() + "/migration/data/" + table + ".json", "w").write(
|
||||||
|
json.dumps(lc, cls=DateTimeEncoder)
|
||||||
|
)
|
||||||
137
migration/export.py
Normal file
137
migration/export.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import frontmatter
|
||||||
|
|
||||||
|
from .extract import extract_html, extract_media
|
||||||
|
from .utils import DateTimeEncoder
|
||||||
|
|
||||||
|
OLD_DATE = "2016-03-05 22:22:00.350000"
|
||||||
|
EXPORT_DEST = "../discoursio-web/data/"
|
||||||
|
parentDir = "/".join(os.getcwd().split("/")[:-1])
|
||||||
|
contentDir = parentDir + "/discoursio-web/content/"
|
||||||
|
ts = datetime.now(tz=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata(r):
|
||||||
|
authors = []
|
||||||
|
for a in r["authors"]:
|
||||||
|
authors.append(
|
||||||
|
{ # a short version for public listings
|
||||||
|
"slug": a.slug or "discours",
|
||||||
|
"name": a.name or "Дискурс",
|
||||||
|
"userpic": a.userpic or "https://discours.io/static/img/discours.png",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
metadata = {}
|
||||||
|
metadata["title"] = r.get("title", "").replace("{", "(").replace("}", ")")
|
||||||
|
metadata["authors"] = authors
|
||||||
|
metadata["createdAt"] = r.get("createdAt", ts)
|
||||||
|
metadata["layout"] = r["layout"]
|
||||||
|
metadata["topics"] = [topic for topic in r["topics"]]
|
||||||
|
metadata["topics"].sort()
|
||||||
|
if r.get("cover", False):
|
||||||
|
metadata["cover"] = r.get("cover")
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def export_mdx(r):
|
||||||
|
# print('[export] mdx %s' % r['slug'])
|
||||||
|
content = ""
|
||||||
|
metadata = get_metadata(r)
|
||||||
|
content = frontmatter.dumps(frontmatter.Post(r["body"], **metadata))
|
||||||
|
ext = "mdx"
|
||||||
|
filepath = contentDir + r["slug"]
|
||||||
|
bc = bytes(content, "utf-8").decode("utf-8", "ignore")
|
||||||
|
open(filepath + "." + ext, "w").write(bc)
|
||||||
|
|
||||||
|
|
||||||
|
def export_body(shout, storage):
|
||||||
|
entry = storage["content_items"]["by_oid"][shout["oid"]]
|
||||||
|
if entry:
|
||||||
|
body = extract_html(entry)
|
||||||
|
media = extract_media(entry)
|
||||||
|
shout["body"] = body # prepare_html_body(entry) # prepare_md_body(entry)
|
||||||
|
shout["media"] = media
|
||||||
|
export_mdx(shout)
|
||||||
|
print("[export] html for %s" % shout["slug"])
|
||||||
|
open(contentDir + shout["slug"] + ".html", "w").write(body)
|
||||||
|
else:
|
||||||
|
raise Exception("no content_items entry found")
|
||||||
|
|
||||||
|
|
||||||
|
def export_slug(slug, storage):
|
||||||
|
shout = storage["shouts"]["by_slug"][slug]
|
||||||
|
shout = storage["shouts"]["by_slug"].get(slug)
|
||||||
|
assert shout, "[export] no shout found by slug: %s " % slug
|
||||||
|
author = shout["authors"][0]
|
||||||
|
assert author, "[export] no author error"
|
||||||
|
export_body(shout, storage)
|
||||||
|
|
||||||
|
|
||||||
|
def export_email_subscriptions():
|
||||||
|
email_subscriptions_data = json.loads(open("migration/data/email_subscriptions.json").read())
|
||||||
|
for data in email_subscriptions_data:
|
||||||
|
# TODO: migrate to mailgun list manually
|
||||||
|
# migrate_email_subscription(data)
|
||||||
|
pass
|
||||||
|
print("[migration] " + str(len(email_subscriptions_data)) + " email subscriptions exported")
|
||||||
|
|
||||||
|
|
||||||
|
def export_shouts(storage):
|
||||||
|
# update what was just migrated or load json again
|
||||||
|
if len(storage["users"]["by_slugs"].keys()) == 0:
|
||||||
|
storage["users"]["by_slugs"] = json.loads(open(EXPORT_DEST + "authors.json").read())
|
||||||
|
print("[migration] " + str(len(storage["users"]["by_slugs"].keys())) + " exported authors ")
|
||||||
|
if len(storage["shouts"]["by_slugs"].keys()) == 0:
|
||||||
|
storage["shouts"]["by_slugs"] = json.loads(open(EXPORT_DEST + "articles.json").read())
|
||||||
|
print(
|
||||||
|
"[migration] " + str(len(storage["shouts"]["by_slugs"].keys())) + " exported articles "
|
||||||
|
)
|
||||||
|
for slug in storage["shouts"]["by_slugs"].keys():
|
||||||
|
export_slug(slug, storage)
|
||||||
|
|
||||||
|
|
||||||
|
def export_json(export_articles={}, export_authors={}, export_topics={}, export_comments={}):
|
||||||
|
open(EXPORT_DEST + "authors.json", "w").write(
|
||||||
|
json.dumps(
|
||||||
|
export_authors,
|
||||||
|
cls=DateTimeEncoder,
|
||||||
|
indent=4,
|
||||||
|
sort_keys=True,
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print("[migration] " + str(len(export_authors.items())) + " authors exported")
|
||||||
|
open(EXPORT_DEST + "topics.json", "w").write(
|
||||||
|
json.dumps(
|
||||||
|
export_topics,
|
||||||
|
cls=DateTimeEncoder,
|
||||||
|
indent=4,
|
||||||
|
sort_keys=True,
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print("[migration] " + str(len(export_topics.keys())) + " topics exported")
|
||||||
|
|
||||||
|
open(EXPORT_DEST + "articles.json", "w").write(
|
||||||
|
json.dumps(
|
||||||
|
export_articles,
|
||||||
|
cls=DateTimeEncoder,
|
||||||
|
indent=4,
|
||||||
|
sort_keys=True,
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print("[migration] " + str(len(export_articles.items())) + " articles exported")
|
||||||
|
open(EXPORT_DEST + "comments.json", "w").write(
|
||||||
|
json.dumps(
|
||||||
|
export_comments,
|
||||||
|
cls=DateTimeEncoder,
|
||||||
|
indent=4,
|
||||||
|
sort_keys=True,
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print("[migration] " + str(len(export_comments.items())) + " exported articles with comments")
|
||||||
276
migration/extract.py
Normal file
276
migration/extract.py
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
TOOLTIP_REGEX = r"(\/\/\/(.+)\/\/\/)"
|
||||||
|
contentDir = os.path.join(
|
||||||
|
os.path.dirname(os.path.realpath(__file__)), "..", "..", "discoursio-web", "content"
|
||||||
|
)
|
||||||
|
|
||||||
|
cdn = "https://images.discours.io"
|
||||||
|
|
||||||
|
|
||||||
|
def replace_tooltips(body):
|
||||||
|
# change if you prefer regexp
|
||||||
|
newbody = body
|
||||||
|
matches = list(re.finditer(TOOLTIP_REGEX, body, re.IGNORECASE | re.MULTILINE))[1:]
|
||||||
|
for match in matches:
|
||||||
|
newbody = body.replace(
|
||||||
|
match.group(1), '<Tooltip text="' + match.group(2) + '" />'
|
||||||
|
) # NOTE: doesn't work
|
||||||
|
if len(matches) > 0:
|
||||||
|
print("[extract] found %d tooltips" % len(matches))
|
||||||
|
return newbody
|
||||||
|
|
||||||
|
|
||||||
|
# def extract_footnotes(body, shout_dict):
|
||||||
|
# parts = body.split("&&&")
|
||||||
|
# lll = len(parts)
|
||||||
|
# newparts = list(parts)
|
||||||
|
# placed = False
|
||||||
|
# if lll & 1:
|
||||||
|
# if lll > 1:
|
||||||
|
# i = 1
|
||||||
|
# print("[extract] found %d footnotes in body" % (lll - 1))
|
||||||
|
# for part in parts[1:]:
|
||||||
|
# if i & 1:
|
||||||
|
# placed = True
|
||||||
|
# if 'a class="footnote-url" href=' in part:
|
||||||
|
# print("[extract] footnote: " + part)
|
||||||
|
# fn = 'a class="footnote-url" href="'
|
||||||
|
# exxtracted_link = part.split(fn, 1)[1].split('"', 1)[0]
|
||||||
|
# extracted_body = part.split(fn, 1)[1].split(">", 1)[1].split("</a>", 1)[0]
|
||||||
|
# print("[extract] footnote link: " + extracted_link)
|
||||||
|
# with local_session() as session:
|
||||||
|
# Reaction.create(
|
||||||
|
# {
|
||||||
|
# "shout": shout_dict["id"],
|
||||||
|
# "kind": ReactionKind.FOOTNOTE,
|
||||||
|
# "body": extracted_body,
|
||||||
|
# "range": str(body.index(fn + link) - len("<"))
|
||||||
|
# + ":"
|
||||||
|
# + str(body.index(extracted_body) + len("</a>")),
|
||||||
|
# }
|
||||||
|
# )
|
||||||
|
# newparts[i] = "<a href='#'>ℹ️</a>"
|
||||||
|
# else:
|
||||||
|
# newparts[i] = part
|
||||||
|
# i += 1
|
||||||
|
# return ("".join(newparts), placed)
|
||||||
|
|
||||||
|
|
||||||
|
# def place_tooltips(body):
|
||||||
|
# parts = body.split("&&&")
|
||||||
|
# lll = len(parts)
|
||||||
|
# newparts = list(parts)
|
||||||
|
# placed = False
|
||||||
|
# if lll & 1:
|
||||||
|
# if lll > 1:
|
||||||
|
# i = 1
|
||||||
|
# print("[extract] found %d tooltips" % (lll - 1))
|
||||||
|
# for part in parts[1:]:
|
||||||
|
# if i & 1:
|
||||||
|
# placed = True
|
||||||
|
# if 'a class="footnote-url" href=' in part:
|
||||||
|
# print("[extract] footnote: " + part)
|
||||||
|
# fn = 'a class="footnote-url" href="'
|
||||||
|
# link = part.split(fn, 1)[1].split('"', 1)[0]
|
||||||
|
# extracted_part = part.split(fn, 1)[0] + " " + part.split("/", 1)[-1]
|
||||||
|
# newparts[i] = (
|
||||||
|
# "<Tooltip"
|
||||||
|
# + (' link="' + link + '" ' if link else "")
|
||||||
|
# + ">"
|
||||||
|
# + extracted_part
|
||||||
|
# + "</Tooltip>"
|
||||||
|
# )
|
||||||
|
# else:
|
||||||
|
# newparts[i] = "<Tooltip>%s</Tooltip>" % part
|
||||||
|
# # print('[extract] ' + newparts[i])
|
||||||
|
# else:
|
||||||
|
# # print('[extract] ' + part[:10] + '..')
|
||||||
|
# newparts[i] = part
|
||||||
|
# i += 1
|
||||||
|
# return ("".join(newparts), placed)
|
||||||
|
|
||||||
|
|
||||||
|
IMG_REGEX = (
|
||||||
|
r"\!\[(.*?)\]\((data\:image\/(png|jpeg|jpg);base64\,((?:[A-Za-z\d+\/]{4})*(?:[A-Za-z\d+\/]{3}="
|
||||||
|
)
|
||||||
|
IMG_REGEX += r"|[A-Za-z\d+\/]{2}==)))\)"
|
||||||
|
|
||||||
|
parentDir = "/".join(os.getcwd().split("/")[:-1])
|
||||||
|
public = parentDir + "/discoursio-web/public"
|
||||||
|
cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
# def reextract_images(body, oid):
|
||||||
|
# # change if you prefer regexp
|
||||||
|
# matches = list(re.finditer(IMG_REGEX, body, re.IGNORECASE | re.MULTILINE))[1:]
|
||||||
|
# i = 0
|
||||||
|
# for match in matches:
|
||||||
|
# print("[extract] image " + match.group(1))
|
||||||
|
# ext = match.group(3)
|
||||||
|
# name = oid + str(i)
|
||||||
|
# link = public + "/upload/image-" + name + "." + ext
|
||||||
|
# img = match.group(4)
|
||||||
|
# title = match.group(1) # NOTE: this is not the title
|
||||||
|
# if img not in cache:
|
||||||
|
# content = base64.b64decode(img + "==")
|
||||||
|
# print(str(len(img)) + " image bytes been written")
|
||||||
|
# open("../" + link, "wb").write(content)
|
||||||
|
# cache[img] = name
|
||||||
|
# i += 1
|
||||||
|
# else:
|
||||||
|
# print("[extract] image cached " + cache[img])
|
||||||
|
# body.replace(
|
||||||
|
# str(match), ""
|
||||||
|
# ) # WARNING: this does not work
|
||||||
|
# return body
|
||||||
|
|
||||||
|
|
||||||
|
IMAGES = {
|
||||||
|
"data:image/png": "png",
|
||||||
|
"data:image/jpg": "jpg",
|
||||||
|
"data:image/jpeg": "jpg",
|
||||||
|
}
|
||||||
|
|
||||||
|
b64 = ";base64,"
|
||||||
|
|
||||||
|
di = "data:image"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_media(entry):
|
||||||
|
"""normalized media extraction method"""
|
||||||
|
# media [ { title pic url body } ]}
|
||||||
|
kind = entry.get("type")
|
||||||
|
if not kind:
|
||||||
|
print(entry)
|
||||||
|
raise Exception("shout no layout")
|
||||||
|
media = []
|
||||||
|
for m in entry.get("media") or []:
|
||||||
|
# title
|
||||||
|
title = m.get("title", "").replace("\n", " ").replace(" ", " ")
|
||||||
|
artist = m.get("performer") or m.get("artist")
|
||||||
|
if artist:
|
||||||
|
title = artist + " - " + title
|
||||||
|
|
||||||
|
# pic
|
||||||
|
url = m.get("fileUrl") or m.get("url", "")
|
||||||
|
pic = ""
|
||||||
|
if m.get("thumborId"):
|
||||||
|
pic = cdn + "/unsafe/" + m["thumborId"]
|
||||||
|
|
||||||
|
# url
|
||||||
|
if not url:
|
||||||
|
if kind == "Image":
|
||||||
|
url = pic
|
||||||
|
elif "youtubeId" in m:
|
||||||
|
url = "https://youtube.com/?watch=" + m["youtubeId"]
|
||||||
|
elif "vimeoId" in m:
|
||||||
|
url = "https://vimeo.com/" + m["vimeoId"]
|
||||||
|
# body
|
||||||
|
body = m.get("body") or m.get("literatureBody") or ""
|
||||||
|
media.append({"url": url, "pic": pic, "title": title, "body": body})
|
||||||
|
return media
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_html_body(entry):
|
||||||
|
# body modifications
|
||||||
|
body = ""
|
||||||
|
kind = entry.get("type")
|
||||||
|
addon = ""
|
||||||
|
if kind == "Video":
|
||||||
|
addon = ""
|
||||||
|
for m in entry.get("media") or []:
|
||||||
|
if "youtubeId" in m:
|
||||||
|
addon += '<iframe width="420" height="345" src="http://www.youtube.com/embed/'
|
||||||
|
addon += m["youtubeId"]
|
||||||
|
addon += '?autoplay=1" frameborder="0" allowfullscreen></iframe>\n'
|
||||||
|
elif "vimeoId" in m:
|
||||||
|
addon += '<iframe src="https://player.vimeo.com/video/'
|
||||||
|
addon += m["vimeoId"]
|
||||||
|
addon += ' width="420" height="345" frameborder="0" allow="autoplay; fullscreen"'
|
||||||
|
addon += " allowfullscreen></iframe>"
|
||||||
|
else:
|
||||||
|
print("[extract] media is not supported")
|
||||||
|
print(m)
|
||||||
|
body += addon
|
||||||
|
|
||||||
|
elif kind == "Music":
|
||||||
|
addon = ""
|
||||||
|
for m in entry.get("media") or []:
|
||||||
|
artist = m.get("performer")
|
||||||
|
trackname = ""
|
||||||
|
if artist:
|
||||||
|
trackname += artist + " - "
|
||||||
|
if "title" in m:
|
||||||
|
trackname += m.get("title", "")
|
||||||
|
addon += "<figure><figcaption>"
|
||||||
|
addon += trackname
|
||||||
|
addon += '</figcaption><audio controls src="'
|
||||||
|
addon += m.get("fileUrl", "")
|
||||||
|
addon += '"></audio></figure>'
|
||||||
|
body += addon
|
||||||
|
|
||||||
|
body = extract_html(entry)
|
||||||
|
# if body_orig: body += extract_md(html2text(body_orig), entry['_id'])
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_html(body: str) -> str:
|
||||||
|
new_body = body
|
||||||
|
regex_remove = [
|
||||||
|
r"style=\"width:\s*\d+px;height:\s*\d+px;\"",
|
||||||
|
r"style=\"width:\s*\d+px;\"",
|
||||||
|
r"style=\"color: #000000;\"",
|
||||||
|
r"style=\"float: none;\"",
|
||||||
|
r"style=\"background: white;\"",
|
||||||
|
r"class=\"Apple-interchange-newline\"",
|
||||||
|
r"class=\"MsoNormalCxSpMiddle\"",
|
||||||
|
r"class=\"MsoNormal\"",
|
||||||
|
r"lang=\"EN-US\"",
|
||||||
|
r"id=\"docs-internal-guid-[\w-]+\"",
|
||||||
|
r"<p>\s*</p>",
|
||||||
|
r"<span></span>",
|
||||||
|
r"<i>\s*</i>",
|
||||||
|
r"<b>\s*</b>",
|
||||||
|
r"<h1>\s*</h1>",
|
||||||
|
r"<h2>\s*</h2>",
|
||||||
|
r"<h3>\s*</h3>",
|
||||||
|
r"<h4>\s*</h4>",
|
||||||
|
r"<div>\s*</div>",
|
||||||
|
]
|
||||||
|
regex_replace = {r"<br>\s*</p>": "</p>"}
|
||||||
|
changed = True
|
||||||
|
while changed:
|
||||||
|
# we need several iterations to clean nested tags this way
|
||||||
|
changed = False
|
||||||
|
new_body_iteration = new_body
|
||||||
|
for regex in regex_remove:
|
||||||
|
new_body = re.sub(regex, "", new_body)
|
||||||
|
for regex, replace in regex_replace.items():
|
||||||
|
new_body = re.sub(regex, replace, new_body)
|
||||||
|
if new_body_iteration != new_body:
|
||||||
|
changed = True
|
||||||
|
return new_body
|
||||||
|
|
||||||
|
|
||||||
|
def extract_html(entry, shout_id=None, cleanup=False):
|
||||||
|
body_orig = (entry.get("body") or "").replace(r"\(", "(").replace(r"\)", ")")
|
||||||
|
if cleanup:
|
||||||
|
# we do that before bs parsing to catch the invalid html
|
||||||
|
body_clean = cleanup_html(body_orig)
|
||||||
|
if body_clean != body_orig:
|
||||||
|
print(f"[migration] html cleaned for slug {entry.get('slug', None)}")
|
||||||
|
body_orig = body_clean
|
||||||
|
# if shout_id:
|
||||||
|
# extract_footnotes(body_orig, shout_id)
|
||||||
|
body_html = str(BeautifulSoup(body_orig, features="html.parser"))
|
||||||
|
if cleanup:
|
||||||
|
# we do that after bs parsing because it can add dummy tags
|
||||||
|
body_clean_html = cleanup_html(body_html)
|
||||||
|
if body_clean_html != body_html:
|
||||||
|
print(f"[migration] html cleaned after bs4 for slug {entry.get('slug', None)}")
|
||||||
|
body_html = body_clean_html
|
||||||
|
return body_html
|
||||||
1023
migration/html2text/__init__.py
Normal file
1023
migration/html2text/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
3
migration/html2text/__main__.py
Normal file
3
migration/html2text/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .cli import main
|
||||||
|
|
||||||
|
main()
|
||||||
318
migration/html2text/cli.py
Normal file
318
migration/html2text/cli.py
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from . import HTML2Text, __version__, config
|
||||||
|
|
||||||
|
|
||||||
|
# noinspection DuplicatedCode
|
||||||
|
def main() -> None:
|
||||||
|
baseurl = ""
|
||||||
|
|
||||||
|
class bcolors:
|
||||||
|
HEADER = "\033[95m"
|
||||||
|
OKBLUE = "\033[94m"
|
||||||
|
OKGREEN = "\033[92m"
|
||||||
|
WARNING = "\033[93m"
|
||||||
|
FAIL = "\033[91m"
|
||||||
|
ENDC = "\033[0m"
|
||||||
|
BOLD = "\033[1m"
|
||||||
|
UNDERLINE = "\033[4m"
|
||||||
|
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument(
|
||||||
|
"--default-image-alt",
|
||||||
|
dest="default_image_alt",
|
||||||
|
default=config.DEFAULT_IMAGE_ALT,
|
||||||
|
help="The default alt string for images with missing ones",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--pad-tables",
|
||||||
|
dest="pad_tables",
|
||||||
|
action="store_true",
|
||||||
|
default=config.PAD_TABLES,
|
||||||
|
help="pad the cells to equal column width in tables",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--no-wrap-links",
|
||||||
|
dest="wrap_links",
|
||||||
|
action="store_false",
|
||||||
|
default=config.WRAP_LINKS,
|
||||||
|
help="don't wrap links during conversion",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--wrap-list-items",
|
||||||
|
dest="wrap_list_items",
|
||||||
|
action="store_true",
|
||||||
|
default=config.WRAP_LIST_ITEMS,
|
||||||
|
help="wrap list items during conversion",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--wrap-tables",
|
||||||
|
dest="wrap_tables",
|
||||||
|
action="store_true",
|
||||||
|
default=config.WRAP_TABLES,
|
||||||
|
help="wrap tables",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--ignore-emphasis",
|
||||||
|
dest="ignore_emphasis",
|
||||||
|
action="store_true",
|
||||||
|
default=config.IGNORE_EMPHASIS,
|
||||||
|
help="don't include any formatting for emphasis",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--reference-links",
|
||||||
|
dest="inline_links",
|
||||||
|
action="store_false",
|
||||||
|
default=config.INLINE_LINKS,
|
||||||
|
help="use reference style links instead of inline links",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--ignore-links",
|
||||||
|
dest="ignore_links",
|
||||||
|
action="store_true",
|
||||||
|
default=config.IGNORE_ANCHORS,
|
||||||
|
help="don't include any formatting for links",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--ignore-mailto-links",
|
||||||
|
action="store_true",
|
||||||
|
dest="ignore_mailto_links",
|
||||||
|
default=config.IGNORE_MAILTO_LINKS,
|
||||||
|
help="don't include mailto: links",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--protect-links",
|
||||||
|
dest="protect_links",
|
||||||
|
action="store_true",
|
||||||
|
default=config.PROTECT_LINKS,
|
||||||
|
help="protect links from line breaks surrounding them with angle brackets",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--ignore-images",
|
||||||
|
dest="ignore_images",
|
||||||
|
action="store_true",
|
||||||
|
default=config.IGNORE_IMAGES,
|
||||||
|
help="don't include any formatting for images",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--images-as-html",
|
||||||
|
dest="images_as_html",
|
||||||
|
action="store_true",
|
||||||
|
default=config.IMAGES_AS_HTML,
|
||||||
|
help=(
|
||||||
|
"Always write image tags as raw html; preserves `height`, `width` and "
|
||||||
|
"`alt` if possible."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--images-to-alt",
|
||||||
|
dest="images_to_alt",
|
||||||
|
action="store_true",
|
||||||
|
default=config.IMAGES_TO_ALT,
|
||||||
|
help="Discard image data, only keep alt text",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--images-with-size",
|
||||||
|
dest="images_with_size",
|
||||||
|
action="store_true",
|
||||||
|
default=config.IMAGES_WITH_SIZE,
|
||||||
|
help=("Write image tags with height and width attrs as raw html to retain " "dimensions"),
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"-g",
|
||||||
|
"--google-doc",
|
||||||
|
action="store_true",
|
||||||
|
dest="google_doc",
|
||||||
|
default=False,
|
||||||
|
help="convert an html-exported Google Document",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"-d",
|
||||||
|
"--dash-unordered-list",
|
||||||
|
action="store_true",
|
||||||
|
dest="ul_style_dash",
|
||||||
|
default=False,
|
||||||
|
help="use a dash rather than a star for unordered list items",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"-e",
|
||||||
|
"--asterisk-emphasis",
|
||||||
|
action="store_true",
|
||||||
|
dest="em_style_asterisk",
|
||||||
|
default=False,
|
||||||
|
help="use an asterisk rather than an underscore for emphasized text",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"-b",
|
||||||
|
"--body-width",
|
||||||
|
dest="body_width",
|
||||||
|
type=int,
|
||||||
|
default=config.BODY_WIDTH,
|
||||||
|
help="number of characters per output line, 0 for no wrap",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"-i",
|
||||||
|
"--google-list-indent",
|
||||||
|
dest="list_indent",
|
||||||
|
type=int,
|
||||||
|
default=config.GOOGLE_LIST_INDENT,
|
||||||
|
help="number of pixels Google indents nested lists",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"-s",
|
||||||
|
"--hide-strikethrough",
|
||||||
|
action="store_true",
|
||||||
|
dest="hide_strikethrough",
|
||||||
|
default=False,
|
||||||
|
help="hide strike-through text. only relevant when -g is " "specified as well",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--escape-all",
|
||||||
|
action="store_true",
|
||||||
|
dest="escape_snob",
|
||||||
|
default=False,
|
||||||
|
help=(
|
||||||
|
"Escape all special characters. Output is less readable, but avoids "
|
||||||
|
"corner case formatting issues."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--bypass-tables",
|
||||||
|
action="store_true",
|
||||||
|
dest="bypass_tables",
|
||||||
|
default=config.BYPASS_TABLES,
|
||||||
|
help="Format tables in HTML rather than Markdown syntax.",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--ignore-tables",
|
||||||
|
action="store_true",
|
||||||
|
dest="ignore_tables",
|
||||||
|
default=config.IGNORE_TABLES,
|
||||||
|
help="Ignore table-related tags (table, th, td, tr) " "while keeping rows.",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--single-line-break",
|
||||||
|
action="store_true",
|
||||||
|
dest="single_line_break",
|
||||||
|
default=config.SINGLE_LINE_BREAK,
|
||||||
|
help=(
|
||||||
|
"Use a single line break after a block element rather than two line "
|
||||||
|
"breaks. NOTE: Requires --body-width=0"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--unicode-snob",
|
||||||
|
action="store_true",
|
||||||
|
dest="unicode_snob",
|
||||||
|
default=config.UNICODE_SNOB,
|
||||||
|
help="Use unicode throughout document",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--no-automatic-links",
|
||||||
|
action="store_false",
|
||||||
|
dest="use_automatic_links",
|
||||||
|
default=config.USE_AUTOMATIC_LINKS,
|
||||||
|
help="Do not use automatic links wherever applicable",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--no-skip-internal-links",
|
||||||
|
action="store_false",
|
||||||
|
dest="skip_internal_links",
|
||||||
|
default=config.SKIP_INTERNAL_LINKS,
|
||||||
|
help="Do not skip internal links",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--links-after-para",
|
||||||
|
action="store_true",
|
||||||
|
dest="links_each_paragraph",
|
||||||
|
default=config.LINKS_EACH_PARAGRAPH,
|
||||||
|
help="Put links after each paragraph instead of document",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--mark-code",
|
||||||
|
action="store_true",
|
||||||
|
dest="mark_code",
|
||||||
|
default=config.MARK_CODE,
|
||||||
|
help="Mark program code blocks with [code]...[/code]",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--decode-errors",
|
||||||
|
dest="decode_errors",
|
||||||
|
default=config.DECODE_ERRORS,
|
||||||
|
help=(
|
||||||
|
"What to do in case of decode errors.'ignore', 'strict' and 'replace' are "
|
||||||
|
"acceptable values"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--open-quote",
|
||||||
|
dest="open_quote",
|
||||||
|
default=config.OPEN_QUOTE,
|
||||||
|
help="The character used to open quotes",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--close-quote",
|
||||||
|
dest="close_quote",
|
||||||
|
default=config.CLOSE_QUOTE,
|
||||||
|
help="The character used to close quotes",
|
||||||
|
)
|
||||||
|
p.add_argument("--version", action="version", version=".".join(map(str, __version__)))
|
||||||
|
p.add_argument("filename", nargs="?")
|
||||||
|
p.add_argument("encoding", nargs="?", default="utf-8")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
if args.filename and args.filename != "-":
|
||||||
|
with open(args.filename, "rb") as fp:
|
||||||
|
data = fp.read()
|
||||||
|
else:
|
||||||
|
data = sys.stdin.buffer.read()
|
||||||
|
|
||||||
|
try:
|
||||||
|
html = data.decode(args.encoding, args.decode_errors)
|
||||||
|
except UnicodeDecodeError as err:
|
||||||
|
warning = bcolors.WARNING + "Warning:" + bcolors.ENDC
|
||||||
|
warning += " Use the " + bcolors.OKGREEN
|
||||||
|
warning += "--decode-errors=ignore" + bcolors.ENDC + " flag."
|
||||||
|
print(warning)
|
||||||
|
raise err
|
||||||
|
|
||||||
|
h = HTML2Text(baseurl=baseurl)
|
||||||
|
# handle options
|
||||||
|
if args.ul_style_dash:
|
||||||
|
h.ul_item_mark = "-"
|
||||||
|
if args.em_style_asterisk:
|
||||||
|
h.emphasis_mark = "*"
|
||||||
|
h.strong_mark = "__"
|
||||||
|
|
||||||
|
h.body_width = args.body_width
|
||||||
|
h.google_list_indent = args.list_indent
|
||||||
|
h.ignore_emphasis = args.ignore_emphasis
|
||||||
|
h.ignore_links = args.ignore_links
|
||||||
|
h.ignore_mailto_links = args.ignore_mailto_links
|
||||||
|
h.protect_links = args.protect_links
|
||||||
|
h.ignore_images = args.ignore_images
|
||||||
|
h.images_as_html = args.images_as_html
|
||||||
|
h.images_to_alt = args.images_to_alt
|
||||||
|
h.images_with_size = args.images_with_size
|
||||||
|
h.google_doc = args.google_doc
|
||||||
|
h.hide_strikethrough = args.hide_strikethrough
|
||||||
|
h.escape_snob = args.escape_snob
|
||||||
|
h.bypass_tables = args.bypass_tables
|
||||||
|
h.ignore_tables = args.ignore_tables
|
||||||
|
h.single_line_break = args.single_line_break
|
||||||
|
h.inline_links = args.inline_links
|
||||||
|
h.unicode_snob = args.unicode_snob
|
||||||
|
h.use_automatic_links = args.use_automatic_links
|
||||||
|
h.skip_internal_links = args.skip_internal_links
|
||||||
|
h.links_each_paragraph = args.links_each_paragraph
|
||||||
|
h.mark_code = args.mark_code
|
||||||
|
h.wrap_links = args.wrap_links
|
||||||
|
h.wrap_list_items = args.wrap_list_items
|
||||||
|
h.wrap_tables = args.wrap_tables
|
||||||
|
h.pad_tables = args.pad_tables
|
||||||
|
h.default_image_alt = args.default_image_alt
|
||||||
|
h.open_quote = args.open_quote
|
||||||
|
h.close_quote = args.close_quote
|
||||||
|
|
||||||
|
sys.stdout.write(h.handle(html))
|
||||||
164
migration/html2text/config.py
Normal file
164
migration/html2text/config.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
# Use Unicode characters instead of their ascii pseudo-replacements
|
||||||
|
UNICODE_SNOB = True
|
||||||
|
|
||||||
|
# Marker to use for marking tables for padding post processing
|
||||||
|
TABLE_MARKER_FOR_PAD = "special_marker_for_table_padding"
|
||||||
|
# Escape all special characters. Output is less readable, but avoids
|
||||||
|
# corner case formatting issues.
|
||||||
|
ESCAPE_SNOB = True
|
||||||
|
|
||||||
|
# Put the links after each paragraph instead of at the end.
|
||||||
|
LINKS_EACH_PARAGRAPH = False
|
||||||
|
|
||||||
|
# Wrap long lines at position. 0 for no wrapping.
|
||||||
|
BODY_WIDTH = 0
|
||||||
|
|
||||||
|
# Don't show internal links (href="#local-anchor") -- corresponding link
|
||||||
|
# targets won't be visible in the plain text file anyway.
|
||||||
|
SKIP_INTERNAL_LINKS = False
|
||||||
|
|
||||||
|
# Use inline, rather than reference, formatting for images and links
|
||||||
|
INLINE_LINKS = True
|
||||||
|
|
||||||
|
# Protect links from line breaks surrounding them with angle brackets (in
|
||||||
|
# addition to their square brackets)
|
||||||
|
PROTECT_LINKS = True
|
||||||
|
WRAP_LINKS = True
|
||||||
|
|
||||||
|
# Wrap list items.
|
||||||
|
WRAP_LIST_ITEMS = False
|
||||||
|
|
||||||
|
# Wrap tables
|
||||||
|
WRAP_TABLES = False
|
||||||
|
|
||||||
|
# Number of pixels Google indents nested lists
|
||||||
|
GOOGLE_LIST_INDENT = 36
|
||||||
|
|
||||||
|
# Values Google and others may use to indicate bold text
|
||||||
|
BOLD_TEXT_STYLE_VALUES = ("bold", "700", "800", "900")
|
||||||
|
|
||||||
|
IGNORE_ANCHORS = False
|
||||||
|
IGNORE_MAILTO_LINKS = False
|
||||||
|
IGNORE_IMAGES = False
|
||||||
|
IMAGES_AS_HTML = False
|
||||||
|
IMAGES_TO_ALT = False
|
||||||
|
IMAGES_WITH_SIZE = False
|
||||||
|
IGNORE_EMPHASIS = False
|
||||||
|
MARK_CODE = True
|
||||||
|
DECODE_ERRORS = "strict"
|
||||||
|
DEFAULT_IMAGE_ALT = ""
|
||||||
|
PAD_TABLES = True
|
||||||
|
|
||||||
|
# Convert links with same href and text to <href> format
|
||||||
|
# if they are absolute links
|
||||||
|
USE_AUTOMATIC_LINKS = True
|
||||||
|
|
||||||
|
# For checking space-only lines on line 771
|
||||||
|
RE_SPACE = re.compile(r"\s\+")
|
||||||
|
|
||||||
|
RE_ORDERED_LIST_MATCHER = re.compile(r"\d+\.\s")
|
||||||
|
RE_UNORDERED_LIST_MATCHER = re.compile(r"[-\*\+]\s")
|
||||||
|
RE_MD_CHARS_MATCHER = re.compile(r"([\\\[\]\(\)])")
|
||||||
|
RE_MD_CHARS_MATCHER_ALL = re.compile(r"([`\*_{}\[\]\(\)#!])")
|
||||||
|
|
||||||
|
# to find links in the text
|
||||||
|
RE_LINK = re.compile(r"(\[.*?\] ?\(.*?\))|(\[.*?\]:.*?)")
|
||||||
|
|
||||||
|
# to find table separators
|
||||||
|
RE_TABLE = re.compile(r" \| ")
|
||||||
|
|
||||||
|
RE_MD_DOT_MATCHER = re.compile(
|
||||||
|
r"""
|
||||||
|
^ # start of line
|
||||||
|
(\s*\d+) # optional whitespace and a number
|
||||||
|
(\.) # dot
|
||||||
|
(?=\s) # lookahead assert whitespace
|
||||||
|
""",
|
||||||
|
re.MULTILINE | re.VERBOSE,
|
||||||
|
)
|
||||||
|
RE_MD_PLUS_MATCHER = re.compile(
|
||||||
|
r"""
|
||||||
|
^
|
||||||
|
(\s*)
|
||||||
|
(\+)
|
||||||
|
(?=\s)
|
||||||
|
""",
|
||||||
|
flags=re.MULTILINE | re.VERBOSE,
|
||||||
|
)
|
||||||
|
RE_MD_DASH_MATCHER = re.compile(
|
||||||
|
r"""
|
||||||
|
^
|
||||||
|
(\s*)
|
||||||
|
(-)
|
||||||
|
(?=\s|\-) # followed by whitespace (bullet list, or spaced out hr)
|
||||||
|
# or another dash (header or hr)
|
||||||
|
""",
|
||||||
|
flags=re.MULTILINE | re.VERBOSE,
|
||||||
|
)
|
||||||
|
RE_SLASH_CHARS = r"\`*_{}[]()#+-.!"
|
||||||
|
RE_MD_BACKSLASH_MATCHER = re.compile(
|
||||||
|
r"""
|
||||||
|
(\\) # match one slash
|
||||||
|
(?=[%s]) # followed by a char that requires escaping
|
||||||
|
"""
|
||||||
|
% re.escape(RE_SLASH_CHARS),
|
||||||
|
flags=re.VERBOSE,
|
||||||
|
)
|
||||||
|
|
||||||
|
UNIFIABLE = {
|
||||||
|
"rsquo": "'",
|
||||||
|
"lsquo": "'",
|
||||||
|
"rdquo": '"',
|
||||||
|
"ldquo": '"',
|
||||||
|
"copy": "(C)",
|
||||||
|
"mdash": "--",
|
||||||
|
"nbsp": " ",
|
||||||
|
"rarr": "->",
|
||||||
|
"larr": "<-",
|
||||||
|
"middot": "*",
|
||||||
|
"ndash": "-",
|
||||||
|
"oelig": "oe",
|
||||||
|
"aelig": "ae",
|
||||||
|
"agrave": "a",
|
||||||
|
"aacute": "a",
|
||||||
|
"acirc": "a",
|
||||||
|
"atilde": "a",
|
||||||
|
"auml": "a",
|
||||||
|
"aring": "a",
|
||||||
|
"egrave": "e",
|
||||||
|
"eacute": "e",
|
||||||
|
"ecirc": "e",
|
||||||
|
"euml": "e",
|
||||||
|
"igrave": "i",
|
||||||
|
"iacute": "i",
|
||||||
|
"icirc": "i",
|
||||||
|
"iuml": "i",
|
||||||
|
"ograve": "o",
|
||||||
|
"oacute": "o",
|
||||||
|
"ocirc": "o",
|
||||||
|
"otilde": "o",
|
||||||
|
"ouml": "o",
|
||||||
|
"ugrave": "u",
|
||||||
|
"uacute": "u",
|
||||||
|
"ucirc": "u",
|
||||||
|
"uuml": "u",
|
||||||
|
"lrm": "",
|
||||||
|
"rlm": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Format tables in HTML rather than Markdown syntax
|
||||||
|
BYPASS_TABLES = False
|
||||||
|
# Ignore table-related tags (table, th, td, tr) while keeping rows
|
||||||
|
IGNORE_TABLES = False
|
||||||
|
|
||||||
|
|
||||||
|
# Use a single line break after a block element rather than two line breaks.
|
||||||
|
# NOTE: Requires body width setting to be 0.
|
||||||
|
SINGLE_LINE_BREAK = False
|
||||||
|
|
||||||
|
|
||||||
|
# Use double quotation marks when converting the <q> tag.
|
||||||
|
OPEN_QUOTE = '"'
|
||||||
|
CLOSE_QUOTE = '"'
|
||||||
18
migration/html2text/elements.py
Normal file
18
migration/html2text/elements.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class AnchorElement:
|
||||||
|
__slots__ = ["attrs", "count", "outcount"]
|
||||||
|
|
||||||
|
def __init__(self, attrs: Dict[str, Optional[str]], count: int, outcount: int):
|
||||||
|
self.attrs = attrs
|
||||||
|
self.count = count
|
||||||
|
self.outcount = outcount
|
||||||
|
|
||||||
|
|
||||||
|
class ListElement:
|
||||||
|
__slots__ = ["name", "num"]
|
||||||
|
|
||||||
|
def __init__(self, name: str, num: int):
|
||||||
|
self.name = name
|
||||||
|
self.num = num
|
||||||
3
migration/html2text/typing.py
Normal file
3
migration/html2text/typing.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
class OutCallback:
|
||||||
|
def __call__(self, s: str) -> None:
|
||||||
|
...
|
||||||
282
migration/html2text/utils.py
Normal file
282
migration/html2text/utils.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import html.entities
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
unifiable_n = {
|
||||||
|
html.entities.name2codepoint[k]: v for k, v in config.UNIFIABLE.items() if k != "nbsp"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def hn(tag: str) -> int:
|
||||||
|
if tag[0] == "h" and len(tag) == 2:
|
||||||
|
n = tag[1]
|
||||||
|
if "0" < n <= "9":
|
||||||
|
return int(n)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def dumb_property_dict(style: str) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
:returns: A hash of css attributes
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
x.strip().lower(): y.strip().lower()
|
||||||
|
for x, y in [z.split(":", 1) for z in style.split(";") if ":" in z]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dumb_css_parser(data: str) -> Dict[str, Dict[str, str]]:
|
||||||
|
"""
|
||||||
|
:type data: str
|
||||||
|
|
||||||
|
:returns: A hash of css selectors, each of which contains a hash of
|
||||||
|
css attributes.
|
||||||
|
:rtype: dict
|
||||||
|
"""
|
||||||
|
# remove @import sentences
|
||||||
|
data += ";"
|
||||||
|
importIndex = data.find("@import")
|
||||||
|
while importIndex != -1:
|
||||||
|
data = data[0:importIndex] + data[data.find(";", importIndex) + 1 :]
|
||||||
|
importIndex = data.find("@import")
|
||||||
|
|
||||||
|
# parse the css. reverted from dictionary comprehension in order to
|
||||||
|
# support older pythons
|
||||||
|
pairs = [x.split("{") for x in data.split("}") if "{" in x.strip()]
|
||||||
|
try:
|
||||||
|
elements = {a.strip(): dumb_property_dict(b) for a, b in pairs}
|
||||||
|
except ValueError:
|
||||||
|
elements = {} # not that important
|
||||||
|
|
||||||
|
return elements
|
||||||
|
|
||||||
|
|
||||||
|
def element_style(
|
||||||
|
attrs: Dict[str, Optional[str]],
|
||||||
|
style_def: Dict[str, Dict[str, str]],
|
||||||
|
parent_style: Dict[str, str],
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
:type attrs: dict
|
||||||
|
:type style_def: dict
|
||||||
|
:type style_def: dict
|
||||||
|
|
||||||
|
:returns: A hash of the 'final' style attributes of the element
|
||||||
|
:rtype: dict
|
||||||
|
"""
|
||||||
|
style = parent_style.copy()
|
||||||
|
attrs_class = attrs.get("class")
|
||||||
|
if attrs_class:
|
||||||
|
for css_class in attrs_class.split():
|
||||||
|
css_style = style_def.get("." + css_class, {})
|
||||||
|
style.update(css_style)
|
||||||
|
attrs_style = attrs.get("style")
|
||||||
|
if attrs_style:
|
||||||
|
immediate_style = dumb_property_dict(attrs_style)
|
||||||
|
style.update(immediate_style)
|
||||||
|
|
||||||
|
return style
|
||||||
|
|
||||||
|
|
||||||
|
def google_list_style(style: Dict[str, str]) -> str:
|
||||||
|
"""
|
||||||
|
Finds out whether this is an ordered or unordered list
|
||||||
|
|
||||||
|
:type style: dict
|
||||||
|
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
if "list-style-type" in style:
|
||||||
|
list_style = style["list-style-type"]
|
||||||
|
if list_style in ["disc", "circle", "square", "none"]:
|
||||||
|
return "ul"
|
||||||
|
|
||||||
|
return "ol"
|
||||||
|
|
||||||
|
|
||||||
|
def google_has_height(style: Dict[str, str]) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the style of the element has the 'height' attribute
|
||||||
|
explicitly defined
|
||||||
|
|
||||||
|
:type style: dict
|
||||||
|
|
||||||
|
:rtype: bool
|
||||||
|
"""
|
||||||
|
return "height" in style
|
||||||
|
|
||||||
|
|
||||||
|
def google_text_emphasis(style: Dict[str, str]) -> List[str]:
|
||||||
|
"""
|
||||||
|
:type style: dict
|
||||||
|
|
||||||
|
:returns: A list of all emphasis modifiers of the element
|
||||||
|
:rtype: list
|
||||||
|
"""
|
||||||
|
emphasis = []
|
||||||
|
if "text-decoration" in style:
|
||||||
|
emphasis.append(style["text-decoration"])
|
||||||
|
if "font-style" in style:
|
||||||
|
emphasis.append(style["font-style"])
|
||||||
|
if "font-weight" in style:
|
||||||
|
emphasis.append(style["font-weight"])
|
||||||
|
|
||||||
|
return emphasis
|
||||||
|
|
||||||
|
|
||||||
|
def google_fixed_width_font(style: Dict[str, str]) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the css of the current element defines a fixed width font
|
||||||
|
|
||||||
|
:type style: dict
|
||||||
|
|
||||||
|
:rtype: bool
|
||||||
|
"""
|
||||||
|
font_family = ""
|
||||||
|
if "font-family" in style:
|
||||||
|
font_family = style["font-family"]
|
||||||
|
return "courier new" == font_family or "consolas" == font_family
|
||||||
|
|
||||||
|
|
||||||
|
def list_numbering_start(attrs: Dict[str, Optional[str]]) -> int:
|
||||||
|
"""
|
||||||
|
Extract numbering from list element attributes
|
||||||
|
|
||||||
|
:type attrs: dict
|
||||||
|
|
||||||
|
:rtype: int or None
|
||||||
|
"""
|
||||||
|
attrs_start = attrs.get("start")
|
||||||
|
if attrs_start:
|
||||||
|
try:
|
||||||
|
return int(attrs_start) - 1
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def skipwrap(para: str, wrap_links: bool, wrap_list_items: bool, wrap_tables: bool) -> bool:
|
||||||
|
# If it appears to contain a link
|
||||||
|
# don't wrap
|
||||||
|
if not wrap_links and config.RE_LINK.search(para):
|
||||||
|
return True
|
||||||
|
# If the text begins with four spaces or one tab, it's a code block;
|
||||||
|
# don't wrap
|
||||||
|
if para[0:4] == " " or para[0] == "\t":
|
||||||
|
return True
|
||||||
|
|
||||||
|
# If the text begins with only two "--", possibly preceded by
|
||||||
|
# whitespace, that's an emdash; so wrap.
|
||||||
|
stripped = para.lstrip()
|
||||||
|
if stripped[0:2] == "--" and len(stripped) > 2 and stripped[2] != "-":
|
||||||
|
return False
|
||||||
|
|
||||||
|
# I'm not sure what this is for; I thought it was to detect lists,
|
||||||
|
# but there's a <br>-inside-<span> case in one of the tests that
|
||||||
|
# also depends upon it.
|
||||||
|
if stripped[0:1] in ("-", "*") and not stripped[0:2] == "**":
|
||||||
|
return not wrap_list_items
|
||||||
|
|
||||||
|
# If text contains a pipe character it is likely a table
|
||||||
|
if not wrap_tables and config.RE_TABLE.search(para):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# If the text begins with a single -, *, or +, followed by a space,
|
||||||
|
# or an integer, followed by a ., followed by a space (in either
|
||||||
|
# case optionally proceeded by whitespace), it's a list; don't wrap.
|
||||||
|
return bool(
|
||||||
|
config.RE_ORDERED_LIST_MATCHER.match(stripped)
|
||||||
|
or config.RE_UNORDERED_LIST_MATCHER.match(stripped)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def escape_md(text: str) -> str:
|
||||||
|
"""
|
||||||
|
Escapes markdown-sensitive characters within other markdown
|
||||||
|
constructs.
|
||||||
|
"""
|
||||||
|
return config.RE_MD_CHARS_MATCHER.sub(r"\\\1", text)
|
||||||
|
|
||||||
|
|
||||||
|
def escape_md_section(text: str, snob: bool = False) -> str:
|
||||||
|
"""
|
||||||
|
Escapes markdown-sensitive characters across whole document sections.
|
||||||
|
"""
|
||||||
|
text = config.RE_MD_BACKSLASH_MATCHER.sub(r"\\\1", text)
|
||||||
|
|
||||||
|
if snob:
|
||||||
|
text = config.RE_MD_CHARS_MATCHER_ALL.sub(r"\\\1", text)
|
||||||
|
|
||||||
|
text = config.RE_MD_DOT_MATCHER.sub(r"\1\\\2", text)
|
||||||
|
text = config.RE_MD_PLUS_MATCHER.sub(r"\1\\\2", text)
|
||||||
|
text = config.RE_MD_DASH_MATCHER.sub(r"\1\\\2", text)
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def reformat_table(lines: List[str], right_margin: int) -> List[str]:
|
||||||
|
"""
|
||||||
|
Given the lines of a table
|
||||||
|
padds the cells and returns the new lines
|
||||||
|
"""
|
||||||
|
# find the maximum width of the columns
|
||||||
|
max_width = [len(x.rstrip()) + right_margin for x in lines[0].split("|")]
|
||||||
|
max_cols = len(max_width)
|
||||||
|
for line in lines:
|
||||||
|
cols = [x.rstrip() for x in line.split("|")]
|
||||||
|
num_cols = len(cols)
|
||||||
|
|
||||||
|
# don't drop any data if colspan attributes result in unequal lengths
|
||||||
|
if num_cols < max_cols:
|
||||||
|
cols += [""] * (max_cols - num_cols)
|
||||||
|
elif max_cols < num_cols:
|
||||||
|
max_width += [len(x) + right_margin for x in cols[-(num_cols - max_cols) :]]
|
||||||
|
max_cols = num_cols
|
||||||
|
|
||||||
|
max_width = [max(len(x) + right_margin, old_len) for x, old_len in zip(cols, max_width)]
|
||||||
|
|
||||||
|
# reformat
|
||||||
|
new_lines = []
|
||||||
|
for line in lines:
|
||||||
|
cols = [x.rstrip() for x in line.split("|")]
|
||||||
|
if set(line.strip()) == set("-|"):
|
||||||
|
filler = "-"
|
||||||
|
new_cols = [
|
||||||
|
x.rstrip() + (filler * (M - len(x.rstrip()))) for x, M in zip(cols, max_width)
|
||||||
|
]
|
||||||
|
new_lines.append("|-" + "|".join(new_cols) + "|")
|
||||||
|
else:
|
||||||
|
filler = " "
|
||||||
|
new_cols = [
|
||||||
|
x.rstrip() + (filler * (M - len(x.rstrip()))) for x, M in zip(cols, max_width)
|
||||||
|
]
|
||||||
|
new_lines.append("| " + "|".join(new_cols) + "|")
|
||||||
|
return new_lines
|
||||||
|
|
||||||
|
|
||||||
|
def pad_tables_in_text(text: str, right_margin: int = 1) -> str:
|
||||||
|
"""
|
||||||
|
Provide padding for tables in the text
|
||||||
|
"""
|
||||||
|
lines = text.split("\n")
|
||||||
|
table_buffer = [] # type: List[str]
|
||||||
|
table_started = False
|
||||||
|
new_lines = []
|
||||||
|
for line in lines:
|
||||||
|
# Toggle table started
|
||||||
|
if config.TABLE_MARKER_FOR_PAD in line:
|
||||||
|
table_started = not table_started
|
||||||
|
if not table_started:
|
||||||
|
table = reformat_table(table_buffer, right_margin)
|
||||||
|
new_lines.extend(table)
|
||||||
|
table_buffer = []
|
||||||
|
new_lines.append("")
|
||||||
|
continue
|
||||||
|
# Process lines
|
||||||
|
if table_started:
|
||||||
|
table_buffer.append(line)
|
||||||
|
else:
|
||||||
|
new_lines.append(line)
|
||||||
|
return "\n".join(new_lines)
|
||||||
196
migration/tables/comments.py
Normal file
196
migration/tables/comments.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from dateutil.parser import parse as date_parse
|
||||||
|
|
||||||
|
from base.orm import local_session
|
||||||
|
from migration.html2text import html2text
|
||||||
|
from orm.reaction import Reaction, ReactionKind
|
||||||
|
from orm.shout import Shout, ShoutReactionsFollower
|
||||||
|
from orm.topic import TopicFollower
|
||||||
|
from orm.user import User
|
||||||
|
|
||||||
|
ts = datetime.now(tz=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def auto_followers(session, topics, reaction_dict):
|
||||||
|
# creating shout's reactions following for reaction author
|
||||||
|
following1 = (
|
||||||
|
session.query(ShoutReactionsFollower)
|
||||||
|
.where(ShoutReactionsFollower.follower == reaction_dict["createdBy"])
|
||||||
|
.filter(ShoutReactionsFollower.shout == reaction_dict["shout"])
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not following1:
|
||||||
|
following1 = ShoutReactionsFollower.create(
|
||||||
|
follower=reaction_dict["createdBy"], shout=reaction_dict["shout"], auto=True
|
||||||
|
)
|
||||||
|
session.add(following1)
|
||||||
|
# creating topics followings for reaction author
|
||||||
|
for t in topics:
|
||||||
|
tf = (
|
||||||
|
session.query(TopicFollower)
|
||||||
|
.where(TopicFollower.follower == reaction_dict["createdBy"])
|
||||||
|
.filter(TopicFollower.topic == t["id"])
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not tf:
|
||||||
|
topic_following = TopicFollower.create(
|
||||||
|
follower=reaction_dict["createdBy"], topic=t["id"], auto=True
|
||||||
|
)
|
||||||
|
session.add(topic_following)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_ratings(session, entry, reaction_dict):
|
||||||
|
for comment_rating_old in entry.get("ratings", []):
|
||||||
|
rater = session.query(User).filter(User.oid == comment_rating_old["createdBy"]).first()
|
||||||
|
re_reaction_dict = {
|
||||||
|
"shout": reaction_dict["shout"],
|
||||||
|
"replyTo": reaction_dict["id"],
|
||||||
|
"kind": ReactionKind.LIKE if comment_rating_old["value"] > 0 else ReactionKind.DISLIKE,
|
||||||
|
"createdBy": rater.id if rater else 1,
|
||||||
|
}
|
||||||
|
cts = comment_rating_old.get("createdAt")
|
||||||
|
if cts:
|
||||||
|
re_reaction_dict["createdAt"] = date_parse(cts)
|
||||||
|
try:
|
||||||
|
# creating reaction from old rating
|
||||||
|
rr = Reaction.create(**re_reaction_dict)
|
||||||
|
following2 = (
|
||||||
|
session.query(ShoutReactionsFollower)
|
||||||
|
.where(ShoutReactionsFollower.follower == re_reaction_dict["createdBy"])
|
||||||
|
.filter(ShoutReactionsFollower.shout == rr.shout)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not following2:
|
||||||
|
following2 = ShoutReactionsFollower.create(
|
||||||
|
follower=re_reaction_dict["createdBy"], shout=rr.shout, auto=True
|
||||||
|
)
|
||||||
|
session.add(following2)
|
||||||
|
session.add(rr)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print("[migration] comment rating error: %r" % re_reaction_dict)
|
||||||
|
raise e
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate(entry, storage):
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"_id": "hdtwS8fSyFLxXCgSC",
|
||||||
|
"body": "<p>",
|
||||||
|
"contentItem": "mnK8KsJHPRi8DrybQ",
|
||||||
|
"createdBy": "bMFPuyNg6qAD2mhXe",
|
||||||
|
"thread": "01/",
|
||||||
|
"createdAt": "2016-04-19 04:33:53+00:00",
|
||||||
|
"ratings": [
|
||||||
|
{ "createdBy": "AqmRukvRiExNpAe8C", "value": 1 },
|
||||||
|
{ "createdBy": "YdE76Wth3yqymKEu5", "value": 1 }
|
||||||
|
],
|
||||||
|
"rating": 2,
|
||||||
|
"updatedAt": "2020-05-27 19:22:57.091000+00:00",
|
||||||
|
"updatedBy": "0"
|
||||||
|
}
|
||||||
|
->
|
||||||
|
type Reaction {
|
||||||
|
id: Int!
|
||||||
|
shout: Shout!
|
||||||
|
createdAt: DateTime!
|
||||||
|
createdBy: User!
|
||||||
|
updatedAt: DateTime
|
||||||
|
deletedAt: DateTime
|
||||||
|
deletedBy: User
|
||||||
|
range: String # full / 0:2340
|
||||||
|
kind: ReactionKind!
|
||||||
|
body: String
|
||||||
|
replyTo: Reaction
|
||||||
|
stat: Stat
|
||||||
|
old_id: String
|
||||||
|
old_thread: String
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
old_ts = entry.get("createdAt")
|
||||||
|
reaction_dict = {
|
||||||
|
"createdAt": (ts if not old_ts else date_parse(old_ts)),
|
||||||
|
"body": html2text(entry.get("body", "")),
|
||||||
|
"oid": entry["_id"],
|
||||||
|
}
|
||||||
|
shout_oid = entry.get("contentItem")
|
||||||
|
if shout_oid not in storage["shouts"]["by_oid"]:
|
||||||
|
if len(storage["shouts"]["by_oid"]) > 0:
|
||||||
|
return shout_oid
|
||||||
|
else:
|
||||||
|
print("[migration] no shouts migrated yet")
|
||||||
|
raise Exception
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
stage = "started"
|
||||||
|
reaction = None
|
||||||
|
with local_session() as session:
|
||||||
|
author = session.query(User).filter(User.oid == entry["createdBy"]).first()
|
||||||
|
old_shout = storage["shouts"]["by_oid"].get(shout_oid)
|
||||||
|
if not old_shout:
|
||||||
|
raise Exception("no old shout in storage")
|
||||||
|
else:
|
||||||
|
stage = "author and old id found"
|
||||||
|
try:
|
||||||
|
shout = session.query(Shout).where(Shout.slug == old_shout["slug"]).one()
|
||||||
|
if shout:
|
||||||
|
reaction_dict["shout"] = shout.id
|
||||||
|
reaction_dict["createdBy"] = author.id if author else 1
|
||||||
|
reaction_dict["kind"] = ReactionKind.COMMENT
|
||||||
|
|
||||||
|
# creating reaction from old comment
|
||||||
|
reaction = Reaction.create(**reaction_dict)
|
||||||
|
session.add(reaction)
|
||||||
|
# session.commit()
|
||||||
|
stage = "new reaction commited"
|
||||||
|
reaction_dict = reaction.dict()
|
||||||
|
topics = [t.dict() for t in shout.topics]
|
||||||
|
auto_followers(session, topics, reaction_dict)
|
||||||
|
|
||||||
|
migrate_ratings(session, entry, reaction_dict)
|
||||||
|
|
||||||
|
return reaction
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
print(reaction)
|
||||||
|
raise Exception(stage)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_2stage(old_comment, idmap):
|
||||||
|
if old_comment.get("body"):
|
||||||
|
new_id = idmap.get(old_comment.get("oid"))
|
||||||
|
new_id = idmap.get(old_comment.get("_id"))
|
||||||
|
if new_id:
|
||||||
|
new_replyto_id = None
|
||||||
|
old_replyto_id = old_comment.get("replyTo")
|
||||||
|
if old_replyto_id:
|
||||||
|
new_replyto_id = int(idmap.get(old_replyto_id, "0"))
|
||||||
|
with local_session() as session:
|
||||||
|
comment = session.query(Reaction).where(Reaction.id == new_id).first()
|
||||||
|
try:
|
||||||
|
if new_replyto_id:
|
||||||
|
new_reply = (
|
||||||
|
session.query(Reaction).where(Reaction.id == new_replyto_id).first()
|
||||||
|
)
|
||||||
|
if not new_reply:
|
||||||
|
print(new_replyto_id)
|
||||||
|
raise Exception("cannot find reply by id!")
|
||||||
|
comment.replyTo = new_reply.id
|
||||||
|
session.add(comment)
|
||||||
|
srf = (
|
||||||
|
session.query(ShoutReactionsFollower)
|
||||||
|
.where(ShoutReactionsFollower.shout == comment.shout)
|
||||||
|
.filter(ShoutReactionsFollower.follower == comment.createdBy)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not srf:
|
||||||
|
srf = ShoutReactionsFollower.create(
|
||||||
|
shout=comment.shout, follower=comment.createdBy, auto=True
|
||||||
|
)
|
||||||
|
session.add(srf)
|
||||||
|
session.commit()
|
||||||
|
except Exception:
|
||||||
|
raise Exception("cannot find a comment by oldid")
|
||||||
399
migration/tables/content_items.py
Normal file
399
migration/tables/content_items.py
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from dateutil.parser import parse as date_parse
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from transliterate import translit
|
||||||
|
|
||||||
|
from base.orm import local_session
|
||||||
|
from migration.extract import extract_html, extract_media
|
||||||
|
from orm.reaction import Reaction, ReactionKind
|
||||||
|
from orm.shout import Shout, ShoutReactionsFollower, ShoutTopic
|
||||||
|
from orm.topic import Topic, TopicFollower
|
||||||
|
from orm.user import User
|
||||||
|
from services.stat.viewed import ViewedStorage
|
||||||
|
|
||||||
|
OLD_DATE = "2016-03-05 22:22:00.350000"
|
||||||
|
ts = datetime.now(tz=timezone.utc)
|
||||||
|
type2layout = {
|
||||||
|
"Article": "article",
|
||||||
|
"Literature": "literature",
|
||||||
|
"Music": "music",
|
||||||
|
"Video": "video",
|
||||||
|
"Image": "image",
|
||||||
|
}
|
||||||
|
|
||||||
|
anondict = {"slug": "anonymous", "id": 1, "name": "Аноним"}
|
||||||
|
discours = {"slug": "discours", "id": 2, "name": "Дискурс"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_shout_slug(entry):
|
||||||
|
slug = entry.get("slug", "")
|
||||||
|
if not slug:
|
||||||
|
for friend in entry.get("friendlySlugs", []):
|
||||||
|
slug = friend.get("slug", "")
|
||||||
|
if slug:
|
||||||
|
break
|
||||||
|
slug = re.sub("[^0-9a-zA-Z]+", "-", slug)
|
||||||
|
return slug
|
||||||
|
|
||||||
|
|
||||||
|
def create_author_from_app(app):
|
||||||
|
user = None
|
||||||
|
userdata = None
|
||||||
|
# check if email is used
|
||||||
|
if app["email"]:
|
||||||
|
with local_session() as session:
|
||||||
|
user = session.query(User).where(User.email == app["email"]).first()
|
||||||
|
if not user:
|
||||||
|
# print('[migration] app %r' % app)
|
||||||
|
name = app.get("name")
|
||||||
|
if name:
|
||||||
|
slug = translit(name, "ru", reversed=True).lower()
|
||||||
|
slug = re.sub("[^0-9a-zA-Z]+", "-", slug)
|
||||||
|
print("[migration] created slug %s" % slug)
|
||||||
|
# check if slug is used
|
||||||
|
if slug:
|
||||||
|
user = session.query(User).where(User.slug == slug).first()
|
||||||
|
|
||||||
|
# get slug from email
|
||||||
|
if user:
|
||||||
|
slug = app["email"].split("@")[0]
|
||||||
|
user = session.query(User).where(User.slug == slug).first()
|
||||||
|
# one more try
|
||||||
|
if user:
|
||||||
|
slug += "-author"
|
||||||
|
user = session.query(User).where(User.slug == slug).first()
|
||||||
|
|
||||||
|
# create user with application data
|
||||||
|
if not user:
|
||||||
|
userdata = {
|
||||||
|
"username": app["email"],
|
||||||
|
"email": app["email"],
|
||||||
|
"name": app.get("name", ""),
|
||||||
|
"emailConfirmed": False,
|
||||||
|
"slug": slug,
|
||||||
|
"createdAt": ts,
|
||||||
|
"lastSeen": ts,
|
||||||
|
}
|
||||||
|
# print('[migration] userdata %r' % userdata)
|
||||||
|
user = User.create(**userdata)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
userdata["id"] = user.id
|
||||||
|
|
||||||
|
userdata = user.dict()
|
||||||
|
return userdata
|
||||||
|
else:
|
||||||
|
raise Exception("app is not ok", app)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_shout(shout_dict):
|
||||||
|
s = Shout.create(**shout_dict)
|
||||||
|
author = s.authors[0]
|
||||||
|
with local_session() as session:
|
||||||
|
srf = (
|
||||||
|
session.query(ShoutReactionsFollower)
|
||||||
|
.where(ShoutReactionsFollower.shout == s.id)
|
||||||
|
.filter(ShoutReactionsFollower.follower == author.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not srf:
|
||||||
|
srf = ShoutReactionsFollower.create(shout=s.id, follower=author.id, auto=True)
|
||||||
|
session.add(srf)
|
||||||
|
session.commit()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user(entry, storage):
|
||||||
|
app = entry.get("application")
|
||||||
|
userdata = None
|
||||||
|
user_oid = None
|
||||||
|
if app:
|
||||||
|
userdata = create_author_from_app(app)
|
||||||
|
else:
|
||||||
|
user_oid = entry.get("createdBy")
|
||||||
|
if user_oid == "0":
|
||||||
|
userdata = discours
|
||||||
|
elif user_oid:
|
||||||
|
userdata = storage["users"]["by_oid"].get(user_oid)
|
||||||
|
if not userdata:
|
||||||
|
print("no userdata by oid, anonymous")
|
||||||
|
userdata = anondict
|
||||||
|
print(app)
|
||||||
|
# cleanup slug
|
||||||
|
if userdata:
|
||||||
|
slug = userdata.get("slug", "")
|
||||||
|
if slug:
|
||||||
|
slug = re.sub("[^0-9a-zA-Z]+", "-", slug)
|
||||||
|
userdata["slug"] = slug
|
||||||
|
else:
|
||||||
|
userdata = anondict
|
||||||
|
|
||||||
|
user = await process_user(userdata, storage, user_oid)
|
||||||
|
return user, user_oid
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate(entry, storage):
|
||||||
|
author, user_oid = await get_user(entry, storage)
|
||||||
|
r = {
|
||||||
|
"layout": type2layout[entry["type"]],
|
||||||
|
"title": entry["title"],
|
||||||
|
"authors": [
|
||||||
|
author,
|
||||||
|
],
|
||||||
|
"slug": get_shout_slug(entry),
|
||||||
|
"cover": (
|
||||||
|
"https://images.discours.io/unsafe/" + entry["thumborId"]
|
||||||
|
if entry.get("thumborId")
|
||||||
|
else entry.get("image", {}).get("url")
|
||||||
|
),
|
||||||
|
"visibility": "public" if entry.get("published") else "community",
|
||||||
|
"publishedAt": date_parse(entry.get("publishedAt")) if entry.get("published") else None,
|
||||||
|
"deletedAt": date_parse(entry.get("deletedAt")) if entry.get("deletedAt") else None,
|
||||||
|
"createdAt": date_parse(entry.get("createdAt", OLD_DATE)),
|
||||||
|
"updatedAt": date_parse(entry["updatedAt"]) if "updatedAt" in entry else ts,
|
||||||
|
"createdBy": author.id,
|
||||||
|
"topics": await add_topics_follower(entry, storage, author),
|
||||||
|
"body": extract_html(entry, cleanup=True),
|
||||||
|
}
|
||||||
|
|
||||||
|
# main topic patch
|
||||||
|
r["mainTopic"] = r["topics"][0]
|
||||||
|
|
||||||
|
# published author auto-confirm
|
||||||
|
if entry.get("published"):
|
||||||
|
with local_session() as session:
|
||||||
|
# update user.emailConfirmed if published
|
||||||
|
author.emailConfirmed = True
|
||||||
|
session.add(author)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# media
|
||||||
|
media = extract_media(entry)
|
||||||
|
r["media"] = json.dumps(media, ensure_ascii=True) if media else None
|
||||||
|
|
||||||
|
# ----------------------------------- copy
|
||||||
|
shout_dict = r.copy()
|
||||||
|
del shout_dict["topics"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# save shout to db
|
||||||
|
shout_dict["oid"] = entry.get("_id", "")
|
||||||
|
shout = await create_shout(shout_dict)
|
||||||
|
except IntegrityError as e:
|
||||||
|
print("[migration] create_shout integrity error", e)
|
||||||
|
shout = await resolve_create_shout(shout_dict)
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(e)
|
||||||
|
|
||||||
|
# udpate data
|
||||||
|
shout_dict = shout.dict()
|
||||||
|
shout_dict["authors"] = [
|
||||||
|
author.dict(),
|
||||||
|
]
|
||||||
|
|
||||||
|
# shout topics aftermath
|
||||||
|
shout_dict["topics"] = await topics_aftermath(r, storage)
|
||||||
|
|
||||||
|
# content_item ratings to reactions
|
||||||
|
await content_ratings_to_reactions(entry, shout_dict["slug"])
|
||||||
|
|
||||||
|
# shout views
|
||||||
|
await ViewedStorage.increment(
|
||||||
|
shout_dict["slug"], amount=entry.get("views", 1), viewer="old-discours"
|
||||||
|
)
|
||||||
|
# del shout_dict['ratings']
|
||||||
|
|
||||||
|
storage["shouts"]["by_oid"][entry["_id"]] = shout_dict
|
||||||
|
storage["shouts"]["by_slug"][shout_dict["slug"]] = shout_dict
|
||||||
|
return shout_dict
|
||||||
|
|
||||||
|
|
||||||
|
async def add_topics_follower(entry, storage, user):
|
||||||
|
topics = set([])
|
||||||
|
category = entry.get("category")
|
||||||
|
topics_by_oid = storage["topics"]["by_oid"]
|
||||||
|
oids = [
|
||||||
|
category,
|
||||||
|
] + entry.get("tags", [])
|
||||||
|
for toid in oids:
|
||||||
|
tslug = topics_by_oid.get(toid, {}).get("slug")
|
||||||
|
if tslug:
|
||||||
|
topics.add(tslug)
|
||||||
|
ttt = list(topics)
|
||||||
|
# add author as TopicFollower
|
||||||
|
with local_session() as session:
|
||||||
|
for tpcslug in topics:
|
||||||
|
try:
|
||||||
|
tpc = session.query(Topic).where(Topic.slug == tpcslug).first()
|
||||||
|
if tpc:
|
||||||
|
tf = (
|
||||||
|
session.query(TopicFollower)
|
||||||
|
.where(TopicFollower.follower == user.id)
|
||||||
|
.filter(TopicFollower.topic == tpc.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not tf:
|
||||||
|
tf = TopicFollower.create(topic=tpc.id, follower=user.id, auto=True)
|
||||||
|
session.add(tf)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
print("[migration.shout] hidden by topic " + tpc.slug)
|
||||||
|
# main topic
|
||||||
|
maintopic = storage["replacements"].get(topics_by_oid.get(category, {}).get("slug"))
|
||||||
|
if maintopic in ttt:
|
||||||
|
ttt.remove(maintopic)
|
||||||
|
ttt.insert(0, maintopic)
|
||||||
|
return ttt
|
||||||
|
|
||||||
|
|
||||||
|
async def process_user(userdata, storage, oid):
|
||||||
|
with local_session() as session:
|
||||||
|
uid = userdata.get("id") # anonymous as
|
||||||
|
if not uid:
|
||||||
|
print(userdata)
|
||||||
|
print("has no id field, set it @anonymous")
|
||||||
|
userdata = anondict
|
||||||
|
uid = 1
|
||||||
|
user = session.query(User).filter(User.id == uid).first()
|
||||||
|
if not user:
|
||||||
|
try:
|
||||||
|
slug = userdata["slug"].lower().strip()
|
||||||
|
slug = re.sub("[^0-9a-zA-Z]+", "-", slug)
|
||||||
|
userdata["slug"] = slug
|
||||||
|
user = User.create(**userdata)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
print(f"[migration] user creating with slug {userdata['slug']}")
|
||||||
|
print("[migration] from userdata")
|
||||||
|
print(userdata)
|
||||||
|
raise Exception("[migration] cannot create user in content_items.get_user()")
|
||||||
|
if user.id == 946:
|
||||||
|
print("[migration] ***************** ALPINA")
|
||||||
|
if user.id == 2:
|
||||||
|
print("[migration] +++++++++++++++++ DISCOURS")
|
||||||
|
userdata["id"] = user.id
|
||||||
|
userdata["createdAt"] = user.createdAt
|
||||||
|
storage["users"]["by_slug"][userdata["slug"]] = userdata
|
||||||
|
storage["users"]["by_oid"][oid] = userdata
|
||||||
|
if not user:
|
||||||
|
raise Exception("could not get a user")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_create_shout(shout_dict):
|
||||||
|
with local_session() as session:
|
||||||
|
s = session.query(Shout).filter(Shout.slug == shout_dict["slug"]).first()
|
||||||
|
bump = False
|
||||||
|
if s:
|
||||||
|
if s.createdAt != shout_dict["createdAt"]:
|
||||||
|
# create new with different slug
|
||||||
|
shout_dict["slug"] += "-" + shout_dict["layout"]
|
||||||
|
try:
|
||||||
|
await create_shout(shout_dict)
|
||||||
|
except IntegrityError as e:
|
||||||
|
print(e)
|
||||||
|
bump = True
|
||||||
|
else:
|
||||||
|
# update old
|
||||||
|
for key in shout_dict:
|
||||||
|
if key in s.__dict__:
|
||||||
|
if s.__dict__[key] != shout_dict[key]:
|
||||||
|
print("[migration] shout already exists, but differs in %s" % key)
|
||||||
|
bump = True
|
||||||
|
else:
|
||||||
|
print("[migration] shout already exists, but lacks %s" % key)
|
||||||
|
bump = True
|
||||||
|
if bump:
|
||||||
|
s.update(shout_dict)
|
||||||
|
else:
|
||||||
|
print("[migration] something went wrong with shout: \n%r" % shout_dict)
|
||||||
|
raise Exception("")
|
||||||
|
session.commit()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
async def topics_aftermath(entry, storage):
|
||||||
|
r = []
|
||||||
|
for tpc in filter(lambda x: bool(x), entry["topics"]):
|
||||||
|
oldslug = tpc
|
||||||
|
newslug = storage["replacements"].get(oldslug, oldslug)
|
||||||
|
|
||||||
|
if newslug:
|
||||||
|
with local_session() as session:
|
||||||
|
shout = session.query(Shout).where(Shout.slug == entry["slug"]).first()
|
||||||
|
new_topic = session.query(Topic).where(Topic.slug == newslug).first()
|
||||||
|
|
||||||
|
shout_topic_old = (
|
||||||
|
session.query(ShoutTopic)
|
||||||
|
.join(Shout)
|
||||||
|
.join(Topic)
|
||||||
|
.filter(Shout.slug == entry["slug"])
|
||||||
|
.filter(Topic.slug == oldslug)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if shout_topic_old:
|
||||||
|
shout_topic_old.update({"topic": new_topic.id})
|
||||||
|
else:
|
||||||
|
shout_topic_new = (
|
||||||
|
session.query(ShoutTopic)
|
||||||
|
.join(Shout)
|
||||||
|
.join(Topic)
|
||||||
|
.filter(Shout.slug == entry["slug"])
|
||||||
|
.filter(Topic.slug == newslug)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not shout_topic_new:
|
||||||
|
try:
|
||||||
|
ShoutTopic.create(**{"shout": shout.id, "topic": new_topic.id})
|
||||||
|
except Exception:
|
||||||
|
print("[migration] shout topic error: " + newslug)
|
||||||
|
session.commit()
|
||||||
|
if newslug not in r:
|
||||||
|
r.append(newslug)
|
||||||
|
else:
|
||||||
|
print("[migration] ignored topic slug: \n%r" % tpc["slug"])
|
||||||
|
# raise Exception
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
async def content_ratings_to_reactions(entry, slug):
|
||||||
|
try:
|
||||||
|
with local_session() as session:
|
||||||
|
for content_rating in entry.get("ratings", []):
|
||||||
|
rater = (
|
||||||
|
session.query(User).filter(User.oid == content_rating["createdBy"]).first()
|
||||||
|
) or User.default_user
|
||||||
|
shout = session.query(Shout).where(Shout.slug == slug).first()
|
||||||
|
cts = content_rating.get("createdAt")
|
||||||
|
reaction_dict = {
|
||||||
|
"createdAt": date_parse(cts) if cts else None,
|
||||||
|
"kind": ReactionKind.LIKE
|
||||||
|
if content_rating["value"] > 0
|
||||||
|
else ReactionKind.DISLIKE,
|
||||||
|
"createdBy": rater.id,
|
||||||
|
"shout": shout.id,
|
||||||
|
}
|
||||||
|
reaction = (
|
||||||
|
session.query(Reaction)
|
||||||
|
.filter(Reaction.shout == reaction_dict["shout"])
|
||||||
|
.filter(Reaction.createdBy == reaction_dict["createdBy"])
|
||||||
|
.filter(Reaction.kind == reaction_dict["kind"])
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if reaction:
|
||||||
|
k = ReactionKind.AGREE if content_rating["value"] > 0 else ReactionKind.DISAGREE
|
||||||
|
reaction_dict["kind"] = k
|
||||||
|
reaction.update(reaction_dict)
|
||||||
|
session.add(reaction)
|
||||||
|
else:
|
||||||
|
rea = Reaction.create(**reaction_dict)
|
||||||
|
session.add(rea)
|
||||||
|
# shout_dict['ratings'].append(reaction_dict)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
except Exception:
|
||||||
|
print("[migration] content_item.ratings error: \n%r" % content_rating)
|
||||||
35
migration/tables/remarks.py
Normal file
35
migration/tables/remarks.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# from base.orm import local_session
|
||||||
|
|
||||||
|
# from migration.extract import extract_md
|
||||||
|
# from migration.html2text import html2text
|
||||||
|
# from orm.reaction import Reaction, ReactionKind
|
||||||
|
|
||||||
|
|
||||||
|
# def migrate(entry, storage):
|
||||||
|
# post_oid = entry["contentItem"]
|
||||||
|
# print(post_oid)
|
||||||
|
# shout_dict = storage["shouts"]["by_oid"].get(post_oid)
|
||||||
|
# if shout_dict:
|
||||||
|
# print(shout_dict["body"])
|
||||||
|
# remark = {
|
||||||
|
# "shout": shout_dict["id"],
|
||||||
|
# "body": extract_md(html2text(entry["body"]), shout_dict),
|
||||||
|
# "kind": ReactionKind.REMARK,
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# if entry.get("textBefore"):
|
||||||
|
# remark["range"] = (
|
||||||
|
# str(shout_dict["body"].index(entry["textBefore"] or ""))
|
||||||
|
# + ":"
|
||||||
|
# + str(
|
||||||
|
# shout_dict["body"].index(entry["textAfter"] or "")
|
||||||
|
# + len(entry["textAfter"] or "")
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# with local_session() as session:
|
||||||
|
# rmrk = Reaction.create(**remark)
|
||||||
|
# session.commit()
|
||||||
|
# del rmrk["_sa_instance_state"]
|
||||||
|
# return rmrk
|
||||||
|
# return
|
||||||
828
migration/tables/replacements.json
Normal file
828
migration/tables/replacements.json
Normal file
@@ -0,0 +1,828 @@
|
|||||||
|
{
|
||||||
|
"207": "207",
|
||||||
|
"1990-e": "90s",
|
||||||
|
"2000-e": "2000s",
|
||||||
|
"90-e": "90s",
|
||||||
|
"Georgia": "georgia",
|
||||||
|
"Japan": "japan",
|
||||||
|
"Sweden": "sweden",
|
||||||
|
"abstraktsiya": "abstract",
|
||||||
|
"absurdism": "absurdism",
|
||||||
|
"acclimatization": "acclimatisation",
|
||||||
|
"activism": "activism",
|
||||||
|
"adolf-gitler": "adolf-hitler",
|
||||||
|
"afrika": "africa",
|
||||||
|
"agata-kristi": "agatha-christie",
|
||||||
|
"agressivnoe-povedenie": "agression",
|
||||||
|
"agressiya": "agression",
|
||||||
|
"aktsii": "actions",
|
||||||
|
"aktsionizm": "actionism",
|
||||||
|
"alber-kamyu": "albert-kamus",
|
||||||
|
"albomy": "albums",
|
||||||
|
"aleksandr-griboedov": "aleksander-griboedov",
|
||||||
|
"aleksandr-pushkin": "aleksander-pushkin",
|
||||||
|
"aleksandr-solzhenitsyn": "aleksander-solzhenitsyn",
|
||||||
|
"aleksandr-vvedenskiy": "aleksander-vvedensky",
|
||||||
|
"aleksey-navalnyy": "alexey-navalny",
|
||||||
|
"alfavit": "alphabet",
|
||||||
|
"alkogol": "alcohol",
|
||||||
|
"alternativa": "alternative",
|
||||||
|
"alternative": "alternative",
|
||||||
|
"alternativnaya-istoriya": "alternative-history",
|
||||||
|
"amerika": "america",
|
||||||
|
"anarhizm": "anarchism",
|
||||||
|
"anatoliy-mariengof": "anatoly-mariengof",
|
||||||
|
"ancient-russia": "ancient-russia",
|
||||||
|
"andegraund": "underground",
|
||||||
|
"andrey-platonov": "andrey-platonov",
|
||||||
|
"andrey-rodionov": "andrey-rodionov",
|
||||||
|
"andrey-tarkovskiy": "andrey-tarkovsky",
|
||||||
|
"angliyskie-istorii": "english-stories",
|
||||||
|
"angliyskiy-yazyk": "english-langugae",
|
||||||
|
"ango": "ango",
|
||||||
|
"animation": "animation",
|
||||||
|
"animatsiya": "animation",
|
||||||
|
"anime": "anime",
|
||||||
|
"anri-volohonskiy": "anri-volohonsky",
|
||||||
|
"antifashizm": "anti-faschism",
|
||||||
|
"antiquity": "antiquity",
|
||||||
|
"antiutopiya": "dystopia",
|
||||||
|
"anton-dolin": "anton-dolin",
|
||||||
|
"antropology": "antropology",
|
||||||
|
"antropotsen": "antropocenus",
|
||||||
|
"architecture": "architecture",
|
||||||
|
"arheologiya": "archeology",
|
||||||
|
"arhetipy": "archetypes",
|
||||||
|
"arhiv": "archive",
|
||||||
|
"aristokraty": "aristocracy",
|
||||||
|
"aristotel": "aristotle",
|
||||||
|
"arktika": "arctic",
|
||||||
|
"armiya": "army",
|
||||||
|
"armiya-1": "army",
|
||||||
|
"art": "art",
|
||||||
|
"art-is": "art-is",
|
||||||
|
"artists": "artists",
|
||||||
|
"ateizm": "atheism",
|
||||||
|
"audio-poetry": "audio-poetry",
|
||||||
|
"audiopoeziya": "audio-poetry",
|
||||||
|
"audiospektakl": "audio-spectacles",
|
||||||
|
"auktsyon": "auktsyon",
|
||||||
|
"avangard": "avantgarde",
|
||||||
|
"avtofikshn": "autofiction",
|
||||||
|
"avtorskaya-pesnya": "bardsongs",
|
||||||
|
"azbuka-immigratsii": "immigration-basics",
|
||||||
|
"aziatskiy-kinematograf": "asian-cinema",
|
||||||
|
"b-movie": "b-movie",
|
||||||
|
"bannye-chteniya": "sauna-reading",
|
||||||
|
"bardsongs": "bardsongs",
|
||||||
|
"bdsm": "bdsm",
|
||||||
|
"beecake": "beecake",
|
||||||
|
"belarus": "belarus",
|
||||||
|
"belgiya": "belgium",
|
||||||
|
"bertold-breht": "berttold-brecht",
|
||||||
|
"bezumie": "madness",
|
||||||
|
"biography": "biography",
|
||||||
|
"biologiya": "biology",
|
||||||
|
"bipolyarnoe-rasstroystvo": "bipolar-disorder",
|
||||||
|
"bitniki": "beatnics",
|
||||||
|
"biznes": "business",
|
||||||
|
"blizhniy-vostok": "middle-east",
|
||||||
|
"blizost": "closeness",
|
||||||
|
"blocked-in-russia": "blocked-in-russia",
|
||||||
|
"blokada": "blockade",
|
||||||
|
"bob-dilan": "bob-dylan",
|
||||||
|
"bog": "god",
|
||||||
|
"bol": "pain",
|
||||||
|
"bolotnoe-delo": "bolotnaya-case",
|
||||||
|
"books": "books",
|
||||||
|
"boris-eltsin": "boris-eltsin",
|
||||||
|
"boris-godunov": "boris-godunov",
|
||||||
|
"boris-grebenschikov": "boris-grebenschikov",
|
||||||
|
"boris-nemtsov": "boris-nemtsov",
|
||||||
|
"boris-pasternak": "boris-pasternak",
|
||||||
|
"brak": "marriage",
|
||||||
|
"bret-iston-ellis": "bret-iston-ellis",
|
||||||
|
"buddizm": "buddhism",
|
||||||
|
"bullying": "bullying",
|
||||||
|
"bunt": "riot",
|
||||||
|
"burning-man": "burning-man",
|
||||||
|
"bytie": "being",
|
||||||
|
"byurokratiya": "bureaucracy",
|
||||||
|
"capitalism": "capitalism",
|
||||||
|
"censored-in-russia": "censored-in-russia",
|
||||||
|
"ch-rno-beloe": "black-and-white",
|
||||||
|
"ch-rnyy-yumor": "black-humour",
|
||||||
|
"chapters": "chapters",
|
||||||
|
"charity": "charity",
|
||||||
|
"chayldfri": "childfree",
|
||||||
|
"chechenskaya-voyna": "chechen-war",
|
||||||
|
"chechnya": "chechnya",
|
||||||
|
"chelovek": "male",
|
||||||
|
"chernobyl": "chernobyl",
|
||||||
|
"chernyy-yumor": "black-humour",
|
||||||
|
"children": "children",
|
||||||
|
"china": "china",
|
||||||
|
"chinovniki": "bureaucracy",
|
||||||
|
"chukotka": "chukotka",
|
||||||
|
"chuma": "plague",
|
||||||
|
"church": "church",
|
||||||
|
"cinema": "cinema",
|
||||||
|
"city": "city",
|
||||||
|
"civil-position": "civil-position",
|
||||||
|
"clips": "clips",
|
||||||
|
"collage": "collage",
|
||||||
|
"comics": "comics",
|
||||||
|
"conspiracy-theory": "conspiracy-theory",
|
||||||
|
"contemporary-art": "contemporary-art",
|
||||||
|
"contemporary-poetry": "poetry",
|
||||||
|
"contemporary-prose": "prose",
|
||||||
|
"coronavirus": "coronavirus",
|
||||||
|
"corruption": "corruption",
|
||||||
|
"creative-writing-school": "creative-writing-school",
|
||||||
|
"crime": "crime",
|
||||||
|
"criticism": "criticism",
|
||||||
|
"critiques": "reviews",
|
||||||
|
"culture": "culture",
|
||||||
|
"dadaizm": "dadaism",
|
||||||
|
"daniel-defo": "daniel-defoe",
|
||||||
|
"daniil-harms": "daniil-kharms",
|
||||||
|
"dante-aligeri": "dante-alighieri",
|
||||||
|
"darkveyv": "darkwave",
|
||||||
|
"death": "death",
|
||||||
|
"debaty": "debats",
|
||||||
|
"delo-seti": "seti-case",
|
||||||
|
"democracy": "democracy",
|
||||||
|
"demografiya": "demographics",
|
||||||
|
"demonstrations": "demonstrations",
|
||||||
|
"depression": "depression",
|
||||||
|
"derevnya": "village",
|
||||||
|
"derrida": "derrida",
|
||||||
|
"design": "design",
|
||||||
|
"detskie-doma": "orphanages",
|
||||||
|
"detstvo": "childhood",
|
||||||
|
"devid-linch": "david-linch",
|
||||||
|
"devyanostye": "90s",
|
||||||
|
"dialog": "dialogue",
|
||||||
|
"digital": "digital",
|
||||||
|
"digital-art": "digital-art",
|
||||||
|
"dinozavry": "dinosaurs",
|
||||||
|
"directing": "directing",
|
||||||
|
"diskurs": "discours",
|
||||||
|
"diskurs-1": "discourse",
|
||||||
|
"diskurs-analiz": "discourse-analytics",
|
||||||
|
"dissidenty": "dissidents",
|
||||||
|
"diy": "diy",
|
||||||
|
"dmitriy-donskoy": "dmitriy-donskoy",
|
||||||
|
"dmitriy-prigov": "dmitriy-prigov",
|
||||||
|
"dnevnik-1": "dairy",
|
||||||
|
"dnevniki": "dairies",
|
||||||
|
"documentary": "documentary",
|
||||||
|
"dokumentalnaya-poema": "documentary-poem",
|
||||||
|
"dokumentalnaya-poeziya": "documentary-poetry",
|
||||||
|
"dokumenty": "doсuments",
|
||||||
|
"domashnee-nasilie": "home-terror",
|
||||||
|
"donald-tramp": "donald-trump",
|
||||||
|
"donbass": "donbass",
|
||||||
|
"donbass-diary": "donbass-diary",
|
||||||
|
"donorstvo": "donation",
|
||||||
|
"dozhd": "rain",
|
||||||
|
"drama": "drama",
|
||||||
|
"dramaturgy": "dramaturgy",
|
||||||
|
"drawing": "drawing",
|
||||||
|
"drevo-zhizni": "tree-of-life",
|
||||||
|
"drugs": "drugs",
|
||||||
|
"duh": "spirit",
|
||||||
|
"dzhaz": "jazz",
|
||||||
|
"dzhek-keruak": "jack-keruak",
|
||||||
|
"dzhim-morrison": "jim-morrison",
|
||||||
|
"dzhordzh-romero": "george-romero",
|
||||||
|
"dzhordzho-agamben": "giorgio-agamben",
|
||||||
|
"ecology": "ecology",
|
||||||
|
"economics": "economics",
|
||||||
|
"eda": "food",
|
||||||
|
"editorial-statements": "editorial-statements",
|
||||||
|
"eduard-limonov": "eduard-limonov",
|
||||||
|
"education": "education",
|
||||||
|
"egor-letov": "egor-letov",
|
||||||
|
"ekspat": "expat",
|
||||||
|
"eksperiment": "experiments",
|
||||||
|
"eksperimentalnaya-muzyka": "experimental-music",
|
||||||
|
"ekspressionizm": "expressionism",
|
||||||
|
"ekstremizm": "extremism",
|
||||||
|
"ekzistentsializm-1": "existentialism",
|
||||||
|
"ekzistentsiya": "existence",
|
||||||
|
"elections": "elections",
|
||||||
|
"electronic": "electronics",
|
||||||
|
"electronics": "electronics",
|
||||||
|
"elena-glinskaya": "elena-glinskaya",
|
||||||
|
"elena-guro": "elena-guro",
|
||||||
|
"elizaveta-mnatsakanova": "elizaveta-mnatsakanova",
|
||||||
|
"embient": "ambient",
|
||||||
|
"emigration": "emigration",
|
||||||
|
"emil-dyurkgeym": "emile-durkheim",
|
||||||
|
"emotsii": "emotions",
|
||||||
|
"empiric": "empiric",
|
||||||
|
"epidemiya": "pandemic",
|
||||||
|
"erich-von-neff": "erich-von-neff",
|
||||||
|
"erotika": "erotics",
|
||||||
|
"essay": "essay",
|
||||||
|
"estetika": "aestetics",
|
||||||
|
"etika": "ethics",
|
||||||
|
"etno": "ethno",
|
||||||
|
"etnos": "ethnics",
|
||||||
|
"everyday-life": "everyday-life",
|
||||||
|
"evgeniy-onegin": "eugene-onegin",
|
||||||
|
"evolyutsiya": "evolution",
|
||||||
|
"exhibitions": "exhibitions",
|
||||||
|
"experience": "experiences",
|
||||||
|
"experimental": "experimental",
|
||||||
|
"experimental-music": "experimental-music",
|
||||||
|
"explanation": "explanation",
|
||||||
|
"faktcheking": "fact-checking",
|
||||||
|
"falsifikatsii": "falsifications",
|
||||||
|
"family": "family",
|
||||||
|
"fanfiki": "fan-fiction",
|
||||||
|
"fantastika": "sci-fi",
|
||||||
|
"fatalizm": "fatalism",
|
||||||
|
"fedor-dostoevskiy": "fedor-dostoevsky",
|
||||||
|
"fedor-ioannovich": "fedor-ioannovich",
|
||||||
|
"feleton": "feuilleton",
|
||||||
|
"feminism": "feminism",
|
||||||
|
"fenomenologiya": "phenomenology",
|
||||||
|
"fentezi": "fantasy",
|
||||||
|
"festival": "festival",
|
||||||
|
"festival-territoriya": "festival-territory",
|
||||||
|
"folk": "folk",
|
||||||
|
"folklor": "folklore",
|
||||||
|
"fotoreportazh": "photoreports",
|
||||||
|
"france": "france",
|
||||||
|
"frants-kafka": "franz-kafka",
|
||||||
|
"frederik-begbeder": "frederick-begbeder",
|
||||||
|
"freedom": "freedom",
|
||||||
|
"friendship": "friendship",
|
||||||
|
"fsb": "fsb",
|
||||||
|
"futbol": "footbool",
|
||||||
|
"future": "future",
|
||||||
|
"futuristy": "futurists",
|
||||||
|
"futurizm": "futurism",
|
||||||
|
"galereya": "gallery",
|
||||||
|
"galereya-anna-nova": "gallery-anna-nova",
|
||||||
|
"gdr": "gdr",
|
||||||
|
"gender": "gender",
|
||||||
|
"gendernyy-diskurs": "gender",
|
||||||
|
"gennadiy-aygi": "gennadiy-aygi",
|
||||||
|
"gerhard-rihter": "gerhard-rihter",
|
||||||
|
"germaniya": "germany",
|
||||||
|
"germenevtika": "hermeneutics",
|
||||||
|
"geroi": "heroes",
|
||||||
|
"girls": "girls",
|
||||||
|
"gkchp": "gkchp",
|
||||||
|
"glitch": "glitch",
|
||||||
|
"globalizatsiya": "globalisation",
|
||||||
|
"gollivud": "hollywood",
|
||||||
|
"gonzo": "gonzo",
|
||||||
|
"gore-ot-uma": "woe-from-wit",
|
||||||
|
"graffiti": "graffiti",
|
||||||
|
"graficheskaya-novella": "graphic-novell",
|
||||||
|
"graphics": "graphics",
|
||||||
|
"gravyura": "engraving",
|
||||||
|
"grazhdanskaya-oborona": "grazhdanskaya-oborona",
|
||||||
|
"gretsiya": "greece",
|
||||||
|
"griby": "mushrooms",
|
||||||
|
"gruziya-2": "georgia",
|
||||||
|
"gulag": "gulag",
|
||||||
|
"han-batyy": "khan-batyy",
|
||||||
|
"hayku": "haiku",
|
||||||
|
"health": "health",
|
||||||
|
"himiya": "chemistry",
|
||||||
|
"hip-hop": "hip-hop",
|
||||||
|
"history": "history",
|
||||||
|
"history-of-russia": "history-of-russia",
|
||||||
|
"holokost": "holocaust",
|
||||||
|
"horeografiya": "choreography",
|
||||||
|
"horror": "horror",
|
||||||
|
"hospis": "hospice",
|
||||||
|
"hristianstvo": "christianity",
|
||||||
|
"humans": "humans",
|
||||||
|
"humour": "humour",
|
||||||
|
"ideologiya": "ideology",
|
||||||
|
"idm": "idm",
|
||||||
|
"igil": "isis",
|
||||||
|
"igor-pomerantsev": "igor-pomerantsev",
|
||||||
|
"igra": "game",
|
||||||
|
"igra-prestolov": "game-of-throne",
|
||||||
|
"igry": "games",
|
||||||
|
"iisus-hristos": "jesus-christ",
|
||||||
|
"illness": "illness",
|
||||||
|
"illustration-history": "illustration-history",
|
||||||
|
"illustrations": "illustrations",
|
||||||
|
"imazhinizm": "imagism",
|
||||||
|
"immanuil-kant": "immanuel-kant",
|
||||||
|
"impressionizm": "impressionism",
|
||||||
|
"improvizatsiya": "improvisation",
|
||||||
|
"indi": "indie",
|
||||||
|
"individualizm": "individualism",
|
||||||
|
"infografika": "infographics",
|
||||||
|
"informatsiya": "information",
|
||||||
|
"ingmar-bergman": "ingmar-bergman",
|
||||||
|
"inklyuziya": "inclusion",
|
||||||
|
"installyatsiya": "installation",
|
||||||
|
"internet": "internet",
|
||||||
|
"interview": "interview",
|
||||||
|
"invalidnost": "disability",
|
||||||
|
"investigations": "investigations",
|
||||||
|
"iosif-brodskiy": "joseph-brodsky",
|
||||||
|
"iosif-stalin": "joseph-stalin",
|
||||||
|
"iskusstvennyy-intellekt": "artificial-intelligence",
|
||||||
|
"islam": "islam",
|
||||||
|
"istoriya-moskvy": "moscow-history",
|
||||||
|
"istoriya-nauki": "history-of-sceince",
|
||||||
|
"istoriya-o-medsestre": "nurse-story",
|
||||||
|
"istoriya-teatra": "theatre-history",
|
||||||
|
"italiya": "italy",
|
||||||
|
"italyanskiy-yazyk": "italian-language",
|
||||||
|
"iudaika": "judaica",
|
||||||
|
"ivan-groznyy": "ivan-grozny",
|
||||||
|
"ivan-iii-gorbatyy": "ivan-iii-gorbaty",
|
||||||
|
"ivan-kalita": "ivan-kalita",
|
||||||
|
"ivan-krylov": "ivan-krylov",
|
||||||
|
"izobreteniya": "inventions",
|
||||||
|
"izrail-1": "israel",
|
||||||
|
"jazz": "jazz",
|
||||||
|
"john-lennon": "john-lennon",
|
||||||
|
"journalism": "journalism",
|
||||||
|
"justice": "justice",
|
||||||
|
"k-pop": "k-pop",
|
||||||
|
"kalligrafiya": "calligraphy",
|
||||||
|
"karikatura": "caricatures",
|
||||||
|
"kartochki-rubinshteyna": "rubinstein-cards",
|
||||||
|
"katrin-nenasheva": "katrin-nenasheva",
|
||||||
|
"kavarga": "kavarga",
|
||||||
|
"kavkaz": "caucasus",
|
||||||
|
"kazan": "kazan",
|
||||||
|
"kiberbezopasnost": "cybersecurity",
|
||||||
|
"kinoklub": "cinema-club",
|
||||||
|
"kinokritika": "film-criticism",
|
||||||
|
"kirill-serebrennikov": "kirill-serebrennikov",
|
||||||
|
"kladbische": "cemetery",
|
||||||
|
"klassika": "classic",
|
||||||
|
"kollektivnoe-bessoznatelnoe": "сollective-unconscious",
|
||||||
|
"komediya": "comedy",
|
||||||
|
"kommunikatsii": "communications",
|
||||||
|
"kommunizm": "communism",
|
||||||
|
"kommuny": "communes",
|
||||||
|
"kompyuternye-igry": "computer-games",
|
||||||
|
"konets-vesny": "end-of-spring",
|
||||||
|
"konservatizm": "conservatism",
|
||||||
|
"kontrkultura": "counter-culture",
|
||||||
|
"kontseptualizm": "conceptualism",
|
||||||
|
"korotkometrazhka": "cinema-shorts",
|
||||||
|
"kosmos": "cosmos",
|
||||||
|
"kraudfanding": "crowdfunding",
|
||||||
|
"kriptovalyuty": "cryptocurrencies",
|
||||||
|
"krizis": "crisis",
|
||||||
|
"krov": "blood",
|
||||||
|
"krym": "crimea",
|
||||||
|
"kulturologiya": "culturology",
|
||||||
|
"kulty": "cults",
|
||||||
|
"kurdistan": "kurdistan",
|
||||||
|
"kurt-kobeyn": "kurt-cobain",
|
||||||
|
"kurt-vonnegut": "kurt-vonnegut",
|
||||||
|
"kvir": "queer",
|
||||||
|
"laboratoriya": "lab",
|
||||||
|
"language": "languages",
|
||||||
|
"lars-fon-trier": "lars-fon-trier",
|
||||||
|
"laws": "laws",
|
||||||
|
"lectures": "lectures",
|
||||||
|
"leto": "summer",
|
||||||
|
"lev-tolstoy": "leo-tolstoy",
|
||||||
|
"lgbt": "lgbt",
|
||||||
|
"liberalizm": "liberalism",
|
||||||
|
"libertarianstvo": "libertarianism",
|
||||||
|
"life": "life",
|
||||||
|
"likbez": "likbez",
|
||||||
|
"lingvistika": "linguistics",
|
||||||
|
"lirika": "lirics",
|
||||||
|
"literary-studies": "literary-studies",
|
||||||
|
"literature": "literature",
|
||||||
|
"literaturnyykaver": "literature-cover",
|
||||||
|
"lo-fi": "lo-fi",
|
||||||
|
"lomonosov": "lomonosov",
|
||||||
|
"love": "love",
|
||||||
|
"luzha-goluboy-krovi": "luzha-goluboy-krovi",
|
||||||
|
"lyudvig-vitgenshteyn": "ludwig-wittgenstein",
|
||||||
|
"lzhedmitriy": "false-dmitry",
|
||||||
|
"lzhenauka": "pseudoscience",
|
||||||
|
"magiya": "magic",
|
||||||
|
"maks-veber": "max-weber",
|
||||||
|
"manifests": "manifests",
|
||||||
|
"manipulyatsii-soznaniem": "mind-manipulation",
|
||||||
|
"marina-abramovich": "marina-abramovich",
|
||||||
|
"marketing": "marketing",
|
||||||
|
"marksizm": "marxism",
|
||||||
|
"marsel-dyushan": "marchel-duchamp",
|
||||||
|
"marsel-prust": "marcel-proust",
|
||||||
|
"martin-haydegger": "martin-hidegger",
|
||||||
|
"matematika": "maths",
|
||||||
|
"mayakovskiy": "vladimir-mayakovsky",
|
||||||
|
"media": "media",
|
||||||
|
"medicine": "medicine",
|
||||||
|
"memuary": "memoirs",
|
||||||
|
"menedzhment": "management",
|
||||||
|
"menty": "police",
|
||||||
|
"merab-mamardashvili": "merab-mamardashvili",
|
||||||
|
"mest": "revenge",
|
||||||
|
"metamodernizm": "metamodern",
|
||||||
|
"metavselennaya": "metaverse",
|
||||||
|
"metro": "metro",
|
||||||
|
"mifologiya": "mythology",
|
||||||
|
"mify": "myth",
|
||||||
|
"mihael-haneke": "michael-haneke",
|
||||||
|
"mihail-baryshnikov": "mihail-baryshnikov",
|
||||||
|
"mihail-bulgakov": "mihail-bulgakov",
|
||||||
|
"mikrotonalnaya-muzyka": "mikrotone-muzyka",
|
||||||
|
"minimalizm": "minimalism",
|
||||||
|
"minkult-privet": "minkult-privet",
|
||||||
|
"mir": "world",
|
||||||
|
"mirovozzrenie": "mindsets",
|
||||||
|
"mishel-fuko": "michel-foucault",
|
||||||
|
"mistika": "mystics",
|
||||||
|
"mitropolit-makariy": "mitropolit-makariy",
|
||||||
|
"mlm": "mlm",
|
||||||
|
"mobilizatsiya": "mobilisation",
|
||||||
|
"moda": "fashion",
|
||||||
|
"modernizm": "modernism",
|
||||||
|
"mokyumentari": "mockumentary",
|
||||||
|
"molodezh": "youth",
|
||||||
|
"moloko-plus": "moloko-plus",
|
||||||
|
"money": "money",
|
||||||
|
"monologs": "monologues",
|
||||||
|
"monstratsiya": "monstration",
|
||||||
|
"moralnaya-otvetstvennost": "moral-responsibility",
|
||||||
|
"more": "sea",
|
||||||
|
"moscow": "moscow",
|
||||||
|
"moshennichestvo": "frauds",
|
||||||
|
"moskovskiy-romanticheskiy-kontseptualizm": "moscow-romantic-conceptualism",
|
||||||
|
"moskovskoe-delo": "moscow-case",
|
||||||
|
"movies": "movies",
|
||||||
|
"mozg": "brain",
|
||||||
|
"multiplikatsiya": "animation",
|
||||||
|
"music": "music",
|
||||||
|
"musulmanstvo": "islam",
|
||||||
|
"muzei": "museum",
|
||||||
|
"muzey": "museum",
|
||||||
|
"muzhchiny": "man",
|
||||||
|
"myshlenie": "thinking",
|
||||||
|
"nagornyy-karabah": "nagorno-karabakh",
|
||||||
|
"nasilie-1": "violence",
|
||||||
|
"natsionalizm": "nationalism",
|
||||||
|
"natsionalnaya-ideya": "national-idea",
|
||||||
|
"natsizm": "nazism",
|
||||||
|
"natyurmort": "nature-morte",
|
||||||
|
"nauchpop": "pop-science",
|
||||||
|
"nbp": "nbp",
|
||||||
|
"nenavist": "hate",
|
||||||
|
"neofitsialnaya-literatura": "unofficial-literature",
|
||||||
|
"neoklassika": "neoclassic",
|
||||||
|
"neprozrachnye-smysly": "hidden-meanings",
|
||||||
|
"neravenstvo": "inequality",
|
||||||
|
"net-voyne": "no-war",
|
||||||
|
"new-year": "new-year",
|
||||||
|
"neyronauka": "neuro-science",
|
||||||
|
"neyroseti": "neural-networks",
|
||||||
|
"niu-vshe": "hse",
|
||||||
|
"nizhniy-novgorod": "nizhny-novgorod",
|
||||||
|
"nko": "nonprofits",
|
||||||
|
"nlo": "ufo",
|
||||||
|
"nobelevskaya-premiya": "nobel-prize",
|
||||||
|
"noize-mc": "noize-mc",
|
||||||
|
"nonkonformizm": "nonconformism",
|
||||||
|
"notforall": "notforall",
|
||||||
|
"novaya-drama": "new-drama",
|
||||||
|
"novosti": "news",
|
||||||
|
"noyz": "noise",
|
||||||
|
"nuar": "noir",
|
||||||
|
"oberiu": "oberiu",
|
||||||
|
"ocherk": "etudes",
|
||||||
|
"ochevidnyy-nuar": "ochevidnyy-nuar",
|
||||||
|
"odinochestvo": "loneliness",
|
||||||
|
"odna-kniga-odna-istoriya": "one-book-one-story",
|
||||||
|
"okrainy": "outskirts",
|
||||||
|
"omon": "swat",
|
||||||
|
"opinions": "opinions",
|
||||||
|
"oppozitsiya": "opposition",
|
||||||
|
"orhan-pamuk": "orhan-pamuk",
|
||||||
|
"ornitologiya": "ornitology",
|
||||||
|
"osen": "autumn",
|
||||||
|
"osip-mandelshtam": "osip-mandelshtam",
|
||||||
|
"oskar-uayld": "oscar-wilde",
|
||||||
|
"osoznanie": "awareness",
|
||||||
|
"otnosheniya": "relationship",
|
||||||
|
"pablo-pikasso": "pablo-picasso",
|
||||||
|
"painting": "painting",
|
||||||
|
"paintings": "painting",
|
||||||
|
"pamyat": "memory",
|
||||||
|
"pandemiya": "pandemic",
|
||||||
|
"parizh": "paris",
|
||||||
|
"patriotizm": "patriotism",
|
||||||
|
"patsifizm": "pacifism",
|
||||||
|
"paul-tselan": "paul-tselan",
|
||||||
|
"per-burd": "pierre-bourdieu",
|
||||||
|
"perezhivaniya": "worries",
|
||||||
|
"performance": "performance",
|
||||||
|
"peyzazh": "landscape",
|
||||||
|
"philology": "philology",
|
||||||
|
"philosophy": "philosophy",
|
||||||
|
"photo": "photography",
|
||||||
|
"photography": "photography",
|
||||||
|
"photoprojects": "photoprojects",
|
||||||
|
"plakaty": "posters",
|
||||||
|
"plastilin": "plasticine",
|
||||||
|
"plays": "plays",
|
||||||
|
"podrostki": "teenagers",
|
||||||
|
"poema": "poem",
|
||||||
|
"poems": "poems",
|
||||||
|
"poeticheskaya-proza": "poetic-prose",
|
||||||
|
"poetry": "poetry",
|
||||||
|
"poetry-of-squares": "poetry-of-squares",
|
||||||
|
"poetry-slam": "poetry-slam",
|
||||||
|
"pokoy": "peace",
|
||||||
|
"police": "police",
|
||||||
|
"politicheskoe-fentezi": "political-fantasy",
|
||||||
|
"politics": "politics",
|
||||||
|
"politzaklyuchennye": "political-prisoners",
|
||||||
|
"polsha": "poland",
|
||||||
|
"pomosch": "help",
|
||||||
|
"pop-art": "pop-art",
|
||||||
|
"pop-culture": "pop-culture",
|
||||||
|
"populyarnaya-psihologiya": "popular-psychology",
|
||||||
|
"pornografiya": "pornography",
|
||||||
|
"portret": "portrait",
|
||||||
|
"poslovitsy": "proverbs",
|
||||||
|
"post-pank": "post-punk",
|
||||||
|
"post-rok": "post-rock",
|
||||||
|
"postmodernism": "postmodernism",
|
||||||
|
"povest": "novells",
|
||||||
|
"povsednevnost": "everyday-life",
|
||||||
|
"power": "power",
|
||||||
|
"pravo": "right",
|
||||||
|
"pravoslavie": "orthodox",
|
||||||
|
"pravozaschitniki": "human-rights-activism",
|
||||||
|
"prazdnik": "holidays",
|
||||||
|
"predatelstvo": "betrayal",
|
||||||
|
"predprinimatelstvo": "entrepreneurship",
|
||||||
|
"premera": "premier",
|
||||||
|
"premiya-oskar": "oscar-prize",
|
||||||
|
"pribaltika-1": "baltic",
|
||||||
|
"priroda": "nature",
|
||||||
|
"prison": "prison",
|
||||||
|
"pritcha": "parable",
|
||||||
|
"privatnost": "privacy",
|
||||||
|
"progress": "progress",
|
||||||
|
"projects": "projects",
|
||||||
|
"prokrastinatsiya": "procrastination",
|
||||||
|
"propaganda": "propaganda",
|
||||||
|
"proschenie": "forgiveness",
|
||||||
|
"prose": "prose",
|
||||||
|
"proshloe": "past",
|
||||||
|
"prostitutsiya": "prostitution",
|
||||||
|
"prosveschenie": "enlightenment",
|
||||||
|
"protests": "protests",
|
||||||
|
"psalmy": "psalms",
|
||||||
|
"psihoanaliz": "psychoanalysis",
|
||||||
|
"psihodeliki": "psychodelics",
|
||||||
|
"pskov": "pskov",
|
||||||
|
"psychiatry": "psychiatry",
|
||||||
|
"psychology": "psychology",
|
||||||
|
"ptitsy": "birds",
|
||||||
|
"punk": "punk",
|
||||||
|
"r-b": "rnb",
|
||||||
|
"rasizm": "racism",
|
||||||
|
"realizm": "realism",
|
||||||
|
"redaktura": "editing",
|
||||||
|
"refleksiya": "reflection",
|
||||||
|
"reggi": "reggae",
|
||||||
|
"religion": "religion",
|
||||||
|
"rene-zhirar": "rene-girard",
|
||||||
|
"renesanss": "renessance",
|
||||||
|
"renovatsiya": "renovation",
|
||||||
|
"rep": "rap",
|
||||||
|
"reportage": "reportage",
|
||||||
|
"reportazh-1": "reportage",
|
||||||
|
"repressions": "repressions",
|
||||||
|
"research": "research",
|
||||||
|
"retroveyv": "retrowave",
|
||||||
|
"review": "review",
|
||||||
|
"revolution": "revolution",
|
||||||
|
"rezo-gabriadze": "rezo-gabriadze",
|
||||||
|
"risunki": "painting",
|
||||||
|
"roboty": "robots",
|
||||||
|
"rock": "rock",
|
||||||
|
"roditeli": "parents",
|
||||||
|
"romantizm": "romantism",
|
||||||
|
"romany": "novell",
|
||||||
|
"ronald-reygan": "ronald-reygan",
|
||||||
|
"roskomnadzor": "roskomnadzor",
|
||||||
|
"rossiyskoe-kino": "russian-cinema",
|
||||||
|
"rouling": "rowling",
|
||||||
|
"rozhava": "rojava",
|
||||||
|
"rpts": "rpts",
|
||||||
|
"rus-na-grani-sryva": "rus-na-grani-sryva",
|
||||||
|
"russia": "russia",
|
||||||
|
"russian-language": "russian-language",
|
||||||
|
"russian-literature": "russian-literature",
|
||||||
|
"russkaya-toska": "russian-toska",
|
||||||
|
"russkiy-mir": "russkiy-mir",
|
||||||
|
"salo": "lard",
|
||||||
|
"salvador-dali": "salvador-dali",
|
||||||
|
"samoidentifikatsiya": "self-identity",
|
||||||
|
"samoopredelenie": "self-definition",
|
||||||
|
"sankt-peterburg": "saint-petersburg",
|
||||||
|
"sasha-skochilenko": "sasha-skochilenko",
|
||||||
|
"satira": "satiric",
|
||||||
|
"saund-art": "sound-art",
|
||||||
|
"schaste": "happiness",
|
||||||
|
"school": "school",
|
||||||
|
"science": "science",
|
||||||
|
"sculpture": "sculpture",
|
||||||
|
"second-world-war": "second-world-war",
|
||||||
|
"sekond-hend": "second-hand",
|
||||||
|
"seksprosvet": "sex-education",
|
||||||
|
"seksualizirovannoe-nasilie": "sexualized-violence",
|
||||||
|
"seksualnoe-nasilie": "sexualized-violence",
|
||||||
|
"sekty": "sects",
|
||||||
|
"semi": "semi",
|
||||||
|
"semiotics": "semiotics",
|
||||||
|
"serbiya": "serbia",
|
||||||
|
"sergey-bodrov-mladshiy": "sergey-bodrov-junior",
|
||||||
|
"sergey-solov-v": "sergey-solovyov",
|
||||||
|
"serialy": "series",
|
||||||
|
"sever": "north",
|
||||||
|
"severnaya-koreya": "north-korea",
|
||||||
|
"sex": "sex",
|
||||||
|
"shotlandiya": "scotland",
|
||||||
|
"shugeyz": "shoegaze",
|
||||||
|
"siloviki": "siloviki",
|
||||||
|
"simeon-bekbulatovich": "simeon-bekbulatovich",
|
||||||
|
"simvolizm": "simbolism",
|
||||||
|
"siriya": "siria",
|
||||||
|
"skulptura": "sculpture",
|
||||||
|
"slavoy-zhizhek": "slavoj-zizek",
|
||||||
|
"smert-1": "death",
|
||||||
|
"smysl": "meaning",
|
||||||
|
"sny": "dreams",
|
||||||
|
"sobytiya": "events",
|
||||||
|
"social": "society",
|
||||||
|
"society": "society",
|
||||||
|
"sociology": "sociology",
|
||||||
|
"sofya-paleolog": "sofya-paleolog",
|
||||||
|
"sofya-vitovtovna": "sofya-vitovtovna",
|
||||||
|
"soobschestva": "communities",
|
||||||
|
"soprotivlenie": "resistence",
|
||||||
|
"sotsializm": "socialism",
|
||||||
|
"sotsialnaya-filosofiya": "social-philosophy",
|
||||||
|
"sotsiologiya-1": "sociology",
|
||||||
|
"sotsseti": "social-networks",
|
||||||
|
"sotvorenie-tretego-rima": "third-rome",
|
||||||
|
"sovremennost": "modernity",
|
||||||
|
"spaces": "spaces",
|
||||||
|
"spektakl": "spectacles",
|
||||||
|
"spetseffekty": "special-fx",
|
||||||
|
"spetsoperatsiya": "special-operation",
|
||||||
|
"spetssluzhby": "special-services",
|
||||||
|
"sport": "sport",
|
||||||
|
"srednevekove": "middle-age",
|
||||||
|
"state": "state",
|
||||||
|
"statistika": "statistics",
|
||||||
|
"stendap": "stand-up",
|
||||||
|
"stihi": "poetry",
|
||||||
|
"stoitsizm": "stoicism",
|
||||||
|
"stories": "stories",
|
||||||
|
"stoyanie-na-ugre": "stoyanie-na-ugre",
|
||||||
|
"strah": "fear",
|
||||||
|
"street-art": "street-art",
|
||||||
|
"stsenarii": "scenarios",
|
||||||
|
"sud": "court",
|
||||||
|
"summary": "summary",
|
||||||
|
"supergeroi": "superheroes",
|
||||||
|
"svetlana-aleksievich": "svetlana-aleksievich",
|
||||||
|
"svobodu-ivanu-golunovu": "free-ivan-golunov",
|
||||||
|
"syurrealizm": "surrealism",
|
||||||
|
"tales": "tales",
|
||||||
|
"tanets": "dance",
|
||||||
|
"tataro-mongolskoe-igo": "mongol-tatar-yoke",
|
||||||
|
"tatuirovki": "tattoo",
|
||||||
|
"technology": "technology",
|
||||||
|
"televidenie": "television",
|
||||||
|
"telo": "body",
|
||||||
|
"telo-kak-iskusstvo": "body-as-art",
|
||||||
|
"terrorizm": "terrorism",
|
||||||
|
"tests": "tests",
|
||||||
|
"text": "texts",
|
||||||
|
"the-beatles": "the-beatles",
|
||||||
|
"theater": "theater",
|
||||||
|
"theory": "theory",
|
||||||
|
"tokio": "tokio",
|
||||||
|
"torture": "torture",
|
||||||
|
"totalitarizm": "totalitarism",
|
||||||
|
"traditions": "traditions",
|
||||||
|
"tragicomedy": "tragicomedy",
|
||||||
|
"transgendernost": "transgender",
|
||||||
|
"translation": "translation",
|
||||||
|
"transport": "transport",
|
||||||
|
"travel": "travel",
|
||||||
|
"travma": "trauma",
|
||||||
|
"trendy": "trends",
|
||||||
|
"tretiy-reyh": "third-reich",
|
||||||
|
"triller": "thriller",
|
||||||
|
"tsar": "central-african-republic",
|
||||||
|
"tsar-edip": "oedipus",
|
||||||
|
"tsarevich-dmitriy": "tsarevich-dmitry",
|
||||||
|
"tsennosti": "values",
|
||||||
|
"tsenzura": "censorship",
|
||||||
|
"tseremonii": "ceremonies",
|
||||||
|
"turizm": "tourism",
|
||||||
|
"tvorchestvo": "creativity",
|
||||||
|
"ugnetennyy-zhilischnyy-klass": "oppressed-housing-class",
|
||||||
|
"uilyam-shekspir": "william-shakespeare",
|
||||||
|
"ukraina-2": "ukraine",
|
||||||
|
"ukraine": "ukraine",
|
||||||
|
"university": "university",
|
||||||
|
"urban-studies": "urban-studies",
|
||||||
|
"uroki-literatury": "literature-lessons",
|
||||||
|
"usa": "usa",
|
||||||
|
"ussr": "ussr",
|
||||||
|
"utopiya": "utopia",
|
||||||
|
"utrata": "loss",
|
||||||
|
"valter-benyamin": "valter-benyamin",
|
||||||
|
"varlam-shalamov": "varlam-shalamov",
|
||||||
|
"vasiliy-ii-temnyy": "basil-ii-temnyy",
|
||||||
|
"vasiliy-iii": "basil-iii",
|
||||||
|
"vdnh": "vdnh",
|
||||||
|
"vechnost": "ethernety",
|
||||||
|
"velikobritaniya": "great-britain",
|
||||||
|
"velimir-hlebnikov": "velimir-hlebnikov",
|
||||||
|
"velkom-tu-greyt-britn": "welcome-to-great-britain",
|
||||||
|
"venedikt-erofeev": "venedikt-erofeev",
|
||||||
|
"venetsiya": "veneece",
|
||||||
|
"vengriya": "hungary",
|
||||||
|
"verlibry": "free-verse",
|
||||||
|
"veschi": "things",
|
||||||
|
"vessels": "vessels",
|
||||||
|
"veterany": "veterans",
|
||||||
|
"video": "video",
|
||||||
|
"videoart": "videoart",
|
||||||
|
"videoklip": "clips",
|
||||||
|
"videopoeziya": "video-poetry",
|
||||||
|
"viktor-astafev": "viktor-astafev",
|
||||||
|
"viktor-pelevin": "viktor-pelevin",
|
||||||
|
"vilgelm-rayh": "wilhelm-reich",
|
||||||
|
"vinzavod": "vinzavod",
|
||||||
|
"violence": "violence",
|
||||||
|
"visual-culture": "visual-culture",
|
||||||
|
"vizualnaya-poeziya": "visual-poetry",
|
||||||
|
"vladimir-lenin": "vladimir-lenin",
|
||||||
|
"vladimir-mayakovskiy": "vladimir-mayakovsky",
|
||||||
|
"vladimir-nabokov": "vladimir-nabokov",
|
||||||
|
"vladimir-putin": "vladimir-putin",
|
||||||
|
"vladimir-sorokin": "vladimir-sorokin",
|
||||||
|
"vladimir-voynovich": "vladimir-voynovich",
|
||||||
|
"vnutrenniy-opyt": "inner-expirience",
|
||||||
|
"volga": "volga",
|
||||||
|
"volontery": "volonteurs",
|
||||||
|
"vong-karvay": "wong-karwai",
|
||||||
|
"vospominaniya": "memories",
|
||||||
|
"vostok": "east",
|
||||||
|
"voyna-na-ukraine": "war-in-ukraine",
|
||||||
|
"voyna-v-ukraine": "war-in-ukraine",
|
||||||
|
"vremya": "time",
|
||||||
|
"vudi-allen": "woody-allen",
|
||||||
|
"vynuzhdennye-otnosheniya": "forced-relationship",
|
||||||
|
"war": "war",
|
||||||
|
"war-in-ukraine-images": "war-in-ukrahine-images",
|
||||||
|
"women": "women",
|
||||||
|
"work": "work",
|
||||||
|
"writers": "writers",
|
||||||
|
"xx-century": "xx-century",
|
||||||
|
"yakob-yordans": "yakob-yordans",
|
||||||
|
"yan-vermeer": "yan-vermeer",
|
||||||
|
"yanka-dyagileva": "yanka-dyagileva",
|
||||||
|
"yaponskaya-literatura": "japan-literature",
|
||||||
|
"yazychestvo": "paganism",
|
||||||
|
"youth": "youth",
|
||||||
|
"yozef-rot": "yozef-rot",
|
||||||
|
"yurgen-habermas": "jorgen-habermas",
|
||||||
|
"za-liniey-mannergeyma": "behind-mannerheim-line",
|
||||||
|
"zabota": "care",
|
||||||
|
"zahar-prilepin": "zahar-prilepin",
|
||||||
|
"zakonodatelstvo": "laws",
|
||||||
|
"zakony-mira": "world-laws",
|
||||||
|
"zametki": "notes",
|
||||||
|
"zhelanie": "wish",
|
||||||
|
"zhivotnye": "animals",
|
||||||
|
"zhoze-saramago": "jose-saramago",
|
||||||
|
"zigmund-freyd": "sigmund-freud",
|
||||||
|
"zolotaya-orda": "golden-horde",
|
||||||
|
"zombi": "zombie",
|
||||||
|
"zombi-simpsony": "zombie-simpsons"
|
||||||
|
}
|
||||||
31
migration/tables/topics.py
Normal file
31
migration/tables/topics.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from base.orm import local_session
|
||||||
|
from migration.html2text import html2text
|
||||||
|
from orm import Topic
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(entry):
|
||||||
|
body_orig = entry.get("description", "").replace(" ", " ")
|
||||||
|
topic_dict = {
|
||||||
|
"slug": entry["slug"],
|
||||||
|
"oid": entry["_id"],
|
||||||
|
"title": entry["title"].replace(" ", " "),
|
||||||
|
"body": html2text(body_orig),
|
||||||
|
}
|
||||||
|
|
||||||
|
with local_session() as session:
|
||||||
|
slug = topic_dict["slug"]
|
||||||
|
topic = session.query(Topic).filter(Topic.slug == slug).first() or Topic.create(
|
||||||
|
**topic_dict
|
||||||
|
)
|
||||||
|
if not topic:
|
||||||
|
raise Exception("no topic!")
|
||||||
|
if topic:
|
||||||
|
if len(topic.title) > len(topic_dict["title"]):
|
||||||
|
Topic.update(topic, {"title": topic_dict["title"]})
|
||||||
|
if len(topic.body) < len(topic_dict["body"]):
|
||||||
|
Topic.update(topic, {"body": topic_dict["body"]})
|
||||||
|
session.commit()
|
||||||
|
# print(topic.__dict__)
|
||||||
|
rt = topic.__dict__.copy()
|
||||||
|
del rt["_sa_instance_state"]
|
||||||
|
return rt
|
||||||
156
migration/tables/users.py
Normal file
156
migration/tables/users.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from dateutil.parser import parse
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from base.orm import local_session
|
||||||
|
from orm.user import AuthorFollower, User, UserRating
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(entry): # noqa: C901
|
||||||
|
if "subscribedTo" in entry:
|
||||||
|
del entry["subscribedTo"]
|
||||||
|
email = entry["emails"][0]["address"]
|
||||||
|
user_dict = {
|
||||||
|
"oid": entry["_id"],
|
||||||
|
"roles": [],
|
||||||
|
"ratings": [],
|
||||||
|
"username": email,
|
||||||
|
"email": email,
|
||||||
|
"createdAt": parse(entry["createdAt"]),
|
||||||
|
"emailConfirmed": ("@discours.io" in email) or bool(entry["emails"][0]["verified"]),
|
||||||
|
"muted": False, # amnesty
|
||||||
|
"links": [],
|
||||||
|
"name": "anonymous",
|
||||||
|
"password": entry["services"]["password"].get("bcrypt"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if "updatedAt" in entry:
|
||||||
|
user_dict["updatedAt"] = parse(entry["updatedAt"])
|
||||||
|
if "wasOnlineAt" in entry:
|
||||||
|
user_dict["lastSeen"] = parse(entry["wasOnlineAt"])
|
||||||
|
if entry.get("profile"):
|
||||||
|
# slug
|
||||||
|
slug = entry["profile"].get("path").lower()
|
||||||
|
slug = re.sub("[^0-9a-zA-Z]+", "-", slug).strip()
|
||||||
|
user_dict["slug"] = slug
|
||||||
|
bio = (
|
||||||
|
(entry.get("profile", {"bio": ""}).get("bio") or "")
|
||||||
|
.replace(r"\(", "(")
|
||||||
|
.replace(r"\)", ")")
|
||||||
|
)
|
||||||
|
bio_text = BeautifulSoup(bio, features="lxml").text
|
||||||
|
|
||||||
|
if len(bio_text) > 120:
|
||||||
|
user_dict["about"] = bio_text
|
||||||
|
else:
|
||||||
|
user_dict["bio"] = bio_text
|
||||||
|
|
||||||
|
# userpic
|
||||||
|
try:
|
||||||
|
user_dict["userpic"] = (
|
||||||
|
"https://images.discours.io/unsafe/" + entry["profile"]["thumborId"]
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
try:
|
||||||
|
user_dict["userpic"] = entry["profile"]["image"]["url"]
|
||||||
|
except KeyError:
|
||||||
|
user_dict["userpic"] = ""
|
||||||
|
|
||||||
|
# name
|
||||||
|
fn = entry["profile"].get("firstName", "")
|
||||||
|
ln = entry["profile"].get("lastName", "")
|
||||||
|
name = fn if fn else ""
|
||||||
|
name = (name + " " + ln) if ln else name
|
||||||
|
if not name:
|
||||||
|
name = slug if slug else "anonymous"
|
||||||
|
name = entry["profile"]["path"].lower().strip().replace(" ", "-") if len(name) < 2 else name
|
||||||
|
user_dict["name"] = name
|
||||||
|
|
||||||
|
# links
|
||||||
|
fb = entry["profile"].get("facebook", False)
|
||||||
|
if fb:
|
||||||
|
user_dict["links"].append(fb)
|
||||||
|
vk = entry["profile"].get("vkontakte", False)
|
||||||
|
if vk:
|
||||||
|
user_dict["links"].append(vk)
|
||||||
|
tr = entry["profile"].get("twitter", False)
|
||||||
|
if tr:
|
||||||
|
user_dict["links"].append(tr)
|
||||||
|
ws = entry["profile"].get("website", False)
|
||||||
|
if ws:
|
||||||
|
user_dict["links"].append(ws)
|
||||||
|
|
||||||
|
# some checks
|
||||||
|
if not user_dict["slug"] and len(user_dict["links"]) > 0:
|
||||||
|
user_dict["slug"] = user_dict["links"][0].split("/")[-1]
|
||||||
|
|
||||||
|
user_dict["slug"] = user_dict.get("slug", user_dict["email"].split("@")[0])
|
||||||
|
oid = user_dict["oid"]
|
||||||
|
user_dict["slug"] = user_dict["slug"].lower().strip().replace(" ", "-")
|
||||||
|
try:
|
||||||
|
user = User.create(**user_dict.copy())
|
||||||
|
except IntegrityError:
|
||||||
|
print("[migration] cannot create user " + user_dict["slug"])
|
||||||
|
with local_session() as session:
|
||||||
|
old_user = session.query(User).filter(User.slug == user_dict["slug"]).first()
|
||||||
|
old_user.oid = oid
|
||||||
|
old_user.password = user_dict["password"]
|
||||||
|
session.commit()
|
||||||
|
user = old_user
|
||||||
|
if not user:
|
||||||
|
print("[migration] ERROR: cannot find user " + user_dict["slug"])
|
||||||
|
raise Exception
|
||||||
|
user_dict["id"] = user.id
|
||||||
|
return user_dict
|
||||||
|
|
||||||
|
|
||||||
|
def post_migrate():
|
||||||
|
old_discours_dict = {
|
||||||
|
"slug": "old-discours",
|
||||||
|
"username": "old-discours",
|
||||||
|
"email": "old@discours.io",
|
||||||
|
"name": "Просмотры на старой версии сайта",
|
||||||
|
}
|
||||||
|
|
||||||
|
with local_session() as session:
|
||||||
|
old_discours_user = User.create(**old_discours_dict)
|
||||||
|
session.add(old_discours_user)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_2stage(entry, id_map):
|
||||||
|
ce = 0
|
||||||
|
for rating_entry in entry.get("ratings", []):
|
||||||
|
rater_oid = rating_entry["createdBy"]
|
||||||
|
rater_slug = id_map.get(rater_oid)
|
||||||
|
if not rater_slug:
|
||||||
|
ce += 1
|
||||||
|
# print(rating_entry)
|
||||||
|
continue
|
||||||
|
oid = entry["_id"]
|
||||||
|
author_slug = id_map.get(oid)
|
||||||
|
|
||||||
|
with local_session() as session:
|
||||||
|
try:
|
||||||
|
rater = session.query(User).where(User.slug == rater_slug).one()
|
||||||
|
user = session.query(User).where(User.slug == author_slug).one()
|
||||||
|
|
||||||
|
user_rating_dict = {
|
||||||
|
"value": rating_entry["value"],
|
||||||
|
"rater": rater.id,
|
||||||
|
"user": user.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
user_rating = UserRating.create(**user_rating_dict)
|
||||||
|
if user_rating_dict["value"] > 0:
|
||||||
|
af = AuthorFollower.create(author=user.id, follower=rater.id, auto=True)
|
||||||
|
session.add(af)
|
||||||
|
session.add(user_rating)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
print("[migration] cannot rate " + author_slug + "`s by " + rater_slug)
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
return ce
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user