upgrade schema, resolvers, panel added

This commit is contained in:
Untone 2025-05-16 09:23:48 +03:00
parent 8a60bec73a
commit 2d382be794
80 changed files with 8641 additions and 1100 deletions

2
.gitignore vendored
View File

@ -162,3 +162,5 @@ views.json
*.crt
*cache.json
.cursor
node_modules/

View File

@ -1,13 +1,163 @@
#### [0.4.20] - 2025-05-03
- Исправлена ошибка в классе `CacheRevalidationManager`: добавлена инициализация атрибута `_redis`
- Улучшена обработка соединения с Redis в менеджере ревалидации кэша:
- Автоматическое восстановление соединения в случае его потери
- Проверка соединения перед выполнением операций с кэшем
- Дополнительное логирование для упрощения диагностики проблем
- Исправлен резолвер `unpublish_shout`:
- Корректное формирование синтетического поля `publication` с `published_at: null`
- Возвращение полноценного словаря с данными вместо объекта модели
- Улучшена загрузка связанных данных (авторы, темы) для правильного формирования ответа
# Changelog
## [Unreleased]
### Изменено
- Радикально упрощена структура клиентской части приложения:
- Удалены все избыточные файлы и директории
- Перемещены модули auth.ts и api.ts из директории client/lib в корень директории client
- Обновлены импорты во всех компонентах для использования модулей из корня директории
- Создана минималистичная архитектура с 5 файлами (App, login, admin, auth, api)
- Следование принципу DRY - устранено дублирование кода
- Выделены общие модули для авторизации и работы с API
- Единый стиль кода и документации для всех компонентов
- Устранены все жесткие редиректы в пользу SolidJS Router
- Упрощена структура проекта для лучшей поддерживаемости
- Упрощена структура клиентской части приложения:
- Оставлены только два основных ресурса: логин и панель управления пользователями
- Удалены избыточные компоненты и файлы
- Упрощена логика авторизации и навигации
- Устранены жесткие редиректы в пользу SolidJS Router
- Созданы компактные и автономные компоненты login.tsx и admin.tsx
- Оптимизированы стили для минимального набора компонентов
### Добавлено
- Создана панель управления пользователями в админке:
- Добавлен компонент UsersList для управления пользователями
- Реализованы функции блокировки/разблокировки пользователей
- Добавлена возможность отключения звука (mute) для пользователей
- Реализовано управление ролями пользователей через модальное окно
- Добавлены GraphQL мутации для управления пользователями в schema/admin.graphql
- Улучшен интерфейс админ-панели с табами для навигации
- Расширена схема GraphQL для админки:
- Добавлены типы AdminUserInfo и AdminUserUpdateInput
- Добавлены мутации adminUpdateUser, adminToggleUserBlock, adminToggleUserMute
- Добавлены запросы adminGetUsers и adminGetRoles
- Пагинация списка пользователей в админ-панели
- Серверная поддержка пагинации в API для админ-панели
- Поиск пользователей по email, имени и ID
### Улучшено
- Улучшен интерфейс админ-панели:
- Добавлены вкладки для переключения между разделами
- Оптимизирован компонент UsersList для работы с большим количеством пользователей
- Добавлены индикаторы статуса для заблокированных и отключенных пользователей
- Улучшена обработка ошибок при выполнении операций с пользователями
- Добавлены подтверждения для критичных операций (блокировка, изменение ролей)
### Полностью переработан клиентский код:
- Создан компактный API клиент с изолированным кодом для доступа к API
- Реализована модульная архитектура с четким разделением ответственности
- Добавлены типизированные интерфейсы для всех компонентов и модулей
- Реализована система маршрутизации с защищенными маршрутами
- Добавлен компонент AuthProvider для управления авторизацией
- Оптимизирована загрузка компонентов с использованием ленивой загрузки
- Унифицирован стиль кода и именования
### Исправлено
- Исправлена проблема с перенаправлением в SolidJS, которое сбрасывало состояние приложения:
- Обновлена функция logout для использования колбэка навигации вместо жесткого редиректа
- Добавлен компонент LoginPage для авторизации без перезагрузки страницы
- Реализована ленивая загрузка компонентов с использованием Suspense
- Улучшена структура роутинга в админ-панели
- Оптимизирован код согласно принципам DRY и KISS
### Улучшения для авторизации в админ-панели
- Исправлена проблема с авторизацией в админ-панели
- Добавлена поддержка httpOnly cookies для безопасного хранения токена авторизации
- Реализован механизм выхода из системы через отзыв токенов
- Добавлен компонент для отображения списка пользователей в админке
- Добавлена постраничная навигация между управлением переменными окружения и списком пользователей
- Улучшена обработка сессий в API GraphQL
### Исправлено
- Переработан резолвер login_mutation для соответствия общему стилю других мутаций в кодбазе
- Реализована корректная обработка логина через `AuthResult`, устранена ошибка GraphQL "Cannot return null for non-nullable field Mutation.login"
- Улучшена обработка ошибок в модуле авторизации:
- Добавлена проверка корректности объекта автора перед созданием токена
- Исправлен порядок импорта резолверов для корректной регистрации обработчиков
- Добавлено расширенное логирование для отладки авторизации
- Гарантирован непустой возврат из резолвера login для предотвращения GraphQL ошибки
- Исправлена ошибка "Author password is empty" при авторизации:
- Добавлено поле password в метод dict() класса Author для корректной передачи при создании экземпляра из словаря
- Устранена ошибка `Author object has no attribute username` при создании токена авторизации:
- Добавлено свойство username в класс Author для совместимости с `TokenStorage`
- Исправлена HTML-форма на странице входа в админ-панель:
- Добавлен тег `<form>` для устранения предупреждения браузера о полях пароля вне формы
- Улучшена доступность и UX формы логина
- Добавлены атрибуты `autocomplete` для улучшения работы с менеджерами паролей
- Внедрена более строгая валидация полей и фокусировка на ошибках
### Added
- Подробная документация модуля аутентификации в `docs/auth.md`
- Система ролей и разрешений (RBAC)
- Защита от брутфорс атак
- Мультиязычная поддержка в email уведомлениях
- Подробная документация по системе авторизации в `docs/auth.md`
- Описание OAuth интеграции
- Руководство по RBAC
- Примеры использования на фронтенде
- Инструкции по безопасности
- Документация по тестированию
- Страница входа для неавторизованных пользователей в админке
- Публичное GraphQL API для модуля аутентификации:
- Типы: `AuthResult`, `Permission`, `SessionInfo`, `OAuthProvider`
- Мутации: `login`, `registerUser`, `sendLink`, `confirmEmail`, `getSession`, `changePassword`
- Запросы: `signOut`, `me`, `isEmailUsed`, `getOAuthProviders`
### Changed
- Переработана структура модуля auth для лучшей модульности
- Улучшена обработка ошибок в auth endpoints
- Оптимизировано хранение сессий в Redis
- Усилена безопасность хеширования паролей
- Удалена поддержка удаленной аутентификации в пользу единой локальной системы аутентификации
- Удалены настройки `AUTH_MODE` и `AUTH_URL`
- Удалены зависимости от внешнего сервиса авторизации
- Упрощен код аутентификации
- Консолидация типов для авторизации:
- Удален дублирующий тип `UserInfo`
- Расширен тип `Author` полями для работы с авторизацией (`roles`, `email_verified`)
- Использование единого типа `Author` во всех запросах авторизации
### Fixed
- Исправлена проблема с кэшированием разрешений
- Улучшена валидация email и username
- Исправлена обработка истекших токенов
- Исправлена ошибка в функции `get_with_stat` в модуле resolvers/stat.py: добавлен вызов метода `.unique()` для результатов запросов с joined eager loads
- Исправлены ошибки в декораторах auth:
- Добавлены проверки на None для объекта `info` в декораторах `admin_auth_required` и `require_permission`
- Улучшена обработка ошибок в GraphQL контексте
- Добавлен AuthenticationMiddleware с использованием InternalAuthentication для работы с request.auth
- Исправлена ошибка с классом InternalAuthentication:
- Добавлен класс AuthenticatedUser
- Реализован корректный возврат кортежа (AuthCredentials, BaseUser) из метода authenticate
#### [0.4.21] - 2023-09-10
### Изменено
- Переработана пагинация в админ-панели: переход с модели page/perPage на limit/offset
- Улучшена производительность при работе с большими списками пользователей
- Оптимизирован GraphQL API для управления пользователями
### Исправлено
- Исправлена ошибка GraphQL "Unknown argument 'page' on field 'Query.adminGetUsers'"
- Согласованы параметры пагинации между клиентом и сервером
#### [0.4.20] - 2023-09-01
### Добавлено
- Пагинация списка пользователей в админ-панели
- Серверная поддержка пагинации в API для админ-панели
- Поиск пользователей по email, имени и ID
### Изменено
- Улучшен интерфейс админ-панели
- Переработана обработка GraphQL запросов для списка пользователей
### Исправлено
- Проблемы с авторизацией и проверкой токенов
- Обработка ошибок в API модулях
#### [0.4.19] - 2025-04-14
- dropped `Shout.description` and `Draft.description` to be UX-generated

View File

@ -74,6 +74,9 @@ pytest
# Type checking
mypy .
# dev run
python -m granian main:app --interface asgi
```
### Code Style

View File

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

122
auth/__init__.py Normal file
View File

@ -0,0 +1,122 @@
from starlette.requests import Request
from starlette.responses import JSONResponse, RedirectResponse
from starlette.routing import Route
from auth.sessions import SessionManager
from auth.internal import verify_internal_auth
from auth.orm import Author
from services.db import local_session
from utils.logger import root_logger as logger
from settings import (
SESSION_COOKIE_NAME,
SESSION_COOKIE_HTTPONLY,
SESSION_COOKIE_SECURE,
SESSION_COOKIE_SAMESITE,
SESSION_COOKIE_MAX_AGE,
)
async def logout(request: Request):
"""
Выход из системы с удалением сессии и cookie.
"""
# Получаем токен из cookie или заголовка
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
# Проверяем заголовок авторизации
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:] # Отрезаем "Bearer "
# Если токен найден, отзываем его
if token:
try:
# Декодируем токен для получения user_id
user_id, _ = await verify_internal_auth(token)
if user_id:
# Отзываем сессию
await SessionManager.revoke_session(user_id, 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}")
# Создаем ответ с редиректом на страницу входа
response = RedirectResponse(url="/login")
# Удаляем cookie с токеном
response.delete_cookie(SESSION_COOKIE_NAME)
logger.info("[auth] logout: Cookie успешно удалена")
return response
async def refresh_token(request: Request):
"""
Обновление токена аутентификации.
"""
# Получаем текущий токен из cookie или заголовка
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:] # Отрезаем "Bearer "
if not token:
return JSONResponse({"success": False, "error": "Токен не найден"}, status_code=401)
try:
# Получаем информацию о пользователе из токена
user_id, _ = await verify_internal_auth(token)
if not user_id:
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:
return JSONResponse({"success": False, "error": "Пользователь не найден"}, status_code=404)
# Обновляем сессию (создаем новую и отзываем старую)
device_info = {"ip": request.client.host, "user_agent": request.headers.get("user-agent")}
new_token = await SessionManager.refresh_session(user_id, token, device_info)
if not new_token:
return JSONResponse(
{"success": False, "error": "Не удалось обновить токен"}, status_code=500
)
# Создаем ответ
response = JSONResponse(
{
"success": True,
"token": new_token,
"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)
# Маршруты для авторизации
routes = [
Route("/auth/logout", logout, methods=["GET", "POST"]),
Route("/auth/refresh", refresh_token, methods=["POST"]),
]

View File

@ -1,54 +1,72 @@
from functools import wraps
from typing import Optional, Tuple
from typing import Optional
from graphql.type import GraphQLResolveInfo
from sqlalchemy.orm import exc, joinedload
from sqlalchemy.orm import exc
from starlette.authentication import AuthenticationBackend
from starlette.requests import HTTPConnection
from auth.credentials import AuthCredentials, AuthUser
from auth.credentials import AuthCredentials
from auth.exceptions import OperationNotAllowed
from auth.tokenstorage import SessionToken
from auth.usermodel import Role, User
from auth.sessions import SessionManager
from auth.orm import Author
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]]:
async def authenticate(self, request: HTTPConnection) -> Optional[AuthCredentials]:
"""
Аутентификация пользователя по JWT токену.
Args:
request: HTTP запрос
Returns:
AuthCredentials при успешной аутентификации или None при ошибке
"""
if SESSION_TOKEN_HEADER not in request.headers:
return AuthCredentials(scopes={}), AuthUser(user_id=None, username="")
return None
token = request.headers.get(SESSION_TOKEN_HEADER)
if not token:
auth_header = request.headers.get(SESSION_TOKEN_HEADER)
if not auth_header:
print("[auth.authenticate] no token in header %s" % SESSION_TOKEN_HEADER)
return AuthCredentials(scopes={}, error_message=str("no token")), AuthUser(user_id=None, username="")
return None
if len(token.split(".")) > 1:
payload = await SessionToken.verify(token)
# Обработка формата "Bearer <token>"
token = auth_header
if auth_header.startswith("Bearer "):
token = auth_header.replace("Bearer ", "", 1).strip()
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()
)
if not token:
print("[auth.authenticate] empty token after Bearer prefix removal")
return None
scopes = {} # TODO: integrate await user.get_permission()
# Проверяем сессию в Redis
payload = await SessionManager.verify_session(token)
if not payload:
return None
return (
AuthCredentials(user_id=payload.user_id, scopes=scopes, logged_in=True),
AuthUser(user_id=user.id, username=""),
)
except exc.NoResultFound:
pass
with local_session() as session:
try:
author = (
session.query(Author)
.filter(Author.id == payload.user_id)
.filter(Author.is_active == True) # noqa
.one()
)
return AuthCredentials(scopes={}, error_message=str("Invalid token")), AuthUser(user_id=None, username="")
if author.is_locked():
return None
# Получаем разрешения из ролей
scopes = author.get_permissions()
return AuthCredentials(
author_id=author.id, scopes=scopes, logged_in=True, email=author.email
)
except exc.NoResultFound:
return None
def login_required(func):
@ -62,15 +80,34 @@ def login_required(func):
return wrap
def permission_required(resource, operation, func):
def permission_required(resource: str, operation: str, func):
"""
Декоратор для проверки разрешений.
Args:
resource (str): Ресурс для проверки
operation (str): Операция для проверки
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
with local_session() as session:
author = session.query(Author).filter(Author.id == auth.author_id).one()
# Проверяем базовые условия
if not author.is_active:
raise OperationNotAllowed("Account is not active")
if author.is_locked():
raise OperationNotAllowed("Account is locked")
# Проверяем разрешение
if not author.has_permission(resource, operation):
raise OperationNotAllowed(f"No permission for {operation} on {resource}")
return await func(parent, info, *args, **kwargs)
@ -82,12 +119,12 @@ def login_accepted(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")
with local_session() as session:
author = session.query(Author).filter(Author.id == auth.author_id).one()
info.context["author"] = author.dict()
info.context["user_id"] = author.id
else:
# Очищаем данные автора из контекста если авторизация отсутствует
info.context["author"] = None
info.context["user_id"] = None

View File

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

142
auth/decorators.py Normal file
View File

@ -0,0 +1,142 @@
from functools import wraps
from typing import Callable, Any
from graphql import GraphQLError
from services.db import local_session
from auth.orm import Author
from auth.exceptions import OperationNotAllowed
from utils.logger import root_logger as logger
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
def admin_auth_required(resolver: Callable) -> Callable:
"""
Декоратор для защиты админских эндпоинтов.
Проверяет принадлежность к списку разрешенных email-адресов.
Args:
resolver: GraphQL резолвер для защиты
Returns:
Обернутый резолвер, который проверяет права доступа
Raises:
GraphQLError: если пользователь не авторизован или не имеет доступа администратора
"""
@wraps(resolver)
async def wrapper(root: Any = None, info: Any = None, **kwargs):
try:
# Проверяем наличие info и контекста
if info is None or not hasattr(info, "context"):
logger.error("Missing GraphQL context information")
raise GraphQLError("Internal server error: missing context")
# Получаем ID пользователя из контекста запроса
request = info.context.get("request")
if not request or not hasattr(request, "auth"):
logger.error("Missing request or auth object in context")
raise GraphQLError("Internal server error: missing auth")
auth = request.auth
if not auth or not auth.logged_in:
client_info = {
"ip": request.client.host if hasattr(request, "client") else "unknown",
"headers": dict(request.headers),
}
logger.error(f"Unauthorized access attempt for admin endpoint: {client_info}")
raise GraphQLError("Unauthorized")
# Проверяем принадлежность к списку админов
with local_session() as session:
try:
author = session.query(Author).filter(Author.id == auth.author_id).one()
# Проверка по email
if author.email in ADMIN_EMAILS:
logger.info(
f"Admin access granted for {author.email} (special admin, ID: {author.id})"
)
return await resolver(root, info, **kwargs)
else:
logger.warning(
f"Admin access denied for {author.email} (ID: {author.id}) - not in admin list"
)
raise GraphQLError("Unauthorized - not an admin")
except Exception as db_error:
logger.error(f"Error fetching author with ID {auth.author_id}: {str(db_error)}")
raise GraphQLError("Unauthorized - user not found")
except Exception as e:
# Если ошибка уже GraphQLError, просто перебрасываем её
if isinstance(e, GraphQLError):
logger.error(f"GraphQL error in admin_auth_required: {str(e)}")
raise e
# Иначе, создаем новую GraphQLError
logger.error(f"Error in admin_auth_required: {str(e)}")
raise GraphQLError(f"Admin access error: {str(e)}")
return wrapper
def require_permission(permission_string: str):
"""
Декоратор для проверки наличия указанного разрешения.
Принимает строку в формате "resource:permission".
Args:
permission_string: Строка в формате "resource:permission"
Returns:
Декоратор, проверяющий наличие указанного разрешения
Raises:
ValueError: если строка разрешения имеет неверный формат
"""
if ":" not in permission_string:
raise ValueError('Permission string must be in format "resource:permission"')
resource, operation = permission_string.split(":", 1)
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(parent, info: Any = None, *args, **kwargs):
# Проверяем наличие info и контекста
if info is None or not hasattr(info, "context"):
logger.error("Missing GraphQL context information in require_permission")
raise OperationNotAllowed("Internal server error: missing context")
auth = info.context["request"].auth
if not auth or not auth.logged_in:
raise OperationNotAllowed("Unauthorized - please login")
with local_session() as session:
try:
author = session.query(Author).filter(Author.id == auth.author_id).one()
# Проверяем базовые условия
if not author.is_active:
raise OperationNotAllowed("Account is not active")
if author.is_locked():
raise OperationNotAllowed("Account is locked")
# Проверяем разрешение
if not author.has_permission(resource, operation):
logger.warning(
f"Access denied for user {auth.author_id} - no permission {resource}:{operation}"
)
raise OperationNotAllowed(f"No permission for {operation} on {resource}")
# Пользователь аутентифицирован и имеет необходимое разрешение
return await func(parent, info, *args, **kwargs)
except Exception as e:
logger.error(f"Error in require_permission: {e}")
if isinstance(e, OperationNotAllowed):
raise e
raise OperationNotAllowed(str(e))
return wrapper
return decorator

View File

@ -1,16 +1,21 @@
from binascii import hexlify
from hashlib import sha256
from typing import Any, Dict, TypeVar, TYPE_CHECKING
from passlib.hash import bcrypt
from auth.exceptions import ExpiredToken, InvalidToken
from auth.exceptions import ExpiredToken, InvalidToken, InvalidPassword
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
# Для типизации
if TYPE_CHECKING:
from auth.orm import Author
AuthorType = TypeVar("AuthorType", bound="Author")
class Password:
@staticmethod
@ -24,6 +29,15 @@ 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)
@ -52,28 +66,93 @@ class Password:
class Identity:
@staticmethod
def password(orm_user: User, password: str) -> User:
user = User(**orm_user.dict())
if not user.password:
# raise InvalidPassword("User password is empty")
return {"error": "User password is empty"}
if not Password.verify(password, user.password):
# raise InvalidPassword("Wrong user password")
return {"error": "Wrong user password"}
return user
def password(orm_author: Any, password: str) -> Any:
"""
Проверяет пароль пользователя
Args:
orm_author (Author): Объект пользователя
password (str): Пароль пользователя
Returns:
Author: Объект автора при успешной проверке
Raises:
InvalidPassword: Если пароль не соответствует хешу или отсутствует
"""
# Импортируем внутри функции для избежания циклических импортов
from auth.orm import Author
from utils.logger import root_logger as logger
# Проверим исходный пароль в orm_author
if not orm_author.password:
logger.warning(
f"[auth.identity] Пароль в исходном объекте автора пуст: email={orm_author.email}"
)
raise InvalidPassword("Пароль не установлен для данного пользователя")
# Проверим словарь до создания нового объекта
author_dict = orm_author.dict()
if "password" not in author_dict or not author_dict["password"]:
logger.warning(
f"[auth.identity] Пароль отсутствует в dict() или пуст: email={orm_author.email}"
)
raise InvalidPassword("Пароль отсутствует в данных пользователя")
# Создаем новый объект автора
author = Author(**author_dict)
if not author.password:
logger.warning(
f"[auth.identity] Пароль в созданном объекте автора пуст: email={orm_author.email}"
)
raise InvalidPassword("Пароль не установлен для данного пользователя")
# Проверяем пароль
if not Password.verify(password, author.password):
logger.warning(f"[auth.identity] Неверный пароль для {orm_author.email}")
raise InvalidPassword("Неверный пароль пользователя")
# Возвращаем исходный объект, чтобы сохранить все связи
return orm_author
@staticmethod
def oauth(inp) -> User:
def oauth(inp: Dict[str, Any]) -> Any:
"""
Создает нового пользователя OAuth, если он не существует
Args:
inp (dict): Данные OAuth пользователя
Returns:
Author: Объект пользователя
"""
# Импортируем внутри функции для избежания циклических импортов
from auth.orm import Author
with local_session() as session:
user = session.query(User).filter(User.email == inp["email"]).first()
if not user:
user = User.create(**inp, emailConfirmed=True)
author = session.query(Author).filter(Author.email == inp["email"]).first()
if not author:
author = Author(**inp)
author.email_verified = True
session.add(author)
session.commit()
return user
return author
@staticmethod
async def onetime(token: str) -> User:
async def onetime(token: str) -> Any:
"""
Проверяет одноразовый токен
Args:
token (str): Одноразовый токен
Returns:
Author: Объект пользователя
"""
# Импортируем внутри функции для избежания циклических импортов
from auth.orm import Author
try:
print("[auth.identity] using one time token")
payload = JWTCodec.decode(token)
@ -87,11 +166,11 @@ class Identity:
# 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:
author = session.query(Author).filter_by(id=payload.user_id).first()
if not author:
# raise Exception("user not exist")
return {"error": "User does not exist"}
if not user.emailConfirmed:
user.emailConfirmed = True
return {"error": "Author does not exist"}
if not author.email_verified:
author.email_verified = True
session.commit()
return user
return author

168
auth/internal.py Normal file
View File

@ -0,0 +1,168 @@
from typing import Optional, Tuple
import time
from sqlalchemy.orm import exc
from starlette.authentication import AuthenticationBackend, BaseUser, UnauthenticatedUser
from starlette.requests import HTTPConnection
from auth.credentials import AuthCredentials
from auth.orm import Author
from auth.sessions import SessionManager
from services.db import local_session
from settings import SESSION_TOKEN_HEADER
from utils.logger import root_logger as logger
class AuthenticatedUser(BaseUser):
"""Аутентифицированный пользователь для Starlette"""
def __init__(self, user_id: str, username: str = "", roles: list = None, permissions: dict = None):
self.user_id = user_id
self.username = username
self.roles = roles or []
self.permissions = permissions or {}
@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 InternalAuthentication(AuthenticationBackend):
"""Внутренняя аутентификация через базу данных и Redis"""
async def authenticate(self, request: HTTPConnection):
"""
Аутентифицирует пользователя по токену из заголовка.
Токен должен быть обработан заранее AuthorizationMiddleware,
который извлекает Bearer токен и преобразует его в чистый токен.
Возвращает:
tuple: (AuthCredentials, BaseUser)
"""
if SESSION_TOKEN_HEADER not in request.headers:
return AuthCredentials(scopes={}), UnauthenticatedUser()
token = request.headers.get(SESSION_TOKEN_HEADER)
if not token:
logger.debug("[auth.authenticate] Пустой токен в заголовке")
return AuthCredentials(scopes={}, error_message="no token"), UnauthenticatedUser()
# Проверяем сессию в Redis
payload = await SessionManager.verify_session(token)
if not payload:
logger.debug("[auth.authenticate] Недействительный токен")
return AuthCredentials(scopes={}, error_message="Invalid token"), UnauthenticatedUser()
with local_session() as session:
try:
author = (
session.query(Author)
.filter(Author.id == payload.user_id)
.filter(Author.is_active == True) # noqa
.one()
)
if author.is_locked():
logger.debug(f"[auth.authenticate] Аккаунт заблокирован: {author.id}")
return AuthCredentials(
scopes={}, error_message="Account is locked"
), UnauthenticatedUser()
# Получаем разрешения из ролей
scopes = author.get_permissions()
# Получаем роли для пользователя
roles = [role.id for role in author.roles] if author.roles else []
# Обновляем last_seen
author.last_seen = int(time.time())
session.commit()
# Создаем объекты авторизации
credentials = AuthCredentials(
author_id=author.id, scopes=scopes, logged_in=True, email=author.email
)
user = AuthenticatedUser(
user_id=str(author.id),
username=author.slug or author.email or "",
roles=roles,
permissions=scopes,
)
logger.debug(f"[auth.authenticate] Успешная аутентификация: {author.email}")
return credentials, user
except exc.NoResultFound:
logger.debug("[auth.authenticate] Пользователь не найден")
return AuthCredentials(scopes={}, error_message="User not found"), UnauthenticatedUser()
async def verify_internal_auth(token: str) -> Tuple[str, list]:
"""
Проверяет локальную авторизацию.
Возвращает user_id и список ролей.
Args:
token: Токен авторизации (может быть как с Bearer, так и без)
Returns:
tuple: (user_id, roles)
"""
# Обработка формата "Bearer <token>" (если токен не был обработан ранее)
if token.startswith("Bearer "):
token = token.replace("Bearer ", "", 1).strip()
# Проверяем сессию
payload = await SessionManager.verify_session(token)
if not payload:
return "", []
with local_session() as session:
try:
author = (
session.query(Author)
.filter(Author.id == payload.user_id)
.filter(Author.is_active == True) # noqa
.one()
)
# Получаем роли
roles = [role.id for role in author.roles]
return str(author.id), roles
except exc.NoResultFound:
return "", []
async def create_internal_session(author: Author, device_info: Optional[dict] = None) -> str:
"""
Создает новую сессию для автора
Args:
author: Объект автора
device_info: Информация об устройстве (опционально)
Returns:
str: Токен сессии
"""
# Сбрасываем счетчик неудачных попыток
author.reset_failed_login()
# Обновляем last_login
author.last_login = int(time.time())
# Создаем сессию, используя token для идентификации
return await SessionManager.create_session(
user_id=str(author.id),
username=author.slug or author.email or author.phone or "",
device_info=device_info,
)

View File

@ -20,7 +20,7 @@ class JWTCodec:
def encode(user, exp: datetime) -> str:
payload = {
"user_id": user.id,
"username": user.email or user.phone,
"username": user.slug or user.email or user.phone or "",
"exp": exp,
"iat": datetime.now(tz=timezone.utc),
"iss": "discours",
@ -50,11 +50,13 @@ class JWTCodec:
return r
except jwt.InvalidIssuedAtError:
print("[auth.jwtcodec] invalid issued at: %r" % payload)
raise ExpiredToken("check token issued time")
raise ExpiredToken("jwt check token issued time")
except jwt.ExpiredSignatureError:
print("[auth.jwtcodec] expired signature %r" % payload)
raise ExpiredToken("check token lifetime")
except jwt.InvalidTokenError:
raise InvalidToken("token is not valid")
raise ExpiredToken("jwt check token lifetime")
except jwt.InvalidSignatureError:
raise InvalidToken("token is not valid")
raise InvalidToken("jwt check signature is not valid")
except jwt.InvalidTokenError:
raise InvalidToken("jwt check token is not valid")
except jwt.InvalidKeyError:
raise InvalidToken("jwt check key is not valid")

110
auth/middleware.py Normal file
View File

@ -0,0 +1,110 @@
"""
Middleware для обработки авторизации в GraphQL запросах
"""
from starlette.datastructures import Headers
from starlette.types import ASGIApp, Scope, Receive, Send
from utils.logger import root_logger as logger
from settings import SESSION_TOKEN_HEADER, SESSION_COOKIE_NAME
class AuthorizationMiddleware:
"""
Middleware для обработки заголовка Authorization и cookie авторизации.
Извлекает Bearer токен из заголовка или cookie и добавляет его в заголовки
запроса для обработки стандартным AuthenticationMiddleware Starlette.
"""
def __init__(self, app: ASGIApp):
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
# Извлекаем заголовки
headers = Headers(scope=scope)
auth_header = headers.get(SESSION_TOKEN_HEADER)
token = None
# Сначала пробуем получить токен из заголовка Authorization
if auth_header:
if auth_header.startswith("Bearer "):
token = auth_header.replace("Bearer ", "", 1).strip()
logger.debug(
f"[middleware] Извлечен Bearer токен из заголовка, длина: {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, длина: {len(token) if token else 0}"
)
break
# Если токен получен, обновляем заголовки в scope
if token:
# Создаем новый список заголовков
new_headers = []
for name, value in scope["headers"]:
# Пропускаем оригинальный заголовок авторизации
if name.decode("latin1").lower() != SESSION_TOKEN_HEADER.lower():
new_headers.append((name, value))
# Добавляем заголовок с чистым токеном
new_headers.append((SESSION_TOKEN_HEADER.encode("latin1"), token.encode("latin1")))
# Обновляем заголовки в scope
scope["headers"] = new_headers
# Также добавляем информацию о типе аутентификации для дальнейшего использования
if "auth" not in scope:
scope["auth"] = {"type": "bearer", "token": token}
await self.app(scope, receive, send)
class GraphQLExtensionsMiddleware:
"""
Утилиты для расширения контекста GraphQL запросов
"""
def set_cookie(self, key, value, **options):
"""Устанавливает cookie в ответе"""
context = getattr(self, "_context", None)
if context and "response" in context and hasattr(context["response"], "set_cookie"):
context["response"].set_cookie(key, value, **options)
def delete_cookie(self, key, **options):
"""Удаляет cookie из ответа"""
context = getattr(self, "_context", None)
if context and "response" in context and hasattr(context["response"], "delete_cookie"):
context["response"].delete_cookie(key, **options)
async def resolve(self, next, root, info, *args, **kwargs):
"""
Middleware для обработки запросов GraphQL.
Добавляет методы для установки cookie в контекст.
"""
try:
# Получаем доступ к контексту запроса
context = info.context
# Сохраняем ссылку на контекст
self._context = context
# Добавляем себя как объект, содержащий утилитные методы
context["extensions"] = self
return await next(root, info, *args, **kwargs)
except Exception as e:
logger.error(f"[GraphQLExtensionsMiddleware] Ошибка: {str(e)}")
raise

View File

@ -1,98 +1,189 @@
from authlib.integrations.starlette_client import OAuth
from starlette.responses import RedirectResponse
from authlib.oauth2.rfc7636 import create_s256_code_challenge
from starlette.responses import RedirectResponse, JSONResponse
from secrets import token_urlsafe
import time
from auth.identity import Identity
from auth.tokenstorage import TokenStorage
from auth.orm import Author
from services.db import local_session
from settings import FRONTEND_URL, OAUTH_CLIENTS
oauth = OAuth()
oauth.register(
name="facebook",
client_id=OAUTH_CLIENTS["FACEBOOK"]["id"],
client_secret=OAUTH_CLIENTS["FACEBOOK"]["key"],
access_token_url="https://graph.facebook.com/v11.0/oauth/access_token",
access_token_params=None,
authorize_url="https://www.facebook.com/v11.0/dialog/oauth",
authorize_params=None,
api_base_url="https://graph.facebook.com/",
client_kwargs={"scope": "public_profile email"},
)
oauth.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"},
)
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",
)
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
async def facebook_profile(client, request, token):
profile = await client.get("me?fields=name,id,email", token=token)
return profile.json()
async def github_profile(client, request, token):
profile = await client.get("user", token=token)
return profile.json()
profile_callbacks = {
"google": google_profile,
"facebook": facebook_profile,
"github": github_profile,
# Конфигурация провайдеров
PROVIDERS = {
"google": {
"name": "google",
"server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
"client_kwargs": {"scope": "openid email profile", "prompt": "select_account"},
},
"github": {
"name": "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/",
"client_kwargs": {"scope": "user:email"},
},
"facebook": {
"name": "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/",
"client_kwargs": {"scope": "public_profile email"},
},
}
# Регистрация провайдеров
for provider, config in PROVIDERS.items():
if provider in OAUTH_CLIENTS:
oauth.register(
name=config["name"],
client_id=OAUTH_CLIENTS[provider.upper()]["id"],
client_secret=OAUTH_CLIENTS[provider.upper()]["key"],
**config,
)
async def get_user_profile(provider: str, client, token) -> dict:
"""Получает профиль пользователя от провайдера OAuth"""
if provider == "google":
userinfo = token.get("userinfo", {})
return {
"id": userinfo.get("sub"),
"email": userinfo.get("email"),
"name": userinfo.get("name"),
"picture": userinfo.get("picture", "").replace("=s96", "=s600"),
}
elif provider == "github":
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"),
}
elif provider == "facebook":
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"),
}
return {}
async def oauth_login(request):
"""Начинает процесс OAuth авторизации"""
provider = request.path_params["provider"]
if provider not in PROVIDERS:
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)
# Сохраняем code_verifier в сессии
request.session["code_verifier"] = code_verifier
request.session["provider"] = provider
client = oauth.create_client(provider)
redirect_uri = "https://v2.discours.io/oauth-authorize"
return await client.authorize_redirect(request, redirect_uri)
request.session["state"] = token_urlsafe(16)
redirect_uri = f"{FRONTEND_URL}/oauth/callback"
try:
return await client.authorize_redirect(
request,
redirect_uri,
code_challenge=code_challenge,
code_challenge_method="S256",
state=request.session["state"],
)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
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"],
}
user = Identity.oauth(user_input)
session_token = await TokenStorage.create_session(user)
response = RedirectResponse(url=FRONTEND_URL + "/confirm")
response.set_cookie("token", session_token)
return response
async def oauth_callback(request):
"""Обрабатывает callback от OAuth провайдера"""
try:
provider = request.session.get("provider")
if not provider:
return JSONResponse({"error": "No active OAuth session"}, status_code=400)
# Проверяем state
state = request.query_params.get("state")
if state != request.session.get("state"):
return JSONResponse({"error": "Invalid state"}, status_code=400)
client = oauth.create_client(provider)
if not client:
return JSONResponse({"error": "Provider not configured"}, status_code=400)
# Получаем токен с PKCE verifier
token = await client.authorize_access_token(
request, code_verifier=request.session.get("code_verifier")
)
# Получаем профиль пользователя
profile = await get_user_profile(provider, client, token)
if not profile.get("email"):
return JSONResponse({"error": "Email not provided"}, status_code=400)
# Создаем или обновляем пользователя
with local_session() as session:
author = session.query(Author).filter(Author.email == profile["email"]).first()
if not author:
author = Author(
email=profile["email"],
name=profile["name"],
username=profile["name"],
pic=profile.get("picture"),
oauth=f"{provider}:{profile['id']}",
email_verified=True,
created_at=int(time.time()),
updated_at=int(time.time()),
last_seen=int(time.time()),
)
session.add(author)
else:
author.name = profile["name"]
author.pic = profile.get("picture") or author.pic
author.oauth = f"{provider}:{profile['id']}"
author.email_verified = True
author.updated_at = int(time.time())
author.last_seen = int(time.time())
session.commit()
# Создаем сессию
session_token = await TokenStorage.create_session(author)
# Очищаем сессию OAuth
request.session.pop("code_verifier", None)
request.session.pop("provider", None)
request.session.pop("state", None)
# Возвращаем токен через cookie
response = RedirectResponse(url=f"{FRONTEND_URL}/auth/success")
response.set_cookie(
"session_token",
session_token,
httponly=True,
secure=True,
samesite="lax",
max_age=30 * 24 * 60 * 60, # 30 days
)
return response
except Exception as e:
return RedirectResponse(url=f"{FRONTEND_URL}/auth/error?message={str(e)}")

259
auth/orm.py Normal file
View File

@ -0,0 +1,259 @@
import time
from typing import Dict, Set
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
from sqlalchemy.orm import relationship
from auth.identity import Password
from services.db import Base
# from sqlalchemy_utils import TSVectorType
# Общие 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},
)
id = None # type: ignore
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},
)
id = None # type: ignore
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
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 RolePermission(Base):
"""Связь роли с разрешениями"""
__tablename__ = "role_permission"
__table_args__ = {"extend_existing": True}
id = None
role = Column(ForeignKey("role.id"), primary_key=True, index=True)
permission = Column(ForeignKey("permission.id"), primary_key=True, index=True)
class Permission(Base):
"""Модель разрешения в системе RBAC"""
__tablename__ = "permission"
__table_args__ = {"extend_existing": True}
id = Column(String, primary_key=True, unique=True, nullable=False, default=None)
resource = Column(String, nullable=False)
operation = Column(String, nullable=False)
class Role(Base):
"""Модель роли в системе RBAC"""
__tablename__ = "role"
__table_args__ = {"extend_existing": True}
id = Column(String, primary_key=True, unique=True, nullable=False, default=None)
name = Column(String, nullable=False)
permissions = relationship(Permission, secondary="role_permission", lazy="joined")
class AuthorRole(Base):
"""Связь автора с ролями"""
__tablename__ = "author_role"
__table_args__ = {"extend_existing": True}
id = None
community = Column(ForeignKey("community.id"), primary_key=True, index=True)
author = Column(ForeignKey("author.id"), primary_key=True, index=True)
role = Column(ForeignKey("role.id"), primary_key=True, index=True)
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")
# Дополнительные поля из User
oauth = Column(String, nullable=True, comment="OAuth provider")
oid = Column(String, nullable=True, comment="OAuth ID")
muted = Column(Boolean, default=False, comment="Is author muted")
# Поля аутентификации
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")
is_active = Column(Boolean, default=True, nullable=False)
email_verified = Column(Boolean, default=False)
phone_verified = Column(Boolean, default=False)
last_login = Column(Integer, nullable=True)
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)
# Связи с ролями
roles = relationship(Role, secondary="author_role", lazy="joined")
# search_vector = Column(
# TSVectorType("name", "slug", "bio", "about", regconfig="pg_catalog.russian")
# )
@property
def is_authenticated(self) -> bool:
"""Проверяет, аутентифицирован ли пользователь"""
return self.id is not None
def get_permissions(self) -> Dict[str, Set[str]]:
"""Получает все разрешения пользователя"""
permissions: Dict[str, Set[str]] = {}
for role in self.roles:
for permission in role.permissions:
if permission.resource not in permissions:
permissions[permission.resource] = set()
permissions[permission.resource].add(permission.operation)
return permissions
def has_permission(self, resource: str, operation: str) -> bool:
"""Проверяет наличие разрешения у пользователя"""
permissions = self.get_permissions()
return resource in permissions and operation in permissions[resource]
def verify_password(self, password: str) -> bool:
"""Проверяет пароль пользователя"""
return Password.verify(password, self.password) if self.password else False
def set_password(self, password: str):
"""Устанавливает пароль пользователя"""
self.password = Password.encode(password)
def increment_failed_login(self):
"""Увеличивает счетчик неудачных попыток входа"""
self.failed_login_attempts += 1
if self.failed_login_attempts >= 5:
self.account_locked_until = int(time.time()) + 300 # 5 минут
def reset_failed_login(self):
"""Сбрасывает счетчик неудачных попыток входа"""
self.failed_login_attempts = 0
self.account_locked_until = None
def is_locked(self) -> bool:
"""Проверяет, заблокирован ли аккаунт"""
if not self.account_locked_until:
return False
return self.account_locked_until > int(time.time())
@property
def username(self) -> str:
"""
Возвращает имя пользователя для использования в токенах.
Необходимо для совместимости с TokenStorage и JWTCodec.
Returns:
str: slug, email или phone пользователя
"""
return self.slug or self.email or self.phone or ""
def dict(self) -> Dict:
"""Преобразует объект Author в словарь"""
return {
"id": self.id,
"slug": self.slug,
"name": self.name,
"bio": self.bio,
"about": self.about,
"pic": self.pic,
"links": self.links,
"email": self.email,
"password": self.password,
"created_at": self.created_at,
"updated_at": self.updated_at,
"last_seen": self.last_seen,
"deleted_at": self.deleted_at,
"roles": [role.id for role in self.roles],
"email_verified": self.email_verified,
}

242
auth/permissions.py Normal file
View File

@ -0,0 +1,242 @@
"""
Модуль для проверки разрешений пользователей в контексте сообществ.
Позволяет проверять доступ пользователя к определенным операциям в сообществе
на основе его роли в этом сообществе.
"""
from typing import List, Union
from sqlalchemy.orm import Session
from auth.orm import Author, Role, RolePermission, Permission
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
from orm.community import Community, CommunityFollower, CommunityRole
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
class ContextualPermissionCheck:
"""
Класс для проверки контекстно-зависимых разрешений.
Позволяет проверять разрешения пользователя в контексте сообщества,
учитывая как глобальные роли пользователя, так и его роли внутри сообщества.
"""
# Маппинг из ролей сообщества в системные роли RBAC
COMMUNITY_ROLE_MAP = {
CommunityRole.READER: "community_reader",
CommunityRole.AUTHOR: "community_author",
CommunityRole.EXPERT: "community_expert",
CommunityRole.EDITOR: "community_editor",
}
# Обратное отображение для отображения системных ролей в роли сообщества
RBAC_TO_COMMUNITY_ROLE = {v: k for k, v in COMMUNITY_ROLE_MAP.items()}
@staticmethod
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.has_permission(resource, operation) or 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
# Получаем роли пользователя в этом сообществе
community_follower = (
session.query(CommunityFollower)
.filter(CommunityFollower.author == author_id, CommunityFollower.community == community.id)
.one_or_none()
)
if not community_follower or not community_follower.roles:
# Пользователь не является членом сообщества или у него нет ролей
return False
# Преобразуем роли сообщества в RBAC роли
rbac_roles = []
community_roles = community_follower.get_roles()
for role in community_roles:
if role in ContextualPermissionCheck.COMMUNITY_ROLE_MAP:
rbac_role_id = ContextualPermissionCheck.COMMUNITY_ROLE_MAP[role]
rbac_roles.append(rbac_role_id)
if not rbac_roles:
return False
# Проверяем наличие разрешения для этих ролей
permission_id = f"{resource}:{operation}"
# Запрос на проверку разрешений для указанных ролей
has_permission = (
session.query(RolePermission)
.join(Role, Role.id == RolePermission.role)
.join(Permission, Permission.id == RolePermission.permission)
.filter(Role.id.in_(rbac_roles), Permission.id == permission_id)
.first()
is not None
)
return has_permission
@staticmethod
def get_user_community_roles(
session: Session, author_id: int, community_slug: str
) -> List[CommunityRole]:
"""
Получает список ролей пользователя в сообществе.
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 [CommunityRole.EDITOR] # Владелец имеет роль редактора по умолчанию
# Получаем роли пользователя в этом сообществе
community_follower = (
session.query(CommunityFollower)
.filter(CommunityFollower.author == author_id, CommunityFollower.community == community.id)
.one_or_none()
)
if not community_follower or not community_follower.roles:
return []
return community_follower.get_roles()
@staticmethod
def assign_role_to_user(
session: Session, author_id: int, community_slug: str, role: Union[CommunityRole, str]
) -> bool:
"""
Назначает роль пользователю в сообществе.
Args:
session: Сессия SQLAlchemy
author_id: ID автора/пользователя
community_slug: Slug сообщества
role: Роль для назначения (CommunityRole или строковое представление)
Returns:
bool: True если роль успешно назначена, иначе False
"""
# Преобразуем строковую роль в CommunityRole если нужно
if isinstance(role, str):
try:
role = CommunityRole(role)
except ValueError:
return False
# Получаем информацию о сообществе
community = session.query(Community).filter(Community.slug == community_slug).one_or_none()
if not community:
return False
# Проверяем существование связи автор-сообщество
community_follower = (
session.query(CommunityFollower)
.filter(CommunityFollower.author == author_id, CommunityFollower.community == community.id)
.one_or_none()
)
if not community_follower:
# Создаем новую запись CommunityFollower
community_follower = CommunityFollower(author=author_id, community=community.id)
session.add(community_follower)
# Назначаем роль
current_roles = community_follower.get_roles() if community_follower.roles else []
if role not in current_roles:
current_roles.append(role)
community_follower.set_roles(current_roles)
session.commit()
return True
@staticmethod
def revoke_role_from_user(
session: Session, author_id: int, community_slug: str, role: Union[CommunityRole, str]
) -> bool:
"""
Отзывает роль у пользователя в сообществе.
Args:
session: Сессия SQLAlchemy
author_id: ID автора/пользователя
community_slug: Slug сообщества
role: Роль для отзыва (CommunityRole или строковое представление)
Returns:
bool: True если роль успешно отозвана, иначе False
"""
# Преобразуем строковую роль в CommunityRole если нужно
if isinstance(role, str):
try:
role = CommunityRole(role)
except ValueError:
return False
# Получаем информацию о сообществе
community = session.query(Community).filter(Community.slug == community_slug).one_or_none()
if not community:
return False
# Проверяем существование связи автор-сообщество
community_follower = (
session.query(CommunityFollower)
.filter(CommunityFollower.author == author_id, CommunityFollower.community == community.id)
.one_or_none()
)
if not community_follower or not community_follower.roles:
return False
# Отзываем роль
current_roles = community_follower.get_roles()
if role in current_roles:
current_roles.remove(role)
community_follower.set_roles(current_roles)
session.commit()
return True

View File

@ -1,22 +1,34 @@
# -*- coding: utf-8 -*-
import re
from datetime import datetime, timezone
from urllib.parse import quote_plus
import time
import traceback
from utils.logger import root_logger as logger
from graphql.type import GraphQLResolveInfo
# import asyncio # Убираем, так как резолвер будет синхронным
from auth.authenticate import login_required
from auth.credentials import AuthCredentials
from auth.decorators import admin_auth_required
from auth.email import send_auth_email
from auth.exceptions import InvalidPassword, InvalidToken, ObjectNotExist, Unauthorized
from auth.exceptions import InvalidToken, ObjectNotExist
from auth.identity import Identity, Password
from auth.jwtcodec import JWTCodec
from auth.tokenstorage import TokenStorage
from orm import Role, User
from auth.orm import Author, Role
from services.db import local_session
from services.schema import mutation, query
from settings import SESSION_TOKEN_HEADER
from settings import (
SESSION_TOKEN_HEADER,
SESSION_COOKIE_NAME,
SESSION_COOKIE_SECURE,
SESSION_COOKIE_SAMESITE,
SESSION_COOKIE_MAX_AGE,
SESSION_COOKIE_HTTPONLY,
)
from utils.generate_slug import generate_unique_slug
from graphql.error import GraphQLError
from math import ceil
from sqlalchemy import or_
@mutation.field("getSession")
@ -26,129 +38,138 @@ async def get_current_user(_, info):
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)
author = session.query(Author).where(Author.id == auth.author_id).one()
author.last_seen = int(time.time())
session.commit()
return {"token": token, "user": user}
return {"token": token, "author": author}
@mutation.field("confirmEmail")
async def confirm_email(_, info, token):
"""confirm owning email address"""
try:
print("[resolvers.auth] confirm email by token")
logger.info("[auth] confirmEmail: Начало подтверждения email по токену.")
payload = JWTCodec.decode(token)
user_id = payload.user_id
# Если TokenStorage.get асинхронный, это нужно будет переделать или вызывать синхронно
# Для теста пока оставим, но это потенциальная точка отказа в синхронном резолвере
await TokenStorage.get(f"{user_id}-{payload.username}-{token}")
with local_session() as session:
user = session.query(User).where(User.id == user_id).first()
user = session.query(Author).where(Author.id == user_id).first()
if not user:
logger.warning(f"[auth] confirmEmail: Пользователь с ID {user_id} не найден.")
return {"success": False, "error": "Пользователь не найден"}
# Если TokenStorage.create_session асинхронный...
session_token = await TokenStorage.create_session(user)
user.emailConfirmed = True
user.lastSeen = datetime.now(tz=timezone.utc)
user.email_verified = True
user.last_seen = int(time.time())
session.add(user)
session.commit()
return {"token": session_token, "user": user}
logger.info(f"[auth] confirmEmail: Email для пользователя {user_id} успешно подтвержден.")
return {"success": True, "token": session_token, "author": user, "error": None}
except InvalidToken as e:
raise InvalidToken(e.message)
logger.warning(f"[auth] confirmEmail: Невалидный токен - {e.message}")
return {"success": False, "token": None, "author": None, "error": f"Невалидный токен: {e.message}"}
except Exception as e:
print(e) # FIXME: debug only
return {"error": "email is not confirmed"}
logger.error(f"[auth] confirmEmail: Общая ошибка - {str(e)}\n{traceback.format_exc()}")
return {
"success": False,
"token": None,
"author": None,
"error": f"Ошибка подтверждения email: {str(e)}",
}
def create_user(user_dict):
user = User(**user_dict)
user = Author(**user_dict)
with local_session() as session:
user.roles.append(session.query(Role).first())
# Добавляем пользователя в БД
session.add(user)
session.flush() # Получаем ID пользователя
# Получаем или создаём стандартную роль "reader"
reader_role = session.query(Role).filter(Role.id == "reader").first()
if not reader_role:
reader_role = Role(id="reader", name="Читатель")
session.add(reader_role)
session.flush()
# Получаем основное сообщество
from orm.community import Community
main_community = session.query(Community).filter(Community.id == 1).first()
if not main_community:
main_community = Community(
id=1,
name="Discours",
slug="discours",
desc="Cообщество Discours",
created_by=user.id,
)
session.add(main_community)
session.flush()
# Создаём связь автор-роль-сообщество
from auth.orm import AuthorRole
author_role = AuthorRole(author=user.id, role=reader_role.id, community=main_community.id)
session.add(author_role)
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"""
logger.info(f"[auth] registerUser: Попытка регистрации для {email}")
with local_session() as session:
user = session.query(User).filter(User.email == email).first()
user = session.query(Author).filter(Author.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,
logger.warning(f"[auth] registerUser: Пользователь {email} уже существует.")
# raise Unauthorized("User already exist") # Это вызовет ошибку GraphQL, но не "cannot return null"
return {"success": False, "token": None, "author": None, "error": "Пользователь уже существует"}
slug = generate_unique_slug(name if name else email.split("@")[0])
user_dict = {
"email": email,
"username": email,
"name": name if name else email.split("@")[0],
"slug": slug,
}
if password:
user_dict["password"] = Password.encode(password)
new_user = create_user(user_dict)
# Предполагается, что auth_send_link вернет объект Author или вызовет исключение
# Для AuthResult нам также нужен токен и статус.
# После регистрации обычно либо сразу логинят, либо просто сообщают об успехе.
# Сейчас auth_send_link используется, что не логично для AuthResult.
# Вернем успешную регистрацию без токена, предполагая, что пользователь должен будет залогиниться или подтвердить email.
# Попытка отправить ссылку для подтверждения email
try:
# Если auth_send_link асинхронный...
await auth_send_link(_, _info, email)
logger.info(
f"[auth] registerUser: Пользователь {email} зарегистрирован, ссылка для подтверждения отправлена."
)
return {
"success": True,
"token": None,
"author": new_user,
"error": "Требуется подтверждение email.",
}
except Exception as e:
logger.error(f"[auth] registerUser: Ошибка при отправке ссылки подтверждения для {email}: {str(e)}")
return {
"success": True,
"token": None,
"author": new_user,
"error": f"Пользователь зарегистрирован, но произошла ошибка при отправке ссылки подтверждения: {str(e)}",
}
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")
@ -156,53 +177,168 @@ async def auth_send_link(_, _info, email, lang="ru", template="email_confirmatio
email = email.lower()
"""send link with confirm code to email"""
with local_session() as session:
user = session.query(User).filter(User.email == email).first()
user = session.query(Author).filter(Author.email == email).first()
if not user:
raise ObjectNotExist("User not found")
else:
# Если TokenStorage.create_onetime асинхронный...
token = await TokenStorage.create_onetime(user)
# Если send_auth_email асинхронный...
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
@mutation.field("login")
async def login_mutation(_, info, email: str, password: str):
"""
Авторизация пользователя с помощью email и пароля.
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"}
Args:
info: Контекст GraphQL запроса
email: Email пользователя
password: Пароль пользователя
else:
# sign in using password
if not orm_user.emailConfirmed:
# not an error, warns users
return {"error": "please, confirm email"}
else:
Returns:
AuthResult с данными пользователя и токеном или сообщением об ошибке
"""
logger.info(f"[auth] login: Попытка входа для {email}")
# Гарантируем, что всегда возвращаем непустой объект AuthResult
default_response = {"success": False, "token": None, "author": None, "error": "Неизвестная ошибка"}
try:
# Нормализуем email
email = email.lower()
# Получаем пользователя из базы
with local_session() as session:
author = session.query(Author).filter(Author.email == email).first()
if not author:
logger.warning(f"[auth] login: Пользователь {email} не найден")
return {
"success": False,
"token": None,
"author": None,
"error": "Пользователь с таким email не найден",
}
# Логируем информацию о найденном авторе
logger.info(
f"[auth] login: Найден автор {email}, id={author.id}, имя={author.name}, пароль есть: {bool(author.password)}"
)
# Проверяем пароль
logger.info(f"[auth] login: НАЧАЛО ПРОВЕРКИ ПАРОЛЯ для {email}")
verify_result = Identity.password(author, password)
logger.info(
f"[auth] login: РЕЗУЛЬТАТ ПРОВЕРКИ ПАРОЛЯ: {verify_result if isinstance(verify_result, dict) else 'успешно'}"
)
if isinstance(verify_result, dict) and verify_result.get("error"):
logger.warning(f"[auth] login: Неверный пароль для {email}: {verify_result.get('error')}")
return {
"success": False,
"token": None,
"author": None,
"error": verify_result.get("error", "Ошибка авторизации"),
}
# Получаем правильный объект автора - результат verify_result
valid_author = verify_result if not isinstance(verify_result, dict) else author
# Создаем токен через правильную функцию вместо прямого кодирования
try:
# Убедимся, что у автора есть нужные поля для создания токена
if (
not hasattr(valid_author, "id")
or not hasattr(valid_author, "username")
and not hasattr(valid_author, "email")
):
logger.error(
f"[auth] login: Объект автора не содержит необходимых атрибутов: {valid_author}"
)
return {
"success": False,
"token": None,
"author": None,
"error": "Внутренняя ошибка: некорректный объект автора",
}
# Создаем сессионный токен
logger.info(f"[auth] login: СОЗДАНИЕ ТОКЕНА для {email}, id={valid_author.id}")
token = await TokenStorage.create_session(valid_author)
logger.info(f"[auth] login: токен успешно создан, длина: {len(token) if token else 0}")
# Обновляем время последнего входа
valid_author.last_seen = int(time.time())
session.commit()
# Устанавливаем httponly cookie с помощью GraphQLExtensionsMiddleware
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"}
# Используем extensions для установки cookie
if hasattr(info.context, "extensions") and hasattr(
info.context.extensions, "set_cookie"
):
logger.info("[auth] login: Устанавливаем httponly cookie через extensions")
info.context.extensions.set_cookie(
SESSION_COOKIE_NAME,
token,
httponly=SESSION_COOKIE_HTTPONLY,
secure=SESSION_COOKIE_SECURE,
samesite=SESSION_COOKIE_SAMESITE,
max_age=SESSION_COOKIE_MAX_AGE,
)
elif hasattr(info.context, "response") and hasattr(info.context.response, "set_cookie"):
logger.info("[auth] login: Устанавливаем httponly cookie через response")
info.context.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,
)
else:
logger.warning(
"[auth] login: Невозможно установить cookie - объекты extensions/response недоступны"
)
except Exception as e:
# В случае ошибки при установке cookie просто логируем, но продолжаем авторизацию
logger.error(f"[auth] login: Ошибка при установке cookie: {str(e)}")
logger.debug(traceback.format_exc())
# Возвращаем успешный результат
logger.info(f"[auth] login: Успешный вход для {email}")
result = {"success": True, "token": token, "author": valid_author, "error": None}
logger.info(
f"[auth] login: Возвращаемый результат: {{success: {result['success']}, token_length: {len(token) if token else 0}}}"
)
return result
except Exception as token_error:
logger.error(f"[auth] login: Ошибка при создании токена: {str(token_error)}")
logger.error(traceback.format_exc())
return {
"success": False,
"token": None,
"author": None,
"error": f"Ошибка авторизации: {str(token_error)}",
}
except Exception as e:
logger.error(f"[auth] login: Ошибка при авторизации {email}: {str(e)}")
logger.error(traceback.format_exc())
return {"success": False, "token": None, "author": None, "error": str(e)}
# Если по какой-то причине мы дошли до этой точки, вернем безопасный результат
return default_response
@query.field("signOut")
@login_required
async def sign_out(_, info: GraphQLResolveInfo):
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER, "")
# Если TokenStorage.revoke асинхронный...
status = await TokenStorage.revoke(token)
return status
@ -211,5 +347,117 @@ async def sign_out(_, info: GraphQLResolveInfo):
async def is_email_used(_, _info, email):
email = email.lower()
with local_session() as session:
user = session.query(User).filter(User.email == email).first()
user = session.query(Author).filter(Author.email == email).first()
return user is not None
@query.field("adminGetUsers")
@admin_auth_required
async def admin_get_users(_, info, limit=10, offset=0, search=None):
"""
Получает список пользователей для админ-панели с поддержкой пагинации и поиска
Args:
info: Контекст GraphQL запроса
limit: Максимальное количество записей для получения
offset: Смещение в списке результатов
search: Строка поиска (по email, имени или ID)
Returns:
Пагинированный список пользователей
"""
try:
# Нормализуем параметры
limit = max(1, min(100, limit or 10)) # Ограничиваем количество записей от 1 до 100
offset = max(0, offset or 0) # Смещение не может быть отрицательным
with local_session() as session:
# Базовый запрос
query = session.query(Author)
# Применяем фильтр поиска, если указан
if search and search.strip():
search_term = f"%{search.strip().lower()}%"
query = query.filter(
or_(
Author.email.ilike(search_term),
Author.name.ilike(search_term),
Author.id.cast(str).ilike(search_term),
)
)
# Получаем общее количество записей
total_count = query.count()
# Вычисляем информацию о пагинации
per_page = limit
total_pages = ceil(total_count / per_page)
current_page = (offset // per_page) + 1 if per_page > 0 else 1
# Применяем пагинацию
users = query.order_by(Author.id).offset(offset).limit(limit).all()
# Преобразуем в формат для API
result = {
"users": [
{
"id": user.id,
"email": user.email,
"name": user.name,
"slug": user.slug,
"roles": [role.role for role in user.roles]
if hasattr(user, "roles") and user.roles
else [],
"created_at": user.created_at,
"last_seen": user.last_seen,
"muted": user.muted or False,
"is_active": not user.blocked if hasattr(user, "blocked") else True,
}
for user in users
],
"total": total_count,
"page": current_page,
"perPage": per_page,
"totalPages": total_pages,
}
return result
except Exception as e:
logger.error(f"Ошибка при получении списка пользователей: {str(e)}")
logger.error(traceback.format_exc())
raise GraphQLError(f"Не удалось получить список пользователей: {str(e)}")
@query.field("adminGetRoles")
@admin_auth_required
async def admin_get_roles(_, info):
"""
Получает список всех ролей для админ-панели
Args:
info: Контекст GraphQL запроса
Returns:
Список ролей с их описаниями
"""
try:
with local_session() as session:
# Получаем все роли из базы данных
roles = session.query(Role).all()
# Преобразуем их в формат для API
result = [
{
"id": role.id,
"name": role.name,
"description": f"Роль с правами: {', '.join(p.resource + ':' + p.operation for p in role.permissions)}"
if role.permissions
else "Роль без особых прав",
}
for role in roles
]
return result
except Exception as e:
logger.error(f"Ошибка при получении списка ролей: {str(e)}")
raise GraphQLError(f"Не удалось получить список ролей: {str(e)}")

228
auth/sessions.py Normal file
View File

@ -0,0 +1,228 @@
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
from pydantic import BaseModel
from services.redis import redis
from auth.jwtcodec import JWTCodec, TokenPayload
from settings import SESSION_TOKEN_LIFE_SPAN
from utils.logger import root_logger as logger
class SessionData(BaseModel):
"""Модель данных сессии"""
user_id: str
username: str
created_at: datetime
expires_at: datetime
device_info: Optional[dict] = None
class SessionManager:
"""
Менеджер сессий в Redis.
Управляет созданием, проверкой и отзывом сессий пользователей.
"""
@staticmethod
def _make_session_key(user_id: str, token: str) -> str:
"""Формирует ключ сессии в Redis"""
return f"session:{user_id}:{token}"
@staticmethod
def _make_user_sessions_key(user_id: str) -> str:
"""Формирует ключ для списка сессий пользователя в Redis"""
return f"user_sessions:{user_id}"
@classmethod
async def create_session(cls, user_id: str, username: str, device_info: dict = None) -> str:
"""
Создает новую сессию для пользователя.
Args:
user_id: ID пользователя
username: Имя пользователя/логин
device_info: Информация об устройстве (опционально)
Returns:
str: Токен сессии
"""
try:
# Создаем JWT токен
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=SESSION_TOKEN_LIFE_SPAN)
session_token = JWTCodec.encode({"id": user_id, "email": username}, exp)
# Создаем данные сессии
session_data = SessionData(
user_id=user_id,
username=username,
created_at=datetime.now(tz=timezone.utc),
expires_at=exp,
device_info=device_info,
)
# Ключи в Redis
session_key = cls._make_session_key(user_id, session_token)
user_sessions_key = cls._make_user_sessions_key(user_id)
# Сохраняем в Redis
pipe = redis.pipeline()
await pipe.hset(session_key, mapping=session_data.dict())
await pipe.expire(session_key, SESSION_TOKEN_LIFE_SPAN)
await pipe.sadd(user_sessions_key, session_token)
await pipe.expire(user_sessions_key, SESSION_TOKEN_LIFE_SPAN)
await pipe.execute()
return session_token
except Exception as e:
logger.error(f"[SessionManager.create_session] Ошибка: {str(e)}")
raise
@classmethod
async def verify_session(cls, token: str) -> Optional[TokenPayload]:
"""
Проверяет валидность сессии.
Args:
token: Токен сессии
Returns:
TokenPayload: Данные токена или None, если токен недействителен
"""
try:
# Декодируем JWT
payload = JWTCodec.decode(token)
# Формируем ключ сессии
session_key = cls._make_session_key(payload.user_id, token)
# Проверяем существование сессии в Redis
session_exists = await redis.exists(session_key)
if not session_exists:
logger.debug(f"[SessionManager.verify_session] Сессия не найдена: {payload.user_id}")
return None
return payload
except Exception as e:
logger.error(f"[SessionManager.verify_session] Ошибка: {str(e)}")
return None
@classmethod
async def get_session_data(cls, user_id: str, token: str) -> Optional[Dict[str, Any]]:
"""
Получает данные сессии.
Args:
user_id: ID пользователя
token: Токен сессии
Returns:
dict: Данные сессии или None, если сессия не найдена
"""
try:
session_key = cls._make_session_key(user_id, token)
session_data = await redis.hgetall(session_key)
return session_data if session_data else None
except Exception as e:
logger.error(f"[SessionManager.get_session_data] Ошибка: {str(e)}")
return None
@classmethod
async def revoke_session(cls, user_id: str, token: str) -> bool:
"""
Отзывает конкретную сессию.
Args:
user_id: ID пользователя
token: Токен сессии
Returns:
bool: True, если сессия успешно отозвана
"""
try:
session_key = cls._make_session_key(user_id, token)
user_sessions_key = cls._make_user_sessions_key(user_id)
# Удаляем сессию и запись из списка сессий пользователя
pipe = redis.pipeline()
await pipe.delete(session_key)
await pipe.srem(user_sessions_key, token)
await pipe.execute()
return True
except Exception as e:
logger.error(f"[SessionManager.revoke_session] Ошибка: {str(e)}")
return False
@classmethod
async def revoke_all_sessions(cls, user_id: str) -> bool:
"""
Отзывает все сессии пользователя.
Args:
user_id: ID пользователя
Returns:
bool: True, если все сессии успешно отозваны
"""
try:
user_sessions_key = cls._make_user_sessions_key(user_id)
# Получаем все токены пользователя
tokens = await redis.smembers(user_sessions_key)
if not tokens:
return True
# Создаем команды для удаления всех сессий
pipe = redis.pipeline()
# Формируем список ключей для удаления
for token in tokens:
session_key = cls._make_session_key(user_id, token)
await pipe.delete(session_key)
# Удаляем список сессий
await pipe.delete(user_sessions_key)
await pipe.execute()
return True
except Exception as e:
logger.error(f"[SessionManager.revoke_all_sessions] Ошибка: {str(e)}")
return False
@classmethod
async def refresh_session(cls, user_id: str, old_token: str, device_info: dict = None) -> Optional[str]:
"""
Обновляет сессию пользователя, заменяя старый токен новым.
Args:
user_id: ID пользователя
old_token: Старый токен сессии
device_info: Информация об устройстве (опционально)
Returns:
str: Новый токен сессии или None в случае ошибки
"""
try:
# Получаем данные старой сессии
old_session_key = cls._make_session_key(user_id, old_token)
old_session_data = await redis.hgetall(old_session_key)
if not old_session_data:
logger.warning(f"[SessionManager.refresh_session] Сессия не найдена: {user_id}")
return None
# Используем старые данные устройства, если новые не предоставлены
if not device_info and "device_info" in old_session_data:
device_info = old_session_data.get("device_info")
# Создаем новую сессию
new_token = await cls.create_session(user_id, old_session_data.get("username", ""), device_info)
# Отзываем старую сессию
await cls.revoke_session(user_id, old_token)
return new_token
except Exception as e:
logger.error(f"[SessionManager.refresh_session] Ошибка: {str(e)}")
return None

View File

@ -1,73 +1,193 @@
from datetime import datetime, timedelta, timezone
import json
from typing import Dict, Any, Optional
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}")
from utils.logger import root_logger as logger
class TokenStorage:
"""
Хранилище токенов в Redis.
Обеспечивает создание, проверку и отзыв токенов.
"""
@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)
async def get(token_key: str) -> Optional[str]:
"""
Получает токен из хранилища.
Args:
token_key: Ключ токена
Returns:
str или None, если токен не найден
"""
logger.debug(f"[tokenstorage.get] Запрос токена: {token_key}")
return await redis.get(token_key)
@staticmethod
async def exists(token_key: str) -> bool:
"""
Проверяет наличие токена в хранилище.
Args:
token_key: Ключ токена
Returns:
bool: True, если токен существует
"""
return bool(await redis.execute("EXISTS", token_key))
@staticmethod
async def save_token(token_key: str, data: Dict[str, Any], life_span: int) -> bool:
"""
Сохраняет токен в хранилище с указанным временем жизни.
Args:
token_key: Ключ токена
data: Данные токена
life_span: Время жизни токена в секундах
Returns:
bool: True, если токен успешно сохранен
"""
try:
# Если данные не строка, преобразуем их в JSON
value = json.dumps(data) if isinstance(data, dict) else data
# Сохраняем токен и устанавливаем время жизни
await redis.set(token_key, value, ex=life_span)
return True
except Exception as e:
logger.error(f"[tokenstorage.save_token] Ошибка сохранения токена: {str(e)}")
return False
@staticmethod
async def create_onetime(user: AuthInput) -> str:
"""
Создает одноразовый токен для пользователя.
Args:
user: Объект пользователя
Returns:
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)
# Сохраняем токен в Redis
token_key = f"{user.id}-{user.username}-{one_time_token}"
await TokenStorage.save_token(token_key, "TRUE", life_span)
return one_time_token
@staticmethod
async def create_session(user: AuthInput) -> str:
"""
Создает сессионный токен для пользователя.
Args:
user: Объект пользователя
Returns:
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)
# Сохраняем токен в Redis
token_key = f"{user.id}-{user.username}-{session_token}"
user_sessions_key = f"user_sessions:{user.id}"
# Создаем данные сессии
session_data = {
"user_id": str(user.id),
"username": user.username,
"created_at": datetime.now(tz=timezone.utc).timestamp(),
"expires_at": exp.timestamp(),
}
# Сохраняем токен и добавляем его в список сессий пользователя
pipe = redis.pipeline()
await pipe.hmset(token_key, session_data)
await pipe.expire(token_key, life_span)
await pipe.sadd(user_sessions_key, session_token)
await pipe.expire(user_sessions_key, life_span)
await pipe.execute()
return session_token
@staticmethod
async def revoke(token: str) -> bool:
payload = None
"""
Отзывает токен.
Args:
token: Токен для отзыва
Returns:
bool: True, если токен успешно отозван
"""
try:
print("[auth.tokenstorage] revoke token")
logger.debug("[tokenstorage.revoke] Отзыв токена")
# Декодируем токен
payload = JWTCodec.decode(token)
except: # noqa
pass
else:
await redis.execute("DEL", f"{payload.user_id}-{payload.username}-{token}")
return True
if not payload:
logger.warning("[tokenstorage.revoke] Невозможно декодировать токен")
return False
# Формируем ключи
token_key = f"{payload.user_id}-{payload.username}-{token}"
user_sessions_key = f"user_sessions:{payload.user_id}"
# Удаляем токен и запись из списка сессий пользователя
pipe = redis.pipeline()
await pipe.delete(token_key)
await pipe.srem(user_sessions_key, token)
await pipe.execute()
return True
except Exception as e:
logger.error(f"[tokenstorage.revoke] Ошибка отзыва токена: {str(e)}")
return False
@staticmethod
async def revoke_all(user: AuthInput):
tokens = await redis.execute("KEYS", f"{user.id}-*")
await redis.execute("DEL", *tokens)
async def revoke_all(user: AuthInput) -> bool:
"""
Отзывает все токены пользователя.
Args:
user: Объект пользователя
Returns:
bool: True, если все токены успешно отозваны
"""
try:
# Формируем ключи
user_sessions_key = f"user_sessions:{user.id}"
# Получаем все токены пользователя
tokens = await redis.smembers(user_sessions_key)
if not tokens:
return True
# Формируем список ключей для удаления
keys_to_delete = [f"{user.id}-{user.username}-{token}" for token in tokens]
keys_to_delete.append(user_sessions_key)
# Удаляем все токены и список сессий
await redis.delete(*keys_to_delete)
return True
except Exception as e:
logger.error(f"[tokenstorage.revoke_all] Ошибка отзыва всех токенов: {str(e)}")
return False

93
biome.json Normal file
View File

@ -0,0 +1,93 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"files": {
"include": ["*.tsx", "*.ts", "*.js", "*.json"],
"ignore": ["./dist", "./node_modules", ".husky", "docs", "gen", "*.gen.ts", "*.d.ts"]
},
"vcs": {
"enabled": true,
"defaultBranch": "dev",
"useIgnoreFile": true,
"clientKind": "git"
},
"organizeImports": {
"enabled": true,
"ignore": ["./gen"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 108,
"ignore": ["./src/graphql/schema", "./gen"]
},
"javascript": {
"formatter": {
"enabled": true,
"semicolons": "asNeeded",
"quoteStyle": "single",
"jsxQuoteStyle": "double",
"arrowParentheses": "always",
"trailingCommas": "none"
}
},
"linter": {
"enabled": true,
"ignore": ["*.scss", "*.md", ".DS_Store", "*.svg", "*.d.ts"],
"rules": {
"all": true,
"complexity": {
"noForEach": "off",
"useOptionalChain": "warn",
"useLiteralKeys": "off",
"noExcessiveCognitiveComplexity": "off",
"useSimplifiedLogicExpression": "off"
},
"correctness": {
"useHookAtTopLevel": "off",
"useImportExtensions": "off",
"noUndeclaredDependencies": "off",
"noNodejsModules": {
"level": "off"
}
},
"a11y": {
"useHeadingContent": "off",
"useKeyWithClickEvents": "off",
"useKeyWithMouseEvents": "off",
"useAnchorContent": "off",
"useValidAnchor": "off",
"useMediaCaption": "off",
"useAltText": "off",
"useButtonType": "off",
"noRedundantAlt": "off",
"noSvgWithoutTitle": "off",
"noLabelWithoutControl": "off"
},
"nursery": {
"useImportRestrictions": "off"
},
"performance": {
"noBarrelFile": "off"
},
"style": {
"noNonNullAssertion": "off",
"noNamespaceImport": "warn",
"noUselessElse": "off",
"useBlockStatements": "off",
"noImplicitBoolean": "off",
"useNamingConvention": "off",
"useImportType": "off",
"noDefaultExport": "off",
"useFilenamingConvention": "off",
"useExplicitLengthCheck": "off",
"useNodejsImportProtocol": "off"
},
"suspicious": {
"noConsole": "off",
"noConsoleLog": "off",
"noAssignInExpressions": "off"
}
}
}
}

14
cache/cache.py vendored
View File

@ -29,12 +29,12 @@ for new cache operations.
import asyncio
import json
from typing import Any, Dict, List, Optional, Union
from typing import Any, List, Optional
import orjson
from sqlalchemy import and_, join, select
from orm.author import Author, AuthorFollower
from auth.orm import Author, AuthorFollower
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic, TopicFollower
from services.db import local_session
@ -78,7 +78,7 @@ async def cache_topic(topic: dict):
async def cache_author(author: dict):
payload = json.dumps(author, cls=CustomJSONEncoder)
await asyncio.gather(
redis.execute("SET", f"author:user:{author['user'].strip()}", str(author["id"])),
redis.execute("SET", f"author:slug:{author['slug'].strip()}", str(author["id"])),
redis.execute("SET", f"author:id:{author['id']}", payload),
)
@ -359,7 +359,13 @@ 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

17
cache/precache.py vendored
View File

@ -4,7 +4,7 @@ import json
from sqlalchemy import and_, join, select
from cache.cache import cache_author, cache_topic
from orm.author import Author, AuthorFollower
from auth.orm import Author, AuthorFollower
from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic
from orm.topic import Topic, TopicFollower
from resolvers.stat import get_with_stat
@ -29,7 +29,9 @@ async def precache_authors_followers(author_id, session):
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)
follows_shouts_query = select(ShoutReactionsFollower.shout).where(
ShoutReactionsFollower.follower == author_id
)
follows_topics = {row[0] for row in session.execute(follows_topics_query) if row[0]}
follows_authors = {row[0] for row in session.execute(follows_authors_query) if row[0]}
@ -111,17 +113,18 @@ async def precache_data():
logger.info(f"{len(topics)} topics and their followings precached")
# authors
authors = get_with_stat(select(Author).where(Author.user.is_not(None)))
logger.info(f"{len(authors)} authors found in database")
authors = get_with_stat(select(Author))
# logger.info(f"{len(authors)} authors found in database")
for author in authors:
if isinstance(author, Author):
profile = author.dict()
author_id = profile.get("id")
user_id = profile.get("user", "").strip()
if author_id and user_id:
# user_id = profile.get("user", "").strip()
if author_id: # and user_id:
await cache_author(profile)
await asyncio.gather(
precache_authors_followers(author_id, session), precache_authors_follows(author_id, session)
precache_authors_followers(author_id, session),
precache_authors_follows(author_id, session),
)
else:
logger.error(f"fail caching {author}")

View File

@ -28,7 +28,6 @@ class CacheRevalidationManager:
"""Запуск фонового воркера для ревалидации кэша."""
# Проверяем, что у нас есть соединение с 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")

2
cache/triggers.py vendored
View File

@ -1,7 +1,7 @@
from sqlalchemy import event
from cache.revalidator import revalidation_manager
from orm.author import Author, AuthorFollower
from auth.orm import Author, AuthorFollower
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower
from orm.topic import Topic, TopicFollower

34
docs/README.md Normal file
View File

@ -0,0 +1,34 @@
# Документация проекта
## Модули
### Аутентификация и авторизация
Подробная документация: [auth.md](auth.md)
Основные возможности:
- Гибкая система аутентификации с использованием локальной БД и Redis
- Система ролей и разрешений (RBAC)
- OAuth интеграция (Google, Facebook, GitHub)
- Защита от брутфорс атак
- Управление сессиями через Redis
- Мультиязычные email уведомления
- Страница авторизации для админ-панели
Конфигурация:
```python
# settings.py
JWT_SECRET_KEY = "your-secret-key" # секретный ключ для JWT токенов
SESSION_TOKEN_LIFE_SPAN = 60 * 60 * 24 * 30 # время жизни сессии (30 дней)
```
### Административный интерфейс
Основные возможности:
- Защищенный доступ только для авторизованных пользователей с ролью admin
- Автоматическая проверка прав пользователя
- Отдельная страница входа для неавторизованных пользователей
- Проверка доступа по email или правам в системе RBAC
Маршруты:
- `/admin` - административная панель с проверкой прав доступа

757
docs/auth.md Normal file
View File

@ -0,0 +1,757 @@
# Модуль аутентификации и авторизации
## Общее описание
Модуль реализует полноценную систему аутентификации с использованием локальной БД и Redis.
## Компоненты
### Модели данных
#### Author (orm.py)
- Основная модель пользователя с расширенным функционалом аутентификации
- Поддерживает:
- Локальную аутентификацию по email/телефону
- Систему ролей и разрешений (RBAC)
- Блокировку аккаунта при множественных неудачных попытках входа
- Верификацию email/телефона
#### Role и Permission (orm.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` - отправка ссылки для входа
### Запросы
- `signOut` - выход из системы
- `isEmailUsed` - проверка использования email
## Безопасность
### Хеширование паролей (identity.py)
- Использование bcrypt с SHA-256
- Настраиваемое количество раундов
- Защита от timing-атак
### Защита от брутфорса
- Блокировка аккаунта после 5 неудачных попыток
- Время блокировки: 30 минут
- Сброс счетчика после успешного входа
## Конфигурация
Основные настройки в 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>
<h1>Панель администратора</h1>
{/* Контент админки */}
</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.orm.Author)
if not user.has_permission('articles', '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(_, 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 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),
is_active=True,
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 user.has_permission('articles', 'edit')
assert not 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

@ -20,15 +20,6 @@
- Настраиваемое время жизни кеша (TTL)
- Возможность ручной инвалидации кеша для конкретных функций и аргументов
## Webhooks
- Автоматическая регистрация вебхука для события user.login
- Предотвращение создания дублирующихся вебхуков
- Автоматическая очистка устаревших вебхуков
- Поддержка авторизации вебхуков через WEBHOOK_SECRET
- Обработка ошибок при операциях с вебхуками
- Динамическое определение endpoint'а на основе окружения
## CORS Configuration
- Поддерживаемые методы: GET, POST, OPTIONS

9
env.d.ts vendored Normal file
View File

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

20
index.html Normal file
View File

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

271
main.py
View File

@ -1,16 +1,19 @@
import asyncio
import os
import sys
from importlib import import_module
from os.path import exists
from os.path import exists, join
from ariadne import load_schema_from_path, make_executable_schema
from ariadne.asgi import GraphQL
from ariadne.asgi.handlers import GraphQLHTTPHandler
from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.middleware import Middleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import Route
from starlette.responses import FileResponse, JSONResponse, HTMLResponse, RedirectResponse
from starlette.routing import Route, Mount
from starlette.staticfiles import StaticFiles
from cache.precache import precache_data
from cache.revalidator import revalidation_manager
@ -18,78 +21,220 @@ from services.exception import ExceptionHandlerMiddleware
from services.redis import redis
from services.schema import create_all_tables, resolvers
from services.search import search_service
from services.viewed import ViewedStorage
from services.webhook import WebhookEndpoint, create_webhook_endpoint
from settings import DEV_SERVER_PID_FILE_NAME, MODE
from settings import DEV_SERVER_PID_FILE_NAME, MODE, ADMIN_EMAILS
from utils.logger import root_logger as logger
from auth.internal import InternalAuthentication
from auth import routes as auth_routes # Импортируем маршруты авторизации
from auth.middleware import (
AuthorizationMiddleware,
GraphQLExtensionsMiddleware,
) # Импортируем middleware для авторизации
import_module("resolvers")
import_module("auth.resolvers")
# Создаем схему GraphQL
schema = make_executable_schema(load_schema_from_path("schema/"), resolvers)
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")
# Пути к клиентским файлам
CLIENT_DIR = join(os.path.dirname(__file__), "client")
DIST_DIR = join(os.path.dirname(__file__), "dist") # Директория для собранных файлов
INDEX_HTML = join(os.path.dirname(__file__), "index.html")
async def lifespan(_app):
try:
create_all_tables()
await asyncio.gather(
redis.connect(),
precache_data(),
ViewedStorage.init(),
create_webhook_endpoint(),
search_service.info(),
start(),
revalidation_manager.start(),
)
yield
finally:
tasks = [redis.disconnect(), ViewedStorage.stop(), revalidation_manager.stop()]
await asyncio.gather(*tasks, return_exceptions=True)
async def index_handler(request: Request):
"""
Раздача основного HTML файла
"""
return FileResponse(INDEX_HTML)
# Создаем экземпляр GraphQL
graphql_app = GraphQL(schema, debug=True)
# GraphQL API
class CustomGraphQLHTTPHandler(GraphQLHTTPHandler):
"""
Кастомный GraphQL HTTP обработчик, который добавляет объект response в контекст
"""
async def get_context_for_request(self, request: Request, data: dict) -> dict:
"""
Переопределяем метод для добавления объекта response и extensions в контекст
"""
context = await super().get_context_for_request(request, data)
# Создаем объект ответа, который будем использовать для установки cookie
response = JSONResponse({})
context["response"] = response
# Добавляем extensions в контекст
if "extensions" not in context:
context["extensions"] = GraphQLExtensionsMiddleware()
return context
# Оборачиваем GraphQL-обработчик для лучшей обработки ошибок
async def graphql_handler(request: Request):
if request.method not in ["GET", "POST"]:
return JSONResponse({"error": "Method Not Allowed"}, status_code=405)
graphql_app = GraphQL(schema, debug=MODE == "development", http_handler=CustomGraphQLHTTPHandler())
try:
result = await graphql_app.handle_request(request)
if isinstance(result, Response):
return result
async def graphql_handler(request):
"""Обработчик GraphQL запросов"""
# Проверяем заголовок Content-Type
content_type = request.headers.get("content-type", "")
if not content_type.startswith("application/json") and "application/json" in request.headers.get(
"accept", ""
):
# Если не application/json, но клиент принимает JSON
request._headers["content-type"] = "application/json"
# Обрабатываем GraphQL запрос
result = await graphql_app.handle_request(request)
# Если result - это ответ от сервера, возвращаем его как есть
if hasattr(result, "body"):
return result
# Если результат - это словарь, значит нужно его сконвертировать в JSONResponse
if isinstance(result, dict):
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)
return result
# Обновляем маршрут в Starlette
app = Starlette(
routes=[
Route("/", graphql_handler, methods=["GET", "POST"]),
Route("/new-author", WebhookEndpoint),
],
lifespan=lifespan,
debug=True,
async def admin_handler(request: Request):
"""
Обработчик для маршрута /admin с серверной проверкой прав доступа
"""
# Проверяем авторизован ли пользователь
if not request.user.is_authenticated:
# Если пользователь не авторизован, перенаправляем на страницу входа
return RedirectResponse(url="/login", status_code=303)
# Проверяем является ли пользователь администратором
auth = getattr(request, "auth", None)
is_admin = False
# Проверяем наличие объекта auth и метода is_admin
if auth:
try:
# Проверяем имеет ли пользователь права администратора
is_admin = auth.is_admin
except Exception as e:
logger.error(f"Ошибка при проверке прав администратора: {e}")
# Дополнительная проверка email (для случаев, когда нет метода is_admin)
admin_emails = ADMIN_EMAILS.split(",")
if not is_admin and hasattr(auth, "email") and auth.email in admin_emails:
is_admin = True
if is_admin:
# Если пользователь - администратор, возвращаем HTML-файл
return FileResponse(INDEX_HTML)
else:
# Для авторизованных пользователей без прав администратора показываем страницу с ошибкой доступа
return HTMLResponse(
"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Доступ запрещен</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 0; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f5f5f5; }
.error-container { max-width: 500px; padding: 30px; background-color: #fff; border-radius: 5px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); text-align: center; }
h1 { color: #e74c3c; margin-bottom: 20px; }
p { color: #333; margin-bottom: 20px; line-height: 1.5; }
.back-button { background-color: #3498db; color: #fff; border: none; padding: 10px 20px; border-radius: 3px; cursor: pointer; text-decoration: none; display: inline-block; }
.back-button:hover { background-color: #2980b9; }
</style>
</head>
<body>
<div class="error-container">
<h1>Доступ запрещен</h1>
<p>У вас нет прав для доступа к административной панели. Обратитесь к администратору системы для получения необходимых разрешений.</p>
<a href="/" class="back-button">Вернуться на главную</a>
</div>
</body>
</html>
""",
status_code=403
)
# Функция запуска сервера
async def start():
"""Запуск сервера и инициализация данных"""
logger.info(f"Запуск сервера в режиме: {MODE}")
# Создаем все таблицы в БД
create_all_tables()
# Запускаем предварительное кеширование данных
asyncio.create_task(precache_data())
# Запускаем задачу ревалидации кеша
asyncio.create_task(revalidation_manager.start())
# Выводим сообщение о запуске сервера и доступности API
logger.info("Сервер запущен и готов принимать запросы")
logger.info("GraphQL API доступно по адресу: /graphql")
logger.info("Админ-панель доступна по адресу: /admin")
# Функция остановки сервера
async def shutdown():
"""Остановка сервера и освобождение ресурсов"""
logger.info("Остановка сервера")
# Закрываем соединение с Redis
await redis.disconnect()
# Останавливаем поисковый сервис
search_service.close()
# Удаляем PID-файл, если он существует
if exists(DEV_SERVER_PID_FILE_NAME):
os.unlink(DEV_SERVER_PID_FILE_NAME)
# Добавляем маршруты статических файлов, если директория существует
routes = []
if exists(DIST_DIR):
# Добавляем маршруты для статических ресурсов, если директория dist существует
routes.append(Mount("/assets", app=StaticFiles(directory=join(DIST_DIR, "assets"))))
routes.append(Mount("/chunks", app=StaticFiles(directory=join(DIST_DIR, "chunks"))))
# Маршруты для API и веб-приложения
routes.extend(
[
Route("/graphql", graphql_handler, methods=["GET", "POST"]),
# Добавляем специальный маршрут для админ-панели с проверкой прав доступа
Route("/admin", admin_handler, methods=["GET"]),
# Маршрут для обработки всех остальных запросов - SPA
Route("/{path:path}", index_handler, methods=["GET"]),
Route("/", index_handler, methods=["GET"]),
]
)
app.add_middleware(ExceptionHandlerMiddleware)
if "dev" in sys.argv:
app.add_middleware(
CORSMiddleware,
allow_origins=["https://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Добавляем маршруты авторизации
routes.extend(auth_routes)
app = Starlette(
debug=MODE == "development",
routes=routes,
middleware=[
Middleware(ExceptionHandlerMiddleware),
Middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
allow_credentials=True,
),
# Добавляем middleware для обработки Authorization заголовка с Bearer токеном
Middleware(AuthorizationMiddleware),
# Добавляем middleware для аутентификации после обработки токенов
Middleware(AuthenticationMiddleware, backend=InternalAuthentication()),
],
on_startup=[start],
on_shutdown=[shutdown],
)

View File

@ -1,136 +0,0 @@
import time
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
from services.db import Base
# from sqlalchemy_utils import TSVectorType
class AuthorRating(Base):
"""
Рейтинг автора от другого автора.
Attributes:
rater (int): ID оценивающего автора
author (int): ID оцениваемого автора
plus (bool): Положительная/отрицательная оценка
"""
__tablename__ = "author_rating"
id = None # type: ignore
rater = Column(ForeignKey("author.id"), primary_key=True)
author = Column(ForeignKey("author.id"), primary_key=True)
plus = Column(Boolean)
# Определяем индексы
__table_args__ = (
# Индекс для быстрого поиска всех оценок конкретного автора
Index("idx_author_rating_author", "author"),
# Индекс для быстрого поиска всех оценок, оставленных конкретным автором
Index("idx_author_rating_rater", "rater"),
)
class AuthorFollower(Base):
"""
Подписка одного автора на другого.
Attributes:
follower (int): ID подписчика
author (int): ID автора, на которого подписываются
created_at (int): Время создания подписки
auto (bool): Признак автоматической подписки
"""
__tablename__ = "author_follower"
id = None # type: ignore
follower = Column(ForeignKey("author.id"), primary_key=True)
author = Column(ForeignKey("author.id"), primary_key=True)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
auto = Column(Boolean, nullable=False, default=False)
# Определяем индексы
__table_args__ = (
# Индекс для быстрого поиска всех подписчиков автора
Index("idx_author_follower_author", "author"),
# Индекс для быстрого поиска всех авторов, на которых подписан конкретный автор
Index("idx_author_follower_follower", "follower"),
)
class AuthorBookmark(Base):
"""
Закладка автора на публикацию.
Attributes:
author (int): ID автора
shout (int): ID публикации
"""
__tablename__ = "author_bookmark"
id = None # type: ignore
author = Column(ForeignKey("author.id"), primary_key=True)
shout = Column(ForeignKey("shout.id"), primary_key=True)
# Определяем индексы
__table_args__ = (
# Индекс для быстрого поиска всех закладок автора
Index("idx_author_bookmark_author", "author"),
# Индекс для быстрого поиска всех авторов, добавивших публикацию в закладки
Index("idx_author_bookmark_shout", "shout"),
)
class Author(Base):
"""
Модель автора в системе.
Attributes:
name (str): Отображаемое имя
slug (str): Уникальный строковый идентификатор
bio (str): Краткая биография/статус
about (str): Полное описание
pic (str): URL изображения профиля
links (dict): Ссылки на социальные сети и сайты
created_at (int): Время создания профиля
last_seen (int): Время последнего посещения
updated_at (int): Время последнего обновления
deleted_at (int): Время удаления (если профиль удален)
"""
__tablename__ = "author"
name = Column(String, nullable=True, comment="Display name")
slug = Column(String, unique=True, comment="Author's slug")
bio = Column(String, nullable=True, comment="Bio") # status description
about = Column(String, nullable=True, comment="About") # long and formatted
pic = Column(String, nullable=True, comment="Picture")
links = Column(JSON, nullable=True, comment="Links")
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
last_seen = Column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
deleted_at = Column(Integer, nullable=True, comment="Deleted at")
# search_vector = Column(
# TSVectorType("name", "slug", "bio", "about", regconfig="pg_catalog.russian")
# )
# Определяем индексы
__table_args__ = (
# Индекс для быстрого поиска по имени
Index("idx_author_name", "name"),
# Индекс для быстрого поиска по slug
Index("idx_author_slug", "slug"),
# Индекс для фильтрации неудаленных авторов
Index(
"idx_author_deleted_at", "deleted_at", postgresql_where=deleted_at.is_(None)
),
# Индекс для сортировки по времени создания (для новых авторов)
Index("idx_author_created_at", "created_at"),
# Индекс для сортировки по времени последнего посещения
Index("idx_author_last_seen", "last_seen"),
)

View File

@ -4,7 +4,7 @@ import time
from sqlalchemy import Column, ForeignKey, Integer, String, Text, distinct, func
from sqlalchemy.ext.hybrid import hybrid_property
from orm.author import Author
from auth.orm import Author
from services.db import Base
@ -66,7 +66,11 @@ class CommunityStats:
def shouts(self):
from orm.shout import Shout
return self.community.session.query(func.count(Shout.id)).filter(Shout.community == self.community.id).scalar()
return (
self.community.session.query(func.count(Shout.id))
.filter(Shout.community == self.community.id)
.scalar()
)
@property
def followers(self):
@ -84,7 +88,11 @@ 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()
)

View File

@ -3,7 +3,7 @@ import time
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from orm.author import Author
from auth.orm import Author
from orm.topic import Topic
from services.db import Base
@ -26,7 +26,6 @@ class DraftAuthor(Base):
caption = Column(String, nullable=True, default="")
class Draft(Base):
__tablename__ = "draft"
# required
@ -70,8 +69,8 @@ class Draft(Base):
primaryjoin="Draft.id == Shout.draft",
foreign_keys="Shout.draft",
uselist=False,
lazy="noload", # Не грузим по умолчанию, только через options
viewonly=True # Указываем, что это связь только для чтения
lazy="noload", # Не грузим по умолчанию, только через options
viewonly=True, # Указываем, что это связь только для чтения
)
def dict(self):
@ -101,5 +100,5 @@ class Draft(Base):
"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 [])]
"authors": [author.dict() for author in (self.authors or [])],
}

View File

@ -4,7 +4,7 @@ import time
from sqlalchemy import JSON, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from orm.author import Author
from auth.orm import Author
from services.db import Base

View File

@ -3,7 +3,7 @@ import time
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
from sqlalchemy.orm import relationship
from orm.author import Author
from auth.orm import Author
from orm.reaction import Reaction
from orm.topic import Topic
from services.db import Base

2236
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "publy-admin",
"version": "0.4.20",
"private": true,
"description": "admin panel",
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"lint": "biome check .",
"format": "biome format . --write",
"type-check": "tsc --noEmit",
"test": "vitest",
"build:auth": "vite build -c client/auth/vite.config.ts",
"watch:auth": "vite build -c client/auth/vite.config.ts --watch"
},
"dependencies": {
"@solidjs/router": "^0.15.0",
"@solid-primitives/storage": "^4.3.0",
"graphql": "^16.8.0",
"graphql-request": "^6.1.0",
"solid-js": "^1.9.6",
"solid-styled-components": "^0.28.0"
},
"devDependencies": {
"@types/node": "^22.15.0",
"@biomejs/biome": "^1.9.4",
"typescript": "^5.8.0",
"vite": "^6.3.0",
"vite-plugin-solid": "^2.11.0",
"terser": "^5.39.0"
},
"exports": {
".": {
"import": "./dist/auth.es.js",
"require": "./dist/auth.umd.js"
}
}
}

111
panel/App.tsx Normal file
View File

@ -0,0 +1,111 @@
import { Route, Router, RouteSectionProps } from '@solidjs/router'
import { Component, Suspense, lazy } from 'solid-js'
import { isAuthenticated } from './auth'
// Ленивая загрузка компонентов
const LoginPage = lazy(() => import('./login'))
const AdminPage = lazy(() => import('./admin'))
/**
* Компонент корневого шаблона приложения
* @param props - Свойства маршрута, включающие дочерние элементы
*/
const RootLayout: Component<RouteSectionProps> = (props) => {
return (
<div class="app-container">
{/* Здесь может быть общий хедер, футер или другие элементы */}
{props.children}
</div>
)
}
/**
* Компонент защиты маршрутов
* Проверяет авторизацию и либо показывает дочерние элементы,
* либо перенаправляет на страницу входа
*/
const RequireAuth: Component<RouteSectionProps> = (props) => {
const authed = isAuthenticated()
if (!authed) {
// Если не авторизован, перенаправляем на /login
window.location.href = '/login'
return (
<div class="loading-screen">
<div class="loading-spinner"></div>
<h2>Перенаправление на страницу входа...</h2>
</div>
)
}
return <>{props.children}</>
}
/**
* Компонент для публичных маршрутов с редиректом,
* если пользователь уже авторизован
*/
const PublicOnlyRoute: Component<RouteSectionProps> = (props) => {
// Если пользователь авторизован, перенаправляем на админ-панель
if (isAuthenticated()) {
window.location.href = '/admin'
return (
<div class="loading-screen">
<div class="loading-spinner"></div>
<h2>Перенаправление в админ-панель...</h2>
</div>
)
}
return <>{props.children}</>
}
/**
* Компонент перенаправления с корневого маршрута
*/
const RootRedirect: Component = () => {
const authenticated = isAuthenticated()
// Выполняем перенаправление сразу после рендеринга
setTimeout(() => {
window.location.href = authenticated ? '/admin' : '/login'
}, 100)
return (
<div class="loading-screen">
<div class="loading-spinner"></div>
<h2>Перенаправление...</h2>
</div>
)
}
/**
* Корневой компонент приложения с настроенными маршрутами
*/
const App: Component = () => {
return (
<Router root={RootLayout}>
<Suspense fallback={
<div class="loading-screen">
<div class="loading-spinner"></div>
<h2>Загрузка...</h2>
</div>
}>
{/* Корневой маршрут с перенаправлением */}
<Route path="/" component={RootRedirect} />
{/* Маршрут логина (только для неавторизованных) */}
<Route path="/login" component={PublicOnlyRoute}>
<Route path="/" component={LoginPage} />
</Route>
{/* Защищенные маршруты (только для авторизованных) */}
<Route path="/admin" component={RequireAuth}>
<Route path="/*" component={AdminPage} />
</Route>
</Suspense>
</Router>
)
}
export default App

676
panel/admin.tsx Normal file
View File

@ -0,0 +1,676 @@
/**
* Компонент страницы администратора
* @module AdminPage
*/
import { useNavigate } from '@solidjs/router'
import { Component, For, Show, createEffect, createSignal, onCleanup, onMount } from 'solid-js'
import { query } from './graphql'
import { isAuthenticated, logout } from './auth'
/**
* Интерфейс для данных пользователя
*/
interface User {
id: number
email: string
name?: string
slug?: string
roles: string[]
created_at?: number
last_seen?: number
muted: boolean
is_active: boolean
}
/**
* Интерфейс для роли пользователя
*/
interface Role {
id: number
name: string
description?: string
}
/**
* Интерфейс для ответа API с пользователями
*/
interface AdminGetUsersResponse {
adminGetUsers: {
users: User[]
total: number
page: number
perPage: number
totalPages: number
}
}
/**
* Интерфейс для ответа API с ролями
*/
interface AdminGetRolesResponse {
adminGetRoles: Role[]
}
/**
* Компонент страницы администратора
*/
const AdminPage: Component = () => {
const [activeTab, setActiveTab] = createSignal('users')
const [users, setUsers] = createSignal<User[]>([])
const [roles, setRoles] = createSignal<Role[]>([])
const [loading, setLoading] = createSignal(true)
const [error, setError] = createSignal<string | null>(null)
const [selectedUser, setSelectedUser] = createSignal<User | null>(null)
const [showRolesModal, setShowRolesModal] = createSignal(false)
const [successMessage, setSuccessMessage] = createSignal<string | null>(null)
// Параметры пагинации
const [pagination, setPagination] = createSignal<{
page: number
limit: number
total: number
totalPages: number
}>({
page: 1,
limit: 10,
total: 0,
totalPages: 1
})
// Поиск
const [searchQuery, setSearchQuery] = createSignal('')
const navigate = useNavigate()
// Периодическая проверка авторизации
onMount(() => {
// Загружаем данные при монтировании
loadUsers()
loadRoles()
})
/**
* Загрузка списка пользователей с учетом пагинации и поиска
*/
async function loadUsers() {
setLoading(true)
setError(null)
try {
const { page, limit } = pagination()
const offset = (page - 1) * limit
const search = searchQuery().trim()
const data = await query<AdminGetUsersResponse>(
`
query AdminGetUsers($limit: Int, $offset: Int, $search: String) {
adminGetUsers(limit: $limit, offset: $offset, search: $search) {
users {
id
email
name
slug
roles
created_at
last_seen
muted
is_active
}
total
page
perPage
totalPages
}
}
`,
{ limit, offset, search: search || null }
)
if (data?.adminGetUsers) {
setUsers(data.adminGetUsers.users)
setPagination({
page: data.adminGetUsers.page,
limit: data.adminGetUsers.perPage,
total: data.adminGetUsers.total,
totalPages: data.adminGetUsers.totalPages
})
}
} catch (err) {
console.error('Ошибка загрузки пользователей:', err)
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
// Если ошибка авторизации - перенаправляем на логин
if (
err instanceof Error &&
(err.message.includes('401') ||
err.message.includes('авторизации') ||
err.message.includes('unauthorized') ||
err.message.includes('Unauthorized'))
) {
handleLogout()
}
} finally {
setLoading(false)
}
}
/**
* Загрузка списка ролей
*/
async function loadRoles() {
try {
const data = await query<AdminGetRolesResponse>(`
query AdminGetRoles {
adminGetRoles {
id
name
description
}
}
`)
if (data?.adminGetRoles) {
setRoles(data.adminGetRoles)
}
} catch (err) {
console.error('Ошибка загрузки ролей:', err)
// Если ошибка авторизации - перенаправляем на логин
if (
err instanceof Error &&
(err.message.includes('401') ||
err.message.includes('авторизации') ||
err.message.includes('unauthorized') ||
err.message.includes('Unauthorized'))
) {
handleLogout()
}
}
}
/**
* Обработчик изменения страницы
* @param page - Номер страницы
*/
function handlePageChange(page: number) {
if (page < 1 || page > pagination().totalPages) return
setPagination((prev) => ({ ...prev, page }))
loadUsers()
}
/**
* Обработчик изменения количества записей на странице
* @param limit - Количество записей на странице
*/
function handlePerPageChange(limit: number) {
setPagination((prev) => ({ ...prev, page: 1, limit }))
loadUsers()
}
/**
* Обработчик изменения поискового запроса
* @param e - Событие изменения ввода
*/
function handleSearchChange(e: Event) {
const target = e.target as HTMLInputElement
setSearchQuery(target.value)
}
/**
* Выполняет поиск при нажатии Enter или кнопки поиска
*/
function handleSearch() {
setPagination((prev) => ({ ...prev, page: 1 })) // Сбрасываем на первую страницу при поиске
loadUsers()
}
/**
* Обработчик нажатия клавиши в поле поиска
* @param e - Событие нажатия клавиши
*/
function handleSearchKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault()
handleSearch()
}
}
/**
* Блокировка/разблокировка пользователя
* @param userId - ID пользователя
* @param isActive - Текущий статус активности
*/
async function toggleUserBlock(userId: number, isActive: boolean) {
// Запрашиваем подтверждение
const action = isActive ? 'заблокировать' : 'разблокировать'
if (!confirm(`Вы действительно хотите ${action} этого пользователя?`)) {
return
}
try {
await query(
`
mutation AdminToggleUserBlock($userId: Int!) {
adminToggleUserBlock(userId: $userId) {
success
error
}
}
`,
{ userId }
)
// Обновляем статус пользователя
setUsers((prev) =>
prev.map((user) => {
if (user.id === userId) {
return { ...user, is_active: !isActive }
}
return user
})
)
// Показываем сообщение об успехе
setSuccessMessage(`Пользователь успешно ${isActive ? 'заблокирован' : 'разблокирован'}`)
// Скрываем сообщение через 3 секунды
setTimeout(() => setSuccessMessage(null), 3000)
} catch (err) {
console.error('Ошибка изменения статуса блокировки:', err)
setError(err instanceof Error ? err.message : 'Ошибка изменения статуса блокировки')
}
}
/**
* Включение/отключение режима "mute" для пользователя
* @param userId - ID пользователя
* @param isMuted - Текущий статус mute
*/
async function toggleUserMute(userId: number, isMuted: boolean) {
// Запрашиваем подтверждение
const action = isMuted ? 'включить звук' : 'отключить звук'
if (!confirm(`Вы действительно хотите ${action} для этого пользователя?`)) {
return
}
try {
await query(
`
mutation AdminToggleUserMute($userId: Int!) {
adminToggleUserMute(userId: $userId) {
success
error
}
}
`,
{ userId }
)
// Обновляем статус пользователя
setUsers((prev) =>
prev.map((user) => {
if (user.id === userId) {
return { ...user, muted: !isMuted }
}
return user
})
)
// Показываем сообщение об успехе
setSuccessMessage(`Звук для пользователя успешно ${isMuted ? 'включен' : 'отключен'}`)
// Скрываем сообщение через 3 секунды
setTimeout(() => setSuccessMessage(null), 3000)
} catch (err) {
console.error('Ошибка изменения статуса mute:', err)
setError(err instanceof Error ? err.message : 'Ошибка изменения статуса mute')
}
}
/**
* Закрывает модальное окно управления ролями
*/
function closeRolesModal() {
setShowRolesModal(false)
setSelectedUser(null)
}
/**
* Обновляет роли пользователя
* @param userId - ID пользователя
* @param roles - Новый список ролей
*/
async function updateUserRoles(userId: number, newRoles: string[]) {
try {
await query(
`
mutation AdminUpdateUser($userId: Int!, $input: AdminUserUpdateInput!) {
adminUpdateUser(userId: $userId, input: $input) {
success
error
}
}
`,
{
userId,
input: { roles: newRoles }
}
)
// Обновляем роли пользователя в списке
setUsers((prev) =>
prev.map((user) => {
if (user.id === userId) {
return { ...user, roles: newRoles }
}
return user
})
)
// Закрываем модальное окно
closeRolesModal()
// Показываем сообщение об успехе
setSuccessMessage('Роли пользователя успешно обновлены')
// Скрываем сообщение через 3 секунды
setTimeout(() => setSuccessMessage(null), 3000)
} catch (err) {
console.error('Ошибка обновления ролей:', err)
setError(err instanceof Error ? err.message : 'Ошибка обновления ролей')
}
}
/**
* Выход из системы
*/
function handleLogout() {
// Сначала выполняем локальные действия по очистке данных
setUsers([])
setRoles([])
// Затем выполняем выход
logout(() => {
// Для гарантии перенаправления после выхода
window.location.href = '/login'
})
}
/**
* Форматирование даты
* @param timestamp - Временная метка
*/
function formatDate(timestamp?: number): string {
if (!timestamp) return 'Н/Д'
return new Date(timestamp * 1000).toLocaleString('ru')
}
/**
* Формирует массив номеров страниц для отображения в пагинации
* @returns Массив номеров страниц
*/
function getPageNumbers(): number[] {
const result: number[] = []
const maxVisible = 5 // Максимальное количество видимых номеров страниц
const paginationData = pagination()
const currentPage = paginationData.page
const totalPages = paginationData.totalPages
let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2))
const endPage = Math.min(totalPages, startPage + maxVisible - 1)
// Если endPage достиг предела, сдвигаем startPage назад
if (endPage - startPage + 1 < maxVisible && startPage > 1) {
startPage = Math.max(1, endPage - maxVisible + 1)
}
// Генерируем номера страниц
for (let i = startPage; i <= endPage; i++) {
result.push(i)
}
return result
}
/**
* Компонент пагинации
*/
const Pagination: Component = () => {
const paginationData = pagination()
const currentPage = paginationData.page
const total = paginationData.totalPages
return (
<div class="pagination">
<div class="pagination-info">
Показано {users().length} из {paginationData.total} пользователей
</div>
<div class="pagination-controls">
<button
class="pagination-button"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
&laquo;
</button>
<For each={getPageNumbers()}>
{(page) =>
typeof page === 'number' ? (
<button
class={`pagination-button ${page === currentPage ? 'active' : ''}`}
onClick={() => handlePageChange(page)}
>
{page}
</button>
) : (
<span class="pagination-ellipsis">{page}</span>
)
}
</For>
<button
class="pagination-button"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === total}
>
&raquo;
</button>
</div>
<div class="pagination-per-page">
<label>
Записей на странице:
<select
value={paginationData.limit}
onChange={(e) => handlePerPageChange(Number.parseInt(e.target.value))}
>
<option value={5}>5</option>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
</label>
</div>
</div>
)
}
/**
* Компонент модального окна для управления ролями
*/
const RolesModal: Component = () => {
const user = selectedUser()
const [selectedRoles, setSelectedRoles] = createSignal<string[]>(user ? [...user.roles] : [])
const toggleRole = (role: string) => {
const current = selectedRoles()
if (current.includes(role)) {
setSelectedRoles(current.filter((r) => r !== role))
} else {
setSelectedRoles([...current, role])
}
}
const saveRoles = () => {
if (user) {
updateUserRoles(user.id, selectedRoles())
}
}
if (!user) return null
return (
<div class="modal-overlay">
<div class="modal-content">
<h2>Управление ролями пользователя</h2>
<p>Пользователь: {user.email}</p>
<div class="roles-list">
<For each={roles()}>
{(role) => (
<div class="role-item">
<label>
<input
type="checkbox"
checked={selectedRoles().includes(role.name)}
onChange={() => toggleRole(role.name)}
/>
{role.name}
</label>
<Show when={role.description}>
<p class="role-description">{role.description}</p>
</Show>
</div>
)}
</For>
</div>
<div class="modal-actions">
<button class="cancel-button" onClick={closeRolesModal}>
Отмена
</button>
<button class="save-button" onClick={saveRoles}>
Сохранить
</button>
</div>
</div>
</div>
)
}
return (
<div class="admin-page">
<header>
<div class="header-container">
<h1>Панель администратора</h1>
<button class="logout-button" onClick={handleLogout}>
Выйти
</button>
</div>
<nav class="admin-tabs">
<button class={activeTab() === 'users' ? 'active' : ''} onClick={() => setActiveTab('users')}>
Пользователи
</button>
</nav>
</header>
<main>
<Show when={error()}>
<div class="error-message">{error()}</div>
</Show>
<Show when={successMessage()}>
<div class="success-message">{successMessage()}</div>
</Show>
<Show when={loading()}>
<div class="loading">Загрузка данных...</div>
</Show>
<Show when={!loading() && users().length === 0 && !error()}>
<div class="empty-state">Нет данных для отображения</div>
</Show>
<Show when={!loading() && users().length > 0}>
<div class="users-controls">
<div class="search-container">
<div class="search-input-group">
<input
type="text"
placeholder="Поиск по email, имени или ID..."
value={searchQuery()}
onInput={handleSearchChange}
onKeyDown={handleSearchKeyDown}
class="search-input"
/>
<button class="search-button" onClick={handleSearch}>
Поиск
</button>
</div>
</div>
</div>
<div class="users-list">
<table>
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Имя</th>
<th>Роли</th>
<th>Создан</th>
<th>Последний вход</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<For each={users()}>
{(user) => (
<tr class={user.is_active ? '' : 'blocked'}>
<td>{user.id}</td>
<td>{user.email}</td>
<td>{user.name || '-'}</td>
<td>{user.roles.join(', ') || '-'}</td>
<td>{formatDate(user.created_at)}</td>
<td>{formatDate(user.last_seen)}</td>
<td>
<span class={`status ${user.is_active ? 'active' : 'inactive'}`}>
{user.is_active ? 'Активен' : 'Заблокирован'}
</span>
</td>
<td class="actions">
<button
class={user.is_active ? 'block' : 'unblock'}
onClick={() => toggleUserBlock(user.id, user.is_active)}
>
{user.is_active ? 'Блокировать' : 'Разблокировать'}
</button>
<button
class={user.muted ? 'unmute' : 'mute'}
onClick={() => toggleUserMute(user.id, user.muted)}
>
{user.muted ? 'Unmute' : 'Mute'}
</button>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
<Pagination />
</Show>
</main>
<Show when={showRolesModal()}>
<RolesModal />
</Show>
</div>
)
}
export default AdminPage

143
panel/auth.ts Normal file
View File

@ -0,0 +1,143 @@
/**
* Модуль авторизации
* @module auth
*/
import { query } from './graphql'
/**
* Интерфейс для учетных данных
*/
export interface Credentials {
email: string
password: string
}
/**
* Интерфейс для результата авторизации
*/
export interface LoginResult {
success: boolean
token?: string
error?: string
}
/**
* Интерфейс для ответа API при логине
*/
interface LoginResponse {
login: LoginResult
}
/**
* Константа для имени ключа токена в localStorage
*/
const AUTH_TOKEN_KEY = 'auth_token'
/**
* Константа для имени ключа токена в cookie
*/
const AUTH_COOKIE_NAME = 'auth_token'
/**
* Получает токен авторизации из cookie
* @returns Токен или пустую строку, если токен не найден
*/
function getAuthTokenFromCookie(): string {
const cookieItems = document.cookie.split(';')
for (const item of cookieItems) {
const [name, value] = item.trim().split('=')
if (name === AUTH_COOKIE_NAME) {
return value
}
}
return ''
}
/**
* Проверяет, авторизован ли пользователь
* @returns Статус авторизации
*/
export function isAuthenticated(): boolean {
// Проверяем наличие cookie auth_token
const cookieToken = getAuthTokenFromCookie()
const hasCookie = !!cookieToken && cookieToken.length > 10
// Проверяем наличие токена в localStorage
const localToken = localStorage.getItem(AUTH_TOKEN_KEY)
const hasLocalToken = !!localToken && localToken.length > 10
// Пользователь авторизован, если есть cookie или токен в localStorage
return hasCookie || hasLocalToken
}
/**
* Выполняет выход из системы
* @param callback - Функция обратного вызова после выхода
*/
export function logout(callback?: () => void): void {
// Очищаем токен из localStorage
localStorage.removeItem(AUTH_TOKEN_KEY)
// Для удаления cookie устанавливаем ей истекшее время жизни
document.cookie = `${AUTH_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`
// Дополнительно пытаемся сделать запрос на сервер для удаления серверных сессий
try {
fetch('/logout', {
method: 'GET',
credentials: 'include'
}).catch(e => {
console.error('Ошибка при запросе на выход:', e)
})
} catch (e) {
console.error('Ошибка при выходе:', e)
}
// Вызываем функцию обратного вызова после очистки токенов
if (callback) callback()
}
/**
* Выполняет вход в систему
* @param credentials - Учетные данные
* @returns Результат авторизации
*/
export async function login(credentials: Credentials): Promise<boolean> {
try {
// Используем query из graphql.ts для выполнения запроса
const data = await query<LoginResponse>(
`
mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) {
success
token
error
}
}
`,
{
email: credentials.email,
password: credentials.password
}
)
if (data?.login?.success) {
// Проверяем, установил ли сервер cookie
const cookieToken = getAuthTokenFromCookie()
const hasCookie = !!cookieToken && cookieToken.length > 10
// Если cookie не установлена, но есть токен в ответе, сохраняем его в localStorage
if (!hasCookie && data.login.token) {
localStorage.setItem(AUTH_TOKEN_KEY, data.login.token)
}
return true
}
throw new Error(data?.login?.error || 'Ошибка авторизации')
} catch (error) {
console.error('Ошибка при входе:', error)
throw error
}
}

189
panel/graphql.ts Normal file
View File

@ -0,0 +1,189 @@
/**
* API-клиент для работы с GraphQL
* @module api
*/
/**
* Базовый URL для API
*/
// Всегда используем абсолютный путь к API
const API_URL = window.location.origin + '/graphql'
/**
* Константа для имени ключа токена в localStorage
*/
const AUTH_TOKEN_KEY = 'auth_token'
/**
* Тип для произвольных данных GraphQL
*/
type GraphQLData = Record<string, unknown>
/**
* Получает токен авторизации из cookie
* @returns Токен или пустую строку, если токен не найден
*/
function getAuthTokenFromCookie(): string {
const cookieItems = document.cookie.split(';')
for (const item of cookieItems) {
const [name, value] = item.trim().split('=')
if (name === 'auth_token') {
return value
}
}
return ''
}
/**
* Обрабатывает ошибки от API
* @param response - Ответ от сервера
* @returns Обработанный текст ошибки
*/
async function handleApiError(response: Response): Promise<string> {
try {
const contentType = response.headers.get('content-type')
if (contentType?.includes('application/json')) {
const errorData = await response.json()
// Проверяем GraphQL ошибки
if (errorData.errors && errorData.errors.length > 0) {
return errorData.errors[0].message
}
// Проверяем сообщение об ошибке
if (errorData.error || errorData.message) {
return errorData.error || errorData.message
}
}
// Если не JSON или нет структурированной ошибки, читаем как текст
const errorText = await response.text()
return `Ошибка сервера: ${response.status} ${response.statusText}. ${errorText.substring(0, 100)}...`
} catch (_e) {
// Если не можем прочитать ответ
return `Ошибка сервера: ${response.status} ${response.statusText}`
}
}
/**
* Проверяет наличие ошибок авторизации в ответе GraphQL
* @param errors - Массив ошибок GraphQL
* @returns true если есть ошибки авторизации
*/
function hasAuthErrors(errors: Array<{ message?: string; extensions?: { code?: string } }>): boolean {
return errors.some(
(error) =>
(error.message && (
error.message.toLowerCase().includes('unauthorized') ||
error.message.toLowerCase().includes('авторизации') ||
error.message.toLowerCase().includes('authentication') ||
error.message.toLowerCase().includes('unauthenticated') ||
error.message.toLowerCase().includes('token')
)) ||
error.extensions?.code === 'UNAUTHENTICATED' ||
error.extensions?.code === 'FORBIDDEN'
)
}
/**
* Выполняет GraphQL запрос
* @param query - GraphQL запрос
* @param variables - Переменные запроса
* @returns Результат запроса
*/
export async function query<T = GraphQLData>(
query: string,
variables: Record<string, unknown> = {}
): Promise<T> {
try {
const headers: Record<string, string> = {
'Content-Type': '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) {
// В соответствии с логами сервера, формат должен быть: Bearer <token>
headers['Authorization'] = `Bearer ${token}`
// Для отладки
console.debug('Отправка запроса с токеном авторизации')
}
const response = await fetch(API_URL, {
method: 'POST',
headers,
// Важно: credentials: 'include' - для передачи cookies с запросом
credentials: 'include',
body: JSON.stringify({
query,
variables
})
})
// Проверяем статус ответа
if (!response.ok) {
const errorMessage = await handleApiError(response)
console.error('Ошибка API:', {
status: response.status,
statusText: response.statusText,
error: errorMessage
})
// Если получен 401 Unauthorized, перенаправляем на страницу входа
if (response.status === 401) {
localStorage.removeItem(AUTH_TOKEN_KEY)
window.location.href = '/login'
throw new Error('Unauthorized')
}
throw new Error(errorMessage)
}
// Проверяем, что ответ содержит JSON
const contentType = response.headers.get('content-type')
if (!contentType?.includes('application/json')) {
const text = await response.text()
throw new Error(`Неверный формат ответа: ${text.substring(0, 100)}...`)
}
const result = await response.json()
if (result.errors) {
// Проверяем ошибки на признаки проблем с авторизацией
if (hasAuthErrors(result.errors)) {
localStorage.removeItem(AUTH_TOKEN_KEY)
window.location.href = '/login'
throw new Error('Unauthorized')
}
throw new Error(result.errors[0].message)
}
return result.data as T
} catch (error) {
console.error('API Error:', error)
throw error
}
}
/**
* Выполняет GraphQL мутацию
* @param mutation - GraphQL мутация
* @param variables - Переменные мутации
* @returns Результат мутации
*/
export function mutate<T = GraphQLData>(
mutation: string,
variables: Record<string, unknown> = {}
): Promise<T> {
return query<T>(mutation, variables)
}

12
panel/index.tsx Normal file
View File

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

112
panel/login.tsx Normal file
View File

@ -0,0 +1,112 @@
/**
* Компонент страницы входа
* @module LoginPage
*/
import { useNavigate } from '@solidjs/router'
import { Component, createSignal, onMount } from 'solid-js'
import { login, isAuthenticated } from './auth'
/**
* Компонент страницы входа
*/
const LoginPage: Component = () => {
const [email, setEmail] = createSignal('')
const [password, setPassword] = createSignal('')
const [isLoading, setIsLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const navigate = useNavigate()
/**
* Проверка авторизации при загрузке компонента
* и перенаправление если пользователь уже авторизован
*/
onMount(() => {
// Если пользователь уже авторизован, перенаправляем на админ-панель
if (isAuthenticated()) {
window.location.href = '/admin'
}
})
/**
* Обработчик отправки формы входа
* @param e - Событие отправки формы
*/
const handleSubmit = async (e: Event) => {
e.preventDefault()
// Очищаем пробелы в email
const cleanEmail = email().trim()
if (!cleanEmail || !password()) {
setError('Пожалуйста, заполните все поля')
return
}
setIsLoading(true)
setError(null)
try {
// Используем функцию login из модуля auth
const loginSuccessful = await login({
email: cleanEmail,
password: password()
})
if (loginSuccessful) {
// Используем прямое перенаправление для надежности
window.location.href = '/admin'
} else {
throw new Error('Вход не выполнен')
}
} catch (err) {
console.error('Ошибка при входе:', err)
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
setIsLoading(false)
}
}
return (
<div class="login-page">
<div class="login-container">
<h1>Вход в систему</h1>
{error() && <div class="error-message">{error()}</div>}
<form onSubmit={handleSubmit}>
<div class="form-group">
<label for="email">Email</label>
<input
type="email"
id="email"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
disabled={isLoading()}
autocomplete="username"
required
/>
</div>
<div class="form-group">
<label for="password">Пароль</label>
<input
type="password"
id="password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
disabled={isLoading()}
autocomplete="current-password"
required
/>
</div>
<button type="submit" disabled={isLoading()}>
{isLoading() ? 'Вход...' : 'Войти'}
</button>
</form>
</div>
</div>
)
}
export default LoginPage

587
panel/styles.css Normal file
View File

@ -0,0 +1,587 @@
/**
* Основные стили приложения
*/
/* Сброс стилей */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* Общие стили */
:root {
--primary-color: #3498db;
--primary-dark: #2980b9;
--success-color: #2ecc71;
--success-light: #d1fae5;
--danger-color: #e74c3c;
--danger-light: #fee2e2;
--warning-color: #f39c12;
--warning-light: #fef3c7;
--text-color: #333;
--bg-color: #f5f5f5;
--card-bg: #fff;
--border-color: #ddd;
}
body {
font-family: system-ui, -apple-system, sans-serif;
margin: 0;
padding: 0;
background-color: var(--bg-color);
color: var(--text-color);
}
/* Общие элементы интерфейса */
.loading-screen, .loading {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 200px;
padding: 20px;
text-align: center;
color: var(--primary-color);
}
.loading-spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: var(--primary-color);
border-radius: 50%;
width: 40px;
height: 40px;
margin-bottom: 20px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-message {
background-color: var(--danger-light);
border-left: 4px solid var(--danger-color);
color: var(--danger-color);
padding: 10px;
margin-bottom: 20px;
border-radius: 4px;
}
.success-message {
background-color: var(--success-light);
border-left: 4px solid var(--success-color);
color: var(--success-color);
padding: 10px;
margin-bottom: 20px;
border-radius: 4px;
}
.empty-state {
text-align: center;
padding: 40px;
color: #999;
font-style: italic;
}
/* Стили для формы и кнопок */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 16px;
}
button {
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 4px;
padding: 10px 15px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.2s;
width: 100%;
}
button:hover {
background-color: var(--primary-dark);
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
/* Стили для страницы входа */
.login-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.login-container {
background-color: var(--card-bg);
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 30px;
width: 100%;
max-width: 400px;
}
.login-container h1 {
margin-top: 0;
margin-bottom: 20px;
text-align: center;
color: var(--primary-color);
}
/* Стили для админ-панели */
.admin-page {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
background-color: var(--card-bg);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
padding: 15px 20px;
}
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
max-width: 1200px;
margin-left: auto;
margin-right: auto;
width: 100%;
}
header h1 {
margin: 0;
color: var(--primary-color);
font-size: 24px;
}
.logout-button {
background-color: transparent;
color: var(--danger-color);
border: 1px solid var(--danger-color);
width: auto;
padding: 8px 16px;
font-size: 14px;
}
.logout-button:hover {
background-color: var(--danger-color);
color: white;
}
.admin-tabs {
display: flex;
border-bottom: 1px solid #ddd;
margin-bottom: 1.5rem;
gap: 10px;
max-width: 1200px;
margin-left: auto;
margin-right: auto;
}
.admin-tabs button {
background: none;
border: none;
padding: 8px 16px;
cursor: pointer;
font-size: 16px;
border-bottom: 3px solid transparent;
transition: all 0.2s;
width: auto;
color: var(--text-color);
}
.admin-tabs button.active {
border-bottom-color: var(--primary-color);
color: var(--primary-color);
font-weight: 600;
background-color: transparent;
}
.admin-tabs button:hover {
background-color: rgba(52, 152, 219, 0.1);
}
main {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
width: 100%;
flex-grow: 1;
}
/* Таблица пользователей */
.users-list {
overflow-x: auto;
margin-top: 1rem;
}
table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--border-color);
}
thead {
background-color: #f3f4f6;
}
th, td {
padding: 10px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
font-weight: 600;
background-color: #f9f9f9;
}
tr:hover {
background-color: rgba(52, 152, 219, 0.05);
}
tr.blocked {
background-color: rgba(231, 76, 60, 0.05);
}
/* Статусы пользователей */
.status {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
margin-right: 4px;
}
.status.active {
background-color: var(--success-light);
color: var(--success-color);
}
.status.blocked {
background-color: var(--danger-light);
color: var(--danger-color);
}
.status.muted {
background-color: var(--warning-light);
color: var(--warning-color);
}
/* Кнопки действий */
.actions {
display: flex;
gap: 5px;
}
.actions button {
padding: 5px 10px;
font-size: 12px;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
width: auto;
}
button.block {
background-color: var(--danger-color);
}
button.unblock {
background-color: var(--success-color);
}
button.mute {
background-color: var(--warning-color);
}
button.unmute {
background-color: var(--primary-color);
}
/* Стили для редактирования ролей */
.roles-container {
display: flex;
align-items: center;
gap: 8px;
}
.roles-text {
flex: 1;
}
.edit-roles-button {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
padding: 0;
opacity: 0.6;
transition: opacity 0.2s;
width: auto;
color: var(--primary-color);
}
.edit-roles-button:hover {
opacity: 1;
background-color: rgba(52, 152, 219, 0.1);
border-radius: 4px;
}
/* Модальное окно */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 20px;
border-radius: 8px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.modal-content h2 {
margin-top: 0;
color: var(--primary-color);
}
.roles-list {
margin: 16px 0;
}
.role-item {
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
}
.role-item:last-child {
border-bottom: none;
}
.role-item label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.role-description {
margin-top: 4px;
margin-left: 24px;
font-size: 14px;
color: #6b7280;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.cancel-button {
padding: 8px 16px;
background-color: #ccc;
color: #333;
width: auto;
}
.save-button {
padding: 8px 16px;
background-color: var(--primary-color);
width: auto;
}
.save-button:hover {
background-color: var(--primary-dark);
}
/* Стили для пагинации */
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
padding: 10px 0;
flex-wrap: wrap;
gap: 10px;
}
.pagination-info {
color: #6b7280;
font-size: 14px;
}
.pagination-controls {
display: flex;
gap: 5px;
align-items: center;
}
.pagination-button {
min-width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 5px;
border: 1px solid var(--border-color);
background-color: white;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.pagination-button:hover:not(:disabled) {
background-color: #f3f4f6;
border-color: #d1d5db;
}
.pagination-button.active {
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.pagination-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-ellipsis {
padding: 0 8px;
color: #6b7280;
}
.pagination-per-page {
display: flex;
align-items: center;
font-size: 14px;
color: #6b7280;
}
.pagination-per-page select {
margin-left: 8px;
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: white;
}
/* Поиск */
.users-controls {
margin-bottom: 16px;
}
.search-container {
max-width: 500px;
width: 100%;
}
.search-input-group {
display: flex;
width: 100%;
}
.search-input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 4px 0 0 4px;
font-size: 14px;
}
.search-input:focus {
border-color: var(--primary-color);
outline: none;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
}
.search-button {
padding: 8px 16px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 0 4px 4px 0;
cursor: pointer;
transition: background-color 0.2s;
font-size: 14px;
}
.search-button:hover {
background-color: var(--primary-dark);
}
/* Адаптивные стили */
@media (max-width: 768px) {
.pagination {
flex-direction: column;
align-items: start;
}
.actions {
flex-direction: column;
}
.users-list {
font-size: 14px;
}
th, td {
padding: 8px 5px;
}
.pagination-per-page {
margin-top: 10px;
}
.header-container {
flex-direction: column;
gap: 10px;
}
}

View File

@ -14,7 +14,7 @@ from cache.cache import (
get_cached_follower_topics,
invalidate_cache_by_prefix,
)
from orm.author import Author
from auth.orm import Author
from resolvers.stat import get_with_stat
from services.auth import login_required
from services.db import local_session
@ -70,7 +70,9 @@ async def get_authors_with_stats(limit=50, offset=0, by: Optional[str] = None):
# Функция для получения авторов из БД
async def fetch_authors_with_stats():
logger.debug(f"Выполняем запрос на получение авторов со статистикой: limit={limit}, offset={offset}, by={by}")
logger.debug(
f"Выполняем запрос на получение авторов со статистикой: limit={limit}, offset={offset}, by={by}"
)
with local_session() as session:
# Базовый запрос для получения авторов
@ -80,7 +82,7 @@ async def get_authors_with_stats(limit=50, offset=0, by: Optional[str] = None):
if by:
if isinstance(by, dict):
# Обработка словаря параметров сортировки
from sqlalchemy import asc, desc
from sqlalchemy import desc
for field, direction in by.items():
column = getattr(Author, field, None)

View File

@ -3,7 +3,7 @@ from operator import and_
from graphql import GraphQLError
from sqlalchemy import delete, insert
from orm.author import AuthorBookmark
from auth.orm import AuthorBookmark
from orm.shout import Shout
from resolvers.feed import apply_options
from resolvers.reader import get_shouts_with_links, query_with_stat
@ -72,7 +72,9 @@ def toggle_bookmark_shout(_, info, slug: str) -> CommonResult:
if existing_bookmark:
db.execute(
delete(AuthorBookmark).where(AuthorBookmark.author == author_id, AuthorBookmark.shout == shout.id)
delete(AuthorBookmark).where(
AuthorBookmark.author == author_id, AuthorBookmark.shout == shout.id
)
)
result = False
else:

View File

@ -1,4 +1,4 @@
from orm.author import Author
from auth.orm import Author
from orm.invite import Invite, InviteStatus
from orm.shout import Shout
from services.auth import login_required

View File

@ -1,4 +1,4 @@
from orm.author import Author
from auth.orm import Author
from orm.community import Community, CommunityFollower
from services.db import local_session
from services.schema import mutation, query
@ -74,9 +74,9 @@ async def update_community(_, info, community_data):
if slug:
with local_session() as session:
try:
session.query(Community).where(Community.created_by == author_id, Community.slug == slug).update(
community_data
)
session.query(Community).where(
Community.created_by == author_id, Community.slug == slug
).update(community_data)
session.commit()
except Exception as e:
return {"ok": False, "error": str(e)}
@ -90,7 +90,9 @@ async def delete_community(_, info, slug: str):
author_id = author_dict.get("id")
with local_session() as session:
try:
session.query(Community).where(Community.slug == slug, Community.created_by == author_id).delete()
session.query(Community).where(
Community.slug == slug, Community.created_by == author_id
).delete()
session.commit()
return {"ok": True}
except Exception as e:

View File

@ -1,26 +1,22 @@
import time
import trafilatura
from sqlalchemy.orm import joinedload
from cache.cache import (
cache_author,
cache_by_id,
cache_topic,
invalidate_shout_related_cache,
invalidate_shouts_cache,
)
from orm.author import Author
from auth.orm import Author
from orm.draft import Draft, DraftAuthor, DraftTopic
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic
from services.auth import login_required
from services.db import local_session
from services.notify import notify_shout
from services.schema import mutation, query
from services.search import search_service
from utils.html_wrapper import wrap_html_fragment
from utils.extract_text import extract_text
from utils.logger import root_logger as logger
def create_shout_from_draft(session, draft, author_id):
"""
Создаёт новый объект публикации (Shout) на основе черновика.
@ -97,7 +93,7 @@ async def load_drafts(_, info):
.options(
joinedload(Draft.topics),
joinedload(Draft.authors),
joinedload(Draft.publication) # Загружаем связанную публикацию
joinedload(Draft.publication), # Загружаем связанную публикацию
)
.filter(Draft.authors.any(Author.id == author_id))
)
@ -116,10 +112,10 @@ async def load_drafts(_, info):
draft_dict["publication"] = {
"id": draft.publication.id,
"slug": draft.publication.slug,
"published_at": draft.publication.published_at
"published_at": draft.publication.published_at,
}
else:
draft_dict["publication"] = None
draft_dict["publication"] = None
drafts_data.append(draft_dict)
@ -198,9 +194,9 @@ async def create_draft(_, info, draft_input):
logger.error(f"Failed to create draft: {e}", exc_info=True)
return {"error": f"Failed to create draft: {str(e)}"}
def generate_teaser(body, limit=300):
body_html = wrap_html_fragment(body)
body_text = trafilatura.extract(body_html, include_comments=False, include_tables=False)
body_text = extract_text(body)
body_teaser = ". ".join(body_text[:limit].split(". ")[:-1])
return body_teaser
@ -246,9 +242,20 @@ async def update_draft(_, info, draft_id: int, draft_input):
# Фильтруем входные данные, оставляя только разрешенные поля
allowed_fields = {
"layout", "author_ids", "topic_ids", "main_topic_id",
"media", "lead", "subtitle", "lang", "seo", "body",
"title", "slug", "cover", "cover_caption"
"layout",
"author_ids",
"topic_ids",
"main_topic_id",
"media",
"lead",
"subtitle",
"lang",
"seo",
"body",
"title",
"slug",
"cover",
"cover_caption",
}
filtered_input = {k: v for k, v in draft_input.items() if k in allowed_fields}
@ -279,7 +286,7 @@ async def update_draft(_, info, draft_id: int, draft_input):
dt = DraftTopic(
shout=draft_id,
topic=tid,
main=(tid == main_topic_id) if main_topic_id else False
main=(tid == main_topic_id) if main_topic_id else False,
)
session.add(dt)
@ -287,13 +294,10 @@ async def update_draft(_, info, draft_id: int, draft_input):
if "seo" not in filtered_input and not draft.seo:
body_src = filtered_input.get("body", draft.body)
lead_src = filtered_input.get("lead", draft.lead)
body_html = wrap_html_fragment(body_src)
lead_html = wrap_html_fragment(lead_src)
try:
body_text = trafilatura.extract(body_html, include_comments=False, include_tables=False) if body_src else None
lead_text = trafilatura.extract(lead_html, include_comments=False, include_tables=False) if lead_src else None
body_text = extract_text(body_src) if body_src else None
lead_text = extract_text(lead_src) if lead_src else None
body_teaser = generate_teaser(body_text, 300) if body_text else ""
filtered_input["seo"] = lead_text if lead_text else body_teaser
except Exception as e:
@ -366,11 +370,8 @@ def validate_html_content(html_content: str) -> tuple[bool, str]:
return False, "Content is empty"
try:
html_content = wrap_html_fragment(html_content)
extracted = trafilatura.extract(html_content)
if not extracted:
return False, "Invalid HTML structure or empty content"
return True, ""
extracted = extract_text(html_content)
return bool(extracted), extracted or ""
except Exception as e:
logger.error(f"HTML validation error: {e}", exc_info=True)
return False, f"Invalid HTML content: {str(e)}"
@ -400,11 +401,7 @@ async def publish_draft(_, info, draft_id: int):
# Загружаем черновик со всеми связями
draft = (
session.query(Draft)
.options(
joinedload(Draft.topics),
joinedload(Draft.authors),
joinedload(Draft.publication)
)
.options(joinedload(Draft.topics), joinedload(Draft.authors), joinedload(Draft.publication))
.filter(Draft.id == draft_id)
.first()
)
@ -421,7 +418,17 @@ async def publish_draft(_, info, draft_id: int):
if draft.publication:
shout = draft.publication
# Обновляем существующую публикацию
for field in ["body", "title", "subtitle", "lead", "cover", "cover_caption", "media", "lang", "seo"]:
for field in [
"body",
"title",
"subtitle",
"lead",
"cover",
"cover_caption",
"media",
"lang",
"seo",
]:
if hasattr(draft, field):
setattr(shout, field, getattr(draft, field))
shout.updated_at = int(time.time())
@ -440,16 +447,14 @@ async def publish_draft(_, info, draft_id: int):
session.query(ShoutTopic).filter(ShoutTopic.shout == shout.id).delete()
# Добавляем авторов
for author in (draft.authors or []):
for author in draft.authors or []:
sa = ShoutAuthor(shout=shout.id, author=author.id)
session.add(sa)
# Добавляем темы
for topic in (draft.topics or []):
for topic in draft.topics or []:
st = ShoutTopic(
topic=topic.id,
shout=shout.id,
main=topic.main if hasattr(topic, "main") else False
topic=topic.id, shout=shout.id, main=topic.main if hasattr(topic, "main") else False
)
session.add(st)

View File

@ -1,7 +1,6 @@
import time
import orjson
import trafilatura
from sqlalchemy import and_, desc, select
from sqlalchemy.orm import joinedload, selectinload
from sqlalchemy.sql.functions import coalesce
@ -12,7 +11,7 @@ from cache.cache import (
invalidate_shout_related_cache,
invalidate_shouts_cache,
)
from orm.author import Author
from auth.orm import Author
from orm.draft import Draft
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic
@ -23,7 +22,7 @@ from services.db import local_session
from services.notify import notify_shout
from services.schema import mutation, query
from services.search import search_service
from utils.html_wrapper import wrap_html_fragment
from utils.extract_text import extract_text
from utils.logger import root_logger as logger
@ -181,11 +180,11 @@ async def create_shout(_, info, inp):
# Создаем публикацию без topics
body = inp.get("body", "")
lead = inp.get("lead", "")
body_html = wrap_html_fragment(body)
lead_html = wrap_html_fragment(lead)
body_text = trafilatura.extract(body_html)
lead_text = trafilatura.extract(lead_html)
seo = inp.get("seo", lead_text.strip() or body_text.strip()[:300].split(". ")[:-1].join(". "))
body_text = extract_text(body)
lead_text = extract_text(lead)
seo = inp.get(
"seo", lead_text.strip() or body_text.strip()[:300].split(". ")[:-1].join(". ")
)
new_shout = Shout(
slug=slug,
body=body,
@ -282,7 +281,9 @@ def patch_main_topic(session, main_topic_slug, shout):
with session.begin():
# Получаем текущий главный топик
old_main = (
session.query(ShoutTopic).filter(and_(ShoutTopic.shout == shout.id, ShoutTopic.main.is_(True))).first()
session.query(ShoutTopic)
.filter(and_(ShoutTopic.shout == shout.id, ShoutTopic.main.is_(True)))
.first()
)
if old_main:
logger.info(f"Found current main topic: {old_main.topic.slug}")
@ -316,7 +317,9 @@ def patch_main_topic(session, main_topic_slug, shout):
session.flush()
logger.info(f"Main topic updated for shout#{shout.id}")
else:
logger.warning(f"No changes needed for main topic (old={old_main is not None}, new={new_main is not None})")
logger.warning(
f"No changes needed for main topic (old={old_main is not None}, new={new_main is not None})"
)
def patch_topics(session, shout, topics_input):
@ -417,7 +420,9 @@ async def update_shout(_, info, shout_id: int, shout_input=None, publish=False):
logger.info(f"Processing update for shout#{shout_id} by author #{author_id}")
shout_by_id = (
session.query(Shout)
.options(joinedload(Shout.topics).joinedload(ShoutTopic.topic), joinedload(Shout.authors))
.options(
joinedload(Shout.topics).joinedload(ShoutTopic.topic), joinedload(Shout.authors)
)
.filter(Shout.id == shout_id)
.first()
)
@ -446,7 +451,10 @@ async def update_shout(_, info, shout_id: int, shout_input=None, publish=False):
shout_input["slug"] = slug
logger.info(f"shout#{shout_id} slug patched")
if filter(lambda x: x.id == author_id, [x for x in shout_by_id.authors]) or "editor" in roles:
if (
filter(lambda x: x.id == author_id, [x for x in shout_by_id.authors])
or "editor" in roles
):
logger.info(f"Author #{author_id} has permission to edit shout#{shout_id}")
# topics patch
@ -560,7 +568,9 @@ async def update_shout(_, info, shout_id: int, shout_input=None, publish=False):
# Получаем полные данные шаута со связями
shout_with_relations = (
session.query(Shout)
.options(joinedload(Shout.topics).joinedload(ShoutTopic.topic), joinedload(Shout.authors))
.options(
joinedload(Shout.topics).joinedload(ShoutTopic.topic), joinedload(Shout.authors)
)
.filter(Shout.id == shout_id)
.first()
)
@ -648,19 +658,17 @@ async def delete_shout(_, info, shout_id: int):
def get_main_topic(topics):
"""Get the main topic from a list of ShoutTopic objects."""
logger.info(f"Starting get_main_topic with {len(topics) if topics else 0} topics")
logger.debug(
f"Topics data: {[(t.slug, getattr(t, 'main', False)) for t in topics] if topics else []}"
)
logger.debug(f"Topics data: {[(t.slug, getattr(t, 'main', False)) for t in topics] if topics else []}")
if not topics:
logger.warning("No topics provided to get_main_topic")
return {"id": 0, "title": "no topic", "slug": "notopic", "is_main": True}
# Проверяем, является ли topics списком объектов ShoutTopic или Topic
if hasattr(topics[0], 'topic') and topics[0].topic:
if hasattr(topics[0], "topic") and topics[0].topic:
# Для ShoutTopic объектов (старый формат)
# Find first main topic in original order
main_topic_rel = next((st for st in topics if getattr(st, 'main', False)), None)
main_topic_rel = next((st for st in topics if getattr(st, "main", False)), None)
logger.debug(
f"Found main topic relation: {main_topic_rel.topic.slug if main_topic_rel and main_topic_rel.topic else None}"
)
@ -701,6 +709,7 @@ def get_main_topic(topics):
logger.warning("No valid topics found, returning default")
return {"slug": "notopic", "title": "no topic", "id": 0, "is_main": True}
@mutation.field("unpublish_shout")
@login_required
async def unpublish_shout(_, info, shout_id: int):
@ -727,27 +736,21 @@ async def unpublish_shout(_, info, shout_id: int):
# Загружаем Shout со всеми связями для правильного формирования ответа
shout = (
session.query(Shout)
.options(
joinedload(Shout.authors),
selectinload(Shout.topics)
)
.options(joinedload(Shout.authors), selectinload(Shout.topics))
.filter(Shout.id == shout_id)
.first()
)
if not shout:
logger.warning(f"Shout not found for unpublish: ID {shout_id}")
return {"error": "Shout not found"}
logger.warning(f"Shout not found for unpublish: ID {shout_id}")
return {"error": "Shout not found"}
# Если у публикации есть связанный черновик, загружаем его с relationships
if shout.draft:
# Отдельно загружаем черновик с его связями
draft = (
session.query(Draft)
.options(
selectinload(Draft.authors),
selectinload(Draft.topics)
)
.options(selectinload(Draft.authors), selectinload(Draft.topics))
.filter(Draft.id == shout.draft)
.first()
)
@ -774,10 +777,7 @@ async def unpublish_shout(_, info, shout_id: int):
# Добавляем связанные данные
shout_dict["topics"] = (
[
{"id": topic.id, "slug": topic.slug, "title": topic.title}
for topic in shout.topics
]
[{"id": topic.id, "slug": topic.slug, "title": topic.title} for topic in shout.topics]
if shout.topics
else []
)
@ -787,10 +787,7 @@ async def unpublish_shout(_, info, shout_id: int):
# Добавляем авторов
shout_dict["authors"] = (
[
{"id": author.id, "name": author.name, "slug": author.slug}
for author in shout.authors
]
[{"id": author.id, "name": author.name, "slug": author.slug} for author in shout.authors]
if shout.authors
else []
)
@ -799,7 +796,7 @@ async def unpublish_shout(_, info, shout_id: int):
shout_dict["publication"] = {
"id": shout_id_for_publication,
"slug": shout_slug,
"published_at": None # Ключевое изменение - устанавливаем published_at в None
"published_at": None, # Ключевое изменение - устанавливаем published_at в None
}
# Инвалидация кэша
@ -814,7 +811,7 @@ async def unpublish_shout(_, info, shout_id: int):
await invalidate_shouts_cache(cache_keys)
logger.info(f"Cache invalidated after unpublishing shout {shout_id}")
except Exception as cache_err:
logger.error(f"Failed to invalidate cache for unpublish shout {shout_id}: {cache_err}")
logger.error(f"Failed to invalidate cache for unpublish shout {shout_id}: {cache_err}")
except Exception as e:
session.rollback()

View File

@ -2,7 +2,7 @@ from typing import List
from sqlalchemy import and_, select
from orm.author import Author, AuthorFollower
from auth.orm import Author, AuthorFollower
from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic
from orm.topic import Topic, TopicFollower
from resolvers.reader import (
@ -71,7 +71,9 @@ def shouts_by_follower(info, follower_id: int, options):
q = query_with_stat(info)
reader_followed_authors = select(AuthorFollower.author).where(AuthorFollower.follower == follower_id)
reader_followed_topics = select(TopicFollower.topic).where(TopicFollower.follower == follower_id)
reader_followed_shouts = select(ShoutReactionsFollower.shout).where(ShoutReactionsFollower.follower == follower_id)
reader_followed_shouts = select(ShoutReactionsFollower.shout).where(
ShoutReactionsFollower.follower == follower_id
)
followed_subquery = (
select(Shout.id)
.join(ShoutAuthor, ShoutAuthor.shout == Shout.id)
@ -140,7 +142,9 @@ async def load_shouts_authored_by(_, info, slug: str, options) -> List[Shout]:
q = (
query_with_stat(info)
if has_field(info, "stat")
else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
else select(Shout).filter(
and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None))
)
)
q = q.filter(Shout.authors.any(id=author_id))
q, limit, offset = apply_options(q, options, author_id)
@ -169,7 +173,9 @@ async def load_shouts_with_topic(_, info, slug: str, options) -> List[Shout]:
q = (
query_with_stat(info)
if has_field(info, "stat")
else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
else select(Shout).filter(
and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None))
)
)
q = q.filter(Shout.topics.any(id=topic_id))
q, limit, offset = apply_options(q, options)

View File

@ -10,7 +10,7 @@ from cache.cache import (
get_cached_follower_authors,
get_cached_follower_topics,
)
from orm.author import Author, AuthorFollower
from auth.orm import Author, AuthorFollower
from orm.community import Community, CommunityFollower
from orm.reaction import Reaction
from orm.shout import Shout, ShoutReactionsFollower
@ -71,11 +71,16 @@ async def follow(_, info, what, slug="", entity_id=0):
with local_session() as session:
existing_sub = (
session.query(follower_class)
.filter(follower_class.follower == follower_id, getattr(follower_class, entity_type) == entity_id)
.filter(
follower_class.follower == follower_id,
getattr(follower_class, entity_type) == entity_id,
)
.first()
)
if existing_sub:
logger.info(f"Пользователь {follower_id} уже подписан на {what.lower()} с ID {entity_id}")
logger.info(
f"Пользователь {follower_id} уже подписан на {what.lower()} с ID {entity_id}"
)
else:
logger.debug("Добавление новой записи в базу данных")
sub = follower_class(follower=follower_id, **{entity_type: entity_id})

View File

@ -7,7 +7,7 @@ from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import aliased
from sqlalchemy.sql import not_
from orm.author import Author
from auth.orm import Author
from orm.notification import (
Notification,
NotificationAction,
@ -66,7 +66,9 @@ def query_notifications(author_id: int, after: int = 0) -> Tuple[int, int, List[
return total, unread, notifications
def group_notification(thread, authors=None, shout=None, reactions=None, entity="follower", action="follow"):
def group_notification(
thread, authors=None, shout=None, reactions=None, entity="follower", action="follow"
):
reactions = reactions or []
authors = authors or []
return {

View File

@ -14,7 +14,11 @@ def handle_proposing(kind: ReactionKind, reply_to: int, shout_id: int):
session.query(Reaction).filter(Reaction.id == reply_to, Reaction.shout == shout_id).first()
)
if replied_reaction and replied_reaction.kind is ReactionKind.PROPOSE.value and replied_reaction.quote:
if (
replied_reaction
and replied_reaction.kind is ReactionKind.PROPOSE.value
and replied_reaction.quote
):
# patch all the proposals' quotes
proposals = (
session.query(Reaction)

View File

@ -1,7 +1,7 @@
from sqlalchemy import and_, case, func, select, true
from sqlalchemy.orm import aliased
from orm.author import Author, AuthorRating
from auth.orm import Author, AuthorRating
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout
from services.auth import login_required
@ -187,7 +187,9 @@ def count_author_shouts_rating(session, author_id) -> int:
def get_author_rating_old(session, author: Author):
likes_count = (
session.query(AuthorRating).filter(and_(AuthorRating.author == author.id, AuthorRating.plus.is_(True))).count()
session.query(AuthorRating)
.filter(and_(AuthorRating.author == author.id, AuthorRating.plus.is_(True)))
.count()
)
dislikes_count = (
session.query(AuthorRating)

View File

@ -3,7 +3,7 @@ import time
from sqlalchemy import and_, asc, case, desc, func, select
from sqlalchemy.orm import aliased
from orm.author import Author
from auth.orm import Author
from orm.rating import PROPOSAL_REACTIONS, RATING_REACTIONS, is_negative, is_positive
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor
@ -334,7 +334,9 @@ async def create_reaction(_, info, reaction):
with local_session() as session:
authors = session.query(ShoutAuthor.author).filter(ShoutAuthor.shout == shout_id).scalar()
is_author = (
bool(list(filter(lambda x: x == int(author_id), authors))) if isinstance(authors, list) else False
bool(list(filter(lambda x: x == int(author_id), authors)))
if isinstance(authors, list)
else False
)
reaction_input["created_by"] = author_id
kind = reaction_input.get("kind")

View File

@ -4,7 +4,7 @@ from sqlalchemy import and_, nulls_last, text
from sqlalchemy.orm import aliased
from sqlalchemy.sql.expression import asc, case, desc, func, select
from orm.author import Author
from auth.orm import Author
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic
@ -93,7 +93,14 @@ def query_with_stat(info):
q = q.join(main_topic, main_topic.id == main_topic_join.topic)
q = q.add_columns(
json_builder(
"id", main_topic.id, "title", main_topic.title, "slug", main_topic.slug, "is_main", main_topic_join.main
"id",
main_topic.id,
"title",
main_topic.title,
"slug",
main_topic.slug,
"is_main",
main_topic_join.main,
).label("main_topic")
)
@ -131,7 +138,9 @@ def query_with_stat(info):
select(
ShoutTopic.shout,
json_array_builder(
json_builder("id", Topic.id, "title", Topic.title, "slug", Topic.slug, "is_main", ShoutTopic.main)
json_builder(
"id", Topic.id, "title", Topic.title, "slug", Topic.slug, "is_main", ShoutTopic.main
)
).label("topics"),
)
.outerjoin(Topic, ShoutTopic.topic == Topic.id)
@ -239,7 +248,9 @@ def get_shouts_with_links(info, q, limit=20, offset=0):
if hasattr(row, "main_topic"):
# logger.debug(f"Raw main_topic for shout#{shout_id}: {row.main_topic}")
main_topic = (
orjson.loads(row.main_topic) if isinstance(row.main_topic, str) else row.main_topic
orjson.loads(row.main_topic)
if isinstance(row.main_topic, str)
else row.main_topic
)
# logger.debug(f"Parsed main_topic for shout#{shout_id}: {main_topic}")
@ -253,7 +264,12 @@ def get_shouts_with_links(info, q, limit=20, offset=0):
}
elif not main_topic:
logger.warning(f"No main_topic and no topics found for shout#{shout_id}")
main_topic = {"id": 0, "title": "no topic", "slug": "notopic", "is_main": True}
main_topic = {
"id": 0,
"title": "no topic",
"slug": "notopic",
"is_main": True,
}
shout_dict["main_topic"] = main_topic
# logger.debug(f"Final main_topic for shout#{shout_id}: {main_topic}")
@ -270,7 +286,9 @@ def get_shouts_with_links(info, q, limit=20, offset=0):
media_data = orjson.loads(media_data)
except orjson.JSONDecodeError:
media_data = []
shout_dict["media"] = [media_data] if isinstance(media_data, dict) else media_data
shout_dict["media"] = (
[media_data] if isinstance(media_data, dict) else media_data
)
shouts.append(shout_dict)
@ -358,7 +376,9 @@ def apply_sorting(q, options):
"""
order_str = options.get("order_by")
if order_str in ["rating", "comments_count", "last_commented_at"]:
query_order_by = desc(text(order_str)) if options.get("order_by_desc", True) else asc(text(order_str))
query_order_by = (
desc(text(order_str)) if options.get("order_by_desc", True) else asc(text(order_str))
)
q = q.distinct(text(order_str), Shout.id).order_by( # DISTINCT ON включает поле сортировки
nulls_last(query_order_by), Shout.id
)
@ -442,7 +462,8 @@ async def load_shouts_unrated(_, info, options):
select(Reaction.shout)
.where(
and_(
Reaction.deleted_at.is_(None), Reaction.kind.in_([ReactionKind.LIKE.value, ReactionKind.DISLIKE.value])
Reaction.deleted_at.is_(None),
Reaction.kind.in_([ReactionKind.LIKE.value, ReactionKind.DISLIKE.value]),
)
)
.group_by(Reaction.shout)
@ -453,11 +474,15 @@ async def load_shouts_unrated(_, info, options):
q = select(Shout).where(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
q = q.join(Author, Author.id == Shout.created_by)
q = q.add_columns(
json_builder("id", Author.id, "name", Author.name, "slug", Author.slug, "pic", Author.pic).label("main_author")
json_builder("id", Author.id, "name", Author.name, "slug", Author.slug, "pic", Author.pic).label(
"main_author"
)
)
q = q.join(ShoutTopic, and_(ShoutTopic.shout == Shout.id, ShoutTopic.main.is_(True)))
q = q.join(Topic, Topic.id == ShoutTopic.topic)
q = q.add_columns(json_builder("id", Topic.id, "title", Topic.title, "slug", Topic.slug).label("main_topic"))
q = q.add_columns(
json_builder("id", Topic.id, "title", Topic.title, "slug", Topic.slug).label("main_topic")
)
q = q.where(Shout.id.not_in(rated_shouts))
q = q.order_by(func.random())

View File

@ -4,7 +4,7 @@ from sqlalchemy import and_, distinct, func, join, select
from sqlalchemy.orm import aliased
from cache.cache import cache_author
from orm.author import Author, AuthorFollower
from auth.orm import Author, AuthorFollower
from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic, TopicFollower
@ -177,7 +177,9 @@ def get_topic_comments_stat(topic_id: int) -> int:
.subquery()
)
# Запрос для суммирования количества комментариев по теме
q = select(func.coalesce(func.sum(sub_comments.c.comments_count), 0)).filter(ShoutTopic.topic == topic_id)
q = select(func.coalesce(func.sum(sub_comments.c.comments_count), 0)).filter(
ShoutTopic.topic == topic_id
)
q = q.outerjoin(sub_comments, ShoutTopic.shout == sub_comments.c.shout_id)
with local_session() as session:
result = session.execute(q).first()
@ -237,7 +239,9 @@ def get_author_followers_stat(author_id: int) -> int:
:return: Количество уникальных подписчиков автора.
"""
aliased_followers = aliased(AuthorFollower)
q = select(func.count(distinct(aliased_followers.follower))).filter(aliased_followers.author == author_id)
q = select(func.count(distinct(aliased_followers.follower))).filter(
aliased_followers.author == author_id
)
with local_session() as session:
result = session.execute(q).first()
return result[0] if result else 0
@ -282,14 +286,16 @@ def get_with_stat(q):
q = add_author_stat_columns(q) if is_author else add_topic_stat_columns(q)
# Выполняем запрос
result = session.execute(q)
result = session.execute(q).unique()
for cols in result:
entity = cols[0]
stat = dict()
stat["shouts"] = cols[1] # Статистика по публикациям
stat["followers"] = cols[2] # Статистика по подписчикам
if is_author:
stat["authors"] = get_author_authors_stat(entity.id) # Статистика по подпискам на авторов
stat["authors"] = get_author_authors_stat(
entity.id
) # Статистика по подпискам на авторов
stat["comments"] = get_author_comments_stat(entity.id) # Статистика по комментариям
else:
stat["authors"] = get_topic_authors_stat(entity.id) # Статистика по авторам темы

View File

@ -8,7 +8,7 @@ from cache.cache import (
get_cached_topic_followers,
invalidate_cache_by_prefix,
)
from orm.author import Author
from auth.orm import Author
from orm.topic import Topic
from orm.reaction import ReactionKind
from resolvers.stat import get_with_stat

71
schema/admin.graphql Normal file
View File

@ -0,0 +1,71 @@
type EnvVariable {
key: String!
value: String!
description: String
type: String!
isSecret: Boolean
}
type EnvSection {
name: String!
description: String
variables: [EnvVariable!]!
}
input EnvVariableInput {
key: String!
value: String!
type: String!
}
# Типы для управления пользователями
type AdminUserInfo {
id: Int!
email: String
name: String
slug: String
roles: [String!]
created_at: Int
last_seen: Int
muted: Boolean
is_active: Boolean
}
input AdminUserUpdateInput {
id: Int!
roles: [String!]
muted: Boolean
is_active: Boolean
}
type Role {
id: String!
name: String!
description: String
}
# Тип для пагинированного ответа пользователей
type AdminUserListResponse {
users: [AdminUserInfo!]!
total: Int!
page: Int!
perPage: Int!
totalPages: Int!
}
extend type Query {
getEnvVariables: [EnvSection!]!
# Запросы для управления пользователями
adminGetUsers(limit: Int, offset: Int, search: String): AdminUserListResponse!
adminGetRoles: [Role!]!
}
extend type Mutation {
updateEnvVariable(key: String!, value: String!): Boolean!
updateEnvVariables(variables: [EnvVariableInput!]!): Boolean!
# Мутации для управления пользователями
adminUpdateUser(user: AdminUserUpdateInput!): Boolean!
adminToggleUserBlock(userId: Int!): Boolean!
adminToggleUserMute(userId: Int!): Boolean!
}

View File

@ -51,3 +51,18 @@ enum InviteStatus {
ACCEPTED
REJECTED
}
# Auth enums
enum AuthAction {
LOGIN
REGISTER
CONFIRM_EMAIL
RESET_PASSWORD
CHANGE_PASSWORD
}
enum RoleType {
SYSTEM
COMMUNITY
CUSTOM
}

View File

@ -116,3 +116,25 @@ input CommunityInput {
desc: String
pic: String
}
# Auth inputs
input LoginCredentials {
email: String!
password: String!
}
input RegisterInput {
email: String!
password: String
name: String
}
input ChangePasswordInput {
oldPassword: String!
newPassword: String!
}
input ResetPasswordInput {
token: String!
newPassword: String!
}

View File

@ -1,4 +1,14 @@
type Mutation {
# Auth mutations
login(email: String!, password: String!): AuthResult!
registerUser(email: String!, password: String, name: String): AuthResult!
sendLink(email: String!, lang: String, template: String): Author!
confirmEmail(token: String!): AuthResult!
getSession: SessionInfo!
changePassword(oldPassword: String!, newPassword: String!): AuthSuccess!
resetPassword(token: String!, newPassword: String!): AuthSuccess!
requestPasswordReset(email: String!, lang: String): AuthSuccess!
# author
rate_author(rated_slug: String!, value: Int!): CommonResult!
update_author(profile: ProfileInput!): CommonResult!

View File

@ -6,6 +6,14 @@ type Query {
load_authors_by(by: AuthorsBy!, limit: Int, offset: Int): [Author]
# search_authors(what: String!): [Author]
# Auth queries
signOut: AuthSuccess!
me: AuthResult!
isEmailUsed(email: String!): Boolean!
isAdmin: Boolean!
getOAuthProviders: [OAuthProvider!]!
getRoles: [RolesInfo!]!
# community
get_community: Community
get_communities_all: [Community]

View File

@ -23,10 +23,14 @@ type Author {
last_seen: Int
updated_at: Int
deleted_at: Int
email: String
seo: String
# synthetic
stat: AuthorStat # ratings inside
communities: [Community]
# Auth fields
roles: [String!]
email_verified: Boolean
}
type ReactionUpdating {
@ -280,3 +284,39 @@ type MyRateComment {
my_rate: ReactionKind
}
# Auth types
type AuthResult {
success: Boolean!
error: String
token: String
author: Author
}
type Permission {
resource: String!
action: String!
conditions: String
}
type SessionInfo {
token: String!
author: Author!
}
type AuthSuccess {
success: Boolean!
}
type OAuthProvider {
id: String!
name: String!
url: String!
}
type RolesInfo {
id: String!
name: String!
description: String
permissions: [Permission!]!
}

View File

@ -1,120 +1,90 @@
from functools import wraps
from typing import Tuple
from cache.cache import get_cached_author_by_user_id
from resolvers.stat import get_with_stat
from services.schema import request_graphql_data
from settings import ADMIN_SECRET, AUTH_URL
from utils.logger import root_logger as logger
from auth.internal import verify_internal_auth
from sqlalchemy import exc
from services.db import local_session
from auth.orm import Author, Role
# Список разрешенных заголовков
ALLOWED_HEADERS = ["Authorization", "Content-Type"]
async def check_auth(req):
async def check_auth(req) -> Tuple[str, list[str]]:
"""
Проверка авторизации пользователя.
Эта функция проверяет токен авторизации, переданный в заголовках запроса,
и возвращает идентификатор пользователя и его роли.
Проверяет токен и получает данные из локальной БД.
Параметры:
- req: Входящий GraphQL запрос, содержащий заголовок авторизации.
Возвращает:
- user_id: str - Идентификатор пользователя.
- user_roles: list[str] - Список ролей пользователя.
- user_id: str - Идентификатор пользователя
- user_roles: list[str] - Список ролей пользователя
"""
# Проверяем наличие токена
token = req.headers.get("Authorization")
if not token:
return "", []
host = req.headers.get("host", "")
logger.debug(f"check_auth: host={host}")
auth_url = AUTH_URL
if ".dscrs.site" in host or "localhost" in host:
auth_url = "https://auth.dscrs.site/graphql"
user_id = ""
user_roles = []
if token:
# Проверяем и очищаем токен от префикса Bearer если он есть
if token.startswith("Bearer "):
token = token.split("Bearer ")[-1].strip()
# Logging the authentication token
logger.debug(f"TOKEN: {token}")
query_name = "validate_jwt_token"
operation = "ValidateToken"
variables = {"params": {"token_type": "access_token", "token": token}}
# Очищаем токен от префикса Bearer если он есть
if token.startswith("Bearer "):
token = token.split("Bearer ")[-1].strip()
# Только необходимые заголовки для GraphQL запроса
headers = {"Content-Type": "application/json"}
logger.debug(f"Checking auth token: {token[:10]}...")
gql = {
"query": f"query {operation}($params: ValidateJWTTokenInput!)"
+ "{"
+ f"{query_name}(params: $params) {{ is_valid claims }} "
+ "}",
"variables": variables,
"operationName": operation,
}
data = await request_graphql_data(gql, url=auth_url, headers=headers)
if data:
logger.debug(f"Auth response: {data}")
validation_result = data.get("data", {}).get(query_name, {})
logger.debug(f"Validation result: {validation_result}")
is_valid = validation_result.get("is_valid", False)
if not is_valid:
logger.error(f"Token validation failed: {validation_result}")
return "", []
user_data = validation_result.get("claims", {})
logger.debug(f"User claims: {user_data}")
user_id = user_data.get("sub", "")
user_roles = user_data.get("allowed_roles", [])
return user_id, user_roles
# Проверяем авторизацию внутренним механизмом
logger.debug("Using internal authentication")
return await verify_internal_auth(token)
async def add_user_role(user_id):
async def add_user_role(user_id: str, roles: list[str] = None):
"""
Добавление роли пользователя.
Добавление ролей пользователю в локальной БД.
Эта функция добавляет роли "author" и "reader" для указанного пользователя
в системе авторизации.
Параметры:
- user_id: str - Идентификатор пользователя, которому нужно добавить роли.
Возвращает:
- user_id: str - Идентификатор пользователя, если операция прошла успешно.
Args:
user_id: ID пользователя
roles: Список ролей для добавления. По умолчанию ["author", "reader"]
"""
logger.info(f"add author role for user_id: {user_id}")
query_name = "_update_user"
operation = "UpdateUserRoles"
headers = {
"Content-Type": "application/json",
"x-authorizer-admin-secret": ADMIN_SECRET,
}
variables = {"params": {"roles": "author, reader", "id": user_id}}
gql = {
"query": f"mutation {operation}($params: UpdateUserInput!) {{ {query_name}(params: $params) {{ id roles }} }}",
"variables": variables,
"operationName": operation,
}
data = await request_graphql_data(gql, headers=headers)
if data:
user_id = data.get("data", {}).get(query_name, {}).get("id")
return user_id
if not roles:
roles = ["author", "reader"]
logger.info(f"Adding roles {roles} to user {user_id}")
logger.debug("Using local authentication")
with local_session() as session:
try:
author = session.query(Author).filter(Author.id == user_id).one()
# Получаем существующие роли
existing_roles = set(role.name for role in author.roles)
# Добавляем новые роли
for role_name in roles:
if role_name not in existing_roles:
# Получаем или создаем роль
role = session.query(Role).filter(Role.name == role_name).first()
if not role:
role = Role(id=role_name, name=role_name)
session.add(role)
# Добавляем роль автору
author.roles.append(role)
session.commit()
return user_id
except exc.NoResultFound:
logger.error(f"Author {user_id} not found")
return None
def login_required(f):
"""
Декоратор для проверки авторизации пользователя.
Этот декоратор проверяет, авторизован ли пользователь, <EFBFBD><EFBFBD> добавляет
информацию о пользователе в контекст функции.
Параметры:
- f: Функция, которую нужно декорировать.
Возвращает:
- Обернутую функцию с добавленной проверкой авторизации.
"""
"""Декоратор для проверки авторизации пользователя."""
@wraps(f)
async def decorated_function(*args, **kwargs):
@ -135,18 +105,7 @@ def login_required(f):
def login_accepted(f):
"""
Декоратор для добавления данных авторизации в контекст.
Этот декоратор добавляет данные авторизации в контекст, если они доступны,
но не блокирует доступ для неавторизованных пользователей.
Параметры:
- f: Функция, которую нужно декорировать.
Возвращает:
- Обернутую функцию с добавленной проверкой авторизации.
"""
"""Декоратор для добавления данных авторизации в контекст."""
@wraps(f)
async def decorated_function(*args, **kwargs):
@ -166,12 +125,11 @@ def login_accepted(f):
author = await get_cached_author_by_user_id(user_id, get_with_stat)
if author:
logger.debug(f"login_accepted: Найден профиль автора: {author}")
# Предполагается, что `author` является объектом с атрибутом `id`
info.context["author"] = author.dict()
else:
logger.error(
f"login_accepted: Профиль автора не найден для пользователя {user_id}. Используем базовые данные."
) # Используем базовую информацию об автор
)
else:
logger.debug("login_accepted: Пользователь не авторизован. Очищаем контекст.")
info.context["user_id"] = None

View File

@ -50,10 +50,25 @@ FILTERED_FIELDS = ["_sa_instance_state", "search_vector"]
def create_table_if_not_exists(engine, table):
"""
Создает таблицу, если она не существует в базе данных.
Args:
engine: SQLAlchemy движок базы данных
table: Класс модели SQLAlchemy
"""
inspector = inspect(engine)
if table and not inspector.has_table(table.__tablename__):
table.__table__.create(engine)
logger.info(f"Table '{table.__tablename__}' created.")
try:
table.__table__.create(engine)
logger.info(f"Table '{table.__tablename__}' created.")
except exc.OperationalError as e:
# Проверяем, содержит ли ошибка упоминание о том, что индекс уже существует
if "already exists" in str(e):
logger.warning(f"Skipping index creation for table '{table.__tablename__}': {e}")
else:
# Перевыбрасываем ошибку, если она не связана с дублированием
raise
else:
logger.info(f"Table '{table.__tablename__}' ok.")
@ -154,21 +169,43 @@ class Base(declarative_base()):
REGISTRY[cls.__name__] = cls
def dict(self) -> Dict[str, Any]:
"""
Конвертирует ORM объект в словарь.
Пропускает атрибуты, которые отсутствуют в объекте, но присутствуют в колонках таблицы.
Преобразует JSON поля в словари.
Добавляет синтетическое поле .stat, если оно существует.
Returns:
Dict[str, Any]: Словарь с атрибутами объекта
"""
column_names = filter(lambda x: x not in FILTERED_FIELDS, self.__table__.columns.keys())
data = {}
try:
for column_name in column_names:
value = getattr(self, column_name)
# Check if the value is JSON and decode it if necessary
if isinstance(value, (str, bytes)) and isinstance(self.__table__.columns[column_name].type, JSON):
try:
data[column_name] = orjson.loads(value)
except (TypeError, orjson.JSONDecodeError) as e:
logger.error(f"Error decoding JSON for column '{column_name}': {e}")
data[column_name] = value
else:
data[column_name] = value
# Add synthetic field .stat if it exists
try:
# Проверяем, существует ли атрибут в объекте
if hasattr(self, column_name):
value = getattr(self, column_name)
# Проверяем, является ли значение JSON и декодируем его при необходимости
if isinstance(value, (str, bytes)) and isinstance(
self.__table__.columns[column_name].type, JSON
):
try:
data[column_name] = orjson.loads(value)
except (TypeError, orjson.JSONDecodeError) as e:
logger.error(f"Error decoding JSON for column '{column_name}': {e}")
data[column_name] = value
else:
data[column_name] = value
else:
# Пропускаем атрибут, если его нет в объекте (может быть добавлен после миграции)
logger.debug(
f"Skipping missing attribute '{column_name}' for {self.__class__.__name__}"
)
except AttributeError as e:
logger.warning(f"Attribute error for column '{column_name}': {e}")
# Добавляем синтетическое поле .stat если оно существует
if hasattr(self, "stat"):
data["stat"] = self.stat
except Exception as e:
@ -186,7 +223,9 @@ class Base(declarative_base()):
# Функция для вывода полного трейсбека при предупреждениях
def warning_with_traceback(message: Warning | str, category, filename: str, lineno: int, file=None, line=None):
def warning_with_traceback(
message: Warning | str, category, filename: str, lineno: int, file=None, line=None
):
tb = traceback.format_stack()
tb_str = "".join(tb)
return f"{message} ({filename}, {lineno}): {category.__name__}\n{tb_str}"

111
services/env.py Normal file
View File

@ -0,0 +1,111 @@
from typing import Dict, List, Optional
from dataclasses import dataclass
from redis import Redis
from settings import REDIS_URL
from utils.logger import root_logger as logger
@dataclass
class EnvVariable:
key: str
value: str
description: Optional[str] = None
type: str = "string"
is_secret: bool = False
@dataclass
class EnvSection:
name: str
variables: List[EnvVariable]
description: Optional[str] = None
class EnvManager:
"""
Менеджер переменных окружения с хранением в Redis
"""
def __init__(self):
self.redis = Redis.from_url(REDIS_URL)
self.prefix = "env:"
def get_all_variables(self) -> List[EnvSection]:
"""
Получение всех переменных окружения, сгруппированных по секциям
"""
try:
# Получаем все ключи с префиксом env:
keys = self.redis.keys(f"{self.prefix}*")
variables: Dict[str, str] = {}
for key in keys:
var_key = key.decode("utf-8").replace(self.prefix, "")
value = self.redis.get(key)
if value:
variables[var_key] = value.decode("utf-8")
# Группируем переменные по секциям
sections = [
EnvSection(
name="Авторизация",
description="Настройки системы авторизации",
variables=[
EnvVariable(
key="JWT_SECRET",
value=variables.get("JWT_SECRET", ""),
description="Секретный ключ для JWT токенов",
type="string",
is_secret=True,
),
],
),
EnvSection(
name="Redis",
description="Настройки подключения к Redis",
variables=[
EnvVariable(
key="REDIS_URL",
value=variables.get("REDIS_URL", ""),
description="URL подключения к Redis",
type="string",
)
],
),
# Добавьте другие секции по необходимости
]
return sections
except Exception as e:
logger.error(f"Ошибка получения переменных: {e}")
return []
def update_variable(self, key: str, value: str) -> bool:
"""
Обновление значения переменной
"""
try:
full_key = f"{self.prefix}{key}"
self.redis.set(full_key, value)
return True
except Exception as e:
logger.error(f"Ошибка обновления переменной {key}: {e}")
return False
def update_variables(self, variables: List[EnvVariable]) -> bool:
"""
Массовое обновление переменных
"""
try:
pipe = self.redis.pipeline()
for var in variables:
full_key = f"{self.prefix}{var.key}"
pipe.set(full_key, var.value)
pipe.execute()
return True
except Exception as e:
logger.error(f"Ошибка массового обновления переменных: {e}")
return False
env_manager = EnvManager()

View File

@ -14,4 +14,7 @@ class ExceptionHandlerMiddleware(BaseHTTPMiddleware):
return response
except Exception as exc:
logger.exception(exc)
return JSONResponse({"detail": "An error occurred. Please try again later."}, status_code=500)
return JSONResponse(
{"detail": "An error occurred. Please try again later."},
status_code=500,
)

View File

@ -94,8 +94,7 @@ async def notify_draft(draft_data, action: str = "publish"):
# Если переданы связанные атрибуты, добавим их
if hasattr(draft_data, "topics") and draft_data.topics is not None:
draft_payload["topics"] = [
{"id": t.id, "name": t.name, "slug": t.slug}
for t in draft_data.topics
{"id": t.id, "name": t.name, "slug": t.slug} for t in draft_data.topics
]
if hasattr(draft_data, "authors") and draft_data.authors is not None:

View File

@ -40,6 +40,17 @@ class RedisService:
except Exception as e:
logger.error(e)
def pipeline(self):
"""
Возвращает пайплайн Redis для выполнения нескольких команд в одной транзакции.
Returns:
Pipeline: объект pipeline Redis
"""
if self._client:
return self._client.pipeline()
raise Exception("Redis client is not initialized")
async def subscribe(self, *channels):
if self._client:
async with self._client.pubsub() as pubsub:
@ -75,6 +86,82 @@ class RedisService:
async def get(self, key):
return await self.execute("get", key)
async def delete(self, *keys):
"""
Удаляет ключи из Redis.
Args:
*keys: Ключи для удаления
Returns:
int: Количество удаленных ключей
"""
if not self._client or not keys:
return 0
return await self._client.delete(*keys)
async def hmset(self, key, mapping):
"""
Устанавливает несколько полей хеша.
Args:
key: Ключ хеша
mapping: Словарь с полями и значениями
"""
if not self._client:
return
await self._client.hset(key, mapping=mapping)
async def expire(self, key, seconds):
"""
Устанавливает время жизни ключа.
Args:
key: Ключ
seconds: Время жизни в секундах
"""
if not self._client:
return
await self._client.expire(key, seconds)
async def sadd(self, key, *values):
"""
Добавляет значения в множество.
Args:
key: Ключ множества
*values: Значения для добавления
"""
if not self._client:
return
await self._client.sadd(key, *values)
async def srem(self, key, *values):
"""
Удаляет значения из множества.
Args:
key: Ключ множества
*values: Значения для удаления
"""
if not self._client:
return
await self._client.srem(key, *values)
async def smembers(self, key):
"""
Получает все элементы множества.
Args:
key: Ключ множества
Returns:
set: Множество элементов
"""
if not self._client:
return set()
return await self._client.smembers(key)
redis = RedisService()

View File

@ -1,10 +1,8 @@
from asyncio.log import logger
import httpx
from ariadne import MutationType, ObjectType, QueryType
from services.db import create_table_if_not_exists, local_session
from settings import AUTH_URL
query = QueryType()
mutation = MutationType()
@ -12,50 +10,19 @@ type_draft = ObjectType("Draft")
resolvers = [query, mutation, type_draft]
async def request_graphql_data(gql, url=AUTH_URL, headers=None):
"""
Выполняет GraphQL запрос к указанному URL
:param gql: GraphQL запрос
:param url: URL для запроса, по умолчанию AUTH_URL
:param headers: Заголовки запроса
:return: Результат запроса или None в случае ошибки
"""
if not url:
return None
if headers is None:
headers = {"Content-Type": "application/json"}
try:
async with httpx.AsyncClient() as client:
response = await client.post(url, json=gql, headers=headers)
if response.status_code == 200:
data = response.json()
errors = data.get("errors")
if errors:
logger.error(f"{url} response: {data}")
else:
return data
else:
logger.error(f"{url}: {response.status_code} {response.text}")
except Exception as _e:
import traceback
logger.error(f"request_graphql_data error: {traceback.format_exc()}")
return None
def create_all_tables():
"""Create all database tables in the correct order."""
from orm import author, community, draft, notification, reaction, shout, topic
from auth.orm import Author, AuthorFollower, AuthorBookmark, AuthorRating
from orm import community, draft, notification, reaction, shout, topic
# Порядок важен - сначала таблицы без внешних ключей, затем зависимые таблицы
models_in_order = [
# user.User, # Базовая таблица auth
author.Author, # Базовая таблица
Author, # Базовая таблица
community.Community, # Базовая таблица
topic.Topic, # Базовая таблица
# Связи для базовых таблиц
author.AuthorFollower, # Зависит от Author
AuthorFollower, # Зависит от Author
community.CommunityFollower, # Зависит от Community
topic.TopicFollower, # Зависит от Topic
# Черновики (теперь без зависимости от Shout)
@ -70,7 +37,8 @@ def create_all_tables():
reaction.Reaction, # Зависит от Author и Shout
shout.ShoutReactionsFollower, # Зависит от Shout и Reaction
# Дополнительные таблицы
author.AuthorRating, # Зависит от Author
AuthorRating, # Зависит от Author
AuthorBookmark, # Зависит от Author
notification.Notification, # Зависит от Author
notification.NotificationSeen, # Зависит от Notification
# collection.Collection,

View File

@ -171,11 +171,16 @@ class SearchService:
}
asyncio.create_task(self.perform_index(shout, index_body))
def close(self):
if self.client:
self.client.close()
async def perform_index(self, shout, index_body):
if self.client:
try:
await asyncio.wait_for(
self.client.index(index=self.index_name, id=str(shout.id), body=index_body), timeout=40.0
self.client.index(index=self.index_name, id=str(shout.id), body=index_body),
timeout=40.0,
)
except asyncio.TimeoutError:
logger.error(f"Indexing timeout for shout {shout.id}")
@ -188,7 +193,9 @@ class SearchService:
logger.info(f"Ищем: {text} {offset}+{limit}")
search_body = {
"query": {"multi_match": {"query": text, "fields": ["title", "lead", "subtitle", "body", "media"]}}
"query": {
"multi_match": {"query": text, "fields": ["title", "lead", "subtitle", "body", "media"]}
}
}
if self.client:

View File

@ -14,7 +14,7 @@ from google.analytics.data_v1beta.types import (
)
from google.analytics.data_v1beta.types import Filter as GAFilter
from orm.author import Author
from auth.orm import Author
from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic
from services.db import local_session
@ -228,12 +228,20 @@ class ViewedStorage:
# Обновление тем и авторов с использованием вспомогательной функции
for [_st, topic] in (
session.query(ShoutTopic, Topic).join(Topic).join(Shout).where(Shout.slug == shout_slug).all()
session.query(ShoutTopic, Topic)
.join(Topic)
.join(Shout)
.where(Shout.slug == shout_slug)
.all()
):
update_groups(self.shouts_by_topic, topic.slug, shout_slug)
for [_st, author] in (
session.query(ShoutAuthor, Author).join(Author).join(Shout).where(Shout.slug == shout_slug).all()
session.query(ShoutAuthor, Author)
.join(Author)
.join(Shout)
.where(Shout.slug == shout_slug)
.all()
):
update_groups(self.shouts_by_author, author.slug, shout_slug)
@ -266,7 +274,9 @@ class ViewedStorage:
if failed == 0:
when = datetime.now(timezone.utc) + timedelta(seconds=self.period)
t = format(when.astimezone().isoformat())
logger.info(" ⎩ next update: %s" % (t.split("T")[0] + " " + t.split("T")[1].split(".")[0]))
logger.info(
" ⎩ next update: %s" % (t.split("T")[0] + " " + t.split("T")[1].split(".")[0])
)
await asyncio.sleep(self.period)
else:
await asyncio.sleep(10)

View File

@ -1,175 +0,0 @@
import asyncio
import os
import re
from asyncio.log import logger
from sqlalchemy import select
from starlette.endpoints import HTTPEndpoint
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import JSONResponse
from cache.cache import cache_author
from orm.author import Author
from resolvers.stat import get_with_stat
from services.db import local_session
from services.schema import request_graphql_data
from settings import ADMIN_SECRET, WEBHOOK_SECRET
async def check_webhook_existence():
"""
Проверяет существование вебхука для user.login события
Returns:
tuple: (bool, str, str) - существует ли вебхук, его id и endpoint если существует
"""
logger.info("check_webhook_existence called")
if not ADMIN_SECRET:
logger.error("ADMIN_SECRET is not set")
return False, None, None
headers = {"Content-Type": "application/json", "X-Authorizer-Admin-Secret": ADMIN_SECRET}
operation = "GetWebhooks"
query_name = "_webhooks"
variables = {"params": {}}
# https://docs.authorizer.dev/core/graphql-api#_webhooks
gql = {
"query": f"query {operation}($params: PaginatedInput!)"
+ "{"
+ f"{query_name}(params: $params) {{ webhooks {{ id event_name endpoint }} }} "
+ "}",
"variables": variables,
"operationName": operation,
}
result = await request_graphql_data(gql, headers=headers)
if result:
webhooks = result.get("data", {}).get(query_name, {}).get("webhooks", [])
logger.info(webhooks)
for webhook in webhooks:
if webhook["event_name"].startswith("user.login"):
return True, webhook["id"], webhook["endpoint"]
return False, None, None
async def create_webhook_endpoint():
"""
Создает вебхук для user.login события.
Если существует старый вебхук - удаляет его и создает новый.
"""
logger.info("create_webhook_endpoint called")
headers = {"Content-Type": "application/json", "X-Authorizer-Admin-Secret": ADMIN_SECRET}
exists, webhook_id, current_endpoint = await check_webhook_existence()
# Определяем endpoint в зависимости от окружения
host = os.environ.get("HOST", "core.dscrs.site")
endpoint = f"https://{host}/new-author"
if exists:
# Если вебхук существует, но с другим endpoint или с модифицированным именем
if current_endpoint != endpoint or webhook_id:
# https://docs.authorizer.dev/core/graphql-api#_delete_webhook
operation = "DeleteWebhook"
query_name = "_delete_webhook"
variables = {"params": {"id": webhook_id}} # Изменено с id на webhook_id
gql = {
"query": f"mutation {operation}($params: WebhookRequest!)"
+ "{"
+ f"{query_name}(params: $params) {{ message }} "
+ "}",
"variables": variables,
"operationName": operation,
}
try:
await request_graphql_data(gql, headers=headers)
exists = False
except Exception as e:
logger.error(f"Failed to delete webhook: {e}")
# Продолжаем выполнение даже при ошибке удаления
exists = False
else:
logger.info(f"Webhook already exists and configured correctly: {webhook_id}")
return
if not exists:
# https://docs.authorizer.dev/core/graphql-api#_add_webhook
operation = "AddWebhook"
query_name = "_add_webhook"
variables = {
"params": {
"event_name": "user.login",
"endpoint": endpoint,
"enabled": True,
"headers": {"Authorization": WEBHOOK_SECRET},
}
}
gql = {
"query": f"mutation {operation}($params: AddWebhookRequest!)"
+ "{"
+ f"{query_name}(params: $params) {{ message }} "
+ "}",
"variables": variables,
"operationName": operation,
}
try:
result = await request_graphql_data(gql, headers=headers)
logger.info(result)
except Exception as e:
logger.error(f"Failed to create webhook: {e}")
class WebhookEndpoint(HTTPEndpoint):
async def post(self, request: Request) -> JSONResponse:
try:
data = await request.json()
if not data:
raise HTTPException(status_code=400, detail="Request body is empty")
auth = request.headers.get("Authorization")
if not auth or auth != os.environ.get("WEBHOOK_SECRET"):
raise HTTPException(status_code=401, detail="Invalid Authorization header")
# logger.debug(data)
user = data.get("user")
if not isinstance(user, dict):
raise HTTPException(status_code=400, detail="User data is not a dictionary")
#
name: str = (
f"{user.get('given_name', user.get('slug'))} {user.get('middle_name', '')}"
+ f"{user.get('family_name', '')}".strip()
) or "Аноним"
user_id: str = user.get("id", "")
email: str = user.get("email", "")
pic: str = user.get("picture", "")
if user_id:
with local_session() as session:
author = session.query(Author).filter(Author.user == user_id).first()
if not author:
# If the author does not exist, create a new one
slug: str = email.split("@")[0].replace(".", "-").lower()
slug: str = re.sub("[^0-9a-z]+", "-", slug)
while True:
author = session.query(Author).filter(Author.slug == slug).first()
if not author:
break
slug = f"{slug}-{len(session.query(Author).filter(Author.email == email).all()) + 1}"
author = Author(user=user_id, slug=slug, name=name, pic=pic)
session.add(author)
session.commit()
author_query = select(Author).filter(Author.user == user_id)
result = get_with_stat(author_query)
if result:
author_with_stat = result[0]
author_dict = author_with_stat.dict()
# await cache_author(author_with_stat)
asyncio.create_task(cache_author(author_dict))
return JSONResponse({"status": "success"})
except HTTPException as e:
return JSONResponse({"status": "error", "message": str(e.detail)}, status_code=e.status_code)
except Exception as e:
import traceback
traceback.print_exc()
return JSONResponse({"status": "error", "message": str(e)}, status_code=500)

View File

@ -1,3 +1,6 @@
"""Настройки приложения"""
import os
import sys
from os import environ
@ -17,13 +20,50 @@ REDIS_URL = environ.get("REDIS_URL") or "redis://127.0.0.1"
# debug
GLITCHTIP_DSN = environ.get("GLITCHTIP_DSN")
# authorizer.dev
AUTH_URL = environ.get("AUTH_URL") or "https://auth.discours.io/graphql"
# auth
ADMIN_SECRET = environ.get("AUTH_SECRET") or "nothing"
WEBHOOK_SECRET = environ.get("WEBHOOK_SECRET") or "nothing-else"
ADMIN_EMAILS = environ.get("ADMIN_EMAILS") or "services@discours.io,guests@discours.io,welcome@discours.io"
# own auth
ONETIME_TOKEN_LIFE_SPAN = 60 * 60 * 24 * 3 # 3 days
SESSION_TOKEN_LIFE_SPAN = 60 * 60 * 24 * 30 # 30 days
ONETIME_TOKEN_LIFE_SPAN = 60 * 15 # 15 минут
SESSION_TOKEN_LIFE_SPAN = 60 * 60 * 24 * 30 # 30 дней
SESSION_TOKEN_HEADER = "Authorization"
JWT_ALGORITHM = "HS256"
JWT_SECRET_KEY = environ.get("JWT_SECRET") or "nothing-else-jwt-secret-matters"
# URL фронтенда
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
# Настройки OAuth провайдеров
OAUTH_CLIENTS = {
"GOOGLE": {
"id": os.getenv("GOOGLE_CLIENT_ID", ""),
"key": os.getenv("GOOGLE_CLIENT_SECRET", ""),
},
"GITHUB": {
"id": os.getenv("GITHUB_CLIENT_ID", ""),
"key": os.getenv("GITHUB_CLIENT_SECRET", ""),
},
"FACEBOOK": {
"id": os.getenv("FACEBOOK_CLIENT_ID", ""),
"key": os.getenv("FACEBOOK_CLIENT_SECRET", ""),
},
}
# Настройки базы данных
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/discours")
# Настройки JWT
JWT_SECRET = os.getenv("JWT_SECRET", "your-secret-key")
JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 30
JWT_REFRESH_TOKEN_EXPIRE_DAYS = 30
# Настройки сессии
SESSION_COOKIE_NAME = "session_token"
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "lax"
SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 days
MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY", "")
MAILGUN_DOMAIN = os.getenv("MAILGUN_DOMAIN", "discours.io")

24
tsconfig.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"allowJs": true,
"strict": true,
"noEmit": true,
"types": [],
"resolveJsonModule": true,
"skipLibCheck": true,
"isolatedModules": true,
"lib": ["DOM", "ESNext"],
"paths": {
"~/*": ["panel/admin/*"],
"@/*": ["panel/auth/*"]
}
},
"exclude": []
}

View File

@ -1 +0,0 @@

View File

@ -2,6 +2,28 @@
Модуль для обработки HTML-фрагментов
"""
import trafilatura
def extract_text(html: str) -> str:
"""
Извлекает текст из HTML-фрагмента.
Args:
html: HTML-фрагмент
Returns:
str: Текст из HTML-фрагмента
"""
return trafilatura.extract(
wrap_html_fragment(html),
include_comments=False,
include_tables=False,
include_images=False,
include_formatting=False,
)
def wrap_html_fragment(fragment: str) -> str:
"""
Оборачивает HTML-фрагмент в полную HTML-структуру для корректной обработки.
@ -20,7 +42,7 @@ def wrap_html_fragment(fragment: str) -> str:
return fragment
# Проверяем, является ли контент полным HTML-документом
is_full_html = fragment.strip().startswith('<!DOCTYPE') or fragment.strip().startswith('<html')
is_full_html = fragment.strip().startswith("<!DOCTYPE") or fragment.strip().startswith("<html")
# Если это фрагмент, оборачиваем его в полный HTML-документ
if not is_full_html:

65
utils/generate_slug.py Normal file
View File

@ -0,0 +1,65 @@
import re
from urllib.parse import quote_plus
from auth.orm import Author
from services.db import local_session
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(Author).where(Author.slug == slug).first()
while user:
user = session.query(Author).where(Author.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("+", "-")

71
vite.config.ts Normal file
View File

@ -0,0 +1,71 @@
import { resolve } from 'path'
import { defineConfig } from 'vite'
import solidPlugin from 'vite-plugin-solid'
// Конфигурация для разных окружений
const isProd = process.env.NODE_ENV === 'production'
export default defineConfig({
plugins: [solidPlugin()],
base: '/',
build: {
target: 'esnext',
outDir: 'dist',
minify: isProd,
sourcemap: !isProd,
rollupOptions: {
input: {
main: resolve(__dirname, 'client/index.tsx')
},
output: {
// Настройка выходных файлов
entryFileNames: '[name].js',
chunkFileNames: 'chunks/[name].[hash].js',
assetFileNames: 'assets/[name].[hash][extname]',
// Настройка разделения кода
manualChunks: {
vendor: ['solid-js', '@solidjs/router'],
graphql: ['./client/graphql.ts'],
auth: ['./client/auth.ts']
}
}
},
// Оптимизация сборки
cssCodeSplit: true,
assetsInlineLimit: 4096,
chunkSizeWarningLimit: 500
},
// Настройка dev сервера
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
},
'/graphql': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
},
// Оптимизация зависимостей
optimizeDeps: {
include: ['solid-js', '@solidjs/router'],
exclude: []
},
// Настройка алиасов для путей
resolve: {
alias: {
'@': resolve(__dirname, 'client')
}
}
})