upgrade schema, resolvers, panel added
This commit is contained in:
parent
8a60bec73a
commit
2d382be794
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -162,3 +162,5 @@ views.json
|
|||
*.crt
|
||||
*cache.json
|
||||
.cursor
|
||||
|
||||
node_modules/
|
170
CHANGELOG.md
170
CHANGELOG.md
|
@ -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
|
||||
|
|
|
@ -74,6 +74,9 @@ pytest
|
|||
|
||||
# Type checking
|
||||
mypy .
|
||||
|
||||
# dev run
|
||||
python -m granian main:app --interface asgi
|
||||
```
|
||||
|
||||
### Code Style
|
||||
|
|
|
@ -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
122
auth/__init__.py
Normal 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"]),
|
||||
]
|
|
@ -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()
|
||||
|
||||
if not token:
|
||||
print("[auth.authenticate] empty token after Bearer prefix removal")
|
||||
return None
|
||||
|
||||
# Проверяем сессию в Redis
|
||||
payload = await SessionManager.verify_session(token)
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
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)
|
||||
author = (
|
||||
session.query(Author)
|
||||
.filter(Author.id == payload.user_id)
|
||||
.filter(Author.is_active == True) # noqa
|
||||
.one()
|
||||
)
|
||||
|
||||
scopes = {} # TODO: integrate await user.get_permission()
|
||||
if author.is_locked():
|
||||
return None
|
||||
|
||||
return (
|
||||
AuthCredentials(user_id=payload.user_id, scopes=scopes, logged_in=True),
|
||||
AuthUser(user_id=user.id, username=""),
|
||||
# Получаем разрешения из ролей
|
||||
scopes = author.get_permissions()
|
||||
|
||||
return AuthCredentials(
|
||||
author_id=author.id, scopes=scopes, logged_in=True, email=author.email
|
||||
)
|
||||
except exc.NoResultFound:
|
||||
pass
|
||||
|
||||
return AuthCredentials(scopes={}, error_message=str("Invalid token")), AuthUser(user_id=None, username="")
|
||||
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
|
||||
|
||||
|
|
|
@ -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
142
auth/decorators.py
Normal 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
|
127
auth/identity.py
127
auth/identity.py
|
@ -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
168
auth/internal.py
Normal 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,
|
||||
)
|
|
@ -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
110
auth/middleware.py
Normal 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
|
253
auth/oauth.py
253
auth/oauth.py
|
@ -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"]
|
||||
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)
|
||||
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)
|
||||
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
259
auth/orm.py
Normal 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
242
auth/permissions.py
Normal 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
|
|
@ -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])
|
||||
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, # will be used to store phone number or some messenger network id
|
||||
"name": name,
|
||||
"username": email,
|
||||
"name": name if name else email.split("@")[0],
|
||||
"slug": slug,
|
||||
}
|
||||
if password:
|
||||
user_dict["password"] = Password.encode(password)
|
||||
user = create_user(user_dict)
|
||||
user = await auth_send_link(_, _info, email)
|
||||
return {"user": user}
|
||||
|
||||
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)}",
|
||||
}
|
||||
|
||||
|
||||
@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: Пароль пользователя
|
||||
|
||||
Returns:
|
||||
AuthResult с данными пользователя и токеном или сообщением об ошибке
|
||||
"""
|
||||
logger.info(f"[auth] login: Попытка входа для {email}")
|
||||
|
||||
# Гарантируем, что всегда возвращаем непустой объект AuthResult
|
||||
default_response = {"success": False, "token": None, "author": None, "error": "Неизвестная ошибка"}
|
||||
|
||||
else:
|
||||
# sign in using password
|
||||
if not orm_user.emailConfirmed:
|
||||
# not an error, warns users
|
||||
return {"error": "please, confirm email"}
|
||||
else:
|
||||
try:
|
||||
user = Identity.password(orm_user, password)
|
||||
session_token = await TokenStorage.create_session(user)
|
||||
print(f"[auth] user {email} authorized")
|
||||
return {"token": session_token, "user": user}
|
||||
except InvalidPassword:
|
||||
print(f"[auth] {email}: invalid password")
|
||||
raise InvalidPassword("invalid password") # contains webserver status
|
||||
# return {"error": "invalid password"}
|
||||
# Нормализуем 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:
|
||||
# Используем 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
228
auth/sessions.py
Normal 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
|
|
@ -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}")
|
||||
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
93
biome.json
Normal 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
14
cache/cache.py
vendored
|
@ -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
17
cache/precache.py
vendored
|
@ -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}")
|
||||
|
|
1
cache/revalidator.py
vendored
1
cache/revalidator.py
vendored
|
@ -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
2
cache/triggers.py
vendored
|
@ -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
34
docs/README.md
Normal 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
757
docs/auth.md
Normal 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'
|
||||
)
|
||||
```
|
|
@ -20,15 +20,6 @@
|
|||
- Настраиваемое время жизни кеша (TTL)
|
||||
- Возможность ручной инвалидации кеша для конкретных функций и аргументов
|
||||
|
||||
## Webhooks
|
||||
|
||||
- Автоматическая регистрация вебхука для события user.login
|
||||
- Предотвращение создания дублирующихся вебхуков
|
||||
- Автоматическая очистка устаревших вебхуков
|
||||
- Поддержка авторизации вебхуков через WEBHOOK_SECRET
|
||||
- Обработка ошибок при операциях с вебхуками
|
||||
- Динамическое определение endpoint'а на основе окружения
|
||||
|
||||
## CORS Configuration
|
||||
|
||||
- Поддерживаемые методы: GET, POST, OPTIONS
|
||||
|
|
9
env.d.ts
vendored
Normal file
9
env.d.ts
vendored
Normal 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
20
index.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Admin Panel">
|
||||
<title>Admin Panel</title>
|
||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||
<meta name="theme-color" content="#228be6">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/panel/index.tsx"></script>
|
||||
<noscript>
|
||||
<div style="text-align: center; padding: 20px;">
|
||||
Для работы приложения необходим JavaScript
|
||||
</div>
|
||||
</noscript>
|
||||
</body>
|
||||
</html>
|
259
main.py
259
main.py
|
@ -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:
|
||||
|
||||
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)
|
||||
if isinstance(result, Response):
|
||||
|
||||
# Если 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)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
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:
|
||||
print(f"GraphQL error: {str(e)}")
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
# Обновляем маршрут в Starlette
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/", graphql_handler, methods=["GET", "POST"]),
|
||||
Route("/new-author", WebhookEndpoint),
|
||||
],
|
||||
lifespan=lifespan,
|
||||
debug=True,
|
||||
# Функция запуска сервера
|
||||
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(
|
||||
# Добавляем маршруты авторизации
|
||||
routes.extend(auth_routes)
|
||||
|
||||
app = Starlette(
|
||||
debug=MODE == "development",
|
||||
routes=routes,
|
||||
middleware=[
|
||||
Middleware(ExceptionHandlerMiddleware),
|
||||
Middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["https://localhost:3000"],
|
||||
allow_credentials=True,
|
||||
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],
|
||||
)
|
||||
|
|
136
orm/author.py
136
orm/author.py
|
@ -1,136 +0,0 @@
|
|||
import time
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
|
||||
|
||||
from services.db import Base
|
||||
|
||||
# from sqlalchemy_utils import TSVectorType
|
||||
|
||||
|
||||
class AuthorRating(Base):
|
||||
"""
|
||||
Рейтинг автора от другого автора.
|
||||
|
||||
Attributes:
|
||||
rater (int): ID оценивающего автора
|
||||
author (int): ID оцениваемого автора
|
||||
plus (bool): Положительная/отрицательная оценка
|
||||
"""
|
||||
|
||||
__tablename__ = "author_rating"
|
||||
|
||||
id = None # type: ignore
|
||||
rater = Column(ForeignKey("author.id"), primary_key=True)
|
||||
author = Column(ForeignKey("author.id"), primary_key=True)
|
||||
plus = Column(Boolean)
|
||||
|
||||
# Определяем индексы
|
||||
__table_args__ = (
|
||||
# Индекс для быстрого поиска всех оценок конкретного автора
|
||||
Index("idx_author_rating_author", "author"),
|
||||
# Индекс для быстрого поиска всех оценок, оставленных конкретным автором
|
||||
Index("idx_author_rating_rater", "rater"),
|
||||
)
|
||||
|
||||
|
||||
class AuthorFollower(Base):
|
||||
"""
|
||||
Подписка одного автора на другого.
|
||||
|
||||
Attributes:
|
||||
follower (int): ID подписчика
|
||||
author (int): ID автора, на которого подписываются
|
||||
created_at (int): Время создания подписки
|
||||
auto (bool): Признак автоматической подписки
|
||||
"""
|
||||
|
||||
__tablename__ = "author_follower"
|
||||
|
||||
id = None # type: ignore
|
||||
follower = Column(ForeignKey("author.id"), primary_key=True)
|
||||
author = Column(ForeignKey("author.id"), primary_key=True)
|
||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
auto = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Определяем индексы
|
||||
__table_args__ = (
|
||||
# Индекс для быстрого поиска всех подписчиков автора
|
||||
Index("idx_author_follower_author", "author"),
|
||||
# Индекс для быстрого поиска всех авторов, на которых подписан конкретный автор
|
||||
Index("idx_author_follower_follower", "follower"),
|
||||
)
|
||||
|
||||
|
||||
class AuthorBookmark(Base):
|
||||
"""
|
||||
Закладка автора на публикацию.
|
||||
|
||||
Attributes:
|
||||
author (int): ID автора
|
||||
shout (int): ID публикации
|
||||
"""
|
||||
|
||||
__tablename__ = "author_bookmark"
|
||||
|
||||
id = None # type: ignore
|
||||
author = Column(ForeignKey("author.id"), primary_key=True)
|
||||
shout = Column(ForeignKey("shout.id"), primary_key=True)
|
||||
|
||||
# Определяем индексы
|
||||
__table_args__ = (
|
||||
# Индекс для быстрого поиска всех закладок автора
|
||||
Index("idx_author_bookmark_author", "author"),
|
||||
# Индекс для быстрого поиска всех авторов, добавивших публикацию в закладки
|
||||
Index("idx_author_bookmark_shout", "shout"),
|
||||
)
|
||||
|
||||
|
||||
class Author(Base):
|
||||
"""
|
||||
Модель автора в системе.
|
||||
|
||||
Attributes:
|
||||
name (str): Отображаемое имя
|
||||
slug (str): Уникальный строковый идентификатор
|
||||
bio (str): Краткая биография/статус
|
||||
about (str): Полное описание
|
||||
pic (str): URL изображения профиля
|
||||
links (dict): Ссылки на социальные сети и сайты
|
||||
created_at (int): Время создания профиля
|
||||
last_seen (int): Время последнего посещения
|
||||
updated_at (int): Время последнего обновления
|
||||
deleted_at (int): Время удаления (если профиль удален)
|
||||
"""
|
||||
|
||||
__tablename__ = "author"
|
||||
|
||||
name = Column(String, nullable=True, comment="Display name")
|
||||
slug = Column(String, unique=True, comment="Author's slug")
|
||||
bio = Column(String, nullable=True, comment="Bio") # status description
|
||||
about = Column(String, nullable=True, comment="About") # long and formatted
|
||||
pic = Column(String, nullable=True, comment="Picture")
|
||||
links = Column(JSON, nullable=True, comment="Links")
|
||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
last_seen = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
updated_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||
deleted_at = Column(Integer, nullable=True, comment="Deleted at")
|
||||
|
||||
# search_vector = Column(
|
||||
# TSVectorType("name", "slug", "bio", "about", regconfig="pg_catalog.russian")
|
||||
# )
|
||||
|
||||
# Определяем индексы
|
||||
__table_args__ = (
|
||||
# Индекс для быстрого поиска по имени
|
||||
Index("idx_author_name", "name"),
|
||||
# Индекс для быстрого поиска по slug
|
||||
Index("idx_author_slug", "slug"),
|
||||
# Индекс для фильтрации неудаленных авторов
|
||||
Index(
|
||||
"idx_author_deleted_at", "deleted_at", postgresql_where=deleted_at.is_(None)
|
||||
),
|
||||
# Индекс для сортировки по времени создания (для новых авторов)
|
||||
Index("idx_author_created_at", "created_at"),
|
||||
# Индекс для сортировки по времени последнего посещения
|
||||
Index("idx_author_last_seen", "last_seen"),
|
||||
)
|
|
@ -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()
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
@ -71,7 +70,7 @@ class Draft(Base):
|
|||
foreign_keys="Shout.draft",
|
||||
uselist=False,
|
||||
lazy="noload", # Не грузим по умолчанию, только через options
|
||||
viewonly=True # Указываем, что это связь только для чтения
|
||||
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 [])],
|
||||
}
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
2236
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
Normal file
39
package.json
Normal file
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"name": "publy-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
111
panel/App.tsx
Normal 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
676
panel/admin.tsx
Normal 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}
|
||||
>
|
||||
«
|
||||
</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}
|
||||
>
|
||||
»
|
||||
</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
143
panel/auth.ts
Normal 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
189
panel/graphql.ts
Normal 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
12
panel/index.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Точка входа в клиентское приложение
|
||||
* @module index
|
||||
*/
|
||||
|
||||
import { render } from 'solid-js/web'
|
||||
import App from './App'
|
||||
|
||||
import './styles.css'
|
||||
|
||||
// Рендеринг приложения в корневой элемент
|
||||
render(() => <App />, document.getElementById('root') as HTMLElement)
|
112
panel/login.tsx
Normal file
112
panel/login.tsx
Normal 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
587
panel/styles.css
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,7 +112,7 @@ 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
|
||||
|
@ -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)
|
||||
|
||||
|
|
|
@ -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,10 +736,7 @@ 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()
|
||||
)
|
||||
|
@ -744,10 +750,7 @@ async def unpublish_shout(_, info, shout_id: int):
|
|||
# Отдельно загружаем черновик с его связями
|
||||
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
|
||||
}
|
||||
|
||||
# Инвалидация кэша
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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())
|
||||
|
||||
|
|
|
@ -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) # Статистика по авторам темы
|
||||
|
|
|
@ -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
71
schema/admin.graphql
Normal 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!
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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!
|
||||
}
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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!]!
|
||||
}
|
||||
|
||||
|
|
152
services/auth.py
152
services/auth.py
|
@ -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 если он есть
|
||||
# Очищаем токен от префикса 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}}
|
||||
|
||||
# Только необходимые заголовки для 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")
|
||||
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
|
||||
|
|
|
@ -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__):
|
||||
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,13 +169,28 @@ 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:
|
||||
try:
|
||||
# Проверяем, существует ли атрибут в объекте
|
||||
if hasattr(self, column_name):
|
||||
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):
|
||||
# Проверяем, является ли значение 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:
|
||||
|
@ -168,7 +198,14 @@ class Base(declarative_base()):
|
|||
data[column_name] = value
|
||||
else:
|
||||
data[column_name] = value
|
||||
# Add synthetic field .stat if it exists
|
||||
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
111
services/env.py
Normal 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()
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
50
settings.py
50
settings.py
|
@ -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
24
tsconfig.json
Normal 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": []
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
|
|
@ -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
65
utils/generate_slug.py
Normal 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
71
vite.config.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
})
|
Loading…
Reference in New Issue
Block a user