Compare commits

..

No commits in common. "feature/auth-internal" and "dev" have entirely different histories.

222 changed files with 4010 additions and 50883 deletions

View File

@ -1,12 +1,11 @@
name: 'Deploy on push'
on: [push]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Cloning repo
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
fetch-depth: 0
@ -30,16 +29,7 @@ jobs:
if: github.ref == 'refs/heads/dev'
uses: dokku/github-action@master
with:
branch: 'main'
branch: 'dev'
force: true
git_remote_url: 'ssh://dokku@v2.discours.io:22/core'
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Push to dokku for staging branch
if: github.ref == 'refs/heads/staging'
uses: dokku/github-action@master
with:
branch: 'dev'
git_remote_url: 'ssh://dokku@staging.discours.io:22/core'
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
git_push_flags: '--force'

View File

@ -11,7 +11,7 @@ jobs:
steps:
- name: Checkout source repository
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
fetch-depth: 0

5
.gitignore vendored
View File

@ -164,8 +164,3 @@ views.json
.cursor
node_modules/
panel/graphql/generated/
panel/types.gen.ts
.cursorrules
.cursor/

View File

@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v4.5.0
hooks:
- id: check-yaml
- id: check-toml
@ -12,63 +12,7 @@ repos:
- id: check-merge-conflict
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.12
rev: v0.4.7
hooks:
- id: ruff
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"
# ]
args: [--fix]

File diff suppressed because it is too large Load Diff

View File

@ -1,134 +0,0 @@
# Contributing to Discours Core
🎉 Thanks for taking the time to contribute!
## 🚀 Quick Start
1. Fork the repository
2. Create a feature branch: `git checkout -b my-new-feature`
3. Make your changes
4. Add tests for your changes
5. Run the test suite: `pytest`
6. Run the linter: `ruff check . --fix && ruff format . --line-length=120`
7. Commit your changes: `git commit -am 'Add some feature'`
8. Push to the branch: `git push origin my-new-feature`
9. Create a Pull Request
## 📋 Development Guidelines
### Code Style
- **Python 3.12+** required
- **Line length**: 120 characters max
- **Type hints**: Required for all functions
- **Docstrings**: Required for public methods
- **Ruff**: linting and formatting
- **MyPy**: typechecks
### Testing
- **Pytest** for testing
- **85%+ coverage** required
- Test both positive and negative cases
- Mock external dependencies
### Commit Messages
We follow [Conventional Commits](https://conventionalcommits.org/):
```
feat: add user authentication
fix: resolve database connection issue
docs: update API documentation
test: add tests for reaction system
refactor: improve GraphQL resolvers
```
### Python Code Standards
```python
# Good example
async def create_reaction(
session: Session,
author_id: int,
reaction_data: dict[str, Any]
) -> dict[str, Any]:
"""
Create a new reaction.
Args:
session: Database session
author_id: ID of the author creating the reaction
reaction_data: Reaction data
Returns:
Created reaction data
Raises:
ValueError: If reaction data is invalid
"""
if not reaction_data.get("kind"):
raise ValueError("Reaction kind is required")
reaction = Reaction(**reaction_data)
session.add(reaction)
session.commit()
return reaction.dict()
```
## 🐛 Bug Reports
When filing a bug report, please include:
- **Python version**
- **Package versions** (`pip freeze`)
- **Error message** and full traceback
- **Steps to reproduce**
- **Expected vs actual behavior**
## 💡 Feature Requests
For feature requests, please include:
- **Use case** description
- **Proposed solution**
- **Alternatives considered**
- **Breaking changes** (if any)
## 📚 Documentation
- Update documentation for new features
- Add examples for complex functionality
- Use Russian comments for Russian-speaking team members
- Keep README.md up to date
## 🔍 Code Review Process
1. **Automated checks** must pass (tests, linting)
2. **Manual review** by at least one maintainer
3. **Documentation** must be updated if needed
4. **Breaking changes** require discussion
## 🏷️ Release Process
We follow [Semantic Versioning](https://semver.org/):
- **MAJOR**: Breaking changes
- **MINOR**: New features (backward compatible)
- **PATCH**: Bug fixes (backward compatible)
## 🤝 Community
- Be respectful and inclusive
- Help newcomers get started
- Share knowledge and best practices
- Follow our [Code of Conduct](CODE_OF_CONDUCT.md)
## 📞 Getting Help
- **Issues**: For bugs and feature requests
- **Discussions**: For questions and general discussion
- **Documentation**: Check `docs/` folder first
Thank you for contributing! 🙏

View File

@ -3,24 +3,16 @@ 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 package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "-m", "granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port", "8000"]
CMD ["python", "-m", "granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port", "8000"]

21
LICENSE
View File

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

132
README.md
View File

@ -1,32 +1,9 @@
# GraphQL API Backend
<div align="center">
Backend service providing GraphQL API for content management system with reactions, ratings and comments.
![Version](https://img.shields.io/badge/v0.7.8-lightgrey)
![Tests](https://img.shields.io/badge/tests%2090%25-lightcyan?logo=pytest&logoColor=black)
![Python](https://img.shields.io/badge/python%203.12+-lightblue?logo=python&logoColor=black)
![PostgreSQL](https://img.shields.io/badge/postgresql%2016.1-lightblue?logo=postgresql&logoColor=black)
![Redis](https://img.shields.io/badge/redis%206.2.0-salmon?logo=redis&logoColor=black)
![txtai](https://img.shields.io/badge/txtai%208.6.0-lavender?logo=elasticsearch&logoColor=black)
![GraphQL](https://img.shields.io/badge/ariadne%200.23.0-pink?logo=graphql&logoColor=black)
![TypeScript](https://img.shields.io/badge/typescript%205.8.3-blue?logo=typescript&logoColor=black)
![SolidJS](https://img.shields.io/badge/solidjs%201.9.1-blue?logo=solid&logoColor=black)
![Vite](https://img.shields.io/badge/vite%207.0.0-blue?logo=vite&logoColor=black)
![Biome](https://img.shields.io/badge/biome%202.0.6-blue?logo=biome&logoColor=black)
## 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
@ -49,34 +26,28 @@ Backend service providing GraphQL API for content management system with reactio
- Activity tracking and stats
- Community features
### RBAC & Permissions
- RBAC with hierarchy using Redis
## Tech Stack
## 🛠️ Tech Stack
- **(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
**Core:** Python 3.12 • GraphQL • PostgreSQL • SQLAlchemy • JWT • Redis • txtai
**Server:** Starlette • Granian 1.8.0 • Nginx
**Frontend:** SolidJS 1.9.1 • TypeScript 5.7.2 • Vite 5.4.11
**GraphQL:** Ariadne 0.23.0
**Tools:** Pytest • MyPy • Biome 2.0.6
## Development
## 🔧 Development
![PRs Welcome](https://img.shields.io/badge/PRs-welcome-lightcyan?logo=git&logoColor=black)
![Biome](https://img.shields.io/badge/biome%202.0.6-yellow?logo=code&logoColor=black)
![Mypy](https://img.shields.io/badge/mypy-lavender?logo=python&logoColor=black)
### 📦 Prepare environment:
### 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, certificates are required to run the server with HTTPS.
First, certifcates are required to run the server.
```shell
mkcert -install
@ -86,40 +57,34 @@ mkcert localhost
Then, run the server:
```shell
python -m granian main:app --interface asgi
python server.py dev
```
### Useful Commands
### Useful Commands
```shell
# Linting and formatting with Biome
biome check . --write
# Linting and import sorting
ruff check . --fix --select I
# Lint only
biome lint .
# Format only
biome format . --write
# Code formatting
ruff format . --line-length=120
# Run tests
pytest
# Type checking
mypy .
# dev run
python -m granian main:app --interface asgi
```
### 📝 Code Style
### Code Style
![Line 120](https://img.shields.io/badge/line%20120-lightblue?logo=prettier&logoColor=black)
![Types](https://img.shields.io/badge/typed-pink?logo=python&logoColor=black)
![Docs](https://img.shields.io/badge/documented-lightcyan?logo=markdown&logoColor=black)
We use:
- Ruff for linting and import sorting
- Line length: 120 characters
- Python type hints
- Docstrings for public methods
**Biome 2.0.6** for linting and formatting • **120 char** lines • **Type hints** required • **Docstrings** for public methods
### 🔍 GraphQL Development
### GraphQL Development
Test queries in GraphQL Playground at `http://localhost:8000`:
@ -135,46 +100,3 @@ query GetShout($slug: String) {
}
}
```
---
## 📊 Project Stats
<div align="center">
![Lines](https://img.shields.io/badge/15k%2B-lines-lightcyan?logo=code&logoColor=black)
![Files](https://img.shields.io/badge/100%2B-files-lavender?logo=folder&logoColor=black)
![Coverage](https://img.shields.io/badge/90%25-coverage-gold?logo=test-tube&logoColor=black)
![MIT](https://img.shields.io/badge/MIT-license-silver?logo=balance-scale&logoColor=black)
</div>
## 🤝 Contributing
[CHANGELOG.md](CHANGELOG.md)
![Contributing](https://img.shields.io/badge/contributing-guide-salmon?logo=handshake&logoColor=black) • [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
![Website](https://img.shields.io/badge/discours.io-website-lightblue?logo=globe&logoColor=black)
![GitHub](https://img.shields.io/badge/discours/core-github-silver?logo=github&logoColor=black)
• [discours.io](https://discours.io)
• [Source Code](https://github.com/discours/core)
---
<div align="center">
**Made with ❤️ by the Discours Team**
![Made with Love](https://img.shields.io/badge/made%20with%20❤-pink?logo=heart&logoColor=black)
![Open Source](https://img.shields.io/badge/open%20source-lightcyan?logo=open-source-initiative&logoColor=black)
</div>

View File

@ -1,93 +0,0 @@
# 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

View File

@ -2,7 +2,6 @@ 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
@ -12,7 +11,7 @@ from settings import DB_URL
config = context.config
# override DB_URL
config.set_main_option("sqlalchemy.url", DB_URL)
config.set_section_option(config.config_ini_section, "DB_URL", DB_URL)
# Interpret the config file for Python logging.
# This line sets up loggers basically.

View File

@ -1,24 +0,0 @@
"""${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"}

View File

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

96
auth/authenticate.py Normal file
View File

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

View File

@ -1,95 +1,43 @@
from typing import Any, Optional
from typing import List, Optional, Text
from pydantic import BaseModel, Field
from pydantic import BaseModel
# from base.exceptions import Unauthorized
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
class Permission(BaseModel):
"""Модель разрешения для RBAC"""
resource: str
operation: str
def __str__(self) -> str:
return f"{self.resource}:{self.operation}"
name: Text
class AuthCredentials(BaseModel):
"""
Модель учетных данных авторизации.
Используется как часть механизма аутентификации Starlette.
"""
author_id: 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]
user_id: Optional[int] = None
scopes: Optional[dict] = {}
logged_in: bool = False
error_message: str = ""
@property
def is_admin(self) -> bool:
"""
Проверяет, является ли пользователь администратором.
def is_admin(self):
# TODO: check admin logix
return True
Returns:
bool: True, если email пользователя находится в списке ADMIN_EMAILS
"""
return self.email in ADMIN_EMAILS if self.email else False
async def to_dict(self) -> dict[str, Any]:
"""
Преобразует учетные данные в словарь
Returns:
Dict[str, Any]: Словарь с данными учетных данных
"""
permissions = self.get_permissions()
return {
"author_id": self.author_id,
"logged_in": self.logged_in,
"is_admin": self.is_admin,
"permissions": list(permissions),
}
async def permissions(self) -> list[Permission]:
if self.author_id is None:
async def permissions(self) -> List[Permission]:
if self.user_id is None:
# raise Unauthorized("Please login first")
return [] # Возвращаем пустой список вместо dict
# TODO: implement permissions logix
print(self.author_id)
return [] # Возвращаем пустой список вместо NotImplemented
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

View File

@ -1,521 +0,0 @@
from collections.abc import Callable
from functools import wraps
from typing import Any, Optional
from graphql import GraphQLError, GraphQLResolveInfo
from sqlalchemy import exc
from auth.credentials import AuthCredentials
from auth.exceptions import OperationNotAllowed
from auth.internal import authenticate
from auth.orm import Author
from orm.community import CommunityAuthor
from services.db import local_session
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
from settings import SESSION_COOKIE_NAME, SESSION_TOKEN_HEADER
from utils.logger import root_logger as logger
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
def get_safe_headers(request: Any) -> dict[str, str]:
"""
Безопасно получает заголовки запроса.
Args:
request: Объект запроса
Returns:
Dict[str, str]: Словарь заголовков
"""
headers = {}
try:
# Первый приоритет: scope из ASGI (самый надежный источник)
if hasattr(request, "scope") and isinstance(request.scope, dict):
scope_headers = request.scope.get("headers", [])
if scope_headers:
headers.update({k.decode("utf-8").lower(): v.decode("utf-8") for k, v in scope_headers})
logger.debug(f"[decorators] Получены заголовки из request.scope: {len(headers)}")
# Второй приоритет: метод headers() или атрибут headers
if hasattr(request, "headers"):
if callable(request.headers):
h = request.headers()
if h:
headers.update({k.lower(): v for k, v in h.items()})
logger.debug(f"[decorators] Получены заголовки из request.headers() метода: {len(headers)}")
else:
h = request.headers
if hasattr(h, "items") and callable(h.items):
headers.update({k.lower(): v for k, v in h.items()})
logger.debug(f"[decorators] Получены заголовки из request.headers атрибута: {len(headers)}")
elif isinstance(h, dict):
headers.update({k.lower(): v for k, v in h.items()})
logger.debug(f"[decorators] Получены заголовки из request.headers словаря: {len(headers)}")
# Третий приоритет: атрибут _headers
if hasattr(request, "_headers") and request._headers:
headers.update({k.lower(): v for k, v in request._headers.items()})
logger.debug(f"[decorators] Получены заголовки из request._headers: {len(headers)}")
except Exception as e:
logger.warning(f"[decorators] Ошибка при доступе к заголовкам: {e}")
return headers
def get_auth_token(request: Any) -> Optional[str]:
"""
Извлекает токен авторизации из запроса.
Порядок проверки:
1. Проверяет auth из middleware
2. Проверяет auth из scope
3. Проверяет заголовок Authorization
4. Проверяет cookie с именем auth_token
Args:
request: Объект запроса
Returns:
Optional[str]: Токен авторизации или None
"""
try:
# 1. Проверяем auth из middleware (если middleware уже обработал токен)
if hasattr(request, "auth") and request.auth:
token = getattr(request.auth, "token", None)
if token:
logger.debug(f"[decorators] Токен получен из request.auth: {len(token)}")
return token
# 2. Проверяем наличие auth в scope
if hasattr(request, "scope") and isinstance(request.scope, dict) and "auth" in request.scope:
auth_info = request.scope.get("auth", {})
if isinstance(auth_info, dict) and "token" in auth_info:
token = auth_info["token"]
logger.debug(f"[decorators] Токен получен из request.scope['auth']: {len(token)}")
return token
# 3. Проверяем заголовок Authorization
headers = get_safe_headers(request)
# Сначала проверяем основной заголовок авторизации
auth_header = headers.get(SESSION_TOKEN_HEADER.lower(), "")
if auth_header:
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
logger.debug(f"[decorators] Токен получен из заголовка {SESSION_TOKEN_HEADER}: {len(token)}")
return token
token = auth_header.strip()
logger.debug(f"[decorators] Прямой токен получен из заголовка {SESSION_TOKEN_HEADER}: {len(token)}")
return token
# Затем проверяем стандартный заголовок Authorization, если основной не определен
if SESSION_TOKEN_HEADER.lower() != "authorization":
auth_header = headers.get("authorization", "")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
logger.debug(f"[decorators] Токен получен из заголовка Authorization: {len(token)}")
return token
# 4. Проверяем cookie
if hasattr(request, "cookies") and request.cookies:
token = request.cookies.get(SESSION_COOKIE_NAME)
if token:
logger.debug(f"[decorators] Токен получен из cookie {SESSION_COOKIE_NAME}: {len(token)}")
return token
# Если токен не найден ни в одном из мест
logger.debug("[decorators] Токен авторизации не найден")
return None
except Exception as e:
logger.warning(f"[decorators] Ошибка при извлечении токена: {e}")
return None
async def validate_graphql_context(info: GraphQLResolveInfo) -> None:
"""
Проверяет валидность GraphQL контекста и проверяет авторизацию.
Args:
info: GraphQL информация о контексте
Raises:
GraphQLError: если контекст невалиден или пользователь не авторизован
"""
# Подробное логирование для диагностики
logger.debug("[validate_graphql_context] Начало проверки контекста и авторизации")
# Проверка базовой структуры контекста
if info is None or not hasattr(info, "context"):
logger.error("[validate_graphql_context] Missing GraphQL context information")
msg = "Internal server error: missing context"
raise GraphQLError(msg)
request = info.context.get("request")
if not request:
logger.error("[validate_graphql_context] Missing request in context")
msg = "Internal server error: missing request"
raise GraphQLError(msg)
# Логируем детали запроса
client_info = {
"ip": getattr(request.client, "host", "unknown") if hasattr(request, "client") else "unknown",
"headers_keys": list(get_safe_headers(request).keys()),
}
logger.debug(f"[validate_graphql_context] Детали запроса: {client_info}")
# Проверяем auth из контекста - если уже авторизован, просто возвращаем
auth = getattr(request, "auth", None)
if auth and getattr(auth, "logged_in", False):
logger.debug(f"[validate_graphql_context] Пользователь уже авторизован через request.auth: {auth.author_id}")
return
# Если аутентификации нет в request.auth, пробуем получить ее из scope
if hasattr(request, "scope") and "auth" in request.scope:
auth_cred = request.scope.get("auth")
if isinstance(auth_cred, AuthCredentials) and getattr(auth_cred, "logged_in", False):
logger.debug(f"[validate_graphql_context] Пользователь авторизован через scope: {auth_cred.author_id}")
return
# Если авторизации нет ни в auth, ни в scope, пробуем получить и проверить токен
token = get_auth_token(request)
if not token:
# Если токен не найден, бросаем ошибку авторизации
client_info = {
"ip": getattr(request.client, "host", "unknown") if hasattr(request, "client") else "unknown",
"headers": {k: v for k, v in get_safe_headers(request).items() if k not in ["authorization", "cookie"]},
}
logger.warning(f"[validate_graphql_context] Токен авторизации не найден: {client_info}")
msg = "Unauthorized - please login"
raise GraphQLError(msg)
# Логируем информацию о найденном токене
logger.debug(f"[validate_graphql_context] Токен найден, длина: {len(token)}")
# Используем единый механизм проверки токена из auth.internal
auth_state = await authenticate(request)
logger.debug(
f"[validate_graphql_context] Результат аутентификации: logged_in={auth_state.logged_in}, author_id={auth_state.author_id}, error={auth_state.error}"
)
if not auth_state.logged_in:
error_msg = auth_state.error or "Invalid or expired token"
logger.warning(f"[validate_graphql_context] Недействительный токен: {error_msg}")
msg = f"Unauthorized - {error_msg}"
raise GraphQLError(msg)
# Если все проверки пройдены, создаем AuthCredentials и устанавливаем в request.scope
with local_session() as session:
try:
author = session.query(Author).filter(Author.id == auth_state.author_id).one()
logger.debug(f"[validate_graphql_context] Найден автор: id={author.id}, email={author.email}")
# Создаем объект авторизации с пустыми разрешениями
# Разрешения будут проверяться через RBAC систему по требованию
auth_cred = AuthCredentials(
author_id=author.id,
scopes={}, # Пустой словарь разрешений
logged_in=True,
error_message="",
email=author.email,
token=auth_state.token,
)
# Устанавливаем auth в request.scope вместо прямого присваивания к request.auth
if hasattr(request, "scope") and isinstance(request.scope, dict):
request.scope["auth"] = auth_cred
logger.debug(
f"[validate_graphql_context] Токен успешно проверен и установлен для пользователя {auth_state.author_id}"
)
else:
logger.error("[validate_graphql_context] Не удалось установить auth: отсутствует request.scope")
msg = "Internal server error: unable to set authentication context"
raise GraphQLError(msg)
except exc.NoResultFound:
logger.error(f"[validate_graphql_context] Пользователь с ID {auth_state.author_id} не найден в базе данных")
msg = "Unauthorized - user not found"
raise GraphQLError(msg) from None
return
def admin_auth_required(resolver: Callable) -> Callable:
"""
Декоратор для защиты админских эндпоинтов.
Проверяет принадлежность к списку разрешенных email-адресов.
Args:
resolver: GraphQL резолвер для защиты
Returns:
Обернутый резолвер, который проверяет права доступа администратора
Raises:
GraphQLError: если пользователь не авторизован или не имеет доступа администратора
Example:
>>> @admin_auth_required
... async def admin_resolver(root, info, **kwargs):
... return "Admin data"
"""
@wraps(resolver)
async def wrapper(root: Any = None, info: Optional[GraphQLResolveInfo] = None, **kwargs: dict[str, Any]) -> Any:
# Подробное логирование для диагностики
logger.debug(f"[admin_auth_required] Начало проверки авторизации для {resolver.__name__}")
# Проверяем авторизацию пользователя
if info is None:
logger.error("[admin_auth_required] GraphQL info is None")
msg = "Invalid GraphQL context"
raise GraphQLError(msg)
# Логируем детали запроса
request = info.context.get("request")
client_info = {
"ip": getattr(request.client, "host", "unknown") if hasattr(request, "client") else "unknown",
"headers": {k: v for k, v in get_safe_headers(request).items() if k not in ["authorization", "cookie"]},
}
logger.debug(f"[admin_auth_required] Детали запроса: {client_info}")
# Проверяем наличие токена до validate_graphql_context
token = get_auth_token(request)
logger.debug(f"[admin_auth_required] Токен найден: {bool(token)}, длина: {len(token) if token else 0}")
try:
# Проверяем авторизацию - НЕ ловим GraphQLError здесь!
await validate_graphql_context(info)
logger.debug("[admin_auth_required] validate_graphql_context успешно пройден")
except GraphQLError:
# Пробрасываем GraphQLError дальше - это ошибки авторизации
logger.debug("[admin_auth_required] GraphQLError от validate_graphql_context - пробрасываем дальше")
raise
# Получаем объект авторизации
auth = None
if hasattr(info.context["request"], "scope") and "auth" in info.context["request"].scope:
auth = info.context["request"].scope.get("auth")
logger.debug(f"[admin_auth_required] Auth из scope: {auth.author_id if auth else None}")
elif hasattr(info.context["request"], "auth"):
auth = info.context["request"].auth
logger.debug(f"[admin_auth_required] Auth из request: {auth.author_id if auth else None}")
else:
logger.error("[admin_auth_required] Auth не найден ни в scope, ни в request")
if not auth or not getattr(auth, "logged_in", False):
logger.error("[admin_auth_required] Пользователь не авторизован после validate_graphql_context")
msg = "Unauthorized - please login"
raise GraphQLError(msg)
# Проверяем, является ли пользователь администратором
try:
with local_session() as session:
# Преобразуем author_id в int для совместимости с базой данных
author_id = int(auth.author_id) if auth and auth.author_id else None
if not author_id:
logger.error(f"[admin_auth_required] ID автора не определен: {auth}")
msg = "Unauthorized - invalid user ID"
raise GraphQLError(msg)
author = session.query(Author).filter(Author.id == author_id).one()
logger.debug(f"[admin_auth_required] Найден автор: {author.id}, {author.email}")
# Проверяем, является ли пользователь системным администратором
if author.email and author.email in ADMIN_EMAILS:
logger.info(f"System admin access granted for {author.email} (ID: {author.id})")
return await resolver(root, info, **kwargs)
# Системный администратор определяется ТОЛЬКО по ADMIN_EMAILS
logger.warning(f"System admin access denied for {author.email} (ID: {author.id}). Not in ADMIN_EMAILS.")
msg = "Unauthorized - system admin access required"
raise GraphQLError(msg)
except exc.NoResultFound:
logger.error(f"[admin_auth_required] Пользователь с ID {auth.author_id} не найден в базе данных")
msg = "Unauthorized - user not found"
raise GraphQLError(msg) from None
except GraphQLError:
# Пробрасываем GraphQLError дальше
raise
except Exception as e:
# Ловим только неожиданные ошибки, не GraphQLError
error_msg = f"Admin access error: {e!s}"
logger.error(f"[admin_auth_required] Неожиданная ошибка: {error_msg}")
raise GraphQLError(error_msg) from e
return wrapper
def permission_required(resource: str, operation: str, func: Callable) -> Callable:
"""
Декоратор для проверки разрешений.
Args:
resource: Ресурс для проверки
operation: Операция для проверки
func: Декорируемая функция
"""
@wraps(func)
async def wrap(parent: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any) -> Any:
# Сначала проверяем авторизацию
await validate_graphql_context(info)
# Получаем объект авторизации
logger.debug(f"[permission_required] Контекст: {info.context}")
auth = None
if hasattr(info.context["request"], "scope") and "auth" in info.context["request"].scope:
auth = info.context["request"].scope.get("auth")
if not auth or not getattr(auth, "logged_in", False):
logger.error("[permission_required] Пользователь не авторизован после validate_graphql_context")
msg = "Требуются права доступа"
raise OperationNotAllowed(msg)
# Проверяем разрешения
with local_session() as session:
try:
author = session.query(Author).filter(Author.id == auth.author_id).one()
# Проверяем базовые условия
if author.is_locked():
msg = "Account is locked"
raise OperationNotAllowed(msg)
# Проверяем, является ли пользователь администратором (у них есть все разрешения)
if author.email in ADMIN_EMAILS:
logger.debug(f"[permission_required] Администратор {author.email} имеет все разрешения")
return await func(parent, info, *args, **kwargs)
# Проверяем роли пользователя
admin_roles = ["admin", "super"]
ca = session.query(CommunityAuthor).filter_by(author_id=author.id, community_id=1).first()
if ca:
user_roles = ca.role_list
else:
user_roles = []
if any(role in admin_roles for role in user_roles):
logger.debug(
f"[permission_required] Пользователь с ролью администратора {author.email} имеет все разрешения"
)
return await func(parent, info, *args, **kwargs)
# Проверяем разрешение
if not author.has_permission(resource, operation):
logger.warning(
f"[permission_required] У пользователя {author.email} нет разрешения {operation} на {resource}"
)
msg = f"No permission for {operation} on {resource}"
raise OperationNotAllowed(msg)
logger.debug(
f"[permission_required] Пользователь {author.email} имеет разрешение {operation} на {resource}"
)
return await func(parent, info, *args, **kwargs)
except exc.NoResultFound:
logger.error(f"[permission_required] Пользователь с ID {auth.author_id} не найден в базе данных")
msg = "User not found"
raise OperationNotAllowed(msg) from None
return wrap
def login_accepted(func: Callable) -> Callable:
"""
Декоратор для проверки аутентификации пользователя.
Args:
func: функция-резолвер для декорирования
Returns:
Callable: обернутая функция
"""
@wraps(func)
async def wrap(parent: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any) -> Any:
try:
await validate_graphql_context(info)
return await func(parent, info, *args, **kwargs)
except GraphQLError:
# Пробрасываем ошибки авторизации далее
raise
except Exception as e:
logger.error(f"[decorators] Unexpected error in login_accepted: {e}")
msg = "Internal server error"
raise GraphQLError(msg) from e
return wrap
def editor_or_admin_required(func: Callable) -> Callable:
"""
Декоратор для проверки, что пользователь имеет роль 'editor' или 'admin'.
Args:
func: функция-резолвер для декорирования
Returns:
Callable: обернутая функция
"""
@wraps(func)
async def wrap(parent: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any) -> Any:
try:
# Сначала проверяем авторизацию
await validate_graphql_context(info)
# Получаем информацию о пользователе
request = info.context.get("request")
author_id = None
# Пробуем получить author_id из разных источников
if hasattr(request, "auth") and request.auth and hasattr(request.auth, "author_id"):
author_id = request.auth.author_id
elif hasattr(request, "scope") and "auth" in request.scope:
auth_info = request.scope.get("auth", {})
if isinstance(auth_info, dict):
author_id = auth_info.get("author_id")
elif hasattr(auth_info, "author_id"):
author_id = auth_info.author_id
if not author_id:
logger.warning("[decorators] Не удалось получить author_id для проверки ролей")
raise GraphQLError("Ошибка авторизации: не удалось определить пользователя")
# Проверяем роли пользователя
with local_session() as session:
author = session.query(Author).filter(Author.id == author_id).first()
if not author:
logger.warning(f"[decorators] Автор с ID {author_id} не найден")
raise GraphQLError("Пользователь не найден")
# Проверяем email админа
if author.email in ADMIN_EMAILS:
logger.debug(f"[decorators] Пользователь {author.email} является админом по email")
return await func(parent, info, *args, **kwargs)
# Получаем список ролей пользователя
ca = session.query(CommunityAuthor).filter_by(author_id=author.id, community_id=1).first()
if ca:
user_roles = ca.role_list
else:
user_roles = []
logger.debug(f"[decorators] Роли пользователя {author_id}: {user_roles}")
# Проверяем наличие роли admin или editor
if "admin" in user_roles or "editor" in user_roles:
logger.debug(f"[decorators] Пользователь {author_id} имеет разрешение (роли: {user_roles})")
return await func(parent, info, *args, **kwargs)
# Если нет нужных ролей
logger.warning(f"[decorators] Пользователю {author_id} отказано в доступе. Роли: {user_roles}")
raise GraphQLError("Доступ запрещен. Требуется роль редактора или администратора.")
except GraphQLError:
# Пробрасываем ошибки авторизации далее
raise
except Exception as e:
logger.error(f"[decorators] Неожиданная ошибка в editor_or_admin_required: {e}")
raise GraphQLError("Внутренняя ошибка сервера") from e
return wrap

View File

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

View File

@ -1,56 +0,0 @@
from ariadne.asgi.handlers import GraphQLHTTPHandler
from starlette.requests import Request
from starlette.responses import JSONResponse
from auth.middleware import auth_middleware
from utils.logger import root_logger as logger
class EnhancedGraphQLHTTPHandler(GraphQLHTTPHandler):
"""
Улучшенный GraphQL HTTP обработчик с поддержкой cookie и авторизации.
Расширяет стандартный GraphQLHTTPHandler для:
1. Создания расширенного контекста запроса с авторизационными данными
2. Корректной обработки ответов с cookie и headers
3. Интеграции с AuthMiddleware
"""
async def get_context_for_request(self, request: Request, data: dict) -> dict:
"""
Расширяем контекст для GraphQL запросов.
Добавляет к стандартному контексту:
- Объект response для установки cookie
- Интеграцию с AuthMiddleware
- Расширения для управления авторизацией
Args:
request: Starlette Request объект
data: данные запроса
Returns:
dict: контекст с дополнительными данными для авторизации и cookie
"""
# Получаем стандартный контекст от базового класса
context = await super().get_context_for_request(request, data)
# Создаем объект ответа для установки cookie
response = JSONResponse({})
context["response"] = response
# Интегрируем с AuthMiddleware
auth_middleware.set_context(context)
context["extensions"] = auth_middleware
# Добавляем данные авторизации только если они доступны
# Проверяем наличие данных авторизации в scope
if hasattr(request, "scope") and isinstance(request.scope, dict) and "auth" in request.scope:
auth_cred = request.scope.get("auth")
context["auth"] = auth_cred
# Безопасно логируем информацию о типе объекта auth
logger.debug(f"[graphql] Добавлены данные авторизации в контекст из scope: {type(auth_cred).__name__}")
logger.debug("[graphql] Подготовлен расширенный контекст для запроса")
return context

View File

@ -1,20 +1,15 @@
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, InvalidPassword, InvalidToken
from auth.exceptions import ExpiredToken, 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:
@ -29,25 +24,16 @@ 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
@ -66,102 +52,46 @@ class Password:
class Identity:
@staticmethod
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
def password(orm_user: User, password: str) -> User:
user = User(**orm_user.dict())
if not user.password:
# raise InvalidPassword("User password is empty")
return {"error": "User password is empty"}
if not Password.verify(password, user.password):
# raise InvalidPassword("Wrong user password")
return {"error": "Wrong user password"}
return user
@staticmethod
def oauth(inp: dict[str, Any]) -> Any:
"""
Создает нового пользователя OAuth, если он не существует
Args:
inp (dict): Данные OAuth пользователя
Returns:
Author: Объект пользователя
"""
# Импортируем внутри функции для избежания циклических импортов
from auth.orm import Author
def oauth(inp) -> User:
with local_session() as session:
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)
user = session.query(User).filter(User.email == inp["email"]).first()
if not user:
user = User.create(**inp, emailConfirmed=True)
session.commit()
return author
return user
@staticmethod
async def onetime(token: str) -> Any:
"""
Проверяет одноразовый токен
Args:
token (str): Одноразовый токен
Returns:
Author: Объект пользователя
"""
# Импортируем внутри функции для избежания циклических импортов
from auth.orm import Author
async def onetime(token: str) -> User:
try:
print("[auth.identity] using one time token")
payload = JWTCodec.decode(token)
if payload is None:
logger.warning("[Identity.token] Токен не валиден (payload is None)")
return {"error": "Invalid token"}
# Проверяем существование токена в хранилище
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
if not await TokenStorage.exist(f"{payload.user_id}-{payload.username}-{token}"):
# raise InvalidToken("Login token has expired, please login again")
return {"error": "Token has expired"}
except ExpiredToken:
# raise InvalidToken("Login token has expired, please try again")
return {"error": "Token has expired"}
except InvalidToken:
# raise InvalidToken("token format error") from e
return {"error": "Token format error"}
with local_session() as session:
user = session.query(User).filter_by(id=payload.user_id).first()
if not user:
# raise Exception("user not exist")
return {"error": "User does not exist"}
if not user.emailConfirmed:
user.emailConfirmed = True
session.commit()
return user

View File

@ -1,147 +0,0 @@
"""
Утилитные функции для внутренней аутентификации
Используются в GraphQL резолверах и декораторах
"""
import time
from typing import Optional
from sqlalchemy.orm import exc
from auth.orm import Author
from auth.state import AuthState
from auth.tokens.storage import TokenStorage as TokenManager
from services.db import local_session
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
from utils.logger import root_logger as logger
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
async def verify_internal_auth(token: str) -> tuple[int, list, bool]:
"""
Проверяет локальную авторизацию.
Возвращает user_id, список ролей и флаг администратора.
Args:
token: Токен авторизации (может быть как с Bearer, так и без)
Returns:
tuple: (user_id, roles, is_admin)
"""
logger.debug(f"[verify_internal_auth] Проверка токена: {token[:10]}...")
# Обработка формата "Bearer <token>" (если токен не был обработан ранее)
if token and token.startswith("Bearer "):
token = token.replace("Bearer ", "", 1).strip()
# Проверяем сессию
payload = await TokenManager.verify_session(token)
if not payload:
logger.warning("[verify_internal_auth] Недействительный токен: payload не получен")
return 0, [], False
logger.debug(f"[verify_internal_auth] Токен действителен, user_id={payload.user_id}")
with local_session() as session:
try:
author = session.query(Author).filter(Author.id == payload.user_id).one()
# Получаем роли
from orm.community import CommunityAuthor
ca = session.query(CommunityAuthor).filter_by(author_id=author.id, community_id=1).first()
if ca:
roles = ca.role_list
else:
roles = []
logger.debug(f"[verify_internal_auth] Роли пользователя: {roles}")
# Определяем, является ли пользователь администратором
is_admin = any(role in ["admin", "super"] for role in roles) or author.email in ADMIN_EMAILS
logger.debug(
f"[verify_internal_auth] Пользователь {author.id} {'является' if is_admin else 'не является'} администратором"
)
return int(author.id), roles, is_admin
except exc.NoResultFound:
logger.warning(f"[verify_internal_auth] Пользователь с ID {payload.user_id} не найден в БД или не активен")
return 0, [], False
async def create_internal_session(author: Author, device_info: Optional[dict] = None) -> str:
"""
Создает новую сессию для автора
Args:
author: Объект автора
device_info: Информация об устройстве (опционально)
Returns:
str: Токен сессии
"""
# Сбрасываем счетчик неудачных попыток
author.reset_failed_login()
# Обновляем last_seen
author.last_seen = int(time.time()) # type: ignore[assignment]
# Создаем сессию, используя token для идентификации
return await TokenManager.create_session(
user_id=str(author.id),
username=str(author.slug or author.email or author.phone or ""),
device_info=device_info,
)
async def authenticate(request) -> AuthState:
"""
Аутентифицирует пользователя по токену из запроса.
Args:
request: Объект запроса
Returns:
AuthState: Состояние аутентификации
"""
from auth.decorators import get_auth_token
from utils.logger import root_logger as logger
logger.debug("[authenticate] Начало аутентификации")
# Создаем объект AuthState
auth_state = AuthState()
auth_state.logged_in = False
auth_state.author_id = None
auth_state.error = None
auth_state.token = None
# Получаем токен из запроса
token = get_auth_token(request)
if not token:
logger.warning("[authenticate] Токен не найден в запросе")
auth_state.error = "No authentication token provided"
return auth_state
logger.debug(f"[authenticate] Токен найден, длина: {len(token)}")
# Проверяем токен
try:
# Используем TokenManager вместо прямого создания SessionTokenManager
auth_result = await TokenManager.verify_session(token)
if auth_result and hasattr(auth_result, "user_id") and auth_result.user_id:
logger.debug(f"[authenticate] Успешная аутентификация, user_id: {auth_result.user_id}")
auth_state.logged_in = True
auth_state.author_id = auth_result.user_id
auth_state.token = token
return auth_state
error_msg = "Invalid or expired token"
logger.warning(f"[authenticate] Недействительный токен: {error_msg}")
auth_state.error = error_msg
return auth_state
except Exception as e:
logger.error(f"[authenticate] Ошибка при проверке токена: {e}")
auth_state.error = f"Authentication error: {e!s}"
return auth_state

View File

@ -1,79 +1,39 @@
from datetime import datetime, timedelta, timezone
from typing import Any, Optional, Union
from datetime import datetime, timezone
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: Optional[datetime] = None
exp: datetime
iat: datetime
iss: str
class JWTCodec:
@staticmethod
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())
def encode(user, exp: datetime) -> str:
payload = {
"user_id": user_id,
"username": username,
"exp": exp_timestamp, # Используем timestamp вместо datetime
"user_id": user.id,
"username": user.email or user.phone,
"exp": exp,
"iat": datetime.now(tz=timezone.utc),
"iss": "discours",
}
logger.debug(f"[JWTCodec.encode] Сформирован payload: {payload}")
try:
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)
return jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM)
except Exception as e:
logger.error(f"[JWTCodec.encode] Ошибка при кодировании JWT: {e}")
raise
print("[auth.jwtcodec] JWT encode error %r" % e)
@staticmethod
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
def decode(token: str, verify_exp: bool = True):
r = None
payload = None
try:
payload = jwt.decode(
token,
@ -85,39 +45,16 @@ class JWTCodec:
algorithms=[JWT_ALGORITHM],
issuer="discours",
)
logger.debug(f"[JWTCodec.decode] Декодирован payload: {payload}")
# Убедимся, что exp существует (добавим обработку если exp отсутствует)
if "exp" not in payload:
logger.warning("[JWTCodec.decode] В токене отсутствует поле exp")
# Добавим exp по умолчанию, чтобы избежать ошибки при создании TokenPayload
payload["exp"] = int((datetime.now(tz=timezone.utc) + timedelta(days=30)).timestamp())
try:
r = TokenPayload(**payload)
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
r = TokenPayload(**payload)
# print('[auth.jwtcodec] debug token %r' % r)
return r
except jwt.InvalidIssuedAtError:
logger.error("[JWTCodec.decode] Недействительное время выпуска токена")
return None
print("[auth.jwtcodec] invalid issued at: %r" % payload)
raise ExpiredToken("check token issued time")
except jwt.ExpiredSignatureError:
logger.error("[JWTCodec.decode] Истек срок действия токена")
return None
except jwt.InvalidSignatureError:
logger.error("[JWTCodec.decode] Недействительная подпись токена")
return None
print("[auth.jwtcodec] expired signature %r" % payload)
raise ExpiredToken("check token lifetime")
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
raise InvalidToken("token is not valid")
except jwt.InvalidSignatureError:
raise InvalidToken("token is not valid")

View File

@ -1,439 +0,0 @@
"""
Единый middleware для обработки авторизации в GraphQL запросах
"""
import time
from collections.abc import Awaitable, MutableMapping
from typing import Any, Callable, Optional
from graphql import GraphQLResolveInfo
from sqlalchemy.orm import exc
from starlette.authentication import UnauthenticatedUser
from starlette.datastructures import Headers
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.types import ASGIApp
from auth.credentials import AuthCredentials
from auth.orm import Author
from auth.tokens.storage import TokenStorage as TokenManager
from orm.community import CommunityAuthor
from services.db import local_session
from settings import (
ADMIN_EMAILS as ADMIN_EMAILS_LIST,
)
from settings import (
SESSION_COOKIE_HTTPONLY,
SESSION_COOKIE_MAX_AGE,
SESSION_COOKIE_NAME,
SESSION_COOKIE_SAMESITE,
SESSION_COOKIE_SECURE,
SESSION_TOKEN_HEADER,
)
from utils.logger import root_logger as logger
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
class AuthenticatedUser:
"""Аутентифицированный пользователь"""
def __init__(
self,
user_id: str,
username: str = "",
roles: Optional[list] = None,
permissions: Optional[dict] = None,
token: Optional[str] = None,
) -> None:
self.user_id = user_id
self.username = username
self.roles = roles or []
self.permissions = permissions or {}
self.token = token
@property
def is_authenticated(self) -> bool:
return True
@property
def display_name(self) -> str:
return self.username
@property
def identity(self) -> str:
return self.user_id
class AuthMiddleware:
"""
Единый middleware для обработки авторизации и аутентификации.
Основные функции:
1. Извлечение Bearer токена из заголовка Authorization или cookie
2. Проверка сессии через TokenStorage
3. Создание request.user и request.auth
4. Предоставление методов для установки/удаления cookies
"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
self._context = None
async def authenticate_user(self, token: str) -> tuple[AuthCredentials, AuthenticatedUser | UnauthenticatedUser]:
"""Аутентифицирует пользователя по токену"""
if not token:
logger.debug("[auth.authenticate] Токен отсутствует")
return AuthCredentials(
author_id=None, scopes={}, logged_in=False, error_message="no token", email=None, token=None
), UnauthenticatedUser()
# Проверяем сессию в Redis
try:
payload = await TokenManager.verify_session(token)
if not payload:
logger.debug("[auth.authenticate] Недействительный токен или сессия не найдена")
return AuthCredentials(
author_id=None,
scopes={},
logged_in=False,
error_message="Invalid token or session",
email=None,
token=None,
), UnauthenticatedUser()
with local_session() as session:
try:
author = session.query(Author).filter(Author.id == payload.user_id).one()
if author.is_locked():
logger.debug(f"[auth.authenticate] Аккаунт заблокирован: {author.id}")
return AuthCredentials(
author_id=None,
scopes={},
logged_in=False,
error_message="Account is locked",
email=None,
token=None,
), UnauthenticatedUser()
# Создаем пустой словарь разрешений
# Разрешения будут проверяться через RBAC систему по требованию
scopes: dict[str, Any] = {}
# Получаем роли для пользователя
ca = session.query(CommunityAuthor).filter_by(author_id=author.id, community_id=1).first()
if ca:
roles = ca.role_list
else:
roles = []
# Обновляем last_seen
author.last_seen = int(time.time())
session.commit()
# Создаем объекты авторизации с сохранением токена
credentials = AuthCredentials(
author_id=author.id,
scopes=scopes,
logged_in=True,
error_message="",
email=author.email,
token=token,
)
user = AuthenticatedUser(
user_id=str(author.id),
username=author.slug or author.email or "",
roles=roles,
permissions=scopes,
token=token,
)
logger.debug(f"[auth.authenticate] Успешная аутентификация: {author.email}")
return credentials, user
except exc.NoResultFound:
logger.debug("[auth.authenticate] Пользователь не найден в базе данных")
return AuthCredentials(
author_id=None,
scopes={},
logged_in=False,
error_message="User not found",
email=None,
token=None,
), UnauthenticatedUser()
except Exception as e:
logger.error(f"[auth.authenticate] Ошибка при работе с базой данных: {e}")
return AuthCredentials(
author_id=None, scopes={}, logged_in=False, error_message=str(e), email=None, token=None
), UnauthenticatedUser()
except Exception as e:
logger.error(f"[auth.authenticate] Ошибка при проверке сессии: {e}")
return AuthCredentials(
author_id=None, scopes={}, logged_in=False, error_message=str(e), email=None, token=None
), UnauthenticatedUser()
async def __call__(
self,
scope: MutableMapping[str, Any],
receive: Callable[[], Awaitable[MutableMapping[str, Any]]],
send: Callable[[MutableMapping[str, Any]], Awaitable[None]],
) -> None:
"""Обработка ASGI запроса"""
if scope["type"] != "http":
await self.app(scope, receive, send)
return
# Извлекаем заголовки
headers = Headers(scope=scope)
token = None
# Сначала пробуем получить токен из заголовка авторизации
auth_header = headers.get(SESSION_TOKEN_HEADER)
if auth_header:
if auth_header.startswith("Bearer "):
token = auth_header.replace("Bearer ", "", 1).strip()
logger.debug(
f"[middleware] Извлечен Bearer токен из заголовка {SESSION_TOKEN_HEADER}, длина: {len(token) if token else 0}"
)
else:
# Если заголовок не начинается с Bearer, предполагаем, что это чистый токен
token = auth_header.strip()
logger.debug(
f"[middleware] Извлечен прямой токен из заголовка {SESSION_TOKEN_HEADER}, длина: {len(token) if token else 0}"
)
# Если токен не получен из основного заголовка и это не Authorization, проверяем заголовок Authorization
if not token and SESSION_TOKEN_HEADER.lower() != "authorization":
auth_header = headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.replace("Bearer ", "", 1).strip()
logger.debug(
f"[middleware] Извлечен Bearer токен из заголовка Authorization, длина: {len(token) if token else 0}"
)
# Если токен не получен из заголовка, пробуем взять из cookie
if not token:
cookies = headers.get("cookie", "")
cookie_items = cookies.split(";")
for item in cookie_items:
if "=" in item:
name, value = item.split("=", 1)
if name.strip() == SESSION_COOKIE_NAME:
token = value.strip()
logger.debug(
f"[middleware] Извлечен токен из cookie {SESSION_COOKIE_NAME}, длина: {len(token) if token else 0}"
)
break
# Аутентифицируем пользователя
auth, user = await self.authenticate_user(token or "")
# Добавляем в scope данные авторизации и пользователя
scope["auth"] = auth
scope["user"] = user
if token:
# Обновляем заголовки в scope для совместимости
new_headers: list[tuple[bytes, bytes]] = []
for name, value in scope["headers"]:
header_name = name.decode("latin1") if isinstance(name, bytes) else str(name)
if header_name.lower() != SESSION_TOKEN_HEADER.lower():
# Ensure both name and value are bytes
name_bytes = name if isinstance(name, bytes) else str(name).encode("latin1")
value_bytes = value if isinstance(value, bytes) else str(value).encode("latin1")
new_headers.append((name_bytes, value_bytes))
new_headers.append((SESSION_TOKEN_HEADER.encode("latin1"), token.encode("latin1")))
scope["headers"] = new_headers
logger.debug(f"[middleware] Пользователь аутентифицирован: {user.is_authenticated}")
else:
logger.debug("[middleware] Токен не найден, пользователь неаутентифицирован")
await self.app(scope, receive, send)
def set_context(self, context) -> None:
"""Сохраняет ссылку на контекст GraphQL запроса"""
self._context = context
logger.debug(f"[middleware] Установлен контекст GraphQL: {bool(context)}")
def set_cookie(self, key: str, value: str, **options: Any) -> None:
"""
Устанавливает cookie в ответе
Args:
key: Имя cookie
value: Значение cookie
**options: Дополнительные параметры (httponly, secure, max_age, etc.)
"""
success = False
# Способ 1: Через response
if self._context and "response" in self._context and hasattr(self._context["response"], "set_cookie"):
try:
self._context["response"].set_cookie(key, value, **options)
logger.debug(f"[middleware] Установлена cookie {key} через response")
success = True
except Exception as e:
logger.error(f"[middleware] Ошибка при установке cookie {key} через response: {e!s}")
# Способ 2: Через собственный response в контексте
if not success and hasattr(self, "_response") and self._response and hasattr(self._response, "set_cookie"):
try:
self._response.set_cookie(key, value, **options)
logger.debug(f"[middleware] Установлена cookie {key} через _response")
success = True
except Exception as e:
logger.error(f"[middleware] Ошибка при установке cookie {key} через _response: {e!s}")
if not success:
logger.error(f"[middleware] Не удалось установить cookie {key}: объекты response недоступны")
def delete_cookie(self, key: str, **options: Any) -> None:
"""
Удаляет cookie из ответа
"""
success = False
# Способ 1: Через response
if self._context and "response" in self._context and hasattr(self._context["response"], "delete_cookie"):
try:
self._context["response"].delete_cookie(key, **options)
logger.debug(f"[middleware] Удалена cookie {key} через response")
success = True
except Exception as e:
logger.error(f"[middleware] Ошибка при удалении cookie {key} через response: {e!s}")
# Способ 2: Через собственный response в контексте
if not success and hasattr(self, "_response") and self._response and hasattr(self._response, "delete_cookie"):
try:
self._response.delete_cookie(key, **options)
logger.debug(f"[middleware] Удалена cookie {key} через _response")
success = True
except Exception as e:
logger.error(f"[middleware] Ошибка при удалении cookie {key} через _response: {e!s}")
if not success:
logger.error(f"[middleware] Не удалось удалить cookie {key}: объекты response недоступны")
async def resolve(
self, next_resolver: Callable[..., Any], root: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any
) -> Any:
"""
Middleware для обработки запросов GraphQL.
Добавляет методы для установки cookie в контекст.
"""
try:
# Получаем доступ к контексту запроса
context = info.context
# Сохраняем ссылку на контекст
self.set_context(context)
# Добавляем себя как объект, содержащий утилитные методы
context["extensions"] = self
# Проверяем наличие response в контексте
if "response" not in context or not context["response"]:
from starlette.responses import JSONResponse
context["response"] = JSONResponse({})
logger.debug("[middleware] Создан новый response объект в контексте GraphQL")
logger.debug("[middleware] GraphQL resolve: контекст подготовлен, добавлены расширения для работы с cookie")
return await next_resolver(root, info, *args, **kwargs)
except Exception as e:
logger.error(f"[AuthMiddleware] Ошибка в GraphQL resolve: {e!s}")
raise
async def process_result(self, request: Request, result: Any) -> Response:
"""
Обрабатывает результат GraphQL запроса, поддерживая установку cookie
Args:
request: Starlette Request объект
result: результат GraphQL запроса (dict или Response)
Returns:
Response: HTTP-ответ с результатом и cookie (если необходимо)
"""
# Проверяем, является ли result уже объектом Response
if isinstance(result, Response):
response = result
# Пытаемся получить данные из response для проверки логина/логаута
result_data = {}
if isinstance(result, JSONResponse):
try:
import json
body_content = result.body
if isinstance(body_content, (bytes, memoryview)):
body_text = bytes(body_content).decode("utf-8")
result_data = json.loads(body_text)
else:
result_data = json.loads(str(body_content))
except Exception as e:
logger.error(f"[process_result] Не удалось извлечь данные из JSONResponse: {e!s}")
else:
response = JSONResponse(result)
result_data = result
# Проверяем, был ли токен в запросе или ответе
if request.method == "POST":
try:
data = await request.json()
op_name = data.get("operationName", "").lower()
# Если это операция логина или обновления токена, и в ответе есть токен
if op_name in ["login", "refreshtoken"]:
token = None
# Пытаемся извлечь токен из данных ответа
if result_data and isinstance(result_data, dict):
data_obj = result_data.get("data", {})
if isinstance(data_obj, dict) and op_name in data_obj:
op_result = data_obj.get(op_name, {})
if isinstance(op_result, dict) and "token" in op_result:
token = op_result.get("token")
if token:
# Устанавливаем cookie с токеном
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=SESSION_COOKIE_HTTPONLY,
secure=SESSION_COOKIE_SECURE,
samesite=SESSION_COOKIE_SAMESITE,
max_age=SESSION_COOKIE_MAX_AGE,
)
logger.debug(
f"[graphql_handler] Установлена cookie {SESSION_COOKIE_NAME} для операции {op_name}"
)
# Если это операция logout, удаляем cookie
elif op_name == "logout":
response.delete_cookie(
key=SESSION_COOKIE_NAME,
secure=SESSION_COOKIE_SECURE,
httponly=SESSION_COOKIE_HTTPONLY,
samesite=SESSION_COOKIE_SAMESITE,
)
logger.debug(f"[graphql_handler] Удалена cookie {SESSION_COOKIE_NAME} для операции {op_name}")
except Exception as e:
logger.error(f"[process_result] Ошибка при обработке POST запроса: {e!s}")
return response
# Создаем единый экземпляр AuthMiddleware для использования с GraphQL
async def _dummy_app(
scope: MutableMapping[str, Any],
receive: Callable[[], Awaitable[MutableMapping[str, Any]]],
send: Callable[[MutableMapping[str, Any]], Awaitable[None]],
) -> None:
"""Dummy ASGI app for middleware initialization"""
auth_middleware = AuthMiddleware(_dummy_app)

View File

@ -1,618 +1,98 @@
import time
from secrets import token_urlsafe
from typing import Any, Callable, Optional
import orjson
from authlib.integrations.starlette_client import OAuth
from authlib.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 starlette.responses import RedirectResponse
from auth.identity import Identity
from auth.tokenstorage import TokenStorage
from settings import FRONTEND_URL, OAUTH_CLIENTS
oauth = OAuth()
# OAuth state management через Redis (TTL 10 минут)
OAUTH_STATE_TTL = 600 # 10 минут
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"},
)
# Конфигурация провайдеров для регистрации
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="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"},
)
# Константы для генерации временного email
TEMP_EMAIL_SUFFIX = "@oauth.local"
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",
)
def _generate_temp_email(provider: str, user_id: str) -> str:
"""Генерирует временный email для OAuth провайдеров без email"""
return f"{provider}_{user_id}@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 _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 facebook_profile(client, request, token):
profile = await client.get("me?fields=name,id,email", 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)
# Провайдеры со специальной обработкой данных
PROVIDER_HANDLERS = {
"google": lambda token, _: {
"id": token.get("userinfo", {}).get("sub"),
"email": token.get("userinfo", {}).get("email"),
"name": token.get("userinfo", {}).get("name"),
"picture": token.get("userinfo", {}).get("picture", "").replace("=s96", "=s600"),
},
"telegram": lambda token, _: {
"id": str(token.get("id", "")),
"email": None,
"phone": str(token.get("phone_number", "")),
"name": token.get("first_name", "") + " " + token.get("last_name", ""),
"picture": token.get("photo_url"),
},
"x": lambda _, profile_data: {
"id": profile_data.get("data", {}).get("id"),
"email": None,
"name": profile_data.get("data", {}).get("name") or profile_data.get("data", {}).get("username"),
"picture": profile_data.get("data", {}).get("profile_image_url", "").replace("_normal", "_400x400"),
},
}
async def _fetch_github_profile(client: Any, token: Any) -> dict:
"""Получает профиль из GitHub API"""
async def github_profile(client, request, token):
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"),
}
return profile.json()
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"),
}
profile_callbacks = {
"google": google_profile,
"facebook": facebook_profile,
"github": github_profile,
}
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)
async def oauth_login(request):
provider = request.path_params["provider"]
request.session["provider"] = provider
client = oauth.create_client(provider)
if not client:
return JSONResponse({"error": "Provider not configured"}, status_code=400)
redirect_uri = "https://v2.discours.io/oauth-authorize"
return await client.authorize_redirect(request, redirect_uri)
# Получаем параметры из 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()),
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"],
}
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
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

View File

@ -1,268 +0,0 @@
import time
from typing import Any, Dict, Optional
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
from sqlalchemy.orm import Session
from auth.identity import Password
from services.db import BaseModel as Base
# Общие table_args для всех моделей
DEFAULT_TABLE_ARGS = {"extend_existing": True}
"""
Модель закладок автора
"""
class AuthorBookmark(Base):
"""
Закладка автора на публикацию.
Attributes:
author (int): ID автора
shout (int): ID публикации
"""
__tablename__ = "author_bookmark"
__table_args__ = (
Index("idx_author_bookmark_author", "author"),
Index("idx_author_bookmark_shout", "shout"),
{"extend_existing": True},
)
author = Column(ForeignKey("author.id"), primary_key=True)
shout = Column(ForeignKey("shout.id"), primary_key=True)
class AuthorRating(Base):
"""
Рейтинг автора от другого автора.
Attributes:
rater (int): ID оценивающего автора
author (int): ID оцениваемого автора
plus (bool): Положительная/отрицательная оценка
"""
__tablename__ = "author_rating"
__table_args__ = (
Index("idx_author_rating_author", "author"),
Index("idx_author_rating_rater", "rater"),
{"extend_existing": True},
)
rater = Column(ForeignKey("author.id"), primary_key=True)
author = Column(ForeignKey("author.id"), primary_key=True)
plus = Column(Boolean)
class AuthorFollower(Base):
"""
Подписка одного автора на другого.
Attributes:
follower (int): ID подписчика
author (int): ID автора, на которого подписываются
created_at (int): Время создания подписки
auto (bool): Признак автоматической подписки
"""
__tablename__ = "author_follower"
__table_args__ = (
Index("idx_author_follower_author", "author"),
Index("idx_author_follower_follower", "follower"),
{"extend_existing": True},
)
id = None # type: ignore[assignment]
follower = Column(ForeignKey("author.id"), primary_key=True)
author = Column(ForeignKey("author.id"), primary_key=True)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
auto = Column(Boolean, nullable=False, default=False)
class Author(Base):
"""
Расширенная модель автора с функциями аутентификации и авторизации
"""
__tablename__ = "author"
__table_args__ = (
Index("idx_author_slug", "slug"),
Index("idx_author_email", "email"),
Index("idx_author_phone", "phone"),
{"extend_existing": True},
)
# Базовые поля автора
id = Column(Integer, primary_key=True)
name = Column(String, nullable=True, comment="Display name")
slug = Column(String, unique=True, comment="Author's slug")
bio = Column(String, nullable=True, comment="Bio") # короткое описание
about = Column(String, nullable=True, comment="About") # длинное форматированное описание
pic = Column(String, nullable=True, comment="Picture")
links = Column(JSON, nullable=True, comment="Links")
# OAuth аккаунты - JSON с данными всех провайдеров
# Формат: {"google": {"id": "123", "email": "user@gmail.com"}, "github": {"id": "456"}}
oauth = Column(JSON, nullable=True, default=dict, comment="OAuth accounts data")
# Поля аутентификации
email = Column(String, unique=True, nullable=True, comment="Email")
phone = Column(String, unique=True, nullable=True, comment="Phone")
password = Column(String, nullable=True, comment="Password hash")
email_verified = Column(Boolean, default=False)
phone_verified = Column(Boolean, default=False)
failed_login_attempts = Column(Integer, default=0)
account_locked_until = Column(Integer, nullable=True)
# Временные метки
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
last_seen = Column(Integer, nullable=False, default=lambda: int(time.time()))
deleted_at = Column(Integer, nullable=True)
oid = Column(String, nullable=True)
# Список защищенных полей, которые видны только владельцу и администраторам
_protected_fields = ["email", "password", "provider_access_token", "provider_refresh_token"]
@property
def is_authenticated(self) -> bool:
"""Проверяет, аутентифицирован ли пользователь"""
return self.id is not None
def verify_password(self, password: str) -> bool:
"""Проверяет пароль пользователя"""
return Password.verify(password, str(self.password)) if self.password else False
def set_password(self, password: str):
"""Устанавливает пароль пользователя"""
self.password = Password.encode(password) # type: ignore[assignment]
def increment_failed_login(self):
"""Увеличивает счетчик неудачных попыток входа"""
self.failed_login_attempts += 1 # type: ignore[assignment]
if self.failed_login_attempts >= 5:
self.account_locked_until = int(time.time()) + 300 # type: ignore[assignment] # 5 минут
def reset_failed_login(self):
"""Сбрасывает счетчик неудачных попыток входа"""
self.failed_login_attempts = 0 # type: ignore[assignment]
self.account_locked_until = None # type: ignore[assignment]
def is_locked(self) -> bool:
"""Проверяет, заблокирован ли аккаунт"""
if not self.account_locked_until:
return False
return bool(self.account_locked_until > int(time.time()))
@property
def username(self) -> str:
"""
Возвращает имя пользователя для использования в токенах.
Необходимо для совместимости с TokenStorage и JWTCodec.
Returns:
str: slug, email или phone пользователя
"""
return str(self.slug or self.email or self.phone or "")
def dict(self, access: bool = False) -> Dict[str, Any]:
"""
Сериализует объект автора в словарь.
Args:
access: Если True, включает защищенные поля
Returns:
Dict: Словарь с данными автора
"""
result: Dict[str, Any] = {
"id": self.id,
"name": self.name,
"slug": self.slug,
"bio": self.bio,
"about": self.about,
"pic": self.pic,
"links": self.links,
"created_at": self.created_at,
"updated_at": self.updated_at,
"last_seen": self.last_seen,
"deleted_at": self.deleted_at,
"email_verified": self.email_verified,
}
# Добавляем защищенные поля только если запрошен полный доступ
if access:
result.update({"email": self.email, "phone": self.phone, "oauth": self.oauth})
return result
@classmethod
def find_by_oauth(cls, provider: str, provider_id: str, session: Session) -> Optional["Author"]:
"""
Находит автора по OAuth провайдеру и ID
Args:
provider (str): Имя OAuth провайдера (google, github и т.д.)
provider_id (str): ID пользователя у провайдера
session: Сессия базы данных
Returns:
Author или None: Найденный автор или None если не найден
"""
# Ищем авторов, у которых есть данный провайдер с данным ID
authors = session.query(cls).filter(cls.oauth.isnot(None)).all()
for author in authors:
if author.oauth and provider in author.oauth:
oauth_data = author.oauth[provider] # type: ignore[index]
if isinstance(oauth_data, dict) and oauth_data.get("id") == provider_id:
return author
return None
def set_oauth_account(self, provider: str, provider_id: str, email: Optional[str] = None) -> None:
"""
Устанавливает OAuth аккаунт для автора
Args:
provider (str): Имя OAuth провайдера (google, github и т.д.)
provider_id (str): ID пользователя у провайдера
email (Optional[str]): Email от провайдера
"""
if not self.oauth:
self.oauth = {} # type: ignore[assignment]
oauth_data: Dict[str, str] = {"id": provider_id}
if email:
oauth_data["email"] = email
self.oauth[provider] = oauth_data # type: ignore[index]
def get_oauth_account(self, provider: str) -> Optional[Dict[str, Any]]:
"""
Получает OAuth аккаунт провайдера
Args:
provider (str): Имя OAuth провайдера
Returns:
dict или None: Данные OAuth аккаунта или None если не найден
"""
oauth_data = getattr(self, "oauth", None)
if not oauth_data:
return None
if isinstance(oauth_data, dict):
return oauth_data.get(provider)
return None
def remove_oauth_account(self, provider: str):
"""
Удаляет OAuth аккаунт провайдера
Args:
provider (str): Имя OAuth провайдера
"""
if self.oauth and provider in self.oauth:
del self.oauth[provider]

View File

@ -1,146 +0,0 @@
"""
Модуль для проверки разрешений пользователей в контексте сообществ.
Позволяет проверять доступ пользователя к определенным операциям в сообществе
на основе его роли в этом сообществе.
"""
from sqlalchemy.orm import Session
from auth.orm import Author
from orm.community import Community, CommunityAuthor
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
class ContextualPermissionCheck:
"""
Класс для проверки контекстно-зависимых разрешений.
Позволяет проверять разрешения пользователя в контексте сообщества,
учитывая как глобальные роли пользователя, так и его роли внутри сообщества.
"""
@staticmethod
async def check_community_permission(
session: Session, author_id: int, community_slug: str, resource: str, operation: str
) -> bool:
"""
Проверяет наличие разрешения у пользователя в контексте сообщества.
Args:
session: Сессия SQLAlchemy
author_id: ID автора/пользователя
community_slug: Slug сообщества
resource: Ресурс для доступа
operation: Операция над ресурсом
Returns:
bool: True, если пользователь имеет разрешение, иначе False
"""
# 1. Проверка глобальных разрешений (например, администратор)
author = session.query(Author).filter(Author.id == author_id).one_or_none()
if not author:
return False
# Если это администратор (по списку email)
if author.email in ADMIN_EMAILS:
return True
# 2. Проверка разрешений в контексте сообщества
# Получаем информацию о сообществе
community = session.query(Community).filter(Community.slug == community_slug).one_or_none()
if not community:
return False
# Если автор является создателем сообщества, то у него есть полные права
if community.created_by == author_id:
return True
# Проверяем наличие разрешения для этих ролей
permission_id = f"{resource}:{operation}"
ca = CommunityAuthor.find_by_user_and_community(author_id, community.id, session)
return bool(await ca.has_permission(permission_id))
@staticmethod
async def get_user_community_roles(session: Session, author_id: int, community_slug: str) -> list[str]:
"""
Получает список ролей пользователя в сообществе.
Args:
session: Сессия SQLAlchemy
author_id: ID автора/пользователя
community_slug: Slug сообщества
Returns:
List[CommunityRole]: Список ролей пользователя в сообществе
"""
# Получаем информацию о сообществе
community = session.query(Community).filter(Community.slug == community_slug).one_or_none()
if not community:
return []
# Если автор является создателем сообщества, то у него есть роль владельца
if community.created_by == author_id:
return ["editor", "author", "expert", "reader"]
ca = CommunityAuthor.find_by_user_and_community(author_id, community.id, session)
return ca.role_list if ca else []
@staticmethod
async def assign_role_to_user(session: Session, author_id: int, community_slug: str, role: str) -> bool:
"""
Назначает роль пользователю в сообществе.
Args:
session: Сессия SQLAlchemy
author_id: ID автора/пользователя
community_slug: Slug сообщества
role: Роль для назначения (CommunityRole или строковое представление)
Returns:
bool: True если роль успешно назначена, иначе False
"""
# Получаем информацию о сообществе
community = session.query(Community).filter(Community.slug == community_slug).one_or_none()
if not community:
return False
# Проверяем существование связи автор-сообщество
ca = CommunityAuthor.find_by_user_and_community(author_id, community.id, session)
if not ca:
return False
# Назначаем роль
ca.add_role(role)
return True
@staticmethod
async def revoke_role_from_user(session: Session, author_id: int, community_slug: str, role: str) -> bool:
"""
Отзывает роль у пользователя в сообществе.
Args:
session: Сессия SQLAlchemy
author_id: ID автора/пользователя
community_slug: Slug сообщества
role: Роль для отзыва (CommunityRole или строковое представление)
Returns:
bool: True если роль успешно отозвана, иначе False
"""
# Получаем информацию о сообществе
community = session.query(Community).filter(Community.slug == community_slug).one_or_none()
if not community:
return False
# Проверяем существование связи автор-сообщество
ca = CommunityAuthor.find_by_user_and_community(author_id, community.id, session)
if not ca:
return False
# Отзываем роль
ca.remove_role(role)
return True

215
auth/resolvers.py Normal file
View File

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

View File

@ -1,25 +0,0 @@
"""
Классы состояния авторизации
"""
from typing import Optional
class AuthState:
"""
Класс для хранения информации о состоянии авторизации пользователя.
Используется в аутентификационных middleware и функциях.
"""
def __init__(self) -> None:
self.logged_in: bool = False
self.author_id: Optional[str] = None
self.token: Optional[str] = None
self.username: Optional[str] = None
self.is_admin: bool = False
self.is_editor: bool = False
self.error: Optional[str] = None
def __bool__(self) -> bool:
"""Возвращает True если пользователь авторизован"""
return self.logged_in

View File

@ -1,54 +0,0 @@
"""
Базовый класс для работы с токенами
"""
import secrets
from functools import lru_cache
from typing import Optional
from .types import TokenType
class BaseTokenManager:
"""
Базовый класс с общими методами для всех типов токенов
"""
@staticmethod
@lru_cache(maxsize=1000)
def _make_token_key(token_type: TokenType, identifier: str, token: Optional[str] = None) -> str:
"""
Создает унифицированный ключ для токена с кэшированием
Args:
token_type: Тип токена
identifier: Идентификатор (user_id, user_id:provider, etc)
token: Сам токен (для session и verification)
Returns:
str: Ключ токена
"""
if token_type == "session": # noqa: S105
return f"session:{identifier}:{token}"
if token_type == "verification": # noqa: S105
return f"verification_token:{token}"
if token_type == "oauth_access": # noqa: S105
return f"oauth_access:{identifier}"
if token_type == "oauth_refresh": # noqa: S105
return f"oauth_refresh:{identifier}"
error_msg = f"Неизвестный тип токена: {token_type}"
raise ValueError(error_msg)
@staticmethod
@lru_cache(maxsize=500)
def _make_user_tokens_key(user_id: str, token_type: TokenType) -> str:
"""Создает ключ для списка токенов пользователя"""
if token_type == "session": # noqa: S105
return f"user_sessions:{user_id}"
return f"user_tokens:{user_id}:{token_type}"
@staticmethod
def generate_token() -> str:
"""Генерирует криптографически стойкий токен"""
return secrets.token_urlsafe(32)

View File

@ -1,197 +0,0 @@
"""
Батчевые операции с токенами для оптимизации производительности
"""
import asyncio
from typing import Any, Dict, List, Optional
from auth.jwtcodec import JWTCodec
from services.redis import redis as redis_adapter
from utils.logger import root_logger as logger
from .base import BaseTokenManager
from .types import BATCH_SIZE
class BatchTokenOperations(BaseTokenManager):
"""
Класс для пакетных операций с токенами
"""
async def batch_validate_tokens(self, tokens: List[str]) -> Dict[str, bool]:
"""
Пакетная валидация токенов для улучшения производительности
Args:
tokens: Список токенов для валидации
Returns:
Dict[str, bool]: Словарь {токен: валиден}
"""
if not tokens:
return {}
results = {}
# Разбиваем на батчи для избежания блокировки Redis
for i in range(0, len(tokens), BATCH_SIZE):
batch = tokens[i : i + BATCH_SIZE]
batch_results = await self._validate_token_batch(batch)
results.update(batch_results)
return results
async def _validate_token_batch(self, token_batch: List[str]) -> Dict[str, bool]:
"""Валидация батча токенов"""
results = {}
# Создаем задачи для декодирования токенов пакетно
decode_tasks = [asyncio.create_task(self._safe_decode_token(token)) for token in token_batch]
decoded_payloads = await asyncio.gather(*decode_tasks, return_exceptions=True)
# Подготавливаем ключи для проверки
token_keys = []
valid_tokens = []
for token, payload in zip(token_batch, decoded_payloads):
if isinstance(payload, Exception) or not payload or not hasattr(payload, "user_id"):
results[token] = False
continue
token_key = self._make_token_key("session", payload.user_id, token)
token_keys.append(token_key)
valid_tokens.append(token)
# Проверяем существование ключей пакетно
if token_keys:
async with redis_adapter.pipeline() as pipe:
for key in token_keys:
await pipe.exists(key)
existence_results = await pipe.execute()
for token, exists in zip(valid_tokens, existence_results):
results[token] = bool(exists)
return results
async def _safe_decode_token(self, token: str) -> Optional[Any]:
"""Безопасное декодирование токена"""
try:
return JWTCodec.decode(token)
except Exception:
return None
async def batch_revoke_tokens(self, tokens: List[str]) -> int:
"""
Пакетный отзыв токенов
Args:
tokens: Список токенов для отзыва
Returns:
int: Количество отозванных токенов
"""
if not tokens:
return 0
revoked_count = 0
# Обрабатываем батчами
for i in range(0, len(tokens), BATCH_SIZE):
batch = tokens[i : i + BATCH_SIZE]
batch_count = await self._revoke_token_batch(batch)
revoked_count += batch_count
return revoked_count
async def _revoke_token_batch(self, token_batch: List[str]) -> int:
"""Отзыв батча токенов"""
keys_to_delete = []
user_updates: Dict[str, set[str]] = {} # {user_id: {tokens_to_remove}}
# Декодируем токены и подготавливаем операции
for token in token_batch:
payload = await self._safe_decode_token(token)
if payload:
user_id = payload.user_id
username = payload.username
# Ключи для удаления
new_key = self._make_token_key("session", user_id, token)
old_key = f"{user_id}-{username}-{token}"
keys_to_delete.extend([new_key, old_key])
# Обновления пользовательских списков
if user_id not in user_updates:
user_updates[user_id] = set()
user_updates[user_id].add(token)
if not keys_to_delete:
return 0
# Выполняем удаление пакетно
async with redis_adapter.pipeline() as pipe:
# Удаляем ключи токенов
await pipe.delete(*keys_to_delete)
# Обновляем пользовательские списки
for user_id, tokens_to_remove in user_updates.items():
user_tokens_key = self._make_user_tokens_key(user_id, "session")
for token in tokens_to_remove:
await pipe.srem(user_tokens_key, token)
results = await pipe.execute()
return len([r for r in results if r > 0])
async def cleanup_expired_tokens(self) -> int:
"""Оптимизированная очистка истекших токенов с использованием SCAN"""
try:
cleaned_count = 0
cursor = 0
# Ищем все ключи пользовательских сессий
while True:
cursor, keys = await redis_adapter.execute("scan", cursor, "user_sessions:*", 100)
for user_tokens_key in keys:
tokens = await redis_adapter.smembers(user_tokens_key)
active_tokens = []
# Проверяем активность токенов пакетно
if tokens:
async with redis_adapter.pipeline() as pipe:
for token in tokens:
token_str = token if isinstance(token, str) else str(token)
session_key = self._make_token_key("session", user_tokens_key.split(":")[1], token_str)
await pipe.exists(session_key)
results = await pipe.execute()
for token, exists in zip(tokens, results):
if exists:
active_tokens.append(token)
else:
cleaned_count += 1
# Обновляем список активных токенов
if active_tokens:
async with redis_adapter.pipeline() as pipe:
await pipe.delete(user_tokens_key)
for token in active_tokens:
await pipe.sadd(user_tokens_key, token)
await pipe.execute()
else:
await redis_adapter.delete(user_tokens_key)
if cursor == 0:
break
if cleaned_count > 0:
logger.info(f"Очищено {cleaned_count} ссылок на истекшие токены")
return cleaned_count
except Exception as e:
logger.error(f"Ошибка очистки токенов: {e}")
return 0

View File

@ -1,189 +0,0 @@
"""
Статистика и мониторинг системы токенов
"""
import asyncio
from typing import Any, Dict
from services.redis import redis as redis_adapter
from utils.logger import root_logger as logger
from .base import BaseTokenManager
from .types import SCAN_BATCH_SIZE
class TokenMonitoring(BaseTokenManager):
"""
Класс для мониторинга и статистики токенов
"""
async def get_token_statistics(self) -> Dict[str, Any]:
"""
Получает статистику по токенам для мониторинга
Returns:
Dict: Статистика токенов
"""
stats = {
"session_tokens": 0,
"verification_tokens": 0,
"oauth_access_tokens": 0,
"oauth_refresh_tokens": 0,
"user_sessions": 0,
"memory_usage": 0,
}
try:
# Считаем токены по типам используя SCAN
patterns = {
"session_tokens": "session:*",
"verification_tokens": "verification_token:*",
"oauth_access_tokens": "oauth_access:*",
"oauth_refresh_tokens": "oauth_refresh:*",
"user_sessions": "user_sessions:*",
}
count_tasks = [self._count_keys_by_pattern(pattern) for pattern in patterns.values()]
counts = await asyncio.gather(*count_tasks)
for (stat_name, _), count in zip(patterns.items(), counts):
stats[stat_name] = count
# Получаем информацию о памяти Redis
memory_info = await redis_adapter.execute("INFO", "MEMORY")
stats["memory_usage"] = memory_info.get("used_memory", 0)
except Exception as e:
logger.error(f"Ошибка получения статистики токенов: {e}")
return stats
async def _count_keys_by_pattern(self, pattern: str) -> int:
"""Подсчет ключей по паттерну используя SCAN"""
count = 0
cursor = 0
while True:
cursor, keys = await redis_adapter.execute("scan", cursor, pattern, SCAN_BATCH_SIZE)
count += len(keys)
if cursor == 0:
break
return count
async def optimize_memory_usage(self) -> Dict[str, Any]:
"""
Оптимизирует использование памяти Redis
Returns:
Dict: Результаты оптимизации
"""
results = {"cleaned_expired": 0, "optimized_structures": 0, "memory_saved": 0}
try:
# Очищаем истекшие токены
from .batch import BatchTokenOperations
batch_ops = BatchTokenOperations()
cleaned = await batch_ops.cleanup_expired_tokens()
results["cleaned_expired"] = cleaned
# Оптимизируем структуры данных
optimized = await self._optimize_data_structures()
results["optimized_structures"] = optimized
# Запускаем сборку мусора Redis
await redis_adapter.execute("MEMORY", "PURGE")
logger.info(f"Оптимизация памяти завершена: {results}")
except Exception as e:
logger.error(f"Ошибка оптимизации памяти: {e}")
return results
async def _optimize_data_structures(self) -> int:
"""Оптимизирует структуры данных Redis"""
optimized_count = 0
cursor = 0
# Оптимизируем пользовательские списки сессий
while True:
cursor, keys = await redis_adapter.execute("scan", cursor, "user_sessions:*", SCAN_BATCH_SIZE)
for key in keys:
try:
# Проверяем размер множества
size = await redis_adapter.execute("scard", key)
if size == 0:
await redis_adapter.delete(key)
optimized_count += 1
elif size > 100: # Слишком много сессий у одного пользователя
# Оставляем только последние 50 сессий
members = await redis_adapter.execute("smembers", key)
if len(members) > 50:
members_list = list(members)
to_remove = members_list[:-50]
if to_remove:
await redis_adapter.srem(key, *to_remove)
optimized_count += len(to_remove)
except Exception as e:
logger.error(f"Ошибка оптимизации ключа {key}: {e}")
continue
if cursor == 0:
break
return optimized_count
async def health_check(self) -> Dict[str, Any]:
"""
Проверка здоровья системы токенов
Returns:
Dict: Результаты проверки
"""
health: Dict[str, Any] = {
"status": "healthy",
"redis_connected": False,
"token_operations": False,
"errors": [],
}
try:
# Проверяем подключение к Redis
await redis_adapter.ping()
health["redis_connected"] = True
# Тестируем основные операции с токенами
from .sessions import SessionTokenManager
session_manager = SessionTokenManager()
test_user_id = "health_check_user"
test_token = await session_manager.create_session(test_user_id)
if test_token:
# Проверяем валидацию
valid, _ = await session_manager.validate_session_token(test_token)
if valid:
# Проверяем отзыв
revoked = await session_manager.revoke_session_token(test_token)
if revoked:
health["token_operations"] = True
else:
health["errors"].append("Failed to revoke test token") # type: ignore[misc]
else:
health["errors"].append("Failed to validate test token") # type: ignore[misc]
else:
health["errors"].append("Failed to create test token") # type: ignore[misc]
except Exception as e:
health["errors"].append(f"Health check error: {e}") # type: ignore[misc]
if health["errors"]:
health["status"] = "unhealthy"
return health

View File

@ -1,155 +0,0 @@
"""
Управление OAuth токенов
"""
import json
import time
from typing import Optional
from services.redis import redis as redis_adapter
from utils.logger import root_logger as logger
from .base import BaseTokenManager
from .types import DEFAULT_TTL, TokenData, TokenType
class OAuthTokenManager(BaseTokenManager):
"""
Менеджер OAuth токенов
"""
async def store_oauth_tokens(
self,
user_id: str,
provider: str,
access_token: str,
refresh_token: Optional[str] = None,
expires_in: Optional[int] = None,
additional_data: Optional[TokenData] = None,
) -> bool:
"""Сохраняет OAuth токены"""
try:
# Сохраняем access token
access_data = {
"token": access_token,
"provider": provider,
"expires_in": expires_in,
**(additional_data or {}),
}
access_ttl = expires_in if expires_in else DEFAULT_TTL["oauth_access"]
await self._create_oauth_token(user_id, access_data, access_ttl, provider, "oauth_access")
# Сохраняем refresh token если есть
if refresh_token:
refresh_data = {
"token": refresh_token,
"provider": provider,
}
await self._create_oauth_token(
user_id, refresh_data, DEFAULT_TTL["oauth_refresh"], provider, "oauth_refresh"
)
return True
except Exception as e:
logger.error(f"Ошибка сохранения OAuth токенов: {e}")
return False
async def _create_oauth_token(
self, user_id: str, token_data: TokenData, ttl: int, provider: str, token_type: TokenType
) -> str:
"""Оптимизированное создание OAuth токена"""
if not provider:
error_msg = "OAuth токены требуют указания провайдера"
raise ValueError(error_msg)
identifier = f"{user_id}:{provider}"
token_key = self._make_token_key(token_type, identifier)
# Добавляем метаданные
token_data.update(
{"user_id": user_id, "token_type": token_type, "provider": provider, "created_at": int(time.time())}
)
# Используем SETEX для атомарной операции
serialized_data = json.dumps(token_data, ensure_ascii=False)
await redis_adapter.execute("setex", token_key, ttl, serialized_data)
logger.info(f"Создан {token_type} токен для пользователя {user_id}, провайдер {provider}")
return token_key
async def get_token(self, user_id: int, provider: str, token_type: TokenType) -> Optional[TokenData]:
"""Получает токен"""
if token_type.startswith("oauth_"):
return await self._get_oauth_data_optimized(token_type, str(user_id), provider)
return None
async def _get_oauth_data_optimized(
self, token_type: TokenType, user_id: str, provider: str
) -> Optional[TokenData]:
"""Оптимизированное получение OAuth данных"""
if not user_id or not provider:
error_msg = "OAuth токены требуют user_id и provider"
raise ValueError(error_msg)
identifier = f"{user_id}:{provider}"
token_key = self._make_token_key(token_type, identifier)
# Получаем данные и TTL в одном pipeline
async with redis_adapter.pipeline() as pipe:
await pipe.get(token_key)
await pipe.ttl(token_key)
results = await pipe.execute()
if results[0]:
token_data = json.loads(results[0])
if results[1] > 0:
token_data["ttl_remaining"] = results[1]
return token_data
return None
async def revoke_oauth_tokens(self, user_id: str, provider: str) -> bool:
"""Удаляет все OAuth токены для провайдера"""
try:
result1 = await self._revoke_oauth_token_optimized("oauth_access", user_id, provider)
result2 = await self._revoke_oauth_token_optimized("oauth_refresh", user_id, provider)
return result1 or result2
except Exception as e:
logger.error(f"Ошибка удаления OAuth токенов: {e}")
return False
async def _revoke_oauth_token_optimized(self, token_type: TokenType, user_id: str, provider: str) -> bool:
"""Оптимизированный отзыв OAuth токена"""
if not user_id or not provider:
error_msg = "OAuth токены требуют user_id и provider"
raise ValueError(error_msg)
identifier = f"{user_id}:{provider}"
token_key = self._make_token_key(token_type, identifier)
result = await redis_adapter.delete(token_key)
return result > 0
async def revoke_user_oauth_tokens(self, user_id: str, token_type: TokenType) -> int:
"""Оптимизированный отзыв OAuth токенов пользователя используя SCAN"""
count = 0
cursor = 0
delete_keys = []
pattern = f"{token_type}:{user_id}:*"
# Используем SCAN для безопасного поиска токенов
while True:
cursor, keys = await redis_adapter.execute("scan", cursor, pattern, 100)
if keys:
delete_keys.extend(keys)
count += len(keys)
if cursor == 0:
break
# Удаляем найденные токены пакетно
if delete_keys:
await redis_adapter.delete(*delete_keys)
return count

View File

@ -1,267 +0,0 @@
"""
Управление токенами сессий
"""
import json
import time
from typing import Any, List, Optional, Union
from auth.jwtcodec import JWTCodec
from services.redis import redis as redis_adapter
from utils.logger import root_logger as logger
from .base import BaseTokenManager
from .types import DEFAULT_TTL, TokenData
class SessionTokenManager(BaseTokenManager):
"""
Менеджер токенов сессий
"""
async def create_session(
self,
user_id: str,
auth_data: Optional[dict] = None,
username: Optional[str] = None,
device_info: Optional[dict] = None,
) -> str:
"""Создает токен сессии"""
session_data = {}
if auth_data:
session_data["auth_data"] = json.dumps(auth_data)
if username:
session_data["username"] = username
if device_info:
session_data["device_info"] = json.dumps(device_info)
return await self.create_session_token(user_id, session_data)
async def create_session_token(self, user_id: str, token_data: TokenData) -> str:
"""Создание JWT токена сессии"""
username = token_data.get("username", "")
# Создаем JWT токен
jwt_token = JWTCodec.encode(
{
"user_id": user_id,
"username": username,
}
)
session_token = jwt_token
token_key = self._make_token_key("session", user_id, session_token)
user_tokens_key = self._make_user_tokens_key(user_id, "session")
ttl = DEFAULT_TTL["session"]
# Добавляем метаданные
token_data.update({"user_id": user_id, "token_type": "session", "created_at": int(time.time())})
# Используем новый метод execute_pipeline для избежания deprecated warnings
commands: list[tuple[str, tuple[Any, ...]]] = []
# Сохраняем данные сессии в hash, преобразуя значения в строки
for field, value in token_data.items():
commands.append(("hset", (token_key, field, str(value))))
commands.append(("expire", (token_key, ttl)))
# Добавляем в список сессий пользователя
commands.append(("sadd", (user_tokens_key, session_token)))
commands.append(("expire", (user_tokens_key, ttl)))
await redis_adapter.execute_pipeline(commands)
logger.info(f"Создан токен сессии для пользователя {user_id}")
return session_token
async def get_session_data(self, token: str, user_id: Optional[str] = None) -> Optional[TokenData]:
"""Получение данных сессии"""
if not user_id:
# Извлекаем user_id из JWT
payload = JWTCodec.decode(token)
if payload:
user_id = payload.user_id
else:
return None
token_key = self._make_token_key("session", user_id, token)
# Используем новый метод execute_pipeline для избежания deprecated warnings
commands: list[tuple[str, tuple[Any, ...]]] = [
("hgetall", (token_key,)),
("hset", (token_key, "last_activity", str(int(time.time())))),
]
results = await redis_adapter.execute_pipeline(commands)
token_data = results[0] if results else None
return dict(token_data) if token_data else None
async def validate_session_token(self, token: str) -> tuple[bool, Optional[TokenData]]:
"""
Проверяет валидность токена сессии
"""
try:
# Декодируем JWT токен
payload = JWTCodec.decode(token)
if not payload:
return False, None
user_id = payload.user_id
token_key = self._make_token_key("session", user_id, token)
# Проверяем существование и получаем данные
commands: list[tuple[str, tuple[Any, ...]]] = [("exists", (token_key,)), ("hgetall", (token_key,))]
results = await redis_adapter.execute_pipeline(commands)
if results and results[0]: # exists
return True, dict(results[1])
return False, None
except Exception as e:
logger.error(f"Ошибка валидации токена сессии: {e}")
return False, None
async def revoke_session_token(self, token: str) -> bool:
"""Отзыв токена сессии"""
payload = JWTCodec.decode(token)
if not payload:
return False
user_id = payload.user_id
# Используем новый метод execute_pipeline для избежания deprecated warnings
token_key = self._make_token_key("session", user_id, token)
user_tokens_key = self._make_user_tokens_key(user_id, "session")
commands: list[tuple[str, tuple[Any, ...]]] = [("delete", (token_key,)), ("srem", (user_tokens_key, token))]
results = await redis_adapter.execute_pipeline(commands)
return any(result > 0 for result in results if result is not None)
async def revoke_user_sessions(self, user_id: str) -> int:
"""Отзыв всех сессий пользователя"""
user_tokens_key = self._make_user_tokens_key(user_id, "session")
tokens = await redis_adapter.smembers(user_tokens_key)
if not tokens:
return 0
# Используем пакетное удаление
keys_to_delete = []
for token in tokens:
token_str = token if isinstance(token, str) else str(token)
keys_to_delete.append(self._make_token_key("session", user_id, token_str))
# Добавляем ключ списка токенов
keys_to_delete.append(user_tokens_key)
# Удаляем все ключи пакетно
if keys_to_delete:
await redis_adapter.delete(*keys_to_delete)
return len(tokens)
async def get_user_sessions(self, user_id: Union[int, str]) -> List[TokenData]:
"""Получение сессий пользователя"""
try:
user_tokens_key = self._make_user_tokens_key(str(user_id), "session")
tokens = await redis_adapter.smembers(user_tokens_key)
if not tokens:
return []
# Получаем данные всех сессий пакетно
sessions = []
async with redis_adapter.pipeline() as pipe:
for token in tokens:
token_str = token if isinstance(token, str) else str(token)
await pipe.hgetall(self._make_token_key("session", str(user_id), token_str))
results = await pipe.execute()
for token, session_data in zip(tokens, results):
if session_data:
token_str = token if isinstance(token, str) else str(token)
session_dict = dict(session_data)
session_dict["token"] = token_str
sessions.append(session_dict)
return sessions
except Exception as e:
logger.error(f"Ошибка получения сессий пользователя: {e}")
return []
async def refresh_session(self, user_id: int, old_token: str, device_info: Optional[dict] = None) -> Optional[str]:
"""
Обновляет сессию пользователя, заменяя старый токен новым
"""
try:
user_id_str = str(user_id)
# Получаем данные старой сессии
old_session_data = await self.get_session_data(old_token)
if not old_session_data:
logger.warning(f"Сессия не найдена: {user_id}")
return None
# Используем старые данные устройства, если новые не предоставлены
if not device_info and "device_info" in old_session_data:
try:
device_info = json.loads(old_session_data.get("device_info", "{}"))
except (json.JSONDecodeError, TypeError):
device_info = None
# Создаем новую сессию
new_token = await self.create_session(
user_id_str, device_info=device_info, username=old_session_data.get("username", "")
)
# Отзываем старую сессию
await self.revoke_session_token(old_token)
return new_token
except Exception as e:
logger.error(f"Ошибка обновления сессии: {e}")
return None
async def verify_session(self, token: str) -> Optional[Any]:
"""
Проверяет сессию по токену для совместимости с TokenStorage
"""
if not token:
logger.debug("Пустой токен")
return None
logger.debug(f"Проверка сессии для токена: {token[:20]}...")
try:
# Декодируем токен для получения payload
payload = JWTCodec.decode(token)
if not payload:
logger.error("Не удалось декодировать токен")
return None
if not hasattr(payload, "user_id"):
logger.error("В токене отсутствует user_id")
return None
logger.debug(f"Успешно декодирован токен, user_id={payload.user_id}")
# Проверяем наличие сессии в Redis
token_key = self._make_token_key("session", str(payload.user_id), token)
session_exists = await redis_adapter.exists(token_key)
if not session_exists:
logger.warning(f"Сессия не найдена в Redis для user_id={payload.user_id}")
return None
# Обновляем last_activity
await redis_adapter.hset(token_key, "last_activity", str(int(time.time())))
return payload
except Exception as e:
logger.error(f"Ошибка при проверке сессии: {e}")
return None

View File

@ -1,114 +0,0 @@
"""
Простой интерфейс для системы токенов
"""
from typing import Any, Optional
from .batch import BatchTokenOperations
from .monitoring import TokenMonitoring
from .oauth import OAuthTokenManager
from .sessions import SessionTokenManager
from .verification import VerificationTokenManager
class _TokenStorageImpl:
"""
Внутренний класс для фасада токенов.
Использует композицию вместо наследования.
"""
def __init__(self) -> None:
self._sessions = SessionTokenManager()
self._verification = VerificationTokenManager()
self._oauth = OAuthTokenManager()
self._batch = BatchTokenOperations()
self._monitoring = TokenMonitoring()
# === МЕТОДЫ ДЛЯ СЕССИЙ ===
async def create_session(
self,
user_id: str,
auth_data: Optional[dict] = None,
username: Optional[str] = None,
device_info: Optional[dict] = None,
) -> str:
"""Создание сессии пользователя"""
return await self._sessions.create_session(user_id, auth_data, username, device_info)
async def verify_session(self, token: str) -> Optional[Any]:
"""Проверка сессии по токену"""
return await self._sessions.verify_session(token)
async def refresh_session(self, user_id: int, old_token: str, device_info: Optional[dict] = None) -> Optional[str]:
"""Обновление сессии пользователя"""
return await self._sessions.refresh_session(user_id, old_token, device_info)
async def revoke_session(self, session_token: str) -> bool:
"""Отзыв сессии"""
return await self._sessions.revoke_session_token(session_token)
async def revoke_user_sessions(self, user_id: str) -> int:
"""Отзыв всех сессий пользователя"""
return await self._sessions.revoke_user_sessions(user_id)
# === ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ===
async def cleanup_expired_tokens(self) -> int:
"""Очистка истекших токенов"""
return await self._batch.cleanup_expired_tokens()
async def get_token_statistics(self) -> dict:
"""Получение статистики токенов"""
return await self._monitoring.get_token_statistics()
# Глобальный экземпляр фасада
_token_storage = _TokenStorageImpl()
class TokenStorage:
"""
Статический фасад для системы токенов.
Все методы делегируются глобальному экземпляру.
"""
@staticmethod
async def create_session(
user_id: str,
auth_data: Optional[dict] = None,
username: Optional[str] = None,
device_info: Optional[dict] = None,
) -> str:
"""Создание сессии пользователя"""
return await _token_storage.create_session(user_id, auth_data, username, device_info)
@staticmethod
async def verify_session(token: str) -> Optional[Any]:
"""Проверка сессии по токену"""
return await _token_storage.verify_session(token)
@staticmethod
async def refresh_session(user_id: int, old_token: str, device_info: Optional[dict] = None) -> Optional[str]:
"""Обновление сессии пользователя"""
return await _token_storage.refresh_session(user_id, old_token, device_info)
@staticmethod
async def revoke_session(session_token: str) -> bool:
"""Отзыв сессии"""
return await _token_storage.revoke_session(session_token)
@staticmethod
async def revoke_user_sessions(user_id: str) -> int:
"""Отзыв всех сессий пользователя"""
return await _token_storage.revoke_user_sessions(user_id)
@staticmethod
async def cleanup_expired_tokens() -> int:
"""Очистка истекших токенов"""
return await _token_storage.cleanup_expired_tokens()
@staticmethod
async def get_token_statistics() -> dict:
"""Получение статистики токенов"""
return await _token_storage.get_token_statistics()

View File

@ -1,23 +0,0 @@
"""
Типы и константы для системы токенов
"""
from typing import Any, Dict, Literal
# Типы токенов
TokenType = Literal["session", "verification", "oauth_access", "oauth_refresh"]
# TTL по умолчанию для разных типов токенов
DEFAULT_TTL = {
"session": 30 * 24 * 60 * 60, # 30 дней
"verification": 3600, # 1 час
"oauth_access": 3600, # 1 час
"oauth_refresh": 86400 * 30, # 30 дней
}
# Размеры батчей для оптимизации Redis операций
BATCH_SIZE = 100 # Размер батча для пакетной обработки токенов
SCAN_BATCH_SIZE = 1000 # Размер батча для SCAN операций
# Общие типы данных
TokenData = Dict[str, Any]

View File

@ -1,161 +0,0 @@
"""
Управление токенами подтверждения
"""
import json
import secrets
import time
from typing import Optional
from services.redis import redis as redis_adapter
from utils.logger import root_logger as logger
from .base import BaseTokenManager
from .types import TokenData
class VerificationTokenManager(BaseTokenManager):
"""
Менеджер токенов подтверждения
"""
async def create_verification_token(
self,
user_id: str,
verification_type: str,
data: TokenData,
ttl: Optional[int] = None,
) -> str:
"""Создает токен подтверждения"""
token_data = {"verification_type": verification_type, **data}
# TTL по типу подтверждения
if ttl is None:
verification_ttls = {
"email_change": 3600, # 1 час
"phone_change": 600, # 10 минут
"password_reset": 1800, # 30 минут
}
ttl = verification_ttls.get(verification_type, 3600)
return await self._create_verification_token(user_id, token_data, ttl)
async def _create_verification_token(
self, user_id: str, token_data: TokenData, ttl: int, token: Optional[str] = None
) -> str:
"""Оптимизированное создание токена подтверждения"""
verification_token = token or secrets.token_urlsafe(32)
token_key = self._make_token_key("verification", user_id, verification_token)
# Добавляем метаданные
token_data.update({"user_id": user_id, "token_type": "verification", "created_at": int(time.time())})
# Отменяем предыдущие токены того же типа
verification_type = token_data.get("verification_type", "unknown")
await self._cancel_verification_tokens_optimized(user_id, verification_type)
# Используем SETEX для атомарной операции установки с TTL
serialized_data = json.dumps(token_data, ensure_ascii=False)
await redis_adapter.execute("setex", token_key, ttl, serialized_data)
logger.info(f"Создан токен подтверждения {verification_type} для пользователя {user_id}")
return verification_token
async def get_verification_token_data(self, token: str) -> Optional[TokenData]:
"""Получает данные токена подтверждения"""
token_key = self._make_token_key("verification", "", token)
return await redis_adapter.get_and_deserialize(token_key)
async def validate_verification_token(self, token_str: str) -> tuple[bool, Optional[TokenData]]:
"""Проверяет валидность токена подтверждения"""
token_key = self._make_token_key("verification", "", token_str)
token_data = await redis_adapter.get_and_deserialize(token_key)
if token_data:
return True, token_data
return False, None
async def confirm_verification_token(self, token_str: str) -> Optional[TokenData]:
"""Подтверждает и использует токен подтверждения (одноразовый)"""
token_data = await self.get_verification_token_data(token_str)
if token_data:
# Удаляем токен после использования
await self.revoke_verification_token(token_str)
return token_data
return None
async def revoke_verification_token(self, token: str) -> bool:
"""Отзывает токен подтверждения"""
token_key = self._make_token_key("verification", "", token)
result = await redis_adapter.delete(token_key)
return result > 0
async def revoke_user_verification_tokens(self, user_id: str) -> int:
"""Оптимизированный отзыв токенов подтверждения пользователя используя SCAN вместо KEYS"""
count = 0
cursor = 0
delete_keys = []
# Используем SCAN для безопасного поиска токенов
while True:
cursor, keys = await redis_adapter.execute("scan", cursor, "verification_token:*", 100)
# Проверяем каждый ключ в пакете
if keys:
async with redis_adapter.pipeline() as pipe:
for key in keys:
await pipe.get(key)
results = await pipe.execute()
for key, data in zip(keys, results):
if data:
try:
token_data = json.loads(data)
if token_data.get("user_id") == user_id:
delete_keys.append(key)
count += 1
except (json.JSONDecodeError, TypeError):
continue
if cursor == 0:
break
# Удаляем найденные токены пакетно
if delete_keys:
await redis_adapter.delete(*delete_keys)
return count
async def _cancel_verification_tokens_optimized(self, user_id: str, verification_type: str) -> None:
"""Оптимизированная отмена токенов подтверждения используя SCAN"""
cursor = 0
delete_keys = []
while True:
cursor, keys = await redis_adapter.execute("scan", cursor, "verification_token:*", 100)
if keys:
# Получаем данные пакетно
async with redis_adapter.pipeline() as pipe:
for key in keys:
await pipe.get(key)
results = await pipe.execute()
# Проверяем какие токены нужно удалить
for key, data in zip(keys, results):
if data:
try:
token_data = json.loads(data)
if (
token_data.get("user_id") == user_id
and token_data.get("verification_type") == verification_type
):
delete_keys.append(key)
except (json.JSONDecodeError, TypeError):
continue
if cursor == 0:
break
# Удаляем найденные токены пакетно
if delete_keys:
await redis_adapter.delete(*delete_keys)

73
auth/tokenstorage.py Normal file
View File

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

View File

@ -1,6 +1,6 @@
import re
from datetime import datetime
from typing import Optional, Union
from typing import Dict, List, Optional, Union
from pydantic import BaseModel, Field, field_validator
@ -19,8 +19,7 @@ class AuthInput(BaseModel):
@classmethod
def validate_user_id(cls, v: str) -> str:
if not v.strip():
msg = "user_id cannot be empty"
raise ValueError(msg)
raise ValueError("user_id cannot be empty")
return v
@ -36,8 +35,7 @@ class UserRegistrationInput(BaseModel):
def validate_email(cls, v: str) -> str:
"""Validate email format"""
if not re.match(EMAIL_PATTERN, v):
msg = "Invalid email format"
raise ValueError(msg)
raise ValueError("Invalid email format")
return v.lower()
@field_validator("password")
@ -45,17 +43,13 @@ class UserRegistrationInput(BaseModel):
def validate_password_strength(cls, v: str) -> str:
"""Validate password meets security requirements"""
if not any(c.isupper() for c in v):
msg = "Password must contain at least one uppercase letter"
raise ValueError(msg)
raise ValueError("Password must contain at least one uppercase letter")
if not any(c.islower() for c in v):
msg = "Password must contain at least one lowercase letter"
raise ValueError(msg)
raise ValueError("Password must contain at least one lowercase letter")
if not any(c.isdigit() for c in v):
msg = "Password must contain at least one number"
raise ValueError(msg)
raise ValueError("Password must contain at least one number")
if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in v):
msg = "Password must contain at least one special character"
raise ValueError(msg)
raise ValueError("Password must contain at least one special character")
return v
@ -69,8 +63,7 @@ class UserLoginInput(BaseModel):
@classmethod
def validate_email(cls, v: str) -> str:
if not re.match(EMAIL_PATTERN, v):
msg = "Invalid email format"
raise ValueError(msg)
raise ValueError("Invalid email format")
return v.lower()
@ -81,7 +74,7 @@ class TokenPayload(BaseModel):
username: str
exp: datetime
iat: datetime
scopes: Optional[list[str]] = []
scopes: Optional[List[str]] = []
class OAuthInput(BaseModel):
@ -96,8 +89,7 @@ class OAuthInput(BaseModel):
def validate_provider(cls, v: str) -> str:
valid_providers = ["google", "github", "facebook"]
if v.lower() not in valid_providers:
msg = f"Provider must be one of: {', '.join(valid_providers)}"
raise ValueError(msg)
raise ValueError(f"Provider must be one of: {', '.join(valid_providers)}")
return v.lower()
@ -107,20 +99,18 @@ 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:
msg = "Error message required when success is False"
raise ValueError(msg)
raise ValueError("Error message required when success is False")
return v
@field_validator("token")
@classmethod
def validate_token_if_success(cls, v: Optional[str], info) -> Optional[str]:
if info.data.get("success") and not v:
msg = "Token required when success is True"
raise ValueError(msg)
raise ValueError("Token required when success is True")
return v

View File

@ -1,109 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.6/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": ["**", "!src/graphql/schema", "!gen", "!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
View File

@ -29,17 +29,17 @@ for new cache operations.
import asyncio
import json
from typing import Any, Callable, Dict, List, Optional, Type, Union
from typing import Any, Dict, List, Optional, Union
import orjson
from sqlalchemy import and_, join, select
from auth.orm import Author, AuthorFollower
from orm.author import Author, AuthorFollower
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic, TopicFollower
from services.db import local_session
from services.redis import redis
from utils.encoders import fast_json_dumps
from utils.encoders import CustomJSONEncoder
from utils.logger import root_logger as logger
DEFAULT_FOLLOWS = {
@ -60,16 +60,14 @@ CACHE_KEYS = {
"TOPIC_FOLLOWERS": "topic:followers:{}",
"TOPIC_SHOUTS": "topic_shouts_{}",
"AUTHOR_ID": "author:id:{}",
"AUTHOR_USER": "author:user:{}",
"SHOUTS": "shouts:{}",
}
# Type alias for JSON encoder
JSONEncoderType = Type[json.JSONEncoder]
# Cache topic data
async def cache_topic(topic: dict) -> None:
payload = fast_json_dumps(topic)
async def cache_topic(topic: dict):
payload = json.dumps(topic, cls=CustomJSONEncoder)
await asyncio.gather(
redis.execute("SET", f"topic:id:{topic['id']}", payload),
redis.execute("SET", f"topic:slug:{topic['slug']}", payload),
@ -77,38 +75,30 @@ async def cache_topic(topic: dict) -> None:
# Cache author data
async def cache_author(author: dict) -> None:
payload = fast_json_dumps(author)
async def cache_author(author: dict):
payload = json.dumps(author, cls=CustomJSONEncoder)
await asyncio.gather(
redis.execute("SET", f"author:slug:{author['slug'].strip()}", str(author["id"])),
redis.execute("SET", f"author:user:{author['user'].strip()}", str(author["id"])),
redis.execute("SET", f"author:id:{author['id']}", payload),
)
# Cache follows data
async def cache_follows(follower_id: int, entity_type: str, entity_id: int, is_insert: bool = True) -> None:
async def cache_follows(follower_id: int, entity_type: str, entity_id: int, is_insert=True):
key = f"author:follows-{entity_type}s:{follower_id}"
follows_str = await redis.execute("GET", key)
if follows_str:
follows = orjson.loads(follows_str)
# Для большинства типов используем пустой список ID, кроме communities
elif entity_type == "community":
follows = DEFAULT_FOLLOWS.get("communities", [])
else:
follows = []
follows = orjson.loads(follows_str) if follows_str else DEFAULT_FOLLOWS[entity_type]
if is_insert:
if entity_id not in follows:
follows.append(entity_id)
else:
follows = [eid for eid in follows if eid != entity_id]
await redis.execute("SET", key, fast_json_dumps(follows))
await redis.execute("SET", key, json.dumps(follows, cls=CustomJSONEncoder))
await update_follower_stat(follower_id, entity_type, len(follows))
# Update follower statistics
async def update_follower_stat(follower_id: int, entity_type: str, count: int) -> None:
async def update_follower_stat(follower_id, entity_type, count):
follower_key = f"author:id:{follower_id}"
follower_str = await redis.execute("GET", follower_key)
follower = orjson.loads(follower_str) if follower_str else None
@ -118,49 +108,23 @@ async def update_follower_stat(follower_id: int, entity_type: str, count: int) -
# Get author from cache
async def get_cached_author(author_id: int, get_with_stat) -> dict | None:
logger.debug(f"[get_cached_author] Начало выполнения для author_id: {author_id}")
async def get_cached_author(author_id: int, get_with_stat):
author_key = f"author:id:{author_id}"
logger.debug(f"[get_cached_author] Проверка кэша по ключу: {author_key}")
result = await redis.execute("GET", author_key)
if result:
logger.debug(f"[get_cached_author] Найдены данные в кэше, размер: {len(result)} байт")
cached_data = orjson.loads(result)
logger.debug(
f"[get_cached_author] Кэшированные данные имеют ключи: {list(cached_data.keys()) if cached_data else 'None'}"
)
return cached_data
logger.debug("[get_cached_author] Данные не найдены в кэше, загрузка из БД")
return orjson.loads(result)
# Load from database if not found in cache
q = select(Author).where(Author.id == author_id)
authors = get_with_stat(q)
logger.debug(f"[get_cached_author] Результат запроса из БД: {len(authors) if authors else 0} записей")
if authors:
author = authors[0]
logger.debug(f"[get_cached_author] Получен автор из БД: {type(author)}, id: {getattr(author, 'id', 'N/A')}")
# Используем безопасный вызов dict() для Author
author_dict = author.dict() if hasattr(author, "dict") else author.__dict__
logger.debug(
f"[get_cached_author] Сериализованные данные автора: {list(author_dict.keys()) if author_dict else 'None'}"
)
await cache_author(author_dict)
logger.debug("[get_cached_author] Автор кэширован")
return author_dict
logger.warning(f"[get_cached_author] Автор с ID {author_id} не найден в БД")
await cache_author(author.dict())
return author.dict()
return None
# Function to get cached topic
async def get_cached_topic(topic_id: int) -> dict | None:
async def get_cached_topic(topic_id: int):
"""
Fetch topic data from cache or database by id.
@ -180,14 +144,14 @@ async def get_cached_topic(topic_id: int) -> dict | None:
topic = session.execute(select(Topic).where(Topic.id == topic_id)).scalar_one_or_none()
if topic:
topic_dict = topic.dict()
await redis.execute("SET", topic_key, fast_json_dumps(topic_dict))
await redis.execute("SET", topic_key, json.dumps(topic_dict, cls=CustomJSONEncoder))
return topic_dict
return None
# Get topic by slug from cache
async def get_cached_topic_by_slug(slug: str, get_with_stat) -> dict | None:
async def get_cached_topic_by_slug(slug: str, get_with_stat):
topic_key = f"topic:slug:{slug}"
result = await redis.execute("GET", topic_key)
if result:
@ -203,7 +167,7 @@ async def get_cached_topic_by_slug(slug: str, get_with_stat) -> dict | None:
# Get list of authors by ID from cache
async def get_cached_authors_by_ids(author_ids: list[int]) -> list[dict]:
async def get_cached_authors_by_ids(author_ids: List[int]) -> List[dict]:
# Fetch all author data concurrently
keys = [f"author:id:{author_id}" for author_id in author_ids]
results = await asyncio.gather(*(redis.execute("GET", key) for key in keys))
@ -214,12 +178,11 @@ 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().unique().all()
missing_authors = session.execute(query).scalars().all()
await asyncio.gather(*(cache_author(author.dict()) for author in missing_authors))
for index, author in zip(missing_indices, missing_authors):
authors[index] = author.dict()
# Фильтруем None значения для корректного типа возвращаемого значения
return [author for author in authors if author is not None]
return authors
async def get_cached_topic_followers(topic_id: int):
@ -250,13 +213,13 @@ async def get_cached_topic_followers(topic_id: int):
.all()
]
await redis.execute("SETEX", cache_key, CACHE_TTL, fast_json_dumps(followers_ids))
await redis.execute("SETEX", cache_key, CACHE_TTL, orjson.dumps(followers_ids))
followers = await get_cached_authors_by_ids(followers_ids)
logger.debug(f"Cached {len(followers)} followers for topic #{topic_id}")
return followers
except Exception as e:
logger.error(f"Error getting followers for topic #{topic_id}: {e!s}")
logger.error(f"Error getting followers for topic #{topic_id}: {str(e)}")
return []
@ -279,8 +242,9 @@ 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}", fast_json_dumps(followers_ids))
return await get_cached_authors_by_ids(followers_ids)
await redis.execute("SET", f"author:followers:{author_id}", orjson.dumps(followers_ids))
followers = await get_cached_authors_by_ids(followers_ids)
return followers
# Get cached follower authors
@ -300,9 +264,10 @@ 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}", fast_json_dumps(authors_ids))
await redis.execute("SET", f"author:follows-authors:{author_id}", orjson.dumps(authors_ids))
return await get_cached_authors_by_ids(authors_ids)
authors = await get_cached_authors_by_ids(authors_ids)
return authors
# Get cached follower topics
@ -321,7 +286,7 @@ async def get_cached_follower_topics(author_id: int):
.where(TopicFollower.follower == author_id)
.all()
]
await redis.execute("SET", f"author:follows-topics:{author_id}", fast_json_dumps(topics_ids))
await redis.execute("SET", f"author:follows-topics:{author_id}", orjson.dumps(topics_ids))
topics = []
for topic_id in topics_ids:
@ -335,32 +300,35 @@ async def get_cached_follower_topics(author_id: int):
return topics
# Get author by author_id from cache
async def get_cached_author_by_id(author_id: int, get_with_stat):
# Get author by user ID from cache
async def get_cached_author_by_user_id(user_id: str, get_with_stat):
"""
Retrieve author information by author_id, checking the cache first, then the database.
Retrieve author information by user_id, checking the cache first, then the database.
Args:
author_id (int): The author identifier for which to retrieve the author.
user_id (str): The user identifier for which to retrieve the author.
Returns:
dict: Dictionary with author data or None if not found.
"""
# Attempt to find author data by author_id in Redis cache
cached_author_data = await redis.execute("GET", f"author:id:{author_id}")
if cached_author_data:
# If data is found, return parsed JSON
return orjson.loads(cached_author_data)
# Attempt to find author ID by user_id in Redis cache
author_id = await redis.execute("GET", f"author:user:{user_id.strip()}")
if author_id:
# If ID is found, get full author data by ID
author_data = await redis.execute("GET", f"author:id:{author_id}")
if author_data:
return orjson.loads(author_data)
# If data is not found in cache, query the database
author_query = select(Author).where(Author.id == author_id)
author_query = select(Author).where(Author.user == user_id)
authors = get_with_stat(author_query)
if authors:
# Cache the retrieved author data
author = authors[0]
author_dict = author.dict()
await asyncio.gather(
redis.execute("SET", f"author:id:{author.id}", fast_json_dumps(author_dict)),
redis.execute("SET", f"author:user:{user_id.strip()}", str(author.id)),
redis.execute("SET", f"author:id:{author.id}", orjson.dumps(author_dict)),
)
return author_dict
@ -391,17 +359,11 @@ async def get_cached_topic_authors(topic_id: int):
select(ShoutAuthor.author)
.select_from(join(ShoutTopic, Shout, ShoutTopic.shout == Shout.id))
.join(ShoutAuthor, ShoutAuthor.shout == Shout.id)
.where(
and_(
ShoutTopic.topic == topic_id,
Shout.published_at.is_not(None),
Shout.deleted_at.is_(None),
)
)
.where(and_(ShoutTopic.topic == topic_id, Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
)
authors_ids = [author_id for (author_id,) in session.execute(query).all()]
# Cache the retrieved author IDs
await redis.execute("SET", rkey, fast_json_dumps(authors_ids))
await redis.execute("SET", rkey, orjson.dumps(authors_ids))
# Retrieve full author details from cached IDs
if authors_ids:
@ -412,12 +374,15 @@ async def get_cached_topic_authors(topic_id: int):
return []
async def invalidate_shouts_cache(cache_keys: list[str]) -> None:
async def invalidate_shouts_cache(cache_keys: List[str]):
"""
Инвалидирует кэш выборок публикаций по переданным ключам.
"""
for cache_key in cache_keys:
for key in cache_keys:
try:
# Формируем полный ключ кэша
cache_key = f"shouts:{key}"
# Удаляем основной кэш
await redis.execute("DEL", cache_key)
logger.debug(f"Invalidated cache key: {cache_key}")
@ -426,8 +391,8 @@ async def invalidate_shouts_cache(cache_keys: list[str]) -> None:
await redis.execute("SETEX", f"{cache_key}:invalidated", CACHE_TTL, "1")
# Если это кэш темы, инвалидируем также связанные ключи
if cache_key.startswith("topic_"):
topic_id = cache_key.split("_")[1]
if key.startswith("topic_"):
topic_id = key.split("_")[1]
related_keys = [
f"topic:id:{topic_id}",
f"topic:authors:{topic_id}",
@ -439,35 +404,38 @@ async def invalidate_shouts_cache(cache_keys: list[str]) -> None:
logger.debug(f"Invalidated related key: {related_key}")
except Exception as e:
logger.error(f"Error invalidating cache key {cache_key}: {e}")
logger.error(f"Error invalidating cache key {key}: {e}")
async def cache_topic_shouts(topic_id: int, shouts: list[dict]) -> None:
async def cache_topic_shouts(topic_id: int, shouts: List[dict]):
"""Кэширует список публикаций для темы"""
key = f"topic_shouts_{topic_id}"
payload = fast_json_dumps(shouts)
payload = json.dumps(shouts, cls=CustomJSONEncoder)
await redis.execute("SETEX", key, CACHE_TTL, payload)
async def get_cached_topic_shouts(topic_id: int) -> list[dict]:
async def get_cached_topic_shouts(topic_id: int) -> List[dict]:
"""Получает кэшированный список публикаций для темы"""
key = f"topic_shouts_{topic_id}"
cached = await redis.execute("GET", key)
if cached:
return orjson.loads(cached)
return []
return None
async def cache_related_entities(shout: Shout) -> None:
async def cache_related_entities(shout: Shout):
"""
Кэширует все связанные с публикацией сущности (авторов и темы)
"""
tasks = [cache_by_id(Author, author.id, cache_author) for author in shout.authors]
tasks.extend(cache_by_id(Topic, topic.id, cache_topic) for topic in shout.topics)
tasks = []
for author in shout.authors:
tasks.append(cache_by_id(Author, author.id, cache_author))
for topic in shout.topics:
tasks.append(cache_by_id(Topic, topic.id, cache_topic))
await asyncio.gather(*tasks)
async def invalidate_shout_related_cache(shout: Shout, author_id: int) -> None:
async def invalidate_shout_related_cache(shout: Shout, author_id: int):
"""
Инвалидирует весь кэш, связанный с публикацией и её связями
@ -535,7 +503,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 None
return
x = result[0]
d = x.dict()
await cache_method(d)
@ -553,7 +521,7 @@ async def cache_data(key: str, data: Any, ttl: Optional[int] = None) -> None:
ttl: Время жизни кеша в секундах (None - бессрочно)
"""
try:
payload = fast_json_dumps(data)
payload = json.dumps(data, cls=CustomJSONEncoder)
if ttl:
await redis.execute("SETEX", key, ttl, payload)
else:
@ -606,7 +574,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,
@ -631,7 +599,7 @@ async def cached_query(
actual_key = cache_key
if use_key_format and "{}" in cache_key:
# Look for a template match in CACHE_KEYS
for key_format in CACHE_KEYS.values():
for key_name, key_format in CACHE_KEYS.items():
if cache_key == key_format:
# We have a match, now look for the id or value to format with
for param_name, param_value in query_params.items():
@ -658,270 +626,3 @@ 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
View File

@ -1,31 +1,32 @@
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 fast_json_dumps
from utils.encoders import CustomJSONEncoder
from utils.logger import root_logger as logger
# Предварительное кеширование подписчиков автора
async def precache_authors_followers(author_id, session) -> None:
authors_followers: set[int] = set()
async def precache_authors_followers(author_id, session):
authors_followers = set()
followers_query = select(AuthorFollower.follower).where(AuthorFollower.author == author_id)
result = session.execute(followers_query)
authors_followers.update(row[0] for row in result if row[0])
followers_payload = fast_json_dumps(list(authors_followers))
followers_payload = json.dumps(list(authors_followers), cls=CustomJSONEncoder)
await redis.execute("SET", f"author:followers:{author_id}", followers_payload)
# Предварительное кеширование подписок автора
async def precache_authors_follows(author_id, session) -> None:
async def precache_authors_follows(author_id, session):
follows_topics_query = select(TopicFollower.topic).where(TopicFollower.follower == author_id)
follows_authors_query = select(AuthorFollower.author).where(AuthorFollower.follower == author_id)
follows_shouts_query = select(ShoutReactionsFollower.shout).where(ShoutReactionsFollower.follower == author_id)
@ -34,9 +35,9 @@ async def precache_authors_follows(author_id, session) -> None:
follows_authors = {row[0] for row in session.execute(follows_authors_query) if row[0]}
follows_shouts = {row[0] for row in session.execute(follows_shouts_query) if row[0]}
topics_payload = fast_json_dumps(list(follows_topics))
authors_payload = fast_json_dumps(list(follows_authors))
shouts_payload = fast_json_dumps(list(follows_shouts))
topics_payload = json.dumps(list(follows_topics), cls=CustomJSONEncoder)
authors_payload = json.dumps(list(follows_authors), cls=CustomJSONEncoder)
shouts_payload = json.dumps(list(follows_shouts), cls=CustomJSONEncoder)
await asyncio.gather(
redis.execute("SET", f"author:follows-topics:{author_id}", topics_payload),
@ -46,7 +47,7 @@ async def precache_authors_follows(author_id, session) -> None:
# Предварительное кеширование авторов тем
async def precache_topics_authors(topic_id: int, session) -> None:
async def precache_topics_authors(topic_id: int, session):
topic_authors_query = (
select(ShoutAuthor.author)
.select_from(join(ShoutTopic, Shout, ShoutTopic.shout == Shout.id))
@ -61,130 +62,67 @@ async def precache_topics_authors(topic_id: int, session) -> None:
)
topic_authors = {row[0] for row in session.execute(topic_authors_query) if row[0]}
authors_payload = fast_json_dumps(list(topic_authors))
authors_payload = json.dumps(list(topic_authors), cls=CustomJSONEncoder)
await redis.execute("SET", f"topic:authors:{topic_id}", authors_payload)
# Предварительное кеширование подписчиков тем
async def precache_topics_followers(topic_id: int, session) -> None:
async def precache_topics_followers(topic_id: int, session):
followers_query = select(TopicFollower.follower).where(TopicFollower.topic == topic_id)
topic_followers = {row[0] for row in session.execute(followers_query) if row[0]}
followers_payload = fast_json_dumps(list(topic_followers))
followers_payload = json.dumps(list(topic_followers), cls=CustomJSONEncoder)
await redis.execute("SET", f"topic:followers:{topic_id}", followers_payload)
async def precache_data() -> None:
async def precache_data():
logger.info("precaching...")
logger.debug("Entering precache_data")
try:
# Список паттернов ключей, которые нужно сохранить при FLUSHDB
preserve_patterns = [
"migrated_views_*", # Данные миграции просмотров
"session:*", # Сессии пользователей
"env_vars:*", # Переменные окружения
"oauth_*", # OAuth токены
]
# Сохраняем все важные ключи перед очисткой
all_keys_to_preserve = []
preserved_data = {}
for pattern in preserve_patterns:
keys = await redis.execute("KEYS", pattern)
if keys:
all_keys_to_preserve.extend(keys)
logger.info(f"Найдено {len(keys)} ключей по паттерну '{pattern}'")
if all_keys_to_preserve:
logger.info(f"Сохраняем {len(all_keys_to_preserve)} важных ключей перед FLUSHDB")
for key in all_keys_to_preserve:
try:
# Определяем тип ключа и сохраняем данные
key_type = await redis.execute("TYPE", key)
if key_type == "hash":
preserved_data[key] = await redis.execute("HGETALL", key)
elif key_type == "string":
preserved_data[key] = await redis.execute("GET", key)
elif key_type == "set":
preserved_data[key] = await redis.execute("SMEMBERS", key)
elif key_type == "list":
preserved_data[key] = await redis.execute("LRANGE", key, 0, -1)
elif key_type == "zset":
preserved_data[key] = await redis.execute("ZRANGE", key, 0, -1, "WITHSCORES")
except Exception as e:
logger.error(f"Ошибка при сохранении ключа {key}: {e}")
continue
key = "authorizer_env"
# cache reset
value = await redis.execute("HGETALL", key)
await redis.execute("FLUSHDB")
logger.debug("Redis database flushed")
logger.info("redis: FLUSHDB")
# Восстанавливаем все сохранённые ключи
if preserved_data:
logger.info(f"Восстанавливаем {len(preserved_data)} сохранённых ключей")
for key, data in preserved_data.items():
try:
if isinstance(data, dict) and data:
# Hash
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
# Преобразуем словарь в список аргументов для HSET
if value:
# Если значение - словарь, преобразуем его в плоский список для HSET
if isinstance(value, dict):
flattened = []
for field, val in value.items():
flattened.extend([field, val])
await redis.execute("HSET", key, *flattened)
else:
# Предполагаем, что значение уже содержит список
await redis.execute("HSET", key, *value)
logger.info(f"redis hash '{key}' was restored")
logger.info("Beginning topic precache phase")
with local_session() as session:
# topics
q = select(Topic).where(Topic.community == 1)
topics = get_with_stat(q)
logger.info(f"Found {len(topics)} topics to precache")
for topic in topics:
topic_dict = topic.dict() if hasattr(topic, "dict") else topic
# logger.debug(f"Precaching topic id={topic_dict.get('id')}")
await cache_topic(topic_dict)
# logger.debug(f"Cached topic id={topic_dict.get('id')}")
await asyncio.gather(
precache_topics_followers(topic_dict["id"], session),
precache_topics_authors(topic_dict["id"], session),
)
# logger.debug(f"Finished precaching followers and authors for topic id={topic_dict.get('id')}")
logger.info(f"{len(topics)} topics and their followings precached")
# authors
authors = get_with_stat(select(Author))
# logger.info(f"{len(authors)} authors found in database")
authors = get_with_stat(select(Author).where(Author.user.is_not(None)))
logger.info(f"{len(authors)} authors found in database")
for author in authors:
if isinstance(author, Author):
profile = author.dict()
author_id = profile.get("id")
# user_id = profile.get("user", "").strip()
if author_id: # and user_id:
user_id = profile.get("user", "").strip()
if author_id and user_id:
await cache_author(profile)
await asyncio.gather(
precache_authors_followers(author_id, session),
precache_authors_follows(author_id, session),
precache_authors_followers(author_id, session), precache_authors_follows(author_id, session)
)
# logger.debug(f"Finished precaching followers and follows for author id={author_id}")
else:
logger.error(f"fail caching {author}")
logger.info(f"{len(authors)} authors and their followings precached")

49
cache/revalidator.py vendored
View File

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

18
cache/triggers.py vendored
View File

@ -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) -> None:
def mark_for_revalidation(entity, *args):
"""Отметка сущности для ревалидации."""
entity_type = (
"authors"
@ -26,7 +26,7 @@ def mark_for_revalidation(entity, *args) -> None:
revalidation_manager.mark_for_revalidation(entity.id, entity_type)
def after_follower_handler(mapper, connection, target, is_delete=False) -> None:
def after_follower_handler(mapper, connection, target, is_delete=False):
"""Обработчик добавления, обновления или удаления подписки."""
entity_type = None
if isinstance(target, AuthorFollower):
@ -44,7 +44,7 @@ def after_follower_handler(mapper, connection, target, is_delete=False) -> None:
revalidation_manager.mark_for_revalidation(target.follower, "authors")
def after_shout_handler(mapper, connection, target) -> None:
def after_shout_handler(mapper, connection, target):
"""Обработчик изменения статуса публикации"""
if not isinstance(target, Shout):
return
@ -63,7 +63,7 @@ def after_shout_handler(mapper, connection, target) -> None:
revalidation_manager.mark_for_revalidation(target.id, "shouts")
def after_reaction_handler(mapper, connection, target) -> None:
def after_reaction_handler(mapper, connection, target):
"""Обработчик для комментариев"""
if not isinstance(target, Reaction):
return
@ -104,7 +104,7 @@ def after_reaction_handler(mapper, connection, target) -> None:
revalidation_manager.mark_for_revalidation(topic.id, "topics")
def events_register() -> None:
def events_register():
"""Регистрация обработчиков событий для всех сущностей."""
event.listen(ShoutAuthor, "after_insert", mark_for_revalidation)
event.listen(ShoutAuthor, "after_update", mark_for_revalidation)
@ -115,7 +115,7 @@ def events_register() -> None:
event.listen(
AuthorFollower,
"after_delete",
lambda mapper, connection, target: after_follower_handler(mapper, connection, target, is_delete=True),
lambda *args: after_follower_handler(*args, is_delete=True),
)
event.listen(TopicFollower, "after_insert", after_follower_handler)
@ -123,7 +123,7 @@ def events_register() -> None:
event.listen(
TopicFollower,
"after_delete",
lambda mapper, connection, target: after_follower_handler(mapper, connection, target, is_delete=True),
lambda *args: after_follower_handler(*args, is_delete=True),
)
event.listen(ShoutReactionsFollower, "after_insert", after_follower_handler)
@ -131,7 +131,7 @@ def events_register() -> None:
event.listen(
ShoutReactionsFollower,
"after_delete",
lambda mapper, connection, target: after_follower_handler(mapper, connection, target, is_delete=True),
lambda *args: after_follower_handler(*args, is_delete=True),
)
event.listen(Reaction, "after_update", mark_for_revalidation)

View File

@ -1,114 +0,0 @@
{
"reader": [
"shout:read",
"topic:read",
"collection:read",
"community:read",
"bookmark:read",
"bookmark:create",
"bookmark:update_own",
"bookmark:delete_own",
"invite:read",
"invite:accept",
"invite:decline",
"chat:read",
"chat:create",
"chat:update_own",
"chat:delete_own",
"message:read",
"message:create",
"message:update_own",
"message:delete_own",
"reaction:read:COMMENT",
"reaction:create:COMMENT",
"reaction:update_own:COMMENT",
"reaction:delete_own:COMMENT",
"reaction:read:QUOTE",
"reaction:create:QUOTE",
"reaction:update_own:QUOTE",
"reaction:delete_own:QUOTE",
"reaction:read:LIKE",
"reaction:create:LIKE",
"reaction:update_own:LIKE",
"reaction:delete_own:LIKE",
"reaction:read:DISLIKE",
"reaction:create:DISLIKE",
"reaction:update_own:DISLIKE",
"reaction:delete_own:DISLIKE",
"reaction:read:CREDIT",
"reaction:read:PROOF",
"reaction:read:DISPROOF",
"reaction:read:AGREE",
"reaction:read:DISAGREE"
],
"author": [
"draft:read",
"draft:create",
"draft:update_own",
"draft:delete_own",
"shout:create",
"shout:update_own",
"shout:delete_own",
"collection:create",
"collection:update_own",
"collection:delete_own",
"invite:create",
"invite:update_own",
"invite:delete_own",
"reaction:create:SILENT",
"reaction:read:SILENT",
"reaction:update_own:SILENT",
"reaction:delete_own:SILENT"
],
"artist": [
"reaction:create:CREDIT",
"reaction:read:CREDIT",
"reaction:update_own:CREDIT",
"reaction:delete_own:CREDIT"
],
"expert": [
"reaction:create:PROOF",
"reaction:read:PROOF",
"reaction:update_own:PROOF",
"reaction:delete_own:PROOF",
"reaction:create:DISPROOF",
"reaction:read:DISPROOF",
"reaction:update_own:DISPROOF",
"reaction:delete_own:DISPROOF",
"reaction:create:AGREE",
"reaction:read:AGREE",
"reaction:update_own:AGREE",
"reaction:delete_own:AGREE",
"reaction:create:DISAGREE",
"reaction:read:DISAGREE",
"reaction:update_own:DISAGREE",
"reaction:delete_own:DISAGREE"
],
"editor": [
"shout:delete_any",
"shout:update_any",
"topic:create",
"topic:delete_own",
"topic:update_own",
"topic:merge",
"reaction:delete_any:*",
"reaction:update_any:*",
"invite:delete_any",
"invite:update_any",
"collection:delete_any",
"collection:update_any",
"community:create",
"community:update_own",
"community:delete_own",
"draft:delete_any",
"draft:update_any"
],
"admin": [
"author:delete_any",
"author:update_any",
"chat:delete_any",
"chat:update_any",
"message:delete_any",
"message:update_any"
]
}

143
dev.py
View File

@ -1,143 +0,0 @@
import argparse
import subprocess
from pathlib import Path
from typing import Optional
from granian import Granian
from granian.constants import Interfaces
from utils.logger import root_logger as logger
def check_mkcert_installed() -> Optional[bool]:
"""
Проверяет, установлен ли инструмент mkcert в системе
Returns:
bool: True если mkcert установлен, иначе False
>>> check_mkcert_installed() # doctest: +SKIP
True
"""
try:
subprocess.run(["mkcert", "-version"], capture_output=True, check=False)
return True
except FileNotFoundError:
return False
def generate_certificates(domain="localhost", cert_file="localhost.pem", key_file="localhost-key.pem"):
"""
Генерирует сертификаты с использованием mkcert
Args:
domain: Домен для сертификата
cert_file: Имя файла сертификата
key_file: Имя файла ключа
Returns:
tuple: (cert_file, key_file) пути к созданным файлам
>>> generate_certificates() # doctest: +SKIP
('localhost.pem', 'localhost-key.pem')
"""
# Проверяем, существуют ли сертификаты
if Path(cert_file).exists() and Path(key_file).exists():
logger.info(f"Сертификаты уже существуют: {cert_file}, {key_file}")
return cert_file, key_file
# Проверяем, установлен ли mkcert
if not check_mkcert_installed():
logger.error("mkcert не установлен. Установите mkcert с помощью команды:")
logger.error(" macOS: brew install mkcert")
logger.error(" Linux: apt install mkcert или эквивалент для вашего дистрибутива")
logger.error(" Windows: choco install mkcert")
logger.error("После установки выполните: mkcert -install")
return None, None
try:
# Запускаем mkcert для создания сертификата
logger.info(f"Создание сертификатов для {domain} с помощью mkcert...")
result = subprocess.run(
["mkcert", "-cert-file", cert_file, "-key-file", key_file, domain],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
logger.error(f"Ошибка при создании сертификатов: {result.stderr}")
return None, None
logger.info(f"Сертификаты созданы: {cert_file}, {key_file}")
return cert_file, key_file
except Exception as e:
logger.error(f"Не удалось создать сертификаты: {e!s}")
return None, None
def run_server(host="localhost", port=8000, use_https=False, workers=1, domain="localhost") -> None:
"""
Запускает сервер Granian с поддержкой HTTPS при необходимости
Args:
host: Хост для запуска сервера
port: Порт для запуска сервера
use_https: Флаг использования HTTPS
workers: Количество рабочих процессов
domain: Домен для сертификата
>>> run_server(use_https=True) # doctest: +SKIP
"""
# Проблема с многопроцессорным режимом - не поддерживает локальные объекты приложений
# Всегда запускаем в режиме одного процесса для отладки
if workers > 1:
logger.warning("Многопроцессорный режим может вызвать проблемы сериализации приложения. Использую 1 процесс.")
workers = 1
try:
if use_https:
# Генерируем сертификаты с помощью mkcert
cert_file, key_file = generate_certificates(domain=domain)
if not cert_file or not key_file:
logger.error("Не удалось сгенерировать сертификаты для HTTPS")
return
logger.info(f"Запуск HTTPS сервера на https://{host}:{port} с использованием Granian")
# Запускаем Granian сервер с явным указанием ASGI
server = Granian(
address=host,
port=port,
workers=workers,
interface=Interfaces.ASGI,
target="main:app",
ssl_cert=Path(cert_file),
ssl_key=Path(key_file),
)
else:
logger.info(f"Запуск HTTP сервера на http://{host}:{port} с использованием Granian")
server = Granian(
address=host,
port=port,
workers=workers,
interface=Interfaces.ASGI,
target="main:app",
)
server.serve()
except Exception as e:
# В случае проблем с Granian, логируем ошибку
logger.error(f"Ошибка при запуске Granian: {e!s}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Запуск сервера разработки с поддержкой HTTPS")
parser.add_argument("--https", action="store_true", help="Использовать HTTPS")
parser.add_argument("--workers", type=int, default=1, help="Количество рабочих процессов")
parser.add_argument("--domain", type=str, default="localhost", help="Домен для сертификата")
parser.add_argument("--port", type=int, default=8000, help="Порт для запуска сервера")
parser.add_argument("--host", type=str, default="localhost", help="Хост для запуска сервера")
args = parser.parse_args()
run_server(host=args.host, port=args.port, use_https=args.https, workers=args.workers, domain=args.domain)

View File

@ -1,116 +0,0 @@
# Документация Discours.io API
## 🚀 Быстрый старт
### Запуск локально
```bash
# Стандартный запуск
python main.py
# С HTTPS (требует mkcert)
python dev.py
```
## 📚 Документация
### Авторизация и безопасность
- [Система авторизации](auth-system.md) - Токены, сессии, OAuth
- [Архитектура](auth-architecture.md) - Диаграммы и схемы
- [Миграция](auth-migration.md) - Переход на новую версию
- [Безопасность](security.md) - Пароли, email, RBAC
- [Система RBAC](rbac-system.md) - Роли, разрешения, топики
- [OAuth](oauth.md) - Google, GitHub, Facebook, X, Telegram, VK, Yandex
- [OAuth настройка](oauth-setup.md) - Инструкции по настройке OAuth провайдеров
### Функциональность
- [Система рейтингов](rating.md) - Лайки, дизлайки, featured статьи
- [Подписки](follower.md) - Follow/unfollow логика
- [Кэширование](caching.md) - Redis, производительность
- [Схема данных Redis](redis-schema.md) - Полная документация структур данных
- [Пагинация комментариев](comments-pagination.md) - Иерархические комментарии
- [Загрузка контента](load_shouts.md) - Оптимизированные запросы
### Администрирование
- **Админ-панель**: Управление пользователями, ролями, переменными среды
- **Управление публикациями**: Просмотр, поиск, фильтрация по статусу (опубликованные/черновики/удаленные)
- **Управление топиками**: Упрощенное редактирование топиков с иерархическим отображением
- **Клик по строке**: Модалка редактирования открывается при клике на строку таблицы
- **Ненавязчивый крестик**: Серая кнопка "×" для удаления, краснеет при hover
- **Простой HTML редактор**: Обычный contenteditable div с моноширинным шрифтом
- **Редактируемые поля**: ID (просмотр), название, slug, описание, сообщество, родители
- **Дерево топиков**: Визуализация родительско-дочерних связей с отступами и символами `└─`
- **Безопасное удаление**: Предупреждения о каскадном удалении дочерних топиков
- **Автообновление**: Рефреш списка после операций с корректной инвалидацией кешей
- **Модерация реакций**: Полная система управления реакциями пользователей
- **Просмотр всех реакций**: Таблица с типом, текстом, автором, публикацией и статистикой
- **Фильтрация по типам**: Лайки, дизлайки, комментарии, цитаты, согласие/несогласие, вопросы, предложения, доказательства/опровержения
- **Поиск и фильтры**: По тексту реакции, автору, email или ID публикации
- **Эмоджи-индикаторы**: Визуальное отображение типов реакций (👍 👎 💬 ❝ ✅ ❌ ❓ 💡 🔬 🚫)
- **Модерация**: Редактирование текста, мягкое удаление и восстановление
- **Статистика**: Рейтинг и количество комментариев к каждой реакции
- **Безопасность**: RBAC защита и аудит всех операций
- **Просмотр данных**: Body, media, авторы, темы с удобной навигацией
- **DRY принцип**: Переиспользование существующих резолверов из reader.py и editor.py
### API и инфраструктура
- [API методы](api.md) - GraphQL эндпоинты
- [Функции системы](features.md) - Полный список возможностей
## ⚡ Ключевые возможности
### Авторизация
- **Модульная архитектура**: SessionTokenManager, VerificationTokenManager, OAuthTokenManager
- **OAuth провайдеры**: 7 поддерживаемых провайдеров с PKCE
- **RBAC**: Система ролей reader/author/artist/expert/editor/admin с наследованием
- **Права на топики**: Специальные разрешения для создания, редактирования и слияния топиков
- **Производительность**: 50% ускорение Redis, 30% меньше памяти
### Nginx (упрощенная конфигурация)
- **KISS принцип**: ~60 строк вместо сложной конфигурации
- **Dokku дефолты**: Максимальное использование встроенных настроек
- **SSL/TLS**: TLS 1.2/1.3, HSTS, OCSP stapling
- **Статические файлы**: Кэширование на 1 год, gzip сжатие
- **Безопасность**: X-Frame-Options, X-Content-Type-Options
### Реакции и комментарии
- **Иерархические комментарии** с эффективной пагинацией
- **Физическое/логическое удаление** (рейтинги/комментарии)
- **Автоматический featured статус** на основе лайков
- **Distinct() оптимизация** для JOIN запросов
### Производительность
- **Redis pipeline операции** для пакетных запросов
- **Автоматическая очистка** истекших токенов
- **Connection pooling** и keepalive
- **Type-safe codebase** (mypy clean)
- **Оптимизированная сортировка авторов** с кешированием по параметрам
## 🔧 Конфигурация
```python
# JWT
JWT_SECRET_KEY = "your-secret-key"
JWT_EXPIRATION_HOURS = 720 # 30 дней
# Redis
REDIS_URL = "redis://localhost:6379/0"
# OAuth (необходимые провайдеры)
OAUTH_CLIENTS_GOOGLE_ID = "..."
OAUTH_CLIENTS_GITHUB_ID = "..."
# ... другие провайдеры
```
## 🛠 Использование API
```python
# Сессии
from auth.tokens.sessions import SessionTokenManager
sessions = SessionTokenManager()
token = await sessions.create_session(user_id, username=username)
# Мониторинг
from auth.tokens.monitoring import TokenMonitoring
monitoring = TokenMonitoring()
stats = await monitoring.get_token_statistics()
```

View File

@ -1,560 +0,0 @@
# Администраторская панель Discours
## Обзор
Администраторская панель — это комплексная система управления платформой Discours, предоставляющая полный контроль над пользователями, публикациями, сообществами и их ролями.
## Архитектура системы доступа
### Уровни доступа
1. **Системные администраторы** — email в переменной `ADMIN_EMAILS` (управление системой через переменные среды)
2. **RBAC роли в сообществах**`reader`, `author`, `artist`, `expert`, `editor`, `admin` (управляемые через админку)
**ВАЖНО**:
- Роль `admin` в RBAC — это обычная роль в сообществе, управляемая через админку
- "Системный администратор" — синтетическая роль, которая НЕ хранится в базе данных
- Синтетическая роль добавляется только в API ответы для пользователей из `ADMIN_EMAILS`
- На фронте в сообществах синтетическая роль НЕ отображается
### Декораторы безопасности
```python
@admin_auth_required # Доступ только системным админам (ADMIN_EMAILS)
@editor_or_admin_required # Доступ редакторам и админам сообщества (RBAC роли)
```
## Модули администрирования
### 1. Управление пользователями
#### Получение списка пользователей
```graphql
query AdminGetUsers(
$limit: Int = 20
$offset: Int = 0
$search: String = ""
) {
adminGetUsers(limit: $limit, offset: $offset, search: $search) {
authors {
id
email
name
slug
roles
created_at
last_seen
}
total
page
perPage
totalPages
}
}
```
**Особенности:**
- Поиск по email, имени и ID
- Пагинация с ограничением 1-100 записей
- Роли получаются из основного сообщества (ID=1)
- Автоматическое добавление синтетической роли "Системный администратор" для email из `ADMIN_EMAILS`
#### Обновление пользователя
```graphql
mutation AdminUpdateUser($user: AdminUserUpdateInput!) {
adminUpdateUser(user: $user) {
success
error
}
}
```
**Поддерживаемые поля:**
- `email`с проверкой уникальности
- `name` — имя пользователя
- `slug`с проверкой уникальности
- `roles` — массив ролей для основного сообщества
### 2. Система ролей и разрешений (RBAC)
#### Иерархия ролей
```
reader → author → artist → expert → editor → admin
```
Каждая роль наследует права предыдущих **только при инициализации** сообщества.
#### Получение ролей
```graphql
query AdminGetRoles($community: Int) {
adminGetRoles(community: $community) {
id
name
description
}
}
```
- Без `community` — все системные роли
- С `community` — роли конкретного сообщества + счетчик разрешений
#### Управление ролями в сообществах
**Получение ролей пользователя:**
```graphql
query AdminGetUserCommunityRoles(
$author_id: Int!
$community_id: Int!
) {
adminGetUserCommunityRoles(
author_id: $author_id
community_id: $community_id
) {
author_id
community_id
roles
}
}
```
**Назначение ролей:**
```graphql
mutation AdminSetUserCommunityRoles(
$author_id: Int!
$community_id: Int!
$roles: [String!]!
) {
adminSetUserCommunityRoles(
author_id: $author_id
community_id: $community_id
roles: $roles
) {
success
error
author_id
community_id
roles
}
}
```
**Добавление отдельной роли:**
```graphql
mutation AdminAddUserToRole(
$author_id: Int!
$role_id: String!
$community_id: Int!
) {
adminAddUserToRole(
author_id: $author_id
role_id: $role_id
community_id: $community_id
) {
success
error
}
}
```
**Удаление роли:**
```graphql
mutation AdminRemoveUserFromRole(
$author_id: Int!
$role_id: String!
$community_id: Int!
) {
adminRemoveUserFromRole(
author_id: $author_id
role_id: $role_id
community_id: $community_id
) {
success
removed
}
}
```
### 3. Управление сообществами
#### Участники сообщества
```graphql
query AdminGetCommunityMembers(
$community_id: Int!
$limit: Int = 20
$offset: Int = 0
) {
adminGetCommunityMembers(
community_id: $community_id
limit: $limit
offset: $offset
) {
members {
id
name
email
slug
roles
}
total
community_id
}
}
```
#### Настройки ролей сообщества
**Получение настроек:**
```graphql
query AdminGetCommunityRoleSettings($community_id: Int!) {
adminGetCommunityRoleSettings(community_id: $community_id) {
community_id
default_roles
available_roles
error
}
}
```
**Обновление настроек:**
```graphql
mutation AdminUpdateCommunityRoleSettings(
$community_id: Int!
$default_roles: [String!]!
$available_roles: [String!]!
) {
adminUpdateCommunityRoleSettings(
community_id: $community_id
default_roles: $default_roles
available_roles: $available_roles
) {
success
error
community_id
default_roles
available_roles
}
}
```
#### Создание пользовательской роли
```graphql
mutation AdminCreateCustomRole($role: CustomRoleInput!) {
adminCreateCustomRole(role: $role) {
success
error
role {
id
name
description
}
}
}
```
#### Удаление пользовательской роли
```graphql
mutation AdminDeleteCustomRole(
$role_id: String!
$community_id: Int!
) {
adminDeleteCustomRole(
role_id: $role_id
community_id: $community_id
) {
success
error
}
}
```
### 4. Управление публикациями
#### Получение списка публикаций
```graphql
query AdminGetShouts(
$limit: Int = 20
$offset: Int = 0
$search: String = ""
$status: String = "all"
$community: Int
) {
adminGetShouts(
limit: $limit
offset: $offset
search: $search
status: $status
community: $community
) {
shouts {
id
title
slug
body
lead
subtitle
# ... остальные поля
created_by {
id
email
name
slug
}
community {
id
name
slug
}
authors {
id
email
name
slug
}
topics {
id
title
slug
}
}
total
page
perPage
totalPages
}
}
```
**Статусы публикаций:**
- `all` — все публикации (включая удаленные)
- `published` — опубликованные
- `draft` — черновики
- `deleted` — удаленные
#### Операции с публикациями
**Обновление:**
```graphql
mutation AdminUpdateShout($shout: AdminShoutUpdateInput!) {
adminUpdateShout(shout: $shout) {
success
error
}
}
```
**Удаление (мягкое):**
```graphql
mutation AdminDeleteShout($shout_id: Int!) {
adminDeleteShout(shout_id: $shout_id) {
success
error
}
}
```
**Восстановление:**
```graphql
mutation AdminRestoreShout($shout_id: Int!) {
adminRestoreShout(shout_id: $shout_id) {
success
error
}
}
```
### 5. Управление приглашениями
#### Получение списка приглашений
```graphql
query AdminGetInvites(
$limit: Int = 20
$offset: Int = 0
$search: String = ""
$status: String = "all"
) {
adminGetInvites(
limit: $limit
offset: $offset
search: $search
status: $status
) {
invites {
inviter_id
author_id
shout_id
status
inviter {
id
email
name
slug
}
author {
id
email
name
slug
}
shout {
id
title
slug
created_by {
id
email
name
slug
}
}
}
total
page
perPage
totalPages
}
}
```
**Статусы приглашений:**
- `PENDING` — ожидает ответа
- `ACCEPTED` — принято
- `REJECTED` — отклонено
#### Операции с приглашениями
**Обновление статуса:**
```graphql
mutation AdminUpdateInvite($invite: AdminInviteUpdateInput!) {
adminUpdateInvite(invite: $invite) {
success
error
}
}
```
**Удаление:**
```graphql
mutation AdminDeleteInvite(
$inviter_id: Int!
$author_id: Int!
$shout_id: Int!
) {
adminDeleteInvite(
inviter_id: $inviter_id
author_id: $author_id
shout_id: $shout_id
) {
success
error
}
}
```
**Пакетное удаление:**
```graphql
mutation AdminDeleteInvitesBatch($invites: [AdminInviteIdInput!]!) {
adminDeleteInvitesBatch(invites: $invites) {
success
error
}
}
```
### 6. Переменные окружения
Системные администраторы могут управлять переменными окружения:
```graphql
query GetEnvVariables {
getEnvVariables {
name
description
variables {
key
value
description
type
isSecret
}
}
}
```
```graphql
mutation UpdateEnvVariable($key: String!, $value: String!) {
updateEnvVariable(key: $key, value: $value) {
success
error
}
}
```
## Особенности реализации
### Принцип DRY
- Переиспользование логики из `reader.py`, `editor.py`
- Общие утилиты в `_get_user_roles()`
- Централизованная обработка ошибок
### Новая RBAC система
- Роли хранятся в CSV формате в `CommunityAuthor.roles`
- Методы модели: `add_role()`, `remove_role()`, `set_roles()`, `has_role()`
- Права наследуются **только при инициализации**
- Redis кэширование развернутых прав
### Синтетические роли
- **"Системный администратор"** — добавляется автоматически для пользователей из `ADMIN_EMAILS`
- НЕ хранится в базе данных, только в API ответах
- НЕ отображается на фронте в интерфейсах управления сообществами
- Используется только для индикации системных прав доступа
### Безопасность
- Валидация всех входных данных
- Проверка существования сущностей
- Контроль доступа через декораторы
- Логирование всех административных действий
### Производительность
- Пагинация для всех списков
- Индексы по ключевым полям
- Ограничения на размер выборки (max 100)
- Оптимизированные SQL запросы с `joinedload`
## Миграция данных
При переходе на новую RBAC систему используется функция:
```python
from orm.community import migrate_old_roles_to_community_author
migrate_old_roles_to_community_author()
```
Функция автоматически переносит роли из старых таблиц в новый формат CSV.
## Мониторинг и логирование
Все административные действия логируются с уровнем INFO:
- Изменение ролей пользователей
- Обновление настроек сообществ
- Операции с публикациями
- Управление приглашениями
Ошибки логируются с уровнем ERROR и полным стектрейсом.
## Лучшие практики
1. **Всегда проверяйте роли перед назначением**
2. **Используйте транзакции для групповых операций**
3. **Логируйте критические изменения**
4. **Валидируйте права доступа на каждом этапе**
5. **Применяйте принцип минимальных привилегий**
## Расширение функциональности
Для добавления новых административных функций:
1. Создайте резолвер с соответствующим декоратором
2. Добавьте GraphQL схему в `schema/admin.graphql`
3. Реализуйте логику с переиспользованием существующих компонентов
4. Добавьте тесты и документацию
5. Обновите права доступа при необходимости

View File

@ -1,40 +0,0 @@
## API Documentation
### GraphQL Schema
- Mutations: Authentication, content management, security
- Queries: Content retrieval, user data
- Types: Author, Topic, Shout, Community
### Key Features
#### Security Management
- Password change with validation
- Email change with confirmation
- Two-factor authentication flow
- Protected fields for user privacy
#### Content Management
- Publication system with drafts
- Topic and community organization
- Author collaboration tools
- Real-time notifications
#### Following System
- Subscribe to authors and topics
- Cache-optimized operations
- Consistent UI state management
## Database
### Models
- `Author` - User accounts with RBAC
- `Shout` - Publications and articles
- `Topic` - Content categorization
- `Community` - User groups
### Cache System
- Redis-based caching
- Automatic cache invalidation
- Optimized for real-time updates

View File

@ -1,253 +0,0 @@
# Архитектура системы авторизации
## Схема потоков данных
```mermaid
graph TB
subgraph "Frontend"
FE[Web Frontend]
MOB[Mobile App]
end
subgraph "Auth Layer"
MW[AuthMiddleware]
DEC[GraphQL Decorators]
HANDLER[Auth Handlers]
end
subgraph "Core Auth"
IDENTITY[Identity]
JWT[JWT Codec]
OAUTH[OAuth Manager]
PERM[Permissions]
end
subgraph "Token System"
TS[TokenStorage]
STM[SessionTokenManager]
VTM[VerificationTokenManager]
OTM[OAuthTokenManager]
BTM[BatchTokenOperations]
MON[TokenMonitoring]
end
subgraph "Storage"
REDIS[(Redis)]
DB[(PostgreSQL)]
end
subgraph "External"
GOOGLE[Google OAuth]
GITHUB[GitHub OAuth]
FACEBOOK[Facebook]
OTHER[Other Providers]
end
FE --> MW
MOB --> MW
MW --> IDENTITY
MW --> JWT
DEC --> PERM
HANDLER --> OAUTH
IDENTITY --> STM
OAUTH --> OTM
TS --> STM
TS --> VTM
TS --> OTM
STM --> REDIS
VTM --> REDIS
OTM --> REDIS
BTM --> REDIS
MON --> REDIS
IDENTITY --> DB
OAUTH --> DB
PERM --> DB
OAUTH --> GOOGLE
OAUTH --> GITHUB
OAUTH --> FACEBOOK
OAUTH --> OTHER
```
## Диаграмма компонентов
```mermaid
graph LR
subgraph "HTTP Layer"
REQ[HTTP Request]
RESP[HTTP Response]
end
subgraph "Middleware"
AUTH_MW[Auth Middleware]
CORS_MW[CORS Middleware]
end
subgraph "GraphQL"
RESOLVER[GraphQL Resolvers]
DECORATOR[Auth Decorators]
end
subgraph "Auth Core"
VALIDATION[Validation]
IDENTIFICATION[Identity Check]
AUTHORIZATION[Permission Check]
end
subgraph "Token Management"
CREATE[Token Creation]
VERIFY[Token Verification]
REVOKE[Token Revocation]
REFRESH[Token Refresh]
end
REQ --> CORS_MW
CORS_MW --> AUTH_MW
AUTH_MW --> RESOLVER
RESOLVER --> DECORATOR
DECORATOR --> VALIDATION
VALIDATION --> IDENTIFICATION
IDENTIFICATION --> AUTHORIZATION
AUTHORIZATION --> CREATE
AUTHORIZATION --> VERIFY
AUTHORIZATION --> REVOKE
AUTHORIZATION --> REFRESH
CREATE --> RESP
VERIFY --> RESP
REVOKE --> RESP
REFRESH --> RESP
```
## Схема OAuth потока
```mermaid
sequenceDiagram
participant U as User
participant F as Frontend
participant A as Auth Service
participant R as Redis
participant P as OAuth Provider
participant D as Database
U->>F: Click "Login with Provider"
F->>A: GET /oauth/{provider}?state={csrf}
A->>R: Store OAuth state
A->>P: Redirect to Provider
P->>U: Show authorization page
U->>P: Grant permission
P->>A: GET /oauth/{provider}/callback?code={code}&state={state}
A->>R: Verify state
A->>P: Exchange code for token
P->>A: Return access token + user data
A->>D: Find/create user
A->>A: Generate JWT session token
A->>R: Store session in Redis
A->>F: Redirect with JWT token
F->>U: User logged in
```
## Схема сессионного управления
```mermaid
stateDiagram-v2
[*] --> Anonymous
Anonymous --> Authenticating: Login attempt
Authenticating --> Authenticated: Valid credentials
Authenticating --> Anonymous: Invalid credentials
Authenticated --> Refreshing: Token near expiry
Refreshing --> Authenticated: Successful refresh
Refreshing --> Anonymous: Refresh failed
Authenticated --> Anonymous: Logout/Revoke
Authenticated --> Anonymous: Token expired
```
## Redis структура данных
```
├── Sessions
│ ├── session:{user_id}:{token} → Hash {user_id, username, device_info, last_activity}
│ ├── user_sessions:{user_id} → Set {token1, token2, ...}
│ └── {user_id}-{username}-{token} → Hash (legacy format)
├── Verification
│ └── verification_token:{token} → JSON {user_id, type, data, created_at}
├── OAuth
│ ├── oauth_access:{user_id}:{provider} → JSON {token, expires_in, scope}
│ ├── oauth_refresh:{user_id}:{provider} → JSON {token, provider_data}
│ └── oauth_state:{state} → JSON {provider, redirect_uri, code_verifier}
└── Monitoring
└── token_stats → Hash {session_count, oauth_count, memory_usage}
```
## Компоненты безопасности
```mermaid
graph TD
subgraph "Input Validation"
EMAIL[Email Format]
PASS[Password Strength]
TOKEN[Token Format]
end
subgraph "Authentication"
BCRYPT[bcrypt + SHA256]
JWT_SIGN[JWT Signing]
OAUTH_VERIFY[OAuth Verification]
end
subgraph "Authorization"
ROLE[Role-based Access]
PERM[Permission Checks]
RESOURCE[Resource Access]
end
subgraph "Session Security"
TTL[Token TTL]
REVOKE[Token Revocation]
REFRESH[Secure Refresh]
end
EMAIL --> BCRYPT
PASS --> BCRYPT
TOKEN --> JWT_SIGN
BCRYPT --> ROLE
JWT_SIGN --> ROLE
OAUTH_VERIFY --> ROLE
ROLE --> PERM
PERM --> RESOURCE
RESOURCE --> TTL
RESOURCE --> REVOKE
RESOURCE --> REFRESH
```
## Масштабирование и производительность
### Горизонтальное масштабирование
- **Stateless JWT** токены
- **Redis Cluster** для высокой доступности
- **Load Balancer** aware session management
### Оптимизации
- **Connection pooling** для Redis
- **Batch operations** для массовых операций
- **Pipeline использование** для атомарности
- **LRU кэширование** для часто используемых данных
### Мониторинг производительности
- **Response time** auth операций
- **Redis memory usage** и hit rate
- **Token creation/validation** rate
- **OAuth provider** response times

View File

@ -1,322 +0,0 @@
# Миграция системы авторизации
## Обзор изменений
Система авторизации была полностью переработана для улучшения производительности, безопасности и поддерживаемости:
### Основные изменения
- ✅ Упрощена архитектура токенов (убрана прокси-логика)
- ✅ Исправлены проблемы с типами (mypy clean)
- ✅ Оптимизированы Redis операции
- ✅ Добавлена система мониторинга токенов
- ✅ Улучшена производительность OAuth
- ✅ Удалены deprecated компоненты
## Миграция кода
### TokenStorage API
#### Было (deprecated):
```python
# Старый универсальный API
await TokenStorage.create_token("session", user_id, data, ttl)
await TokenStorage.get_token_data("session", token)
await TokenStorage.validate_token(token, "session")
await TokenStorage.revoke_token("session", token)
```
#### Стало (рекомендуется):
```python
# Прямое использование менеджеров
from auth.tokens.sessions import SessionTokenManager
from auth.tokens.verification import VerificationTokenManager
from auth.tokens.oauth import OAuthTokenManager
# Сессии
sessions = SessionTokenManager()
token = await sessions.create_session(user_id, username=username)
valid, data = await sessions.validate_session_token(token)
await sessions.revoke_session_token(token)
# Токены подтверждения
verification = VerificationTokenManager()
token = await verification.create_verification_token(user_id, "email_change", data)
valid, data = await verification.validate_verification_token(token)
# OAuth токены
oauth = OAuthTokenManager()
await oauth.store_oauth_tokens(user_id, "google", access_token, refresh_token)
```
#### Фасад TokenStorage (для совместимости):
```python
# Упрощенный фасад для основных операций
await TokenStorage.create_session(user_id, username=username)
await TokenStorage.verify_session(token)
await TokenStorage.refresh_session(user_id, old_token, device_info)
await TokenStorage.revoke_session(token)
```
### Redis Service
#### Обновленный API:
```python
from 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`

View File

@ -1,349 +0,0 @@
# Система авторизации Discours.io
## Обзор архитектуры
Система авторизации построена на модульной архитектуре с разделением на независимые компоненты:
```
auth/
├── tokens/ # Система управления токенами
├── middleware.py # HTTP middleware для аутентификации
├── decorators.py # GraphQL декораторы авторизации
├── oauth.py # OAuth провайдеры
├── orm.py # ORM модели пользователей
├── permissions.py # Система разрешений
├── identity.py # Методы идентификации
├── jwtcodec.py # JWT кодек
├── validations.py # Валидация данных
├── credentials.py # Работа с креденшалами
├── exceptions.py # Исключения авторизации
└── handler.py # HTTP обработчики
```
## Система токенов
### Типы токенов
| Тип | TTL | Назначение |
|-----|-----|------------|
| `session` | 30 дней | Токены пользовательских сессий |
| `verification` | 1 час | Токены подтверждения (email, телефон) |
| `oauth_access` | 1 час | OAuth access токены |
| `oauth_refresh` | 30 дней | OAuth refresh токены |
### Компоненты системы токенов
#### `SessionTokenManager`
Управление сессиями пользователей:
- JWT-токены с payload `{user_id, username, iat, exp}`
- Redis хранение для отзыва и управления
- Поддержка multiple sessions per user
- Автоматическое продление при активности
**Основные методы:**
```python
async def create_session(user_id: str, auth_data=None, username=None, device_info=None) -> str
async def verify_session(token: str) -> Optional[Any]
async def refresh_session(user_id: int, old_token: str, device_info=None) -> Optional[str]
async def revoke_session_token(token: str) -> bool
async def revoke_user_sessions(user_id: str) -> int
```
**Redis структура:**
```
session:{user_id}:{token} # hash с данными сессии
user_sessions:{user_id} # set с активными токенами
{user_id}-{username}-{token} # legacy ключи для совместимости
```
#### `VerificationTokenManager`
Управление токенами подтверждения:
- Email verification
- Phone verification
- Password reset
- Одноразовые токены
**Основные методы:**
```python
async def create_verification_token(user_id: str, verification_type: str, data: TokenData, ttl=None) -> str
async def validate_verification_token(token: str) -> tuple[bool, Optional[TokenData]]
async def confirm_verification_token(token: str) -> Optional[TokenData] # одноразовое использование
```
#### `OAuthTokenManager`
Управление OAuth токенами:
- Google, GitHub, Facebook, X, Telegram, VK, Yandex
- Access/refresh token pairs
- Provider-specific storage
**Redis структура:**
```
oauth_access:{user_id}:{provider} # access токен
oauth_refresh:{user_id}:{provider} # refresh токен
```
#### `BatchTokenOperations`
Пакетные операции для производительности:
- Массовая валидация токенов
- Пакетный отзыв
- Очистка истекших токенов
#### `TokenMonitoring`
Мониторинг и статистика:
- Подсчет активных токенов по типам
- Статистика использования памяти
- Health check системы токенов
- Оптимизация производительности
### TokenStorage (Фасад)
Упрощенный фасад для основных операций:
```python
# Основные методы
await TokenStorage.create_session(user_id, username=username)
await TokenStorage.verify_session(token)
await TokenStorage.refresh_session(user_id, old_token, device_info)
await TokenStorage.revoke_session(token)
# Deprecated методы (для миграции)
await TokenStorage.create_onetime(user) # -> VerificationTokenManager
```
## OAuth система
### Поддерживаемые провайдеры
- **Google** - OpenID Connect
- **GitHub** - OAuth 2.0
- **Facebook** - Facebook Login
- **X (Twitter)** - OAuth 2.0 (без email)
- **Telegram** - Telegram Login Widget (без email)
- **VK** - VK OAuth (требует разрешений для email)
- **Yandex** - Yandex OAuth
### Процесс OAuth авторизации
1. **Инициация**: `GET /oauth/{provider}?state={csrf_token}&redirect_uri={url}`
2. **Callback**: `GET /oauth/{provider}/callback?code={code}&state={state}`
3. **Обработка**: Получение user profile, создание/обновление пользователя
4. **Результат**: JWT токен в cookie + redirect на фронтенд
### Безопасность OAuth
- **PKCE** (Proof Key for Code Exchange) для дополнительной безопасности
- **State параметры** хранятся в Redis с TTL 10 минут
- **Одноразовые сессии** - после использования удаляются
- **Генерация временных email** для провайдеров без email (X, Telegram)
## Middleware и декораторы
### AuthMiddleware
HTTP middleware для автоматической аутентификации:
- Извлечение токенов из cookies/headers
- Валидация JWT токенов
- Добавление user context в request
- Обработка истекших токенов
### GraphQL декораторы
```python
@auth_required # Требует авторизации
@permission_required # Требует конкретных разрешений
@admin_required # Требует admin права
```
## ORM модели
### Author (Пользователь)
```python
class Author:
id: int
email: str
name: str
slug: str
password: Optional[str] # bcrypt hash
pic: Optional[str] # URL аватара
bio: Optional[str]
email_verified: bool
created_at: int
updated_at: int
last_seen: int
# OAuth связи
oauth_accounts: List[OAuthAccount]
```
### OAuthAccount
```python
class OAuthAccount:
id: int
author_id: int
provider: str # google, github, etc.
provider_id: str # ID пользователя у провайдера
provider_email: Optional[str]
provider_data: dict # Дополнительные данные от провайдера
```
## Система разрешений
### Роли
- **user** - Обычный пользователь
- **moderator** - Модератор контента
- **admin** - Администратор системы
### Разрешения
- **read** - Чтение контента
- **write** - Создание контента
- **moderate** - Модерация контента
- **admin** - Административные действия
### Проверка разрешений
```python
from auth.permissions import check_permission
@permission_required("moderate")
async def moderate_content(info, content_id: str):
# Только пользователи с правами модерации
pass
```
## Безопасность
### Хеширование паролей
- **bcrypt** с rounds=10
- **SHA256** препроцессинг для длинных паролей
- **Salt** автоматически генерируется bcrypt
### JWT токены
- **Алгоритм**: HS256
- **Secret**: Из переменной окружения JWT_SECRET
- **Payload**: `{user_id, username, iat, exp}`
- **Expiration**: 30 дней (настраивается)
### Redis security
- **TTL** для всех токенов
- **Атомарные операции** через pipelines
- **SCAN** вместо KEYS для производительности
- **Транзакции** для критических операций
## Конфигурация
### Переменные окружения
```bash
# JWT
JWT_SECRET=your_super_secret_key
JWT_EXPIRATION_HOURS=720 # 30 дней
# Redis
REDIS_URL=redis://localhost:6379/0
# OAuth провайдеры
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
FACEBOOK_APP_ID=...
FACEBOOK_APP_SECRET=...
# ... и т.д.
# Session cookies
SESSION_COOKIE_NAME=session_token
SESSION_COOKIE_SECURE=true
SESSION_COOKIE_HTTPONLY=true
SESSION_COOKIE_SAMESITE=lax
SESSION_COOKIE_MAX_AGE=2592000 # 30 дней
# Frontend
FRONTEND_URL=https://yourdomain.com
```
## API Endpoints
### Аутентификация
```
POST /auth/login # Email/password вход
POST /auth/logout # Выход (отзыв токена)
POST /auth/refresh # Обновление токена
POST /auth/register # Регистрация
```
### OAuth
```
GET /oauth/{provider} # Инициация OAuth
GET /oauth/{provider}/callback # OAuth callback
```
### Профиль
```
GET /auth/profile # Текущий пользователь
PUT /auth/profile # Обновление профиля
POST /auth/change-password # Смена пароля
```
## Мониторинг и логирование
### Метрики
- Количество активных сессий по типам
- Использование памяти Redis
- Статистика OAuth провайдеров
- Health check всех компонентов
### Логирование
- **INFO**: Успешные операции (создание сессий, OAuth)
- **WARNING**: Подозрительная активность (неверные пароли)
- **ERROR**: Ошибки системы (Redis недоступен, JWT invalid)
## Производительность
### Оптимизации Redis
- **Pipeline операции** для атомарности
- **Batch обработка** токенов (100-1000 за раз)
- **SCAN** вместо KEYS для безопасности
- **TTL** автоматическая очистка
### Кэширование
- **@lru_cache** для часто используемых ключей
- **Connection pooling** для Redis
- **JWT decode caching** в middleware
## Миграция и совместимость
### Legacy поддержка
- Старые ключи Redis: `{user_id}-{username}-{token}`
- Автоматическая миграция при обращении
- Deprecated методы с предупреждениями
### Планы развития
- [ ] Удаление legacy ключей
- [ ] Переход на RS256 для JWT
- [ ] WebAuthn/FIDO2 поддержка
- [ ] Rate limiting для auth endpoints
- [ ] Audit log для всех auth операций
## Тестирование
### Unit тесты
```bash
pytest tests/auth/ # Все auth тесты
pytest tests/auth/test_oauth.py # OAuth тесты
pytest tests/auth/test_tokens.py # Token тесты
```
### Integration тесты
- OAuth flow с моками провайдеров
- Redis операции
- JWT lifecycle
- Permission checks
## Troubleshooting
### Частые проблемы
1. **Redis connection failed** - Проверить REDIS_URL и доступность
2. **JWT invalid** - Проверить JWT_SECRET и время сервера
3. **OAuth failed** - Проверить client_id/secret провайдеров
4. **Session not found** - Возможно токен истек или отозван
### Диагностика
```python
# Проверка health системы токенов
from auth.tokens.monitoring import TokenMonitoring
health = await TokenMonitoring().health_check()
# Статистика токенов
stats = await TokenMonitoring().get_token_statistics()
```

View File

@ -1,797 +0,0 @@
# Модуль аутентификации и авторизации
## Общее описание
Модуль реализует полноценную систему аутентификации с использованием локальной БД и Redis.
## Компоненты
### Модели данных
#### Author (orm.py)
- Основная модель пользователя с расширенным функционалом аутентификации
- Поддерживает:
- Локальную аутентификацию по email/телефону
- Систему ролей и разрешений (RBAC)
- Блокировку аккаунта при множественных неудачных попытках входа
- Верификацию email/телефона
#### Role и Permission (resolvers/rbac.py)
- Реализация RBAC (Role-Based Access Control)
- Роли содержат наборы разрешений
- Разрешения определяются как пары resource:operation
### Аутентификация
#### Внутренняя аутентификация
- Проверка токена в Redis
- Получение данных пользователя из локальной БД
- Проверка статуса аккаунта и разрешений
### Управление сессиями (sessions.py)
- Хранение сессий в Redis
- Поддержка:
- Создание сессий
- Верификация
- Отзыв отдельных сессий
- Отзыв всех сессий пользователя
- Автоматическое удаление истекших сессий
### JWT токены (jwtcodec.py)
- Кодирование/декодирование JWT токенов
- Проверка:
- Срока действия
- Подписи
- Издателя
- Поддержка пользовательских claims
### OAuth интеграция (oauth.py)
Поддерживаемые провайдеры:
- Google
- Facebook
- GitHub
Функционал:
- Авторизация через OAuth провайдеров
- Получение профиля пользователя
- Создание/обновление локального профиля
### Валидация (validations.py)
Модели валидации для:
- Регистрации пользователей
- Входа в систему
- OAuth данных
- JWT payload
- Ответов API
### Email функционал (email.py)
- Отправка писем через Mailgun
- Поддержка шаблонов
- Мультиязычность (ru/en)
- Подтверждение email
- Сброс пароля
## API Endpoints (resolvers.py)
### Мутации
- `login` - вход в систему
- `getSession` - получение текущей сессии
- `confirmEmail` - подтверждение email
- `registerUser` - регистрация пользователя
- `sendLink` - отправка ссылки для входа
### Запросы
- `logout` - выход из системы
- `isEmailUsed` - проверка использования email
## Безопасность
### Хеширование паролей (identity.py)
- Использование bcrypt с SHA-256
- Настраиваемое количество раундов
- Защита от timing-атак
### Защита от брутфорса
- Блокировка аккаунта после 5 неудачных попыток
- Время блокировки: 30 минут
- Сброс счетчика после успешного входа
## Обработка заголовков авторизации
### Особенности работы с заголовками в Starlette
При работе с заголовками в Starlette/FastAPI необходимо учитывать следующие особенности:
1. **Регистр заголовков**: Заголовки в объекте `Request` чувствительны к регистру. Для надежного получения заголовка `Authorization` следует использовать регистронезависимый поиск.
2. **Формат Bearer токена**: Токен может приходить как с префиксом `Bearer `, так и без него. Необходимо обрабатывать оба варианта.
### Правильное получение заголовка авторизации
```python
# Получение заголовка с учетом регистра
headers_dict = dict(req.headers.items())
token = None
# Ищем заголовок независимо от регистра
for header_name, header_value in headers_dict.items():
if header_name.lower() == SESSION_TOKEN_HEADER.lower():
token = header_value
break
# Обработка Bearer префикса
if token and token.startswith("Bearer "):
token = token.split("Bearer ")[1].strip()
```
### Распространенные проблемы и их решения
1. **Проблема**: Заголовок не находится при прямом обращении `req.headers.get("Authorization")`
**Решение**: Использовать регистронезависимый поиск по всем заголовкам
2. **Проблема**: Токен приходит с префиксом "Bearer" в одних запросах и без него в других
**Решение**: Всегда проверять и обрабатывать оба варианта
3. **Проблема**: Токен декодируется, но сессия не находится в Redis
**Решение**: Проверить формирование ключа сессии и добавить автоматическое создание сессии для валидных токенов
4. **Проблема**: Ошибки при декодировании JWT вызывают исключения
**Решение**: Обернуть декодирование в try-except и возвращать None вместо вызова исключений
## Конфигурация
Основные настройки в settings.py:
- `SESSION_TOKEN_LIFE_SPAN` - время жизни сессии
- `ONETIME_TOKEN_LIFE_SPAN` - время жизни одноразовых токенов
- `JWT_SECRET_KEY` - секретный ключ для JWT
- `JWT_ALGORITHM` - алгоритм подписи JWT
## Примеры использования
### Аутентификация
```python
# Проверка авторизации
user_id, roles = await check_auth(request)
# Добавление роли
await add_user_role(user_id, ["author"])
# Создание сессии
token = await create_local_session(author)
```
### OAuth авторизация
```python
# Инициация OAuth процесса
await oauth_login(request)
# Обработка callback
response = await oauth_authorize(request)
```
### 1. Базовая авторизация на фронтенде
```typescript
// pages/Login.tsx
// Предполагается, что AuthClient и createAuth импортированы корректно
// import { AuthClient } from '../auth/AuthClient'; // Путь может отличаться
// import { createAuth } from '../auth/useAuth'; // Путь может отличаться
import { Component, Show } from 'solid-js'; // Show для условного рендеринга
export const LoginPage: Component = () => {
// Клиент и хук авторизации (пример из client/auth/useAuth.ts)
// const authClient = new AuthClient(/* baseUrl or other config */);
// const auth = createAuth(authClient);
// Для простоты примера, предположим, что auth уже доступен через контекст или пропсы
// В реальном приложении используйте useAuthContext() если он настроен
const { store, login } = useAuthContext(); // Пример, если используется контекст
const handleSubmit = async (event: SubmitEvent) => {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const emailInput = form.elements.namedItem('email') as HTMLInputElement;
const passwordInput = form.elements.namedItem('password') as HTMLInputElement;
if (!emailInput || !passwordInput) {
console.error("Email or password input not found");
return;
}
const success = await login({
email: emailInput.value,
password: passwordInput.value
});
if (success) {
console.log('Login successful, redirecting...');
// window.location.href = '/'; // Раскомментируйте для реального редиректа
} else {
// Ошибка уже должна быть в store().error, обработанная в useAuth
console.error('Login failed:', store().error);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label for="email">Email:</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label for="password">Пароль:</label>
<input id="password" name="password" type="password" required />
</div>
<button type="submit" disabled={store().isLoading}>
{store().isLoading ? 'Вход...' : 'Войти'}
</button>
<Show when={store().error}>
<p style={{ color: 'red' }}>{store().error}</p>
</Show>
</form>
);
}
```
### 2. Защита компонента с помощью ролей
```typescript
// components/AdminPanel.tsx
import { useAuthContext } from '../auth'
export const AdminPanel: Component = () => {
const auth = useAuthContext()
// Проверяем наличие роли админа
if (!auth.hasRole('admin')) {
return <div>Доступ запрещен</div>
}
return (
<div>
{/* Контент админки */}
</div>
)
}
```
### 3. OAuth авторизация через Google
```typescript
// components/GoogleLoginButton.tsx
import { Component } from 'solid-js';
export const GoogleLoginButton: Component = () => {
const handleGoogleLogin = () => {
// Предполагается, что API_BASE_URL настроен глобально или импортирован
// const API_BASE_URL = 'http://localhost:8000'; // Пример
// window.location.href = `${API_BASE_URL}/auth/login/google`;
// Или если пути относительные и сервер на том же домене:
window.location.href = '/auth/login/google';
};
return (
<button onClick={handleGoogleLogin}>
Войти через Google
</button>
);
}
```
### 4. Работа с пользователем на бэкенде
```python
# routes/articles.py
# Предполагаемые импорты:
# from starlette.requests import Request
# from starlette.responses import JSONResponse
# from sqlalchemy.orm import Session
# from ..dependencies import get_db_session # Пример получения сессии БД
# from ..auth.decorators import login_required # Ваш декоратор
# from ..auth.orm import Author # Модель пользователя
# from ..models.article import Article # Модель статьи (пример)
# @login_required # Декоратор проверяет аутентификацию и добавляет user в request
async def create_article_example(request: Request): # Используем Request из Starlette
"""
Пример создания статьи с проверкой прав.
В реальном приложении используйте DI для сессии БД (например, FastAPI Depends).
"""
user: Author = request.user # request.user добавляется декоратором @login_required
# Проверяем право на создание статей (метод из модели auth.auth.orm)
if not await user.has_permission('shout:create'):
return JSONResponse({'error': 'Недостаточно прав для создания статьи'}, status_code=403)
try:
article_data = await request.json()
title = article_data.get('title')
content = article_data.get('content')
if not title or not content:
return JSONResponse({'error': 'Title and content are required'}, status_code=400)
except ValueError: # Если JSON некорректен
return JSONResponse({'error': 'Invalid JSON data'}, status_code=400)
# Пример работы с БД. В реальном приложении сессия db будет получена через DI.
# Здесь db - это заглушка, замените на вашу реальную логику работы с БД.
# Пример:
# with get_db_session() as db: # Получение сессии SQLAlchemy
# new_article = Article(
# title=title,
# content=content,
# author_id=user.id # Связываем статью с автором
# )
# db.add(new_article)
# db.commit()
# db.refresh(new_article)
# return JSONResponse({'id': new_article.id, 'title': new_article.title}, status_code=201)
# Заглушка для примера в документации
mock_article_id = 123
print(f"User {user.id} ({user.email}) is creating article '{title}'.")
return JSONResponse({'id': mock_article_id, 'title': title}, status_code=201)
```
### 5. Проверка прав в GraphQL резолверах
```python
# resolvers/mutations.py
from auth.decorators import login_required
from auth.models import Author
@login_required
async def update_article(_: None,info, article_id: int, data: dict):
"""
Обновление статьи с проверкой прав
"""
user: Author = info.context.user
# Получаем статью
article = db.query(Article).get(article_id)
if not article:
raise GraphQLError('Статья не найдена')
# Проверяем права на редактирование
if not await user.has_permission('articles', 'edit'):
raise GraphQLError('Недостаточно прав')
# Обновляем поля
article.title = data.get('title', article.title)
article.content = data.get('content', article.content)
db.commit()
return article
```
### 6. Создание пользователя с ролями
```python
# scripts/create_admin.py
from auth.models import Author, Role
from auth.password import hash_password
def create_admin(email: str, password: str):
"""Создание администратора"""
# Получаем роль админа
admin_role = db.query(Role).filter(Role.id == 'admin').first()
# Создаем пользователя
admin = Author(
email=email,
password=hash_password(password),
email_verified=True
)
# Назначаем роль
admin.roles.append(admin_role)
# Сохраняем
db.add(admin)
db.commit()
return admin
```
### 7. Работа с сессиями
```python
# auth/session_management.py (примерное название файла)
# Предполагаемые импорты:
# from starlette.responses import RedirectResponse
# from starlette.requests import Request
# from ..auth.orm import Author # Модель пользователя
# from ..auth.token import TokenStorage # Ваш модуль для работы с токенами
# from ..settings import SESSION_COOKIE_MAX_AGE, SESSION_COOKIE_NAME, SESSION_COOKIE_SECURE, SESSION_COOKIE_HTTPONLY, SESSION_COOKIE_SAMESITE
# Замените FRONTEND_URL_AUTH_SUCCESS и FRONTEND_URL_LOGOUT на реальные URL из настроек
FRONTEND_URL_AUTH_SUCCESS = "/auth/success" # Пример
FRONTEND_URL_LOGOUT = "/logout" # Пример
async def login_user_session(request: Request, user: Author, response_class=RedirectResponse):
"""
Создание сессии пользователя и установка cookie.
"""
if not hasattr(user, 'id'): # Проверка наличия id у пользователя
raise ValueError("User object must have an id attribute")
# Создаем токен сессии (TokenStorage из вашего модуля auth.token)
session_token = TokenStorage.create_session(str(user.id)) # ID пользователя обычно число, приводим к строке если нужно
# Устанавливаем cookie
# В реальном приложении FRONTEND_URL_AUTH_SUCCESS должен вести на страницу вашего фронтенда
response = response_class(url=FRONTEND_URL_AUTH_SUCCESS)
response.set_cookie(
key=SESSION_COOKIE_NAME, # 'session_token' из settings.py
value=session_token,
httponly=SESSION_COOKIE_HTTPONLY, # True из settings.py
secure=SESSION_COOKIE_SECURE, # True для HTTPS из settings.py
samesite=SESSION_COOKIE_SAMESITE, # 'lax' из settings.py
max_age=SESSION_COOKIE_MAX_AGE # 30 дней в секундах из settings.py
)
print(f"Session created for user {user.id}. Token: {session_token[:10]}...") # Логируем для отладки
return response
async def logout_user_session(request: Request, response_class=RedirectResponse):
"""
Завершение сессии пользователя и удаление cookie.
"""
session_token = request.cookies.get(SESSION_COOKIE_NAME)
if session_token:
# Удаляем токен из хранилища (TokenStorage из вашего модуля auth.token)
TokenStorage.delete_session(session_token)
print(f"Session token {session_token[:10]}... deleted from storage.")
# Удаляем cookie
# В реальном приложении FRONTEND_URL_LOGOUT должен вести на страницу вашего фронтенда
response = response_class(url=FRONTEND_URL_LOGOUT)
response.delete_cookie(SESSION_COOKIE_NAME)
print(f"Cookie {SESSION_COOKIE_NAME} deleted.")
return response
```
### 8. Проверка CSRF в формах
```typescript
// components/ProfileForm.tsx
// import { useAuthContext } from '../auth'; // Предполагаем, что auth есть в контексте
import { Component, createSignal, Show } from 'solid-js';
export const ProfileForm: Component = () => {
const { store, checkAuth } = useAuthContext(); // Пример получения из контекста
const [message, setMessage] = createSignal<string | null>(null);
const [error, setError] = createSignal<string | null>(null);
const handleSubmit = async (event: SubmitEvent) => {
event.preventDefault();
setMessage(null);
setError(null);
const form = event.currentTarget as HTMLFormElement;
const formData = new FormData(form);
// ВАЖНО: Получение CSRF-токена из cookie - это один из способов.
// Если CSRF-токен устанавливается как httpOnly cookie, то он будет автоматически
// отправляться браузером, и его не нужно доставать вручную для fetch,
// если сервер настроен на его проверку из заголовка (например, X-CSRF-Token),
// который fetch *не* устанавливает автоматически для httpOnly cookie.
// Либо сервер может предоставлять CSRF-токен через специальный эндпоинт.
// Представленный ниже способ подходит, если CSRF-токен доступен для JS.
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrf_token=')) // Имя cookie может отличаться
?.split('=')[1];
if (!csrfToken) {
// setError('CSRF token not found. Please refresh the page.');
// В продакшене CSRF-токен должен быть всегда. Этот лог для отладки.
console.warn('CSRF token not found in cookies. Ensure it is set by the server.');
// Для данного примера, если токен не найден, можно либо прервать, либо положиться на серверную проверку.
// Для большей безопасности, прерываем, если CSRF-защита критична на клиенте.
}
try {
// Замените '/api/profile' на ваш реальный эндпоинт
const response = await fetch('/api/profile', {
method: 'POST',
headers: {
// Сервер должен быть настроен на чтение этого заголовка
// если CSRF токен не отправляется автоматически с httpOnly cookie.
...(csrfToken && { 'X-CSRF-Token': csrfToken }),
// 'Content-Type': 'application/json' // Если отправляете JSON
},
body: formData // FormData отправится как 'multipart/form-data'
// Если нужно JSON: body: JSON.stringify(Object.fromEntries(formData))
});
if (response.ok) {
const result = await response.json();
setMessage(result.message || 'Профиль успешно обновлен!');
checkAuth(); // Обновить данные пользователя в сторе
} else {
const errData = await response.json();
setError(errData.error || `Ошибка: ${response.status}`);
}
} catch (err) {
console.error('Profile update error:', err);
setError('Не удалось обновить профиль. Попробуйте позже.');
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label for="name">Имя:</label>
<input id="name" name="name" defaultValue={store().user?.name || ''} />
</div>
{/* Другие поля профиля */}
<button type="submit">Сохранить изменения</button>
<Show when={message()}>
<p style={{ color: 'green' }}>{message()}</p>
</Show>
<Show when={error()}>
<p style={{ color: 'red' }}>{error()}</p>
</Show>
</form>
);
}
```
### 9. Кастомные валидаторы для форм
```typescript
// validators/auth.ts
export const validatePassword = (password: string): string[] => {
const errors: string[] = []
if (password.length < 8) {
errors.push('Пароль должен быть не менее 8 символов')
}
if (!/[A-Z]/.test(password)) {
errors.push('Пароль должен содержать заглавную букву')
}
if (!/[0-9]/.test(password)) {
errors.push('Пароль должен содержать цифру')
}
return errors
}
// components/RegisterForm.tsx
import { validatePassword } from '../validators/auth'
export const RegisterForm: Component = () => {
const [errors, setErrors] = createSignal<string[]>([])
const handleSubmit = async (e: Event) => {
e.preventDefault()
const form = e.target as HTMLFormElement
const data = new FormData(form)
// Валидация пароля
const password = data.get('password') as string
const passwordErrors = validatePassword(password)
if (passwordErrors.length > 0) {
setErrors(passwordErrors)
return
}
// Отправка формы...
}
return (
<form onSubmit={handleSubmit}>
<input name="password" type="password" />
{errors().map(error => (
<div class="error">{error}</div>
))}
<button type="submit">Регистрация</button>
</form>
)
}
```
### 10. Интеграция с внешними сервисами
```python
# services/notifications.py
from auth.models import Author
async def notify_login(user: Author, ip: str, device: str):
"""Отправка уведомления о новом входе"""
# Формируем текст
text = f"""
Новый вход в аккаунт:
IP: {ip}
Устройство: {device}
Время: {datetime.now()}
"""
# Отправляем email
await send_email(
to=user.email,
subject='Новый вход в аккаунт',
text=text
)
# Логируем
logger.info(f'New login for user {user.id} from {ip}')
```
## Тестирование
### 1. Тест OAuth авторизации
```python
# tests/test_oauth.py
@pytest.mark.asyncio
async def test_google_oauth_success(client, mock_google):
# Мокаем ответ от Google
mock_google.return_value = {
'id': '123',
'email': 'test@gmail.com',
'name': 'Test User'
}
# Запрос на авторизацию
response = await client.get('/auth/login/google')
assert response.status_code == 302
# Проверяем редирект
assert 'accounts.google.com' in response.headers['location']
# Проверяем сессию
assert 'state' in client.session
assert 'code_verifier' in client.session
```
### 2. Тест ролей и разрешений
```python
# tests/test_permissions.py
def test_user_permissions():
# Создаем тестовые данные
role = Role(id='editor', name='Editor')
permission = Permission(
id='articles:edit',
resource='articles',
operation='edit'
)
role.permissions.append(permission)
user = Author(email='test@test.com')
user.roles.append(role)
# Проверяем разрешения
assert await user.has_permission('articles', 'edit')
assert not await user.has_permission('articles', 'delete')
```
## Безопасность
### 1. Rate Limiting
```python
# middleware/rate_limit.py
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from redis import Redis
class RateLimitMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
# Получаем IP
ip = request.client.host
# Проверяем лимиты в Redis
redis = Redis()
key = f'rate_limit:{ip}'
# Увеличиваем счетчик
count = redis.incr(key)
if count == 1:
redis.expire(key, 60) # TTL 60 секунд
# Проверяем лимит
if count > 100: # 100 запросов в минуту
return JSONResponse(
{'error': 'Too many requests'},
status_code=429
)
return await call_next(request)
```
### 2. Защита от брутфорса
```python
# auth/login.py
async def handle_login_attempt(user: Author, success: bool):
"""Обработка попытки входа"""
if not success:
# Увеличиваем счетчик неудачных попыток
user.increment_failed_login()
if user.is_locked():
# Аккаунт заблокирован
raise AuthError(
'Account is locked. Try again later.',
'ACCOUNT_LOCKED'
)
else:
# Сбрасываем счетчик при успешном входе
user.reset_failed_login()
```
## Мониторинг
### 1. Логирование событий авторизации
```python
# auth/logging.py
import structlog
logger = structlog.get_logger()
def log_auth_event(
event_type: str,
user_id: int = None,
success: bool = True,
**kwargs
):
"""
Логирование событий авторизации
Args:
event_type: Тип события (login, logout, etc)
user_id: ID пользователя
success: Успешность операции
**kwargs: Дополнительные поля
"""
logger.info(
'auth_event',
event_type=event_type,
user_id=user_id,
success=success,
**kwargs
)
```
### 2. Метрики для Prometheus
```python
# metrics/auth.py
from prometheus_client import Counter, Histogram
# Счетчики
login_attempts = Counter(
'auth_login_attempts_total',
'Number of login attempts',
['success']
)
oauth_logins = Counter(
'auth_oauth_logins_total',
'Number of OAuth logins',
['provider']
)
# Гистограммы
login_duration = Histogram(
'auth_login_duration_seconds',
'Time spent processing login'
)
```

View File

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

View File

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

View File

@ -1,53 +1,8 @@
## Админ-панель
- **Управление пользователями**: Просмотр, поиск, назначение ролей (user/moderator/admin)
- **Управление публикациями**: Таблица со всеми публикациями, фильтрация по статусу, превью контента
- **Управление топиками**: Полноценное редактирование топиков в админ-панели
- **Иерархическое отображение**: Темы показываются в виде дерева с отступами и символами `└─` для дочерних элементов
- **Колонки таблицы**: ID, название, slug, описание, сообщество, родители, действия
- **Простой интерфейс редактирования**:
- **Клик по строке**: Модалка редактирования открывается при клике на любом месте строки таблицы
- **Ненавязчивый крестик**: Кнопка удаления в виде серого "×", краснеет при hover
- **Простой HTML редактор**: Обычный contenteditable div с моноширинным шрифтом вместо сложного редактора
- **Редактируемые поля**:
- **ID**: Отображается для идентификации (поле только для чтения)
- **Название и slug**: Текстовые поля для основной информации
- **Описание**: Простой HTML редактор с placeholder
- **Картинка**: URL изображения топика
- **Сообщество**: ID сообщества с числовой валидацией
- **Родители**: Список parent_ids через запятую с автоматическим парсингом
- **Безопасное удаление**: Модальное окно подтверждения при клике на крестик
- **Корректная инвалидация кешей**: Автоматическое обновление счетчиков подписок у всех подписчиков
- **GraphQL интеграция**: Использование мутаций `UPDATE_TOPIC_MUTATION` и `DELETE_TOPIC_MUTATION`
- **Управление переменными среды**: Настройка конфигурации приложения
- **TypeScript интеграция**: Полная типизация с автогенерацией типов из GraphQL схемы
- **Responsive дизайн**: Адаптивность для разных размеров экранов
## Codegen интеграция
- **Автоматическая генерация типов**: TypeScript типы генерируются из GraphQL схемы
- **Файл конфигурации**: `codegen.ts` с настройками для client-side генерации
- **Структура проекта**: Разделение на queries, mutations и index файлы в `panel/graphql/generated/`
- **Type safety**: Строгая типизация для всех GraphQL операций в админ-панели
- **Developer Experience**: Автокомплит и проверка типов в IDE
## Улучшенная система кеширования топиков
- **Централизованная функция**: `invalidate_topic_followers_cache()` в модуле cache
- **Комплексная инвалидация**: Обработка кешей как самого топика, так и всех его подписчиков
- **Правильная последовательность**: Получение подписчиков ДО удаления данных из БД
- **Инвалидируемые кеши**:
- `author:follows-topics:{follower_id}` - список подписок на топики
- `author:followers:{follower_id}` - счетчики подписчиков
- `author:stat:{follower_id}` - общая статистика автора
- `topic:followers:{topic_id}` - список подписчиков топика
- **Архитектурные принципы**: Разделение ответственности, переиспользуемость, тестируемость
## Просмотры публикаций
- Интеграция с Google Analytics для отслеживания просмотров публикаций
- Подсчет уникальных пользователей и общего количества просмотров
- Автоматическое обновление статистики при запросе данных публикации
- Автоматическое обновление статистики при запросе данных публикации
## Мультидоменная авторизация
@ -57,16 +12,7 @@
## Система кеширования
- **Redis как основное хранилище**: Кэширование, сессии, токены, временные данные
- **Полная документация схемы**: [redis-schema.md](redis-schema.md) - детальное описание всех структур данных
- **11 категорий данных**: Аутентификация, кэш сущностей, поиск, просмотры, уведомления
- **Система токенов**: Сессии, OAuth токены, токены подтверждения с TTL
- **Переменные окружения**: Централизованное хранение конфигурации в Redis
- **Кэш сущностей**: Авторы, темы, публикации с автоматической инвалидацией
- **Поисковый кэш**: Нормализованные запросы с результатами
- **Pub/Sub каналы**: Real-time уведомления и коммуникация
- **Оптимизация**: Pipeline операции, стратегии кэширования
- **Мониторинг**: Команды диагностики и решение проблем производительности
- Redis используется в качестве основного механизма кеширования
- Поддержка как синхронных, так и асинхронных функций в декораторе cache_on_arguments
- Автоматическая сериализация/десериализация данных в JSON с использованием CustomJSONEncoder
- Резервная сериализация через pickle для сложных объектов
@ -74,6 +20,15 @@
- Настраиваемое время жизни кеша (TTL)
- Возможность ручной инвалидации кеша для конкретных функций и аргументов
## Webhooks
- Автоматическая регистрация вебхука для события user.login
- Предотвращение создания дублирующихся вебхуков
- Автоматическая очистка устаревших вебхуков
- Поддержка авторизации вебхуков через WEBHOOK_SECRET
- Обработка ошибок при операциях с вебхуками
- Динамическое определение endpoint'а на основе окружения
## CORS Configuration
- Поддерживаемые методы: GET, POST, OPTIONS
@ -90,84 +45,4 @@
- Использование поля `stat.comments_count` для отображения количества ответов на комментарий
- Добавление специального поля `first_replies` для хранения первых ответов на комментарий
- Поддержка различных методов сортировки (новые, старые, популярные)
- Оптимизированные SQL запросы для минимизации нагрузки на базу данных
## Модульная система авторизации
- **Специализированные менеджеры токенов**:
- `SessionTokenManager`: Управление пользовательскими сессиями
- `VerificationTokenManager`: Токены для подтверждения email, телефона, смены пароля
- `OAuthTokenManager`: Управление OAuth токенами для внешних провайдеров
- `BatchTokenOperations`: Пакетные операции с токенами
- `TokenMonitoring`: Мониторинг и статистика использования токенов
- **Улучшенная производительность**:
- 50% ускорение Redis операций через пайплайны
- 30% снижение потребления памяти
- Оптимизированные запросы к базе данных
- **Безопасность**:
- Поддержка PKCE для всех OAuth провайдеров
- Автоматическая очистка истекших токенов
- Защита от replay-атак
## OAuth интеграция
- **7 поддерживаемых провайдеров**:
- Google, GitHub, Facebook
- X (Twitter), Telegram
- VK (ВКонтакте), Yandex
- **Обработка провайдеров без email**:
- Генерация временных email для X и Telegram
- Возможность обновления email в профиле
- **Токены в Redis**:
- Хранение access и refresh токенов с TTL
- Автоматическое обновление токенов
- Централизованное управление через Redis
- **Безопасность**:
- PKCE для всех OAuth потоков
- Временные state параметры в Redis (10 минут TTL)
- Одноразовые сессии
- Логирование неудачных попыток аутентификации
## Система управления паролями и email
- **Мутация updateSecurity**:
- Смена пароля с валидацией сложности
- Смена email с двухэтапным подтверждением
- Одновременная смена пароля и email
- **Токены подтверждения в Redis**:
- Автоматический TTL для всех токенов
- Безопасное хранение данных подтверждения
- **Дополнительные мутации**:
- confirmEmailChange
- cancelEmailChange
## Система featured публикаций
- **Автоматическое получение статуса featured**:
- Публикация получает статус featured при более чем 4 лайках от авторов с featured статьями
- Проверка квалификации автора: наличие опубликованных featured статей
- Логирование процесса для отладки и мониторинга
- **Условия удаления с главной (unfeatured)**:
- **Условие 1**: Менее 5 голосов "за" (положительные реакции)
- **Условие 2**: 20% или более отрицательных реакций от общего количества голосов
- Проверка выполняется только для уже featured публикаций
- **Оптимизированная логика обработки**:
- Проверка unfeatured имеет приоритет над featured при обработке реакций
- Автоматическая проверка условий при добавлении/удалении реакций
- Корректная обработка типов данных в функциях проверки
- **Интеграция с системой реакций**:
- Обработка в `create_reaction` для новых реакций
- Обработка в `delete_reaction` для удаленных реакций
- Учет только реакций на саму публикацию (не на комментарии)
## RBAC
- **Наследование разрешений между ролями** происходит только при инициализации прав для сообщества. В Redis хранятся уже развернутые (полные) списки разрешений для каждой роли. Проверка прав — это быстрый lookup без on-the-fly наследования.
## Core features
- RBAC с иерархией ролей, наследование только при инициализации, быстрый доступ к правам через Redis
## Changelog
- v0.6.11: RBAC — наследование только при инициализации, ускорение, упрощение кода, исправлены тесты
- Оптимизированные SQL запросы для минимизации нагрузки на базу данных

View File

@ -37,12 +37,10 @@ Unfollow an entity.
**Returns:** Same as `follow`
**Important:** Always returns current following list even if the subscription was not found, ensuring UI consistency.
### Queries
#### get_shout_followers
Get list of authors who reacted to a shout.
Get list of users who reacted to a shout.
**Parameters:**
- `slug: String` - Shout slug
@ -64,126 +62,9 @@ Author[] // List of authors who reacted
### Cache Flow
1. On follow/unfollow:
- Update entity in cache
- **Invalidate user's following list cache** (NEW)
- Update follower's following list
2. Cache is updated before notifications
### Cache Invalidation (NEW)
Following cache keys are invalidated after operations:
- `author:follows-topics:{user_id}` - After topic follow/unfollow
- `author:follows-authors:{user_id}` - After author follow/unfollow
This ensures fresh data is fetched from database on next request.
## Error Handling
### Enhanced Error Handling (UPDATED)
- 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
@ -192,6 +73,14 @@ return {f"{entity_type}s": existing_follows, "error": error}
- Author ID
- Action type ("follow"/"unfollow")
## Error Handling
- Unauthorized access check
- Entity existence validation
- Duplicate follow prevention
- Full error logging
- Transaction safety with `local_session()`
## Database Schema
### Follower Tables
@ -202,18 +91,4 @@ return {f"{entity_type}s": existing_follows, "error": error}
Each table contains:
- `follower` - ID of following user
- `{entity_type}` - ID of followed entity
## Testing
Run the test script to verify fixes:
```bash
python test_unfollow_fix.py
```
### Test Coverage
- ✅ Unfollow existing subscription
- ✅ Unfollow non-existent subscription
- ✅ Cache invalidation
- ✅ Proper error handling
- ✅ UI state consistency
- `{entity_type}` - ID of followed entity

View File

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

View File

@ -1,199 +0,0 @@
# OAuth Deployment Checklist
## 🚀 Quick Setup Guide
### 1. Backend Implementation
```bash
# Добавьте в requirements.txt или poetry
redis>=4.0.0
httpx>=0.24.0
pydantic>=2.0.0
```
### 2. Environment Variables
```bash
# .env file
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
FACEBOOK_APP_ID=your_facebook_app_id
FACEBOOK_APP_SECRET=your_facebook_app_secret
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
VK_APP_ID=your_vk_app_id
VK_APP_SECRET=your_vk_app_secret
YANDEX_CLIENT_ID=your_yandex_client_id
YANDEX_CLIENT_SECRET=your_yandex_client_secret
REDIS_URL=redis://localhost:6379/0
JWT_SECRET=your_super_secret_jwt_key
JWT_EXPIRATION_HOURS=24
```
### 3. Database Migration
```sql
-- Create oauth_links table
CREATE TABLE oauth_links (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES authors(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL,
provider_id VARCHAR(255) NOT NULL,
provider_data JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(provider, provider_id)
);
CREATE INDEX idx_oauth_links_user_id ON oauth_links(user_id);
CREATE INDEX idx_oauth_links_provider ON oauth_links(provider, provider_id);
```
### 4. OAuth Provider Setup
#### Google OAuth
1. Перейти в [Google Cloud Console](https://console.cloud.google.com/)
2. Создать новый проект или выбрать существующий
3. Включить Google+ API
4. Настроить OAuth consent screen
5. Создать OAuth 2.0 credentials
6. Добавить redirect URIs:
- `https://your-domain.com/auth/oauth/google/callback`
- `http://localhost:3000/auth/oauth/google/callback` (для разработки)
#### Facebook OAuth
1. Перейти в [Facebook Developers](https://developers.facebook.com/)
2. Создать новое приложение
3. Добавить продукт "Facebook Login"
4. Настроить Valid OAuth Redirect URIs:
- `https://your-domain.com/auth/oauth/facebook/callback`
#### GitHub OAuth
1. Перейти в [GitHub Settings](https://github.com/settings/applications/new)
2. Создать новое OAuth App
3. Настроить Authorization callback URL:
- `https://your-domain.com/auth/oauth/github/callback`
### 5. Backend Endpoints (FastAPI example)
```python
# auth/oauth.py
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
router = APIRouter(prefix="/auth/oauth")
@router.get("/{provider}")
async def oauth_redirect(provider: str, state: str, redirect_uri: str):
# Валидация провайдера
if provider not in ["google", "facebook", "github", "vk", "yandex"]:
raise HTTPException(400, "Unsupported provider")
# Сохранение state в Redis
await store_oauth_state(state, redirect_uri)
# Генерация URL провайдера
oauth_url = generate_provider_url(provider, state, redirect_uri)
return RedirectResponse(url=oauth_url)
@router.get("/{provider}/callback")
async def oauth_callback(provider: str, code: str, state: str):
# Проверка state
stored_data = await get_oauth_state(state)
if not stored_data:
raise HTTPException(400, "Invalid state")
# Обмен code на user_data
user_data = await exchange_code_for_user_data(provider, code)
# Создание/поиск пользователя
user = await get_or_create_user_from_oauth(provider, user_data)
# Генерация JWT
access_token = generate_jwt_token(user.id)
# Редирект с токеном
return RedirectResponse(
url=f"{stored_data['redirect_uri']}?state={state}&access_token={access_token}"
)
```
### 6. Testing
```bash
# Запуск E2E тестов
npm run test:e2e -- oauth.spec.ts
# Проверка OAuth endpoints
curl -X GET "http://localhost:8000/auth/oauth/google?state=test&redirect_uri=http://localhost:3000"
```
### 7. Production Deployment
#### Frontend
- [ ] Проверить корректность `coreApiUrl` в production
- [ ] Добавить обработку ошибок OAuth в UI
- [ ] Настроить CSP headers для OAuth редиректов
#### Backend
- [ ] Настроить HTTPS для всех OAuth endpoints
- [ ] Добавить rate limiting для OAuth endpoints
- [ ] Настроить CORS для фронтенд доменов
- [ ] Добавить мониторинг OAuth ошибок
- [ ] Настроить логирование OAuth событий
#### Infrastructure
- [ ] Настроить Redis для production
- [ ] Добавить health checks для OAuth endpoints
- [ ] Настроить backup для oauth_links таблицы
### 8. Security Checklist
- [ ] Все OAuth секреты в environment variables
- [ ] State validation с TTL (10 минут)
- [ ] CSRF protection включен
- [ ] Redirect URI validation
- [ ] Rate limiting на OAuth endpoints
- [ ] Логирование всех OAuth событий
- [ ] HTTPS обязателен в production
### 9. Monitoring
```python
# Добавить метрики для мониторинга
from prometheus_client import Counter, Histogram
oauth_requests = Counter('oauth_requests_total', 'OAuth requests', ['provider', 'status'])
oauth_duration = Histogram('oauth_duration_seconds', 'OAuth request duration')
@router.get("/{provider}")
async def oauth_redirect(provider: str, state: str, redirect_uri: str):
with oauth_duration.time():
try:
# OAuth logic
oauth_requests.labels(provider=provider, status='success').inc()
except Exception as e:
oauth_requests.labels(provider=provider, status='error').inc()
raise
```
## 🔧 Troubleshooting
### Частые ошибки
1. **"OAuth state mismatch"**
- Проверьте TTL Redis
- Убедитесь, что state генерируется правильно
2. **"Provider authentication failed"**
- Проверьте client_id и client_secret
- Убедитесь, что redirect_uri совпадает с настройками провайдера
3. **"Invalid redirect URI"**
- Добавьте все возможные redirect URIs в настройки приложения
- Проверьте HTTPS/HTTP в production/development
### Логи для отладки
```bash
# Backend логи
tail -f /var/log/app/oauth.log | grep "oauth"
# Frontend логи (browser console)
# Фильтр: "[oauth]" или "[SessionProvider]"
```

View File

@ -1,430 +0,0 @@
# OAuth Implementation Guide
## Фронтенд (Текущая реализация)
### Контекст сессии
```typescript
// src/context/session.tsx
const oauth = (provider: string) => {
console.info('[oauth] Starting OAuth flow for provider:', provider)
if (isServer) {
console.warn('[oauth] OAuth not available during SSR')
return
}
// Генерируем state для OAuth
const state = crypto.randomUUID()
localStorage.setItem('oauth_state', state)
// Формируем URL для OAuth
const oauthUrl = `${coreApiUrl}/auth/oauth/${provider}?state=${state}&redirect_uri=${encodeURIComponent(window.location.origin)}`
// Перенаправляем на OAuth провайдера
window.location.href = oauthUrl
}
```
### Обработка OAuth callback
```typescript
// Обработка OAuth параметров в SessionProvider
createEffect(
on([() => searchParams?.state, () => searchParams?.access_token, () => searchParams?.token],
([state, access_token, token]) => {
// OAuth обработка
if (state && access_token) {
console.info('[SessionProvider] Processing OAuth callback')
const storedState = !isServer ? localStorage.getItem('oauth_state') : null
if (storedState === state) {
console.info('[SessionProvider] OAuth state verified')
batch(() => {
changeSearchParams({ mode: 'confirm-email', m: 'auth', access_token }, { replace: true })
if (!isServer) localStorage.removeItem('oauth_state')
})
} else {
console.warn('[SessionProvider] OAuth state mismatch')
setAuthError('OAuth state mismatch')
}
return
}
// Обработка токена сброса пароля
if (token) {
console.info('[SessionProvider] Processing password reset token')
changeSearchParams({ mode: 'change-password', m: 'auth', token }, { replace: true })
}
},
{ defer: true }
)
)
```
## Бекенд Requirements
### 1. OAuth Endpoints
#### GET `/auth/oauth/{provider}`
```python
@router.get("/auth/oauth/{provider}")
async def oauth_redirect(
provider: str,
state: str,
redirect_uri: str,
request: Request
):
"""
Инициация OAuth flow с внешним провайдером
Args:
provider: Провайдер OAuth (google, facebook, github)
state: CSRF токен от клиента
redirect_uri: URL для редиректа после авторизации
Returns:
RedirectResponse: Редирект на провайдера OAuth
"""
# Валидация провайдера
if provider not in SUPPORTED_PROVIDERS:
raise HTTPException(status_code=400, detail="Unsupported OAuth provider")
# Сохранение state в сессии/Redis для проверки
await store_oauth_state(state, redirect_uri)
# Генерация URL провайдера
oauth_url = generate_provider_url(provider, state, redirect_uri)
return RedirectResponse(url=oauth_url)
```
#### GET `/auth/oauth/{provider}/callback`
```python
@router.get("/auth/oauth/{provider}/callback")
async def oauth_callback(
provider: str,
code: str,
state: str,
request: Request
):
"""
Обработка callback от OAuth провайдера
Args:
provider: Провайдер OAuth
code: Authorization code от провайдера
state: CSRF токен для проверки
Returns:
RedirectResponse: Редирект обратно на фронтенд с токеном
"""
# Проверка state
stored_data = await get_oauth_state(state)
if not stored_data:
raise HTTPException(status_code=400, detail="Invalid or expired state")
# Обмен code на access_token
try:
user_data = await exchange_code_for_user_data(provider, code)
except OAuthException as e:
logger.error(f"OAuth error for {provider}: {e}")
return RedirectResponse(url=f"{stored_data['redirect_uri']}?error=oauth_failed")
# Поиск/создание пользователя
user = await get_or_create_user_from_oauth(provider, user_data)
# Генерация JWT токена
access_token = generate_jwt_token(user.id)
# Редирект обратно на фронтенд
redirect_url = f"{stored_data['redirect_uri']}?state={state}&access_token={access_token}"
return RedirectResponse(url=redirect_url)
```
### 2. Provider Configuration
#### Google OAuth
```python
GOOGLE_OAUTH_CONFIG = {
"client_id": os.getenv("GOOGLE_CLIENT_ID"),
"client_secret": os.getenv("GOOGLE_CLIENT_SECRET"),
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"user_info_url": "https://www.googleapis.com/oauth2/v2/userinfo",
"scope": "openid email profile"
}
```
#### Facebook OAuth
```python
FACEBOOK_OAUTH_CONFIG = {
"client_id": os.getenv("FACEBOOK_APP_ID"),
"client_secret": os.getenv("FACEBOOK_APP_SECRET"),
"auth_url": "https://www.facebook.com/v18.0/dialog/oauth",
"token_url": "https://graph.facebook.com/v18.0/oauth/access_token",
"user_info_url": "https://graph.facebook.com/v18.0/me",
"scope": "email public_profile"
}
```
#### GitHub OAuth
```python
GITHUB_OAUTH_CONFIG = {
"client_id": os.getenv("GITHUB_CLIENT_ID"),
"client_secret": os.getenv("GITHUB_CLIENT_SECRET"),
"auth_url": "https://github.com/login/oauth/authorize",
"token_url": "https://github.com/login/oauth/access_token",
"user_info_url": "https://api.github.com/user",
"scope": "read:user user:email"
}
```
### 3. User Management
#### OAuth User Model
```python
class OAuthUser(BaseModel):
provider: str
provider_id: str
email: str
name: str
avatar_url: Optional[str] = None
raw_data: dict
```
#### User Creation/Linking
```python
async def get_or_create_user_from_oauth(
provider: str,
oauth_data: OAuthUser
) -> User:
"""
Поиск существующего пользователя или создание нового
Args:
provider: OAuth провайдер
oauth_data: Данные пользователя от провайдера
Returns:
User: Пользователь в системе
"""
# Поиск по OAuth связке
oauth_link = await OAuthLink.get_by_provider_and_id(
provider=provider,
provider_id=oauth_data.provider_id
)
if oauth_link:
return await User.get(oauth_link.user_id)
# Поиск по email
existing_user = await User.get_by_email(oauth_data.email)
if existing_user:
# Привязка OAuth к существующему пользователю
await OAuthLink.create(
user_id=existing_user.id,
provider=provider,
provider_id=oauth_data.provider_id,
provider_data=oauth_data.raw_data
)
return existing_user
# Создание нового пользователя
new_user = await User.create(
email=oauth_data.email,
name=oauth_data.name,
pic=oauth_data.avatar_url,
is_verified=True, # OAuth email считается верифицированным
registration_method='oauth',
registration_provider=provider
)
# Создание OAuth связки
await OAuthLink.create(
user_id=new_user.id,
provider=provider,
provider_id=oauth_data.provider_id,
provider_data=oauth_data.raw_data
)
return new_user
```
### 4. Security
#### State Management
```python
import redis
from datetime import timedelta
redis_client = redis.Redis()
async def store_oauth_state(
state: str,
redirect_uri: str,
ttl: timedelta = timedelta(minutes=10)
):
"""Сохранение OAuth state с TTL"""
key = f"oauth_state:{state}"
data = {
"redirect_uri": redirect_uri,
"created_at": datetime.utcnow().isoformat()
}
await redis_client.setex(key, ttl, json.dumps(data))
async def get_oauth_state(state: str) -> Optional[dict]:
"""Получение и удаление OAuth state"""
key = f"oauth_state:{state}"
data = await redis_client.get(key)
if data:
await redis_client.delete(key) # One-time use
return json.loads(data)
return None
```
#### CSRF Protection
```python
def validate_oauth_state(stored_state: str, received_state: str) -> bool:
"""Проверка OAuth state для защиты от CSRF"""
return stored_state == received_state
def validate_redirect_uri(uri: str) -> bool:
"""Валидация redirect_uri для предотвращения открытых редиректов"""
allowed_domains = [
"localhost:3000",
"discours.io",
"new.discours.io"
]
parsed = urlparse(uri)
return any(domain in parsed.netloc for domain in allowed_domains)
```
### 5. Database Schema
#### OAuth Links Table
```sql
CREATE TABLE oauth_links (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL,
provider_id VARCHAR(255) NOT NULL,
provider_data JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(provider, provider_id),
INDEX(user_id),
INDEX(provider, provider_id)
);
```
### 6. Environment Variables
#### Required Config
```bash
# Google OAuth
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
# Facebook OAuth
FACEBOOK_APP_ID=your_facebook_app_id
FACEBOOK_APP_SECRET=your_facebook_app_secret
# GitHub OAuth
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
# Redis для state management
REDIS_URL=redis://localhost:6379/0
# JWT
JWT_SECRET=your_jwt_secret_key
JWT_EXPIRATION_HOURS=24
```
### 7. Error Handling
#### OAuth Exceptions
```python
class OAuthException(Exception):
pass
class InvalidProviderException(OAuthException):
pass
class StateValidationException(OAuthException):
pass
class ProviderAPIException(OAuthException):
pass
# Error responses
@app.exception_handler(OAuthException)
async def oauth_exception_handler(request: Request, exc: OAuthException):
logger.error(f"OAuth error: {exc}")
return RedirectResponse(
url=f"{request.base_url}?error=oauth_failed&message={str(exc)}"
)
```
### 8. Testing
#### Unit Tests
```python
def test_oauth_redirect():
response = client.get("/auth/oauth/google?state=test&redirect_uri=http://localhost:3000")
assert response.status_code == 307
assert "accounts.google.com" in response.headers["location"]
def test_oauth_callback():
# Mock provider response
with mock.patch('oauth.exchange_code_for_user_data') as mock_exchange:
mock_exchange.return_value = OAuthUser(
provider="google",
provider_id="123456",
email="test@example.com",
name="Test User"
)
response = client.get("/auth/oauth/google/callback?code=test_code&state=test_state")
assert response.status_code == 307
assert "access_token=" in response.headers["location"]
```
## Frontend Testing
### E2E Tests
```typescript
// tests/oauth.spec.ts
test('OAuth flow with Google', async ({ page }) => {
await page.goto('/login')
// Click Google OAuth button
await page.click('[data-testid="oauth-google"]')
// Should redirect to Google
await page.waitForURL(/accounts\.google\.com/)
// Mock successful OAuth (in test environment)
await page.goto('/?state=test&access_token=mock_token')
// Should be logged in
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible()
})
```
## Deployment Checklist
- [ ] Зарегистрировать OAuth приложения у провайдеров
- [ ] Настроить redirect URLs в консолях провайдеров
- [ ] Добавить environment variables
- [ ] Настроить Redis для state management
- [ ] Создать таблицу oauth_links
- [ ] Добавить rate limiting для OAuth endpoints
- [ ] Настроить мониторинг OAuth ошибок
- [ ] Протестировать все провайдеры в staging
- [ ] Добавить логирование OAuth событий

View File

@ -1,123 +0,0 @@
# OAuth Providers Setup Guide
This guide explains how to set up OAuth authentication for various social platforms.
## Supported Providers
The platform supports the following OAuth providers:
- Google
- GitHub
- Facebook
- X (Twitter)
- Telegram
- VK (VKontakte)
- Yandex
## Environment Variables
Add the following environment variables to your `.env` file:
```bash
# Google OAuth
OAUTH_CLIENTS_GOOGLE_ID=your_google_client_id
OAUTH_CLIENTS_GOOGLE_KEY=your_google_client_secret
# GitHub OAuth
OAUTH_CLIENTS_GITHUB_ID=your_github_client_id
OAUTH_CLIENTS_GITHUB_KEY=your_github_client_secret
# Facebook OAuth
OAUTH_CLIENTS_FACEBOOK_ID=your_facebook_app_id
OAUTH_CLIENTS_FACEBOOK_KEY=your_facebook_app_secret
# X (Twitter) OAuth
OAUTH_CLIENTS_X_ID=your_x_client_id
OAUTH_CLIENTS_X_KEY=your_x_client_secret
# Telegram OAuth
OAUTH_CLIENTS_TELEGRAM_ID=your_telegram_bot_token
OAUTH_CLIENTS_TELEGRAM_KEY=your_telegram_bot_secret
# VK OAuth
OAUTH_CLIENTS_VK_ID=your_vk_app_id
OAUTH_CLIENTS_VK_KEY=your_vk_secure_key
# Yandex OAuth
OAUTH_CLIENTS_YANDEX_ID=your_yandex_client_id
OAUTH_CLIENTS_YANDEX_KEY=your_yandex_client_secret
```
## Provider Setup Instructions
### Google
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select existing
3. Enable Google+ API and OAuth 2.0
4. Create OAuth 2.0 Client ID credentials
5. Add your callback URLs: `https://yourdomain.com/oauth/google/callback`
### GitHub
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
2. Create a new OAuth App
3. Set Authorization callback URL: `https://yourdomain.com/oauth/github/callback`
### Facebook
1. Go to [Facebook Developers](https://developers.facebook.com/)
2. Create a new app
3. Add Facebook Login product
4. Configure Valid OAuth redirect URIs: `https://yourdomain.com/oauth/facebook/callback`
### X (Twitter)
1. Go to [Twitter Developer Portal](https://developer.twitter.com/)
2. Create a new app
3. Enable OAuth 2.0 authentication
4. Set Callback URLs: `https://yourdomain.com/oauth/x/callback`
5. **Note**: X doesn't provide email addresses through their API
### Telegram
1. Create a bot with [@BotFather](https://t.me/botfather)
2. Use `/newbot` command and follow instructions
3. Get your bot token
4. Configure domain settings with `/setdomain` command
5. **Note**: Telegram doesn't provide email addresses
### VK (VKontakte)
1. Go to [VK for Developers](https://vk.com/dev)
2. Create a new application
3. Set Authorized redirect URI: `https://yourdomain.com/oauth/vk/callback`
4. **Note**: Email access requires special permissions from VK
### Yandex
1. Go to [Yandex OAuth](https://oauth.yandex.com/)
2. Create a new application
3. Set Callback URI: `https://yourdomain.com/oauth/yandex/callback`
4. Select required permissions: `login:email login:info`
## Email Handling
Some providers (X, Telegram) don't provide email addresses. In these cases:
- A temporary email is generated: `{provider}_{user_id}@oauth.local`
- Users can update their email in profile settings later
- `email_verified` is set to `false` for generated emails
## Usage in Frontend
OAuth URLs:
```
/oauth/google
/oauth/github
/oauth/facebook
/oauth/x
/oauth/telegram
/oauth/vk
/oauth/yandex
```
Each provider accepts a `state` parameter for CSRF protection and a `redirect_uri` for post-authentication redirects.
## Security Notes
- All OAuth flows use PKCE (Proof Key for Code Exchange) for additional security
- State parameters are stored in Redis with 10-minute TTL
- OAuth sessions are one-time use only
- Failed authentications are logged for monitoring

View File

@ -1,329 +0,0 @@
# OAuth Token Management
## Overview
Система управления OAuth токенами с использованием Redis для безопасного и производительного хранения токенов доступа и обновления от различных провайдеров.
## Архитектура
### Redis Storage
OAuth токены хранятся в Redis с автоматическим истечением (TTL):
- `oauth_access:{user_id}:{provider}` - access tokens
- `oauth_refresh:{user_id}:{provider}` - refresh tokens
### Поддерживаемые провайдеры
- Google OAuth 2.0
- Facebook Login
- GitHub OAuth
## API Documentation
### OAuthTokenStorage Class
#### store_access_token()
Сохраняет access token в Redis с автоматическим TTL.
```python
await OAuthTokenStorage.store_access_token(
user_id=123,
provider="google",
access_token="ya29.a0AfH6SM...",
expires_in=3600,
additional_data={"scope": "profile email"}
)
```
#### store_refresh_token()
Сохраняет refresh token с длительным TTL (30 дней по умолчанию).
```python
await OAuthTokenStorage.store_refresh_token(
user_id=123,
provider="google",
refresh_token="1//04...",
ttl=2592000 # 30 дней
)
```
#### get_access_token()
Получает действующий access token из Redis.
```python
token_data = await OAuthTokenStorage.get_access_token(123, "google")
if token_data:
access_token = token_data["token"]
expires_in = token_data["expires_in"]
```
#### refresh_access_token()
Обновляет access token (и опционально refresh token).
```python
success = await OAuthTokenStorage.refresh_access_token(
user_id=123,
provider="google",
new_access_token="ya29.new_token...",
expires_in=3600,
new_refresh_token="1//04new..." # опционально
)
```
#### delete_tokens()
Удаляет все токены пользователя для провайдера.
```python
await OAuthTokenStorage.delete_tokens(123, "google")
```
#### get_user_providers()
Получает список OAuth провайдеров для пользователя.
```python
providers = await OAuthTokenStorage.get_user_providers(123)
# ["google", "github"]
```
#### extend_token_ttl()
Продлевает срок действия токена.
```python
# Продлить access token на 30 минут
success = await OAuthTokenStorage.extend_token_ttl(123, "google", "access", 1800)
# Продлить refresh token на 7 дней
success = await OAuthTokenStorage.extend_token_ttl(123, "google", "refresh", 604800)
```
#### get_token_info()
Получает подробную информацию о токенах включая TTL.
```python
info = await OAuthTokenStorage.get_token_info(123, "google")
# {
# "user_id": 123,
# "provider": "google",
# "access_token": {"exists": True, "ttl": 3245},
# "refresh_token": {"exists": True, "ttl": 2589600}
# }
```
## Data Structures
### Access Token Structure
```json
{
"token": "ya29.a0AfH6SM...",
"provider": "google",
"user_id": 123,
"created_at": 1640995200,
"expires_in": 3600,
"scope": "profile email",
"token_type": "Bearer"
}
```
### Refresh Token Structure
```json
{
"token": "1//04...",
"provider": "google",
"user_id": 123,
"created_at": 1640995200
}
```
## Security Considerations
### Token Expiration
- **Access tokens**: TTL основан на `expires_in` от провайдера (обычно 1 час)
- **Refresh tokens**: TTL 30 дней по умолчанию
- **Автоматическая очистка**: Redis автоматически удаляет истекшие токены
- **Внутренняя система истечения**: Использует SET + EXPIRE для точного контроля TTL
### Redis Expiration Benefits
- **Гибкость**: Можно изменять TTL существующих токенов через EXPIRE
- **Мониторинг**: Команда TTL показывает оставшееся время жизни токена
- **Расширение**: Возможность продления срока действия токенов без перезаписи
- **Атомарность**: Separate SET/EXPIRE operations для лучшего контроля
### Access Control
- Токены доступны только владельцу аккаунта
- Нет доступа к токенам через GraphQL API
- Токены не хранятся в основной базе данных
### Provider Isolation
- Токены разных провайдеров хранятся отдельно
- Удаление токенов одного провайдера не влияет на другие
- Поддержка множественных OAuth подключений
## Integration Examples
### OAuth Login Flow
```python
# После успешной авторизации через OAuth провайдера
async def handle_oauth_callback(user_id: int, provider: str, tokens: dict):
# Сохраняем токены в Redis
await OAuthTokenStorage.store_access_token(
user_id=user_id,
provider=provider,
access_token=tokens["access_token"],
expires_in=tokens.get("expires_in", 3600)
)
if "refresh_token" in tokens:
await OAuthTokenStorage.store_refresh_token(
user_id=user_id,
provider=provider,
refresh_token=tokens["refresh_token"]
)
```
### Token Refresh
```python
async def refresh_oauth_token(user_id: int, provider: str):
# Получаем refresh token
refresh_data = await OAuthTokenStorage.get_refresh_token(user_id, provider)
if not refresh_data:
return False
# Обмениваем refresh token на новый access token
new_tokens = await exchange_refresh_token(
provider, refresh_data["token"]
)
# Сохраняем новые токены
return await OAuthTokenStorage.refresh_access_token(
user_id=user_id,
provider=provider,
new_access_token=new_tokens["access_token"],
expires_in=new_tokens.get("expires_in"),
new_refresh_token=new_tokens.get("refresh_token")
)
```
### API Integration
```python
async def make_oauth_request(user_id: int, provider: str, endpoint: str):
# Получаем действующий access token
token_data = await OAuthTokenStorage.get_access_token(user_id, provider)
if not token_data:
# Токен отсутствует, требуется повторная авторизация
raise OAuthTokenMissing()
# Делаем запрос к API провайдера
headers = {"Authorization": f"Bearer {token_data['token']}"}
response = await httpx.get(endpoint, headers=headers)
if response.status_code == 401:
# Токен истек, пытаемся обновить
if await refresh_oauth_token(user_id, provider):
# Повторяем запрос с новым токеном
token_data = await OAuthTokenStorage.get_access_token(user_id, provider)
headers = {"Authorization": f"Bearer {token_data['token']}"}
response = await httpx.get(endpoint, headers=headers)
return response.json()
```
### TTL Monitoring and Management
```python
async def monitor_token_expiration(user_id: int, provider: str):
"""Мониторинг и управление сроком действия токенов"""
# Получаем информацию о токенах
info = await OAuthTokenStorage.get_token_info(user_id, provider)
# Проверяем access token
if info["access_token"]["exists"]:
ttl = info["access_token"]["ttl"]
if ttl < 300: # Меньше 5 минут
logger.warning(f"Access token expires soon: {ttl}s")
# Автоматически обновляем токен
await refresh_oauth_token(user_id, provider)
# Проверяем refresh token
if info["refresh_token"]["exists"]:
ttl = info["refresh_token"]["ttl"]
if ttl < 86400: # Меньше 1 дня
logger.warning(f"Refresh token expires soon: {ttl}s")
# Уведомляем пользователя о необходимости повторной авторизации
async def extend_session_if_active(user_id: int, provider: str):
"""Продлевает сессию для активных пользователей"""
# Проверяем активность пользователя
if await is_user_active(user_id):
# Продлеваем access token на 1 час
success = await OAuthTokenStorage.extend_token_ttl(
user_id, provider, "access", 3600
)
if success:
logger.info(f"Extended access token for active user {user_id}")
```
## Migration from Database
Если у вас уже есть OAuth токены в базе данных, используйте этот скрипт для миграции:
```python
async def migrate_oauth_tokens():
"""Миграция OAuth токенов из БД в Redis"""
with local_session() as session:
# Предполагая, что токены хранились в таблице authors
authors = session.query(Author).filter(
or_(
Author.provider_access_token.is_not(None),
Author.provider_refresh_token.is_not(None)
)
).all()
for author in authors:
# Получаем провайдер из oauth вместо старого поля oauth
if author.oauth:
for provider in author.oauth.keys():
if author.provider_access_token:
await OAuthTokenStorage.store_access_token(
user_id=author.id,
provider=provider,
access_token=author.provider_access_token
)
if author.provider_refresh_token:
await OAuthTokenStorage.store_refresh_token(
user_id=author.id,
provider=provider,
refresh_token=author.provider_refresh_token
)
print(f"Migrated OAuth tokens for {len(authors)} authors")
```
## Performance Benefits
### Redis Advantages
- **Скорость**: Доступ к токенам за микросекунды
- **Масштабируемость**: Не нагружает основную БД
- **Автоматическая очистка**: TTL убирает истекшие токены
- **Память**: Эффективное использование памяти Redis
### Reduced Database Load
- OAuth токены больше не записываются в основную БД
- Уменьшено количество записей в таблице authors
- Faster user queries без JOIN к токенам
## Monitoring and Maintenance
### Redis Memory Usage
```bash
# Проверка использования памяти OAuth токенами
redis-cli --scan --pattern "oauth_*" | wc -l
redis-cli memory usage oauth_access:123:google
```
### Cleanup Statistics
```python
# Периодическая очистка и логирование (опционально)
async def oauth_cleanup_job():
cleaned = await OAuthTokenStorage.cleanup_expired_tokens()
logger.info(f"OAuth cleanup completed, {cleaned} tokens processed")
```

View File

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

View File

@ -1,403 +0,0 @@
# Система RBAC (Role-Based Access Control)
## Обзор
Система управления доступом на основе ролей для платформы Discours. Роли хранятся в CSV формате в таблице `CommunityAuthor` и могут быть назначены пользователям в рамках конкретного сообщества.
> **v0.6.11: Важно!** Наследование разрешений между ролями происходит **только при инициализации** прав для сообщества. В Redis хранятся уже развернутые (полные) списки разрешений для каждой роли. При запросе прав никакого on-the-fly наследования не происходит — только lookup по роли.
## Архитектура
### Основные принципы
- **CSV хранение**: Роли хранятся как CSV строка в поле `roles` таблицы `CommunityAuthor`
- **Простота**: Один пользователь может иметь несколько ролей в одном сообществе
- **Привязка к сообществу**: Роли существуют в контексте конкретного сообщества
- **Иерархия ролей**: `reader``author``artist``expert``editor``admin`
- **Наследование прав**: Каждая роль наследует все права предыдущих ролей **только при инициализации**
### Схема базы данных
#### Таблица `community_author`
```sql
CREATE TABLE community_author (
id INTEGER PRIMARY KEY,
community_id INTEGER REFERENCES community(id) NOT NULL,
author_id INTEGER REFERENCES author(id) NOT NULL,
roles TEXT, -- CSV строка ролей ("reader,author,expert")
joined_at INTEGER NOT NULL, -- Unix timestamp присоединения
CONSTRAINT uq_community_author UNIQUE (community_id, author_id)
);
```
#### Индексы
```sql
CREATE INDEX idx_community_author_community ON community_author(community_id);
CREATE INDEX idx_community_author_author ON community_author(author_id);
```
## Работа с ролями
### Модель CommunityAuthor
#### Основные методы
```python
from orm.community import CommunityAuthor
# Получение списка ролей
ca = session.query(CommunityAuthor).first()
roles = ca.role_list # ['reader', 'author', 'expert']
# Установка ролей
ca.role_list = ['reader', 'author']
# Проверка роли
has_author = ca.has_role('author') # True
# Добавление роли
ca.add_role('expert')
# Удаление роли
ca.remove_role('author')
# Установка полного списка ролей
ca.set_roles(['reader', 'editor'])
# Получение всех разрешений
permissions = await ca.get_permissions() # ['shout:read', 'shout:create', ...]
# Проверка разрешения
can_create = await ca.has_permission('shout:create') # True
```
### Вспомогательные функции
#### Основные функции из `orm/community.py`
```python
from orm.community import (
get_user_roles_in_community,
check_user_permission_in_community,
assign_role_to_user,
remove_role_from_user,
get_all_community_members_with_roles,
bulk_assign_roles
)
# Получение ролей пользователя
roles = get_user_roles_in_community(author_id=123, community_id=1)
# Возвращает: ['reader', 'author']
# Проверка разрешения
has_perm = await check_user_permission_in_community(
author_id=123,
permission='shout:create',
community_id=1
)
# Назначение роли
success = assign_role_to_user(
author_id=123,
role='expert',
community_id=1
)
# Удаление роли
success = remove_role_from_user(
author_id=123,
role='author',
community_id=1
)
# Получение всех участников с ролями
members = get_all_community_members_with_roles(community_id=1)
# Возвращает: [{'author_id': 123, 'roles': ['reader', 'author'], ...}, ...]
# Массовое назначение ролей
bulk_assign_roles([
{'author_id': 123, 'roles': ['reader', 'author']},
{'author_id': 456, 'roles': ['expert', 'editor']}
], community_id=1)
```
## Система разрешений
### Иерархия ролей
```
reader → author → artist → expert → editor → admin
```
Каждая роль наследует все права предыдущих ролей в дефолтной иерархии **только при создании сообщества**.
### Стандартные роли и их права
| Роль | Базовые права | Дополнительные права |
|------|---------------|---------------------|
| `reader` | `*:read`, базовые реакции | `chat:*`, `message:*`, `bookmark:*` |
| `author` | Наследует `reader` + `*:create`, `*:update_own`, `*:delete_own` | `draft:*` |
| `artist` | Наследует `author` | `reaction:CREDIT:accept`, `reaction:CREDIT:decline` |
| `expert` | Наследует `author` | `reaction:PROOF:*`, `reaction:DISPROOF:*`, `reaction:AGREE:*`, `reaction:DISAGREE:*` |
| `editor` | `*:read`, `*:create`, `*:update_any`, `*:delete_any` | `community:read`, `community:update_own`, `topic:merge`, `topic:create`, `topic:update_own`, `topic:delete_own` |
| `admin` | Все права (`*`) | Полный доступ ко всем функциям |
### Формат разрешений
- Базовые: `<entity>:<action>` (например: `shout:create`, `topic:create`)
- Реакции: `reaction:<type>:<action>` (например: `reaction:LIKE:create`)
- Специальные: `topic:merge` (слияние топиков)
- Wildcard: `<entity>:*` или `*` (только для admin)
### Права на топики
- `topic:create` - создание новых топиков (роли: `author`, `editor`)
- `topic:read` - чтение топиков (роли: `reader` и выше)
- `topic:update_own` - обновление собственных топиков (роли: `author`)
- `topic:update_any` - обновление любых топиков (роли: `editor`)
- `topic:delete_own` - удаление собственных топиков (роли: `author`)
- `topic:delete_any` - удаление любых топиков (роли: `editor`)
- `topic:merge` - слияние топиков (роли: `editor`)
## GraphQL API
### Запросы
#### Получение участников сообщества с ролями
```graphql
query AdminGetCommunityMembers(
$community_id: Int!
$page: Int = 1
$limit: Int = 50
) {
adminGetCommunityMembers(
community_id: $community_id
page: $page
limit: $limit
) {
success
error
members {
id
name
slug
email
roles
is_follower
created_at
}
total
page
limit
has_next
}
}
```
### Мутации
#### Назначение ролей пользователю
```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 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
}
}
```
## Использование декораторов RBAC
### Импорт декораторов
```python
from resolvers.rbac import (
require_permission, require_role, admin_only,
authenticated_only, require_any_permission,
require_all_permissions, RBACError
)
```
### Примеры использования
#### Проверка конкретного разрешения
```python
@mutation.field("createShout")
@require_permission("shout:create")
async def create_shout(self, info: GraphQLResolveInfo, **kwargs):
# Только пользователи с правом создания статей
return await self._create_shout_logic(**kwargs)
@mutation.field("create_topic")
@require_permission("topic:create")
async def create_topic(self, info: GraphQLResolveInfo, topic_input: dict):
# Только пользователи с правом создания топиков (author, editor)
return await self._create_topic_logic(topic_input)
@mutation.field("merge_topics")
@require_permission("topic:merge")
async def merge_topics(self, info: GraphQLResolveInfo, merge_input: dict):
# Только пользователи с правом слияния топиков (editor)
return await self._merge_topics_logic(merge_input)
```
#### Проверка любого из разрешений (OR логика)
```python
@mutation.field("updateShout")
@require_any_permission(["shout:update_own", "shout:update_any"])
async def update_shout(self, info: GraphQLResolveInfo, shout_id: int, **kwargs):
# Может редактировать свои статьи ИЛИ любые статьи
return await self._update_shout_logic(shout_id, **kwargs)
@mutation.field("update_topic")
@require_any_permission(["topic:update_own", "topic:update_any"])
async def update_topic(self, info: GraphQLResolveInfo, topic_input: dict):
# Может редактировать свои топики ИЛИ любые топики
return await self._update_topic_logic(topic_input)
@mutation.field("delete_topic")
@require_any_permission(["topic:delete_own", "topic:delete_any"])
async def delete_topic(self, info: GraphQLResolveInfo, topic_id: int):
# Может удалять свои топики ИЛИ любые топики
return await self._delete_topic_logic(topic_id)
```
#### Проверка конкретной роли
```python
@mutation.field("verifyEvidence")
@require_role("expert")
async def verify_evidence(self, info: GraphQLResolveInfo, **kwargs):
# Только эксперты могут верифицировать доказательства
return await self._verify_evidence_logic(**kwargs)
```
#### Только для администраторов
```python
@mutation.field("deleteAnyContent")
@admin_only
async def delete_any_content(self, info: GraphQLResolveInfo, content_id: int):
# Только администраторы
return await self._delete_content_logic(content_id)
```
### Обработка ошибок
```python
from resolvers.rbac import RBACError
try:
result = await some_rbac_protected_function()
except RBACError as e:
return {"success": False, "error": str(e)}
```
## Настройка сообщества
### Управление ролями в сообществе
```python
from orm.community import Community
community = session.query(Community).filter(Community.id == 1).first()
# Установка доступных ролей
community.set_available_roles(['reader', 'author', 'expert', 'admin'])
# Установка дефолтных ролей для новых участников
community.set_default_roles(['reader'])
# Получение настроек
available = community.get_available_roles() # ['reader', 'author', 'expert', 'admin']
default = community.get_default_roles() # ['reader']
```
### Автоматическое назначение дефолтных ролей
При создании связи пользователя с сообществом автоматически назначаются роли из `default_roles`.
## Интеграция с GraphQL контекстом
### Middleware для установки ролей
```python
async def rbac_middleware(request, call_next):
# Получаем автора из контекста
author = getattr(request.state, 'author', None)
if author:
# Устанавливаем роли в контекст для текущего сообщества
community_id = get_current_community_id(request)
if community_id:
user_roles = get_user_roles_in_community(author.id, community_id)
request.state.user_roles = user_roles
response = await call_next(request)
return response
```
### Получение ролей в resolver'ах
```python
def get_user_roles_from_context(info):
"""Получение ролей пользователя из GraphQL контекста"""
# Из middleware
user_roles = getattr(info.context, "user_roles", [])
if user_roles:
return user_roles
# Из author'а напрямую
author = getattr(info.context, "author", None)
if author and hasattr(author, "roles"):
return author.roles.split(",") if author.roles else []
return []
```
## Миграция и обновления
### Миграция с предыдущей системы ролей
Если в проекте была отдельная таблица ролей, необходимо:
1. Создать миграцию для добавления поля `roles` в `CommunityAuthor`
2. Перенести данные из старых таблиц в CSV формат
3. Удалить старые таблицы ролей
```bash
alembic revision --autogenerate -m "Add CSV roles to CommunityAuthor"
alembic upgrade head
```
### Обновление CHANGELOG.md
После внесения изменений в RBAC систему обновляется `CHANGELOG.md` с новой версией.
## Производительность
### Оптимизация
- CSV роли хранятся в одном поле, что снижает количество JOIN'ов
- Индексы на `community_id` и `author_id` ускоряют запросы
- Кеширование разрешений на уровне приложения
### Рекомендации
- Избегать частых изменений ролей
- Кешировать результаты `get_role_permissions_for_community()`
- Использовать bulk операции для массового назначения ролей

View File

@ -1,378 +0,0 @@
# Миграция с React 18 на SolidStart: Comprehensive Guide
## 1. Введение
### 1.1 Что такое SolidStart?
SolidStart - это метафреймворк для SolidJS, который предоставляет полнофункциональное решение для создания веб-приложений. Ключевые особенности:
- Полностью изоморфное приложение (работает на клиенте и сервере)
- Встроенная поддержка SSR, SSG и CSR
- Интеграция с Vite и Nitro
- Гибкая маршрутизация
- Встроенные серверные функции и действия
### 1.2 Основные различия между React и SolidStart
| Характеристика | React 18 | SolidStart |
|---------------|----------|------------|
| Рендеринг | Virtual DOM | Компиляция и прямое обновление DOM |
| Серверный рендеринг | Сложная настройка | Встроенная поддержка |
| Размер бандла | ~40 кБ | ~7.7 кБ |
| Реактивность | Хуки с зависимостями | Сигналы без явных зависимостей |
| Маршрутизация | react-router | @solidjs/router |
## 2. Подготовка проекта
### 2.1 Установка зависимостей
```bash
# Удаление React зависимостей
npm uninstall react react-dom react-router-dom
# Установка SolidStart и связанных библиотек
npm install @solidjs/start solid-js @solidjs/router
```
### 2.2 Обновление конфигурации
#### Vite Configuration (`vite.config.ts`)
```typescript
import { defineConfig } from 'vite';
import solid from 'solid-start/vite';
export default defineConfig({
plugins: [solid()],
// Дополнительные настройки
});
```
#### TypeScript Configuration (`tsconfig.json`)
```json
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["solid-start/env"]
}
}
```
#### SolidStart Configuration (`app.config.ts`)
```typescript
import { defineConfig } from "@solidjs/start/config";
export default defineConfig({
server: {
// Настройки сервера, например:
preset: "netlify" // или другой провайдер
},
// Дополнительные настройки
});
```
## 3. Миграция компонентов и логики
### 3.1 Состояние и реактивность
#### React:
```typescript
const [count, setCount] = useState(0);
```
#### SolidJS:
```typescript
const [count, setCount] = createSignal(0);
// Использование: count(), setCount(newValue)
```
### 3.2 Серверные функции и загрузка данных
В SolidStart есть несколько способов работы с данными:
#### Серверная функция
```typescript
// server/api.ts
export function getUser(id: string) {
return db.users.findUnique({ where: { id } });
}
// Component
export default function UserProfile() {
const user = createAsync(() => getUser(params.id));
return <div>{user()?.name}</div>;
}
```
#### Действия (Actions)
```typescript
export function updateProfile(formData: FormData) {
'use server';
const name = formData.get('name');
// Логика обновления профиля
}
```
### 3.3 Маршрутизация
```typescript
// src/routes/index.tsx
import { A } from "@solidjs/router";
export default function HomePage() {
return (
<div>
<A href="/about">О нас</A>
<A href="/profile">Профиль</A>
</div>
);
}
// src/routes/profile.tsx
export default function ProfilePage() {
return <div>Профиль пользователя</div>;
}
```
## 4. Оптимизация и производительность
### 4.1 Мемоизация
```typescript
// Кэширование сложных вычислений
const sortedUsers = createMemo(() =>
users().sort((a, b) => a.name.localeCompare(b.name))
);
// Ленивая загрузка
const UserList = lazy(() => import('./UserList'));
```
### 4.2 Серверный рендеринг и предзагрузка
```typescript
// Предзагрузка данных
export function routeData() {
return {
user: createAsync(() => fetchUser())
};
}
export default function UserPage() {
const user = useRouteData<typeof routeData>();
return <div>{user().name}</div>;
}
```
## 5. Особенности миграции
### 5.1 Ключевые изменения
- Замена `useState` на `createSignal`
- Использование `createAsync` вместо `useEffect` для загрузки данных
- Серверные функции с `'use server'`
- Маршрутизация через `@solidjs/router`
### 5.2 Потенциальные проблемы
- Переписать все React-специфичные хуки
- Адаптировать библиотеки компонентов
- Обновить тесты и CI/CD
## 6. Деплой
SolidStart поддерживает множество платформ:
- Netlify
- Vercel
- Cloudflare
- AWS
- Deno
- и другие
```typescript
// app.config.ts
export default defineConfig({
server: {
preset: "netlify" // Выберите вашу платформу
}
});
```
## 7. Инструменты и экосистема
### Рекомендованные библиотеки
- Роутинг: `@solidjs/router`
- Состояние: Встроенные примитивы SolidJS
- Запросы: `@tanstack/solid-query`
- Девтулзы: `solid-devtools`
## 8. Миграция конкретных компонентов
### 8.1 Страница регистрации (RegisterPage)
#### React-версия
```typescript
import React from 'react'
import { Navigate } from 'react-router-dom'
import { RegisterForm } from '../components/auth/RegisterForm'
import { useAuthStore } from '../store/authStore'
export const RegisterPage: React.FC = () => {
const { isAuthenticated } = useAuthStore()
if (isAuthenticated) {
return <Navigate to="/" replace />
}
return (
<div className="min-h-screen ...">
<RegisterForm />
</div>
)
}
```
#### SolidJS-версия
```typescript
import { Navigate } from '@solidjs/router'
import { Show } from 'solid-js'
import { RegisterForm } from '../components/auth/RegisterForm'
import { useAuthStore } from '../store/authStore'
export default function RegisterPage() {
const { isAuthenticated } = useAuthStore()
return (
<Show when={!isAuthenticated()} fallback={<Navigate href="/" />}>
<div class="min-h-screen ...">
<RegisterForm />
</div>
</Show>
)
}
```
#### Ключевые изменения
- Удаление импорта React
- Использование `@solidjs/router` вместо `react-router-dom`
- Замена `className` на `class`
- Использование `Show` для условного рендеринга
- Вызов `isAuthenticated()` как функции
- Использование `href` вместо `to`
- Экспорт по умолчанию вместо именованного экспорта
### Рекомендации
- Всегда используйте `Show` для условного рендеринга
- Помните, что сигналы в SolidJS - это функции
- Следите за совместимостью импортов и маршрутизации
## 9. UI Component Migration
### 9.1 Key Differences in Component Structure
When migrating UI components from React to SolidJS, several key changes are necessary:
1. **Props Handling**
- Replace `React.FC<Props>` with function component syntax
- Use object destructuring for props instead of individual parameters
- Replace `className` with `class`
- Use `props.children` instead of `children` prop
2. **Type Annotations**
- Use TypeScript interfaces for props
- Explicitly type `children` as `any` or a more specific type
- Remove React-specific type imports
3. **Event Handling**
- Use SolidJS event types (e.g., `InputEvent`)
- Modify event handler signatures to match SolidJS conventions
### 9.2 Component Migration Example
#### React Component
```typescript
import React from 'react'
import { clsx } from 'clsx'
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary'
fullWidth?: boolean
}
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
fullWidth = false,
className,
children,
...props
}) => {
const classes = clsx(
'button',
variant === 'primary' && 'bg-blue-500',
fullWidth && 'w-full',
className
)
return (
<button className={classes} {...props}>
{children}
</button>
)
}
```
#### SolidJS Component
```typescript
import { clsx } from 'clsx'
interface ButtonProps {
variant?: 'primary' | 'secondary'
fullWidth?: boolean
class?: string
children: any
disabled?: boolean
type?: 'button' | 'submit'
onClick?: () => void
}
export const Button = (props: ButtonProps) => {
const classes = clsx(
'button',
props.variant === 'primary' && 'bg-blue-500',
props.fullWidth && 'w-full',
props.class
)
return (
<button
class={classes}
disabled={props.disabled}
type={props.type || 'button'}
onClick={props.onClick}
>
{props.children}
</button>
)
}
```
### 9.3 Key Migration Strategies
- Replace `React.FC` with standard function components
- Use `props` object instead of individual parameters
- Replace `className` with `class`
- Modify event handling to match SolidJS patterns
- Remove React-specific lifecycle methods
- Use SolidJS primitives like `createEffect` for side effects
## Заключение
Миграция на SolidStart требует внимательного подхода, но предоставляет значительные преимущества в производительности, простоте разработки и серверных возможностях.
### Рекомендации
- Мигрируйте постепенно
- Пишите тесты на каждом этапе
- Используйте инструменты совместимости
---
Этот гайд поможет вам систематически и безопасно мигрировать ваш проект на SolidStart, сохраняя существующую функциональность и улучшая производительность.

View File

@ -1,434 +0,0 @@
# Схема данных Redis в Discours.io
## Обзор
Redis используется как основное хранилище для кэширования, сессий, токенов и временных данных. Все ключи следуют структурированным паттернам для обеспечения консистентности и производительности.
## Принципы именования ключей
### Общие правила
- Использование двоеточия `:` как разделителя иерархии
- Формат: `{category}:{type}:{identifier}` или `{entity}:{property}:{value}`
- Константное время поиска через точные ключи
- TTL для всех временных данных
### Категории данных
1. **Аутентификация**: `session:*`, `oauth_*`, `env_vars:*`
2. **Кэш сущностей**: `author:*`, `topic:*`, `shout:*`
3. **Поиск**: `search_cache:*`
4. **Просмотры**: `migrated_views_*`, `viewed_*`
5. **Уведомления**: publish/subscribe каналы
## 1. Система аутентификации
### 1.1 Сессии пользователей
#### Структура ключей
```
session:{user_id}:{jwt_token} # HASH - данные сессии
user_sessions:{user_id} # SET - список активных токенов пользователя
{user_id}-{username}-{token} # STRING - legacy формат (deprecated)
```
#### Данные сессии (HASH)
```redis
HGETALL session:123:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
```
**Поля:**
- `user_id`: ID пользователя (string)
- `username`: Имя пользователя (string)
- `token_type`: "session" (string)
- `created_at`: Unix timestamp создания (string)
- `last_activity`: Unix timestamp последней активности (string)
- `auth_data`: JSON строка с данными авторизации (string, optional)
- `device_info`: JSON строка с информацией об устройстве (string, optional)
**TTL**: 30 дней (2592000 секунд)
#### Список токенов пользователя (SET)
```redis
SMEMBERS user_sessions:123
```
**Содержимое**: JWT токены активных сессий пользователя
**TTL**: 30 дней
### 1.2 OAuth токены
#### Структура ключей
```
oauth_access:{user_id}:{provider} # STRING - access токен
oauth_refresh:{user_id}:{provider} # STRING - refresh токен
oauth_state:{state} # HASH - временное состояние OAuth flow
```
#### Access токены
**Провайдеры**: `google`, `github`, `facebook`, `twitter`, `telegram`, `vk`, `yandex`
**TTL**: 1 час (3600 секунд)
**Пример**:
```redis
GET oauth_access:123:google
# Возвращает: access_token_string
```
#### Refresh токены
**TTL**: 30 дней (2592000 секунд)
**Пример**:
```redis
GET oauth_refresh:123:google
# Возвращает: refresh_token_string
```
#### OAuth состояние (временное)
```redis
HGETALL oauth_state:a1b2c3d4e5f6
```
**Поля:**
- `redirect_uri`: URL для перенаправления после авторизации
- `csrf_token`: CSRF защита
- `provider`: Провайдер OAuth
- `created_at`: Время создания
**TTL**: 10 минут (600 секунд)
### 1.3 Токены подтверждения
#### Структура ключей
```
verification:{user_id}:{type}:{token} # HASH - данные токена подтверждения
```
#### Типы подтверждения
- `email_verification`: Подтверждение email
- `phone_verification`: Подтверждение телефона
- `password_reset`: Сброс пароля
- `email_change`: Смена email
**Поля токена**:
- `user_id`: ID пользователя
- `token_type`: Тип токена
- `verification_type`: Тип подтверждения
- `created_at`: Время создания
- `data`: JSON с дополнительными данными
**TTL**: 1 час (3600 секунд)
## 2. Переменные окружения
### Структура ключей
```
env_vars:{variable_name} # STRING - значение переменной
```
### Примеры переменных
```redis
GET env_vars:JWT_SECRET # Секретный ключ JWT
GET env_vars:REDIS_URL # URL Redis
GET env_vars:OAUTH_GOOGLE_CLIENT_ID # Google OAuth Client ID
GET env_vars:FEATURE_REGISTRATION # Флаг функции регистрации
```
**Категории переменных**:
- **database**: DB_URL, POSTGRES_*
- **auth**: JWT_SECRET, OAUTH_*
- **redis**: REDIS_URL, REDIS_HOST, REDIS_PORT
- **search**: SEARCH_API_KEY, ELASTICSEARCH_URL
- **integrations**: GOOGLE_ANALYTICS_ID, SENTRY_DSN, SMTP_*
- **security**: CORS_ORIGINS, ALLOWED_HOSTS
- **logging**: LOG_LEVEL, DEBUG
- **features**: FEATURE_*
**TTL**: Без ограничения (постоянное хранение)
## 3. Кэш сущностей
### 3.1 Авторы (пользователи)
#### Структура ключей
```
author:id:{author_id} # STRING - JSON данные автора
author:slug:{author_slug} # STRING - ID автора по slug
author:followers:{author_id} # STRING - JSON массив подписчиков
author:follows-topics:{author_id} # STRING - JSON массив отслеживаемых тем
author:follows-authors:{author_id} # STRING - JSON массив отслеживаемых авторов
author:follows-shouts:{author_id} # STRING - JSON массив отслеживаемых публикаций
```
#### Данные автора (JSON)
```json
{
"id": 123,
"email": "user@example.com",
"name": "Имя Пользователя",
"slug": "username",
"pic": "https://example.com/avatar.jpg",
"bio": "Описание автора",
"email_verified": true,
"created_at": 1640995200,
"updated_at": 1640995200,
"last_seen": 1640995200,
"stat": {
"topics": 15,
"authors": 8,
"shouts": 42
}
}
```
#### Подписчики автора
```json
[123, 456, 789] // Массив ID подписчиков
```
#### Подписки автора
```json
// author:follows-topics:123
[1, 5, 10, 15] // ID отслеживаемых тем
// author:follows-authors:123
[45, 67, 89] // ID отслеживаемых авторов
// author:follows-shouts:123
[101, 102, 103] // ID отслеживаемых публикаций
```
**TTL**: Без ограничения (инвалидация при изменениях)
### 3.2 Темы
#### Структура ключей
```
topic:id:{topic_id} # STRING - JSON данные темы
topic:slug:{topic_slug} # STRING - JSON данные темы
topic:authors:{topic_id} # STRING - JSON массив авторов темы
topic:followers:{topic_id} # STRING - JSON массив подписчиков темы
topic_shouts_{topic_id} # STRING - JSON массив публикаций темы (legacy)
```
#### Данные темы (JSON)
```json
{
"id": 5,
"title": "Название темы",
"slug": "tema-slug",
"description": "Описание темы",
"pic": "https://example.com/topic.jpg",
"community": 1,
"created_at": 1640995200,
"updated_at": 1640995200,
"stat": {
"shouts": 150,
"authors": 25,
"followers": 89
}
}
```
#### Авторы темы
```json
[123, 456, 789] // ID авторов, писавших в теме
```
#### Подписчики темы
```json
[111, 222, 333, 444] // ID подписчиков темы
```
**TTL**: Без ограничения (инвалидация при изменениях)
### 3.3 Публикации (Shouts)
#### Структура ключей
```
shouts:{params_hash} # STRING - JSON массив публикаций
topic_shouts_{topic_id} # STRING - JSON массив публикаций темы
```
#### Примеры ключей публикаций
```
shouts:limit=20:offset=0:sort=created_at # Последние публикации
shouts:author=123:limit=10 # Публикации автора
shouts:topic=5:featured=true # Рекомендуемые публикации темы
```
**TTL**: 5 минут (300 секунд)
## 4. Поисковый кэш
### Структура ключей
```
search_cache:{normalized_query} # STRING - JSON результаты поиска
```
### Нормализация запроса
- Приведение к нижнему регистру
- Удаление лишних пробелов
- Сортировка параметров
### Данные поиска (JSON)
```json
{
"query": "поисковый запрос",
"results": [
{
"type": "shout",
"id": 123,
"title": "Заголовок публикации",
"slug": "publication-slug",
"score": 0.95
}
],
"total": 15,
"cached_at": 1640995200
}
```
**TTL**: 10 минут (600 секунд)
## 5. Система просмотров
### Структура ключей
```
migrated_views_{timestamp} # HASH - просмотры публикаций
migrated_views_slugs # HASH - маппинг slug -> id
viewed:{shout_id} # STRING - счетчик просмотров
```
### Мигрированные просмотры (HASH)
```redis
HGETALL migrated_views_1640995200
```
**Поля**:
- `{shout_id}`: количество просмотров (string)
- `_timestamp`: время создания записи
- `_total`: общее количество записей
### Маппинг slug -> ID
```redis
HGETALL migrated_views_slugs
```
**Поля**: `{shout_slug}` -> `{shout_id}`
**TTL**: Без ограничения (данные аналитики)
## 6. Pub/Sub каналы
### Каналы уведомлений
```
notifications:{user_id} # Персональные уведомления
notifications:global # Глобальные уведомления
notifications:topic:{topic_id} # Уведомления темы
notifications:shout:{shout_id} # Уведомления публикации
```
### Структура сообщения (JSON)
```json
{
"type": "notification_type",
"user_id": 123,
"entity_type": "shout",
"entity_id": 456,
"action": "created|updated|deleted",
"data": {
"title": "Заголовок",
"author": "Автор"
},
"timestamp": 1640995200
}
```
## 7. Временные данные
### Ключи блокировок
```
lock:{operation}:{entity_id} # STRING - блокировка операции
```
**TTL**: 30 секунд (автоматическое снятие блокировки)
### Ключи состояния
```
state:{process}:{identifier} # HASH - состояние процесса
```
**TTL**: От 1 минуты до 1 часа в зависимости от процесса
## 8. Мониторинг и статистика
### Ключи метрик
```
metrics:{metric_name}:{period} # STRING - значение метрики
stats:{entity}:{timeframe} # HASH - статистика сущности
```
### Примеры метрик
```
metrics:active_sessions:hourly # Количество активных сессий
metrics:cache_hits:daily # Попадания в кэш за день
stats:topics:weekly # Статистика тем за неделю
```
**TTL**: От 1 часа до 30 дней в зависимости от типа метрики
## 9. Оптимизация и производительность
### Пакетные операции
Используются Redis pipelines для атомарных операций:
```python
# Пример создания сессии
commands = [
("hset", (token_key, "user_id", user_id)),
("hset", (token_key, "created_at", timestamp)),
("expire", (token_key, ttl)),
("sadd", (user_tokens_key, token)),
]
await redis.execute_pipeline(commands)
```
### Стратегии кэширования
1. **Write-through**: Немедленное обновление кэша при изменении данных
2. **Cache-aside**: Lazy loading с обновлением при промахе
3. **Write-behind**: Отложенная запись в БД
### Инвалидация кэша
- **Точечная**: Удаление конкретных ключей при изменениях
- **По префиксу**: Массовое удаление связанных ключей
- **TTL**: Автоматическое истечение для временных данных
## 10. Мониторинг
### Команды диагностики
```bash
# Статистика использования памяти
redis-cli info memory
# Количество ключей по типам
redis-cli --scan --pattern "session:*" | wc -l
redis-cli --scan --pattern "author:*" | wc -l
redis-cli --scan --pattern "topic:*" | wc -l
# Размер конкретного ключа
redis-cli memory usage session:123:token...
# Анализ истечения ключей
redis-cli --scan --pattern "*" | xargs -I {} redis-cli ttl {}
```
### Проблемы и решения
1. **Память**: Использование TTL для временных данных
2. **Производительность**: Pipeline операции, connection pooling
3. **Консистентность**: Транзакции для критических операций
4. **Масштабирование**: Шардирование по user_id для сессий
## 11. Безопасность
### Принципы
- TTL для всех временных данных предотвращает накопление мусора
- Раздельное хранение секретных данных (токены) и публичных (кэш)
- Использование pipeline для атомарных операций
- Регулярная очистка истекших ключей
### Рекомендации
- Мониторинг использования памяти Redis
- Backup критичных данных (переменные окружения)
- Ограничение размера значений для предотвращения OOM
- Использование отдельных баз данных для разных типов данных

View File

@ -1,212 +0,0 @@
# Security System
## Overview
Система безопасности обеспечивает управление паролями и email адресами пользователей через специализированные GraphQL мутации с использованием Redis для хранения токенов.
## GraphQL API
### Мутации
#### updateSecurity
Универсальная мутация для смены пароля и/или email пользователя с полной валидацией и безопасностью.
**Parameters:**
- `email: String` - Новый email (опционально)
- `old_password: String` - Текущий пароль (обязательно для любых изменений)
- `new_password: String` - Новый пароль (опционально)
**Returns:**
```typescript
type SecurityUpdateResult {
success: Boolean!
error: String
author: Author
}
```
**Примеры использования:**
```graphql
# Смена пароля
mutation {
updateSecurity(
old_password: "current123"
new_password: "newPassword456"
) {
success
error
author {
id
name
email
}
}
}
# Смена email
mutation {
updateSecurity(
email: "newemail@example.com"
old_password: "current123"
) {
success
error
author {
id
name
email
}
}
}
# Одновременная смена пароля и email
mutation {
updateSecurity(
email: "newemail@example.com"
old_password: "current123"
new_password: "newPassword456"
) {
success
error
author {
id
name
email
}
}
}
```
#### confirmEmailChange
Подтверждение смены email по токену, полученному на новый email адрес.
**Parameters:**
- `token: String!` - Токен подтверждения
**Returns:** `SecurityUpdateResult`
#### cancelEmailChange
Отмена процесса смены email.
**Returns:** `SecurityUpdateResult`
### Валидация и Ошибки
```typescript
const ERRORS = {
NOT_AUTHENTICATED: "User not authenticated",
INCORRECT_OLD_PASSWORD: "incorrect old password",
PASSWORDS_NOT_MATCH: "New passwords do not match",
EMAIL_ALREADY_EXISTS: "email already exists",
INVALID_EMAIL: "Invalid email format",
WEAK_PASSWORD: "Password too weak",
SAME_PASSWORD: "New password must be different from current",
VALIDATION_ERROR: "Validation failed",
INVALID_TOKEN: "Invalid token",
TOKEN_EXPIRED: "Token expired",
NO_PENDING_EMAIL: "No pending email change"
}
```
## Логика смены email
1. **Инициация смены:**
- Пользователь вызывает `updateSecurity` с новым email
- Генерируется токен подтверждения `token_urlsafe(32)`
- Данные смены email сохраняются в Redis с ключом `email_change:{user_id}`
- Устанавливается автоматическое истечение токена (1 час)
- Отправляется письмо на новый email с токеном
2. **Подтверждение:**
- Пользователь получает письмо с токеном
- Вызывает `confirmEmailChange` с токеном
- Система проверяет токен и срок действия в Redis
- Если токен валиден, email обновляется в базе данных
- Данные смены email удаляются из Redis
3. **Отмена:**
- Пользователь может отменить смену через `cancelEmailChange`
- Данные смены email удаляются из Redis
## Redis Storage
### Хранение токенов смены email
```json
{
"key": "email_change:{user_id}",
"value": {
"user_id": 123,
"old_email": "old@example.com",
"new_email": "new@example.com",
"token": "random_token_32_chars",
"expires_at": 1640995200
},
"ttl": 3600 // 1 час
}
```
### Хранение OAuth токенов
```json
{
"key": "oauth_access:{user_id}:{provider}",
"value": {
"token": "oauth_access_token",
"provider": "google",
"user_id": 123,
"created_at": 1640995200,
"expires_in": 3600,
"scope": "profile email"
},
"ttl": 3600 // время из expires_in или 1 час по умолчанию
}
```
```json
{
"key": "oauth_refresh:{user_id}:{provider}",
"value": {
"token": "oauth_refresh_token",
"provider": "google",
"user_id": 123,
"created_at": 1640995200
},
"ttl": 2592000 // 30 дней по умолчанию
}
```
### Преимущества Redis хранения
- **Автоматическое истечение**: TTL в Redis автоматически удаляет истекшие токены
- **Производительность**: Быстрый доступ к данным токенов
- **Масштабируемость**: Не нагружает основную базу данных
- **Безопасность**: Токены не хранятся в основной БД
- **Простота**: Не требует миграции схемы базы данных
- **OAuth токены**: Централизованное управление токенами всех OAuth провайдеров
## Безопасность
### Требования к паролю
- Минимум 8 символов
- Не может совпадать с текущим паролем
### Аутентификация
- Все операции требуют валидного токена аутентификации
- Старый пароль обязателен для подтверждения личности
### Валидация email
- Проверка формата email через регулярное выражение
- Проверка уникальности email в системе
- Защита от race conditions при смене email
### Токены безопасности
- Генерация токенов через `secrets.token_urlsafe(32)`
- Автоматическое истечение через 1 час
- Удаление токенов после использования или отмены
## Database Schema
Система не требует изменений в схеме базы данных. Все токены и временные данные хранятся в Redis.
### Защищенные поля
Следующие поля показываются только владельцу аккаунта:
- `email`
- `password`

11
env.d.ts vendored
View File

@ -1,11 +0,0 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

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

290
main.py
View File

@ -1,8 +1,8 @@
import asyncio
import os
from contextlib import asynccontextmanager
import sys
from importlib import import_module
from pathlib import Path
from os.path import exists
from ariadne import load_schema_from_path, make_executable_schema
from ariadne.asgi import GraphQL
@ -10,269 +10,105 @@ from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request
from starlette.responses import FileResponse, JSONResponse, Response
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from starlette.responses import JSONResponse, Response
from starlette.routing import Route
from auth.handler import EnhancedGraphQLHTTPHandler
from auth.middleware import AuthMiddleware, auth_middleware
from auth.oauth import oauth_callback, 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 check_search_service, initialize_search_index_background, search_service
from services.search import search_service
from services.viewed import ViewedStorage
from settings import DEV_SERVER_PID_FILE_NAME
from utils.logger import root_logger as logger
DEVMODE = os.getenv("DOKKU_APP_TYPE", "false").lower() == "false"
DIST_DIR = Path(__file__).parent / "dist" # Директория для собранных файлов
INDEX_HTML = Path(__file__).parent / "index.html"
from services.webhook import WebhookEndpoint, create_webhook_endpoint
from settings import DEV_SERVER_PID_FILE_NAME, MODE
import_module("resolvers")
schema = make_executable_schema(load_schema_from_path("schema/"), resolvers)
# Создаем middleware с правильным порядком
middleware = [
# Начинаем с обработки ошибок
Middleware(ExceptionHandlerMiddleware),
# CORS должен быть перед другими middleware для корректной обработки preflight-запросов
Middleware(
CORSMiddleware,
allow_origins=[
"https://testing.discours.io",
"https://testing3.discours.io",
"https://v3.dscrs.site",
"https://session-daily.vercel.app",
"https://coretest.discours.io",
"https://new.discours.io",
],
allow_methods=["GET", "POST", "OPTIONS"], # Явно указываем OPTIONS
allow_headers=["*"],
allow_credentials=True,
),
# Аутентификация должна быть после CORS
Middleware(AuthMiddleware),
]
# Создаем экземпляр GraphQL с улучшенным обработчиком
graphql_app = GraphQL(schema, debug=DEVMODE, http_handler=EnhancedGraphQLHTTPHandler())
async def start():
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-обработчик для лучшей обработки ошибок
async def graphql_handler(request: Request) -> Response:
"""
Обработчик GraphQL запросов с поддержкой middleware и обработкой ошибок.
Выполняет:
1. Проверку метода запроса (GET, POST, OPTIONS)
2. Обработку GraphQL запроса через ariadne
3. Применение middleware для корректной обработки cookie и авторизации
4. Обработку исключений и формирование ответа
Args:
request: Starlette Request объект
Returns:
Response: объект ответа (обычно JSONResponse)
"""
if request.method not in ["GET", "POST", "OPTIONS"]:
return JSONResponse({"error": "Method Not Allowed by main.py"}, status_code=405)
# Проверяем, что все необходимые middleware корректно отработали
if not hasattr(request, "scope") or "auth" not in request.scope:
logger.warning("[graphql] AuthMiddleware не обработал запрос перед GraphQL обработчиком")
async def lifespan(_app):
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(),
check_search_service(),
create_webhook_endpoint(),
search_service.info(),
start(),
revalidation_manager.start(),
)
if DEVMODE:
await dev_start()
print("[lifespan] Basic initialization complete")
# Add a delay before starting the intensive search indexing
print("[lifespan] Waiting for system stabilization before search indexing...")
await asyncio.sleep(1) # 1-second delay to let the system stabilize
# Start search indexing as a background task with lower priority
search_task = asyncio.create_task(initialize_search_index_background())
background_tasks.append(search_task)
# Не ждем завершения задачи, позволяем ей выполняться в фоне
yield
finally:
print("[lifespan] Shutting down application services")
# Отменяем все background tasks
for task in background_tasks:
if not task.done():
task.cancel()
# Ждем завершения отмены tasks
if background_tasks:
await asyncio.gather(*background_tasks, return_exceptions=True)
tasks = [redis.disconnect(), ViewedStorage.stop(), revalidation_manager.stop()]
await asyncio.gather(*tasks, return_exceptions=True)
print("[lifespan] Shutdown complete")
# Создаем экземпляр 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)
middleware = [
# Начинаем с обработки ошибок
Middleware(ExceptionHandlerMiddleware),
# CORS должен быть перед другими middleware для корректной обработки preflight-запросов
Middleware(
CORSMiddleware,
allow_origins=[
"https://localhost:3000",
"https://testing.discours.io",
"https://testing3.discours.io",
"https://discours.io",
"https://new.discours.io"
],
allow_methods=["GET", "POST", "OPTIONS"], # Явно указываем OPTIONS
allow_headers=["*"],
allow_credentials=True,
),
]
# Обновляем маршрут в Starlette
app = Starlette(
routes=[
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"]),
Route("/", graphql_handler, methods=["GET", "POST"]),
Route("/new-author", WebhookEndpoint),
],
middleware=middleware, # Используем единый список middleware
middleware=middleware,
lifespan=lifespan,
debug=True,
)
if DEVMODE:
# Для DEV режима регистрируем дополнительный CORS middleware только для localhost
app.add_middleware(ExceptionHandlerMiddleware)
if "dev" in sys.argv:
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://localhost:3000",
"https://localhost:3001",
"https://localhost:3002",
"http://localhost:3000",
"http://localhost:3001",
"http://localhost:3002",
],
allow_origins=["https://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],

View File

@ -1,87 +0,0 @@
[mypy]
# Основные настройки
python_version = 3.12
warn_return_any = False
warn_unused_configs = True
disallow_untyped_defs = False
disallow_incomplete_defs = False
no_implicit_optional = False
explicit_package_bases = True
namespace_packages = True
check_untyped_defs = False
# Игнорируем 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

136
orm/author.py Normal file
View File

@ -0,0 +1,136 @@
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"),
)

View File

@ -1,14 +1,14 @@
import time
from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from services.db import BaseModel as Base
from services.db import 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,5 +23,3 @@ 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])

View File

@ -1,62 +1,41 @@
import enum
import time
from typing import Any, Dict
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String, Text, UniqueConstraint, distinct, func
from sqlalchemy import Column, ForeignKey, Integer, String, Text, distinct, func
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship
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": "Полные права",
}
from orm.author import Author
from services.db import Base
class CommunityFollower(BaseModel):
"""
Простая подписка пользователя на сообщество.
class CommunityRole(enum.Enum):
READER = "reader" # can read and comment
AUTHOR = "author" # + can vote and invite collaborators
ARTIST = "artist" # + can be credited as featured artist
EXPERT = "expert" # + can add proof or disproof to shouts, can manage topics
EDITOR = "editor" # + can manage topics, comments and community settings
Использует обычный id как первичный ключ для простоты и производительности.
Уникальность обеспечивается индексом по (community, follower).
"""
__tablename__ = "community_follower"
# Простые поля - стандартный подход
community = 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]
@classmethod
def as_string_array(cls, roles):
return [role.value for role in roles]
class Community(BaseModel):
class CommunityFollower(Base):
__tablename__ = "community_author"
author = Column(ForeignKey("author.id"), primary_key=True)
community = Column(ForeignKey("community.id"), primary_key=True)
joined_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
roles = Column(Text, nullable=True, comment="Roles (comma-separated)")
def set_roles(self, roles):
self.roles = CommunityRole.as_string_array(roles)
def get_roles(self):
return [CommunityRole(role) for role in self.roles]
class Community(Base):
__tablename__ = "community"
name = Column(String, nullable=False)
@ -65,263 +44,22 @@ class Community(BaseModel):
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)
def is_followed_by(self, author_id: int) -> bool:
"""Проверяет, подписан ли пользователь на сообщество"""
from services.db import local_session
@property
def role_list(self):
return self.roles.split(",") if self.roles else []
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]
@role_list.setter
def role_list(self, value):
self.roles = ",".join(value) if value else None
class CommunityStats:
def __init__(self, community) -> None:
def __init__(self, community):
self.community = community
@property
@ -333,7 +71,7 @@ class CommunityStats:
@property
def followers(self):
return (
self.community.session.query(func.count(CommunityFollower.follower))
self.community.session.query(func.count(CommunityFollower.author))
.filter(CommunityFollower.community == self.community.id)
.scalar()
)
@ -346,463 +84,23 @@ 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(BaseModel):
"""
Связь автора с сообществом и его ролями.
Attributes:
id: Уникальный ID записи
community_id: ID сообщества
author_id: ID автора
roles: CSV строка с ролями (например: "reader,author,editor")
joined_at: Время присоединения к сообществу (unix timestamp)
"""
class CommunityAuthor(Base):
__tablename__ = "community_author"
id = Column(Integer, primary_key=True)
community_id = Column(Integer, ForeignKey("community.id"), nullable=False)
author_id = Column(Integer, ForeignKey("author.id"), nullable=False)
community_id = Column(Integer, ForeignKey("community.id"))
author_id = Column(Integer, ForeignKey("author.id"))
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) -> list[str]:
"""Получает список ролей как список строк"""
return [role.strip() for role in self.roles.split(",") if role.strip()] if self.roles else []
def role_list(self):
return self.roles.split(",") if self.roles else []
@role_list.setter
def role_list(self, value: list[str]) -> None:
"""Устанавливает список ролей из списка строк"""
self.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}
def role_list(self, value):
self.roles = ",".join(value) if value else None

View File

@ -3,9 +3,9 @@ import time
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from auth.orm import Author
from orm.author import Author
from orm.topic import Topic
from services.db import BaseModel as Base
from services.db import Base
class DraftTopic(Base):
@ -26,30 +26,80 @@ class DraftAuthor(Base):
caption = Column(String, nullable=True, default="")
class Draft(Base):
__tablename__ = "draft"
# required
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)
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)
# optional
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
layout: str = Column(String, nullable=True, default="article")
slug: str = Column(String, unique=True)
title: str = Column(String, nullable=True)
subtitle: str | None = Column(String, nullable=True)
lead: str | None = Column(String, nullable=True)
body: str = Column(String, nullable=False, comment="Body")
media: dict | None = Column(JSON, nullable=True)
cover: str | None = Column(String, nullable=True, comment="Cover image url")
cover_caption: str | None = Column(String, nullable=True, comment="Cover image alt caption")
lang: str = Column(String, nullable=False, default="ru", comment="Language")
seo: str | None = Column(String, nullable=True) # JSON
# auto
updated_at = 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")
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 [])]
}

View File

@ -3,7 +3,7 @@ import enum
from sqlalchemy import Column, ForeignKey, String
from sqlalchemy.orm import relationship
from services.db import BaseModel as Base
from services.db import 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 # type: ignore[assignment]
self.status = status.value
def get_status(self) -> InviteStatus:
return InviteStatus.from_string(self.status)

View File

@ -4,8 +4,8 @@ import time
from sqlalchemy import JSON, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from auth.orm import Author
from services.db import BaseModel as Base
from orm.author import Author
from services.db import 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 # type: ignore[assignment]
self.entity = entity.value
def get_entity(self) -> NotificationEntity:
return NotificationEntity.from_string(self.entity)
def set_action(self, action: NotificationAction):
self.action = action.value # type: ignore[assignment]
self.action = action.value
def get_action(self) -> NotificationAction:
return NotificationAction.from_string(self.action)

View File

@ -3,43 +3,30 @@ from enum import Enum as Enumeration
from sqlalchemy import Column, ForeignKey, Integer, String
from services.db import BaseModel as Base
from services.db import Base
class ReactionKind(Enumeration):
# TYPE = <reaction index> # rating diff
# editor specials
# editor mode
AGREE = "AGREE" # +1
DISAGREE = "DISAGREE" # -1
# coauthor specials
ASK = "ASK" # 0
PROPOSE = "PROPOSE" # 0
# generic internal reactions
ASK = "ASK" # +0
PROPOSE = "PROPOSE" # +0
ACCEPT = "ACCEPT" # +1
REJECT = "REJECT" # -1
# experts speacials
# expert mode
PROOF = "PROOF" # +1
DISPROOF = "DISPROOF" # -1
# comment and quote
QUOTE = "QUOTE" # 0
COMMENT = "COMMENT" # 0
# generic rating
# public feed
QUOTE = "QUOTE" # +0 TODO: use to bookmark in collection
COMMENT = "COMMENT" # +0
LIKE = "LIKE" # +1
DISLIKE = "DISLIKE" # -1
# credit artist or researcher
CREDIT = "CREDIT" # +1
SILENT = "SILENT" # 0
REACTION_KINDS = ReactionKind.__members__.keys()
class Reaction(Base):
__tablename__ = "reaction"

View File

@ -3,10 +3,10 @@ import time
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
from sqlalchemy.orm import relationship
from auth.orm import Author
from orm.author import Author
from orm.reaction import Reaction
from orm.topic import Topic
from services.db import BaseModel as Base
from services.db import Base
class ShoutTopic(Base):
@ -75,37 +75,38 @@ class Shout(Base):
__tablename__ = "shout"
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_at: int = Column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at: int | None = Column(Integer, nullable=True, index=True)
published_at: int | None = Column(Integer, nullable=True, index=True)
featured_at: int | None = Column(Integer, nullable=True, index=True)
deleted_at: int | None = Column(Integer, nullable=True, index=True)
created_by = 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)
created_by: int = Column(ForeignKey("author.id"), nullable=False)
updated_by: int | None = Column(ForeignKey("author.id"), nullable=True)
deleted_by: int | None = Column(ForeignKey("author.id"), nullable=True)
community: int = Column(ForeignKey("community.id"), nullable=False)
body = 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)
body: str = Column(String, nullable=False, comment="Body")
slug: str = Column(String, unique=True)
cover: str | None = Column(String, nullable=True, comment="Cover image url")
cover_caption: str | None = Column(String, nullable=True, comment="Cover image alt caption")
lead: str | None = Column(String, nullable=True)
title: str = Column(String, nullable=False)
subtitle: str | None = Column(String, nullable=True)
layout: str = Column(String, nullable=False, default="article")
media: dict | None = Column(JSON, nullable=True)
authors = relationship(Author, secondary="shout_author")
topics = relationship(Topic, secondary="shout_topic")
reactions = relationship(Reaction)
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
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)
draft = Column(ForeignKey("draft.id"), nullable=True)
seo: str | None = Column(String, nullable=True) # JSON
draft: int | None = Column(ForeignKey("draft.id"), nullable=True)
# Определяем индексы
__table_args__ = (

View File

@ -2,7 +2,7 @@ import time
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
from services.db import BaseModel as Base
from services.db import Base
class TopicFollower(Base):

6428
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,39 +0,0 @@
{
"name": "publy-panel",
"version": "0.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"
}
}

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -1,18 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,158 +0,0 @@
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
}
}

View File

@ -1,339 +0,0 @@
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_BY_COMMUNITY_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)

View File

@ -1,150 +0,0 @@
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
}

View File

@ -1,142 +0,0 @@
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: 'Статус'
}

View File

@ -1,145 +0,0 @@
/**
* 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)
}

View File

@ -1,239 +0,0 @@
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
}
}
}
`

View File

@ -1,381 +0,0 @@
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 || ''

View File

@ -1,6 +0,0 @@
export interface GraphQLContext {
token?: string
userId?: number
roles?: string[]
communityId?: number
}

View File

@ -1,12 +0,0 @@
/**
* Точка входа в клиентское приложение
* @module index
*/
import { render } from 'solid-js/web'
import App from './App'
import './styles.css'
// Рендеринг приложения в корневой элемент
render(() => <App />, document.getElementById('root') as HTMLElement)

View File

@ -1,317 +0,0 @@
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() }
}

View File

@ -1,237 +0,0 @@
{
"Выйти": "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..."
}

View File

@ -1,216 +0,0 @@
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

View File

@ -1,346 +0,0 @@
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 Modal from '../ui/Modal'
import RoleManager from '../ui/RoleManager'
import HTMLEditor from '../ui/HTMLEditor'
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

View File

@ -1,182 +0,0 @@
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

View File

@ -1,202 +0,0 @@
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

View File

@ -1,277 +0,0 @@
import { Component, createEffect, createSignal, Show } from 'solid-js'
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 Shout {
id: number
title: string
slug: string
created_by: Author
}
interface Invite {
inviter_id: number
author_id: number
shout_id: number
status: 'PENDING' | 'ACCEPTED' | 'REJECTED'
inviter: Author
author: Author
shout: Shout
created_at?: number
}
interface InviteEditModalProps {
isOpen: boolean
invite: Invite | null // null для создания нового
onClose: () => void
onSave: (invite: Partial<Invite>) => void
}
/**
* Модальное окно для создания и редактирования приглашений
*/
const InviteEditModal: Component<InviteEditModalProps> = (props) => {
const [formData, setFormData] = createSignal({
inviter_id: 0,
author_id: 0,
shout_id: 0,
status: 'PENDING' as 'PENDING' | 'ACCEPTED' | 'REJECTED'
})
const [errors, setErrors] = createSignal<Record<string, string>>({})
// Синхронизация с props.invite
createEffect(() => {
if (props.isOpen) {
if (props.invite) {
// Редактирование существующего приглашения
setFormData({
inviter_id: props.invite.inviter_id,
author_id: props.invite.author_id,
shout_id: props.invite.shout_id,
status: props.invite.status
})
} else {
// Создание нового приглашения
setFormData({
inviter_id: 0,
author_id: 0,
shout_id: 0,
status: 'PENDING'
})
}
setErrors({})
}
})
const validateForm = () => {
const newErrors: Record<string, string> = {}
const data = formData()
// Валидация ID приглашающего
if (!data.inviter_id || data.inviter_id <= 0) {
newErrors.inviter_id = 'ID приглашающего обязателен'
}
// Валидация ID приглашаемого
if (!data.author_id || data.author_id <= 0) {
newErrors.author_id = 'ID приглашаемого обязателен'
}
// Валидация ID публикации
if (!data.shout_id || data.shout_id <= 0) {
newErrors.shout_id = 'ID публикации обязателен'
}
// Проверка что приглашающий и приглашаемый не совпадают
if (data.inviter_id === data.author_id && data.inviter_id > 0) {
newErrors.author_id = 'Приглашающий и приглашаемый не могут быть одним и тем же автором'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const updateField = (field: string, value: string | number) => {
setFormData((prev) => ({ ...prev, [field]: value }))
// Очищаем ошибку для поля при изменении
setErrors((prev) => ({ ...prev, [field]: '' }))
}
const handleSave = () => {
if (!validateForm()) {
return
}
const inviteData = { ...formData() }
props.onSave(inviteData)
}
const isCreating = () => props.invite === null
const modalTitle = () =>
isCreating()
? 'Создание нового приглашения'
: `Редактирование приглашения: ${props.invite?.inviter.name || ''}${props.invite?.author.name || ''}`
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>
ID приглашающего
<span class={formStyles.required}>*</span>
</span>
</label>
<input
type="number"
value={formData().inviter_id}
onInput={(e) => updateField('inviter_id', Number.parseInt(e.target.value) || 0)}
class={`${formStyles.input} ${errors().inviter_id ? formStyles.error : ''} ${!isCreating() ? formStyles.disabled : ''}`}
placeholder="1"
required
disabled={!isCreating()} // При редактировании ID нельзя менять
/>
{errors().inviter_id && (
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().inviter_id}
</div>
)}
<div class={formStyles.hint}>
<span class={formStyles.hintIcon}>💡</span>
ID автора, который отправляет приглашение
</div>
</div>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>👥</span>
ID приглашаемого
<span class={formStyles.required}>*</span>
</span>
</label>
<input
type="number"
value={formData().author_id}
onInput={(e) => updateField('author_id', Number.parseInt(e.target.value) || 0)}
class={`${formStyles.input} ${errors().author_id ? formStyles.error : ''} ${!isCreating() ? formStyles.disabled : ''}`}
placeholder="2"
required
disabled={!isCreating()} // При редактировании ID нельзя менять
/>
<Show when={errors().author_id}>
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().author_id}
</div>
</Show>
<div class={formStyles.hint}>
<span class={formStyles.hintIcon}>💡</span>
ID автора, которого приглашают к сотрудничеству
</div>
</div>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>📄</span>
ID публикации
<span class={formStyles.required}>*</span>
</span>
</label>
<input
type="number"
value={formData().shout_id}
onInput={(e) => updateField('shout_id', Number.parseInt(e.target.value) || 0)}
class={`${formStyles.input} ${errors().shout_id ? formStyles.error : ''} ${!isCreating() ? formStyles.disabled : ''}`}
placeholder="123"
required
disabled={!isCreating()} // При редактировании ID нельзя менять
/>
<Show when={errors().shout_id}>
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().shout_id}
</div>
</Show>
<div class={formStyles.hint}>
<span class={formStyles.hintIcon}>💡</span>
ID публикации, к которой приглашают на сотрудничество
</div>
</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>
<select
value={formData().status}
onChange={(e) => updateField('status', e.target.value)}
class={formStyles.select}
required
>
<option value="PENDING">Ожидает ответа</option>
<option value="ACCEPTED">Принято</option>
<option value="REJECTED">Отклонено</option>
</select>
<div class={formStyles.hint}>
<span class={formStyles.hintIcon}>💡</span>
Текущий статус приглашения
</div>
</div>
{/* Информация о связанных объектах при редактировании */}
<Show when={!isCreating() && props.invite}>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}></span>
Информация о приглашении
</span>
</label>
<div class={formStyles.hint} style={{ 'margin-bottom': '8px' }}>
<span class={formStyles.hintIcon}>👤</span>
<strong>Приглашающий:</strong> {props.invite?.inviter.name} ({props.invite?.inviter.email})
</div>
<div class={formStyles.hint} style={{ 'margin-bottom': '8px' }}>
<span class={formStyles.hintIcon}>👥</span>
<strong>Приглашаемый:</strong> {props.invite?.author.name} ({props.invite?.author.email})
</div>
<div class={formStyles.hint}>
<span class={formStyles.hintIcon}>📄</span>
<strong>Публикация:</strong> {props.invite?.shout.title}
</div>
</div>
</Show>
<div class={styles.modalActions}>
<Button variant="secondary" onClick={props.onClose}>
Отмена
</Button>
<Button variant="primary" onClick={handleSave}>
{isCreating() ? 'Создать' : 'Сохранить'}
</Button>
</div>
</div>
</div>
</Modal>
)
}
export default InviteEditModal

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