Compare commits
199 Commits
80cb8df41c
...
feature/au
| Author | SHA1 | Date | |
|---|---|---|---|
| 867232e48f | |||
| 3826797317 | |||
| 5d766b7601 | |||
| d03336174f | |||
| 9f70654fb5 | |||
| c8728540ed | |||
| db92cc6406 | |||
| 2ca2a7b256 | |||
| f51d15c871 | |||
| faf25d77a1 | |||
| b2df345072 | |||
| eb2140bcc6 | |||
| 441cca8045 | |||
| 27c5a57709 | |||
| 82111ed0f6 | |||
| 7585dae0ab | |||
| 971b87c0be | |||
| 27a358a41f | |||
| 547c934302 | |||
| 30757fb38a | |||
| bb41c02d62 | |||
| 2683982180 | |||
| 71f26a76c3 | |||
| 5f48ec465a | |||
| 6c95b0575a | |||
| 5cfde98c22 | |||
| b01de1fdc1 | |||
| ab65fd4fd8 | |||
| 41395eb7c6 | |||
| 1e2c85e56a | |||
| 952b294345 | |||
| 9de86c0fae | |||
| 23a6bf66b9 | |||
| eb7a85100b | |||
| 81926e4738 | |||
| d69310567c | |||
| 9cbd5e4288 | |||
| b417000cc1 | |||
| a58e0191d8 | |||
| 567b18fe24 | |||
| 3ccd8ce1d0 | |||
| 8f93d77eef | |||
| 20840b2d54 | |||
| 4bafadde45 | |||
| c68e964bf5 | |||
| cbecf13053 | |||
| 7c20415533 | |||
| da951ed14e | |||
| 52bf78320b | |||
| c48f5f9368 | |||
| 7c11c9875f | |||
| b1775f4814 | |||
| 0c4a2bcf6d | |||
| f4d7cd8f67 | |||
| 599a6c9f59 | |||
| b5aa7032eb | |||
| 6a582d49d4 | |||
| 8a5f4a2421 | |||
| 6edc0ed3db | |||
| 0375939e73 | |||
| 1329aee1f1 | |||
| 7fc9908857 | |||
| a3e4d6a49a | |||
| 91133e11f6 | |||
| 852cb6d653 | |||
| 36ea07b8fc | |||
| 1710fce600 | |||
| 6ab76a9754 | |||
| 6689847c0e | |||
| 17b6069fb2 | |||
| f00eea2c31 | |||
| 9555cc3125 | |||
| b97912c3c4 | |||
| 8d410fcac3 | |||
| 5e370eef95 | |||
| b905ba59e4 | |||
| ba21a4b920 | |||
| aeb53a7354 | |||
|
|
e1d1096674 | ||
| 9f16ee022b | |||
| 89f6c32b78 | |||
| 903065fdb3 | |||
| 63c96ef965 | |||
| 21d28a0d8b | |||
| cca2f71c59 | |||
| 3327976586 | |||
| baca19a4d5 | |||
| ffe19ef238 | |||
| 0140fcd522 | |||
| 90260534eb | |||
| f160ab4d26 | |||
| f8ad73571c | |||
| 6ba5c04564 | |||
| 5bdfdad63e | |||
| d917d63bf2 | |||
| 1223c1d278 | |||
| e375db4125 | |||
| 6e5545b190 | |||
| bdc9854037 | |||
| 47b551068a | |||
| fb4f98ebf6 | |||
| 5f10599a51 | |||
| 97d2b914b7 | |||
| 4070f4fcde | |||
| d4c16658bd | |||
| 6c0d96e7ac | |||
| 301145fcff | |||
| 627be9a4f1 | |||
| c06a187fd6 | |||
| 6d734af5ce | |||
| 8489320ab1 | |||
| ee79091e35 | |||
| 92ba4c1c03 | |||
| 16c34ac792 | |||
| d18e99ee4c | |||
| bad4928219 | |||
| 071d8217dd | |||
| ab39b534fe | |||
| 32bc1276e0 | |||
| 91258721c6 | |||
| 09f0747c1f | |||
| 5874d3ccae | |||
| ebf9dfcf62 | |||
| f6156ccfa3 | |||
| d3a760b6ba | |||
|
|
82870a4e47 | ||
|
|
80b909d801 | ||
|
|
1ada0a02f9 | ||
|
|
44aef147b5 | ||
| 1d64811880 | |||
|
|
2bebfbd4df | ||
| dc5ad46df9 | |||
|
|
f19248184a | ||
|
|
7df9361daa | ||
|
|
e38a1c1338 | ||
| 11e46f7352 | |||
| 2d382be794 | |||
|
|
1281157d93 | ||
|
|
0018749905 | ||
|
|
c344fcee2d | ||
|
|
a1a61a6731 | ||
|
|
8d6ad2c84f | ||
|
|
beba1992e9 | ||
|
|
b0296d7747 | ||
|
|
98e3dff35e | ||
|
|
3782a9dffb | ||
|
|
93c00b3dd1 | ||
|
|
fac43e5997 | ||
|
|
e7facf8d87 | ||
|
|
3062a2b7de | ||
|
|
c0406dbbf2 | ||
|
|
ab4610575f | ||
|
|
5425dbf832 | ||
|
|
a10db2d38a | ||
|
|
83e70856cd | ||
|
|
11654dba68 | ||
|
|
ec9465ad40 | ||
|
|
4d965fb27b | ||
|
|
e382cc1ea5 | ||
| 83d61ca76d | |||
|
|
106222b0e0 | ||
|
|
c533241d1e | ||
|
|
78326047bf | ||
|
|
bc4ec79240 | ||
|
|
a0db5707c4 | ||
|
|
ecc443c3ad | ||
|
|
9a02ca74ad | ||
|
|
9ebb81cbd3 | ||
|
|
0bc55977ac | ||
|
|
ff3a4debce | ||
|
|
ae85b32f69 | ||
|
|
34a354e9e3 | ||
|
|
e405fb527b | ||
|
|
7f36f93d92 | ||
|
|
f089a32394 | ||
|
|
1fd623a660 | ||
|
|
88012f1b8c | ||
|
|
6e284640c0 | ||
|
|
077cb46482 | ||
|
|
60a13a9097 | ||
|
|
316375bf18 | ||
|
|
fb820f67fd | ||
|
|
f1d9f4e036 | ||
|
|
ebb67eb311 | ||
|
|
50a8c24ead | ||
|
|
eb4b9363ab | ||
|
|
19c5028a0c | ||
|
|
57e1e8e6bd | ||
|
|
385057ffcd | ||
|
|
90699768ff | ||
|
|
ad0ca75aa9 | ||
|
|
39242d5e6c | ||
|
|
24cca7f2cb | ||
|
|
a9c7ac49d6 | ||
|
|
f249752db5 | ||
|
|
c0b2116da2 | ||
|
|
59e71c8144 | ||
|
|
e6a416383d | ||
|
|
d55448398d |
@@ -1,11 +1,12 @@
|
||||
name: 'Deploy on push'
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Cloning repo
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -29,7 +30,16 @@ jobs:
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
uses: dokku/github-action@master
|
||||
with:
|
||||
branch: 'dev'
|
||||
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'
|
||||
|
||||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout source repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -162,4 +162,10 @@ views.json
|
||||
*.crt
|
||||
*cache.json
|
||||
.cursor
|
||||
.devcontainer/devcontainer.json
|
||||
|
||||
node_modules/
|
||||
panel/graphql/generated/
|
||||
panel/types.gen.ts
|
||||
|
||||
.cursorrules
|
||||
.cursor/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: check-yaml
|
||||
- id: check-toml
|
||||
@@ -12,7 +12,63 @@ repos:
|
||||
- id: check-merge-conflict
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.4.7
|
||||
rev: v0.11.12
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
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/pre-commit/mirrors-mypy
|
||||
# rev: v1.16.0
|
||||
# hooks:
|
||||
# - id: mypy
|
||||
# name: mypy type checking
|
||||
# entry: mypy
|
||||
# language: python
|
||||
# types: [python]
|
||||
# require_serial: true
|
||||
# additional_dependencies: [
|
||||
# "types-redis",
|
||||
# "types-requests",
|
||||
# "types-passlib",
|
||||
# "types-Authlib",
|
||||
# "sqlalchemy[mypy]"
|
||||
# ]
|
||||
# args: [
|
||||
# "--config-file=mypy.ini",
|
||||
# "--show-error-codes",
|
||||
# "--no-error-summary",
|
||||
# "--ignore-missing-imports"
|
||||
# ]
|
||||
|
||||
1585
CHANGELOG.md
1585
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
134
CONTRIBUTING.md
Normal file
134
CONTRIBUTING.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# 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! 🙏
|
||||
18
Dockerfile
18
Dockerfile
@@ -3,16 +3,24 @@ FROM python: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
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["python", "-m", "granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["python", "-m", "granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
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.
|
||||
132
README.md
132
README.md
@@ -1,9 +1,32 @@
|
||||
# GraphQL API Backend
|
||||
|
||||
Backend service providing GraphQL API for content management system with reactions, ratings and comments.
|
||||
<div align="center">
|
||||
|
||||
## Core Features
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
Backend service providing GraphQL API for content management system with reactions, ratings and topics.
|
||||
|
||||
## 📚 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
|
||||
@@ -26,28 +49,34 @@ Backend service providing GraphQL API for content management system with reactio
|
||||
- Activity tracking and stats
|
||||
- Community features
|
||||
|
||||
## Tech Stack
|
||||
### RBAC & Permissions
|
||||
- RBAC with hierarchy using Redis
|
||||
|
||||
- **(Python)[https://www.python.org/]** 3.12+
|
||||
- **GraphQL** with [Ariadne](https://ariadnegraphql.org/)
|
||||
- **(SQLAlchemy)[https://docs.sqlalchemy.org/en/20/orm/]**
|
||||
- **(PostgreSQL)[https://www.postgresql.org/]/(SQLite)[https://www.sqlite.org/]** support
|
||||
- **(Starlette)[https://www.starlette.io/]** for ASGI server
|
||||
- **(Redis)[https://redis.io/]** for caching
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
## Development
|
||||
**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
|
||||
|
||||
### Prepare environment:
|
||||
## 🔧 Development
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
### 📦 Prepare environment:
|
||||
|
||||
```shell
|
||||
mkdir .venv
|
||||
python3.12 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.dev.txt
|
||||
```
|
||||
|
||||
### Run server
|
||||
### 🚀 Run server
|
||||
|
||||
First, certifcates are required to run the server.
|
||||
First, certificates are required to run the server with HTTPS.
|
||||
|
||||
```shell
|
||||
mkcert -install
|
||||
@@ -57,34 +86,40 @@ mkcert localhost
|
||||
Then, run the server:
|
||||
|
||||
```shell
|
||||
python -m granian main:app --interface asgi --host 0.0.0.0 --port 8000
|
||||
python -m granian main:app --interface asgi
|
||||
```
|
||||
|
||||
### Useful Commands
|
||||
### ⚡ Useful Commands
|
||||
|
||||
```shell
|
||||
# Linting and import sorting
|
||||
ruff check . --fix --select I
|
||||
# Linting and formatting with Biome
|
||||
biome check . --write
|
||||
|
||||
# Code formatting
|
||||
ruff format . --line-length=120
|
||||
# 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
|
||||
### 📝 Code Style
|
||||
|
||||
We use:
|
||||
- Ruff for linting and import sorting
|
||||
- Line length: 120 characters
|
||||
- Python type hints
|
||||
- Docstrings for public methods
|
||||

|
||||

|
||||

|
||||
|
||||
### GraphQL Development
|
||||
**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`:
|
||||
|
||||
@@ -100,3 +135,46 @@ query GetShout($slug: String) {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Project Stats
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</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>
|
||||
|
||||
93
alembic.ini
Normal file
93
alembic.ini
Normal file
@@ -0,0 +1,93 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python-dateutil library that can be
|
||||
# installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to dateutil.tz.gettz()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version number format.
|
||||
version_num_format = %%04d
|
||||
|
||||
# version name format.
|
||||
version_name_format = %%s
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = sqlite:///discoursio.db
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -2,6 +2,7 @@ from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
# Импорт всех моделей для корректной генерации миграций
|
||||
from alembic import context
|
||||
from services.db import Base
|
||||
from settings import DB_URL
|
||||
@@ -11,7 +12,7 @@ from settings import DB_URL
|
||||
config = context.config
|
||||
|
||||
# override DB_URL
|
||||
config.set_section_option(config.config_ini_section, "DB_URL", DB_URL)
|
||||
config.set_main_option("sqlalchemy.url", DB_URL)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
|
||||
24
alembic/script.py.mako
Normal file
24
alembic/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
179
auth/__init__.py
Normal file
179
auth/__init__.py
Normal file
@@ -0,0 +1,179 @@
|
||||
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)
|
||||
@@ -1,96 +0,0 @@
|
||||
from functools import wraps
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from graphql.type import GraphQLResolveInfo
|
||||
from sqlalchemy.orm import exc, joinedload
|
||||
from starlette.authentication import AuthenticationBackend
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
from auth.credentials import AuthCredentials, AuthUser
|
||||
from auth.exceptions import OperationNotAllowed
|
||||
from auth.tokenstorage import SessionToken
|
||||
from auth.usermodel import Role, User
|
||||
from services.db import local_session
|
||||
from settings import SESSION_TOKEN_HEADER
|
||||
|
||||
|
||||
class JWTAuthenticate(AuthenticationBackend):
|
||||
async def authenticate(self, request: HTTPConnection) -> Optional[Tuple[AuthCredentials, AuthUser]]:
|
||||
if SESSION_TOKEN_HEADER not in request.headers:
|
||||
return AuthCredentials(scopes={}), AuthUser(user_id=None, username="")
|
||||
|
||||
token = request.headers.get(SESSION_TOKEN_HEADER)
|
||||
if not token:
|
||||
print("[auth.authenticate] no token in header %s" % SESSION_TOKEN_HEADER)
|
||||
return AuthCredentials(scopes={}, error_message=str("no token")), AuthUser(user_id=None, username="")
|
||||
|
||||
if len(token.split(".")) > 1:
|
||||
payload = await SessionToken.verify(token)
|
||||
|
||||
with local_session() as session:
|
||||
try:
|
||||
user = (
|
||||
session.query(User)
|
||||
.options(
|
||||
joinedload(User.roles).options(joinedload(Role.permissions)),
|
||||
joinedload(User.ratings),
|
||||
)
|
||||
.filter(User.id == payload.user_id)
|
||||
.one()
|
||||
)
|
||||
|
||||
scopes = {} # TODO: integrate await user.get_permission()
|
||||
|
||||
return (
|
||||
AuthCredentials(user_id=payload.user_id, scopes=scopes, logged_in=True),
|
||||
AuthUser(user_id=user.id, username=""),
|
||||
)
|
||||
except exc.NoResultFound:
|
||||
pass
|
||||
|
||||
return AuthCredentials(scopes={}, error_message=str("Invalid token")), AuthUser(user_id=None, username="")
|
||||
|
||||
|
||||
def login_required(func):
|
||||
@wraps(func)
|
||||
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
|
||||
auth: AuthCredentials = info.context["request"].auth
|
||||
if not auth or not auth.logged_in:
|
||||
return {"error": "Please login first"}
|
||||
return await func(parent, info, *args, **kwargs)
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
def permission_required(resource, operation, func):
|
||||
@wraps(func)
|
||||
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
|
||||
print("[auth.authenticate] permission_required for %r with info %r" % (func, info)) # debug only
|
||||
auth: AuthCredentials = info.context["request"].auth
|
||||
if not auth.logged_in:
|
||||
raise OperationNotAllowed(auth.error_message or "Please login")
|
||||
|
||||
# TODO: add actual check permission logix here
|
||||
|
||||
return await func(parent, info, *args, **kwargs)
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
def login_accepted(func):
|
||||
@wraps(func)
|
||||
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
|
||||
auth: AuthCredentials = info.context["request"].auth
|
||||
|
||||
# Если есть авторизация, добавляем данные автора в контекст
|
||||
if auth and auth.logged_in:
|
||||
info.context["author"] = auth.author
|
||||
info.context["user_id"] = auth.author.get("id")
|
||||
else:
|
||||
# Очищаем данные автора из контекста если авторизация отсутствует
|
||||
info.context["author"] = None
|
||||
info.context["user_id"] = None
|
||||
|
||||
return await func(parent, info, *args, **kwargs)
|
||||
|
||||
return wrap
|
||||
@@ -1,43 +1,95 @@
|
||||
from typing import List, Optional, Text
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# from base.exceptions import Unauthorized
|
||||
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
|
||||
|
||||
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
|
||||
|
||||
|
||||
class Permission(BaseModel):
|
||||
name: Text
|
||||
"""Модель разрешения для RBAC"""
|
||||
|
||||
resource: str
|
||||
operation: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.resource}:{self.operation}"
|
||||
|
||||
|
||||
class AuthCredentials(BaseModel):
|
||||
user_id: Optional[int] = None
|
||||
scopes: Optional[dict] = {}
|
||||
logged_in: bool = False
|
||||
error_message: str = ""
|
||||
"""
|
||||
Модель учетных данных авторизации.
|
||||
Используется как часть механизма аутентификации Starlette.
|
||||
"""
|
||||
|
||||
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
|
||||
def is_admin(self):
|
||||
# TODO: check admin logix
|
||||
return True
|
||||
def is_admin(self) -> bool:
|
||||
"""
|
||||
Проверяет, является ли пользователь администратором.
|
||||
|
||||
async def permissions(self) -> List[Permission]:
|
||||
if self.user_id is None:
|
||||
Returns:
|
||||
bool: True, если email пользователя находится в списке ADMIN_EMAILS
|
||||
"""
|
||||
return self.email in ADMIN_EMAILS if self.email else False
|
||||
|
||||
async def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Преобразует учетные данные в словарь
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Словарь с данными учетных данных
|
||||
"""
|
||||
permissions = self.get_permissions()
|
||||
return {
|
||||
"author_id": self.author_id,
|
||||
"logged_in": self.logged_in,
|
||||
"is_admin": self.is_admin,
|
||||
"permissions": list(permissions),
|
||||
}
|
||||
|
||||
async def permissions(self) -> list[Permission]:
|
||||
if self.author_id is None:
|
||||
# raise Unauthorized("Please login first")
|
||||
return {"error": "Please login first"}
|
||||
else:
|
||||
# TODO: implement permissions logix
|
||||
print(self.user_id)
|
||||
return NotImplemented
|
||||
|
||||
|
||||
class AuthUser(BaseModel):
|
||||
user_id: Optional[int]
|
||||
username: Optional[str]
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
return self.user_id is not None
|
||||
|
||||
# @property
|
||||
# def display_id(self) -> int:
|
||||
# return self.user_id
|
||||
return [] # Возвращаем пустой список вместо dict
|
||||
# TODO: implement permissions logix
|
||||
print(self.author_id)
|
||||
return [] # Возвращаем пустой список вместо NotImplemented
|
||||
|
||||
521
auth/decorators.py
Normal file
521
auth/decorators.py
Normal file
@@ -0,0 +1,521 @@
|
||||
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,3 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from settings import MAILGUN_API_KEY, MAILGUN_DOMAIN
|
||||
@@ -7,9 +9,9 @@ noreply = "discours.io <noreply@%s>" % (MAILGUN_DOMAIN or "discours.io")
|
||||
lang_subject = {"ru": "Подтверждение почты", "en": "Confirm email"}
|
||||
|
||||
|
||||
async def send_auth_email(user, token, lang="ru", template="email_confirmation"):
|
||||
async def send_auth_email(user: Any, token: str, lang: str = "ru", template: str = "email_confirmation") -> None:
|
||||
try:
|
||||
to = "%s <%s>" % (user.name, user.email)
|
||||
to = f"{user.name} <{user.email}>"
|
||||
if lang not in ["ru", "en"]:
|
||||
lang = "ru"
|
||||
subject = lang_subject.get(lang, lang_subject["en"])
|
||||
@@ -19,12 +21,12 @@ async def send_auth_email(user, token, lang="ru", template="email_confirmation")
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"template": template,
|
||||
"h:X-Mailgun-Variables": '{ "token": "%s" }' % token,
|
||||
"h:X-Mailgun-Variables": f'{{ "token": "{token}" }}',
|
||||
}
|
||||
print("[auth.email] payload: %r" % payload)
|
||||
print(f"[auth.email] payload: {payload!r}")
|
||||
# debug
|
||||
# print('http://localhost:3000/?modal=auth&mode=confirm-email&token=%s' % token)
|
||||
response = requests.post(api_url, auth=("api", MAILGUN_API_KEY), data=payload)
|
||||
response = requests.post(api_url, auth=("api", MAILGUN_API_KEY), data=payload, timeout=30)
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
56
auth/handler.py
Normal file
56
auth/handler.py
Normal file
@@ -0,0 +1,56 @@
|
||||
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
|
||||
138
auth/identity.py
138
auth/identity.py
@@ -1,15 +1,20 @@
|
||||
from binascii import hexlify
|
||||
from hashlib import sha256
|
||||
from typing import TYPE_CHECKING, Any, TypeVar
|
||||
|
||||
from passlib.hash import bcrypt
|
||||
|
||||
from auth.exceptions import ExpiredToken, InvalidToken
|
||||
from auth.exceptions import ExpiredToken, InvalidPassword, InvalidToken
|
||||
from auth.jwtcodec import JWTCodec
|
||||
from auth.tokenstorage import TokenStorage
|
||||
from orm.user import User
|
||||
|
||||
# from base.exceptions import InvalidPassword, InvalidToken
|
||||
from services.db import local_session
|
||||
from services.redis import redis
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
# Для типизации
|
||||
if TYPE_CHECKING:
|
||||
from auth.orm import Author
|
||||
|
||||
AuthorType = TypeVar("AuthorType", bound="Author")
|
||||
|
||||
|
||||
class Password:
|
||||
@@ -24,16 +29,25 @@ class Password:
|
||||
|
||||
@staticmethod
|
||||
def encode(password: str) -> str:
|
||||
"""
|
||||
Кодирует пароль пользователя
|
||||
|
||||
Args:
|
||||
password (str): Пароль пользователя
|
||||
|
||||
Returns:
|
||||
str: Закодированный пароль
|
||||
"""
|
||||
password_sha256 = Password._get_sha256(password)
|
||||
return bcrypt.using(rounds=10).hash(password_sha256)
|
||||
|
||||
@staticmethod
|
||||
def verify(password: str, hashed: str) -> bool:
|
||||
"""
|
||||
r"""
|
||||
Verify that password hash is equal to specified hash. Hash format:
|
||||
|
||||
$2a$10$Ro0CUfOqk6cXEKf3dyaM7OhSCvnwM9s4wIX9JeLapehKK5YdLxKcm
|
||||
\__/\/ \____________________/\_____________________________/ # noqa: W605
|
||||
\__/\/ \____________________/\_____________________________/
|
||||
| | Salt Hash
|
||||
| Cost
|
||||
Version
|
||||
@@ -52,46 +66,102 @@ class Password:
|
||||
|
||||
class Identity:
|
||||
@staticmethod
|
||||
def password(orm_user: User, password: str) -> User:
|
||||
user = User(**orm_user.dict())
|
||||
if not user.password:
|
||||
# raise InvalidPassword("User password is empty")
|
||||
return {"error": "User password is empty"}
|
||||
if not Password.verify(password, user.password):
|
||||
# raise InvalidPassword("Wrong user password")
|
||||
return {"error": "Wrong user password"}
|
||||
return user
|
||||
def password(orm_author: AuthorType, password: str) -> AuthorType:
|
||||
"""
|
||||
Проверяет пароль пользователя
|
||||
|
||||
Args:
|
||||
orm_author (Author): Объект пользователя
|
||||
password (str): Пароль пользователя
|
||||
|
||||
Returns:
|
||||
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
|
||||
def oauth(inp) -> User:
|
||||
def oauth(inp: dict[str, Any]) -> Any:
|
||||
"""
|
||||
Создает нового пользователя OAuth, если он не существует
|
||||
|
||||
Args:
|
||||
inp (dict): Данные OAuth пользователя
|
||||
|
||||
Returns:
|
||||
Author: Объект пользователя
|
||||
"""
|
||||
# Импортируем внутри функции для избежания циклических импортов
|
||||
from auth.orm import Author
|
||||
|
||||
with local_session() as session:
|
||||
user = session.query(User).filter(User.email == inp["email"]).first()
|
||||
if not user:
|
||||
user = User.create(**inp, emailConfirmed=True)
|
||||
author = session.query(Author).filter(Author.email == inp["email"]).first()
|
||||
if not author:
|
||||
author = Author(**inp)
|
||||
author.email_verified = True # type: ignore[assignment]
|
||||
session.add(author)
|
||||
session.commit()
|
||||
|
||||
return user
|
||||
return author
|
||||
|
||||
@staticmethod
|
||||
async def onetime(token: str) -> User:
|
||||
async def onetime(token: str) -> Any:
|
||||
"""
|
||||
Проверяет одноразовый токен
|
||||
|
||||
Args:
|
||||
token (str): Одноразовый токен
|
||||
|
||||
Returns:
|
||||
Author: Объект пользователя
|
||||
"""
|
||||
# Импортируем внутри функции для избежания циклических импортов
|
||||
from auth.orm import Author
|
||||
|
||||
try:
|
||||
print("[auth.identity] using one time token")
|
||||
payload = JWTCodec.decode(token)
|
||||
if not await TokenStorage.exist(f"{payload.user_id}-{payload.username}-{token}"):
|
||||
# raise InvalidToken("Login token has expired, please login again")
|
||||
return {"error": "Token has expired"}
|
||||
if payload is None:
|
||||
logger.warning("[Identity.token] Токен не валиден (payload is None)")
|
||||
return {"error": "Invalid token"}
|
||||
|
||||
# Проверяем существование токена в хранилище
|
||||
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")
|
||||
return {"error": "Token has expired"}
|
||||
except InvalidToken:
|
||||
# raise InvalidToken("token format error") from e
|
||||
return {"error": "Token format error"}
|
||||
with local_session() as session:
|
||||
user = session.query(User).filter_by(id=payload.user_id).first()
|
||||
if not user:
|
||||
# raise Exception("user not exist")
|
||||
return {"error": "User does not exist"}
|
||||
if not user.emailConfirmed:
|
||||
user.emailConfirmed = True
|
||||
session.commit()
|
||||
return user
|
||||
|
||||
147
auth/internal.py
Normal file
147
auth/internal.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Утилитные функции для внутренней аутентификации
|
||||
Используются в 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
|
||||
107
auth/jwtcodec.py
107
auth/jwtcodec.py
@@ -1,39 +1,79 @@
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import jwt
|
||||
from pydantic import BaseModel
|
||||
|
||||
from auth.exceptions import ExpiredToken, InvalidToken
|
||||
from settings import JWT_ALGORITHM, JWT_SECRET_KEY
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
user_id: str
|
||||
username: str
|
||||
exp: datetime
|
||||
exp: Optional[datetime] = None
|
||||
iat: datetime
|
||||
iss: str
|
||||
|
||||
|
||||
class JWTCodec:
|
||||
@staticmethod
|
||||
def encode(user, exp: datetime) -> str:
|
||||
def encode(user: Union[dict[str, Any], Any], exp: Optional[datetime] = None) -> 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 = {
|
||||
"user_id": user.id,
|
||||
"username": user.email or user.phone,
|
||||
"exp": exp,
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"exp": exp_timestamp, # Используем timestamp вместо datetime
|
||||
"iat": datetime.now(tz=timezone.utc),
|
||||
"iss": "discours",
|
||||
}
|
||||
|
||||
logger.debug(f"[JWTCodec.encode] Сформирован payload: {payload}")
|
||||
|
||||
try:
|
||||
return jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM)
|
||||
token = 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:
|
||||
print("[auth.jwtcodec] JWT encode error %r" % e)
|
||||
logger.error(f"[JWTCodec.encode] Ошибка при кодировании JWT: {e}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def decode(token: str, verify_exp: bool = True):
|
||||
r = None
|
||||
payload = None
|
||||
def decode(token: str, verify_exp: bool = True) -> Optional[TokenPayload]:
|
||||
logger.debug(f"[JWTCodec.decode] Начало декодирования токена длиной {len(token) if token else 0}")
|
||||
|
||||
if not token:
|
||||
logger.error("[JWTCodec.decode] Пустой токен")
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
@@ -45,16 +85,39 @@ class JWTCodec:
|
||||
algorithms=[JWT_ALGORITHM],
|
||||
issuer="discours",
|
||||
)
|
||||
r = TokenPayload(**payload)
|
||||
# print('[auth.jwtcodec] debug token %r' % r)
|
||||
return r
|
||||
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)
|
||||
logger.debug(
|
||||
f"[JWTCodec.decode] Создан объект TokenPayload: user_id={r.user_id}, username={r.username}"
|
||||
)
|
||||
return r
|
||||
except Exception as e:
|
||||
logger.error(f"[JWTCodec.decode] Ошибка при создании TokenPayload: {e}")
|
||||
return None
|
||||
|
||||
except jwt.InvalidIssuedAtError:
|
||||
print("[auth.jwtcodec] invalid issued at: %r" % payload)
|
||||
raise ExpiredToken("check token issued time")
|
||||
logger.error("[JWTCodec.decode] Недействительное время выпуска токена")
|
||||
return None
|
||||
except jwt.ExpiredSignatureError:
|
||||
print("[auth.jwtcodec] expired signature %r" % payload)
|
||||
raise ExpiredToken("check token lifetime")
|
||||
except jwt.InvalidTokenError:
|
||||
raise InvalidToken("token is not valid")
|
||||
logger.error("[JWTCodec.decode] Истек срок действия токена")
|
||||
return None
|
||||
except jwt.InvalidSignatureError:
|
||||
raise InvalidToken("token is not valid")
|
||||
logger.error("[JWTCodec.decode] Недействительная подпись токена")
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
logger.error("[JWTCodec.decode] Недействительный токен")
|
||||
return None
|
||||
except jwt.InvalidKeyError:
|
||||
logger.error("[JWTCodec.decode] Недействительный ключ")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[JWTCodec.decode] Неожиданная ошибка при декодировании: {e}")
|
||||
return None
|
||||
|
||||
439
auth/middleware.py
Normal file
439
auth/middleware.py
Normal file
@@ -0,0 +1,439 @@
|
||||
"""
|
||||
Единый 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)
|
||||
680
auth/oauth.py
680
auth/oauth.py
@@ -1,98 +1,618 @@
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from starlette.responses import RedirectResponse
|
||||
import time
|
||||
from secrets import token_urlsafe
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import orjson
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from authlib.oauth2.rfc7636 import create_s256_code_challenge
|
||||
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.register(
|
||||
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 state management через Redis (TTL 10 минут)
|
||||
OAUTH_STATE_TTL = 600 # 10 минут
|
||||
|
||||
oauth.register(
|
||||
name="github",
|
||||
client_id=OAUTH_CLIENTS["GITHUB"]["id"],
|
||||
client_secret=OAUTH_CLIENTS["GITHUB"]["key"],
|
||||
access_token_url="https://github.com/login/oauth/access_token",
|
||||
access_token_params=None,
|
||||
authorize_url="https://github.com/login/oauth/authorize",
|
||||
authorize_params=None,
|
||||
api_base_url="https://api.github.com/",
|
||||
client_kwargs={"scope": "user:email"},
|
||||
)
|
||||
# Конфигурация провайдеров для регистрации
|
||||
PROVIDER_CONFIGS = {
|
||||
"google": {
|
||||
"server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
|
||||
},
|
||||
"github": {
|
||||
"access_token_url": "https://github.com/login/oauth/access_token",
|
||||
"authorize_url": "https://github.com/login/oauth/authorize",
|
||||
"api_base_url": "https://api.github.com/",
|
||||
},
|
||||
"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",
|
||||
},
|
||||
}
|
||||
|
||||
oauth.register(
|
||||
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",
|
||||
)
|
||||
# Константы для генерации временного email
|
||||
TEMP_EMAIL_SUFFIX = "@oauth.local"
|
||||
|
||||
|
||||
async def google_profile(client, request, token):
|
||||
userinfo = token["userinfo"]
|
||||
|
||||
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 _generate_temp_email(provider: str, user_id: str) -> str:
|
||||
"""Генерирует временный email для OAuth провайдеров без email"""
|
||||
return f"{provider}_{user_id}@oauth.local"
|
||||
|
||||
|
||||
async def facebook_profile(client, request, token):
|
||||
profile = await client.get("me?fields=name,id,email", token=token)
|
||||
return profile.json()
|
||||
def _register_oauth_provider(provider: str, client_config: dict) -> None:
|
||||
"""Регистрирует OAuth провайдер в зависимости от его типа"""
|
||||
try:
|
||||
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}")
|
||||
|
||||
|
||||
async def github_profile(client, request, token):
|
||||
profile = await client.get("user", token=token)
|
||||
return profile.json()
|
||||
for provider in PROVIDER_CONFIGS:
|
||||
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)
|
||||
|
||||
|
||||
profile_callbacks = {
|
||||
"google": google_profile,
|
||||
"facebook": facebook_profile,
|
||||
"github": github_profile,
|
||||
# Провайдеры со специальной обработкой данных
|
||||
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 oauth_login(request):
|
||||
provider = request.path_params["provider"]
|
||||
request.session["provider"] = provider
|
||||
client = oauth.create_client(provider)
|
||||
redirect_uri = "https://v2.discours.io/oauth-authorize"
|
||||
return await client.authorize_redirect(request, redirect_uri)
|
||||
|
||||
|
||||
async def oauth_authorize(request):
|
||||
provider = request.session["provider"]
|
||||
client = oauth.create_client(provider)
|
||||
token = await client.authorize_access_token(request)
|
||||
get_profile = profile_callbacks[provider]
|
||||
profile = await get_profile(client, request, token)
|
||||
user_oauth_info = "%s:%s" % (provider, profile["id"])
|
||||
user_input = {
|
||||
"oauth": user_oauth_info,
|
||||
"email": profile["email"],
|
||||
"username": profile["name"],
|
||||
"userpic": profile["userpic"],
|
||||
async def _fetch_github_profile(client: Any, token: Any) -> dict:
|
||||
"""Получает профиль из GitHub API"""
|
||||
profile = await client.get("user", token=token)
|
||||
profile_data = 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"),
|
||||
}
|
||||
user = Identity.oauth(user_input)
|
||||
session_token = await TokenStorage.create_session(user)
|
||||
response = RedirectResponse(url=FRONTEND_URL + "/confirm")
|
||||
response.set_cookie("token", session_token)
|
||||
return response
|
||||
|
||||
|
||||
async def _fetch_facebook_profile(client: Any, token: Any) -> dict:
|
||||
"""Получает профиль из Facebook API"""
|
||||
profile = await client.get("me?fields=id,name,email,picture.width(600)", token=token)
|
||||
profile_data = profile.json()
|
||||
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:
|
||||
"""Получает профиль из X (Twitter) API"""
|
||||
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["state"] = state
|
||||
|
||||
# Сохраняем состояние OAuth в Redis
|
||||
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:
|
||||
"""HTTP handler для OAuth callback"""
|
||||
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)
|
||||
token = await client.authorize_access_token(request)
|
||||
|
||||
profile = await get_user_profile(provider, client, token)
|
||||
if not profile:
|
||||
return JSONResponse({"error": "Failed to get user profile"}, status_code=400)
|
||||
|
||||
# Создаем или обновляем пользователя используя helper функцию
|
||||
author = await _create_or_update_user(provider, profile)
|
||||
|
||||
# Создаем токен сессии
|
||||
session_token = await TokenStorage.create_session(str(author.id))
|
||||
|
||||
# Очищаем OAuth сессию
|
||||
request.session.pop("code_verifier", None)
|
||||
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
|
||||
|
||||
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
Normal file
268
auth/orm.py
Normal file
@@ -0,0 +1,268 @@
|
||||
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]
|
||||
146
auth/permissions.py
Normal file
146
auth/permissions.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
Модуль для проверки разрешений пользователей в контексте сообществ.
|
||||
|
||||
Позволяет проверять доступ пользователя к определенным операциям в сообществе
|
||||
на основе его роли в этом сообществе.
|
||||
"""
|
||||
|
||||
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,215 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from graphql.type import GraphQLResolveInfo
|
||||
|
||||
from auth.authenticate import login_required
|
||||
from auth.credentials import AuthCredentials
|
||||
from auth.email import send_auth_email
|
||||
from auth.exceptions import InvalidPassword, InvalidToken, ObjectNotExist, Unauthorized
|
||||
from auth.identity import Identity, Password
|
||||
from auth.jwtcodec import JWTCodec
|
||||
from auth.tokenstorage import TokenStorage
|
||||
from orm import Role, User
|
||||
from services.db import local_session
|
||||
from services.schema import mutation, query
|
||||
from settings import SESSION_TOKEN_HEADER
|
||||
|
||||
|
||||
@mutation.field("getSession")
|
||||
@login_required
|
||||
async def get_current_user(_, info):
|
||||
auth: AuthCredentials = info.context["request"].auth
|
||||
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER)
|
||||
|
||||
with local_session() as session:
|
||||
user = session.query(User).where(User.id == auth.user_id).one()
|
||||
user.lastSeen = datetime.now(tz=timezone.utc)
|
||||
session.commit()
|
||||
|
||||
return {"token": token, "user": user}
|
||||
|
||||
|
||||
@mutation.field("confirmEmail")
|
||||
async def confirm_email(_, info, token):
|
||||
"""confirm owning email address"""
|
||||
try:
|
||||
print("[resolvers.auth] confirm email by token")
|
||||
payload = JWTCodec.decode(token)
|
||||
user_id = payload.user_id
|
||||
await TokenStorage.get(f"{user_id}-{payload.username}-{token}")
|
||||
with local_session() as session:
|
||||
user = session.query(User).where(User.id == user_id).first()
|
||||
session_token = await TokenStorage.create_session(user)
|
||||
user.emailConfirmed = True
|
||||
user.lastSeen = datetime.now(tz=timezone.utc)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
return {"token": session_token, "user": user}
|
||||
except InvalidToken as e:
|
||||
raise InvalidToken(e.message)
|
||||
except Exception as e:
|
||||
print(e) # FIXME: debug only
|
||||
return {"error": "email is not confirmed"}
|
||||
|
||||
|
||||
def create_user(user_dict):
|
||||
user = User(**user_dict)
|
||||
with local_session() as session:
|
||||
user.roles.append(session.query(Role).first())
|
||||
session.add(user)
|
||||
session.commit()
|
||||
return user
|
||||
|
||||
|
||||
def replace_translit(src):
|
||||
ruchars = "абвгдеёжзийклмнопрстуфхцчшщъыьэюя."
|
||||
enchars = [
|
||||
"a",
|
||||
"b",
|
||||
"v",
|
||||
"g",
|
||||
"d",
|
||||
"e",
|
||||
"yo",
|
||||
"zh",
|
||||
"z",
|
||||
"i",
|
||||
"y",
|
||||
"k",
|
||||
"l",
|
||||
"m",
|
||||
"n",
|
||||
"o",
|
||||
"p",
|
||||
"r",
|
||||
"s",
|
||||
"t",
|
||||
"u",
|
||||
"f",
|
||||
"h",
|
||||
"c",
|
||||
"ch",
|
||||
"sh",
|
||||
"sch",
|
||||
"",
|
||||
"y",
|
||||
"'",
|
||||
"e",
|
||||
"yu",
|
||||
"ya",
|
||||
"-",
|
||||
]
|
||||
return src.translate(str.maketrans(ruchars, enchars))
|
||||
|
||||
|
||||
def generate_unique_slug(src):
|
||||
print("[resolvers.auth] generating slug from: " + src)
|
||||
slug = replace_translit(src.lower())
|
||||
slug = re.sub("[^0-9a-zA-Z]+", "-", slug)
|
||||
if slug != src:
|
||||
print("[resolvers.auth] translited name: " + slug)
|
||||
c = 1
|
||||
with local_session() as session:
|
||||
user = session.query(User).where(User.slug == slug).first()
|
||||
while user:
|
||||
user = session.query(User).where(User.slug == slug).first()
|
||||
slug = slug + "-" + str(c)
|
||||
c += 1
|
||||
if not user:
|
||||
unique_slug = slug
|
||||
print("[resolvers.auth] " + unique_slug)
|
||||
return quote_plus(unique_slug.replace("'", "")).replace("+", "-")
|
||||
|
||||
|
||||
@mutation.field("registerUser")
|
||||
async def register_by_email(_, _info, email: str, password: str = "", name: str = ""):
|
||||
email = email.lower()
|
||||
"""creates new user account"""
|
||||
with local_session() as session:
|
||||
user = session.query(User).filter(User.email == email).first()
|
||||
if user:
|
||||
raise Unauthorized("User already exist")
|
||||
else:
|
||||
slug = generate_unique_slug(name)
|
||||
user = session.query(User).where(User.slug == slug).first()
|
||||
if user:
|
||||
slug = generate_unique_slug(email.split("@")[0])
|
||||
user_dict = {
|
||||
"email": email,
|
||||
"username": email, # will be used to store phone number or some messenger network id
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
}
|
||||
if password:
|
||||
user_dict["password"] = Password.encode(password)
|
||||
user = create_user(user_dict)
|
||||
user = await auth_send_link(_, _info, email)
|
||||
return {"user": user}
|
||||
|
||||
|
||||
@mutation.field("sendLink")
|
||||
async def auth_send_link(_, _info, email, lang="ru", template="email_confirmation"):
|
||||
email = email.lower()
|
||||
"""send link with confirm code to email"""
|
||||
with local_session() as session:
|
||||
user = session.query(User).filter(User.email == email).first()
|
||||
if not user:
|
||||
raise ObjectNotExist("User not found")
|
||||
else:
|
||||
token = await TokenStorage.create_onetime(user)
|
||||
await send_auth_email(user, token, lang, template)
|
||||
return user
|
||||
|
||||
|
||||
@query.field("signIn")
|
||||
async def login(_, info, email: str, password: str = "", lang: str = "ru"):
|
||||
email = email.lower()
|
||||
with local_session() as session:
|
||||
orm_user = session.query(User).filter(User.email == email).first()
|
||||
if orm_user is None:
|
||||
print(f"[auth] {email}: email not found")
|
||||
# return {"error": "email not found"}
|
||||
raise ObjectNotExist("User not found") # contains webserver status
|
||||
|
||||
if not password:
|
||||
print(f"[auth] send confirm link to {email}")
|
||||
token = await TokenStorage.create_onetime(orm_user)
|
||||
await send_auth_email(orm_user, token, lang)
|
||||
# FIXME: not an error, warning
|
||||
return {"error": "no password, email link was sent"}
|
||||
|
||||
else:
|
||||
# sign in using password
|
||||
if not orm_user.emailConfirmed:
|
||||
# not an error, warns users
|
||||
return {"error": "please, confirm email"}
|
||||
else:
|
||||
try:
|
||||
user = Identity.password(orm_user, password)
|
||||
session_token = await TokenStorage.create_session(user)
|
||||
print(f"[auth] user {email} authorized")
|
||||
return {"token": session_token, "user": user}
|
||||
except InvalidPassword:
|
||||
print(f"[auth] {email}: invalid password")
|
||||
raise InvalidPassword("invalid password") # contains webserver status
|
||||
# return {"error": "invalid password"}
|
||||
|
||||
|
||||
@query.field("signOut")
|
||||
@login_required
|
||||
async def sign_out(_, info: GraphQLResolveInfo):
|
||||
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER, "")
|
||||
status = await TokenStorage.revoke(token)
|
||||
return status
|
||||
|
||||
|
||||
@query.field("isEmailUsed")
|
||||
async def is_email_used(_, _info, email):
|
||||
email = email.lower()
|
||||
with local_session() as session:
|
||||
user = session.query(User).filter(User.email == email).first()
|
||||
return user is not None
|
||||
25
auth/state.py
Normal file
25
auth/state.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Классы состояния авторизации
|
||||
"""
|
||||
|
||||
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
|
||||
0
auth/tokens/__init__.py
Normal file
0
auth/tokens/__init__.py
Normal file
54
auth/tokens/base.py
Normal file
54
auth/tokens/base.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Базовый класс для работы с токенами
|
||||
"""
|
||||
|
||||
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)
|
||||
197
auth/tokens/batch.py
Normal file
197
auth/tokens/batch.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Батчевые операции с токенами для оптимизации производительности
|
||||
"""
|
||||
|
||||
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
|
||||
189
auth/tokens/monitoring.py
Normal file
189
auth/tokens/monitoring.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Статистика и мониторинг системы токенов
|
||||
"""
|
||||
|
||||
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
|
||||
155
auth/tokens/oauth.py
Normal file
155
auth/tokens/oauth.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Управление 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
|
||||
267
auth/tokens/sessions.py
Normal file
267
auth/tokens/sessions.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
Управление токенами сессий
|
||||
"""
|
||||
|
||||
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
|
||||
114
auth/tokens/storage.py
Normal file
114
auth/tokens/storage.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Простой интерфейс для системы токенов
|
||||
"""
|
||||
|
||||
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()
|
||||
23
auth/tokens/types.py
Normal file
23
auth/tokens/types.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Типы и константы для системы токенов
|
||||
"""
|
||||
|
||||
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]
|
||||
161
auth/tokens/verification.py
Normal file
161
auth/tokens/verification.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Управление токенами подтверждения
|
||||
"""
|
||||
|
||||
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)
|
||||
@@ -1,73 +0,0 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from auth.jwtcodec import JWTCodec
|
||||
from auth.validations import AuthInput
|
||||
from services.redis import redis
|
||||
from settings import ONETIME_TOKEN_LIFE_SPAN, SESSION_TOKEN_LIFE_SPAN
|
||||
|
||||
|
||||
async def save(token_key, life_span, auto_delete=True):
|
||||
await redis.execute("SET", token_key, "True")
|
||||
if auto_delete:
|
||||
expire_at = (datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)).timestamp()
|
||||
await redis.execute("EXPIREAT", token_key, int(expire_at))
|
||||
|
||||
|
||||
class SessionToken:
|
||||
@classmethod
|
||||
async def verify(cls, token: str):
|
||||
"""
|
||||
Rules for a token to be valid.
|
||||
- token format is legal
|
||||
- token exists in redis database
|
||||
- token is not expired
|
||||
"""
|
||||
try:
|
||||
return JWTCodec.decode(token)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
@classmethod
|
||||
async def get(cls, payload, token):
|
||||
return await TokenStorage.get(f"{payload.user_id}-{payload.username}-{token}")
|
||||
|
||||
|
||||
class TokenStorage:
|
||||
@staticmethod
|
||||
async def get(token_key):
|
||||
print("[tokenstorage.get] " + token_key)
|
||||
# 2041-user@domain.zn-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyMDQxLCJ1c2VybmFtZSI6ImFudG9uLnJld2luK3Rlc3QtbG9hZGNoYXRAZ21haWwuY29tIiwiZXhwIjoxNjcxNzgwNjE2LCJpYXQiOjE2NjkxODg2MTYsImlzcyI6ImRpc2NvdXJzIn0.Nml4oV6iMjMmc6xwM7lTKEZJKBXvJFEIZ-Up1C1rITQ
|
||||
return await redis.execute("GET", token_key)
|
||||
|
||||
@staticmethod
|
||||
async def create_onetime(user: AuthInput) -> str:
|
||||
life_span = ONETIME_TOKEN_LIFE_SPAN
|
||||
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
|
||||
one_time_token = JWTCodec.encode(user, exp)
|
||||
await save(f"{user.id}-{user.username}-{one_time_token}", life_span)
|
||||
return one_time_token
|
||||
|
||||
@staticmethod
|
||||
async def create_session(user: AuthInput) -> str:
|
||||
life_span = SESSION_TOKEN_LIFE_SPAN
|
||||
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
|
||||
session_token = JWTCodec.encode(user, exp)
|
||||
await save(f"{user.id}-{user.username}-{session_token}", life_span)
|
||||
return session_token
|
||||
|
||||
@staticmethod
|
||||
async def revoke(token: str) -> bool:
|
||||
payload = None
|
||||
try:
|
||||
print("[auth.tokenstorage] revoke token")
|
||||
payload = JWTCodec.decode(token)
|
||||
except: # noqa
|
||||
pass
|
||||
else:
|
||||
await redis.execute("DEL", f"{payload.user_id}-{payload.username}-{token}")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def revoke_all(user: AuthInput):
|
||||
tokens = await redis.execute("KEYS", f"{user.id}-*")
|
||||
await redis.execute("DEL", *tokens)
|
||||
@@ -1,6 +1,6 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Union
|
||||
from typing import Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
@@ -19,7 +19,8 @@ class AuthInput(BaseModel):
|
||||
@classmethod
|
||||
def validate_user_id(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("user_id cannot be empty")
|
||||
msg = "user_id cannot be empty"
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
@@ -35,7 +36,8 @@ class UserRegistrationInput(BaseModel):
|
||||
def validate_email(cls, v: str) -> str:
|
||||
"""Validate email format"""
|
||||
if not re.match(EMAIL_PATTERN, v):
|
||||
raise ValueError("Invalid email format")
|
||||
msg = "Invalid email format"
|
||||
raise ValueError(msg)
|
||||
return v.lower()
|
||||
|
||||
@field_validator("password")
|
||||
@@ -43,13 +45,17 @@ class UserRegistrationInput(BaseModel):
|
||||
def validate_password_strength(cls, v: str) -> str:
|
||||
"""Validate password meets security requirements"""
|
||||
if not any(c.isupper() for c in v):
|
||||
raise ValueError("Password must contain at least one uppercase letter")
|
||||
msg = "Password must contain at least one uppercase letter"
|
||||
raise ValueError(msg)
|
||||
if not any(c.islower() for c in v):
|
||||
raise ValueError("Password must contain at least one lowercase letter")
|
||||
msg = "Password must contain at least one lowercase letter"
|
||||
raise ValueError(msg)
|
||||
if not any(c.isdigit() for c in v):
|
||||
raise ValueError("Password must contain at least one number")
|
||||
msg = "Password must contain at least one number"
|
||||
raise ValueError(msg)
|
||||
if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in v):
|
||||
raise ValueError("Password must contain at least one special character")
|
||||
msg = "Password must contain at least one special character"
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
@@ -63,7 +69,8 @@ class UserLoginInput(BaseModel):
|
||||
@classmethod
|
||||
def validate_email(cls, v: str) -> str:
|
||||
if not re.match(EMAIL_PATTERN, v):
|
||||
raise ValueError("Invalid email format")
|
||||
msg = "Invalid email format"
|
||||
raise ValueError(msg)
|
||||
return v.lower()
|
||||
|
||||
|
||||
@@ -74,7 +81,7 @@ class TokenPayload(BaseModel):
|
||||
username: str
|
||||
exp: datetime
|
||||
iat: datetime
|
||||
scopes: Optional[List[str]] = []
|
||||
scopes: Optional[list[str]] = []
|
||||
|
||||
|
||||
class OAuthInput(BaseModel):
|
||||
@@ -89,7 +96,8 @@ class OAuthInput(BaseModel):
|
||||
def validate_provider(cls, v: str) -> str:
|
||||
valid_providers = ["google", "github", "facebook"]
|
||||
if v.lower() not in valid_providers:
|
||||
raise ValueError(f"Provider must be one of: {', '.join(valid_providers)}")
|
||||
msg = f"Provider must be one of: {', '.join(valid_providers)}"
|
||||
raise ValueError(msg)
|
||||
return v.lower()
|
||||
|
||||
|
||||
@@ -99,18 +107,20 @@ class AuthResponse(BaseModel):
|
||||
success: bool
|
||||
token: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
user: Optional[Dict[str, Union[str, int, bool]]] = 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:
|
||||
raise ValueError("Error message required when success is False")
|
||||
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:
|
||||
raise ValueError("Token required when success is True")
|
||||
msg = "Token required when success is True"
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
109
biome.json
Normal file
109
biome.json
Normal file
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
441
cache/cache.py
vendored
441
cache/cache.py
vendored
@@ -29,17 +29,17 @@ for new cache operations.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from typing import Any, Callable, Dict, List, Optional, Type, Union
|
||||
|
||||
import orjson
|
||||
from sqlalchemy import and_, join, select
|
||||
|
||||
from orm.author import Author, AuthorFollower
|
||||
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 CustomJSONEncoder
|
||||
from utils.encoders import fast_json_dumps
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
DEFAULT_FOLLOWS = {
|
||||
@@ -60,14 +60,16 @@ CACHE_KEYS = {
|
||||
"TOPIC_FOLLOWERS": "topic:followers:{}",
|
||||
"TOPIC_SHOUTS": "topic_shouts_{}",
|
||||
"AUTHOR_ID": "author:id:{}",
|
||||
"AUTHOR_USER": "author:user:{}",
|
||||
"SHOUTS": "shouts:{}",
|
||||
}
|
||||
|
||||
# Type alias for JSON encoder
|
||||
JSONEncoderType = Type[json.JSONEncoder]
|
||||
|
||||
|
||||
# Cache topic data
|
||||
async def cache_topic(topic: dict):
|
||||
payload = json.dumps(topic, cls=CustomJSONEncoder)
|
||||
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),
|
||||
@@ -75,30 +77,38 @@ async def cache_topic(topic: dict):
|
||||
|
||||
|
||||
# Cache author data
|
||||
async def cache_author(author: dict):
|
||||
payload = json.dumps(author, cls=CustomJSONEncoder)
|
||||
async def cache_author(author: dict) -> None:
|
||||
payload = fast_json_dumps(author)
|
||||
await asyncio.gather(
|
||||
redis.execute("SET", f"author:user:{author['user'].strip()}", str(author["id"])),
|
||||
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=True):
|
||||
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)
|
||||
follows = orjson.loads(follows_str) if follows_str else DEFAULT_FOLLOWS[entity_type]
|
||||
|
||||
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, json.dumps(follows, cls=CustomJSONEncoder))
|
||||
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, entity_type, count):
|
||||
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
|
||||
@@ -108,23 +118,49 @@ async def update_follower_stat(follower_id, entity_type, count):
|
||||
|
||||
|
||||
# Get author from cache
|
||||
async def get_cached_author(author_id: int, get_with_stat):
|
||||
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:
|
||||
return orjson.loads(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]
|
||||
await cache_author(author.dict())
|
||||
return author.dict()
|
||||
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):
|
||||
async def get_cached_topic(topic_id: int) -> dict | None:
|
||||
"""
|
||||
Fetch topic data from cache or database by id.
|
||||
|
||||
@@ -144,14 +180,14 @@ async def get_cached_topic(topic_id: int):
|
||||
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, json.dumps(topic_dict, cls=CustomJSONEncoder))
|
||||
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):
|
||||
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:
|
||||
@@ -167,7 +203,7 @@ async def get_cached_topic_by_slug(slug: str, get_with_stat):
|
||||
|
||||
|
||||
# Get list of authors by ID from cache
|
||||
async def get_cached_authors_by_ids(author_ids: List[int]) -> List[dict]:
|
||||
async def get_cached_authors_by_ids(author_ids: list[int]) -> list[dict]:
|
||||
# Fetch all author data concurrently
|
||||
keys = [f"author:id:{author_id}" for author_id in author_ids]
|
||||
results = await asyncio.gather(*(redis.execute("GET", key) for key in keys))
|
||||
@@ -178,11 +214,12 @@ async def get_cached_authors_by_ids(author_ids: List[int]) -> List[dict]:
|
||||
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().all()
|
||||
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()
|
||||
return authors
|
||||
# Фильтруем None значения для корректного типа возвращаемого значения
|
||||
return [author for author in authors if author is not None]
|
||||
|
||||
|
||||
async def get_cached_topic_followers(topic_id: int):
|
||||
@@ -213,13 +250,13 @@ async def get_cached_topic_followers(topic_id: int):
|
||||
.all()
|
||||
]
|
||||
|
||||
await redis.execute("SETEX", cache_key, CACHE_TTL, orjson.dumps(followers_ids))
|
||||
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}: {str(e)}")
|
||||
logger.error(f"Error getting followers for topic #{topic_id}: {e!s}")
|
||||
return []
|
||||
|
||||
|
||||
@@ -242,9 +279,8 @@ async def get_cached_author_followers(author_id: int):
|
||||
.filter(AuthorFollower.author == author_id, Author.id != author_id)
|
||||
.all()
|
||||
]
|
||||
await redis.execute("SET", f"author:followers:{author_id}", orjson.dumps(followers_ids))
|
||||
followers = await get_cached_authors_by_ids(followers_ids)
|
||||
return followers
|
||||
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
|
||||
@@ -264,10 +300,9 @@ async def get_cached_follower_authors(author_id: int):
|
||||
.where(AuthorFollower.follower == author_id)
|
||||
).all()
|
||||
]
|
||||
await redis.execute("SET", f"author:follows-authors:{author_id}", orjson.dumps(authors_ids))
|
||||
await redis.execute("SET", f"author:follows-authors:{author_id}", fast_json_dumps(authors_ids))
|
||||
|
||||
authors = await get_cached_authors_by_ids(authors_ids)
|
||||
return authors
|
||||
return await get_cached_authors_by_ids(authors_ids)
|
||||
|
||||
|
||||
# Get cached follower topics
|
||||
@@ -286,7 +321,7 @@ async def get_cached_follower_topics(author_id: int):
|
||||
.where(TopicFollower.follower == author_id)
|
||||
.all()
|
||||
]
|
||||
await redis.execute("SET", f"author:follows-topics:{author_id}", orjson.dumps(topics_ids))
|
||||
await redis.execute("SET", f"author:follows-topics:{author_id}", fast_json_dumps(topics_ids))
|
||||
|
||||
topics = []
|
||||
for topic_id in topics_ids:
|
||||
@@ -300,35 +335,32 @@ async def get_cached_follower_topics(author_id: int):
|
||||
return topics
|
||||
|
||||
|
||||
# Get author by user ID from cache
|
||||
async def get_cached_author_by_user_id(user_id: str, get_with_stat):
|
||||
# Get author by author_id from cache
|
||||
async def get_cached_author_by_id(author_id: int, get_with_stat):
|
||||
"""
|
||||
Retrieve author information by user_id, checking the cache first, then the database.
|
||||
Retrieve author information by author_id, checking the cache first, then the database.
|
||||
|
||||
Args:
|
||||
user_id (str): The user identifier for which to retrieve the author.
|
||||
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 ID by user_id in Redis cache
|
||||
author_id = await redis.execute("GET", f"author:user:{user_id.strip()}")
|
||||
if author_id:
|
||||
# If ID is found, get full author data by ID
|
||||
author_data = await redis.execute("GET", f"author:id:{author_id}")
|
||||
if author_data:
|
||||
return orjson.loads(author_data)
|
||||
# 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.user == user_id)
|
||||
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:user:{user_id.strip()}", str(author.id)),
|
||||
redis.execute("SET", f"author:id:{author.id}", orjson.dumps(author_dict)),
|
||||
redis.execute("SET", f"author:id:{author.id}", fast_json_dumps(author_dict)),
|
||||
)
|
||||
return author_dict
|
||||
|
||||
@@ -359,11 +391,17 @@ async def get_cached_topic_authors(topic_id: int):
|
||||
select(ShoutAuthor.author)
|
||||
.select_from(join(ShoutTopic, Shout, ShoutTopic.shout == Shout.id))
|
||||
.join(ShoutAuthor, ShoutAuthor.shout == Shout.id)
|
||||
.where(and_(ShoutTopic.topic == topic_id, Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
|
||||
.where(
|
||||
and_(
|
||||
ShoutTopic.topic == topic_id,
|
||||
Shout.published_at.is_not(None),
|
||||
Shout.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
authors_ids = [author_id for (author_id,) in session.execute(query).all()]
|
||||
# Cache the retrieved author IDs
|
||||
await redis.execute("SET", rkey, orjson.dumps(authors_ids))
|
||||
await redis.execute("SET", rkey, fast_json_dumps(authors_ids))
|
||||
|
||||
# Retrieve full author details from cached IDs
|
||||
if authors_ids:
|
||||
@@ -374,15 +412,12 @@ async def get_cached_topic_authors(topic_id: int):
|
||||
return []
|
||||
|
||||
|
||||
async def invalidate_shouts_cache(cache_keys: List[str]):
|
||||
async def invalidate_shouts_cache(cache_keys: list[str]) -> None:
|
||||
"""
|
||||
Инвалидирует кэш выборок публикаций по переданным ключам.
|
||||
"""
|
||||
for key in cache_keys:
|
||||
for cache_key in cache_keys:
|
||||
try:
|
||||
# Формируем полный ключ кэша
|
||||
cache_key = f"shouts:{key}"
|
||||
|
||||
# Удаляем основной кэш
|
||||
await redis.execute("DEL", cache_key)
|
||||
logger.debug(f"Invalidated cache key: {cache_key}")
|
||||
@@ -391,8 +426,8 @@ async def invalidate_shouts_cache(cache_keys: List[str]):
|
||||
await redis.execute("SETEX", f"{cache_key}:invalidated", CACHE_TTL, "1")
|
||||
|
||||
# Если это кэш темы, инвалидируем также связанные ключи
|
||||
if key.startswith("topic_"):
|
||||
topic_id = key.split("_")[1]
|
||||
if cache_key.startswith("topic_"):
|
||||
topic_id = cache_key.split("_")[1]
|
||||
related_keys = [
|
||||
f"topic:id:{topic_id}",
|
||||
f"topic:authors:{topic_id}",
|
||||
@@ -404,38 +439,35 @@ async def invalidate_shouts_cache(cache_keys: List[str]):
|
||||
logger.debug(f"Invalidated related key: {related_key}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error invalidating cache key {key}: {e}")
|
||||
logger.error(f"Error invalidating cache key {cache_key}: {e}")
|
||||
|
||||
|
||||
async def cache_topic_shouts(topic_id: int, shouts: List[dict]):
|
||||
async def cache_topic_shouts(topic_id: int, shouts: list[dict]) -> None:
|
||||
"""Кэширует список публикаций для темы"""
|
||||
key = f"topic_shouts_{topic_id}"
|
||||
payload = json.dumps(shouts, cls=CustomJSONEncoder)
|
||||
payload = fast_json_dumps(shouts)
|
||||
await redis.execute("SETEX", key, CACHE_TTL, payload)
|
||||
|
||||
|
||||
async def get_cached_topic_shouts(topic_id: int) -> List[dict]:
|
||||
async def get_cached_topic_shouts(topic_id: int) -> list[dict]:
|
||||
"""Получает кэшированный список публикаций для темы"""
|
||||
key = f"topic_shouts_{topic_id}"
|
||||
cached = await redis.execute("GET", key)
|
||||
if cached:
|
||||
return orjson.loads(cached)
|
||||
return None
|
||||
return []
|
||||
|
||||
|
||||
async def cache_related_entities(shout: Shout):
|
||||
async def cache_related_entities(shout: Shout) -> None:
|
||||
"""
|
||||
Кэширует все связанные с публикацией сущности (авторов и темы)
|
||||
"""
|
||||
tasks = []
|
||||
for author in shout.authors:
|
||||
tasks.append(cache_by_id(Author, author.id, cache_author))
|
||||
for topic in shout.topics:
|
||||
tasks.append(cache_by_id(Topic, topic.id, cache_topic))
|
||||
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):
|
||||
async def invalidate_shout_related_cache(shout: Shout, author_id: int) -> None:
|
||||
"""
|
||||
Инвалидирует весь кэш, связанный с публикацией и её связями
|
||||
|
||||
@@ -503,7 +535,7 @@ async def cache_by_id(entity, entity_id: int, cache_method):
|
||||
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
|
||||
return None
|
||||
x = result[0]
|
||||
d = x.dict()
|
||||
await cache_method(d)
|
||||
@@ -521,7 +553,7 @@ async def cache_data(key: str, data: Any, ttl: Optional[int] = None) -> None:
|
||||
ttl: Время жизни кеша в секундах (None - бессрочно)
|
||||
"""
|
||||
try:
|
||||
payload = json.dumps(data, cls=CustomJSONEncoder)
|
||||
payload = fast_json_dumps(data)
|
||||
if ttl:
|
||||
await redis.execute("SETEX", key, ttl, payload)
|
||||
else:
|
||||
@@ -574,7 +606,7 @@ async def invalidate_cache_by_prefix(prefix: str) -> None:
|
||||
# Универсальная функция для получения и кеширования данных
|
||||
async def cached_query(
|
||||
cache_key: str,
|
||||
query_func: callable,
|
||||
query_func: Callable,
|
||||
ttl: Optional[int] = None,
|
||||
force_refresh: bool = False,
|
||||
use_key_format: bool = True,
|
||||
@@ -599,7 +631,7 @@ async def cached_query(
|
||||
actual_key = cache_key
|
||||
if use_key_format and "{}" in cache_key:
|
||||
# Look for a template match in CACHE_KEYS
|
||||
for key_name, key_format in CACHE_KEYS.items():
|
||||
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():
|
||||
@@ -626,3 +658,270 @@ async def cached_query(
|
||||
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
|
||||
|
||||
132
cache/precache.py
vendored
132
cache/precache.py
vendored
@@ -1,32 +1,31 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from sqlalchemy import and_, join, select
|
||||
|
||||
from auth.orm import Author, AuthorFollower
|
||||
from cache.cache import cache_author, cache_topic
|
||||
from orm.author import Author, AuthorFollower
|
||||
from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic
|
||||
from orm.topic import Topic, TopicFollower
|
||||
from resolvers.stat import get_with_stat
|
||||
from services.db import local_session
|
||||
from services.redis import redis
|
||||
from utils.encoders import CustomJSONEncoder
|
||||
from utils.encoders import fast_json_dumps
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
|
||||
# Предварительное кеширование подписчиков автора
|
||||
async def precache_authors_followers(author_id, session):
|
||||
authors_followers = set()
|
||||
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 = json.dumps(list(authors_followers), cls=CustomJSONEncoder)
|
||||
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):
|
||||
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)
|
||||
@@ -35,9 +34,9 @@ async def precache_authors_follows(author_id, session):
|
||||
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 = json.dumps(list(follows_topics), cls=CustomJSONEncoder)
|
||||
authors_payload = json.dumps(list(follows_authors), cls=CustomJSONEncoder)
|
||||
shouts_payload = json.dumps(list(follows_shouts), cls=CustomJSONEncoder)
|
||||
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),
|
||||
@@ -47,7 +46,7 @@ async def precache_authors_follows(author_id, session):
|
||||
|
||||
|
||||
# Предварительное кеширование авторов тем
|
||||
async def precache_topics_authors(topic_id: int, session):
|
||||
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))
|
||||
@@ -62,67 +61,130 @@ async def precache_topics_authors(topic_id: int, session):
|
||||
)
|
||||
topic_authors = {row[0] for row in session.execute(topic_authors_query) if row[0]}
|
||||
|
||||
authors_payload = json.dumps(list(topic_authors), cls=CustomJSONEncoder)
|
||||
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):
|
||||
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 = json.dumps(list(topic_followers), cls=CustomJSONEncoder)
|
||||
followers_payload = fast_json_dumps(list(topic_followers))
|
||||
await redis.execute("SET", f"topic:followers:{topic_id}", followers_payload)
|
||||
|
||||
|
||||
async def precache_data():
|
||||
async def precache_data() -> None:
|
||||
logger.info("precaching...")
|
||||
logger.debug("Entering precache_data")
|
||||
try:
|
||||
key = "authorizer_env"
|
||||
# cache reset
|
||||
value = await redis.execute("HGETALL", key)
|
||||
# Список паттернов ключей, которые нужно сохранить при 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")
|
||||
|
||||
# Преобразуем словарь в список аргументов для HSET
|
||||
if value:
|
||||
# Если значение - словарь, преобразуем его в плоский список для HSET
|
||||
if isinstance(value, dict):
|
||||
flattened = []
|
||||
for field, val in value.items():
|
||||
flattened.extend([field, val])
|
||||
await redis.execute("HSET", key, *flattened)
|
||||
else:
|
||||
# Предполагаем, что значение уже содержит список
|
||||
await redis.execute("HSET", key, *value)
|
||||
logger.info(f"redis hash '{key}' was restored")
|
||||
# Восстанавливаем все сохранённые ключи
|
||||
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).where(Author.user.is_not(None)))
|
||||
logger.info(f"{len(authors)} authors found in database")
|
||||
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:
|
||||
# user_id = profile.get("user", "").strip()
|
||||
if author_id: # and user_id:
|
||||
await cache_author(profile)
|
||||
await asyncio.gather(
|
||||
precache_authors_followers(author_id, session), precache_authors_follows(author_id, session)
|
||||
precache_authors_followers(author_id, session),
|
||||
precache_authors_follows(author_id, session),
|
||||
)
|
||||
# logger.debug(f"Finished precaching followers and follows for author id={author_id}")
|
||||
else:
|
||||
logger.error(f"fail caching {author}")
|
||||
logger.info(f"{len(authors)} authors and their followings precached")
|
||||
|
||||
49
cache/revalidator.py
vendored
49
cache/revalidator.py
vendored
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
|
||||
from cache.cache import (
|
||||
cache_author,
|
||||
@@ -15,29 +16,33 @@ CACHE_REVALIDATION_INTERVAL = 300 # 5 minutes
|
||||
|
||||
|
||||
class CacheRevalidationManager:
|
||||
def __init__(self, interval=CACHE_REVALIDATION_INTERVAL):
|
||||
def __init__(self, interval=CACHE_REVALIDATION_INTERVAL) -> None:
|
||||
"""Инициализация менеджера с заданным интервалом проверки (в секундах)."""
|
||||
self.interval = interval
|
||||
self.items_to_revalidate = {"authors": set(), "topics": set(), "shouts": set(), "reactions": set()}
|
||||
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):
|
||||
async def start(self) -> None:
|
||||
"""Запуск фонового воркера для ревалидации кэша."""
|
||||
# Проверяем, что у нас есть соединение с Redis
|
||||
if not self._redis._client:
|
||||
logger.warning("Redis connection not established. Waiting for connection...")
|
||||
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):
|
||||
async def revalidate_cache(self) -> None:
|
||||
"""Циклическая проверка и ревалидация кэша каждые self.interval секунд."""
|
||||
try:
|
||||
while self.running:
|
||||
@@ -48,12 +53,12 @@ class CacheRevalidationManager:
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred in the revalidation worker: {e}")
|
||||
|
||||
async def process_revalidation(self):
|
||||
async def process_revalidation(self) -> None:
|
||||
"""Обновление кэша для всех сущностей, требующих ревалидации."""
|
||||
# Проверяем соединение с Redis
|
||||
if not self._redis._client:
|
||||
return # Выходим из метода, если не удалось подключиться
|
||||
|
||||
|
||||
async with self.lock:
|
||||
# Ревалидация кэша авторов
|
||||
if self.items_to_revalidate["authors"]:
|
||||
@@ -62,9 +67,12 @@ class CacheRevalidationManager:
|
||||
if author_id == "all":
|
||||
await invalidate_cache_by_prefix("authors")
|
||||
break
|
||||
author = await get_cached_author(author_id, get_with_stat)
|
||||
if author:
|
||||
await cache_author(author)
|
||||
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()
|
||||
|
||||
# Ревалидация кэша тем
|
||||
@@ -74,9 +82,12 @@ class CacheRevalidationManager:
|
||||
if topic_id == "all":
|
||||
await invalidate_cache_by_prefix("topics")
|
||||
break
|
||||
topic = await get_cached_topic(topic_id)
|
||||
if topic:
|
||||
await cache_topic(topic)
|
||||
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()
|
||||
|
||||
# Ревалидация шаутов (публикаций)
|
||||
@@ -147,26 +158,24 @@ class CacheRevalidationManager:
|
||||
|
||||
self.items_to_revalidate["reactions"].clear()
|
||||
|
||||
def mark_for_revalidation(self, entity_id, entity_type):
|
||||
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):
|
||||
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):
|
||||
async def stop(self) -> None:
|
||||
"""Остановка фонового воркера."""
|
||||
self.running = False
|
||||
if hasattr(self, "task"):
|
||||
self.task.cancel()
|
||||
try:
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self.task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
revalidation_manager = CacheRevalidationManager()
|
||||
|
||||
18
cache/triggers.py
vendored
18
cache/triggers.py
vendored
@@ -1,7 +1,7 @@
|
||||
from sqlalchemy import event
|
||||
|
||||
from auth.orm import Author, AuthorFollower
|
||||
from cache.revalidator import revalidation_manager
|
||||
from orm.author import Author, AuthorFollower
|
||||
from orm.reaction import Reaction, ReactionKind
|
||||
from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower
|
||||
from orm.topic import Topic, TopicFollower
|
||||
@@ -9,7 +9,7 @@ from services.db import local_session
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
|
||||
def mark_for_revalidation(entity, *args):
|
||||
def mark_for_revalidation(entity, *args) -> None:
|
||||
"""Отметка сущности для ревалидации."""
|
||||
entity_type = (
|
||||
"authors"
|
||||
@@ -26,7 +26,7 @@ def mark_for_revalidation(entity, *args):
|
||||
revalidation_manager.mark_for_revalidation(entity.id, entity_type)
|
||||
|
||||
|
||||
def after_follower_handler(mapper, connection, target, is_delete=False):
|
||||
def after_follower_handler(mapper, connection, target, is_delete=False) -> None:
|
||||
"""Обработчик добавления, обновления или удаления подписки."""
|
||||
entity_type = None
|
||||
if isinstance(target, AuthorFollower):
|
||||
@@ -44,7 +44,7 @@ def after_follower_handler(mapper, connection, target, is_delete=False):
|
||||
revalidation_manager.mark_for_revalidation(target.follower, "authors")
|
||||
|
||||
|
||||
def after_shout_handler(mapper, connection, target):
|
||||
def after_shout_handler(mapper, connection, target) -> None:
|
||||
"""Обработчик изменения статуса публикации"""
|
||||
if not isinstance(target, Shout):
|
||||
return
|
||||
@@ -63,7 +63,7 @@ def after_shout_handler(mapper, connection, target):
|
||||
revalidation_manager.mark_for_revalidation(target.id, "shouts")
|
||||
|
||||
|
||||
def after_reaction_handler(mapper, connection, target):
|
||||
def after_reaction_handler(mapper, connection, target) -> None:
|
||||
"""Обработчик для комментариев"""
|
||||
if not isinstance(target, Reaction):
|
||||
return
|
||||
@@ -104,7 +104,7 @@ def after_reaction_handler(mapper, connection, target):
|
||||
revalidation_manager.mark_for_revalidation(topic.id, "topics")
|
||||
|
||||
|
||||
def events_register():
|
||||
def events_register() -> None:
|
||||
"""Регистрация обработчиков событий для всех сущностей."""
|
||||
event.listen(ShoutAuthor, "after_insert", mark_for_revalidation)
|
||||
event.listen(ShoutAuthor, "after_update", mark_for_revalidation)
|
||||
@@ -115,7 +115,7 @@ def events_register():
|
||||
event.listen(
|
||||
AuthorFollower,
|
||||
"after_delete",
|
||||
lambda *args: after_follower_handler(*args, is_delete=True),
|
||||
lambda mapper, connection, target: after_follower_handler(mapper, connection, target, is_delete=True),
|
||||
)
|
||||
|
||||
event.listen(TopicFollower, "after_insert", after_follower_handler)
|
||||
@@ -123,7 +123,7 @@ def events_register():
|
||||
event.listen(
|
||||
TopicFollower,
|
||||
"after_delete",
|
||||
lambda *args: after_follower_handler(*args, is_delete=True),
|
||||
lambda mapper, connection, target: after_follower_handler(mapper, connection, target, is_delete=True),
|
||||
)
|
||||
|
||||
event.listen(ShoutReactionsFollower, "after_insert", after_follower_handler)
|
||||
@@ -131,7 +131,7 @@ def events_register():
|
||||
event.listen(
|
||||
ShoutReactionsFollower,
|
||||
"after_delete",
|
||||
lambda *args: after_follower_handler(*args, is_delete=True),
|
||||
lambda mapper, connection, target: after_follower_handler(mapper, connection, target, is_delete=True),
|
||||
)
|
||||
|
||||
event.listen(Reaction, "after_update", mark_for_revalidation)
|
||||
|
||||
114
default_role_permissions.json
Normal file
114
default_role_permissions.json
Normal file
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"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
Normal file
143
dev.py
Normal file
@@ -0,0 +1,143 @@
|
||||
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
Normal file
116
docs/README.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Документация 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()
|
||||
```
|
||||
560
docs/admin-panel.md
Normal file
560
docs/admin-panel.md
Normal file
@@ -0,0 +1,560 @@
|
||||
# Администраторская панель 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
Normal file
40
docs/api.md
Normal file
@@ -0,0 +1,40 @@
|
||||
|
||||
|
||||
## 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
|
||||
253
docs/auth-architecture.md
Normal file
253
docs/auth-architecture.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# Архитектура системы авторизации
|
||||
|
||||
## Схема потоков данных
|
||||
|
||||
```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
|
||||
322
docs/auth-migration.md
Normal file
322
docs/auth-migration.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# Миграция системы авторизации
|
||||
|
||||
## Обзор изменений
|
||||
|
||||
Система авторизации была полностью переработана для улучшения производительности, безопасности и поддерживаемости:
|
||||
|
||||
### Основные изменения
|
||||
- ✅ Упрощена архитектура токенов (убрана прокси-логика)
|
||||
- ✅ Исправлены проблемы с типами (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`
|
||||
349
docs/auth-system.md
Normal file
349
docs/auth-system.md
Normal file
@@ -0,0 +1,349 @@
|
||||
# Система авторизации 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
Normal file
797
docs/auth.md
Normal file
@@ -0,0 +1,797 @@
|
||||
# Модуль аутентификации и авторизации
|
||||
|
||||
## Общее описание
|
||||
|
||||
Модуль реализует полноценную систему аутентификации с использованием локальной БД и 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'
|
||||
)
|
||||
```
|
||||
151
docs/caching.md
151
docs/caching.md
@@ -150,15 +150,15 @@ 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):
|
||||
# Добавляет сущность в очередь на ревалидацию
|
||||
# ...
|
||||
@@ -213,14 +213,14 @@ async def precache_data():
|
||||
async def get_topics_with_stats(limit=10, offset=0, by="title"):
|
||||
# Формирование ключа кеша по конвенции
|
||||
cache_key = f"topics:stats:limit={limit}:offset={offset}:sort={by}"
|
||||
|
||||
|
||||
cached_data = await get_cached_data(cache_key)
|
||||
if cached_data:
|
||||
return cached_data
|
||||
|
||||
|
||||
# Выполнение запроса к базе данных
|
||||
result = ... # логика получения данных
|
||||
|
||||
|
||||
await cache_data(cache_key, result, ttl=300)
|
||||
return result
|
||||
```
|
||||
@@ -232,16 +232,16 @@ async def get_topics_with_stats(limit=10, offset=0, by="title"):
|
||||
async def fetch_data(limit, offset, by):
|
||||
# Логика получения данных
|
||||
return result
|
||||
|
||||
|
||||
# Формирование ключа кеша по конвенции
|
||||
cache_key = f"topics:stats:limit={limit}:offset={offset}:sort={by}"
|
||||
|
||||
|
||||
return await cached_query(
|
||||
cache_key,
|
||||
fetch_data,
|
||||
ttl=300,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
cache_key,
|
||||
fetch_data,
|
||||
ttl=300,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
by=by
|
||||
)
|
||||
```
|
||||
@@ -249,16 +249,129 @@ async def get_topics_with_stats(limit=10, offset=0, by="title"):
|
||||
### Точечная инвалидация кеша при изменении данных
|
||||
|
||||
```python
|
||||
async def update_topic(topic_id, new_data):
|
||||
async def update_author(author_id, data):
|
||||
# Обновление данных в базе
|
||||
# ...
|
||||
|
||||
# Точечная инвалидация кеша только для измененной темы
|
||||
await invalidate_topics_cache(topic_id)
|
||||
|
||||
return updated_topic
|
||||
|
||||
# Инвалидация только кеша этого автора
|
||||
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. При создании новых ключей кеша следует придерживаться существующих конвенций именования.
|
||||
|
||||
## Отладка и мониторинг
|
||||
|
||||
Система кеширования использует логгер для отслеживания операций:
|
||||
|
||||
@@ -150,7 +150,7 @@ const { data } = await client.query({
|
||||
1. Для эффективной работы со сложными ветками обсуждений рекомендуется:
|
||||
|
||||
- Сначала загружать только корневые комментарии с первыми N ответами
|
||||
- При наличии дополнительных ответов (когда `stat.comments_count > first_replies.length`)
|
||||
- При наличии дополнительных ответов (когда `stat.comments_count > first_replies.length`)
|
||||
добавить кнопку "Показать все ответы"
|
||||
- При нажатии на кнопку загружать дополнительные ответы с помощью запроса с указанным `parentId`
|
||||
|
||||
@@ -162,4 +162,4 @@ const { data } = await client.query({
|
||||
3. Для улучшения производительности:
|
||||
- Кешировать результаты запросов на клиенте
|
||||
- Использовать оптимистичные обновления при добавлении/редактировании комментариев
|
||||
- При необходимости загружать комментарии порциями (ленивая загрузка)
|
||||
- При необходимости загружать комментарии порциями (ленивая загрузка)
|
||||
|
||||
149
docs/features.md
149
docs/features.md
@@ -1,8 +1,53 @@
|
||||
## Админ-панель
|
||||
|
||||
- **Управление пользователями**: Просмотр, поиск, назначение ролей (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 для отслеживания просмотров публикаций
|
||||
- Подсчет уникальных пользователей и общего количества просмотров
|
||||
- Автоматическое обновление статистики при запросе данных публикации
|
||||
- Автоматическое обновление статистики при запросе данных публикации
|
||||
|
||||
## Мультидоменная авторизация
|
||||
|
||||
@@ -12,7 +57,16 @@
|
||||
|
||||
## Система кеширования
|
||||
|
||||
- Redis используется в качестве основного механизма кеширования
|
||||
- **Redis как основное хранилище**: Кэширование, сессии, токены, временные данные
|
||||
- **Полная документация схемы**: [redis-schema.md](redis-schema.md) - детальное описание всех структур данных
|
||||
- **11 категорий данных**: Аутентификация, кэш сущностей, поиск, просмотры, уведомления
|
||||
- **Система токенов**: Сессии, OAuth токены, токены подтверждения с TTL
|
||||
- **Переменные окружения**: Централизованное хранение конфигурации в Redis
|
||||
- **Кэш сущностей**: Авторы, темы, публикации с автоматической инвалидацией
|
||||
- **Поисковый кэш**: Нормализованные запросы с результатами
|
||||
- **Pub/Sub каналы**: Real-time уведомления и коммуникация
|
||||
- **Оптимизация**: Pipeline операции, стратегии кэширования
|
||||
- **Мониторинг**: Команды диагностики и решение проблем производительности
|
||||
- Поддержка как синхронных, так и асинхронных функций в декораторе cache_on_arguments
|
||||
- Автоматическая сериализация/десериализация данных в JSON с использованием CustomJSONEncoder
|
||||
- Резервная сериализация через pickle для сложных объектов
|
||||
@@ -20,15 +74,6 @@
|
||||
- Настраиваемое время жизни кеша (TTL)
|
||||
- Возможность ручной инвалидации кеша для конкретных функций и аргументов
|
||||
|
||||
## Webhooks
|
||||
|
||||
- Автоматическая регистрация вебхука для события user.login
|
||||
- Предотвращение создания дублирующихся вебхуков
|
||||
- Автоматическая очистка устаревших вебхуков
|
||||
- Поддержка авторизации вебхуков через WEBHOOK_SECRET
|
||||
- Обработка ошибок при операциях с вебхуками
|
||||
- Динамическое определение endpoint'а на основе окружения
|
||||
|
||||
## CORS Configuration
|
||||
|
||||
- Поддерживаемые методы: GET, POST, OPTIONS
|
||||
@@ -45,4 +90,84 @@
|
||||
- Использование поля `stat.comments_count` для отображения количества ответов на комментарий
|
||||
- Добавление специального поля `first_replies` для хранения первых ответов на комментарий
|
||||
- Поддержка различных методов сортировки (новые, старые, популярные)
|
||||
- Оптимизированные SQL запросы для минимизации нагрузки на базу данных
|
||||
- Оптимизированные 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 — наследование только при инициализации, ускорение, упрощение кода, исправлены тесты
|
||||
|
||||
145
docs/follower.md
145
docs/follower.md
@@ -37,10 +37,12 @@ Unfollow an entity.
|
||||
|
||||
**Returns:** Same as `follow`
|
||||
|
||||
**Important:** Always returns current following list even if the subscription was not found, ensuring UI consistency.
|
||||
|
||||
### Queries
|
||||
|
||||
#### get_shout_followers
|
||||
Get list of users who reacted to a shout.
|
||||
Get list of authors who reacted to a shout.
|
||||
|
||||
**Parameters:**
|
||||
- `slug: String` - Shout slug
|
||||
@@ -62,9 +64,126 @@ Author[] // List of authors who reacted
|
||||
### Cache Flow
|
||||
1. On follow/unfollow:
|
||||
- Update entity in cache
|
||||
- **Invalidate user's following list cache** (NEW)
|
||||
- Update follower's following list
|
||||
2. Cache is updated before notifications
|
||||
|
||||
### Cache Invalidation (NEW)
|
||||
Following cache keys are invalidated after operations:
|
||||
- `author:follows-topics:{user_id}` - After topic follow/unfollow
|
||||
- `author:follows-authors:{user_id}` - After author follow/unfollow
|
||||
|
||||
This ensures fresh data is fetched from database on next request.
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Enhanced Error Handling (UPDATED)
|
||||
- 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
|
||||
@@ -73,14 +192,6 @@ Author[] // List of authors who reacted
|
||||
- Author ID
|
||||
- Action type ("follow"/"unfollow")
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Unauthorized access check
|
||||
- Entity existence validation
|
||||
- Duplicate follow prevention
|
||||
- Full error logging
|
||||
- Transaction safety with `local_session()`
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Follower Tables
|
||||
@@ -91,4 +202,18 @@ Author[] // List of authors who reacted
|
||||
|
||||
Each table contains:
|
||||
- `follower` - ID of following user
|
||||
- `{entity_type}` - ID of followed entity
|
||||
- `{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
|
||||
|
||||
@@ -77,4 +77,4 @@
|
||||
- Проверка прав доступа
|
||||
- Фильтрация удаленного контента
|
||||
- Защита от SQL-инъекций
|
||||
- Валидация входных данных
|
||||
- Валидация входных данных
|
||||
|
||||
199
docs/oauth-deployment.md
Normal file
199
docs/oauth-deployment.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# 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]"
|
||||
```
|
||||
430
docs/oauth-implementation.md
Normal file
430
docs/oauth-implementation.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# 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 событий
|
||||
123
docs/oauth-setup.md
Normal file
123
docs/oauth-setup.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# 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
Normal file
329
docs/oauth.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# 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")
|
||||
```
|
||||
@@ -52,7 +52,7 @@ Rate another author (karma system).
|
||||
- Excludes deleted reactions
|
||||
- Excludes comment reactions
|
||||
|
||||
#### Comments Rating
|
||||
#### Comments Rating
|
||||
- Calculated from LIKE/DISLIKE reactions on author's comments
|
||||
- Each LIKE: +1
|
||||
- Each DISLIKE: -1
|
||||
@@ -79,4 +79,4 @@ Rate another author (karma system).
|
||||
- All ratings exclude deleted content
|
||||
- Reactions are unique per user/content
|
||||
- Rating calculations are optimized with SQLAlchemy
|
||||
- System supports both direct author rating and content-based rating
|
||||
- System supports both direct author rating and content-based rating
|
||||
|
||||
477
docs/rbac-system.md
Normal file
477
docs/rbac-system.md
Normal file
@@ -0,0 +1,477 @@
|
||||
# Система ролей и разрешений (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')
|
||||
```
|
||||
378
docs/react-to-solidjs.md
Normal file
378
docs/react-to-solidjs.md
Normal file
@@ -0,0 +1,378 @@
|
||||
# Миграция с 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, сохраняя существующую функциональность и улучшая производительность.
|
||||
434
docs/redis-schema.md
Normal file
434
docs/redis-schema.md
Normal file
@@ -0,0 +1,434 @@
|
||||
# Схема данных 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
Normal file
212
docs/security.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# 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
Normal file
11
env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
20
index.html
Normal file
20
index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!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>
|
||||
272
main.py
272
main.py
@@ -1,94 +1,278 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from importlib import import_module
|
||||
from os.path import exists
|
||||
from pathlib import Path
|
||||
|
||||
from ariadne import load_schema_from_path, make_executable_schema
|
||||
from ariadne.asgi import GraphQL
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.routing import Route
|
||||
from starlette.responses import FileResponse, JSONResponse, Response
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from auth.handler import EnhancedGraphQLHTTPHandler
|
||||
from auth.middleware import AuthMiddleware, auth_middleware
|
||||
from auth.oauth import oauth_callback, oauth_login
|
||||
from cache.precache import precache_data
|
||||
from cache.revalidator import revalidation_manager
|
||||
from services.exception import ExceptionHandlerMiddleware
|
||||
from services.redis import redis
|
||||
from services.schema import create_all_tables, resolvers
|
||||
from services.search import search_service
|
||||
from services.search import check_search_service, initialize_search_index_background, search_service
|
||||
from services.viewed import ViewedStorage
|
||||
from services.webhook import WebhookEndpoint, create_webhook_endpoint
|
||||
from settings import DEV_SERVER_PID_FILE_NAME, MODE
|
||||
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"
|
||||
DIST_DIR = Path(__file__).parent / "dist" # Директория для собранных файлов
|
||||
INDEX_HTML = Path(__file__).parent / "index.html"
|
||||
|
||||
import_module("resolvers")
|
||||
|
||||
schema = make_executable_schema(load_schema_from_path("schema/"), resolvers)
|
||||
|
||||
# Создаем middleware с правильным порядком
|
||||
middleware = [
|
||||
# Начинаем с обработки ошибок
|
||||
Middleware(ExceptionHandlerMiddleware),
|
||||
# CORS должен быть перед другими middleware для корректной обработки preflight-запросов
|
||||
Middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"https://testing.discours.io",
|
||||
"https://testing3.discours.io",
|
||||
"https://v3.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),
|
||||
]
|
||||
|
||||
async def start():
|
||||
if MODE == "development":
|
||||
if not exists(DEV_SERVER_PID_FILE_NAME):
|
||||
# pid file management
|
||||
with open(DEV_SERVER_PID_FILE_NAME, "w", encoding="utf-8") as f:
|
||||
f.write(str(os.getpid()))
|
||||
print(f"[main] process started in {MODE} mode")
|
||||
# Создаем экземпляр GraphQL с улучшенным обработчиком
|
||||
graphql_app = GraphQL(schema, debug=DEVMODE, http_handler=EnhancedGraphQLHTTPHandler())
|
||||
|
||||
|
||||
async def lifespan(_app):
|
||||
# Оборачиваем GraphQL-обработчик для лучшей обработки ошибок
|
||||
async def graphql_handler(request: Request) -> Response:
|
||||
"""
|
||||
Обработчик GraphQL запросов с поддержкой middleware и обработкой ошибок.
|
||||
|
||||
Выполняет:
|
||||
1. Проверку метода запроса (GET, POST, OPTIONS)
|
||||
2. Обработку GraphQL запроса через ariadne
|
||||
3. Применение middleware для корректной обработки cookie и авторизации
|
||||
4. Обработку исключений и формирование ответа
|
||||
|
||||
Args:
|
||||
request: Starlette Request объект
|
||||
|
||||
Returns:
|
||||
Response: объект ответа (обычно JSONResponse)
|
||||
"""
|
||||
if request.method not in ["GET", "POST", "OPTIONS"]:
|
||||
return JSONResponse({"error": "Method Not Allowed by main.py"}, status_code=405)
|
||||
|
||||
# Проверяем, что все необходимые middleware корректно отработали
|
||||
if not hasattr(request, "scope") or "auth" not in request.scope:
|
||||
logger.warning("[graphql] AuthMiddleware не обработал запрос перед GraphQL обработчиком")
|
||||
|
||||
try:
|
||||
# Обрабатываем запрос через GraphQL приложение
|
||||
result = await graphql_app.handle_request(request)
|
||||
|
||||
# Применяем middleware для установки cookie
|
||||
# Используем метод process_result из auth_middleware для корректной обработки
|
||||
# cookie на основе результатов операций login/logout
|
||||
return await auth_middleware.process_result(request, result)
|
||||
except asyncio.CancelledError:
|
||||
return JSONResponse({"error": "Request cancelled"}, status_code=499)
|
||||
except Exception as e:
|
||||
logger.error(f"GraphQL error: {e!s}")
|
||||
# Логируем более подробную информацию для отладки
|
||||
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:
|
||||
"""
|
||||
Обработчик для SPA (Single Page Application) fallback.
|
||||
|
||||
Возвращает index.html для всех маршрутов, которые не найдены,
|
||||
чтобы клиентский роутер (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:
|
||||
"""Остановка сервера и освобождение ресурсов"""
|
||||
logger.info("Остановка сервера")
|
||||
|
||||
# Закрываем соединение с Redis
|
||||
await redis.disconnect()
|
||||
|
||||
# Останавливаем поисковый сервис
|
||||
await search_service.close()
|
||||
|
||||
# Удаляем PID-файл, если он существует
|
||||
from settings import DEV_SERVER_PID_FILE_NAME
|
||||
|
||||
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(),
|
||||
create_webhook_endpoint(),
|
||||
search_service.info(),
|
||||
start(),
|
||||
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)
|
||||
|
||||
|
||||
# Создаем экземпляр GraphQL
|
||||
graphql_app = GraphQL(schema, debug=True)
|
||||
|
||||
|
||||
# Оборачиваем GraphQL-обработчик для лучшей обработки ошибок
|
||||
async def graphql_handler(request: Request):
|
||||
if request.method not in ["GET", "POST"]:
|
||||
return JSONResponse({"error": "Method Not Allowed"}, status_code=405)
|
||||
|
||||
try:
|
||||
result = await graphql_app.handle_request(request)
|
||||
if isinstance(result, Response):
|
||||
return result
|
||||
return JSONResponse(result)
|
||||
except asyncio.CancelledError:
|
||||
return JSONResponse({"error": "Request cancelled"}, status_code=499)
|
||||
except Exception as e:
|
||||
print(f"GraphQL error: {str(e)}")
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
print("[lifespan] Shutdown complete")
|
||||
|
||||
|
||||
# Обновляем маршрут в Starlette
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/", graphql_handler, methods=["GET", "POST"]),
|
||||
Route("/new-author", WebhookEndpoint),
|
||||
Route("/graphql", graphql_handler, methods=["GET", "POST", "OPTIONS"]),
|
||||
# OAuth маршруты
|
||||
Route("/oauth/{provider}", oauth_login, methods=["GET"]),
|
||||
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.add_middleware(ExceptionHandlerMiddleware)
|
||||
if "dev" in sys.argv:
|
||||
if DEVMODE:
|
||||
# Для DEV режима регистрируем дополнительный CORS middleware только для localhost
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["https://localhost:3000"],
|
||||
allow_origins=[
|
||||
"https://localhost:3000",
|
||||
"https://localhost:3001",
|
||||
"https://localhost:3002",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"http://localhost:3002",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
|
||||
87
mypy.ini
Normal file
87
mypy.ini
Normal file
@@ -0,0 +1,87 @@
|
||||
[mypy]
|
||||
# Основные настройки
|
||||
python_version = 3.12
|
||||
warn_return_any = False
|
||||
warn_unused_configs = True
|
||||
disallow_untyped_defs = False
|
||||
disallow_incomplete_defs = False
|
||||
no_implicit_optional = False
|
||||
explicit_package_bases = True
|
||||
namespace_packages = True
|
||||
check_untyped_defs = False
|
||||
|
||||
# Игнорируем missing imports для внешних библиотек
|
||||
ignore_missing_imports = True
|
||||
|
||||
# Временно исключаем все проблематичные файлы
|
||||
exclude = ^(tests/.*|alembic/.*|orm/.*|auth/.*|resolvers/.*|services/db\.py|services/schema\.py)$
|
||||
|
||||
# Настройки для конкретных модулей
|
||||
[mypy-graphql.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-ariadne.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-starlette.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-orjson.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-pytest.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-pydantic.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-granian.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-jwt.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-httpx.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-trafilatura.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-sentry_sdk.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-colorlog.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-google.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-txtai.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-h11.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-hiredis.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-htmldate.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-httpcore.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-courlan.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-certifi.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-charset_normalizer.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-anyio.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-sniffio.*]
|
||||
ignore_missing_imports = True
|
||||
@@ -1,5 +1,5 @@
|
||||
log_format custom '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'origin=$http_origin allow_origin=$allow_origin status=$status '
|
||||
'origin=$http_origin status=$status '
|
||||
'"$http_referer" "$http_user_agent"';
|
||||
|
||||
{{ $proxy_settings := "proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $http_connection; proxy_set_header Host $http_host; proxy_set_header X-Request-Start $msec;" }}
|
||||
@@ -49,34 +49,6 @@ server {
|
||||
{{ $proxy_settings }}
|
||||
{{ $gzip_settings }}
|
||||
|
||||
# Handle CORS for OPTIONS method
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' $allow_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'POST, GET, OPTIONS';
|
||||
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Max-Age' 1728000;
|
||||
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
# Handle CORS for POST method
|
||||
if ($request_method = 'POST') {
|
||||
add_header 'Access-Control-Allow-Origin' $allow_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'POST, GET, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
}
|
||||
|
||||
# Handle CORS for GET method
|
||||
if ($request_method = 'GET') {
|
||||
add_header 'Access-Control-Allow-Origin' $allow_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'POST, GET, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
}
|
||||
|
||||
proxy_cache my_cache;
|
||||
proxy_cache_revalidate on;
|
||||
proxy_cache_min_uses 2;
|
||||
@@ -95,14 +67,7 @@ server {
|
||||
}
|
||||
|
||||
location ~* \.(mp3|wav|ogg|flac|aac|aif|webm)$ {
|
||||
proxy_pass http://{{ $.APP }}-{{ $upstream_port }};
|
||||
if ($request_method = 'GET') {
|
||||
add_header 'Access-Control-Allow-Origin' $allow_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
}
|
||||
proxy_pass http://{{ $.APP }}-{{ $upstream_port }};
|
||||
}
|
||||
|
||||
|
||||
|
||||
136
orm/author.py
136
orm/author.py
@@ -1,136 +0,0 @@
|
||||
import time
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
|
||||
|
||||
from services.db import Base
|
||||
|
||||
# from sqlalchemy_utils import TSVectorType
|
||||
|
||||
|
||||
class AuthorRating(Base):
|
||||
"""
|
||||
Рейтинг автора от другого автора.
|
||||
|
||||
Attributes:
|
||||
rater (int): ID оценивающего автора
|
||||
author (int): ID оцениваемого автора
|
||||
plus (bool): Положительная/отрицательная оценка
|
||||
"""
|
||||
|
||||
__tablename__ = "author_rating"
|
||||
|
||||
id = None # type: ignore
|
||||
rater = Column(ForeignKey("author.id"), primary_key=True)
|
||||
author = Column(ForeignKey("author.id"), primary_key=True)
|
||||
plus = Column(Boolean)
|
||||
|
||||
# Определяем индексы
|
||||
__table_args__ = (
|
||||
# Индекс для быстрого поиска всех оценок конкретного автора
|
||||
Index("idx_author_rating_author", "author"),
|
||||
# Индекс для быстрого поиска всех оценок, оставленных конкретным автором
|
||||
Index("idx_author_rating_rater", "rater"),
|
||||
)
|
||||
|
||||
|
||||
class AuthorFollower(Base):
|
||||
"""
|
||||
Подписка одного автора на другого.
|
||||
|
||||
Attributes:
|
||||
follower (int): ID подписчика
|
||||
author (int): ID автора, на которого подписываются
|
||||
created_at (int): Время создания подписки
|
||||
auto (bool): Признак автоматической подписки
|
||||
"""
|
||||
|
||||
__tablename__ = "author_follower"
|
||||
|
||||
id = None # type: ignore
|
||||
follower = Column(ForeignKey("author.id"), primary_key=True)
|
||||
author = Column(ForeignKey("author.id"), primary_key=True)
|
||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
auto = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Определяем индексы
|
||||
__table_args__ = (
|
||||
# Индекс для быстрого поиска всех подписчиков автора
|
||||
Index("idx_author_follower_author", "author"),
|
||||
# Индекс для быстрого поиска всех авторов, на которых подписан конкретный автор
|
||||
Index("idx_author_follower_follower", "follower"),
|
||||
)
|
||||
|
||||
|
||||
class AuthorBookmark(Base):
|
||||
"""
|
||||
Закладка автора на публикацию.
|
||||
|
||||
Attributes:
|
||||
author (int): ID автора
|
||||
shout (int): ID публикации
|
||||
"""
|
||||
|
||||
__tablename__ = "author_bookmark"
|
||||
|
||||
id = None # type: ignore
|
||||
author = Column(ForeignKey("author.id"), primary_key=True)
|
||||
shout = Column(ForeignKey("shout.id"), primary_key=True)
|
||||
|
||||
# Определяем индексы
|
||||
__table_args__ = (
|
||||
# Индекс для быстрого поиска всех закладок автора
|
||||
Index("idx_author_bookmark_author", "author"),
|
||||
# Индекс для быстрого поиска всех авторов, добавивших публикацию в закладки
|
||||
Index("idx_author_bookmark_shout", "shout"),
|
||||
)
|
||||
|
||||
|
||||
class Author(Base):
|
||||
"""
|
||||
Модель автора в системе.
|
||||
|
||||
Attributes:
|
||||
name (str): Отображаемое имя
|
||||
slug (str): Уникальный строковый идентификатор
|
||||
bio (str): Краткая биография/статус
|
||||
about (str): Полное описание
|
||||
pic (str): URL изображения профиля
|
||||
links (dict): Ссылки на социальные сети и сайты
|
||||
created_at (int): Время создания профиля
|
||||
last_seen (int): Время последнего посещения
|
||||
updated_at (int): Время последнего обновления
|
||||
deleted_at (int): Время удаления (если профиль удален)
|
||||
"""
|
||||
|
||||
__tablename__ = "author"
|
||||
|
||||
name = Column(String, nullable=True, comment="Display name")
|
||||
slug = Column(String, unique=True, comment="Author's slug")
|
||||
bio = Column(String, nullable=True, comment="Bio") # status description
|
||||
about = Column(String, nullable=True, comment="About") # long and formatted
|
||||
pic = Column(String, nullable=True, comment="Picture")
|
||||
links = Column(JSON, nullable=True, comment="Links")
|
||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
last_seen = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
updated_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
deleted_at = Column(Integer, nullable=True, comment="Deleted at")
|
||||
|
||||
# search_vector = Column(
|
||||
# TSVectorType("name", "slug", "bio", "about", regconfig="pg_catalog.russian")
|
||||
# )
|
||||
|
||||
# Определяем индексы
|
||||
__table_args__ = (
|
||||
# Индекс для быстрого поиска по имени
|
||||
Index("idx_author_name", "name"),
|
||||
# Индекс для быстрого поиска по slug
|
||||
Index("idx_author_slug", "slug"),
|
||||
# Индекс для фильтрации неудаленных авторов
|
||||
Index(
|
||||
"idx_author_deleted_at", "deleted_at", postgresql_where=deleted_at.is_(None)
|
||||
),
|
||||
# Индекс для сортировки по времени создания (для новых авторов)
|
||||
Index("idx_author_created_at", "created_at"),
|
||||
# Индекс для сортировки по времени последнего посещения
|
||||
Index("idx_author_last_seen", "last_seen"),
|
||||
)
|
||||
@@ -1,14 +1,14 @@
|
||||
import time
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from services.db import Base
|
||||
from services.db import BaseModel as Base
|
||||
|
||||
|
||||
class ShoutCollection(Base):
|
||||
__tablename__ = "shout_collection"
|
||||
|
||||
id = None # type: ignore
|
||||
shout = Column(ForeignKey("shout.id"), primary_key=True)
|
||||
collection = Column(ForeignKey("collection.id"), primary_key=True)
|
||||
|
||||
@@ -23,3 +23,5 @@ class Collection(Base):
|
||||
created_at = Column(Integer, default=lambda: int(time.time()))
|
||||
created_by = Column(ForeignKey("author.id"), comment="Created By")
|
||||
published_at = Column(Integer, default=lambda: int(time.time()))
|
||||
|
||||
created_by_author = relationship("Author", foreign_keys=[created_by])
|
||||
|
||||
792
orm/community.py
792
orm/community.py
@@ -1,41 +1,62 @@
|
||||
import enum
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String, Text, distinct, func
|
||||
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String, Text, UniqueConstraint, distinct, func
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from orm.author import Author
|
||||
from services.db import Base
|
||||
from auth.orm import Author
|
||||
from services.db import BaseModel
|
||||
from services.rbac import get_permissions_for_role
|
||||
|
||||
# Словарь названий ролей
|
||||
role_names = {
|
||||
"reader": "Читатель",
|
||||
"author": "Автор",
|
||||
"artist": "Художник",
|
||||
"expert": "Эксперт",
|
||||
"editor": "Редактор",
|
||||
"admin": "Администратор",
|
||||
}
|
||||
|
||||
# Словарь описаний ролей
|
||||
role_descriptions = {
|
||||
"reader": "Может читать и комментировать",
|
||||
"author": "Может создавать публикации",
|
||||
"artist": "Может быть credited artist",
|
||||
"expert": "Может добавлять доказательства",
|
||||
"editor": "Может модерировать контент",
|
||||
"admin": "Полные права",
|
||||
}
|
||||
|
||||
|
||||
class CommunityRole(enum.Enum):
|
||||
READER = "reader" # can read and comment
|
||||
AUTHOR = "author" # + can vote and invite collaborators
|
||||
ARTIST = "artist" # + can be credited as featured artist
|
||||
EXPERT = "expert" # + can add proof or disproof to shouts, can manage topics
|
||||
EDITOR = "editor" # + can manage topics, comments and community settings
|
||||
class CommunityFollower(BaseModel):
|
||||
"""
|
||||
Простая подписка пользователя на сообщество.
|
||||
|
||||
@classmethod
|
||||
def as_string_array(cls, roles):
|
||||
return [role.value for role in roles]
|
||||
Использует обычный id как первичный ключ для простоты и производительности.
|
||||
Уникальность обеспечивается индексом по (community, follower).
|
||||
"""
|
||||
|
||||
__tablename__ = "community_follower"
|
||||
|
||||
# Простые поля - стандартный подход
|
||||
community = Column(ForeignKey("community.id"), nullable=False, index=True)
|
||||
follower = Column(ForeignKey("author.id"), nullable=False, index=True)
|
||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
|
||||
# Уникальность по паре сообщество-подписчик
|
||||
__table_args__ = (
|
||||
UniqueConstraint("community", "follower", name="uq_community_follower"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
|
||||
def __init__(self, community: int, follower: int) -> None:
|
||||
self.community = community # type: ignore[assignment]
|
||||
self.follower = follower # type: ignore[assignment]
|
||||
|
||||
|
||||
class CommunityFollower(Base):
|
||||
__tablename__ = "community_author"
|
||||
|
||||
author = Column(ForeignKey("author.id"), primary_key=True)
|
||||
community = Column(ForeignKey("community.id"), primary_key=True)
|
||||
joined_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
roles = Column(Text, nullable=True, comment="Roles (comma-separated)")
|
||||
|
||||
def set_roles(self, roles):
|
||||
self.roles = CommunityRole.as_string_array(roles)
|
||||
|
||||
def get_roles(self):
|
||||
return [CommunityRole(role) for role in self.roles]
|
||||
|
||||
|
||||
class Community(Base):
|
||||
class Community(BaseModel):
|
||||
__tablename__ = "community"
|
||||
|
||||
name = Column(String, nullable=False)
|
||||
@@ -44,22 +65,263 @@ class Community(Base):
|
||||
pic = Column(String, nullable=False, default="")
|
||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
created_by = Column(ForeignKey("author.id"), nullable=False)
|
||||
settings = Column(JSON, nullable=True)
|
||||
updated_at = Column(Integer, nullable=True)
|
||||
deleted_at = Column(Integer, nullable=True)
|
||||
private = Column(Boolean, default=False)
|
||||
|
||||
followers = relationship("Author", secondary="community_follower")
|
||||
created_by_author = relationship("Author", foreign_keys=[created_by])
|
||||
|
||||
@hybrid_property
|
||||
def stat(self):
|
||||
return CommunityStats(self)
|
||||
|
||||
@property
|
||||
def role_list(self):
|
||||
return self.roles.split(",") if self.roles else []
|
||||
def is_followed_by(self, author_id: int) -> bool:
|
||||
"""Проверяет, подписан ли пользователь на сообщество"""
|
||||
from services.db import local_session
|
||||
|
||||
@role_list.setter
|
||||
def role_list(self, value):
|
||||
self.roles = ",".join(value) if value else None
|
||||
with local_session() as session:
|
||||
follower = (
|
||||
session.query(CommunityFollower)
|
||||
.filter(CommunityFollower.community == self.id, CommunityFollower.follower == author_id)
|
||||
.first()
|
||||
)
|
||||
return follower is not None
|
||||
|
||||
def get_user_roles(self, user_id: int) -> list[str]:
|
||||
"""
|
||||
Получает роли пользователя в данном сообществе через CommunityAuthor
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Список ролей пользователя в сообществе
|
||||
"""
|
||||
from services.db import local_session
|
||||
|
||||
with local_session() as session:
|
||||
community_author = (
|
||||
session.query(CommunityAuthor)
|
||||
.filter(CommunityAuthor.community_id == self.id, CommunityAuthor.author_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
return community_author.role_list if community_author else []
|
||||
|
||||
def has_user_role(self, user_id: int, role_id: str) -> bool:
|
||||
"""
|
||||
Проверяет, есть ли у пользователя указанная роль в этом сообществе
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
role_id: ID роли
|
||||
|
||||
Returns:
|
||||
True если роль есть, False если нет
|
||||
"""
|
||||
user_roles = self.get_user_roles(user_id)
|
||||
return role_id in user_roles
|
||||
|
||||
def add_user_role(self, user_id: int, role: str) -> None:
|
||||
"""
|
||||
Добавляет роль пользователю в сообществе
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
role: Название роли
|
||||
"""
|
||||
from services.db import local_session
|
||||
|
||||
with local_session() as session:
|
||||
# Ищем существующую запись
|
||||
community_author = (
|
||||
session.query(CommunityAuthor)
|
||||
.filter(CommunityAuthor.community_id == self.id, CommunityAuthor.author_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if community_author:
|
||||
# Добавляем роль к существующей записи
|
||||
community_author.add_role(role)
|
||||
else:
|
||||
# Создаем новую запись
|
||||
community_author = CommunityAuthor(community_id=self.id, author_id=user_id, roles=role)
|
||||
session.add(community_author)
|
||||
|
||||
session.commit()
|
||||
|
||||
def remove_user_role(self, user_id: int, role: str) -> None:
|
||||
"""
|
||||
Удаляет роль у пользователя в сообществе
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
role: Название роли
|
||||
"""
|
||||
from services.db import local_session
|
||||
|
||||
with local_session() as session:
|
||||
community_author = (
|
||||
session.query(CommunityAuthor)
|
||||
.filter(CommunityAuthor.community_id == self.id, CommunityAuthor.author_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if community_author:
|
||||
community_author.remove_role(role)
|
||||
|
||||
# Если ролей не осталось, удаляем запись
|
||||
if not community_author.role_list:
|
||||
session.delete(community_author)
|
||||
|
||||
session.commit()
|
||||
|
||||
def set_user_roles(self, user_id: int, roles: list[str]) -> None:
|
||||
"""
|
||||
Устанавливает полный список ролей пользователя в сообществе
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
roles: Список ролей для установки
|
||||
"""
|
||||
from services.db import local_session
|
||||
|
||||
with local_session() as session:
|
||||
# Ищем существующую запись
|
||||
community_author = (
|
||||
session.query(CommunityAuthor)
|
||||
.filter(CommunityAuthor.community_id == self.id, CommunityAuthor.author_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if community_author:
|
||||
if roles:
|
||||
# Обновляем роли
|
||||
community_author.set_roles(roles)
|
||||
else:
|
||||
# Если ролей нет, удаляем запись
|
||||
session.delete(community_author)
|
||||
elif roles:
|
||||
# Создаем новую запись, если есть роли
|
||||
community_author = CommunityAuthor(community_id=self.id, author_id=user_id)
|
||||
community_author.set_roles(roles)
|
||||
session.add(community_author)
|
||||
|
||||
session.commit()
|
||||
|
||||
def get_community_members(self, with_roles: bool = False) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Получает список участников сообщества
|
||||
|
||||
Args:
|
||||
with_roles: Если True, включает информацию о ролях
|
||||
|
||||
Returns:
|
||||
Список участников с информацией о ролях
|
||||
"""
|
||||
from services.db import local_session
|
||||
|
||||
with local_session() as session:
|
||||
community_authors = session.query(CommunityAuthor).filter(CommunityAuthor.community_id == self.id).all()
|
||||
|
||||
members = []
|
||||
for ca in community_authors:
|
||||
member_info = {
|
||||
"author_id": ca.author_id,
|
||||
"joined_at": ca.joined_at,
|
||||
}
|
||||
|
||||
if with_roles:
|
||||
member_info["roles"] = ca.role_list # type: ignore[assignment]
|
||||
# Получаем разрешения синхронно
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
member_info["permissions"] = asyncio.run(ca.get_permissions()) # type: ignore[assignment]
|
||||
except Exception:
|
||||
# Если не удается получить разрешения асинхронно, используем пустой список
|
||||
member_info["permissions"] = [] # type: ignore[assignment]
|
||||
|
||||
members.append(member_info)
|
||||
|
||||
return members
|
||||
|
||||
def assign_default_roles_to_user(self, user_id: int) -> None:
|
||||
"""
|
||||
Назначает дефолтные роли новому пользователю в сообществе
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
"""
|
||||
default_roles = self.get_default_roles()
|
||||
self.set_user_roles(user_id, default_roles)
|
||||
|
||||
def get_default_roles(self) -> list[str]:
|
||||
"""
|
||||
Получает список дефолтных ролей для новых пользователей в сообществе
|
||||
|
||||
Returns:
|
||||
Список ID ролей, которые назначаются новым пользователям по умолчанию
|
||||
"""
|
||||
if not self.settings:
|
||||
return ["reader", "author"] # По умолчанию базовые роли
|
||||
|
||||
return self.settings.get("default_roles", ["reader", "author"])
|
||||
|
||||
def set_default_roles(self, roles: list[str]) -> None:
|
||||
"""
|
||||
Устанавливает дефолтные роли для новых пользователей в сообществе
|
||||
|
||||
Args:
|
||||
roles: Список ID ролей для назначения по умолчанию
|
||||
"""
|
||||
if not self.settings:
|
||||
self.settings = {} # type: ignore[assignment]
|
||||
|
||||
self.settings["default_roles"] = roles # type: ignore[index]
|
||||
|
||||
async def initialize_role_permissions(self) -> None:
|
||||
"""
|
||||
Инициализирует права ролей для сообщества из дефолтных настроек.
|
||||
Вызывается при создании нового сообщества.
|
||||
"""
|
||||
from services.rbac import initialize_community_permissions
|
||||
|
||||
await initialize_community_permissions(int(self.id))
|
||||
|
||||
def get_available_roles(self) -> list[str]:
|
||||
"""
|
||||
Получает список доступных ролей в сообществе
|
||||
|
||||
Returns:
|
||||
Список ID ролей, которые могут быть назначены в этом сообществе
|
||||
"""
|
||||
if not self.settings:
|
||||
return ["reader", "author", "artist", "expert", "editor", "admin"] # Все стандартные роли
|
||||
|
||||
return self.settings.get("available_roles", ["reader", "author", "artist", "expert", "editor", "admin"])
|
||||
|
||||
def set_available_roles(self, roles: list[str]) -> None:
|
||||
"""
|
||||
Устанавливает список доступных ролей в сообществе
|
||||
|
||||
Args:
|
||||
roles: Список ID ролей, доступных в сообществе
|
||||
"""
|
||||
if not self.settings:
|
||||
self.settings = {} # type: ignore[assignment]
|
||||
|
||||
self.settings["available_roles"] = roles # type: ignore[index]
|
||||
|
||||
def set_slug(self, slug: str) -> None:
|
||||
"""Устанавливает slug сообщества"""
|
||||
self.slug = slug # type: ignore[assignment]
|
||||
|
||||
|
||||
class CommunityStats:
|
||||
def __init__(self, community):
|
||||
def __init__(self, community) -> None:
|
||||
self.community = community
|
||||
|
||||
@property
|
||||
@@ -71,7 +333,7 @@ class CommunityStats:
|
||||
@property
|
||||
def followers(self):
|
||||
return (
|
||||
self.community.session.query(func.count(CommunityFollower.author))
|
||||
self.community.session.query(func.count(CommunityFollower.follower))
|
||||
.filter(CommunityFollower.community == self.community.id)
|
||||
.scalar()
|
||||
)
|
||||
@@ -84,23 +346,463 @@ class CommunityStats:
|
||||
return (
|
||||
self.community.session.query(func.count(distinct(Author.id)))
|
||||
.join(Shout)
|
||||
.filter(Shout.community == self.community.id, Shout.featured_at.is_not(None), Author.id.in_(Shout.authors))
|
||||
.filter(
|
||||
Shout.community == self.community.id,
|
||||
Shout.featured_at.is_not(None),
|
||||
Author.id.in_(Shout.authors),
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
|
||||
class CommunityAuthor(Base):
|
||||
class CommunityAuthor(BaseModel):
|
||||
"""
|
||||
Связь автора с сообществом и его ролями.
|
||||
|
||||
Attributes:
|
||||
id: Уникальный ID записи
|
||||
community_id: ID сообщества
|
||||
author_id: ID автора
|
||||
roles: CSV строка с ролями (например: "reader,author,editor")
|
||||
joined_at: Время присоединения к сообществу (unix timestamp)
|
||||
"""
|
||||
|
||||
__tablename__ = "community_author"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
community_id = Column(Integer, ForeignKey("community.id"))
|
||||
author_id = Column(Integer, ForeignKey("author.id"))
|
||||
community_id = Column(Integer, ForeignKey("community.id"), nullable=False)
|
||||
author_id = Column(Integer, ForeignKey("author.id"), nullable=False)
|
||||
roles = Column(Text, nullable=True, comment="Roles (comma-separated)")
|
||||
joined_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
|
||||
# Связи
|
||||
community = relationship("Community", foreign_keys=[community_id])
|
||||
author = relationship("Author", foreign_keys=[author_id])
|
||||
|
||||
# Уникальность по сообществу и автору
|
||||
__table_args__ = (
|
||||
Index("idx_community_author_community", "community_id"),
|
||||
Index("idx_community_author_author", "author_id"),
|
||||
UniqueConstraint("community_id", "author_id", name="uq_community_author"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
|
||||
@property
|
||||
def role_list(self):
|
||||
return self.roles.split(",") if self.roles else []
|
||||
def role_list(self) -> list[str]:
|
||||
"""Получает список ролей как список строк"""
|
||||
return [role.strip() for role in self.roles.split(",") if role.strip()] if self.roles else []
|
||||
|
||||
@role_list.setter
|
||||
def role_list(self, value):
|
||||
self.roles = ",".join(value) if value else None
|
||||
def role_list(self, value: list[str]) -> None:
|
||||
"""Устанавливает список ролей из списка строк"""
|
||||
self.roles = ",".join(value) if value else None # type: ignore[assignment]
|
||||
|
||||
def has_role(self, role: str) -> bool:
|
||||
"""
|
||||
Проверяет наличие роли у автора в сообществе
|
||||
|
||||
Args:
|
||||
role: Название роли для проверки
|
||||
|
||||
Returns:
|
||||
True если роль есть, False если нет
|
||||
"""
|
||||
return role in self.role_list
|
||||
|
||||
def add_role(self, role: str) -> None:
|
||||
"""
|
||||
Добавляет роль автору (если её ещё нет)
|
||||
|
||||
Args:
|
||||
role: Название роли для добавления
|
||||
"""
|
||||
roles = self.role_list
|
||||
if role not in roles:
|
||||
roles.append(role)
|
||||
self.role_list = roles
|
||||
|
||||
def remove_role(self, role: str) -> None:
|
||||
"""
|
||||
Удаляет роль у автора
|
||||
|
||||
Args:
|
||||
role: Название роли для удаления
|
||||
"""
|
||||
roles = self.role_list
|
||||
if role in roles:
|
||||
roles.remove(role)
|
||||
self.role_list = roles
|
||||
|
||||
def set_roles(self, roles: list[str]) -> None:
|
||||
"""
|
||||
Устанавливает полный список ролей (заменяет текущие)
|
||||
|
||||
Args:
|
||||
roles: Список ролей для установки
|
||||
"""
|
||||
self.role_list = roles
|
||||
|
||||
async def get_permissions(self) -> list[str]:
|
||||
"""
|
||||
Получает все разрешения автора на основе его ролей в конкретном сообществе
|
||||
|
||||
Returns:
|
||||
Список разрешений (permissions)
|
||||
"""
|
||||
|
||||
all_permissions = set()
|
||||
for role in self.role_list:
|
||||
role_perms = await get_permissions_for_role(role, int(self.community_id))
|
||||
all_permissions.update(role_perms)
|
||||
|
||||
return list(all_permissions)
|
||||
|
||||
def has_permission(self, permission: str) -> bool:
|
||||
"""
|
||||
Проверяет наличие разрешения у автора
|
||||
|
||||
Args:
|
||||
permission: Разрешение для проверки (например: "shout:create")
|
||||
|
||||
Returns:
|
||||
True если разрешение есть, False если нет
|
||||
"""
|
||||
return permission in self.role_list
|
||||
|
||||
def dict(self, access: bool = False) -> dict[str, Any]:
|
||||
"""
|
||||
Сериализует объект в словарь
|
||||
|
||||
Args:
|
||||
access: Если True, включает дополнительную информацию
|
||||
|
||||
Returns:
|
||||
Словарь с данными объекта
|
||||
"""
|
||||
result = {
|
||||
"id": self.id,
|
||||
"community_id": self.community_id,
|
||||
"author_id": self.author_id,
|
||||
"roles": self.role_list,
|
||||
"joined_at": self.joined_at,
|
||||
}
|
||||
|
||||
if access:
|
||||
# Note: permissions должны быть получены заранее через await
|
||||
# Здесь мы не можем использовать await в sync методе
|
||||
result["permissions"] = [] # Placeholder - нужно получить асинхронно
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def get_user_communities_with_roles(cls, author_id: int, session=None) -> list[Dict[str, Any]]:
|
||||
"""
|
||||
Получает все сообщества пользователя с его ролями
|
||||
|
||||
Args:
|
||||
author_id: ID автора
|
||||
session: Сессия БД (опционально)
|
||||
|
||||
Returns:
|
||||
Список словарей с информацией о сообществах и ролях
|
||||
"""
|
||||
from services.db import local_session
|
||||
|
||||
if session is None:
|
||||
with local_session() as ssession:
|
||||
return cls.get_user_communities_with_roles(author_id, ssession)
|
||||
|
||||
community_authors = session.query(cls).filter(cls.author_id == author_id).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"community_id": ca.community_id,
|
||||
"roles": ca.role_list,
|
||||
"permissions": [], # Нужно получить асинхронно
|
||||
"joined_at": ca.joined_at,
|
||||
}
|
||||
for ca in community_authors
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def find_by_user_and_community(cls, author_id: int, community_id: int, session=None) -> "CommunityAuthor | None":
|
||||
"""
|
||||
Находит запись CommunityAuthor по ID автора и сообщества
|
||||
|
||||
Args:
|
||||
author_id: ID автора
|
||||
community_id: ID сообщества
|
||||
session: Сессия БД (опционально)
|
||||
|
||||
Returns:
|
||||
CommunityAuthor или None
|
||||
"""
|
||||
from services.db import local_session
|
||||
|
||||
if session is None:
|
||||
with local_session() as ssession:
|
||||
return cls.find_by_user_and_community(author_id, community_id, ssession)
|
||||
|
||||
return session.query(cls).filter(cls.author_id == author_id, cls.community_id == community_id).first()
|
||||
|
||||
@classmethod
|
||||
def get_users_with_role(cls, community_id: int, role: str, session=None) -> list[int]:
|
||||
"""
|
||||
Получает список ID пользователей с указанной ролью в сообществе
|
||||
|
||||
Args:
|
||||
community_id: ID сообщества
|
||||
role: Название роли
|
||||
session: Сессия БД (опционально)
|
||||
|
||||
Returns:
|
||||
Список ID пользователей
|
||||
"""
|
||||
from services.db import local_session
|
||||
|
||||
if session is None:
|
||||
with local_session() as ssession:
|
||||
return cls.get_users_with_role(community_id, role, ssession)
|
||||
|
||||
community_authors = session.query(cls).filter(cls.community_id == community_id).all()
|
||||
|
||||
return [ca.author_id for ca in community_authors if ca.has_role(role)]
|
||||
|
||||
@classmethod
|
||||
def get_community_stats(cls, community_id: int, session=None) -> Dict[str, Any]:
|
||||
"""
|
||||
Получает статистику ролей в сообществе
|
||||
|
||||
Args:
|
||||
community_id: ID сообщества
|
||||
session: Сессия БД (опционально)
|
||||
|
||||
Returns:
|
||||
Словарь со статистикой ролей
|
||||
"""
|
||||
from services.db import local_session
|
||||
|
||||
if session is None:
|
||||
with local_session() as s:
|
||||
return cls.get_community_stats(community_id, s)
|
||||
|
||||
community_authors = session.query(cls).filter(cls.community_id == community_id).all()
|
||||
|
||||
role_counts: dict[str, int] = {}
|
||||
total_members = len(community_authors)
|
||||
|
||||
for ca in community_authors:
|
||||
for role in ca.role_list:
|
||||
role_counts[role] = role_counts.get(role, 0) + 1
|
||||
|
||||
return {
|
||||
"total_members": total_members,
|
||||
"role_counts": role_counts,
|
||||
"roles_distribution": {
|
||||
role: count / total_members if total_members > 0 else 0 for role, count in role_counts.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# === HELPER ФУНКЦИИ ДЛЯ РАБОТЫ С РОЛЯМИ ===
|
||||
|
||||
|
||||
def get_user_roles_in_community(author_id: int, community_id: int = 1) -> list[str]:
|
||||
"""
|
||||
Удобная функция для получения ролей пользователя в сообществе
|
||||
|
||||
Args:
|
||||
author_id: ID автора
|
||||
community_id: ID сообщества (по умолчанию 1)
|
||||
|
||||
Returns:
|
||||
Список ролей пользователя
|
||||
"""
|
||||
from services.db import local_session
|
||||
|
||||
with local_session() as session:
|
||||
ca = CommunityAuthor.find_by_user_and_community(author_id, community_id, session)
|
||||
return ca.role_list if ca else []
|
||||
|
||||
|
||||
async def check_user_permission_in_community(author_id: int, permission: str, community_id: int = 1) -> bool:
|
||||
"""
|
||||
Проверяет разрешение пользователя в сообществе с учетом иерархии ролей
|
||||
|
||||
Args:
|
||||
author_id: ID автора
|
||||
permission: Разрешение для проверки
|
||||
community_id: ID сообщества (по умолчанию 1)
|
||||
|
||||
Returns:
|
||||
True если разрешение есть, False если нет
|
||||
"""
|
||||
# Используем новую систему RBAC с иерархией
|
||||
from services.rbac import user_has_permission
|
||||
|
||||
return await user_has_permission(author_id, permission, community_id)
|
||||
|
||||
|
||||
def assign_role_to_user(author_id: int, role: str, community_id: int = 1) -> bool:
|
||||
"""
|
||||
Назначает роль пользователю в сообществе
|
||||
|
||||
Args:
|
||||
author_id: ID автора
|
||||
role: Название роли
|
||||
community_id: ID сообщества (по умолчанию 1)
|
||||
|
||||
Returns:
|
||||
True если роль была добавлена, False если уже была
|
||||
"""
|
||||
from services.db import local_session
|
||||
|
||||
with local_session() as session:
|
||||
ca = CommunityAuthor.find_by_user_and_community(author_id, community_id, session)
|
||||
|
||||
if ca:
|
||||
if ca.has_role(role):
|
||||
return False # Роль уже есть
|
||||
ca.add_role(role)
|
||||
else:
|
||||
# Создаем новую запись
|
||||
ca = CommunityAuthor(community_id=community_id, author_id=author_id, roles=role)
|
||||
session.add(ca)
|
||||
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
|
||||
def remove_role_from_user(author_id: int, role: str, community_id: int = 1) -> bool:
|
||||
"""
|
||||
Удаляет роль у пользователя в сообществе
|
||||
|
||||
Args:
|
||||
author_id: ID автора
|
||||
role: Название роли
|
||||
community_id: ID сообщества (по умолчанию 1)
|
||||
|
||||
Returns:
|
||||
True если роль была удалена, False если её не было
|
||||
"""
|
||||
from services.db import local_session
|
||||
|
||||
with local_session() as session:
|
||||
ca = CommunityAuthor.find_by_user_and_community(author_id, community_id, session)
|
||||
|
||||
if ca and ca.has_role(role):
|
||||
ca.remove_role(role)
|
||||
|
||||
# Если ролей не осталось, удаляем запись
|
||||
if not ca.role_list:
|
||||
session.delete(ca)
|
||||
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def migrate_old_roles_to_community_author():
|
||||
"""
|
||||
Функция миграции для переноса ролей из старой системы в CommunityAuthor
|
||||
|
||||
[непроверенное] Предполагает, что старые роли хранились в auth.orm.AuthorRole
|
||||
"""
|
||||
from auth.orm import AuthorRole
|
||||
from services.db import local_session
|
||||
|
||||
with local_session() as session:
|
||||
# Получаем все старые роли
|
||||
old_roles = session.query(AuthorRole).all()
|
||||
|
||||
print(f"[миграция] Найдено {len(old_roles)} старых записей ролей")
|
||||
|
||||
# Группируем по автору и сообществу
|
||||
user_community_roles = {}
|
||||
|
||||
for role in old_roles:
|
||||
key = (role.author, role.community)
|
||||
if key not in user_community_roles:
|
||||
user_community_roles[key] = []
|
||||
|
||||
# Извлекаем базовое имя роли (убираем суффикс сообщества если есть)
|
||||
role_name = role.role
|
||||
if isinstance(role_name, str) and "-" in role_name:
|
||||
base_role = role_name.split("-")[0]
|
||||
else:
|
||||
base_role = role_name
|
||||
|
||||
if base_role not in user_community_roles[key]:
|
||||
user_community_roles[key].append(base_role)
|
||||
|
||||
# Создаем новые записи CommunityAuthor
|
||||
migrated_count = 0
|
||||
for (author_id, community_id), roles in user_community_roles.items():
|
||||
# Проверяем, есть ли уже запись
|
||||
existing = CommunityAuthor.find_by_user_and_community(author_id, community_id, session)
|
||||
|
||||
if not existing:
|
||||
ca = CommunityAuthor(community_id=community_id, author_id=author_id)
|
||||
ca.set_roles(roles)
|
||||
session.add(ca)
|
||||
migrated_count += 1
|
||||
else:
|
||||
print(f"[миграция] Запись для автора {author_id} в сообществе {community_id} уже существует")
|
||||
|
||||
session.commit()
|
||||
print(f"[миграция] Создано {migrated_count} новых записей CommunityAuthor")
|
||||
print("[миграция] Миграция завершена. Проверьте результаты перед удалением старых таблиц.")
|
||||
|
||||
|
||||
# === CRUD ОПЕРАЦИИ ДЛЯ RBAC ===
|
||||
|
||||
|
||||
def get_all_community_members_with_roles(community_id: int = 1) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Получает всех участников сообщества с их ролями и разрешениями
|
||||
|
||||
Args:
|
||||
community_id: ID сообщества
|
||||
|
||||
Returns:
|
||||
Список участников с полной информацией
|
||||
"""
|
||||
from services.db import local_session
|
||||
|
||||
with local_session() as session:
|
||||
community = session.query(Community).filter(Community.id == community_id).first()
|
||||
|
||||
if not community:
|
||||
return []
|
||||
|
||||
return community.get_community_members(with_roles=True)
|
||||
|
||||
|
||||
def bulk_assign_roles(user_role_pairs: list[tuple[int, str]], community_id: int = 1) -> dict[str, int]:
|
||||
"""
|
||||
Массовое назначение ролей пользователям
|
||||
|
||||
Args:
|
||||
user_role_pairs: Список кортежей (author_id, role)
|
||||
community_id: ID сообщества
|
||||
|
||||
Returns:
|
||||
Статистика операции в формате {"success": int, "failed": int}
|
||||
"""
|
||||
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for author_id, role in user_role_pairs:
|
||||
try:
|
||||
if assign_role_to_user(author_id, role, community_id):
|
||||
success_count += 1
|
||||
else:
|
||||
# Если роль уже была, считаем это успехом
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
print(f"[ошибка] Не удалось назначить роль {role} пользователю {author_id}: {e}")
|
||||
failed_count += 1
|
||||
|
||||
return {"success": success_count, "failed": failed_count}
|
||||
|
||||
94
orm/draft.py
94
orm/draft.py
@@ -3,9 +3,9 @@ import time
|
||||
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from orm.author import Author
|
||||
from auth.orm import Author
|
||||
from orm.topic import Topic
|
||||
from services.db import Base
|
||||
from services.db import BaseModel as Base
|
||||
|
||||
|
||||
class DraftTopic(Base):
|
||||
@@ -26,80 +26,30 @@ class DraftAuthor(Base):
|
||||
caption = Column(String, nullable=True, default="")
|
||||
|
||||
|
||||
|
||||
class Draft(Base):
|
||||
__tablename__ = "draft"
|
||||
# required
|
||||
created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
# Колонки для связей с автором
|
||||
created_by: int = Column("created_by", ForeignKey("author.id"), nullable=False)
|
||||
community: int = Column("community", ForeignKey("community.id"), nullable=False, default=1)
|
||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
created_by = Column(ForeignKey("author.id"), nullable=False)
|
||||
community = Column(ForeignKey("community.id"), nullable=False, default=1)
|
||||
|
||||
# optional
|
||||
layout: str = Column(String, nullable=True, default="article")
|
||||
slug: str = Column(String, unique=True)
|
||||
title: str = Column(String, nullable=True)
|
||||
subtitle: str | None = Column(String, nullable=True)
|
||||
lead: str | None = Column(String, nullable=True)
|
||||
body: str = Column(String, nullable=False, comment="Body")
|
||||
media: dict | None = Column(JSON, nullable=True)
|
||||
cover: str | None = Column(String, nullable=True, comment="Cover image url")
|
||||
cover_caption: str | None = Column(String, nullable=True, comment="Cover image alt caption")
|
||||
lang: str = Column(String, nullable=False, default="ru", comment="Language")
|
||||
seo: str | None = Column(String, nullable=True) # JSON
|
||||
layout = Column(String, nullable=True, default="article")
|
||||
slug = Column(String, unique=True)
|
||||
title = Column(String, nullable=True)
|
||||
subtitle = Column(String, nullable=True)
|
||||
lead = Column(String, nullable=True)
|
||||
body = Column(String, nullable=False, comment="Body")
|
||||
media = Column(JSON, nullable=True)
|
||||
cover = Column(String, nullable=True, comment="Cover image url")
|
||||
cover_caption = Column(String, nullable=True, comment="Cover image alt caption")
|
||||
lang = Column(String, nullable=False, default="ru", comment="Language")
|
||||
seo = Column(String, nullable=True) # JSON
|
||||
|
||||
# auto
|
||||
updated_at: int | None = Column(Integer, nullable=True, index=True)
|
||||
deleted_at: int | None = Column(Integer, nullable=True, index=True)
|
||||
updated_by: int | None = Column("updated_by", ForeignKey("author.id"), nullable=True)
|
||||
deleted_by: int | None = Column("deleted_by", ForeignKey("author.id"), nullable=True)
|
||||
|
||||
# --- Relationships ---
|
||||
# Только many-to-many связи через вспомогательные таблицы
|
||||
authors = relationship(Author, secondary="draft_author", lazy="select")
|
||||
topics = relationship(Topic, secondary="draft_topic", lazy="select")
|
||||
|
||||
# Связь с Community (если нужна как объект, а не ID)
|
||||
# community = relationship("Community", foreign_keys=[community_id], lazy="joined")
|
||||
# Пока оставляем community_id как ID
|
||||
|
||||
# Связь с публикацией (один-к-одному или один-к-нулю)
|
||||
# Загружается через joinedload в резолвере
|
||||
publication = relationship(
|
||||
"Shout",
|
||||
primaryjoin="Draft.id == Shout.draft",
|
||||
foreign_keys="Shout.draft",
|
||||
uselist=False,
|
||||
lazy="noload", # Не грузим по умолчанию, только через options
|
||||
viewonly=True # Указываем, что это связь только для чтения
|
||||
)
|
||||
|
||||
def dict(self):
|
||||
"""
|
||||
Сериализует объект Draft в словарь.
|
||||
Гарантирует, что поля topics и authors всегда будут списками.
|
||||
"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"created_at": self.created_at,
|
||||
"created_by": self.created_by,
|
||||
"community": self.community,
|
||||
"layout": self.layout,
|
||||
"slug": self.slug,
|
||||
"title": self.title,
|
||||
"subtitle": self.subtitle,
|
||||
"lead": self.lead,
|
||||
"body": self.body,
|
||||
"media": self.media or [],
|
||||
"cover": self.cover,
|
||||
"cover_caption": self.cover_caption,
|
||||
"lang": self.lang,
|
||||
"seo": self.seo,
|
||||
"updated_at": self.updated_at,
|
||||
"deleted_at": self.deleted_at,
|
||||
"updated_by": self.updated_by,
|
||||
"deleted_by": self.deleted_by,
|
||||
# Гарантируем, что topics и authors всегда будут списками
|
||||
"topics": [topic.dict() for topic in (self.topics or [])],
|
||||
"authors": [author.dict() for author in (self.authors or [])]
|
||||
}
|
||||
updated_at = Column(Integer, nullable=True, index=True)
|
||||
deleted_at = Column(Integer, nullable=True, index=True)
|
||||
updated_by = Column(ForeignKey("author.id"), nullable=True)
|
||||
deleted_by = Column(ForeignKey("author.id"), nullable=True)
|
||||
authors = relationship(Author, secondary="draft_author")
|
||||
topics = relationship(Topic, secondary="draft_topic")
|
||||
|
||||
@@ -3,7 +3,7 @@ import enum
|
||||
from sqlalchemy import Column, ForeignKey, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from services.db import Base
|
||||
from services.db import BaseModel as Base
|
||||
|
||||
|
||||
class InviteStatus(enum.Enum):
|
||||
@@ -29,7 +29,7 @@ class Invite(Base):
|
||||
shout = relationship("Shout")
|
||||
|
||||
def set_status(self, status: InviteStatus):
|
||||
self.status = status.value
|
||||
self.status = status.value # type: ignore[assignment]
|
||||
|
||||
def get_status(self) -> InviteStatus:
|
||||
return InviteStatus.from_string(self.status)
|
||||
|
||||
@@ -4,8 +4,8 @@ import time
|
||||
from sqlalchemy import JSON, Column, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from orm.author import Author
|
||||
from services.db import Base
|
||||
from auth.orm import Author
|
||||
from services.db import BaseModel as Base
|
||||
|
||||
|
||||
class NotificationEntity(enum.Enum):
|
||||
@@ -51,13 +51,13 @@ class Notification(Base):
|
||||
seen = relationship(Author, secondary="notification_seen")
|
||||
|
||||
def set_entity(self, entity: NotificationEntity):
|
||||
self.entity = entity.value
|
||||
self.entity = entity.value # type: ignore[assignment]
|
||||
|
||||
def get_entity(self) -> NotificationEntity:
|
||||
return NotificationEntity.from_string(self.entity)
|
||||
|
||||
def set_action(self, action: NotificationAction):
|
||||
self.action = action.value
|
||||
self.action = action.value # type: ignore[assignment]
|
||||
|
||||
def get_action(self) -> NotificationAction:
|
||||
return NotificationAction.from_string(self.action)
|
||||
|
||||
@@ -3,30 +3,43 @@ from enum import Enum as Enumeration
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String
|
||||
|
||||
from services.db import Base
|
||||
from services.db import BaseModel as Base
|
||||
|
||||
|
||||
class ReactionKind(Enumeration):
|
||||
# TYPE = <reaction index> # rating diff
|
||||
|
||||
# editor mode
|
||||
# editor specials
|
||||
AGREE = "AGREE" # +1
|
||||
DISAGREE = "DISAGREE" # -1
|
||||
ASK = "ASK" # +0
|
||||
PROPOSE = "PROPOSE" # +0
|
||||
|
||||
# coauthor specials
|
||||
ASK = "ASK" # 0
|
||||
PROPOSE = "PROPOSE" # 0
|
||||
|
||||
# generic internal reactions
|
||||
ACCEPT = "ACCEPT" # +1
|
||||
REJECT = "REJECT" # -1
|
||||
|
||||
# expert mode
|
||||
# experts speacials
|
||||
PROOF = "PROOF" # +1
|
||||
DISPROOF = "DISPROOF" # -1
|
||||
|
||||
# public feed
|
||||
QUOTE = "QUOTE" # +0 TODO: use to bookmark in collection
|
||||
COMMENT = "COMMENT" # +0
|
||||
# comment and quote
|
||||
QUOTE = "QUOTE" # 0
|
||||
COMMENT = "COMMENT" # 0
|
||||
|
||||
# generic rating
|
||||
LIKE = "LIKE" # +1
|
||||
DISLIKE = "DISLIKE" # -1
|
||||
|
||||
# credit artist or researcher
|
||||
CREDIT = "CREDIT" # +1
|
||||
SILENT = "SILENT" # 0
|
||||
|
||||
|
||||
REACTION_KINDS = ReactionKind.__members__.keys()
|
||||
|
||||
|
||||
class Reaction(Base):
|
||||
__tablename__ = "reaction"
|
||||
|
||||
51
orm/shout.py
51
orm/shout.py
@@ -3,10 +3,10 @@ import time
|
||||
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from orm.author import Author
|
||||
from auth.orm import Author
|
||||
from orm.reaction import Reaction
|
||||
from orm.topic import Topic
|
||||
from services.db import Base
|
||||
from services.db import BaseModel as Base
|
||||
|
||||
|
||||
class ShoutTopic(Base):
|
||||
@@ -75,38 +75,37 @@ class Shout(Base):
|
||||
|
||||
__tablename__ = "shout"
|
||||
|
||||
created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
updated_at: int | None = Column(Integer, nullable=True, index=True)
|
||||
published_at: int | None = Column(Integer, nullable=True, index=True)
|
||||
featured_at: int | None = Column(Integer, nullable=True, index=True)
|
||||
deleted_at: int | None = Column(Integer, nullable=True, index=True)
|
||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
updated_at = Column(Integer, nullable=True, index=True)
|
||||
published_at = Column(Integer, nullable=True, index=True)
|
||||
featured_at = Column(Integer, nullable=True, index=True)
|
||||
deleted_at = Column(Integer, nullable=True, index=True)
|
||||
|
||||
created_by: int = Column(ForeignKey("author.id"), nullable=False)
|
||||
updated_by: int | None = Column(ForeignKey("author.id"), nullable=True)
|
||||
deleted_by: int | None = Column(ForeignKey("author.id"), nullable=True)
|
||||
community: int = Column(ForeignKey("community.id"), nullable=False)
|
||||
created_by = Column(ForeignKey("author.id"), nullable=False)
|
||||
updated_by = Column(ForeignKey("author.id"), nullable=True)
|
||||
deleted_by = Column(ForeignKey("author.id"), nullable=True)
|
||||
community = Column(ForeignKey("community.id"), nullable=False)
|
||||
|
||||
body: str = Column(String, nullable=False, comment="Body")
|
||||
slug: str = Column(String, unique=True)
|
||||
cover: str | None = Column(String, nullable=True, comment="Cover image url")
|
||||
cover_caption: str | None = Column(String, nullable=True, comment="Cover image alt caption")
|
||||
lead: str | None = Column(String, nullable=True)
|
||||
title: str = Column(String, nullable=False)
|
||||
subtitle: str | None = Column(String, nullable=True)
|
||||
layout: str = Column(String, nullable=False, default="article")
|
||||
media: dict | None = Column(JSON, nullable=True)
|
||||
body = Column(String, nullable=False, comment="Body")
|
||||
slug = Column(String, unique=True)
|
||||
cover = Column(String, nullable=True, comment="Cover image url")
|
||||
cover_caption = Column(String, nullable=True, comment="Cover image alt caption")
|
||||
lead = Column(String, nullable=True)
|
||||
title = Column(String, nullable=False)
|
||||
subtitle = Column(String, nullable=True)
|
||||
layout = Column(String, nullable=False, default="article")
|
||||
media = Column(JSON, nullable=True)
|
||||
|
||||
authors = relationship(Author, secondary="shout_author")
|
||||
topics = relationship(Topic, secondary="shout_topic")
|
||||
reactions = relationship(Reaction)
|
||||
|
||||
lang: str = Column(String, nullable=False, default="ru", comment="Language")
|
||||
version_of: int | None = Column(ForeignKey("shout.id"), nullable=True)
|
||||
oid: str | None = Column(String, nullable=True)
|
||||
lang = Column(String, nullable=False, default="ru", comment="Language")
|
||||
version_of = Column(ForeignKey("shout.id"), nullable=True)
|
||||
oid = Column(String, nullable=True)
|
||||
seo = Column(String, nullable=True) # JSON
|
||||
|
||||
seo: str | None = Column(String, nullable=True) # JSON
|
||||
|
||||
draft: int | None = Column(ForeignKey("draft.id"), nullable=True)
|
||||
draft = Column(ForeignKey("draft.id"), nullable=True)
|
||||
|
||||
# Определяем индексы
|
||||
__table_args__ = (
|
||||
|
||||
@@ -2,7 +2,7 @@ import time
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
|
||||
|
||||
from services.db import Base
|
||||
from services.db import BaseModel as Base
|
||||
|
||||
|
||||
class TopicFollower(Base):
|
||||
|
||||
6174
package-lock.json
generated
Normal file
6174
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
Normal file
39
package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "publy-panel",
|
||||
"version": "0.7.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"lint": "biome check . --fix",
|
||||
"format": "biome format . --write",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"codegen": "graphql-codegen --config codegen.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.0.6",
|
||||
"@graphql-codegen/cli": "^5.0.7",
|
||||
"@graphql-codegen/client-preset": "^4.8.3",
|
||||
"@graphql-codegen/typescript": "^4.0.6",
|
||||
"@graphql-codegen/typescript-operations": "^4.2.0",
|
||||
"@graphql-codegen/typescript-resolvers": "^4.0.6",
|
||||
"@types/node": "^24.0.7",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"graphql": "^16.11.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"lightningcss": "^1.30.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"solid-js": "^1.9.7",
|
||||
"terser": "^5.39.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-solid": "^2.11.7"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@solidjs/router": "^0.15.3"
|
||||
}
|
||||
}
|
||||
36
panel/App.tsx
Normal file
36
panel/App.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Route, Router } from '@solidjs/router'
|
||||
import { lazy, onMount } from 'solid-js'
|
||||
import { AuthProvider } from './context/auth'
|
||||
import { I18nProvider } from './intl/i18n'
|
||||
import LoginPage from './routes/login'
|
||||
|
||||
const ProtectedRoute = lazy(() =>
|
||||
import('./ui/ProtectedRoute').then((module) => ({ default: module.ProtectedRoute }))
|
||||
)
|
||||
/**
|
||||
* Корневой компонент приложения
|
||||
*/
|
||||
const App = () => {
|
||||
console.log('[App] Initializing root component...')
|
||||
|
||||
onMount(() => {
|
||||
console.log('[App] Root component mounted')
|
||||
})
|
||||
|
||||
return (
|
||||
<I18nProvider>
|
||||
<AuthProvider>
|
||||
<div class="app-container">
|
||||
<Router>
|
||||
<Route path="/login" component={LoginPage} />
|
||||
<Route path="/" component={ProtectedRoute} />
|
||||
<Route path="/admin" component={ProtectedRoute} />
|
||||
<Route path="/admin/:tab" component={ProtectedRoute} />
|
||||
</Router>
|
||||
</div>
|
||||
</AuthProvider>
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
22
panel/assets/discours.svg
Normal file
22
panel/assets/discours.svg
Normal file
@@ -0,0 +1,22 @@
|
||||
<svg width="172" height="32" viewBox="0 0 175 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- D -->
|
||||
<path d="M24.51 28.16H19.78V23H5.074V28.16H0.344V18.055H2.881C3.11033 17.7397 3.397 17.2093 3.741 16.464C4.11367 15.69 4.472 14.6437 4.816 13.325C5.18867 12.0063 5.504 10.3723 5.762 8.423C6.02 6.47367 6.149 4.166 6.149 1.5H21.113V18.055H24.51V28.16ZM15.523 18.313V6.23H11.008C10.9507 7.262 10.8503 8.36567 10.707 9.541C10.5637 10.6877 10.3773 11.8057 10.148 12.895C9.94733 13.9843 9.70367 15.002 9.417 15.948C9.13033 16.894 8.815 17.6823 8.471 18.313H15.523Z" fill="currentColor"/>
|
||||
|
||||
<!-- I -->
|
||||
<path d="M42.3382 13.196L42.5532 10.143H42.4242L32.0612 23H27.6752V1.5H33.2652V11.734L33.0072 14.658H33.1792L43.5422 1.5H47.9282V23H42.3382V13.196Z" fill="currentColor"/>
|
||||
|
||||
<!-- S -->
|
||||
<path d="M73.3244 20.936C72.1491 21.8247 70.7731 22.4983 69.1964 22.957C67.6197 23.387 65.9714 23.602 64.2514 23.602C62.3881 23.602 60.7254 23.3297 59.2634 22.785C57.8301 22.2403 56.6117 21.4807 55.6084 20.506C54.6337 19.5027 53.8884 18.2987 53.3724 16.894C52.8564 15.4893 52.5984 13.9413 52.5984 12.25C52.5984 10.444 52.8994 8.83867 53.5014 7.434C54.1034 6.02933 54.9347 4.83967 55.9954 3.865C57.0847 2.89033 58.3604 2.15933 59.8224 1.672C61.2844 1.156 62.8754 0.898 64.5954 0.898C66.2007 0.898 67.7057 1.08433 69.1104 1.457C70.5151 1.82966 71.5901 2.188 72.3354 2.532V10.1H67.6054V6.144C66.7167 5.94333 65.8281 5.843 64.9394 5.843C64.1367 5.843 63.3341 5.972 62.5314 6.23C61.7574 6.45933 61.0551 6.84633 60.4244 7.391C59.8224 7.907 59.3207 8.56633 58.9194 9.369C58.5467 10.1717 58.3604 11.132 58.3604 12.25C58.3604 13.1673 58.5181 14.013 58.8334 14.787C59.1487 15.561 59.5931 16.2347 60.1664 16.808C60.7397 17.3813 61.4421 17.84 62.2734 18.184C63.1334 18.4993 64.0794 18.657 65.1114 18.657C66.7454 18.657 68.0784 18.4277 69.1104 17.969C70.1711 17.5103 70.9594 17.109 71.4754 16.765L73.3244 20.936Z" fill="currentColor"/>
|
||||
|
||||
<!-- C -->
|
||||
<path d="M86.4226 14.529H83.6276V23H78.0376V1.5H83.6276V10.229H85.9066L93.0016 1.5H99.3226L90.7656 11.519L96.0546 18.27H99.8386V23H93.3886L86.4226 14.529Z" fill="currentColor"/>
|
||||
|
||||
<!-- O -->
|
||||
<path d="M112.636 16.937H114.27L118.441 1.5H124.203L118.097 20.893C117.552 22.4983 117.022 23.9603 116.506 25.279C115.99 26.6263 115.388 27.7873 114.7 28.762C114.012 29.7367 113.195 30.482 112.249 30.998C111.331 31.5427 110.199 31.815 108.852 31.815C108.192 31.815 107.562 31.729 106.96 31.557C106.386 31.385 105.856 31.17 105.369 30.912C104.881 30.6827 104.451 30.4247 104.079 30.138C103.706 29.88 103.405 29.6363 103.176 29.407L106.057 25.58C106.429 25.8953 106.874 26.182 107.39 26.44C107.906 26.7267 108.393 26.87 108.852 26.87C109.712 26.87 110.385 26.569 110.873 25.967C111.389 25.365 111.876 24.376 112.335 23H109.282L100.338 1.5H106.401L112.636 16.937Z" fill="currentColor"/>
|
||||
|
||||
<!-- U -->
|
||||
<path d="M126.444 1.5H133.754L134.399 4.08H134.571C136.061 1.95867 138.469 0.898 141.795 0.898C143.113 0.898 144.317 1.113 145.407 1.543C146.525 1.94433 147.471 2.58933 148.245 3.478C149.047 4.36667 149.664 5.48467 150.094 6.832C150.524 8.17933 150.739 9.799 150.739 11.691C150.739 13.5257 150.495 15.1883 150.008 16.679C149.52 18.141 148.818 19.388 147.901 20.42C146.983 21.452 145.865 22.2403 144.547 22.785C143.228 23.3297 141.723 23.602 140.032 23.602C139.143 23.602 138.269 23.5303 137.409 23.387C136.549 23.2723 135.832 23.0717 135.259 22.785V31.6H129.669V6.23H126.444V1.5ZM140.118 5.628C139.028 5.628 138.025 5.90033 137.108 6.445C136.219 6.98967 135.603 7.80667 135.259 8.896V17.84C135.66 18.1553 136.233 18.4133 136.979 18.614C137.753 18.786 138.527 18.872 139.301 18.872C140.103 18.872 140.849 18.743 141.537 18.485C142.225 18.1983 142.827 17.754 143.343 17.152C143.859 16.55 144.26 15.7903 144.547 14.873C144.833 13.9557 144.977 12.852 144.977 11.562C144.977 9.67 144.518 8.208 143.601 7.176C142.683 6.144 141.522 5.628 140.118 5.628Z" fill="currentColor"/>
|
||||
|
||||
<!-- R -->
|
||||
<path d="M174.845 20.936C173.669 21.8247 172.293 22.4983 170.717 22.957C169.14 23.387 167.492 23.602 165.772 23.602C163.908 23.602 162.246 23.3297 160.784 22.785C159.35 22.2403 158.132 21.4807 157.129 20.506C156.154 19.5027 155.409 18.2987 154.893 16.894C154.377 15.4893 154.119 13.9413 154.119 12.25C154.119 10.444 154.42 8.83867 155.022 7.434C155.624 6.02933 156.455 4.83967 157.516 3.865C158.605 2.89033 159.881 2.15933 161.343 1.672C162.805 1.156 164.396 0.898 166.116 0.898C167.721 0.898 169.226 1.08433 170.631 1.457C172.035 1.82966 173.11 2.188 173.856 2.532V10.1H169.126V6.144C168.237 5.94333 167.348 5.843 166.46 5.843C165.657 5.843 164.854 5.972 164.052 6.23C163.278 6.45933 162.575 6.84633 161.945 7.391C161.343 7.907 160.841 8.56633 160.44 9.369C160.067 10.1717 159.881 11.132 159.881 12.25C159.881 13.1673 160.038 14.013 160.354 14.787C160.669 15.561 161.113 16.2347 161.687 16.808C162.26 17.3813 162.962 17.84 163.794 18.184C164.654 18.4993 165.6 18.657 166.632 18.657C168.266 18.657 169.599 18.4277 170.631 17.969C171.691 17.5103 172.48 17.109 172.996 16.765L174.845 20.936Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
18
panel/assets/publy.svg
Normal file
18
panel/assets/publy.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg width="96" height="32" viewBox="0 0 96 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- p (без перекладины, чистая петля) -->
|
||||
<path d="M15 28V6C15 3 20 3 20 6V16C20 19 15 19 15 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
<!-- u (симметричный, вписан в высоту 6-28) -->
|
||||
<path d="M30 6V22C30 26 36 26 36 22V6" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
<!-- b (без перекладины, чистая петля, p вверх ногами, отражён по горизонтали, вписан в высоту 6-28) -->
|
||||
<g transform="translate(48.5,17) scale(-1,1) translate(-48.5,-17)">
|
||||
<path d="M51 6V28C51 31 46 31 46 28V18C46 15 51 15 51 18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
|
||||
<!-- l (симметричный, вписан в высоту 6-28) -->
|
||||
<path d="M62 6V28" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>
|
||||
|
||||
<!-- y (симметричный, вписан в высоту 6-28) -->
|
||||
<path d="M75 6V18C75 24 80 24 80 18V6M80 18C80 28 72 28 72 28" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
158
panel/context/auth.tsx
Normal file
158
panel/context/auth.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Component, createContext, createSignal, JSX, useContext } from 'solid-js'
|
||||
import { query } from '../graphql'
|
||||
import { ADMIN_LOGIN_MUTATION, ADMIN_LOGOUT_MUTATION } from '../graphql/mutations'
|
||||
import {
|
||||
AUTH_TOKEN_KEY,
|
||||
CSRF_TOKEN_KEY,
|
||||
checkAuthStatus,
|
||||
clearAuthTokens,
|
||||
getAuthTokenFromCookie,
|
||||
getCsrfTokenFromCookie,
|
||||
saveAuthToken
|
||||
} from '../utils/auth'
|
||||
/**
|
||||
* Модуль авторизации
|
||||
* @module auth
|
||||
*/
|
||||
|
||||
/**
|
||||
* Интерфейс для учетных данных
|
||||
*/
|
||||
export interface Credentials {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс для результата авторизации
|
||||
*/
|
||||
export interface LoginResult {
|
||||
success: boolean
|
||||
token?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Экспортируем утилитарные функции для обратной совместимости
|
||||
export {
|
||||
AUTH_TOKEN_KEY,
|
||||
CSRF_TOKEN_KEY,
|
||||
getAuthTokenFromCookie,
|
||||
getCsrfTokenFromCookie,
|
||||
checkAuthStatus,
|
||||
clearAuthTokens,
|
||||
saveAuthToken
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: () => boolean
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
isAuthenticated: () => false,
|
||||
login: async () => {},
|
||||
logout: async () => {}
|
||||
})
|
||||
|
||||
export const useAuth = () => useContext(AuthContext)
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export const AuthProvider: Component<AuthProviderProps> = (props) => {
|
||||
console.log('[AuthProvider] Initializing...')
|
||||
const [isAuthenticated, setIsAuthenticated] = createSignal(checkAuthStatus())
|
||||
console.log(
|
||||
`[AuthProvider] Initial auth state: ${isAuthenticated() ? 'authenticated' : 'not authenticated'}`
|
||||
)
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
console.log('[AuthProvider] Attempting login...')
|
||||
try {
|
||||
const result = await query<{
|
||||
login: { success: boolean; token?: string }
|
||||
}>(`${location.origin}/graphql`, ADMIN_LOGIN_MUTATION, {
|
||||
email: username,
|
||||
password
|
||||
})
|
||||
|
||||
if (result?.login?.success) {
|
||||
console.log('[AuthProvider] Login successful')
|
||||
if (result.login.token) {
|
||||
saveAuthToken(result.login.token)
|
||||
}
|
||||
setIsAuthenticated(true)
|
||||
// Убираем window.location.href - пусть роутер сам обрабатывает навигацию
|
||||
} else {
|
||||
console.error('[AuthProvider] Login failed')
|
||||
throw new Error('Неверные учетные данные')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AuthProvider] Login error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
console.log('[AuthProvider] Attempting logout...')
|
||||
try {
|
||||
// Сначала очищаем токены на клиенте
|
||||
clearAuthTokens()
|
||||
setIsAuthenticated(false)
|
||||
|
||||
// Затем делаем запрос на сервер
|
||||
const result = await query<{ logout: { success: boolean; message?: string } }>(
|
||||
`${location.origin}/graphql`,
|
||||
ADMIN_LOGOUT_MUTATION
|
||||
)
|
||||
|
||||
console.log('[AuthProvider] Logout response:', result)
|
||||
|
||||
if (result?.logout?.success) {
|
||||
console.log('[AuthProvider] Logout successful:', result.logout.message)
|
||||
window.location.href = '/login'
|
||||
} else {
|
||||
console.warn('[AuthProvider] Logout was not successful:', result?.logout?.message)
|
||||
// Все равно редиректим на страницу входа
|
||||
window.location.href = '/login'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AuthProvider] Logout error:', error)
|
||||
// При любой ошибке редиректим на страницу входа
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
|
||||
const value: AuthContextType = {
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout
|
||||
}
|
||||
|
||||
console.log('[AuthProvider] Rendering provider with context')
|
||||
return <AuthContext.Provider value={value}>{props.children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
// Export the logout function for direct use
|
||||
export const logout = async () => {
|
||||
console.log('[Auth] Executing standalone logout...')
|
||||
try {
|
||||
const result = await query<{ logout: { success: boolean } }>(
|
||||
`${location.origin}/graphql`,
|
||||
ADMIN_LOGOUT_MUTATION
|
||||
)
|
||||
console.log('[Auth] Standalone logout result:', result)
|
||||
if (result?.logout?.success) {
|
||||
clearAuthTokens()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('[Auth] Standalone logout error:', error)
|
||||
// Даже при ошибке очищаем токены
|
||||
clearAuthTokens()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
338
panel/context/data.tsx
Normal file
338
panel/context/data.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import { createContext, createEffect, createSignal, JSX, onMount, useContext } from 'solid-js'
|
||||
import { query } from '../graphql'
|
||||
import {
|
||||
ADMIN_GET_ROLES_QUERY,
|
||||
ADMIN_GET_TOPICS_QUERY,
|
||||
GET_COMMUNITIES_QUERY,
|
||||
GET_TOPICS_QUERY
|
||||
} from '../graphql/queries'
|
||||
|
||||
export interface Community {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
desc?: string
|
||||
pic?: string
|
||||
}
|
||||
|
||||
export interface Topic {
|
||||
id: number
|
||||
slug: string
|
||||
title: string
|
||||
body?: string
|
||||
pic?: string
|
||||
community: number
|
||||
parent_ids?: number[]
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface DataContextType {
|
||||
// Сообщества
|
||||
communities: () => Community[]
|
||||
getCommunityById: (id: number) => Community | undefined
|
||||
getCommunityName: (id: number) => string
|
||||
selectedCommunity: () => number | null
|
||||
setSelectedCommunity: (id: number | null) => void
|
||||
|
||||
// Топики
|
||||
topics: () => Topic[]
|
||||
allTopics: () => Topic[]
|
||||
setTopics: (topics: Topic[]) => void
|
||||
getTopicById: (id: number) => Topic | undefined
|
||||
getTopicTitle: (id: number) => string
|
||||
loadTopicsByCommunity: (communityId: number) => Promise<Topic[]>
|
||||
|
||||
// Роли
|
||||
roles: () => Role[]
|
||||
getRoleById: (id: string) => Role | undefined
|
||||
getRoleName: (id: string) => string
|
||||
|
||||
// Общие методы
|
||||
isLoading: () => boolean
|
||||
loadData: () => Promise<void>
|
||||
// biome-ignore lint/suspicious/noExplicitAny: grahphql
|
||||
queryGraphQL: (query: string, variables?: Record<string, any>) => Promise<any>
|
||||
}
|
||||
|
||||
const DataContext = createContext<DataContextType>({
|
||||
// Сообщества
|
||||
communities: () => [],
|
||||
getCommunityById: () => undefined,
|
||||
getCommunityName: () => '',
|
||||
selectedCommunity: () => null,
|
||||
setSelectedCommunity: () => {},
|
||||
|
||||
// Топики
|
||||
topics: () => [],
|
||||
allTopics: () => [],
|
||||
setTopics: () => {},
|
||||
getTopicById: () => undefined,
|
||||
getTopicTitle: () => '',
|
||||
loadTopicsByCommunity: async () => [],
|
||||
|
||||
// Роли
|
||||
roles: () => [],
|
||||
getRoleById: () => undefined,
|
||||
getRoleName: () => '',
|
||||
|
||||
// Общие методы
|
||||
isLoading: () => false,
|
||||
loadData: async () => {},
|
||||
queryGraphQL: async () => {}
|
||||
})
|
||||
|
||||
/**
|
||||
* Ключ для сохранения выбранного сообщества в localStorage
|
||||
*/
|
||||
const COMMUNITY_STORAGE_KEY = 'admin-selected-community'
|
||||
|
||||
export function DataProvider(props: { children: JSX.Element }) {
|
||||
const [communities, setCommunities] = createSignal<Community[]>([])
|
||||
const [topics, setTopics] = createSignal<Topic[]>([])
|
||||
const [allTopics, setAllTopics] = createSignal<Topic[]>([])
|
||||
const [roles, setRoles] = createSignal<Role[]>([])
|
||||
|
||||
// Обертка для setTopics с логированием
|
||||
const setTopicsWithLogging = (newTopics: Topic[]) => {
|
||||
console.log('[DataProvider] setTopics called with', newTopics.length, 'topics')
|
||||
setTopics(newTopics)
|
||||
}
|
||||
|
||||
// Инициализация выбранного сообщества из localStorage
|
||||
const initialCommunity = (() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(COMMUNITY_STORAGE_KEY)
|
||||
if (stored) {
|
||||
const communityId = Number.parseInt(stored, 10)
|
||||
return Number.isNaN(communityId) ? 1 : communityId
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[DataProvider] Ошибка при чтении сообщества из localStorage:', e)
|
||||
}
|
||||
return 1 // По умолчанию выбираем сообщество с ID 1 (Дискурс)
|
||||
})()
|
||||
|
||||
const [selectedCommunity, setSelectedCommunity] = createSignal<number | null>(initialCommunity)
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
|
||||
// Сохранение выбранного сообщества в localStorage
|
||||
const updateSelectedCommunity = (id: number | null) => {
|
||||
try {
|
||||
if (id !== null) {
|
||||
localStorage.setItem(COMMUNITY_STORAGE_KEY, id.toString())
|
||||
console.log('[DataProvider] Сохранено сообщество в localStorage:', id)
|
||||
} else {
|
||||
localStorage.removeItem(COMMUNITY_STORAGE_KEY)
|
||||
console.log('[DataProvider] Удалено сохраненное сообщество из localStorage')
|
||||
}
|
||||
setSelectedCommunity(id)
|
||||
} catch (e) {
|
||||
console.error('[DataProvider] Ошибка при сохранении сообщества в localStorage:', e)
|
||||
setSelectedCommunity(id) // Всё равно обновляем состояние
|
||||
}
|
||||
}
|
||||
|
||||
// Эффект для загрузки ролей при изменении сообщества
|
||||
createEffect(() => {
|
||||
const community = selectedCommunity()
|
||||
if (community !== null) {
|
||||
console.log('[DataProvider] Загрузка ролей для сообщества:', community)
|
||||
loadRoles(community).catch((err) => {
|
||||
console.warn('Не удалось загрузить роли для сообщества:', err)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Загрузка данных при монтировании
|
||||
onMount(() => {
|
||||
console.log('[DataProvider] Инициализация с сообществом:', initialCommunity)
|
||||
loadData().catch((err) => {
|
||||
console.error('Ошибка при начальной загрузке данных:', err)
|
||||
})
|
||||
})
|
||||
|
||||
// Загрузка сообществ
|
||||
const loadCommunities = async () => {
|
||||
try {
|
||||
const result = await query<{ get_communities_all: Community[] }>(
|
||||
`${location.origin}/graphql`,
|
||||
GET_COMMUNITIES_QUERY
|
||||
)
|
||||
|
||||
const communitiesData = result.get_communities_all || []
|
||||
setCommunities(communitiesData)
|
||||
return communitiesData
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки сообществ:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка всех топиков
|
||||
const loadTopics = async () => {
|
||||
try {
|
||||
const result = await query<{ get_topics_all: Topic[] }>(
|
||||
`${location.origin}/graphql`,
|
||||
GET_TOPICS_QUERY
|
||||
)
|
||||
|
||||
const topicsData = result.get_topics_all || []
|
||||
setTopicsWithLogging(topicsData)
|
||||
return topicsData
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки топиков:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка всех топиков сообщества
|
||||
const loadTopicsByCommunity = async (communityId: number) => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
// Используем админский резолвер для получения всех топиков без лимитов
|
||||
const result = await query<{ adminGetTopics: Topic[] }>(
|
||||
`${location.origin}/graphql`,
|
||||
ADMIN_GET_TOPICS_QUERY,
|
||||
{ community_id: communityId }
|
||||
)
|
||||
|
||||
const allTopicsData = result.adminGetTopics || []
|
||||
|
||||
// Сохраняем все данные сразу для отображения
|
||||
setTopicsWithLogging(allTopicsData)
|
||||
setAllTopics(allTopicsData)
|
||||
|
||||
console.log(`[DataProvider] Загружено ${allTopicsData.length} топиков для сообщества ${communityId}`)
|
||||
return allTopicsData
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки топиков по сообществу:', error)
|
||||
return []
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка ролей для конкретного сообщества
|
||||
const loadRoles = async (communityId?: number) => {
|
||||
try {
|
||||
console.log(
|
||||
'[DataProvider] Загружаем роли...',
|
||||
communityId ? `для сообщества ${communityId}` : 'все роли'
|
||||
)
|
||||
|
||||
const variables = communityId ? { community: communityId } : {}
|
||||
|
||||
const result = await query<{ adminGetRoles: Role[] }>(
|
||||
`${location.origin}/graphql`,
|
||||
ADMIN_GET_ROLES_QUERY,
|
||||
variables
|
||||
)
|
||||
|
||||
const rolesData = result.adminGetRoles || []
|
||||
console.log('[DataProvider] Роли успешно загружены:', rolesData)
|
||||
setRoles(rolesData)
|
||||
return rolesData
|
||||
} catch (error) {
|
||||
console.warn('Ошибка загрузки ролей:', error)
|
||||
setRoles([])
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка всех данных
|
||||
const loadData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Загружаем все данные сразу (вызывается только для авторизованных пользователей)
|
||||
// Роли загружаем в фоне - их отсутствие не должно блокировать интерфейс
|
||||
await Promise.all([
|
||||
loadCommunities(),
|
||||
loadTopics(),
|
||||
loadRoles(selectedCommunity() || undefined).catch((err) => {
|
||||
console.warn('Роли недоступны (возможно не хватает прав):', err)
|
||||
return []
|
||||
})
|
||||
])
|
||||
|
||||
// selectedCommunity теперь всегда инициализировано со значением 1,
|
||||
// поэтому дополнительная проверка не нужна
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки данных:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Методы для работы с сообществами
|
||||
const getCommunityById = (id: number): Community | undefined => {
|
||||
return communities().find((community) => community.id === id)
|
||||
}
|
||||
|
||||
const getCommunityName = (id: number): string => getCommunityById(id)?.name || ''
|
||||
const getTopicTitle = (id: number): string => {
|
||||
const topic = getTopicById(id)
|
||||
const title = topic?.title || ''
|
||||
console.log(`[DataProvider] getTopicTitle(${id}) -> "${title}", parent_ids:`, topic?.parent_ids)
|
||||
return title
|
||||
}
|
||||
|
||||
// Методы для работы с топиками
|
||||
const getTopicById = (id: number): Topic | undefined => {
|
||||
return topics().find((topic) => topic.id === id)
|
||||
}
|
||||
|
||||
// Методы для работы с ролями
|
||||
const getRoleById = (id: string): Role | undefined => {
|
||||
return roles().find((role) => role.id === id)
|
||||
}
|
||||
|
||||
const getRoleName = (id: string): string => {
|
||||
const role = getRoleById(id)
|
||||
return role ? role.name : id
|
||||
}
|
||||
|
||||
const value = {
|
||||
// Сообщества
|
||||
communities,
|
||||
getCommunityById,
|
||||
getCommunityName,
|
||||
selectedCommunity,
|
||||
setSelectedCommunity: updateSelectedCommunity,
|
||||
|
||||
// Топики
|
||||
topics,
|
||||
allTopics,
|
||||
setTopics: setTopicsWithLogging,
|
||||
getTopicById,
|
||||
getTopicTitle,
|
||||
loadTopicsByCommunity,
|
||||
|
||||
// Роли
|
||||
roles,
|
||||
getRoleById,
|
||||
getRoleName,
|
||||
|
||||
// Общие методы
|
||||
isLoading,
|
||||
loadData,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: grahphql
|
||||
queryGraphQL: async (queryStr: string, variables?: Record<string, any>) => {
|
||||
try {
|
||||
return await query(`${location.origin}/graphql`, queryStr, variables)
|
||||
} catch (error) {
|
||||
console.error('Ошибка выполнения GraphQL запроса:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return <DataContext.Provider value={value}>{props.children}</DataContext.Provider>
|
||||
}
|
||||
|
||||
export const useData = () => useContext(DataContext)
|
||||
150
panel/context/sort.tsx
Normal file
150
panel/context/sort.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { createContext, createSignal, ParentComponent, useContext } from 'solid-js'
|
||||
|
||||
/**
|
||||
* Типы полей сортировки для разных вкладок
|
||||
*/
|
||||
export type AuthorsSortField = 'id' | 'email' | 'name' | 'created_at' | 'last_seen'
|
||||
export type ShoutsSortField = 'id' | 'title' | 'slug' | 'created_at' | 'published_at' | 'updated_at'
|
||||
export type TopicsSortField =
|
||||
| 'id'
|
||||
| 'title'
|
||||
| 'slug'
|
||||
| 'created_at'
|
||||
| 'authors'
|
||||
| 'shouts'
|
||||
| 'followers'
|
||||
| 'authors'
|
||||
export type CommunitiesSortField =
|
||||
| 'id'
|
||||
| 'name'
|
||||
| 'slug'
|
||||
| 'created_at'
|
||||
| 'created_by'
|
||||
| 'shouts'
|
||||
| 'followers'
|
||||
| 'authors'
|
||||
export type CollectionsSortField = 'id' | 'title' | 'slug' | 'created_at' | 'published_at'
|
||||
export type InvitesSortField = 'inviter_name' | 'author_name' | 'shout_title' | 'status'
|
||||
|
||||
/**
|
||||
* Общий тип для всех полей сортировки
|
||||
*/
|
||||
export type SortField =
|
||||
| AuthorsSortField
|
||||
| ShoutsSortField
|
||||
| TopicsSortField
|
||||
| CommunitiesSortField
|
||||
| CollectionsSortField
|
||||
| InvitesSortField
|
||||
|
||||
/**
|
||||
* Направление сортировки
|
||||
*/
|
||||
export type SortDirection = 'asc' | 'desc'
|
||||
|
||||
/**
|
||||
* Состояние сортировки
|
||||
*/
|
||||
export interface SortState {
|
||||
field: SortField
|
||||
direction: SortDirection
|
||||
}
|
||||
|
||||
/**
|
||||
* Конфигурация сортировки для разных вкладок
|
||||
*/
|
||||
export interface TabSortConfig {
|
||||
allowedFields: SortField[]
|
||||
defaultField: SortField
|
||||
defaultDirection: SortDirection
|
||||
}
|
||||
|
||||
/**
|
||||
* Контекст для управления сортировкой таблиц
|
||||
*/
|
||||
interface TableSortContextType {
|
||||
sortState: () => SortState
|
||||
setSortState: (state: SortState) => void
|
||||
handleSort: (field: SortField, allowedFields?: SortField[]) => void
|
||||
getSortIcon: (field: SortField) => string
|
||||
isFieldAllowed: (field: SortField, allowedFields?: SortField[]) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаем контекст
|
||||
*/
|
||||
const TableSortContext = createContext<TableSortContextType>()
|
||||
|
||||
/**
|
||||
* Провайдер контекста сортировки
|
||||
*/
|
||||
export const TableSortProvider: ParentComponent = (props) => {
|
||||
// Состояние сортировки - по умолчанию сортировка по ID по возрастанию
|
||||
const [sortState, setSortState] = createSignal<SortState>({
|
||||
field: 'id',
|
||||
direction: 'asc'
|
||||
})
|
||||
|
||||
/**
|
||||
* Проверяет, разрешено ли поле для сортировки
|
||||
*/
|
||||
const isFieldAllowed = (field: SortField, allowedFields?: SortField[]) => {
|
||||
if (!allowedFields) return true
|
||||
return allowedFields.includes(field)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик клика по заголовку колонки для сортировки
|
||||
*/
|
||||
const handleSort = (field: SortField, allowedFields?: SortField[]) => {
|
||||
// Проверяем, разрешено ли поле для сортировки
|
||||
if (!isFieldAllowed(field, allowedFields)) {
|
||||
console.warn(`Поле ${field} не разрешено для сортировки`)
|
||||
return
|
||||
}
|
||||
|
||||
const current = sortState()
|
||||
let newDirection: SortDirection = 'asc'
|
||||
|
||||
if (current.field === field) {
|
||||
// Если кликнули по той же колонке, меняем направление
|
||||
newDirection = current.direction === 'asc' ? 'desc' : 'asc'
|
||||
}
|
||||
|
||||
const newState = { field, direction: newDirection }
|
||||
console.log('Изменение сортировки:', { from: current, to: newState })
|
||||
setSortState(newState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает иконку сортировки для колонки
|
||||
*/
|
||||
const getSortIcon = (field: SortField) => {
|
||||
const current = sortState()
|
||||
if (current.field !== field) {
|
||||
return '⇅' // Неактивная сортировка
|
||||
}
|
||||
return current.direction === 'asc' ? '▲' : '▼'
|
||||
}
|
||||
|
||||
const contextValue: TableSortContextType = {
|
||||
sortState,
|
||||
setSortState,
|
||||
handleSort,
|
||||
getSortIcon,
|
||||
isFieldAllowed
|
||||
}
|
||||
|
||||
return <TableSortContext.Provider value={contextValue}>{props.children}</TableSortContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для использования контекста сортировки
|
||||
*/
|
||||
export const useTableSort = () => {
|
||||
const context = useContext(TableSortContext)
|
||||
if (!context) {
|
||||
throw new Error('useTableSort должен использоваться внутри TableSortProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
142
panel/context/sortConfig.ts
Normal file
142
panel/context/sortConfig.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type {
|
||||
AuthorsSortField,
|
||||
CollectionsSortField,
|
||||
CommunitiesSortField,
|
||||
InvitesSortField,
|
||||
ShoutsSortField,
|
||||
TabSortConfig,
|
||||
TopicsSortField
|
||||
} from './sort'
|
||||
|
||||
/**
|
||||
* Конфигурации сортировки для разных вкладок админ-панели
|
||||
* Основаны на том, что реально поддерживают резолверы в бэкенде
|
||||
*/
|
||||
|
||||
/**
|
||||
* Конфигурация сортировки для вкладки "Авторы"
|
||||
* Основана на резолвере admin_get_users в resolvers/admin.py
|
||||
*/
|
||||
export const AUTHORS_SORT_CONFIG: TabSortConfig = {
|
||||
allowedFields: ['id', 'email', 'name', 'created_at', 'last_seen'] as AuthorsSortField[],
|
||||
defaultField: 'id' as AuthorsSortField,
|
||||
defaultDirection: 'asc'
|
||||
}
|
||||
|
||||
/**
|
||||
* Конфигурация сортировки для вкладки "Публикации"
|
||||
* Основана на резолвере admin_get_shouts в resolvers/admin.py
|
||||
*/
|
||||
export const SHOUTS_SORT_CONFIG: TabSortConfig = {
|
||||
allowedFields: ['id', 'title', 'slug', 'created_at', 'published_at', 'updated_at'] as ShoutsSortField[],
|
||||
defaultField: 'id' as ShoutsSortField,
|
||||
defaultDirection: 'desc' // Новые публикации сначала
|
||||
}
|
||||
|
||||
/**
|
||||
* Конфигурация сортировки для вкладки "Темы"
|
||||
* Основана на резолвере get_topics_with_stats в resolvers/topic.py
|
||||
*/
|
||||
export const TOPICS_SORT_CONFIG: TabSortConfig = {
|
||||
allowedFields: [
|
||||
'id',
|
||||
'title',
|
||||
'slug',
|
||||
'created_at',
|
||||
'authors',
|
||||
'shouts',
|
||||
'followers'
|
||||
] as TopicsSortField[],
|
||||
defaultField: 'id' as TopicsSortField,
|
||||
defaultDirection: 'asc'
|
||||
}
|
||||
|
||||
/**
|
||||
* Конфигурация сортировки для вкладки "Сообщества"
|
||||
* Основана на резолвере get_communities_all в resolvers/community.py
|
||||
*/
|
||||
export const COMMUNITIES_SORT_CONFIG: TabSortConfig = {
|
||||
allowedFields: [
|
||||
'id',
|
||||
'name',
|
||||
'slug',
|
||||
'created_at',
|
||||
'created_by',
|
||||
'shouts',
|
||||
'followers',
|
||||
'authors'
|
||||
] as CommunitiesSortField[],
|
||||
defaultField: 'id' as CommunitiesSortField,
|
||||
defaultDirection: 'asc'
|
||||
}
|
||||
|
||||
/**
|
||||
* Конфигурация сортировки для вкладки "Коллекции"
|
||||
* Основана на резолвере get_collections_all в resolvers/collection.py
|
||||
*/
|
||||
export const COLLECTIONS_SORT_CONFIG: TabSortConfig = {
|
||||
allowedFields: ['id', 'title', 'slug', 'created_at', 'published_at'] as CollectionsSortField[],
|
||||
defaultField: 'id' as CollectionsSortField,
|
||||
defaultDirection: 'asc'
|
||||
}
|
||||
|
||||
/**
|
||||
* Конфигурация сортировки для вкладки "Приглашения"
|
||||
* Основана на резолвере admin_get_invites в resolvers/admin.py
|
||||
*/
|
||||
export const INVITES_SORT_CONFIG: TabSortConfig = {
|
||||
allowedFields: ['inviter_name', 'author_name', 'shout_title', 'status'] as InvitesSortField[],
|
||||
defaultField: 'inviter_name' as InvitesSortField,
|
||||
defaultDirection: 'asc'
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает конфигурацию сортировки для указанной вкладки
|
||||
*/
|
||||
export const getSortConfigForTab = (tab: string): TabSortConfig => {
|
||||
switch (tab) {
|
||||
case 'authors':
|
||||
return AUTHORS_SORT_CONFIG
|
||||
case 'shouts':
|
||||
return SHOUTS_SORT_CONFIG
|
||||
case 'topics':
|
||||
return TOPICS_SORT_CONFIG
|
||||
case 'communities':
|
||||
return COMMUNITIES_SORT_CONFIG
|
||||
case 'collections':
|
||||
return COLLECTIONS_SORT_CONFIG
|
||||
case 'invites':
|
||||
return INVITES_SORT_CONFIG
|
||||
default:
|
||||
// По умолчанию возвращаем конфигурацию авторов
|
||||
return AUTHORS_SORT_CONFIG
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Переводы названий полей для отображения пользователю
|
||||
*/
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
// Общие поля
|
||||
id: 'ID',
|
||||
title: 'Название',
|
||||
name: 'Имя',
|
||||
slug: 'Slug',
|
||||
created_at: 'Создано',
|
||||
updated_at: 'Обновлено',
|
||||
published_at: 'Опубликовано',
|
||||
created_by: 'Создатель',
|
||||
shouts: 'Публикации',
|
||||
followers: 'Подписчики',
|
||||
authors: 'Авторы',
|
||||
|
||||
// Поля авторов
|
||||
email: 'Email',
|
||||
last_seen: 'Последний вход',
|
||||
|
||||
// Поля приглашений
|
||||
inviter_name: 'Приглашающий',
|
||||
author_name: 'Приглашаемый',
|
||||
shout_title: 'Публикация',
|
||||
status: 'Статус'
|
||||
}
|
||||
145
panel/graphql/index.ts
Normal file
145
panel/graphql/index.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* API-клиент для работы с GraphQL
|
||||
* @module api
|
||||
*/
|
||||
|
||||
import {
|
||||
AUTH_TOKEN_KEY,
|
||||
clearAuthTokens,
|
||||
getAuthTokenFromCookie,
|
||||
getCsrfTokenFromCookie
|
||||
} from '../utils/auth'
|
||||
|
||||
/**
|
||||
* Тип для произвольных данных GraphQL
|
||||
*/
|
||||
type GraphQLData = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* Возвращает заголовки для GraphQL запроса с учетом авторизации и CSRF
|
||||
* @returns Объект с заголовками
|
||||
*/
|
||||
function getRequestHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json'
|
||||
}
|
||||
|
||||
// Проверяем наличие токена в localStorage
|
||||
const localToken = localStorage.getItem(AUTH_TOKEN_KEY)
|
||||
|
||||
// Проверяем наличие токена в cookie
|
||||
const cookieToken = getAuthTokenFromCookie()
|
||||
|
||||
// Используем токен из localStorage или cookie
|
||||
const token = localToken || cookieToken
|
||||
|
||||
// Если есть токен, добавляем его в заголовок Authorization с префиксом Bearer
|
||||
if (token && token.length > 10) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
console.debug('Отправка запроса с токеном авторизации')
|
||||
}
|
||||
|
||||
// Добавляем CSRF-токен, если он есть
|
||||
const csrfToken = getCsrfTokenFromCookie()
|
||||
if (csrfToken) {
|
||||
headers['X-CSRF-Token'] = csrfToken
|
||||
console.debug('Добавлен CSRF-токен в запрос')
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет GraphQL запрос
|
||||
* @param endpoint - URL эндпоинта GraphQL
|
||||
* @param query - GraphQL запрос
|
||||
* @param variables - Переменные запроса
|
||||
* @returns Результат запроса
|
||||
*/
|
||||
export async function query<T = unknown>(
|
||||
endpoint: string,
|
||||
query: string,
|
||||
variables?: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
try {
|
||||
console.log(`[GraphQL] Making request to ${endpoint}`)
|
||||
console.log(`[GraphQL] Query: ${query.substring(0, 100)}...`)
|
||||
|
||||
// Используем существующую функцию для получения всех необходимых заголовков
|
||||
const headers = getRequestHeaders()
|
||||
console.log(
|
||||
`[GraphQL] Заголовки установлены, Authorization: ${headers['Authorization'] ? 'присутствует' : 'отсутствует'}`
|
||||
)
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables
|
||||
})
|
||||
})
|
||||
|
||||
console.log(`[GraphQL] Response status: ${response.status}`)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
console.log('[GraphQL] Unauthorized response, clearing auth tokens')
|
||||
clearAuthTokens()
|
||||
// Перенаправляем на страницу входа только если мы не на ней
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
const errorText = await response.text()
|
||||
throw new Error(`HTTP error: ${response.status} ${errorText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log('[GraphQL] Response received:', result)
|
||||
|
||||
if (result.errors) {
|
||||
// Проверяем ошибки авторизации
|
||||
const hasUnauthorized = result.errors.some(
|
||||
(error: { message?: string }) =>
|
||||
error.message?.toLowerCase().includes('unauthorized') ||
|
||||
error.message?.toLowerCase().includes('please login')
|
||||
)
|
||||
|
||||
if (hasUnauthorized) {
|
||||
console.log('[GraphQL] Unauthorized error in response, clearing auth tokens')
|
||||
clearAuthTokens()
|
||||
// Перенаправляем на страницу входа только если мы не на ней
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
|
||||
// Handle GraphQL errors
|
||||
const errorMessage = result.errors.map((e: { message?: string }) => e.message).join(', ')
|
||||
throw new Error(`GraphQL error: ${errorMessage}`)
|
||||
}
|
||||
|
||||
return result.data
|
||||
} catch (error) {
|
||||
console.error('[GraphQL] Query error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет GraphQL мутацию
|
||||
* @param url - URL для запроса
|
||||
* @param mutation - GraphQL мутация
|
||||
* @param variables - Переменные мутации
|
||||
* @returns Результат мутации
|
||||
*/
|
||||
export function mutate<T = GraphQLData>(
|
||||
url: string,
|
||||
mutation: string,
|
||||
variables: Record<string, unknown> = {}
|
||||
): Promise<T> {
|
||||
return query<T>(url, mutation, variables)
|
||||
}
|
||||
239
panel/graphql/mutations.ts
Normal file
239
panel/graphql/mutations.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
export const ADMIN_LOGIN_MUTATION = `
|
||||
mutation AdminLogin($email: String!, $password: String!) {
|
||||
login(email: $email, password: $password) {
|
||||
success
|
||||
token
|
||||
author {
|
||||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
roles
|
||||
}
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_LOGOUT_MUTATION = `
|
||||
mutation AdminLogout {
|
||||
logout {
|
||||
success
|
||||
message
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_UPDATE_USER_MUTATION = `
|
||||
mutation AdminUpdateUser($user: AdminUserUpdateInput!) {
|
||||
adminUpdateUser(user: $user) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_UPDATE_ENV_VARIABLE_MUTATION = `
|
||||
mutation AdminUpdateEnvVariable($key: String!, $value: String!) {
|
||||
updateEnvVariable(key: $key, value: $value)
|
||||
}
|
||||
`
|
||||
|
||||
export const CREATE_TOPIC_MUTATION = `
|
||||
mutation CreateTopic($topic_input: TopicInput!) {
|
||||
create_topic(topic_input: $topic_input) {
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_TOPIC_MUTATION = `
|
||||
mutation UpdateTopic($topic_input: TopicInput!) {
|
||||
update_topic(topic_input: $topic_input) {
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const DELETE_TOPIC_MUTATION = `
|
||||
mutation DeleteTopic($id: Int!) {
|
||||
delete_topic_by_id(id: $id) {
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CREATE_COMMUNITY_MUTATION = `
|
||||
mutation CreateCommunity($community_input: CommunityInput!) {
|
||||
create_community(community_input: $community_input) {
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_COMMUNITY_MUTATION = `
|
||||
mutation UpdateCommunity($community_input: CommunityInput!) {
|
||||
update_community(community_input: $community_input) {
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const DELETE_COMMUNITY_MUTATION = `
|
||||
mutation DeleteCommunity($slug: String!) {
|
||||
delete_community(slug: $slug) {
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CREATE_COLLECTION_MUTATION = `
|
||||
mutation CreateCollection($collection_input: CollectionInput!) {
|
||||
create_collection(collection_input: $collection_input) {
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_COLLECTION_MUTATION = `
|
||||
mutation UpdateCollection($collection_input: CollectionInput!) {
|
||||
update_collection(collection_input: $collection_input) {
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const DELETE_COLLECTION_MUTATION = `
|
||||
mutation DeleteCollection($slug: String!) {
|
||||
delete_collection(slug: $slug) {
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_CREATE_INVITE_MUTATION = `
|
||||
mutation AdminCreateInvite($invite: AdminInviteUpdateInput!) {
|
||||
adminCreateInvite(invite: $invite) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_UPDATE_INVITE_MUTATION = `
|
||||
mutation AdminUpdateInvite($invite: AdminInviteUpdateInput!) {
|
||||
adminUpdateInvite(invite: $invite) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_DELETE_INVITE_MUTATION = `
|
||||
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
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_DELETE_INVITES_BATCH_MUTATION = `
|
||||
mutation AdminDeleteInvitesBatch($invites: [AdminInviteIdInput!]!) {
|
||||
adminDeleteInvitesBatch(invites: $invites) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const MERGE_TOPICS_MUTATION = `
|
||||
mutation MergeTopics($merge_input: TopicMergeInput!) {
|
||||
merge_topics(merge_input: $merge_input) {
|
||||
error
|
||||
message
|
||||
topic {
|
||||
id
|
||||
title
|
||||
slug
|
||||
}
|
||||
stats
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SET_TOPIC_PARENT_MUTATION = `
|
||||
mutation SetTopicParent($topic_id: Int!, $parent_id: Int) {
|
||||
set_topic_parent(topic_id: $topic_id, parent_id: $parent_id) {
|
||||
error
|
||||
message
|
||||
topic {
|
||||
id
|
||||
title
|
||||
slug
|
||||
parent_ids
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_UPDATE_TOPIC_MUTATION = `
|
||||
mutation AdminUpdateTopic($topic: AdminTopicInput!) {
|
||||
adminUpdateTopic(topic: $topic) {
|
||||
success
|
||||
error
|
||||
topic {
|
||||
id
|
||||
title
|
||||
slug
|
||||
body
|
||||
community
|
||||
parent_ids
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_UPDATE_REACTION_MUTATION = `
|
||||
mutation AdminUpdateReaction($reaction: AdminReactionUpdateInput!) {
|
||||
adminUpdateReaction(reaction: $reaction) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_DELETE_REACTION_MUTATION = `
|
||||
mutation AdminDeleteReaction($reaction_id: Int!) {
|
||||
adminDeleteReaction(reaction_id: $reaction_id) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_RESTORE_REACTION_MUTATION = `
|
||||
mutation AdminRestoreReaction($reaction_id: Int!) {
|
||||
adminRestoreReaction(reaction_id: $reaction_id) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_CREATE_TOPIC_MUTATION = `
|
||||
mutation AdminCreateTopic($topic: AdminTopicInput!) {
|
||||
adminCreateTopic(topic: $topic) {
|
||||
success
|
||||
error
|
||||
topic {
|
||||
id
|
||||
title
|
||||
slug
|
||||
body
|
||||
community
|
||||
parent_ids
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
381
panel/graphql/queries.ts
Normal file
381
panel/graphql/queries.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { gql } from 'graphql-tag'
|
||||
|
||||
// Определяем GraphQL запрос
|
||||
export const ADMIN_GET_SHOUTS_QUERY: string =
|
||||
gql`
|
||||
query AdminGetShouts($limit: Int, $offset: Int, $search: String, $status: String, $community: Int) {
|
||||
adminGetShouts(limit: $limit, offset: $offset, search: $search, status: $status, community: $community) {
|
||||
shouts {
|
||||
id
|
||||
title
|
||||
slug
|
||||
body
|
||||
lead
|
||||
subtitle
|
||||
layout
|
||||
lang
|
||||
cover
|
||||
cover_caption
|
||||
media {
|
||||
url
|
||||
title
|
||||
body
|
||||
source
|
||||
pic
|
||||
}
|
||||
seo
|
||||
created_at
|
||||
updated_at
|
||||
published_at
|
||||
featured_at
|
||||
deleted_at
|
||||
created_by {
|
||||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
created_at
|
||||
}
|
||||
updated_by {
|
||||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
created_at
|
||||
}
|
||||
deleted_by {
|
||||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
created_at
|
||||
}
|
||||
community {
|
||||
id
|
||||
name
|
||||
slug
|
||||
}
|
||||
authors {
|
||||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
created_at
|
||||
}
|
||||
topics {
|
||||
id
|
||||
title
|
||||
slug
|
||||
}
|
||||
version_of
|
||||
draft
|
||||
stat {
|
||||
rating
|
||||
comments_count
|
||||
viewed
|
||||
last_commented_at
|
||||
}
|
||||
}
|
||||
total
|
||||
page
|
||||
perPage
|
||||
totalPages
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const ADMIN_GET_USERS_QUERY: string =
|
||||
gql`
|
||||
query AdminGetUsers($limit: Int, $offset: Int, $search: String) {
|
||||
adminGetUsers(limit: $limit, offset: $offset, search: $search) {
|
||||
authors {
|
||||
id
|
||||
email
|
||||
name
|
||||
slug
|
||||
roles
|
||||
created_at
|
||||
last_seen
|
||||
}
|
||||
total
|
||||
page
|
||||
perPage
|
||||
totalPages
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const ADMIN_GET_ROLES_QUERY: string =
|
||||
gql`
|
||||
query AdminGetRoles($community: Int) {
|
||||
adminGetRoles(community: $community) {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const ADMIN_GET_ENV_VARIABLES_QUERY: string =
|
||||
gql`
|
||||
query GetEnvVariables {
|
||||
getEnvVariables {
|
||||
name
|
||||
description
|
||||
variables {
|
||||
key
|
||||
value
|
||||
description
|
||||
type
|
||||
isSecret
|
||||
}
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const GET_COMMUNITIES_QUERY: string =
|
||||
gql`
|
||||
query GetCommunities {
|
||||
get_communities_all {
|
||||
id
|
||||
slug
|
||||
name
|
||||
desc
|
||||
pic
|
||||
created_at
|
||||
created_by {
|
||||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
}
|
||||
stat {
|
||||
shouts
|
||||
followers
|
||||
authors
|
||||
}
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const GET_TOPICS_QUERY: string =
|
||||
gql`
|
||||
query GetTopics {
|
||||
get_topics_all {
|
||||
id
|
||||
slug
|
||||
title
|
||||
body
|
||||
pic
|
||||
community
|
||||
parent_ids
|
||||
stat {
|
||||
shouts
|
||||
followers
|
||||
authors
|
||||
comments
|
||||
}
|
||||
oid
|
||||
is_main
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const GET_TOPICS_BY_COMMUNITY_QUERY: string =
|
||||
gql`
|
||||
query GetTopicsByCommunity($community_id: Int!, $limit: Int, $offset: Int) {
|
||||
get_topics_by_community(community_id: $community_id, limit: $limit, offset: $offset) {
|
||||
id
|
||||
slug
|
||||
title
|
||||
body
|
||||
pic
|
||||
community
|
||||
parent_ids
|
||||
oid
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const ADMIN_GET_REACTIONS_QUERY: string =
|
||||
gql`
|
||||
query AdminGetReactions($limit: Int, $offset: Int, $search: String, $kind: ReactionKind, $shout_id: Int, $status: String) {
|
||||
adminGetReactions(limit: $limit, offset: $offset, search: $search, kind: $kind, shout_id: $shout_id, status: $status) {
|
||||
reactions {
|
||||
id
|
||||
kind
|
||||
body
|
||||
created_at
|
||||
updated_at
|
||||
deleted_at
|
||||
reply_to
|
||||
created_by {
|
||||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
created_at
|
||||
}
|
||||
shout {
|
||||
id
|
||||
title
|
||||
slug
|
||||
layout
|
||||
created_at
|
||||
published_at
|
||||
deleted_at
|
||||
}
|
||||
stat {
|
||||
comments_count
|
||||
rating
|
||||
}
|
||||
}
|
||||
total
|
||||
page
|
||||
perPage
|
||||
totalPages
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const ADMIN_GET_TOPICS_QUERY: string =
|
||||
gql`
|
||||
query AdminGetTopics($community_id: Int!) {
|
||||
adminGetTopics(community_id: $community_id) {
|
||||
id
|
||||
title
|
||||
slug
|
||||
body
|
||||
community
|
||||
parent_ids
|
||||
pic
|
||||
oid
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const GET_COLLECTIONS_QUERY: string =
|
||||
gql`
|
||||
query GetCollections {
|
||||
get_collections_all {
|
||||
id
|
||||
slug
|
||||
title
|
||||
desc
|
||||
pic
|
||||
amount
|
||||
published_at
|
||||
created_at
|
||||
created_by {
|
||||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const ADMIN_GET_INVITES_QUERY: string =
|
||||
gql`
|
||||
query AdminGetInvites($limit: Int, $offset: Int, $search: String, $status: String) {
|
||||
adminGetInvites(limit: $limit, offset: $offset, search: $search, status: $status) {
|
||||
invites {
|
||||
inviter_id
|
||||
author_id
|
||||
shout_id
|
||||
status
|
||||
inviter {
|
||||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
}
|
||||
author {
|
||||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
}
|
||||
shout {
|
||||
id
|
||||
title
|
||||
slug
|
||||
created_by {
|
||||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
}
|
||||
}
|
||||
created_at
|
||||
}
|
||||
total
|
||||
page
|
||||
perPage
|
||||
totalPages
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
// Запросы для работы с ролями сообществ
|
||||
export const GET_COMMUNITY_ROLE_SETTINGS_QUERY: string =
|
||||
gql`
|
||||
query GetCommunityRoleSettings($community_id: Int!) {
|
||||
adminGetCommunityRoleSettings(community_id: $community_id) {
|
||||
default_roles
|
||||
available_roles
|
||||
error
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const GET_COMMUNITY_ROLES_QUERY: string =
|
||||
gql`
|
||||
query GetCommunityRoles($community: Int) {
|
||||
adminGetRoles(community: $community) {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const UPDATE_COMMUNITY_ROLE_SETTINGS_MUTATION: string =
|
||||
gql`
|
||||
mutation UpdateCommunityRoleSettings($community_id: Int!, $default_roles: [String!]!, $available_roles: [String!]!) {
|
||||
adminUpdateCommunityRoleSettings(
|
||||
community_id: $community_id,
|
||||
default_roles: $default_roles,
|
||||
available_roles: $available_roles
|
||||
) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const CREATE_CUSTOM_ROLE_MUTATION: string =
|
||||
gql`
|
||||
mutation CreateCustomRole($role: CustomRoleInput!) {
|
||||
adminCreateCustomRole(role: $role) {
|
||||
success
|
||||
error
|
||||
role {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const DELETE_CUSTOM_ROLE_MUTATION: string =
|
||||
gql`
|
||||
mutation DeleteCustomRole($role_id: String!, $community_id: Int!) {
|
||||
adminDeleteCustomRole(role_id: $role_id, community_id: $community_id) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
6
panel/graphql/types.ts
Normal file
6
panel/graphql/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface GraphQLContext {
|
||||
token?: string
|
||||
userId?: number
|
||||
roles?: string[]
|
||||
communityId?: number
|
||||
}
|
||||
12
panel/index.tsx
Normal file
12
panel/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Точка входа в клиентское приложение
|
||||
* @module index
|
||||
*/
|
||||
|
||||
import { render } from 'solid-js/web'
|
||||
import App from './App'
|
||||
|
||||
import './styles.css'
|
||||
|
||||
// Рендеринг приложения в корневой элемент
|
||||
render(() => <App />, document.getElementById('root') as HTMLElement)
|
||||
317
panel/intl/i18n.tsx
Normal file
317
panel/intl/i18n.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import {
|
||||
createContext,
|
||||
createEffect,
|
||||
createSignal,
|
||||
JSX,
|
||||
onCleanup,
|
||||
onMount,
|
||||
ParentComponent,
|
||||
useContext
|
||||
} from 'solid-js'
|
||||
import strings from './strings.json'
|
||||
|
||||
/**
|
||||
* Тип для поддерживаемых языков
|
||||
*/
|
||||
export type Language = 'ru' | 'en'
|
||||
|
||||
/**
|
||||
* Ключ для сохранения языка в localStorage
|
||||
*/
|
||||
const STORAGE_KEY = 'admin-language'
|
||||
|
||||
/**
|
||||
* Регекс для детекции кириллических символов
|
||||
*/
|
||||
const CYRILLIC_REGEX = /[\u0400-\u04FF]/
|
||||
|
||||
/**
|
||||
* Контекст интернационализации
|
||||
*/
|
||||
interface I18nContextType {
|
||||
language: () => Language
|
||||
setLanguage: (lang: Language) => void
|
||||
t: (key: string) => string
|
||||
tr: (text: string) => string
|
||||
isRussian: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаем контекст
|
||||
*/
|
||||
const I18nContext = createContext<I18nContextType>()
|
||||
|
||||
/**
|
||||
* Функция для перевода строки
|
||||
*/
|
||||
const translateString = (text: string, language: Language): string => {
|
||||
// Если язык русский или строка не содержит кириллицу, возвращаем как есть
|
||||
if (language === 'ru' || !CYRILLIC_REGEX.test(text)) {
|
||||
return text
|
||||
}
|
||||
|
||||
// Ищем перевод в словаре
|
||||
const translation = strings[text as keyof typeof strings]
|
||||
return translation || text
|
||||
}
|
||||
|
||||
/**
|
||||
* Автоматический переводчик элементов
|
||||
* Перехватывает создание JSX элементов и автоматически делает кириллические строки реактивными
|
||||
*/
|
||||
const AutoTranslator = (props: { children: JSX.Element; language: () => Language }) => {
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let observer: MutationObserver | undefined
|
||||
|
||||
// Кэш для переведенных элементов
|
||||
const translationCache = new WeakMap<Node, string>()
|
||||
|
||||
// Функция для обновления текстового содержимого
|
||||
const updateTextContent = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const originalText = node.textContent || ''
|
||||
|
||||
// Проверяем, содержит ли кириллицу
|
||||
if (CYRILLIC_REGEX.test(originalText)) {
|
||||
const currentLang = props.language()
|
||||
const translatedText = translateString(originalText, currentLang)
|
||||
|
||||
// Обновляем только если текст изменился
|
||||
if (node.textContent !== translatedText) {
|
||||
console.log(`📝 Переводим текстовый узел "${originalText}" -> "${translatedText}"`)
|
||||
node.textContent = translatedText
|
||||
translationCache.set(node, originalText) // Сохраняем оригинал
|
||||
}
|
||||
}
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as Element
|
||||
|
||||
// Переводим атрибуты
|
||||
const attributesToTranslate = ['title', 'placeholder', 'alt', 'aria-label', 'data-placeholder']
|
||||
attributesToTranslate.forEach((attr) => {
|
||||
const value = element.getAttribute(attr)
|
||||
if (value && CYRILLIC_REGEX.test(value)) {
|
||||
const currentLang = props.language()
|
||||
const translatedValue = translateString(value, currentLang)
|
||||
if (translatedValue !== value) {
|
||||
console.log(`📝 Переводим атрибут ${attr}="${value}" -> "${translatedValue}"`)
|
||||
element.setAttribute(attr, translatedValue)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Специальная обработка элементов с текстом (кнопки, ссылки, лейблы, заголовки и т.д.)
|
||||
const textElements = [
|
||||
'BUTTON',
|
||||
'A',
|
||||
'LABEL',
|
||||
'SPAN',
|
||||
'DIV',
|
||||
'P',
|
||||
'H1',
|
||||
'H2',
|
||||
'H3',
|
||||
'H4',
|
||||
'H5',
|
||||
'H6',
|
||||
'TD',
|
||||
'TH'
|
||||
]
|
||||
if (textElements.includes(element.tagName)) {
|
||||
// Ищем прямые текстовые узлы внутри элемента
|
||||
const directTextNodes = Array.from(element.childNodes).filter(
|
||||
(child) => child.nodeType === Node.TEXT_NODE && child.textContent?.trim()
|
||||
)
|
||||
|
||||
// Если есть прямые текстовые узлы, обрабатываем их
|
||||
directTextNodes.forEach((textNode) => {
|
||||
const text = textNode.textContent || ''
|
||||
if (CYRILLIC_REGEX.test(text)) {
|
||||
const currentLang = props.language()
|
||||
const translatedText = translateString(text, currentLang)
|
||||
if (translatedText !== text) {
|
||||
console.log(`📝 Переводим "${text}" -> "${translatedText}" (${element.tagName})`)
|
||||
textNode.textContent = translatedText
|
||||
translationCache.set(textNode, text)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Дополнительная проверка для кнопок с вложенными элементами
|
||||
if (element.tagName === 'BUTTON' && directTextNodes.length === 0) {
|
||||
// Если у кнопки нет прямых текстовых узлов, но есть вложенные элементы
|
||||
const buttonText = element.textContent?.trim()
|
||||
if (buttonText && CYRILLIC_REGEX.test(buttonText)) {
|
||||
const valueAttr = element.getAttribute('value')
|
||||
if (valueAttr && CYRILLIC_REGEX.test(valueAttr)) {
|
||||
const currentLang = props.language()
|
||||
const translatedValue = translateString(valueAttr, currentLang)
|
||||
if (translatedValue !== valueAttr) {
|
||||
console.log(`📝 Переводим value="${valueAttr}" -> "${translatedValue}"`)
|
||||
element.setAttribute('value', translatedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Рекурсивно обрабатываем дочерние узлы
|
||||
Array.from(node.childNodes).forEach(updateTextContent)
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для обновления всего контейнера
|
||||
const updateAll = () => {
|
||||
if (containerRef) {
|
||||
updateTextContent(containerRef)
|
||||
}
|
||||
}
|
||||
|
||||
// Настройка MutationObserver для отслеживания новых элементов
|
||||
const setupObserver = () => {
|
||||
if (!containerRef) return
|
||||
|
||||
observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'childList') {
|
||||
mutation.addedNodes.forEach(updateTextContent)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
observer.observe(containerRef, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
})
|
||||
}
|
||||
|
||||
// Реагируем на изменения языка
|
||||
createEffect(() => {
|
||||
const currentLang = props.language()
|
||||
console.log('🌐 Язык изменился на:', currentLang)
|
||||
updateAll() // обновляем все тексты при изменении языка
|
||||
})
|
||||
|
||||
// Инициализация при монтировании
|
||||
onMount(() => {
|
||||
if (containerRef) {
|
||||
updateAll()
|
||||
setupObserver()
|
||||
}
|
||||
})
|
||||
|
||||
// Очистка
|
||||
onCleanup(() => {
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ display: 'contents' }}>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Провайдер интернационализации с автоматическим переводом
|
||||
*/
|
||||
export const I18nProvider: ParentComponent = (props) => {
|
||||
const [language, setLanguage] = createSignal<Language>('ru')
|
||||
|
||||
/**
|
||||
* Функция перевода по ключу
|
||||
*/
|
||||
const t = (key: string): string => {
|
||||
const currentLang = language()
|
||||
if (currentLang === 'ru') {
|
||||
return key
|
||||
}
|
||||
|
||||
const translation = strings[key as keyof typeof strings]
|
||||
return translation || key
|
||||
}
|
||||
|
||||
/**
|
||||
* Реактивная функция перевода - использует текущий язык
|
||||
*/
|
||||
const tr = (text: string): string => {
|
||||
const currentLang = language()
|
||||
if (currentLang === 'ru' || !CYRILLIC_REGEX.test(text)) {
|
||||
return text
|
||||
}
|
||||
const translation = strings[text as keyof typeof strings]
|
||||
return translation || text
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка, русский ли язык
|
||||
*/
|
||||
const isRussian = () => language() === 'ru'
|
||||
|
||||
/**
|
||||
* Загружаем язык из localStorage при инициализации
|
||||
*/
|
||||
onMount(() => {
|
||||
const savedLanguage = localStorage.getItem(STORAGE_KEY) as Language
|
||||
if (savedLanguage && (savedLanguage === 'ru' || savedLanguage === 'en')) {
|
||||
setLanguage(savedLanguage)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Сохраняем язык в localStorage при изменении и перезагружаем страницу
|
||||
*/
|
||||
const handleLanguageChange = (lang: Language) => {
|
||||
// Сохраняем новый язык
|
||||
localStorage.setItem(STORAGE_KEY, lang)
|
||||
|
||||
// Если язык действительно изменился
|
||||
if (language() !== lang) {
|
||||
console.log(`🔄 Перезагрузка страницы после смены языка с ${language()} на ${lang}`)
|
||||
|
||||
// Устанавливаем сигнал (хотя это не обязательно при перезагрузке)
|
||||
setLanguage(lang)
|
||||
|
||||
// Перезагружаем страницу для корректного обновления всех DOM элементов
|
||||
window.location.reload()
|
||||
} else {
|
||||
// Если язык не изменился, просто обновляем сигнал
|
||||
setLanguage(lang)
|
||||
}
|
||||
}
|
||||
|
||||
const contextValue: I18nContextType = {
|
||||
language,
|
||||
setLanguage: handleLanguageChange,
|
||||
t,
|
||||
tr,
|
||||
isRussian
|
||||
}
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={contextValue}>
|
||||
<AutoTranslator language={language}>{props.children}</AutoTranslator>
|
||||
</I18nContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для использования контекста интернационализации
|
||||
*/
|
||||
export const useI18n = (): I18nContextType => {
|
||||
const context = useContext(I18nContext)
|
||||
if (!context) {
|
||||
throw new Error('useI18n must be used within I18nProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для получения функции перевода
|
||||
*/
|
||||
export const useTranslation = () => {
|
||||
const { t, tr, language, isRussian } = useI18n()
|
||||
return { t, tr, language: language(), isRussian: isRussian() }
|
||||
}
|
||||
237
panel/intl/strings.json
Normal file
237
panel/intl/strings.json
Normal file
@@ -0,0 +1,237 @@
|
||||
{
|
||||
"Выйти": "Logout",
|
||||
"Авторы": "Authors",
|
||||
"Публикации": "Publications",
|
||||
"Темы": "Topics",
|
||||
"Сообщества": "Communities",
|
||||
"Коллекции": "Collections",
|
||||
"Приглашения": "Invites",
|
||||
"Переменные среды": "Environment Variables",
|
||||
"Ошибка при выходе": "Logout error",
|
||||
|
||||
"Вход": "Login",
|
||||
"Имя пользователя": "Username",
|
||||
"Пароль": "Password",
|
||||
"Войти": "Login",
|
||||
"Вход...": "Logging in...",
|
||||
"Ошибка при входе": "Login error",
|
||||
"Неверные учетные данные": "Invalid credentials",
|
||||
|
||||
"ID": "ID",
|
||||
"Email": "Email",
|
||||
"Имя": "Name",
|
||||
"Создан": "Created",
|
||||
"Создано": "Created",
|
||||
"Роли": "Roles",
|
||||
"Загрузка данных...": "Loading data...",
|
||||
"Нет данных для отображения": "No data to display",
|
||||
"Данные пользователя успешно обновлены": "User data successfully updated",
|
||||
"Ошибка обновления данных пользователя": "Error updating user data",
|
||||
|
||||
"Заголовок": "Title",
|
||||
"Слаг": "Slug",
|
||||
"Статус": "Status",
|
||||
"Содержимое": "Content",
|
||||
"Опубликовано": "Published",
|
||||
"Действия": "Actions",
|
||||
"Загрузка публикаций...": "Loading publications...",
|
||||
"Нет публикаций для отображения": "No publications to display",
|
||||
"Содержимое публикации": "Publication content",
|
||||
"Введите содержимое публикации...": "Enter publication content...",
|
||||
"Содержимое публикации обновлено": "Publication content updated",
|
||||
"Удалена": "Deleted",
|
||||
"Опубликована": "Published",
|
||||
"Черновик": "Draft",
|
||||
|
||||
"Название": "Title",
|
||||
"Описание": "Description",
|
||||
"Создатель": "Creator",
|
||||
"Подписчики": "Subscribers",
|
||||
"Сообщество": "Community",
|
||||
"Все сообщества": "All communities",
|
||||
"Родители": "Parents",
|
||||
"Сортировка:": "Sorting:",
|
||||
"По названию": "By title",
|
||||
"Загрузка топиков...": "Loading topics...",
|
||||
"Все": "All",
|
||||
"Действие": "Action",
|
||||
"Удалить": "Delete",
|
||||
"Слить": "Merge",
|
||||
"Выбрать все": "Select all",
|
||||
"Подтверждение удаления": "Delete confirmation",
|
||||
"Топик успешно обновлен": "Topic successfully updated",
|
||||
"Ошибка обновления топика": "Error updating topic",
|
||||
"Топик успешно создан": "Topic successfully created",
|
||||
"Выберите действие и топики": "Select action and topics",
|
||||
"Топик успешно удален": "Topic successfully deleted",
|
||||
"Ошибка удаления топика": "Error deleting topic",
|
||||
"Выберите одну тему для назначения родителя": "Select one topic to assign parent",
|
||||
|
||||
"Загрузка сообществ...": "Loading communities...",
|
||||
"Сообщество успешно создано": "Community successfully created",
|
||||
"Сообщество успешно обновлено": "Community successfully updated",
|
||||
"Ошибка создания": "Creation error",
|
||||
"Ошибка обновления": "Update error",
|
||||
"Сообщество успешно удалено": "Community successfully deleted",
|
||||
"Удалить сообщество": "Delete community",
|
||||
|
||||
"Загрузка коллекций...": "Loading collections...",
|
||||
"Коллекция успешно создана": "Collection successfully created",
|
||||
"Коллекция успешно обновлена": "Collection successfully updated",
|
||||
"Коллекция успешно удалена": "Collection successfully deleted",
|
||||
"Удалить коллекцию": "Delete collection",
|
||||
|
||||
"Поиск по приглашающему, приглашаемому, публикации...": "Search by inviter, invitee, publication...",
|
||||
"Все статусы": "All statuses",
|
||||
"Ожидает ответа": "Pending",
|
||||
"Принято": "Accepted",
|
||||
"Отклонено": "Rejected",
|
||||
"Загрузка приглашений...": "Loading invites...",
|
||||
"Приглашения не найдены": "No invites found",
|
||||
"Удалить выбранные приглашения": "Delete selected invites",
|
||||
"Ожидает": "Pending",
|
||||
"Удалить приглашение": "Delete invite",
|
||||
"Приглашение успешно удалено": "Invite successfully deleted",
|
||||
"Не выбрано ни одного приглашения для удаления": "No invites selected for deletion",
|
||||
"Подтверждение пакетного удаления": "Bulk delete confirmation",
|
||||
"Без имени": "No name",
|
||||
|
||||
"Загрузка переменных окружения...": "Loading environment variables...",
|
||||
"Переменные окружения не найдены": "No environment variables found",
|
||||
"Как добавить переменные?": "How to add variables?",
|
||||
"Ключ": "Key",
|
||||
"Значение": "Value",
|
||||
"не задано": "not set",
|
||||
"Скопировать": "Copy",
|
||||
"Скрыть": "Hide",
|
||||
"Показать": "Show",
|
||||
"Не удалось обновить переменную": "Failed to update variable",
|
||||
"Ошибка при обновлении переменной": "Error updating variable",
|
||||
|
||||
"Загрузка...": "Loading...",
|
||||
"Загрузка тем...": "Loading topics...",
|
||||
"Обновить": "Refresh",
|
||||
"Отмена": "Cancel",
|
||||
"Сохранить": "Save",
|
||||
"Создать": "Create",
|
||||
"Создать сообщество": "Create community",
|
||||
"Редактировать": "Edit",
|
||||
"Поиск": "Search",
|
||||
"Поиск...": "Search...",
|
||||
|
||||
"Управление иерархией тем": "Topic Hierarchy Management",
|
||||
"Инструкции:": "Instructions:",
|
||||
"🔍 Найдите тему по названию или прокрутите список": "🔍 Find topic by title or scroll through list",
|
||||
"# Нажмите на тему, чтобы выбрать её для перемещения (синяя рамка)": "# Click on topic to select it for moving (blue border)",
|
||||
"📂 Нажмите на другую тему, чтобы сделать её родителем (зеленая рамка)": "📂 Click on another topic to make it parent (green border)",
|
||||
"🏠 Используйте кнопку \"Сделать корневой\" для перемещения на верхний уровень": "🏠 Use \"Make root\" button to move to top level",
|
||||
"▶/▼ Раскрывайте/сворачивайте ветки дерева": "▶/▼ Expand/collapse tree branches",
|
||||
"Поиск темы:": "Search topic:",
|
||||
"Введите название темы для поиска...": "Enter topic title to search...",
|
||||
"✅ Найдена тема:": "✅ Found topic:",
|
||||
"❌ Тема не найдена": "❌ Topic not found",
|
||||
"Планируемые изменения": "Planned changes",
|
||||
"станет корневой темой": "will become root topic",
|
||||
"переместится под тему": "will move under topic",
|
||||
"Выбрана для перемещения:": "Selected for moving:",
|
||||
"🏠 Сделать корневой темой": "🏠 Make root topic",
|
||||
"❌ Отменить выбор": "❌ Cancel selection",
|
||||
"Сохранить изменения": "Save changes",
|
||||
"Выбрана тема": "Selected topic",
|
||||
"для перемещения. Теперь нажмите на новую родительскую тему или используйте \"Сделать корневой\".": "for moving. Now click on new parent topic or use \"Make root\".",
|
||||
"Нельзя переместить тему в своего потомка": "Cannot move topic to its descendant",
|
||||
"Нет изменений для сохранения": "No changes to save",
|
||||
|
||||
"Назначить родительскую тему": "Assign parent topic",
|
||||
"Редактируемая тема:": "Editing topic:",
|
||||
"Текущее расположение:": "Current location:",
|
||||
"Поиск новой родительской темы:": "Search for new parent topic:",
|
||||
"Введите название темы...": "Enter topic title...",
|
||||
"Выберите новую родительскую тему:": "Select new parent topic:",
|
||||
"Путь:": "Path:",
|
||||
"Предварительный просмотр:": "Preview:",
|
||||
"Новое расположение:": "New location:",
|
||||
"Не найдено подходящих тем по запросу": "No matching topics found for query",
|
||||
"Нет доступных родительских тем": "No available parent topics",
|
||||
"Назначение...": "Assigning...",
|
||||
"Назначить родителя": "Assign parent",
|
||||
"Неизвестная тема": "Unknown topic",
|
||||
|
||||
"Создать тему": "Create topic",
|
||||
"Слияние тем": "Topic merge",
|
||||
"Выбор целевой темы": "Target topic selection",
|
||||
"Выберите целевую тему": "Select target topic",
|
||||
"Выбор исходных тем для слияния": "Source topics selection for merge",
|
||||
"Настройки слияния": "Merge settings",
|
||||
"Сохранить свойства целевой темы": "Keep target topic properties",
|
||||
"Предпросмотр слияния:": "Merge preview:",
|
||||
"Целевая тема:": "Target topic:",
|
||||
"Исходные темы:": "Source topics:",
|
||||
"шт.": "pcs.",
|
||||
"Действие:": "Action:",
|
||||
"Все подписчики, публикации и черновики будут перенесены в целевую": "All subscribers, publications and drafts will be moved to target",
|
||||
"Выполняется слияние...": "Merging...",
|
||||
"Слить темы": "Merge topics",
|
||||
"Невозможно выполнить слияние с текущими настройками": "Cannot perform merge with current settings",
|
||||
|
||||
"Автор:": "Author:",
|
||||
"Просмотры:": "Views:",
|
||||
"Содержание": "Content",
|
||||
|
||||
"PENDING": "PENDING",
|
||||
"ACCEPTED": "ACCEPTED",
|
||||
"REJECTED": "REJECTED",
|
||||
"Текущий статус приглашения": "Current invite status",
|
||||
"Информация о приглашении": "Invite information",
|
||||
"Приглашающий:": "Inviter:",
|
||||
"Приглашаемый:": "Invitee:",
|
||||
"Публикация:": "Publication:",
|
||||
"Приглашающий и приглашаемый не могут быть одним и тем же автором": "Inviter and invitee cannot be the same author",
|
||||
"Создание нового приглашения": "Creating new invite",
|
||||
|
||||
"уникальный-идентификатор": "unique-identifier",
|
||||
"Название коллекции": "Collection title",
|
||||
"Описание коллекции...": "Collection description...",
|
||||
"Название сообщества": "Community title",
|
||||
"Описание сообщества...": "Community description...",
|
||||
"Создать коллекцию": "Create collection",
|
||||
|
||||
"body": "Body",
|
||||
"Описание топика": "Topic body",
|
||||
"Введите содержимое топика...": "Enter topic content...",
|
||||
"Содержимое топика обновлено": "Topic content updated",
|
||||
|
||||
"Выберите действие:": "Select action:",
|
||||
"Установить нового родителя": "Set new parent",
|
||||
"Выбор родительской темы:": "Parent topic selection:",
|
||||
"Поиск родительской темы...": "Search parent topic...",
|
||||
|
||||
"Иван Иванов": "Ivan Ivanov",
|
||||
"Системная информация": "System information",
|
||||
"Дата регистрации:": "Registration date:",
|
||||
"Последняя активность:": "Last activity:",
|
||||
"Основные данные": "Basic data",
|
||||
|
||||
"Введите значение переменной...": "Enter variable value...",
|
||||
"Скрыть превью": "Hide preview",
|
||||
"Показать превью": "Show preview",
|
||||
|
||||
"Нажмите для редактирования...": "Click to edit...",
|
||||
|
||||
"Поиск по email, имени или ID...": "Search by email, name or ID...",
|
||||
"Поиск по заголовку, slug или ID...": "Search by title, slug or ID...",
|
||||
"Введите HTML описание топика...": "Enter HTML topic description...",
|
||||
"https://example.com/image.jpg": "https://example.com/image.jpg",
|
||||
"1, 5, 12": "1, 5, 12",
|
||||
"user@example.com": "user@example.com",
|
||||
"1": "1",
|
||||
"2": "2",
|
||||
"123": "123",
|
||||
"Введите содержимое media.body...": "Enter media.body content...",
|
||||
"Поиск по названию, slug или ID...": "Search by title, slug or ID...",
|
||||
"Дискурс": "Discours",
|
||||
|
||||
"Родительские топики отображаются вверху списка синим цветом. Кликните по топику чтобы добавить или убрать из выбранных.": "Parent topics are displayed at the top of the list in blue color. Click on the topic to add or remove from selected.",
|
||||
"Выбрано:": "Selected:",
|
||||
"Поиск по топикам...": "Search by topics..."
|
||||
}
|
||||
213
panel/modals/CollectionEditModal.tsx
Normal file
213
panel/modals/CollectionEditModal.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { Component, createEffect, createSignal } from 'solid-js'
|
||||
import formStyles from '../styles/Form.module.css'
|
||||
import styles from '../styles/Modal.module.css'
|
||||
import Button from '../ui/Button'
|
||||
import HTMLEditor from '../ui/HTMLEditor'
|
||||
import Modal from '../ui/Modal'
|
||||
|
||||
interface Collection {
|
||||
id: number
|
||||
slug: string
|
||||
title: string
|
||||
desc?: string
|
||||
pic?: string
|
||||
amount?: number
|
||||
published_at?: number
|
||||
created_at: number
|
||||
created_by: {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
}
|
||||
|
||||
interface CollectionEditModalProps {
|
||||
isOpen: boolean
|
||||
collection: Collection | null // null для создания новой
|
||||
onClose: () => void
|
||||
onSave: (collection: Partial<Collection>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Модальное окно для создания и редактирования коллекций
|
||||
*/
|
||||
const CollectionEditModal: Component<CollectionEditModalProps> = (props) => {
|
||||
const [formData, setFormData] = createSignal({
|
||||
slug: '',
|
||||
title: '',
|
||||
desc: '',
|
||||
pic: ''
|
||||
})
|
||||
const [errors, setErrors] = createSignal<Record<string, string>>({})
|
||||
|
||||
// Синхронизация с props.collection
|
||||
createEffect(() => {
|
||||
if (props.isOpen) {
|
||||
if (props.collection) {
|
||||
// Редактирование существующей коллекции
|
||||
setFormData({
|
||||
slug: props.collection.slug,
|
||||
title: props.collection.title,
|
||||
desc: props.collection.desc || '',
|
||||
pic: props.collection.pic || ''
|
||||
})
|
||||
} else {
|
||||
// Создание новой коллекции
|
||||
setFormData({
|
||||
slug: '',
|
||||
title: '',
|
||||
desc: '',
|
||||
pic: ''
|
||||
})
|
||||
}
|
||||
setErrors({})
|
||||
}
|
||||
})
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
const data = formData()
|
||||
|
||||
// Валидация slug
|
||||
if (!data.slug.trim()) {
|
||||
newErrors.slug = 'Slug обязателен'
|
||||
} else if (!/^[a-z0-9-_]+$/.test(data.slug)) {
|
||||
newErrors.slug = 'Slug может содержать только латинские буквы, цифры, дефисы и подчеркивания'
|
||||
}
|
||||
|
||||
// Валидация названия
|
||||
if (!data.title.trim()) {
|
||||
newErrors.title = 'Название обязательно'
|
||||
}
|
||||
|
||||
// Валидация URL картинки (если указан)
|
||||
if (data.pic.trim() && !/^https?:\/\/.+/.test(data.pic)) {
|
||||
newErrors.pic = 'Некорректный URL картинки'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const updateField = (field: string, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
// Очищаем ошибку для поля при изменении
|
||||
setErrors((prev) => ({ ...prev, [field]: '' }))
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
const collectionData = { ...formData() }
|
||||
props.onSave(collectionData)
|
||||
}
|
||||
|
||||
const isCreating = () => props.collection === null
|
||||
const modalTitle = () =>
|
||||
isCreating() ? 'Создание новой коллекции' : `Редактирование коллекции: ${props.collection?.title || ''}`
|
||||
|
||||
return (
|
||||
<Modal isOpen={props.isOpen} onClose={props.onClose} title={modalTitle()} size="medium">
|
||||
<div class={styles.modalContent}>
|
||||
<div class={formStyles.form}>
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>📝</span>
|
||||
Название
|
||||
<span class={formStyles.required}>*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class={`${formStyles.input} ${errors().title ? formStyles.error : ''}`}
|
||||
value={formData().title}
|
||||
onInput={(e) => updateField('title', e.target.value)}
|
||||
placeholder="Введите название коллекции"
|
||||
required
|
||||
/>
|
||||
{errors().title && (
|
||||
<div class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>🔗</span>
|
||||
Slug
|
||||
<span class={formStyles.required}>*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class={`${formStyles.input} ${errors().slug ? formStyles.error : ''}`}
|
||||
value={formData().slug}
|
||||
onInput={(e) => updateField('slug', e.target.value)}
|
||||
placeholder="collection-slug"
|
||||
required
|
||||
/>
|
||||
{errors().slug && (
|
||||
<div class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().slug}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>📄</span>
|
||||
Описание
|
||||
</span>
|
||||
</label>
|
||||
<HTMLEditor value={formData().desc} onInput={(value) => updateField('desc', value)} />
|
||||
</div>
|
||||
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>🖼️</span>
|
||||
URL картинки
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
class={`${formStyles.input} ${errors().pic ? formStyles.error : ''}`}
|
||||
value={formData().pic}
|
||||
onInput={(e) => updateField('pic', e.target.value)}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
{errors().pic && (
|
||||
<div class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().pic}
|
||||
</div>
|
||||
)}
|
||||
<div class={formStyles.hint}>
|
||||
<span class={formStyles.hintIcon}>💡</span>
|
||||
Необязательно. URL изображения для обложки коллекции.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.modalActions}>
|
||||
<Button variant="secondary" onClick={props.onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
{isCreating() ? 'Создать' : 'Сохранить'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default CollectionEditModal
|
||||
343
panel/modals/CommunityEditModal.tsx
Normal file
343
panel/modals/CommunityEditModal.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
import { createEffect, createSignal, Show } from 'solid-js'
|
||||
import { useData } from '../context/data'
|
||||
import type { Role } from '../graphql/generated/schema'
|
||||
import {
|
||||
GET_COMMUNITY_ROLE_SETTINGS_QUERY,
|
||||
GET_COMMUNITY_ROLES_QUERY,
|
||||
UPDATE_COMMUNITY_ROLE_SETTINGS_MUTATION
|
||||
} from '../graphql/queries'
|
||||
import formStyles from '../styles/Form.module.css'
|
||||
import styles from '../styles/Modal.module.css'
|
||||
import Button from '../ui/Button'
|
||||
import HTMLEditor from '../ui/HTMLEditor'
|
||||
import Modal from '../ui/Modal'
|
||||
import RoleManager from '../ui/RoleManager'
|
||||
|
||||
interface Community {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
desc?: string
|
||||
pic?: string
|
||||
}
|
||||
|
||||
interface CommunityEditModalProps {
|
||||
isOpen: boolean
|
||||
community: Community | null
|
||||
onClose: () => void
|
||||
onSave: (communityData: Partial<Community>) => Promise<void>
|
||||
}
|
||||
|
||||
interface RoleSettings {
|
||||
default_roles: string[]
|
||||
available_roles: string[]
|
||||
}
|
||||
|
||||
interface CustomRole {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const STANDARD_ROLES = [
|
||||
{ id: 'reader', name: 'Читатель', description: 'Может читать и комментировать', icon: '👁️' },
|
||||
{ id: 'author', name: 'Автор', description: 'Может создавать публикации', icon: '✍️' },
|
||||
{ id: 'artist', name: 'Художник', description: 'Может быть credited artist', icon: '🎨' },
|
||||
{ id: 'expert', name: 'Эксперт', description: 'Может добавлять доказательства', icon: '🧠' },
|
||||
{ id: 'editor', name: 'Редактор', description: 'Может модерировать контент', icon: '📝' },
|
||||
{ id: 'admin', name: 'Администратор', description: 'Полные права', icon: '👑' }
|
||||
]
|
||||
|
||||
const CommunityEditModal = (props: CommunityEditModalProps) => {
|
||||
const { queryGraphQL } = useData()
|
||||
const [formData, setFormData] = createSignal<Partial<Community>>({})
|
||||
const [roleSettings, setRoleSettings] = createSignal<RoleSettings>({
|
||||
default_roles: ['reader'],
|
||||
available_roles: ['reader', 'author', 'artist', 'expert', 'editor', 'admin']
|
||||
})
|
||||
const [customRoles, setCustomRoles] = createSignal<CustomRole[]>([])
|
||||
const [errors, setErrors] = createSignal<Record<string, string>>({})
|
||||
const [activeTab, setActiveTab] = createSignal<'basic' | 'roles'>('basic')
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
|
||||
// Инициализация формы при открытии
|
||||
createEffect(() => {
|
||||
if (props.isOpen) {
|
||||
if (props.community) {
|
||||
setFormData({
|
||||
name: props.community.name || '',
|
||||
slug: props.community.slug || '',
|
||||
desc: props.community.desc || '',
|
||||
pic: props.community.pic || ''
|
||||
})
|
||||
void loadRoleSettings()
|
||||
} else {
|
||||
setFormData({ name: '', slug: '', desc: '', pic: '' })
|
||||
setRoleSettings({
|
||||
default_roles: ['reader'],
|
||||
available_roles: ['reader', 'author', 'artist', 'expert', 'editor', 'admin']
|
||||
})
|
||||
}
|
||||
setErrors({})
|
||||
setActiveTab('basic')
|
||||
setCustomRoles([])
|
||||
}
|
||||
})
|
||||
|
||||
const loadRoleSettings = async () => {
|
||||
if (!props.community?.id) return
|
||||
|
||||
try {
|
||||
const data = await queryGraphQL(GET_COMMUNITY_ROLE_SETTINGS_QUERY, {
|
||||
community_id: props.community.id
|
||||
})
|
||||
|
||||
if (data?.adminGetCommunityRoleSettings && !data.adminGetCommunityRoleSettings.error) {
|
||||
setRoleSettings({
|
||||
default_roles: data.adminGetCommunityRoleSettings.default_roles,
|
||||
available_roles: data.adminGetCommunityRoleSettings.available_roles
|
||||
})
|
||||
}
|
||||
|
||||
// Загружаем все роли сообщества для получения произвольных
|
||||
const rolesData = await queryGraphQL(GET_COMMUNITY_ROLES_QUERY, {
|
||||
community: props.community.id
|
||||
})
|
||||
|
||||
if (rolesData?.adminGetRoles) {
|
||||
// Фильтруем только произвольные роли (не стандартные)
|
||||
const standardRoleIds = STANDARD_ROLES.map((r) => r.id)
|
||||
const customRolesList = rolesData.adminGetRoles
|
||||
.filter((role: Role) => !standardRoleIds.includes(role.id))
|
||||
.map((role: Role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
description: role.description || '',
|
||||
icon: '🔖' // Пока иконки не хранятся в БД
|
||||
}))
|
||||
|
||||
setCustomRoles(customRolesList)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки настроек ролей:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
const data = formData()
|
||||
|
||||
if (!data.name?.trim()) {
|
||||
newErrors.name = 'Название обязательно'
|
||||
}
|
||||
|
||||
if (!data.slug?.trim()) {
|
||||
newErrors.slug = 'Слаг обязательный'
|
||||
} else if (!/^[a-z0-9-]+$/.test(data.slug)) {
|
||||
newErrors.slug = 'Слаг может содержать только латинские буквы, цифры и дефисы'
|
||||
}
|
||||
|
||||
// Валидация ролей
|
||||
const roleSet = roleSettings()
|
||||
if (roleSet.default_roles.length === 0) {
|
||||
newErrors.roles = 'Должна быть хотя бы одна дефолтная роль'
|
||||
}
|
||||
|
||||
const invalidDefaults = roleSet.default_roles.filter((role) => !roleSet.available_roles.includes(role))
|
||||
if (invalidDefaults.length > 0) {
|
||||
newErrors.roles = 'Дефолтные роли должны быть из списка доступных'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const updateField = (field: string, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
setErrors((prev) => ({ ...prev, [field]: '' }))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
// Сохраняем основные данные сообщества
|
||||
await props.onSave(formData())
|
||||
|
||||
// Если редактируем существующее сообщество, сохраняем настройки ролей
|
||||
if (props.community?.id) {
|
||||
const roleData = await queryGraphQL(UPDATE_COMMUNITY_ROLE_SETTINGS_MUTATION, {
|
||||
community_id: props.community.id,
|
||||
default_roles: roleSettings().default_roles,
|
||||
available_roles: roleSettings().available_roles
|
||||
})
|
||||
|
||||
if (!roleData?.adminUpdateCommunityRoleSettings?.success) {
|
||||
console.error(
|
||||
'Ошибка сохранения настроек ролей:',
|
||||
roleData?.adminUpdateCommunityRoleSettings?.error
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка сохранения:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isCreating = () => props.community === null
|
||||
const modalTitle = () =>
|
||||
isCreating()
|
||||
? 'Создание нового сообщества'
|
||||
: `Редактирование сообщества: ${props.community?.name || ''}`
|
||||
|
||||
return (
|
||||
<Modal isOpen={props.isOpen} onClose={props.onClose} title={modalTitle()} size="large">
|
||||
<div class={styles.content}>
|
||||
{/* Табы */}
|
||||
<div class={formStyles.tabs}>
|
||||
<button
|
||||
type="button"
|
||||
class={`${formStyles.tab} ${activeTab() === 'basic' ? formStyles.active : ''}`}
|
||||
onClick={() => setActiveTab('basic')}
|
||||
>
|
||||
<span class={formStyles.tabIcon}>⚙️</span>
|
||||
Основные настройки
|
||||
</button>
|
||||
<Show when={!isCreating()}>
|
||||
<button
|
||||
type="button"
|
||||
class={`${formStyles.tab} ${activeTab() === 'roles' ? formStyles.active : ''}`}
|
||||
onClick={() => setActiveTab('roles')}
|
||||
>
|
||||
<span class={formStyles.tabIcon}>👥</span>
|
||||
Роли и права
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Контент табов */}
|
||||
<div class={formStyles.content}>
|
||||
<Show when={activeTab() === 'basic'}>
|
||||
<div class={formStyles.form}>
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>🏷️</span>
|
||||
Название сообщества
|
||||
<span class={formStyles.required}>*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class={`${formStyles.input} ${errors().name ? formStyles.error : ''}`}
|
||||
value={formData().name || ''}
|
||||
onInput={(e) => updateField('name', e.currentTarget.value)}
|
||||
placeholder="Введите название сообщества"
|
||||
/>
|
||||
<Show when={errors().name}>
|
||||
<span class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().name}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>🔗</span>
|
||||
Слаг
|
||||
<span class={formStyles.required}>*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class={`${formStyles.input} ${errors().slug ? formStyles.error : ''} ${!isCreating() ? formStyles.disabled : ''}`}
|
||||
value={formData().slug || ''}
|
||||
onInput={(e) => updateField('slug', e.currentTarget.value)}
|
||||
placeholder="community-slug"
|
||||
disabled={!isCreating()}
|
||||
/>
|
||||
<Show when={errors().slug}>
|
||||
<span class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().slug}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={!isCreating()}>
|
||||
<span class={formStyles.hint}>
|
||||
<span class={formStyles.hintIcon}>💡</span>
|
||||
Слаг нельзя изменить после создания
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>📝</span>
|
||||
Описание
|
||||
</span>
|
||||
</label>
|
||||
<HTMLEditor value={formData().desc || ''} onInput={(value) => updateField('desc', value)} />
|
||||
</div>
|
||||
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>🖼️</span>
|
||||
Изображение (URL)
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
class={formStyles.input}
|
||||
value={formData().pic || ''}
|
||||
onInput={(e) => updateField('pic', e.currentTarget.value)}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === 'roles' && !isCreating()}>
|
||||
<RoleManager
|
||||
communityId={props.community?.id}
|
||||
roleSettings={roleSettings()}
|
||||
onRoleSettingsChange={setRoleSettings}
|
||||
customRoles={customRoles()}
|
||||
onCustomRolesChange={setCustomRoles}
|
||||
/>
|
||||
|
||||
<Show when={errors().roles}>
|
||||
<span class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().roles}
|
||||
</span>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={styles.footer}>
|
||||
<Button variant="secondary" onClick={props.onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave} disabled={loading()}>
|
||||
<Show when={loading()}>
|
||||
<span class={formStyles.spinner} />
|
||||
</Show>
|
||||
{loading() ? 'Сохранение...' : isCreating() ? 'Создать' : 'Сохранить'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommunityEditModal
|
||||
182
panel/modals/CommunityRolesModal.tsx
Normal file
182
panel/modals/CommunityRolesModal.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { Component, createEffect, createSignal, For, Show } from 'solid-js'
|
||||
import { useData } from '../context/data'
|
||||
import formStyles from '../styles/Form.module.css'
|
||||
import styles from '../styles/Modal.module.css'
|
||||
import Button from '../ui/Button'
|
||||
import Modal from '../ui/Modal'
|
||||
|
||||
interface Author {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
interface Community {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
interface Role {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface CommunityRolesModalProps {
|
||||
isOpen: boolean
|
||||
author: Author | null
|
||||
community: Community | null
|
||||
onClose: () => void
|
||||
onSave: (authorId: number, communityId: number, roles: string[]) => Promise<void>
|
||||
}
|
||||
|
||||
const CommunityRolesModal: Component<CommunityRolesModalProps> = (props) => {
|
||||
const { queryGraphQL } = useData()
|
||||
const [roles, setRoles] = createSignal<Role[]>([])
|
||||
const [userRoles, setUserRoles] = createSignal<string[]>([])
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
|
||||
// Загружаем доступные роли при открытии модала
|
||||
createEffect(() => {
|
||||
if (props.isOpen && props.community) {
|
||||
void loadRolesData()
|
||||
}
|
||||
})
|
||||
|
||||
const loadRolesData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Получаем доступные роли
|
||||
const rolesData = await queryGraphQL(
|
||||
`
|
||||
query GetRoles($community: Int) {
|
||||
adminGetRoles(community: $community) {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ community: props.community?.id }
|
||||
)
|
||||
|
||||
if (rolesData?.adminGetRoles) {
|
||||
setRoles(rolesData.adminGetRoles)
|
||||
}
|
||||
|
||||
// Получаем текущие роли пользователя
|
||||
if (props.author) {
|
||||
const membersData = await queryGraphQL(
|
||||
`
|
||||
query GetCommunityMembers($community_id: Int!) {
|
||||
adminGetCommunityMembers(community_id: $community_id, limit: 1000) {
|
||||
members {
|
||||
id
|
||||
roles
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ community_id: props.community?.id }
|
||||
)
|
||||
|
||||
const members = membersData?.adminGetCommunityMembers?.members || []
|
||||
const currentUser = members.find((m: { id: number }) => m.id === props.author?.id)
|
||||
setUserRoles(currentUser?.roles || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки ролей:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRoleToggle = (roleId: string) => {
|
||||
const currentRoles = userRoles()
|
||||
if (currentRoles.includes(roleId)) {
|
||||
setUserRoles(currentRoles.filter((r) => r !== roleId))
|
||||
} else {
|
||||
setUserRoles([...currentRoles, roleId])
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!props.author || !props.community) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await props.onSave(props.author.id, props.community.id, userRoles())
|
||||
props.onClose()
|
||||
} catch (error) {
|
||||
console.error('Ошибка сохранения ролей:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={props.isOpen}
|
||||
onClose={props.onClose}
|
||||
title={`Роли пользователя: ${props.author?.name || ''}`}
|
||||
>
|
||||
<div class={styles.content}>
|
||||
<Show when={props.community && props.author}>
|
||||
<div class={formStyles.field}>
|
||||
<label class={formStyles.label}>
|
||||
Сообщество: <strong>{props.community?.name}</strong>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class={formStyles.field}>
|
||||
<label class={formStyles.label}>
|
||||
Пользователь: <strong>{props.author?.name}</strong> ({props.author?.email})
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class={formStyles.field}>
|
||||
<label class={formStyles.label}>Роли:</label>
|
||||
<Show when={!loading()} fallback={<div>Загрузка ролей...</div>}>
|
||||
<div class={formStyles.checkboxGroup}>
|
||||
<For each={roles()}>
|
||||
{(role) => (
|
||||
<div class={formStyles.checkboxItem}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`role-${role.id}`}
|
||||
checked={userRoles().includes(role.id)}
|
||||
onChange={() => handleRoleToggle(role.id)}
|
||||
class={formStyles.checkbox}
|
||||
/>
|
||||
<label for={`role-${role.id}`} class={formStyles.checkboxLabel}>
|
||||
<div>
|
||||
<strong>{role.name}</strong>
|
||||
<Show when={role.description}>
|
||||
<div class={formStyles.description}>{role.description}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class={styles.actions}>
|
||||
<Button variant="secondary" onClick={props.onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave} disabled={loading()}>
|
||||
{loading() ? 'Сохранение...' : 'Сохранить'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommunityRolesModal
|
||||
202
panel/modals/EnvVariableModal.tsx
Normal file
202
panel/modals/EnvVariableModal.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { Component, createMemo, createSignal, Show } from 'solid-js'
|
||||
import { query } from '../graphql'
|
||||
import { EnvVariable } from '../graphql/generated/schema'
|
||||
import { ADMIN_UPDATE_ENV_VARIABLE_MUTATION } from '../graphql/mutations'
|
||||
import formStyles from '../styles/Form.module.css'
|
||||
import Button from '../ui/Button'
|
||||
import Modal from '../ui/Modal'
|
||||
import TextPreview from '../ui/TextPreview'
|
||||
|
||||
interface EnvVariableModalProps {
|
||||
isOpen: boolean
|
||||
variable: EnvVariable
|
||||
onClose: () => void
|
||||
onSave: () => void
|
||||
onValueChange?: (value: string) => void // FIXME: no need
|
||||
}
|
||||
|
||||
const EnvVariableModal: Component<EnvVariableModalProps> = (props) => {
|
||||
const [value, setValue] = createSignal(props.variable.value)
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [showFormatted, setShowFormatted] = createSignal(false)
|
||||
|
||||
// Определяем нужно ли использовать textarea
|
||||
const needsTextarea = createMemo(() => {
|
||||
const val = value()
|
||||
return (
|
||||
val.length > 50 ||
|
||||
val.includes('\n') ||
|
||||
props.variable.type === 'json' ||
|
||||
props.variable.key.includes('URL') ||
|
||||
props.variable.key.includes('SECRET')
|
||||
)
|
||||
})
|
||||
|
||||
// Форматируем JSON если возможно
|
||||
const formattedValue = createMemo(() => {
|
||||
if (props.variable.type === 'json' || (value().startsWith('{') && value().endsWith('}'))) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value()), null, 2)
|
||||
} catch {
|
||||
return value()
|
||||
}
|
||||
}
|
||||
return value()
|
||||
})
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await query<{ updateEnvVariable: boolean }>(
|
||||
`${location.origin}/graphql`,
|
||||
ADMIN_UPDATE_ENV_VARIABLE_MUTATION,
|
||||
{
|
||||
key: props.variable.key,
|
||||
value: value()
|
||||
}
|
||||
)
|
||||
|
||||
if (result?.updateEnvVariable) {
|
||||
props.onSave()
|
||||
} else {
|
||||
setError('Failed to update environment variable')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error occurred')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatValue = () => {
|
||||
if (props.variable.type === 'json') {
|
||||
try {
|
||||
const formatted = JSON.stringify(JSON.parse(value()), null, 2)
|
||||
setValue(formatted)
|
||||
} catch (_e) {
|
||||
setError('Invalid JSON format')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={props.isOpen}
|
||||
title={`Редактировать ${props.variable.key}`}
|
||||
onClose={props.onClose}
|
||||
size="large"
|
||||
>
|
||||
<div class={formStyles.modalWide}>
|
||||
<form class={formStyles.form} onSubmit={(e) => e.preventDefault()}>
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>🔑</span>
|
||||
Ключ
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.variable.key}
|
||||
disabled
|
||||
class={`${formStyles.input} ${formStyles.disabled}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>💾</span>
|
||||
Значение
|
||||
<span class={formStyles.labelInfo}>
|
||||
({props.variable.type}
|
||||
{props.variable.isSecret && ', секретное'})
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Show when={needsTextarea()}>
|
||||
<div class={formStyles.textareaContainer}>
|
||||
<textarea
|
||||
value={value()}
|
||||
onInput={(e) => setValue(e.currentTarget.value)}
|
||||
class={formStyles.textarea}
|
||||
rows={Math.min(Math.max(value().split('\n').length + 2, 4), 15)}
|
||||
placeholder="Введите значение переменной..."
|
||||
/>
|
||||
<Show when={props.variable.type === 'json'}>
|
||||
<div class={formStyles.textareaActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={formatValue}
|
||||
title="Форматировать JSON"
|
||||
>
|
||||
🎨 Форматировать
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => setShowFormatted(!showFormatted())}
|
||||
title={showFormatted() ? 'Скрыть превью' : 'Показать превью'}
|
||||
>
|
||||
{showFormatted() ? '👁️ Скрыть' : '👁️ Превью'}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!needsTextarea()}>
|
||||
<input
|
||||
type={props.variable.isSecret ? 'password' : 'text'}
|
||||
value={value()}
|
||||
onInput={(e) => setValue(e.currentTarget.value)}
|
||||
class={formStyles.input}
|
||||
placeholder="Введите значение переменной..."
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={showFormatted() && (props.variable.type === 'json' || value().startsWith('{'))}>
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>👁️</span>
|
||||
Превью (форматированное)
|
||||
</span>
|
||||
</label>
|
||||
<div class={formStyles.codePreview}>
|
||||
<TextPreview content={formattedValue()} />
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.variable.description}>
|
||||
<div class={formStyles.formHelp}>
|
||||
<strong>Описание:</strong> {props.variable.description}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class={formStyles.formError}>{error()}</div>
|
||||
</Show>
|
||||
|
||||
<div class={formStyles.formActions}>
|
||||
<Button variant="secondary" onClick={props.onClose} disabled={saving()}>
|
||||
Отменить
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave} loading={saving()}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default EnvVariableModal
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user