upgrade schema, resolvers, panel added
This commit is contained in:
parent
8a60bec73a
commit
2d382be794
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -161,4 +161,6 @@ views.json
|
||||||
*.key
|
*.key
|
||||||
*.crt
|
*.crt
|
||||||
*cache.json
|
*cache.json
|
||||||
.cursor
|
.cursor
|
||||||
|
|
||||||
|
node_modules/
|
170
CHANGELOG.md
170
CHANGELOG.md
|
@ -1,13 +1,163 @@
|
||||||
#### [0.4.20] - 2025-05-03
|
# Changelog
|
||||||
- Исправлена ошибка в классе `CacheRevalidationManager`: добавлена инициализация атрибута `_redis`
|
|
||||||
- Улучшена обработка соединения с Redis в менеджере ревалидации кэша:
|
## [Unreleased]
|
||||||
- Автоматическое восстановление соединения в случае его потери
|
|
||||||
- Проверка соединения перед выполнением операций с кэшем
|
### Изменено
|
||||||
- Дополнительное логирование для упрощения диагностики проблем
|
- Радикально упрощена структура клиентской части приложения:
|
||||||
- Исправлен резолвер `unpublish_shout`:
|
- Удалены все избыточные файлы и директории
|
||||||
- Корректное формирование синтетического поля `publication` с `published_at: null`
|
- Перемещены модули 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
|
#### [0.4.19] - 2025-04-14
|
||||||
- dropped `Shout.description` and `Draft.description` to be UX-generated
|
- dropped `Shout.description` and `Draft.description` to be UX-generated
|
||||||
|
|
|
@ -74,6 +74,9 @@ pytest
|
||||||
|
|
||||||
# Type checking
|
# Type checking
|
||||||
mypy .
|
mypy .
|
||||||
|
|
||||||
|
# dev run
|
||||||
|
python -m granian main:app --interface asgi
|
||||||
```
|
```
|
||||||
|
|
||||||
### Code Style
|
### Code Style
|
||||||
|
|
|
@ -11,7 +11,7 @@ from settings import DB_URL
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
# override DB_URL
|
# 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.
|
# Interpret the config file for Python logging.
|
||||||
# This line sets up loggers basically.
|
# 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 functools import wraps
|
||||||
from typing import Optional, Tuple
|
from typing import Optional
|
||||||
|
|
||||||
from graphql.type import GraphQLResolveInfo
|
from graphql.type import GraphQLResolveInfo
|
||||||
from sqlalchemy.orm import exc, joinedload
|
from sqlalchemy.orm import exc
|
||||||
from starlette.authentication import AuthenticationBackend
|
from starlette.authentication import AuthenticationBackend
|
||||||
from starlette.requests import HTTPConnection
|
from starlette.requests import HTTPConnection
|
||||||
|
|
||||||
from auth.credentials import AuthCredentials, AuthUser
|
from auth.credentials import AuthCredentials
|
||||||
from auth.exceptions import OperationNotAllowed
|
from auth.exceptions import OperationNotAllowed
|
||||||
from auth.tokenstorage import SessionToken
|
from auth.sessions import SessionManager
|
||||||
from auth.usermodel import Role, User
|
from auth.orm import Author
|
||||||
from services.db import local_session
|
from services.db import local_session
|
||||||
from settings import SESSION_TOKEN_HEADER
|
from settings import SESSION_TOKEN_HEADER
|
||||||
|
|
||||||
|
|
||||||
class JWTAuthenticate(AuthenticationBackend):
|
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:
|
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)
|
auth_header = request.headers.get(SESSION_TOKEN_HEADER)
|
||||||
if not token:
|
if not auth_header:
|
||||||
print("[auth.authenticate] no token in header %s" % SESSION_TOKEN_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:
|
# Обработка формата "Bearer <token>"
|
||||||
payload = await SessionToken.verify(token)
|
token = auth_header
|
||||||
|
if auth_header.startswith("Bearer "):
|
||||||
|
token = auth_header.replace("Bearer ", "", 1).strip()
|
||||||
|
|
||||||
with local_session() as session:
|
if not token:
|
||||||
try:
|
print("[auth.authenticate] empty token after Bearer prefix removal")
|
||||||
user = (
|
return None
|
||||||
session.query(User)
|
|
||||||
.options(
|
|
||||||
joinedload(User.roles).options(joinedload(Role.permissions)),
|
|
||||||
joinedload(User.ratings),
|
|
||||||
)
|
|
||||||
.filter(User.id == payload.user_id)
|
|
||||||
.one()
|
|
||||||
)
|
|
||||||
|
|
||||||
scopes = {} # TODO: integrate await user.get_permission()
|
# Проверяем сессию в Redis
|
||||||
|
payload = await SessionManager.verify_session(token)
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
|
||||||
return (
|
with local_session() as session:
|
||||||
AuthCredentials(user_id=payload.user_id, scopes=scopes, logged_in=True),
|
try:
|
||||||
AuthUser(user_id=user.id, username=""),
|
author = (
|
||||||
)
|
session.query(Author)
|
||||||
except exc.NoResultFound:
|
.filter(Author.id == payload.user_id)
|
||||||
pass
|
.filter(Author.is_active == True) # noqa
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
|
||||||
return AuthCredentials(scopes={}, error_message=str("Invalid token")), AuthUser(user_id=None, username="")
|
if author.is_locked():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Получаем разрешения из ролей
|
||||||
|
scopes = author.get_permissions()
|
||||||
|
|
||||||
|
return AuthCredentials(
|
||||||
|
author_id=author.id, scopes=scopes, logged_in=True, email=author.email
|
||||||
|
)
|
||||||
|
except exc.NoResultFound:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def login_required(func):
|
def login_required(func):
|
||||||
|
@ -62,15 +80,34 @@ def login_required(func):
|
||||||
return wrap
|
return wrap
|
||||||
|
|
||||||
|
|
||||||
def permission_required(resource, operation, func):
|
def permission_required(resource: str, operation: str, func):
|
||||||
|
"""
|
||||||
|
Декоратор для проверки разрешений.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resource (str): Ресурс для проверки
|
||||||
|
operation (str): Операция для проверки
|
||||||
|
func: Декорируемая функция
|
||||||
|
"""
|
||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
|
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
|
auth: AuthCredentials = info.context["request"].auth
|
||||||
if not auth.logged_in:
|
if not auth.logged_in:
|
||||||
raise OperationNotAllowed(auth.error_message or "Please login")
|
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)
|
return await func(parent, info, *args, **kwargs)
|
||||||
|
|
||||||
|
@ -82,12 +119,12 @@ def login_accepted(func):
|
||||||
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
|
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
|
||||||
auth: AuthCredentials = info.context["request"].auth
|
auth: AuthCredentials = info.context["request"].auth
|
||||||
|
|
||||||
# Если есть авторизация, добавляем данные автора в контекст
|
|
||||||
if auth and auth.logged_in:
|
if auth and auth.logged_in:
|
||||||
info.context["author"] = auth.author
|
with local_session() as session:
|
||||||
info.context["user_id"] = auth.author.get("id")
|
author = session.query(Author).filter(Author.id == auth.author_id).one()
|
||||||
|
info.context["author"] = author.dict()
|
||||||
|
info.context["user_id"] = author.id
|
||||||
else:
|
else:
|
||||||
# Очищаем данные автора из контекста если авторизация отсутствует
|
|
||||||
info.context["author"] = None
|
info.context["author"] = None
|
||||||
info.context["user_id"] = 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 base.exceptions import Unauthorized
|
||||||
|
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
|
||||||
|
|
||||||
|
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
|
||||||
|
|
||||||
|
|
||||||
class Permission(BaseModel):
|
class Permission(BaseModel):
|
||||||
name: Text
|
"""Модель разрешения для RBAC"""
|
||||||
|
|
||||||
|
resource: str
|
||||||
|
operation: str
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.resource}:{self.operation}"
|
||||||
|
|
||||||
|
|
||||||
class AuthCredentials(BaseModel):
|
class AuthCredentials(BaseModel):
|
||||||
user_id: Optional[int] = None
|
"""
|
||||||
scopes: Optional[dict] = {}
|
Модель учетных данных авторизации.
|
||||||
logged_in: bool = False
|
Используется как часть механизма аутентификации Starlette.
|
||||||
error_message: str = ""
|
"""
|
||||||
|
|
||||||
|
author_id: Optional[int] = Field(None, description="ID автора")
|
||||||
|
scopes: Dict[str, Set[str]] = Field(default_factory=dict, description="Разрешения пользователя")
|
||||||
|
logged_in: bool = Field(False, description="Флаг, указывающий, авторизован ли пользователь")
|
||||||
|
error_message: str = Field("", description="Сообщение об ошибке аутентификации")
|
||||||
|
email: Optional[str] = Field(None, description="Email пользователя")
|
||||||
|
|
||||||
|
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
|
@property
|
||||||
def is_admin(self):
|
def is_admin(self) -> bool:
|
||||||
# TODO: check admin logix
|
"""
|
||||||
return True
|
Проверяет, является ли пользователь администратором.
|
||||||
|
|
||||||
|
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]:
|
async def permissions(self) -> List[Permission]:
|
||||||
if self.user_id is None:
|
if self.author_id is None:
|
||||||
# raise Unauthorized("Please login first")
|
# raise Unauthorized("Please login first")
|
||||||
return {"error": "Please login first"}
|
return {"error": "Please login first"}
|
||||||
else:
|
else:
|
||||||
# TODO: implement permissions logix
|
# TODO: implement permissions logix
|
||||||
print(self.user_id)
|
print(self.author_id)
|
||||||
return NotImplemented
|
return NotImplemented
|
||||||
|
|
||||||
|
|
||||||
class AuthUser(BaseModel):
|
|
||||||
user_id: Optional[int]
|
|
||||||
username: Optional[str]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_authenticated(self) -> bool:
|
|
||||||
return self.user_id is not None
|
|
||||||
|
|
||||||
# @property
|
|
||||||
# def display_id(self) -> int:
|
|
||||||
# return self.user_id
|
|
||||||
|
|
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 binascii import hexlify
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
|
from typing import Any, Dict, TypeVar, TYPE_CHECKING
|
||||||
|
|
||||||
from passlib.hash import bcrypt
|
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.jwtcodec import JWTCodec
|
||||||
from auth.tokenstorage import TokenStorage
|
from auth.tokenstorage import TokenStorage
|
||||||
from orm.user import User
|
|
||||||
|
|
||||||
# from base.exceptions import InvalidPassword, InvalidToken
|
|
||||||
from services.db import local_session
|
from services.db import local_session
|
||||||
|
|
||||||
|
# Для типизации
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from auth.orm import Author
|
||||||
|
|
||||||
|
AuthorType = TypeVar("AuthorType", bound="Author")
|
||||||
|
|
||||||
|
|
||||||
class Password:
|
class Password:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@ -24,6 +29,15 @@ class Password:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def encode(password: str) -> str:
|
def encode(password: str) -> str:
|
||||||
|
"""
|
||||||
|
Кодирует пароль пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
password (str): Пароль пользователя
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Закодированный пароль
|
||||||
|
"""
|
||||||
password_sha256 = Password._get_sha256(password)
|
password_sha256 = Password._get_sha256(password)
|
||||||
return bcrypt.using(rounds=10).hash(password_sha256)
|
return bcrypt.using(rounds=10).hash(password_sha256)
|
||||||
|
|
||||||
|
@ -52,28 +66,93 @@ class Password:
|
||||||
|
|
||||||
class Identity:
|
class Identity:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def password(orm_user: User, password: str) -> User:
|
def password(orm_author: Any, password: str) -> Any:
|
||||||
user = User(**orm_user.dict())
|
"""
|
||||||
if not user.password:
|
Проверяет пароль пользователя
|
||||||
# raise InvalidPassword("User password is empty")
|
|
||||||
return {"error": "User password is empty"}
|
Args:
|
||||||
if not Password.verify(password, user.password):
|
orm_author (Author): Объект пользователя
|
||||||
# raise InvalidPassword("Wrong user password")
|
password (str): Пароль пользователя
|
||||||
return {"error": "Wrong user password"}
|
|
||||||
return user
|
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
|
@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:
|
with local_session() as session:
|
||||||
user = session.query(User).filter(User.email == inp["email"]).first()
|
author = session.query(Author).filter(Author.email == inp["email"]).first()
|
||||||
if not user:
|
if not author:
|
||||||
user = User.create(**inp, emailConfirmed=True)
|
author = Author(**inp)
|
||||||
|
author.email_verified = True
|
||||||
|
session.add(author)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
return user
|
return author
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def onetime(token: str) -> User:
|
async def onetime(token: str) -> Any:
|
||||||
|
"""
|
||||||
|
Проверяет одноразовый токен
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token (str): Одноразовый токен
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Author: Объект пользователя
|
||||||
|
"""
|
||||||
|
# Импортируем внутри функции для избежания циклических импортов
|
||||||
|
from auth.orm import Author
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print("[auth.identity] using one time token")
|
print("[auth.identity] using one time token")
|
||||||
payload = JWTCodec.decode(token)
|
payload = JWTCodec.decode(token)
|
||||||
|
@ -87,11 +166,11 @@ class Identity:
|
||||||
# raise InvalidToken("token format error") from e
|
# raise InvalidToken("token format error") from e
|
||||||
return {"error": "Token format error"}
|
return {"error": "Token format error"}
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
user = session.query(User).filter_by(id=payload.user_id).first()
|
author = session.query(Author).filter_by(id=payload.user_id).first()
|
||||||
if not user:
|
if not author:
|
||||||
# raise Exception("user not exist")
|
# raise Exception("user not exist")
|
||||||
return {"error": "User does not exist"}
|
return {"error": "Author does not exist"}
|
||||||
if not user.emailConfirmed:
|
if not author.email_verified:
|
||||||
user.emailConfirmed = True
|
author.email_verified = True
|
||||||
session.commit()
|
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:
|
def encode(user, exp: datetime) -> str:
|
||||||
payload = {
|
payload = {
|
||||||
"user_id": user.id,
|
"user_id": user.id,
|
||||||
"username": user.email or user.phone,
|
"username": user.slug or user.email or user.phone or "",
|
||||||
"exp": exp,
|
"exp": exp,
|
||||||
"iat": datetime.now(tz=timezone.utc),
|
"iat": datetime.now(tz=timezone.utc),
|
||||||
"iss": "discours",
|
"iss": "discours",
|
||||||
|
@ -50,11 +50,13 @@ class JWTCodec:
|
||||||
return r
|
return r
|
||||||
except jwt.InvalidIssuedAtError:
|
except jwt.InvalidIssuedAtError:
|
||||||
print("[auth.jwtcodec] invalid issued at: %r" % payload)
|
print("[auth.jwtcodec] invalid issued at: %r" % payload)
|
||||||
raise ExpiredToken("check token issued time")
|
raise ExpiredToken("jwt check token issued time")
|
||||||
except jwt.ExpiredSignatureError:
|
except jwt.ExpiredSignatureError:
|
||||||
print("[auth.jwtcodec] expired signature %r" % payload)
|
print("[auth.jwtcodec] expired signature %r" % payload)
|
||||||
raise ExpiredToken("check token lifetime")
|
raise ExpiredToken("jwt check token lifetime")
|
||||||
except jwt.InvalidTokenError:
|
|
||||||
raise InvalidToken("token is not valid")
|
|
||||||
except jwt.InvalidSignatureError:
|
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
|
257
auth/oauth.py
257
auth/oauth.py
|
@ -1,98 +1,189 @@
|
||||||
from authlib.integrations.starlette_client import OAuth
|
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.tokenstorage import TokenStorage
|
||||||
|
from auth.orm import Author
|
||||||
|
from services.db import local_session
|
||||||
from settings import FRONTEND_URL, OAUTH_CLIENTS
|
from settings import FRONTEND_URL, OAUTH_CLIENTS
|
||||||
|
|
||||||
oauth = OAuth()
|
oauth = OAuth()
|
||||||
|
|
||||||
oauth.register(
|
# Конфигурация провайдеров
|
||||||
name="facebook",
|
PROVIDERS = {
|
||||||
client_id=OAUTH_CLIENTS["FACEBOOK"]["id"],
|
"google": {
|
||||||
client_secret=OAUTH_CLIENTS["FACEBOOK"]["key"],
|
"name": "google",
|
||||||
access_token_url="https://graph.facebook.com/v11.0/oauth/access_token",
|
"server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
|
||||||
access_token_params=None,
|
"client_kwargs": {"scope": "openid email profile", "prompt": "select_account"},
|
||||||
authorize_url="https://www.facebook.com/v11.0/dialog/oauth",
|
},
|
||||||
authorize_params=None,
|
"github": {
|
||||||
api_base_url="https://graph.facebook.com/",
|
"name": "github",
|
||||||
client_kwargs={"scope": "public_profile email"},
|
"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/",
|
||||||
oauth.register(
|
"client_kwargs": {"scope": "user:email"},
|
||||||
name="github",
|
},
|
||||||
client_id=OAUTH_CLIENTS["GITHUB"]["id"],
|
"facebook": {
|
||||||
client_secret=OAUTH_CLIENTS["GITHUB"]["key"],
|
"name": "facebook",
|
||||||
access_token_url="https://github.com/login/oauth/access_token",
|
"access_token_url": "https://graph.facebook.com/v13.0/oauth/access_token",
|
||||||
access_token_params=None,
|
"authorize_url": "https://www.facebook.com/v13.0/dialog/oauth",
|
||||||
authorize_url="https://github.com/login/oauth/authorize",
|
"api_base_url": "https://graph.facebook.com/",
|
||||||
authorize_params=None,
|
"client_kwargs": {"scope": "public_profile email"},
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Регистрация провайдеров
|
||||||
|
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):
|
async def oauth_login(request):
|
||||||
|
"""Начинает процесс OAuth авторизации"""
|
||||||
provider = request.path_params["provider"]
|
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
|
request.session["provider"] = provider
|
||||||
client = oauth.create_client(provider)
|
request.session["state"] = token_urlsafe(16)
|
||||||
redirect_uri = "https://v2.discours.io/oauth-authorize"
|
|
||||||
return await client.authorize_redirect(request, redirect_uri)
|
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):
|
async def oauth_callback(request):
|
||||||
provider = request.session["provider"]
|
"""Обрабатывает callback от OAuth провайдера"""
|
||||||
client = oauth.create_client(provider)
|
try:
|
||||||
token = await client.authorize_access_token(request)
|
provider = request.session.get("provider")
|
||||||
get_profile = profile_callbacks[provider]
|
if not provider:
|
||||||
profile = await get_profile(client, request, token)
|
return JSONResponse({"error": "No active OAuth session"}, status_code=400)
|
||||||
user_oauth_info = "%s:%s" % (provider, profile["id"])
|
|
||||||
user_input = {
|
# Проверяем state
|
||||||
"oauth": user_oauth_info,
|
state = request.query_params.get("state")
|
||||||
"email": profile["email"],
|
if state != request.session.get("state"):
|
||||||
"username": profile["name"],
|
return JSONResponse({"error": "Invalid state"}, status_code=400)
|
||||||
"userpic": profile["userpic"],
|
|
||||||
}
|
client = oauth.create_client(provider)
|
||||||
user = Identity.oauth(user_input)
|
if not client:
|
||||||
session_token = await TokenStorage.create_session(user)
|
return JSONResponse({"error": "Provider not configured"}, status_code=400)
|
||||||
response = RedirectResponse(url=FRONTEND_URL + "/confirm")
|
|
||||||
response.set_cookie("token", session_token)
|
# Получаем токен с PKCE verifier
|
||||||
return response
|
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 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
import time
|
||||||
import re
|
import traceback
|
||||||
from datetime import datetime, timezone
|
from utils.logger import root_logger as logger
|
||||||
from urllib.parse import quote_plus
|
|
||||||
|
|
||||||
from graphql.type import GraphQLResolveInfo
|
from graphql.type import GraphQLResolveInfo
|
||||||
|
# import asyncio # Убираем, так как резолвер будет синхронным
|
||||||
|
|
||||||
from auth.authenticate import login_required
|
from auth.authenticate import login_required
|
||||||
from auth.credentials import AuthCredentials
|
from auth.credentials import AuthCredentials
|
||||||
|
from auth.decorators import admin_auth_required
|
||||||
from auth.email import send_auth_email
|
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.identity import Identity, Password
|
||||||
from auth.jwtcodec import JWTCodec
|
from auth.jwtcodec import JWTCodec
|
||||||
from auth.tokenstorage import TokenStorage
|
from auth.tokenstorage import TokenStorage
|
||||||
from orm import Role, User
|
from auth.orm import Author, Role
|
||||||
from services.db import local_session
|
from services.db import local_session
|
||||||
from services.schema import mutation, query
|
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")
|
@mutation.field("getSession")
|
||||||
|
@ -26,129 +38,138 @@ async def get_current_user(_, info):
|
||||||
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER)
|
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER)
|
||||||
|
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
user = session.query(User).where(User.id == auth.user_id).one()
|
author = session.query(Author).where(Author.id == auth.author_id).one()
|
||||||
user.lastSeen = datetime.now(tz=timezone.utc)
|
author.last_seen = int(time.time())
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
return {"token": token, "user": user}
|
return {"token": token, "author": author}
|
||||||
|
|
||||||
|
|
||||||
@mutation.field("confirmEmail")
|
@mutation.field("confirmEmail")
|
||||||
async def confirm_email(_, info, token):
|
async def confirm_email(_, info, token):
|
||||||
"""confirm owning email address"""
|
"""confirm owning email address"""
|
||||||
try:
|
try:
|
||||||
print("[resolvers.auth] confirm email by token")
|
logger.info("[auth] confirmEmail: Начало подтверждения email по токену.")
|
||||||
payload = JWTCodec.decode(token)
|
payload = JWTCodec.decode(token)
|
||||||
user_id = payload.user_id
|
user_id = payload.user_id
|
||||||
|
# Если TokenStorage.get асинхронный, это нужно будет переделать или вызывать синхронно
|
||||||
|
# Для теста пока оставим, но это потенциальная точка отказа в синхронном резолвере
|
||||||
await TokenStorage.get(f"{user_id}-{payload.username}-{token}")
|
await TokenStorage.get(f"{user_id}-{payload.username}-{token}")
|
||||||
with local_session() as session:
|
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)
|
session_token = await TokenStorage.create_session(user)
|
||||||
user.emailConfirmed = True
|
user.email_verified = True
|
||||||
user.lastSeen = datetime.now(tz=timezone.utc)
|
user.last_seen = int(time.time())
|
||||||
session.add(user)
|
session.add(user)
|
||||||
session.commit()
|
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:
|
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:
|
except Exception as e:
|
||||||
print(e) # FIXME: debug only
|
logger.error(f"[auth] confirmEmail: Общая ошибка - {str(e)}\n{traceback.format_exc()}")
|
||||||
return {"error": "email is not confirmed"}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"token": None,
|
||||||
|
"author": None,
|
||||||
|
"error": f"Ошибка подтверждения email: {str(e)}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_user(user_dict):
|
def create_user(user_dict):
|
||||||
user = User(**user_dict)
|
user = Author(**user_dict)
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
user.roles.append(session.query(Role).first())
|
# Добавляем пользователя в БД
|
||||||
session.add(user)
|
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()
|
session.commit()
|
||||||
return user
|
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")
|
@mutation.field("registerUser")
|
||||||
async def register_by_email(_, _info, email: str, password: str = "", name: str = ""):
|
async def register_by_email(_, _info, email: str, password: str = "", name: str = ""):
|
||||||
email = email.lower()
|
email = email.lower()
|
||||||
"""creates new user account"""
|
"""creates new user account"""
|
||||||
|
logger.info(f"[auth] registerUser: Попытка регистрации для {email}")
|
||||||
with local_session() as session:
|
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:
|
if user:
|
||||||
raise Unauthorized("User already exist")
|
logger.warning(f"[auth] registerUser: Пользователь {email} уже существует.")
|
||||||
else:
|
# raise Unauthorized("User already exist") # Это вызовет ошибку GraphQL, но не "cannot return null"
|
||||||
slug = generate_unique_slug(name)
|
return {"success": False, "token": None, "author": None, "error": "Пользователь уже существует"}
|
||||||
user = session.query(User).where(User.slug == slug).first()
|
|
||||||
if user:
|
slug = generate_unique_slug(name if name else email.split("@")[0])
|
||||||
slug = generate_unique_slug(email.split("@")[0])
|
|
||||||
user_dict = {
|
user_dict = {
|
||||||
"email": email,
|
"email": email,
|
||||||
"username": email, # will be used to store phone number or some messenger network id
|
"username": email,
|
||||||
"name": name,
|
"name": name if name else email.split("@")[0],
|
||||||
"slug": slug,
|
"slug": slug,
|
||||||
|
}
|
||||||
|
if password:
|
||||||
|
user_dict["password"] = Password.encode(password)
|
||||||
|
|
||||||
|
new_user = create_user(user_dict)
|
||||||
|
# Предполагается, что auth_send_link вернет объект Author или вызовет исключение
|
||||||
|
# Для AuthResult нам также нужен токен и статус.
|
||||||
|
# После регистрации обычно либо сразу логинят, либо просто сообщают об успехе.
|
||||||
|
# Сейчас auth_send_link используется, что не логично для AuthResult.
|
||||||
|
# Вернем успешную регистрацию без токена, предполагая, что пользователь должен будет залогиниться или подтвердить email.
|
||||||
|
|
||||||
|
# Попытка отправить ссылку для подтверждения email
|
||||||
|
try:
|
||||||
|
# Если auth_send_link асинхронный...
|
||||||
|
await auth_send_link(_, _info, email)
|
||||||
|
logger.info(
|
||||||
|
f"[auth] registerUser: Пользователь {email} зарегистрирован, ссылка для подтверждения отправлена."
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"token": None,
|
||||||
|
"author": new_user,
|
||||||
|
"error": "Требуется подтверждение email.",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[auth] registerUser: Ошибка при отправке ссылки подтверждения для {email}: {str(e)}")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"token": None,
|
||||||
|
"author": new_user,
|
||||||
|
"error": f"Пользователь зарегистрирован, но произошла ошибка при отправке ссылки подтверждения: {str(e)}",
|
||||||
}
|
}
|
||||||
if password:
|
|
||||||
user_dict["password"] = Password.encode(password)
|
|
||||||
user = create_user(user_dict)
|
|
||||||
user = await auth_send_link(_, _info, email)
|
|
||||||
return {"user": user}
|
|
||||||
|
|
||||||
|
|
||||||
@mutation.field("sendLink")
|
@mutation.field("sendLink")
|
||||||
|
@ -156,53 +177,168 @@ async def auth_send_link(_, _info, email, lang="ru", template="email_confirmatio
|
||||||
email = email.lower()
|
email = email.lower()
|
||||||
"""send link with confirm code to email"""
|
"""send link with confirm code to email"""
|
||||||
with local_session() as session:
|
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:
|
if not user:
|
||||||
raise ObjectNotExist("User not found")
|
raise ObjectNotExist("User not found")
|
||||||
else:
|
else:
|
||||||
|
# Если TokenStorage.create_onetime асинхронный...
|
||||||
token = await TokenStorage.create_onetime(user)
|
token = await TokenStorage.create_onetime(user)
|
||||||
|
# Если send_auth_email асинхронный...
|
||||||
await send_auth_email(user, token, lang, template)
|
await send_auth_email(user, token, lang, template)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@query.field("signIn")
|
@mutation.field("login")
|
||||||
async def login(_, info, email: str, password: str = "", lang: str = "ru"):
|
async def login_mutation(_, info, email: str, password: str):
|
||||||
email = email.lower()
|
"""
|
||||||
with local_session() as session:
|
Авторизация пользователя с помощью email и пароля.
|
||||||
orm_user = session.query(User).filter(User.email == email).first()
|
|
||||||
if orm_user is None:
|
|
||||||
print(f"[auth] {email}: email not found")
|
|
||||||
# return {"error": "email not found"}
|
|
||||||
raise ObjectNotExist("User not found") # contains webserver status
|
|
||||||
|
|
||||||
if not password:
|
Args:
|
||||||
print(f"[auth] send confirm link to {email}")
|
info: Контекст GraphQL запроса
|
||||||
token = await TokenStorage.create_onetime(orm_user)
|
email: Email пользователя
|
||||||
await send_auth_email(orm_user, token, lang)
|
password: Пароль пользователя
|
||||||
# FIXME: not an error, warning
|
|
||||||
return {"error": "no password, email link was sent"}
|
|
||||||
|
|
||||||
else:
|
Returns:
|
||||||
# sign in using password
|
AuthResult с данными пользователя и токеном или сообщением об ошибке
|
||||||
if not orm_user.emailConfirmed:
|
"""
|
||||||
# not an error, warns users
|
logger.info(f"[auth] login: Попытка входа для {email}")
|
||||||
return {"error": "please, confirm email"}
|
|
||||||
else:
|
# Гарантируем, что всегда возвращаем непустой объект AuthResult
|
||||||
|
default_response = {"success": False, "token": None, "author": None, "error": "Неизвестная ошибка"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Нормализуем email
|
||||||
|
email = email.lower()
|
||||||
|
|
||||||
|
# Получаем пользователя из базы
|
||||||
|
with local_session() as session:
|
||||||
|
author = session.query(Author).filter(Author.email == email).first()
|
||||||
|
|
||||||
|
if not author:
|
||||||
|
logger.warning(f"[auth] login: Пользователь {email} не найден")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"token": None,
|
||||||
|
"author": None,
|
||||||
|
"error": "Пользователь с таким email не найден",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Логируем информацию о найденном авторе
|
||||||
|
logger.info(
|
||||||
|
f"[auth] login: Найден автор {email}, id={author.id}, имя={author.name}, пароль есть: {bool(author.password)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем пароль
|
||||||
|
logger.info(f"[auth] login: НАЧАЛО ПРОВЕРКИ ПАРОЛЯ для {email}")
|
||||||
|
verify_result = Identity.password(author, password)
|
||||||
|
logger.info(
|
||||||
|
f"[auth] login: РЕЗУЛЬТАТ ПРОВЕРКИ ПАРОЛЯ: {verify_result if isinstance(verify_result, dict) else 'успешно'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(verify_result, dict) and verify_result.get("error"):
|
||||||
|
logger.warning(f"[auth] login: Неверный пароль для {email}: {verify_result.get('error')}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"token": None,
|
||||||
|
"author": None,
|
||||||
|
"error": verify_result.get("error", "Ошибка авторизации"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Получаем правильный объект автора - результат verify_result
|
||||||
|
valid_author = verify_result if not isinstance(verify_result, dict) else author
|
||||||
|
|
||||||
|
# Создаем токен через правильную функцию вместо прямого кодирования
|
||||||
|
try:
|
||||||
|
# Убедимся, что у автора есть нужные поля для создания токена
|
||||||
|
if (
|
||||||
|
not hasattr(valid_author, "id")
|
||||||
|
or not hasattr(valid_author, "username")
|
||||||
|
and not hasattr(valid_author, "email")
|
||||||
|
):
|
||||||
|
logger.error(
|
||||||
|
f"[auth] login: Объект автора не содержит необходимых атрибутов: {valid_author}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"token": None,
|
||||||
|
"author": None,
|
||||||
|
"error": "Внутренняя ошибка: некорректный объект автора",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Создаем сессионный токен
|
||||||
|
logger.info(f"[auth] login: СОЗДАНИЕ ТОКЕНА для {email}, id={valid_author.id}")
|
||||||
|
token = await TokenStorage.create_session(valid_author)
|
||||||
|
logger.info(f"[auth] login: токен успешно создан, длина: {len(token) if token else 0}")
|
||||||
|
|
||||||
|
# Обновляем время последнего входа
|
||||||
|
valid_author.last_seen = int(time.time())
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Устанавливаем httponly cookie с помощью GraphQLExtensionsMiddleware
|
||||||
try:
|
try:
|
||||||
user = Identity.password(orm_user, password)
|
# Используем extensions для установки cookie
|
||||||
session_token = await TokenStorage.create_session(user)
|
if hasattr(info.context, "extensions") and hasattr(
|
||||||
print(f"[auth] user {email} authorized")
|
info.context.extensions, "set_cookie"
|
||||||
return {"token": session_token, "user": user}
|
):
|
||||||
except InvalidPassword:
|
logger.info("[auth] login: Устанавливаем httponly cookie через extensions")
|
||||||
print(f"[auth] {email}: invalid password")
|
info.context.extensions.set_cookie(
|
||||||
raise InvalidPassword("invalid password") # contains webserver status
|
SESSION_COOKIE_NAME,
|
||||||
# return {"error": "invalid password"}
|
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")
|
@query.field("signOut")
|
||||||
@login_required
|
@login_required
|
||||||
async def sign_out(_, info: GraphQLResolveInfo):
|
async def sign_out(_, info: GraphQLResolveInfo):
|
||||||
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER, "")
|
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER, "")
|
||||||
|
# Если TokenStorage.revoke асинхронный...
|
||||||
status = await TokenStorage.revoke(token)
|
status = await TokenStorage.revoke(token)
|
||||||
return status
|
return status
|
||||||
|
|
||||||
|
@ -211,5 +347,117 @@ async def sign_out(_, info: GraphQLResolveInfo):
|
||||||
async def is_email_used(_, _info, email):
|
async def is_email_used(_, _info, email):
|
||||||
email = email.lower()
|
email = email.lower()
|
||||||
with local_session() as session:
|
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
|
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
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import json
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
from auth.jwtcodec import JWTCodec
|
from auth.jwtcodec import JWTCodec
|
||||||
from auth.validations import AuthInput
|
from auth.validations import AuthInput
|
||||||
from services.redis import redis
|
from services.redis import redis
|
||||||
from settings import ONETIME_TOKEN_LIFE_SPAN, SESSION_TOKEN_LIFE_SPAN
|
from settings import ONETIME_TOKEN_LIFE_SPAN, SESSION_TOKEN_LIFE_SPAN
|
||||||
|
from utils.logger import root_logger as logger
|
||||||
|
|
||||||
async def save(token_key, life_span, auto_delete=True):
|
|
||||||
await redis.execute("SET", token_key, "True")
|
|
||||||
if auto_delete:
|
|
||||||
expire_at = (datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)).timestamp()
|
|
||||||
await redis.execute("EXPIREAT", token_key, int(expire_at))
|
|
||||||
|
|
||||||
|
|
||||||
class SessionToken:
|
|
||||||
@classmethod
|
|
||||||
async def verify(cls, token: str):
|
|
||||||
"""
|
|
||||||
Rules for a token to be valid.
|
|
||||||
- token format is legal
|
|
||||||
- token exists in redis database
|
|
||||||
- token is not expired
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return JWTCodec.decode(token)
|
|
||||||
except Exception as e:
|
|
||||||
raise e
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get(cls, payload, token):
|
|
||||||
return await TokenStorage.get(f"{payload.user_id}-{payload.username}-{token}")
|
|
||||||
|
|
||||||
|
|
||||||
class TokenStorage:
|
class TokenStorage:
|
||||||
|
"""
|
||||||
|
Хранилище токенов в Redis.
|
||||||
|
Обеспечивает создание, проверку и отзыв токенов.
|
||||||
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get(token_key):
|
async def get(token_key: str) -> Optional[str]:
|
||||||
print("[tokenstorage.get] " + token_key)
|
"""
|
||||||
# 2041-user@domain.zn-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyMDQxLCJ1c2VybmFtZSI6ImFudG9uLnJld2luK3Rlc3QtbG9hZGNoYXRAZ21haWwuY29tIiwiZXhwIjoxNjcxNzgwNjE2LCJpYXQiOjE2NjkxODg2MTYsImlzcyI6ImRpc2NvdXJzIn0.Nml4oV6iMjMmc6xwM7lTKEZJKBXvJFEIZ-Up1C1rITQ
|
Получает токен из хранилища.
|
||||||
return await redis.execute("GET", token_key)
|
|
||||||
|
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
|
@staticmethod
|
||||||
async def create_onetime(user: AuthInput) -> str:
|
async def create_onetime(user: AuthInput) -> str:
|
||||||
|
"""
|
||||||
|
Создает одноразовый токен для пользователя.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: Объект пользователя
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Сгенерированный токен
|
||||||
|
"""
|
||||||
life_span = ONETIME_TOKEN_LIFE_SPAN
|
life_span = ONETIME_TOKEN_LIFE_SPAN
|
||||||
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
|
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
|
||||||
one_time_token = JWTCodec.encode(user, exp)
|
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
|
return one_time_token
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def create_session(user: AuthInput) -> str:
|
async def create_session(user: AuthInput) -> str:
|
||||||
|
"""
|
||||||
|
Создает сессионный токен для пользователя.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: Объект пользователя
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Сгенерированный токен
|
||||||
|
"""
|
||||||
life_span = SESSION_TOKEN_LIFE_SPAN
|
life_span = SESSION_TOKEN_LIFE_SPAN
|
||||||
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
|
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
|
||||||
session_token = JWTCodec.encode(user, exp)
|
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
|
return session_token
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def revoke(token: str) -> bool:
|
async def revoke(token: str) -> bool:
|
||||||
payload = None
|
"""
|
||||||
|
Отзывает токен.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: Токен для отзыва
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True, если токен успешно отозван
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
print("[auth.tokenstorage] revoke token")
|
logger.debug("[tokenstorage.revoke] Отзыв токена")
|
||||||
|
|
||||||
|
# Декодируем токен
|
||||||
payload = JWTCodec.decode(token)
|
payload = JWTCodec.decode(token)
|
||||||
except: # noqa
|
if not payload:
|
||||||
pass
|
logger.warning("[tokenstorage.revoke] Невозможно декодировать токен")
|
||||||
else:
|
return False
|
||||||
await redis.execute("DEL", f"{payload.user_id}-{payload.username}-{token}")
|
|
||||||
return True
|
# Формируем ключи
|
||||||
|
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
|
@staticmethod
|
||||||
async def revoke_all(user: AuthInput):
|
async def revoke_all(user: AuthInput) -> bool:
|
||||||
tokens = await redis.execute("KEYS", f"{user.id}-*")
|
"""
|
||||||
await redis.execute("DEL", *tokens)
|
Отзывает все токены пользователя.
|
||||||
|
|
||||||
|
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 asyncio
|
||||||
import json
|
import json
|
||||||
from typing import Any, Dict, List, Optional, Union
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
import orjson
|
import orjson
|
||||||
from sqlalchemy import and_, join, select
|
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.shout import Shout, ShoutAuthor, ShoutTopic
|
||||||
from orm.topic import Topic, TopicFollower
|
from orm.topic import Topic, TopicFollower
|
||||||
from services.db import local_session
|
from services.db import local_session
|
||||||
|
@ -78,7 +78,7 @@ async def cache_topic(topic: dict):
|
||||||
async def cache_author(author: dict):
|
async def cache_author(author: dict):
|
||||||
payload = json.dumps(author, cls=CustomJSONEncoder)
|
payload = json.dumps(author, cls=CustomJSONEncoder)
|
||||||
await asyncio.gather(
|
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),
|
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(ShoutAuthor.author)
|
||||||
.select_from(join(ShoutTopic, Shout, ShoutTopic.shout == Shout.id))
|
.select_from(join(ShoutTopic, Shout, ShoutTopic.shout == Shout.id))
|
||||||
.join(ShoutAuthor, ShoutAuthor.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()]
|
authors_ids = [author_id for (author_id,) in session.execute(query).all()]
|
||||||
# Cache the retrieved author IDs
|
# 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 sqlalchemy import and_, join, select
|
||||||
|
|
||||||
from cache.cache import cache_author, cache_topic
|
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.shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic
|
||||||
from orm.topic import Topic, TopicFollower
|
from orm.topic import Topic, TopicFollower
|
||||||
from resolvers.stat import get_with_stat
|
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):
|
async def precache_authors_follows(author_id, session):
|
||||||
follows_topics_query = select(TopicFollower.topic).where(TopicFollower.follower == author_id)
|
follows_topics_query = select(TopicFollower.topic).where(TopicFollower.follower == author_id)
|
||||||
follows_authors_query = select(AuthorFollower.author).where(AuthorFollower.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_topics = {row[0] for row in session.execute(follows_topics_query) if row[0]}
|
||||||
follows_authors = {row[0] for row in session.execute(follows_authors_query) if row[0]}
|
follows_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")
|
logger.info(f"{len(topics)} topics and their followings precached")
|
||||||
|
|
||||||
# authors
|
# authors
|
||||||
authors = get_with_stat(select(Author).where(Author.user.is_not(None)))
|
authors = get_with_stat(select(Author))
|
||||||
logger.info(f"{len(authors)} authors found in database")
|
# logger.info(f"{len(authors)} authors found in database")
|
||||||
for author in authors:
|
for author in authors:
|
||||||
if isinstance(author, Author):
|
if isinstance(author, Author):
|
||||||
profile = author.dict()
|
profile = author.dict()
|
||||||
author_id = profile.get("id")
|
author_id = profile.get("id")
|
||||||
user_id = profile.get("user", "").strip()
|
# user_id = profile.get("user", "").strip()
|
||||||
if author_id and user_id:
|
if author_id: # and user_id:
|
||||||
await cache_author(profile)
|
await cache_author(profile)
|
||||||
await asyncio.gather(
|
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:
|
else:
|
||||||
logger.error(f"fail caching {author}")
|
logger.error(f"fail caching {author}")
|
||||||
|
|
5
cache/revalidator.py
vendored
5
cache/revalidator.py
vendored
|
@ -28,13 +28,12 @@ class CacheRevalidationManager:
|
||||||
"""Запуск фонового воркера для ревалидации кэша."""
|
"""Запуск фонового воркера для ревалидации кэша."""
|
||||||
# Проверяем, что у нас есть соединение с Redis
|
# Проверяем, что у нас есть соединение с Redis
|
||||||
if not self._redis._client:
|
if not self._redis._client:
|
||||||
logger.warning("Redis connection not established. Waiting for connection...")
|
|
||||||
try:
|
try:
|
||||||
await self._redis.connect()
|
await self._redis.connect()
|
||||||
logger.info("Redis connection established for revalidation manager")
|
logger.info("Redis connection established for revalidation manager")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to connect to Redis: {e}")
|
logger.error(f"Failed to connect to Redis: {e}")
|
||||||
|
|
||||||
self.task = asyncio.create_task(self.revalidate_cache())
|
self.task = asyncio.create_task(self.revalidate_cache())
|
||||||
|
|
||||||
async def revalidate_cache(self):
|
async def revalidate_cache(self):
|
||||||
|
@ -53,7 +52,7 @@ class CacheRevalidationManager:
|
||||||
# Проверяем соединение с Redis
|
# Проверяем соединение с Redis
|
||||||
if not self._redis._client:
|
if not self._redis._client:
|
||||||
return # Выходим из метода, если не удалось подключиться
|
return # Выходим из метода, если не удалось подключиться
|
||||||
|
|
||||||
async with self.lock:
|
async with self.lock:
|
||||||
# Ревалидация кэша авторов
|
# Ревалидация кэша авторов
|
||||||
if self.items_to_revalidate["authors"]:
|
if self.items_to_revalidate["authors"]:
|
||||||
|
|
2
cache/triggers.py
vendored
2
cache/triggers.py
vendored
|
@ -1,7 +1,7 @@
|
||||||
from sqlalchemy import event
|
from sqlalchemy import event
|
||||||
|
|
||||||
from cache.revalidator import revalidation_manager
|
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.reaction import Reaction, ReactionKind
|
||||||
from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower
|
from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower
|
||||||
from orm.topic import Topic, TopicFollower
|
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)
|
- Настраиваемое время жизни кеша (TTL)
|
||||||
- Возможность ручной инвалидации кеша для конкретных функций и аргументов
|
- Возможность ручной инвалидации кеша для конкретных функций и аргументов
|
||||||
|
|
||||||
## Webhooks
|
|
||||||
|
|
||||||
- Автоматическая регистрация вебхука для события user.login
|
|
||||||
- Предотвращение создания дублирующихся вебхуков
|
|
||||||
- Автоматическая очистка устаревших вебхуков
|
|
||||||
- Поддержка авторизации вебхуков через WEBHOOK_SECRET
|
|
||||||
- Обработка ошибок при операциях с вебхуками
|
|
||||||
- Динамическое определение endpoint'а на основе окружения
|
|
||||||
|
|
||||||
## CORS Configuration
|
## CORS Configuration
|
||||||
|
|
||||||
- Поддерживаемые методы: GET, POST, OPTIONS
|
- Поддерживаемые методы: 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>
|
271
main.py
271
main.py
|
@ -1,16 +1,19 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
from importlib import import_module
|
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 import load_schema_from_path, make_executable_schema
|
||||||
from ariadne.asgi import GraphQL
|
from ariadne.asgi import GraphQL
|
||||||
|
from ariadne.asgi.handlers import GraphQLHTTPHandler
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.middleware.cors import CORSMiddleware
|
from starlette.middleware.cors import CORSMiddleware
|
||||||
|
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||||
|
from starlette.middleware import Middleware
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse, Response
|
from starlette.responses import FileResponse, JSONResponse, HTMLResponse, RedirectResponse
|
||||||
from starlette.routing import Route
|
from starlette.routing import Route, Mount
|
||||||
|
from starlette.staticfiles import StaticFiles
|
||||||
|
|
||||||
from cache.precache import precache_data
|
from cache.precache import precache_data
|
||||||
from cache.revalidator import revalidation_manager
|
from cache.revalidator import revalidation_manager
|
||||||
|
@ -18,78 +21,220 @@ from services.exception import ExceptionHandlerMiddleware
|
||||||
from services.redis import redis
|
from services.redis import redis
|
||||||
from services.schema import create_all_tables, resolvers
|
from services.schema import create_all_tables, resolvers
|
||||||
from services.search import search_service
|
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, ADMIN_EMAILS
|
||||||
from settings import DEV_SERVER_PID_FILE_NAME, MODE
|
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("resolvers")
|
||||||
|
import_module("auth.resolvers")
|
||||||
|
|
||||||
|
# Создаем схему GraphQL
|
||||||
schema = make_executable_schema(load_schema_from_path("schema/"), resolvers)
|
schema = make_executable_schema(load_schema_from_path("schema/"), resolvers)
|
||||||
|
|
||||||
|
# Пути к клиентским файлам
|
||||||
async def start():
|
CLIENT_DIR = join(os.path.dirname(__file__), "client")
|
||||||
if MODE == "development":
|
DIST_DIR = join(os.path.dirname(__file__), "dist") # Директория для собранных файлов
|
||||||
if not exists(DEV_SERVER_PID_FILE_NAME):
|
INDEX_HTML = join(os.path.dirname(__file__), "index.html")
|
||||||
# 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")
|
|
||||||
|
|
||||||
|
|
||||||
async def lifespan(_app):
|
async def index_handler(request: Request):
|
||||||
try:
|
"""
|
||||||
create_all_tables()
|
Раздача основного HTML файла
|
||||||
await asyncio.gather(
|
"""
|
||||||
redis.connect(),
|
return FileResponse(INDEX_HTML)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
# Создаем экземпляр GraphQL
|
# GraphQL API
|
||||||
graphql_app = GraphQL(schema, debug=True)
|
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-обработчик для лучшей обработки ошибок
|
graphql_app = GraphQL(schema, debug=MODE == "development", http_handler=CustomGraphQLHTTPHandler())
|
||||||
async def graphql_handler(request: Request):
|
|
||||||
if request.method not in ["GET", "POST"]:
|
|
||||||
return JSONResponse({"error": "Method Not Allowed"}, status_code=405)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await graphql_app.handle_request(request)
|
async def graphql_handler(request):
|
||||||
if isinstance(result, Response):
|
"""Обработчик GraphQL запросов"""
|
||||||
return result
|
# Проверяем заголовок Content-Type
|
||||||
|
content_type = request.headers.get("content-type", "")
|
||||||
|
if not content_type.startswith("application/json") and "application/json" in request.headers.get(
|
||||||
|
"accept", ""
|
||||||
|
):
|
||||||
|
# Если не application/json, но клиент принимает JSON
|
||||||
|
request._headers["content-type"] = "application/json"
|
||||||
|
|
||||||
|
# Обрабатываем GraphQL запрос
|
||||||
|
result = await graphql_app.handle_request(request)
|
||||||
|
|
||||||
|
# Если result - это ответ от сервера, возвращаем его как есть
|
||||||
|
if hasattr(result, "body"):
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Если результат - это словарь, значит нужно его сконвертировать в JSONResponse
|
||||||
|
if isinstance(result, dict):
|
||||||
return JSONResponse(result)
|
return JSONResponse(result)
|
||||||
except asyncio.CancelledError:
|
|
||||||
return JSONResponse({"error": "Request cancelled"}, status_code=499)
|
return result
|
||||||
except Exception as e:
|
|
||||||
print(f"GraphQL error: {str(e)}")
|
|
||||||
return JSONResponse({"error": str(e)}, status_code=500)
|
|
||||||
|
|
||||||
|
|
||||||
# Обновляем маршрут в Starlette
|
async def admin_handler(request: Request):
|
||||||
app = Starlette(
|
"""
|
||||||
routes=[
|
Обработчик для маршрута /admin с серверной проверкой прав доступа
|
||||||
Route("/", graphql_handler, methods=["GET", "POST"]),
|
"""
|
||||||
Route("/new-author", WebhookEndpoint),
|
# Проверяем авторизован ли пользователь
|
||||||
],
|
if not request.user.is_authenticated:
|
||||||
lifespan=lifespan,
|
# Если пользователь не авторизован, перенаправляем на страницу входа
|
||||||
debug=True,
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
|
||||||
|
# Проверяем является ли пользователь администратором
|
||||||
|
auth = getattr(request, "auth", None)
|
||||||
|
is_admin = False
|
||||||
|
|
||||||
|
# Проверяем наличие объекта auth и метода is_admin
|
||||||
|
if auth:
|
||||||
|
try:
|
||||||
|
# Проверяем имеет ли пользователь права администратора
|
||||||
|
is_admin = auth.is_admin
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при проверке прав администратора: {e}")
|
||||||
|
|
||||||
|
# Дополнительная проверка email (для случаев, когда нет метода is_admin)
|
||||||
|
admin_emails = ADMIN_EMAILS.split(",")
|
||||||
|
if not is_admin and hasattr(auth, "email") and auth.email in admin_emails:
|
||||||
|
is_admin = True
|
||||||
|
|
||||||
|
if is_admin:
|
||||||
|
# Если пользователь - администратор, возвращаем HTML-файл
|
||||||
|
return FileResponse(INDEX_HTML)
|
||||||
|
else:
|
||||||
|
# Для авторизованных пользователей без прав администратора показываем страницу с ошибкой доступа
|
||||||
|
return HTMLResponse(
|
||||||
|
"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Доступ запрещен</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 0; padding: 0; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f5f5f5; }
|
||||||
|
.error-container { max-width: 500px; padding: 30px; background-color: #fff; border-radius: 5px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); text-align: center; }
|
||||||
|
h1 { color: #e74c3c; margin-bottom: 20px; }
|
||||||
|
p { color: #333; margin-bottom: 20px; line-height: 1.5; }
|
||||||
|
.back-button { background-color: #3498db; color: #fff; border: none; padding: 10px 20px; border-radius: 3px; cursor: pointer; text-decoration: none; display: inline-block; }
|
||||||
|
.back-button:hover { background-color: #2980b9; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="error-container">
|
||||||
|
<h1>Доступ запрещен</h1>
|
||||||
|
<p>У вас нет прав для доступа к административной панели. Обратитесь к администратору системы для получения необходимых разрешений.</p>
|
||||||
|
<a href="/" class="back-button">Вернуться на главную</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""",
|
||||||
|
status_code=403
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Функция запуска сервера
|
||||||
|
async def start():
|
||||||
|
"""Запуск сервера и инициализация данных"""
|
||||||
|
logger.info(f"Запуск сервера в режиме: {MODE}")
|
||||||
|
|
||||||
|
# Создаем все таблицы в БД
|
||||||
|
create_all_tables()
|
||||||
|
|
||||||
|
# Запускаем предварительное кеширование данных
|
||||||
|
asyncio.create_task(precache_data())
|
||||||
|
|
||||||
|
# Запускаем задачу ревалидации кеша
|
||||||
|
asyncio.create_task(revalidation_manager.start())
|
||||||
|
|
||||||
|
# Выводим сообщение о запуске сервера и доступности API
|
||||||
|
logger.info("Сервер запущен и готов принимать запросы")
|
||||||
|
logger.info("GraphQL API доступно по адресу: /graphql")
|
||||||
|
logger.info("Админ-панель доступна по адресу: /admin")
|
||||||
|
|
||||||
|
|
||||||
|
# Функция остановки сервера
|
||||||
|
async def shutdown():
|
||||||
|
"""Остановка сервера и освобождение ресурсов"""
|
||||||
|
logger.info("Остановка сервера")
|
||||||
|
|
||||||
|
# Закрываем соединение с Redis
|
||||||
|
await redis.disconnect()
|
||||||
|
|
||||||
|
# Останавливаем поисковый сервис
|
||||||
|
search_service.close()
|
||||||
|
|
||||||
|
# Удаляем PID-файл, если он существует
|
||||||
|
if exists(DEV_SERVER_PID_FILE_NAME):
|
||||||
|
os.unlink(DEV_SERVER_PID_FILE_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
# Добавляем маршруты статических файлов, если директория существует
|
||||||
|
routes = []
|
||||||
|
if exists(DIST_DIR):
|
||||||
|
# Добавляем маршруты для статических ресурсов, если директория dist существует
|
||||||
|
routes.append(Mount("/assets", app=StaticFiles(directory=join(DIST_DIR, "assets"))))
|
||||||
|
routes.append(Mount("/chunks", app=StaticFiles(directory=join(DIST_DIR, "chunks"))))
|
||||||
|
|
||||||
|
# Маршруты для API и веб-приложения
|
||||||
|
routes.extend(
|
||||||
|
[
|
||||||
|
Route("/graphql", graphql_handler, methods=["GET", "POST"]),
|
||||||
|
# Добавляем специальный маршрут для админ-панели с проверкой прав доступа
|
||||||
|
Route("/admin", admin_handler, methods=["GET"]),
|
||||||
|
# Маршрут для обработки всех остальных запросов - SPA
|
||||||
|
Route("/{path:path}", index_handler, methods=["GET"]),
|
||||||
|
Route("/", index_handler, methods=["GET"]),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(ExceptionHandlerMiddleware)
|
# Добавляем маршруты авторизации
|
||||||
if "dev" in sys.argv:
|
routes.extend(auth_routes)
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
app = Starlette(
|
||||||
allow_origins=["https://localhost:3000"],
|
debug=MODE == "development",
|
||||||
allow_credentials=True,
|
routes=routes,
|
||||||
allow_methods=["*"],
|
middleware=[
|
||||||
allow_headers=["*"],
|
Middleware(ExceptionHandlerMiddleware),
|
||||||
)
|
Middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
),
|
||||||
|
# Добавляем middleware для обработки Authorization заголовка с Bearer токеном
|
||||||
|
Middleware(AuthorizationMiddleware),
|
||||||
|
# Добавляем middleware для аутентификации после обработки токенов
|
||||||
|
Middleware(AuthenticationMiddleware, backend=InternalAuthentication()),
|
||||||
|
],
|
||||||
|
on_startup=[start],
|
||||||
|
on_shutdown=[shutdown],
|
||||||
|
)
|
||||||
|
|
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 import Column, ForeignKey, Integer, String, Text, distinct, func
|
||||||
from sqlalchemy.ext.hybrid import hybrid_property
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
|
|
||||||
from orm.author import Author
|
from auth.orm import Author
|
||||||
from services.db import Base
|
from services.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
@ -66,7 +66,11 @@ class CommunityStats:
|
||||||
def shouts(self):
|
def shouts(self):
|
||||||
from orm.shout import Shout
|
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
|
@property
|
||||||
def followers(self):
|
def followers(self):
|
||||||
|
@ -84,7 +88,11 @@ class CommunityStats:
|
||||||
return (
|
return (
|
||||||
self.community.session.query(func.count(distinct(Author.id)))
|
self.community.session.query(func.count(distinct(Author.id)))
|
||||||
.join(Shout)
|
.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()
|
.scalar()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
25
orm/draft.py
25
orm/draft.py
|
@ -3,7 +3,7 @@ import time
|
||||||
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String
|
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from orm.author import Author
|
from auth.orm import Author
|
||||||
from orm.topic import Topic
|
from orm.topic import Topic
|
||||||
from services.db import Base
|
from services.db import Base
|
||||||
|
|
||||||
|
@ -26,7 +26,6 @@ class DraftAuthor(Base):
|
||||||
caption = Column(String, nullable=True, default="")
|
caption = Column(String, nullable=True, default="")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Draft(Base):
|
class Draft(Base):
|
||||||
__tablename__ = "draft"
|
__tablename__ = "draft"
|
||||||
# required
|
# required
|
||||||
|
@ -53,12 +52,12 @@ class Draft(Base):
|
||||||
deleted_at: int | None = Column(Integer, nullable=True, index=True)
|
deleted_at: int | None = Column(Integer, nullable=True, index=True)
|
||||||
updated_by: int | None = Column("updated_by", ForeignKey("author.id"), nullable=True)
|
updated_by: int | None = Column("updated_by", ForeignKey("author.id"), nullable=True)
|
||||||
deleted_by: int | None = Column("deleted_by", ForeignKey("author.id"), nullable=True)
|
deleted_by: int | None = Column("deleted_by", ForeignKey("author.id"), nullable=True)
|
||||||
|
|
||||||
# --- Relationships ---
|
# --- Relationships ---
|
||||||
# Только many-to-many связи через вспомогательные таблицы
|
# Только many-to-many связи через вспомогательные таблицы
|
||||||
authors = relationship(Author, secondary="draft_author", lazy="select")
|
authors = relationship(Author, secondary="draft_author", lazy="select")
|
||||||
topics = relationship(Topic, secondary="draft_topic", lazy="select")
|
topics = relationship(Topic, secondary="draft_topic", lazy="select")
|
||||||
|
|
||||||
# Связь с Community (если нужна как объект, а не ID)
|
# Связь с Community (если нужна как объект, а не ID)
|
||||||
# community = relationship("Community", foreign_keys=[community_id], lazy="joined")
|
# community = relationship("Community", foreign_keys=[community_id], lazy="joined")
|
||||||
# Пока оставляем community_id как ID
|
# Пока оставляем community_id как ID
|
||||||
|
@ -66,12 +65,12 @@ class Draft(Base):
|
||||||
# Связь с публикацией (один-к-одному или один-к-нулю)
|
# Связь с публикацией (один-к-одному или один-к-нулю)
|
||||||
# Загружается через joinedload в резолвере
|
# Загружается через joinedload в резолвере
|
||||||
publication = relationship(
|
publication = relationship(
|
||||||
"Shout",
|
"Shout",
|
||||||
primaryjoin="Draft.id == Shout.draft",
|
primaryjoin="Draft.id == Shout.draft",
|
||||||
foreign_keys="Shout.draft",
|
foreign_keys="Shout.draft",
|
||||||
uselist=False,
|
uselist=False,
|
||||||
lazy="noload", # Не грузим по умолчанию, только через options
|
lazy="noload", # Не грузим по умолчанию, только через options
|
||||||
viewonly=True # Указываем, что это связь только для чтения
|
viewonly=True, # Указываем, что это связь только для чтения
|
||||||
)
|
)
|
||||||
|
|
||||||
def dict(self):
|
def dict(self):
|
||||||
|
@ -101,5 +100,5 @@ class Draft(Base):
|
||||||
"deleted_by": self.deleted_by,
|
"deleted_by": self.deleted_by,
|
||||||
# Гарантируем, что topics и authors всегда будут списками
|
# Гарантируем, что topics и authors всегда будут списками
|
||||||
"topics": [topic.dict() for topic in (self.topics or [])],
|
"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 import JSON, Column, ForeignKey, Integer, String
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from orm.author import Author
|
from auth.orm import Author
|
||||||
from services.db import Base
|
from services.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ import time
|
||||||
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
|
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from orm.author import Author
|
from auth.orm import Author
|
||||||
from orm.reaction import Reaction
|
from orm.reaction import Reaction
|
||||||
from orm.topic import Topic
|
from orm.topic import Topic
|
||||||
from services.db import Base
|
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,
|
get_cached_follower_topics,
|
||||||
invalidate_cache_by_prefix,
|
invalidate_cache_by_prefix,
|
||||||
)
|
)
|
||||||
from orm.author import Author
|
from auth.orm import Author
|
||||||
from resolvers.stat import get_with_stat
|
from resolvers.stat import get_with_stat
|
||||||
from services.auth import login_required
|
from services.auth import login_required
|
||||||
from services.db import local_session
|
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():
|
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:
|
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 by:
|
||||||
if isinstance(by, dict):
|
if isinstance(by, dict):
|
||||||
# Обработка словаря параметров сортировки
|
# Обработка словаря параметров сортировки
|
||||||
from sqlalchemy import asc, desc
|
from sqlalchemy import desc
|
||||||
|
|
||||||
for field, direction in by.items():
|
for field, direction in by.items():
|
||||||
column = getattr(Author, field, None)
|
column = getattr(Author, field, None)
|
||||||
|
|
|
@ -3,7 +3,7 @@ from operator import and_
|
||||||
from graphql import GraphQLError
|
from graphql import GraphQLError
|
||||||
from sqlalchemy import delete, insert
|
from sqlalchemy import delete, insert
|
||||||
|
|
||||||
from orm.author import AuthorBookmark
|
from auth.orm import AuthorBookmark
|
||||||
from orm.shout import Shout
|
from orm.shout import Shout
|
||||||
from resolvers.feed import apply_options
|
from resolvers.feed import apply_options
|
||||||
from resolvers.reader import get_shouts_with_links, query_with_stat
|
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:
|
if existing_bookmark:
|
||||||
db.execute(
|
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
|
result = False
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from orm.author import Author
|
from auth.orm import Author
|
||||||
from orm.invite import Invite, InviteStatus
|
from orm.invite import Invite, InviteStatus
|
||||||
from orm.shout import Shout
|
from orm.shout import Shout
|
||||||
from services.auth import login_required
|
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 orm.community import Community, CommunityFollower
|
||||||
from services.db import local_session
|
from services.db import local_session
|
||||||
from services.schema import mutation, query
|
from services.schema import mutation, query
|
||||||
|
@ -74,9 +74,9 @@ async def update_community(_, info, community_data):
|
||||||
if slug:
|
if slug:
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
try:
|
try:
|
||||||
session.query(Community).where(Community.created_by == author_id, Community.slug == slug).update(
|
session.query(Community).where(
|
||||||
community_data
|
Community.created_by == author_id, Community.slug == slug
|
||||||
)
|
).update(community_data)
|
||||||
session.commit()
|
session.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"ok": False, "error": str(e)}
|
return {"ok": False, "error": str(e)}
|
||||||
|
@ -90,7 +90,9 @@ async def delete_community(_, info, slug: str):
|
||||||
author_id = author_dict.get("id")
|
author_id = author_dict.get("id")
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
try:
|
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()
|
session.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
@ -1,26 +1,22 @@
|
||||||
import time
|
import time
|
||||||
import trafilatura
|
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from cache.cache import (
|
from cache.cache import (
|
||||||
cache_author,
|
|
||||||
cache_by_id,
|
|
||||||
cache_topic,
|
|
||||||
invalidate_shout_related_cache,
|
invalidate_shout_related_cache,
|
||||||
invalidate_shouts_cache,
|
invalidate_shouts_cache,
|
||||||
)
|
)
|
||||||
from orm.author import Author
|
from auth.orm import Author
|
||||||
from orm.draft import Draft, DraftAuthor, DraftTopic
|
from orm.draft import Draft, DraftAuthor, DraftTopic
|
||||||
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
||||||
from orm.topic import Topic
|
|
||||||
from services.auth import login_required
|
from services.auth import login_required
|
||||||
from services.db import local_session
|
from services.db import local_session
|
||||||
from services.notify import notify_shout
|
from services.notify import notify_shout
|
||||||
from services.schema import mutation, query
|
from services.schema import mutation, query
|
||||||
from services.search import search_service
|
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
|
from utils.logger import root_logger as logger
|
||||||
|
|
||||||
|
|
||||||
def create_shout_from_draft(session, draft, author_id):
|
def create_shout_from_draft(session, draft, author_id):
|
||||||
"""
|
"""
|
||||||
Создаёт новый объект публикации (Shout) на основе черновика.
|
Создаёт новый объект публикации (Shout) на основе черновика.
|
||||||
|
@ -62,11 +58,11 @@ def create_shout_from_draft(session, draft, author_id):
|
||||||
draft=draft.id,
|
draft=draft.id,
|
||||||
deleted_at=None,
|
deleted_at=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Инициализируем пустые массивы для связей
|
# Инициализируем пустые массивы для связей
|
||||||
shout.topics = []
|
shout.topics = []
|
||||||
shout.authors = []
|
shout.authors = []
|
||||||
|
|
||||||
return shout
|
return shout
|
||||||
|
|
||||||
|
|
||||||
|
@ -75,10 +71,10 @@ def create_shout_from_draft(session, draft, author_id):
|
||||||
async def load_drafts(_, info):
|
async def load_drafts(_, info):
|
||||||
"""
|
"""
|
||||||
Загружает все черновики, доступные текущему пользователю.
|
Загружает все черновики, доступные текущему пользователю.
|
||||||
|
|
||||||
Предварительно загружает связанные объекты (topics, authors, publication),
|
Предварительно загружает связанные объекты (topics, authors, publication),
|
||||||
чтобы избежать ошибок с отсоединенными объектами при сериализации.
|
чтобы избежать ошибок с отсоединенными объектами при сериализации.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Список черновиков или сообщение об ошибке
|
dict: Список черновиков или сообщение об ошибке
|
||||||
"""
|
"""
|
||||||
|
@ -97,12 +93,12 @@ async def load_drafts(_, info):
|
||||||
.options(
|
.options(
|
||||||
joinedload(Draft.topics),
|
joinedload(Draft.topics),
|
||||||
joinedload(Draft.authors),
|
joinedload(Draft.authors),
|
||||||
joinedload(Draft.publication) # Загружаем связанную публикацию
|
joinedload(Draft.publication), # Загружаем связанную публикацию
|
||||||
)
|
)
|
||||||
.filter(Draft.authors.any(Author.id == author_id))
|
.filter(Draft.authors.any(Author.id == author_id))
|
||||||
)
|
)
|
||||||
drafts = drafts_query.all()
|
drafts = drafts_query.all()
|
||||||
|
|
||||||
# Преобразуем объекты в словари, пока они в контексте сессии
|
# Преобразуем объекты в словари, пока они в контексте сессии
|
||||||
drafts_data = []
|
drafts_data = []
|
||||||
for draft in drafts:
|
for draft in drafts:
|
||||||
|
@ -110,19 +106,19 @@ async def load_drafts(_, info):
|
||||||
# Всегда возвращаем массив для topics, даже если он пустой
|
# Всегда возвращаем массив для topics, даже если он пустой
|
||||||
draft_dict["topics"] = [topic.dict() for topic in (draft.topics or [])]
|
draft_dict["topics"] = [topic.dict() for topic in (draft.topics or [])]
|
||||||
draft_dict["authors"] = [author.dict() for author in (draft.authors or [])]
|
draft_dict["authors"] = [author.dict() for author in (draft.authors or [])]
|
||||||
|
|
||||||
# Добавляем информацию о публикации, если она есть
|
# Добавляем информацию о публикации, если она есть
|
||||||
if draft.publication:
|
if draft.publication:
|
||||||
draft_dict["publication"] = {
|
draft_dict["publication"] = {
|
||||||
"id": draft.publication.id,
|
"id": draft.publication.id,
|
||||||
"slug": draft.publication.slug,
|
"slug": draft.publication.slug,
|
||||||
"published_at": draft.publication.published_at
|
"published_at": draft.publication.published_at,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
draft_dict["publication"] = None
|
draft_dict["publication"] = None
|
||||||
|
|
||||||
drafts_data.append(draft_dict)
|
drafts_data.append(draft_dict)
|
||||||
|
|
||||||
return {"drafts": drafts_data}
|
return {"drafts": drafts_data}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load drafts: {e}", exc_info=True)
|
logger.error(f"Failed to load drafts: {e}", exc_info=True)
|
||||||
|
@ -180,27 +176,27 @@ async def create_draft(_, info, draft_input):
|
||||||
# Remove id from input if present since it's auto-generated
|
# Remove id from input if present since it's auto-generated
|
||||||
if "id" in draft_input:
|
if "id" in draft_input:
|
||||||
del draft_input["id"]
|
del draft_input["id"]
|
||||||
|
|
||||||
# Добавляем текущее время создания и ID автора
|
# Добавляем текущее время создания и ID автора
|
||||||
draft_input["created_at"] = int(time.time())
|
draft_input["created_at"] = int(time.time())
|
||||||
draft_input["created_by"] = author_id
|
draft_input["created_by"] = author_id
|
||||||
draft = Draft(**draft_input)
|
draft = Draft(**draft_input)
|
||||||
session.add(draft)
|
session.add(draft)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
# Добавляем создателя как автора
|
# Добавляем создателя как автора
|
||||||
da = DraftAuthor(shout=draft.id, author=author_id)
|
da = DraftAuthor(shout=draft.id, author=author_id)
|
||||||
session.add(da)
|
session.add(da)
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
return {"draft": draft}
|
return {"draft": draft}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create draft: {e}", exc_info=True)
|
logger.error(f"Failed to create draft: {e}", exc_info=True)
|
||||||
return {"error": f"Failed to create draft: {str(e)}"}
|
return {"error": f"Failed to create draft: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
def generate_teaser(body, limit=300):
|
def generate_teaser(body, limit=300):
|
||||||
body_html = wrap_html_fragment(body)
|
body_text = extract_text(body)
|
||||||
body_text = trafilatura.extract(body_html, include_comments=False, include_tables=False)
|
|
||||||
body_teaser = ". ".join(body_text[:limit].split(". ")[:-1])
|
body_teaser = ". ".join(body_text[:limit].split(". ")[:-1])
|
||||||
return body_teaser
|
return body_teaser
|
||||||
|
|
||||||
|
@ -246,9 +242,20 @@ async def update_draft(_, info, draft_id: int, draft_input):
|
||||||
|
|
||||||
# Фильтруем входные данные, оставляя только разрешенные поля
|
# Фильтруем входные данные, оставляя только разрешенные поля
|
||||||
allowed_fields = {
|
allowed_fields = {
|
||||||
"layout", "author_ids", "topic_ids", "main_topic_id",
|
"layout",
|
||||||
"media", "lead", "subtitle", "lang", "seo", "body",
|
"author_ids",
|
||||||
"title", "slug", "cover", "cover_caption"
|
"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}
|
filtered_input = {k: v for k, v in draft_input.items() if k in allowed_fields}
|
||||||
|
|
||||||
|
@ -277,9 +284,9 @@ async def update_draft(_, info, draft_id: int, draft_input):
|
||||||
# Добавляем новые связи
|
# Добавляем новые связи
|
||||||
for tid in topic_ids:
|
for tid in topic_ids:
|
||||||
dt = DraftTopic(
|
dt = DraftTopic(
|
||||||
shout=draft_id,
|
shout=draft_id,
|
||||||
topic=tid,
|
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)
|
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:
|
if "seo" not in filtered_input and not draft.seo:
|
||||||
body_src = filtered_input.get("body", draft.body)
|
body_src = filtered_input.get("body", draft.body)
|
||||||
lead_src = filtered_input.get("lead", draft.lead)
|
lead_src = filtered_input.get("lead", draft.lead)
|
||||||
body_html = wrap_html_fragment(body_src)
|
|
||||||
lead_html = wrap_html_fragment(lead_src)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
body_text = trafilatura.extract(body_html, include_comments=False, include_tables=False) if body_src else None
|
body_text = extract_text(body_src) if body_src else None
|
||||||
lead_text = trafilatura.extract(lead_html, include_comments=False, include_tables=False) if lead_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 ""
|
body_teaser = generate_teaser(body_text, 300) if body_text else ""
|
||||||
filtered_input["seo"] = lead_text if lead_text else body_teaser
|
filtered_input["seo"] = lead_text if lead_text else body_teaser
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -308,14 +312,14 @@ async def update_draft(_, info, draft_id: int, draft_input):
|
||||||
draft.updated_by = author_id
|
draft.updated_by = author_id
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
# Преобразуем объект в словарь для ответа
|
# Преобразуем объект в словарь для ответа
|
||||||
draft_dict = draft.dict()
|
draft_dict = draft.dict()
|
||||||
draft_dict["topics"] = [topic.dict() for topic in draft.topics]
|
draft_dict["topics"] = [topic.dict() for topic in draft.topics]
|
||||||
draft_dict["authors"] = [author.dict() for author in draft.authors]
|
draft_dict["authors"] = [author.dict() for author in draft.authors]
|
||||||
# Добавляем объект автора в updated_by
|
# Добавляем объект автора в updated_by
|
||||||
draft_dict["updated_by"] = author_dict
|
draft_dict["updated_by"] = author_dict
|
||||||
|
|
||||||
return {"draft": draft_dict}
|
return {"draft": draft_dict}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -343,13 +347,13 @@ async def delete_draft(_, info, draft_id: int):
|
||||||
def validate_html_content(html_content: str) -> tuple[bool, str]:
|
def validate_html_content(html_content: str) -> tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Проверяет валидность HTML контента через trafilatura.
|
Проверяет валидность HTML контента через trafilatura.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
html_content: HTML строка для проверки
|
html_content: HTML строка для проверки
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple[bool, str]: (валидность, сообщение об ошибке)
|
tuple[bool, str]: (валидность, сообщение об ошибке)
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> is_valid, error = validate_html_content("<p>Valid HTML</p>")
|
>>> is_valid, error = validate_html_content("<p>Valid HTML</p>")
|
||||||
>>> is_valid
|
>>> is_valid
|
||||||
|
@ -364,13 +368,10 @@ def validate_html_content(html_content: str) -> tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
if not html_content or not html_content.strip():
|
if not html_content or not html_content.strip():
|
||||||
return False, "Content is empty"
|
return False, "Content is empty"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
html_content = wrap_html_fragment(html_content)
|
extracted = extract_text(html_content)
|
||||||
extracted = trafilatura.extract(html_content)
|
return bool(extracted), extracted or ""
|
||||||
if not extracted:
|
|
||||||
return False, "Invalid HTML structure or empty content"
|
|
||||||
return True, ""
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"HTML validation error: {e}", exc_info=True)
|
logger.error(f"HTML validation error: {e}", exc_info=True)
|
||||||
return False, f"Invalid HTML content: {str(e)}"
|
return False, f"Invalid HTML content: {str(e)}"
|
||||||
|
@ -381,10 +382,10 @@ def validate_html_content(html_content: str) -> tuple[bool, str]:
|
||||||
async def publish_draft(_, info, draft_id: int):
|
async def publish_draft(_, info, draft_id: int):
|
||||||
"""
|
"""
|
||||||
Публикует черновик, создавая новый Shout или обновляя существующий.
|
Публикует черновик, создавая новый Shout или обновляя существующий.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
draft_id (int): ID черновика для публикации
|
draft_id (int): ID черновика для публикации
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Результат публикации с shout или сообщением об ошибке
|
dict: Результат публикации с shout или сообщением об ошибке
|
||||||
"""
|
"""
|
||||||
|
@ -400,11 +401,7 @@ async def publish_draft(_, info, draft_id: int):
|
||||||
# Загружаем черновик со всеми связями
|
# Загружаем черновик со всеми связями
|
||||||
draft = (
|
draft = (
|
||||||
session.query(Draft)
|
session.query(Draft)
|
||||||
.options(
|
.options(joinedload(Draft.topics), joinedload(Draft.authors), joinedload(Draft.publication))
|
||||||
joinedload(Draft.topics),
|
|
||||||
joinedload(Draft.authors),
|
|
||||||
joinedload(Draft.publication)
|
|
||||||
)
|
|
||||||
.filter(Draft.id == draft_id)
|
.filter(Draft.id == draft_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
@ -421,7 +418,17 @@ async def publish_draft(_, info, draft_id: int):
|
||||||
if draft.publication:
|
if draft.publication:
|
||||||
shout = 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):
|
if hasattr(draft, field):
|
||||||
setattr(shout, field, getattr(draft, field))
|
setattr(shout, field, getattr(draft, field))
|
||||||
shout.updated_at = int(time.time())
|
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()
|
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)
|
sa = ShoutAuthor(shout=shout.id, author=author.id)
|
||||||
session.add(sa)
|
session.add(sa)
|
||||||
|
|
||||||
# Добавляем темы
|
# Добавляем темы
|
||||||
for topic in (draft.topics or []):
|
for topic in draft.topics or []:
|
||||||
st = ShoutTopic(
|
st = ShoutTopic(
|
||||||
topic=topic.id,
|
topic=topic.id, shout=shout.id, main=topic.main if hasattr(topic, "main") else False
|
||||||
shout=shout.id,
|
|
||||||
main=topic.main if hasattr(topic, "main") else False
|
|
||||||
)
|
)
|
||||||
session.add(st)
|
session.add(st)
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import orjson
|
import orjson
|
||||||
import trafilatura
|
|
||||||
from sqlalchemy import and_, desc, select
|
from sqlalchemy import and_, desc, select
|
||||||
from sqlalchemy.orm import joinedload, selectinload
|
from sqlalchemy.orm import joinedload, selectinload
|
||||||
from sqlalchemy.sql.functions import coalesce
|
from sqlalchemy.sql.functions import coalesce
|
||||||
|
@ -12,7 +11,7 @@ from cache.cache import (
|
||||||
invalidate_shout_related_cache,
|
invalidate_shout_related_cache,
|
||||||
invalidate_shouts_cache,
|
invalidate_shouts_cache,
|
||||||
)
|
)
|
||||||
from orm.author import Author
|
from auth.orm import Author
|
||||||
from orm.draft import Draft
|
from orm.draft import Draft
|
||||||
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
||||||
from orm.topic import Topic
|
from orm.topic import Topic
|
||||||
|
@ -23,7 +22,7 @@ from services.db import local_session
|
||||||
from services.notify import notify_shout
|
from services.notify import notify_shout
|
||||||
from services.schema import mutation, query
|
from services.schema import mutation, query
|
||||||
from services.search import search_service
|
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
|
from utils.logger import root_logger as logger
|
||||||
|
|
||||||
|
|
||||||
|
@ -181,11 +180,11 @@ async def create_shout(_, info, inp):
|
||||||
# Создаем публикацию без topics
|
# Создаем публикацию без topics
|
||||||
body = inp.get("body", "")
|
body = inp.get("body", "")
|
||||||
lead = inp.get("lead", "")
|
lead = inp.get("lead", "")
|
||||||
body_html = wrap_html_fragment(body)
|
body_text = extract_text(body)
|
||||||
lead_html = wrap_html_fragment(lead)
|
lead_text = extract_text(lead)
|
||||||
body_text = trafilatura.extract(body_html)
|
seo = inp.get(
|
||||||
lead_text = trafilatura.extract(lead_html)
|
"seo", lead_text.strip() or body_text.strip()[:300].split(". ")[:-1].join(". ")
|
||||||
seo = inp.get("seo", lead_text.strip() or body_text.strip()[:300].split(". ")[:-1].join(". "))
|
)
|
||||||
new_shout = Shout(
|
new_shout = Shout(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
body=body,
|
body=body,
|
||||||
|
@ -282,7 +281,9 @@ def patch_main_topic(session, main_topic_slug, shout):
|
||||||
with session.begin():
|
with session.begin():
|
||||||
# Получаем текущий главный топик
|
# Получаем текущий главный топик
|
||||||
old_main = (
|
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:
|
if old_main:
|
||||||
logger.info(f"Found current main topic: {old_main.topic.slug}")
|
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()
|
session.flush()
|
||||||
logger.info(f"Main topic updated for shout#{shout.id}")
|
logger.info(f"Main topic updated for shout#{shout.id}")
|
||||||
else:
|
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):
|
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}")
|
logger.info(f"Processing update for shout#{shout_id} by author #{author_id}")
|
||||||
shout_by_id = (
|
shout_by_id = (
|
||||||
session.query(Shout)
|
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)
|
.filter(Shout.id == shout_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
@ -446,7 +451,10 @@ async def update_shout(_, info, shout_id: int, shout_input=None, publish=False):
|
||||||
shout_input["slug"] = slug
|
shout_input["slug"] = slug
|
||||||
logger.info(f"shout#{shout_id} slug patched")
|
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}")
|
logger.info(f"Author #{author_id} has permission to edit shout#{shout_id}")
|
||||||
|
|
||||||
# topics patch
|
# topics patch
|
||||||
|
@ -560,7 +568,9 @@ async def update_shout(_, info, shout_id: int, shout_input=None, publish=False):
|
||||||
# Получаем полные данные шаута со связями
|
# Получаем полные данные шаута со связями
|
||||||
shout_with_relations = (
|
shout_with_relations = (
|
||||||
session.query(Shout)
|
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)
|
.filter(Shout.id == shout_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
@ -648,19 +658,17 @@ async def delete_shout(_, info, shout_id: int):
|
||||||
def get_main_topic(topics):
|
def get_main_topic(topics):
|
||||||
"""Get the main topic from a list of ShoutTopic objects."""
|
"""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.info(f"Starting get_main_topic with {len(topics) if topics else 0} topics")
|
||||||
logger.debug(
|
logger.debug(f"Topics data: {[(t.slug, getattr(t, 'main', False)) for t in topics] if topics else []}")
|
||||||
f"Topics data: {[(t.slug, getattr(t, 'main', False)) for t in topics] if topics else []}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not topics:
|
if not topics:
|
||||||
logger.warning("No topics provided to get_main_topic")
|
logger.warning("No topics provided to get_main_topic")
|
||||||
return {"id": 0, "title": "no topic", "slug": "notopic", "is_main": True}
|
return {"id": 0, "title": "no topic", "slug": "notopic", "is_main": True}
|
||||||
|
|
||||||
# Проверяем, является ли topics списком объектов ShoutTopic или Topic
|
# Проверяем, является ли topics списком объектов ShoutTopic или Topic
|
||||||
if hasattr(topics[0], 'topic') and topics[0].topic:
|
if hasattr(topics[0], "topic") and topics[0].topic:
|
||||||
# Для ShoutTopic объектов (старый формат)
|
# Для ShoutTopic объектов (старый формат)
|
||||||
# Find first main topic in original order
|
# 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(
|
logger.debug(
|
||||||
f"Found main topic relation: {main_topic_rel.topic.slug if main_topic_rel and main_topic_rel.topic else None}"
|
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")
|
logger.warning("No valid topics found, returning default")
|
||||||
return {"slug": "notopic", "title": "no topic", "id": 0, "is_main": True}
|
return {"slug": "notopic", "title": "no topic", "id": 0, "is_main": True}
|
||||||
|
|
||||||
|
|
||||||
@mutation.field("unpublish_shout")
|
@mutation.field("unpublish_shout")
|
||||||
@login_required
|
@login_required
|
||||||
async def unpublish_shout(_, info, shout_id: int):
|
async def unpublish_shout(_, info, shout_id: int):
|
||||||
|
@ -727,31 +736,25 @@ async def unpublish_shout(_, info, shout_id: int):
|
||||||
# Загружаем Shout со всеми связями для правильного формирования ответа
|
# Загружаем Shout со всеми связями для правильного формирования ответа
|
||||||
shout = (
|
shout = (
|
||||||
session.query(Shout)
|
session.query(Shout)
|
||||||
.options(
|
.options(joinedload(Shout.authors), selectinload(Shout.topics))
|
||||||
joinedload(Shout.authors),
|
|
||||||
selectinload(Shout.topics)
|
|
||||||
)
|
|
||||||
.filter(Shout.id == shout_id)
|
.filter(Shout.id == shout_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
if not shout:
|
if not shout:
|
||||||
logger.warning(f"Shout not found for unpublish: ID {shout_id}")
|
logger.warning(f"Shout not found for unpublish: ID {shout_id}")
|
||||||
return {"error": "Shout not found"}
|
return {"error": "Shout not found"}
|
||||||
|
|
||||||
# Если у публикации есть связанный черновик, загружаем его с relationships
|
# Если у публикации есть связанный черновик, загружаем его с relationships
|
||||||
if shout.draft:
|
if shout.draft:
|
||||||
# Отдельно загружаем черновик с его связями
|
# Отдельно загружаем черновик с его связями
|
||||||
draft = (
|
draft = (
|
||||||
session.query(Draft)
|
session.query(Draft)
|
||||||
.options(
|
.options(selectinload(Draft.authors), selectinload(Draft.topics))
|
||||||
selectinload(Draft.authors),
|
|
||||||
selectinload(Draft.topics)
|
|
||||||
)
|
|
||||||
.filter(Draft.id == shout.draft)
|
.filter(Draft.id == shout.draft)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Связываем черновик с публикацией вручную для доступа через API
|
# Связываем черновик с публикацией вручную для доступа через API
|
||||||
if draft:
|
if draft:
|
||||||
shout.draft_obj = draft
|
shout.draft_obj = draft
|
||||||
|
@ -768,38 +771,32 @@ async def unpublish_shout(_, info, shout_id: int):
|
||||||
# Снимаем с публикации (устанавливаем published_at в None)
|
# Снимаем с публикации (устанавливаем published_at в None)
|
||||||
shout.published_at = None
|
shout.published_at = None
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
# Формируем полноценный словарь для ответа
|
# Формируем полноценный словарь для ответа
|
||||||
shout_dict = shout.dict()
|
shout_dict = shout.dict()
|
||||||
|
|
||||||
# Добавляем связанные данные
|
# Добавляем связанные данные
|
||||||
shout_dict["topics"] = (
|
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
|
if shout.topics
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
|
|
||||||
# Добавляем main_topic
|
# Добавляем main_topic
|
||||||
shout_dict["main_topic"] = get_main_topic(shout.topics)
|
shout_dict["main_topic"] = get_main_topic(shout.topics)
|
||||||
|
|
||||||
# Добавляем авторов
|
# Добавляем авторов
|
||||||
shout_dict["authors"] = (
|
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
|
if shout.authors
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
|
|
||||||
# Важно! Обновляем поле publication, отражая состояние "снят с публикации"
|
# Важно! Обновляем поле publication, отражая состояние "снят с публикации"
|
||||||
shout_dict["publication"] = {
|
shout_dict["publication"] = {
|
||||||
"id": shout_id_for_publication,
|
"id": shout_id_for_publication,
|
||||||
"slug": shout_slug,
|
"slug": shout_slug,
|
||||||
"published_at": None # Ключевое изменение - устанавливаем published_at в None
|
"published_at": None, # Ключевое изменение - устанавливаем published_at в None
|
||||||
}
|
}
|
||||||
|
|
||||||
# Инвалидация кэша
|
# Инвалидация кэша
|
||||||
|
@ -810,17 +807,17 @@ async def unpublish_shout(_, info, shout_id: int):
|
||||||
"random_top", # случайные топовые
|
"random_top", # случайные топовые
|
||||||
"unrated", # неоцененные
|
"unrated", # неоцененные
|
||||||
]
|
]
|
||||||
await invalidate_shout_related_cache(shout, author_id)
|
await invalidate_shout_related_cache(shout, author_id)
|
||||||
await invalidate_shouts_cache(cache_keys)
|
await invalidate_shouts_cache(cache_keys)
|
||||||
logger.info(f"Cache invalidated after unpublishing shout {shout_id}")
|
logger.info(f"Cache invalidated after unpublishing shout {shout_id}")
|
||||||
except Exception as cache_err:
|
except Exception as cache_err:
|
||||||
logger.error(f"Failed to invalidate cache for unpublish shout {shout_id}: {cache_err}")
|
logger.error(f"Failed to invalidate cache for unpublish shout {shout_id}: {cache_err}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
logger.error(f"Failed to unpublish shout {shout_id}: {e}", exc_info=True)
|
logger.error(f"Failed to unpublish shout {shout_id}: {e}", exc_info=True)
|
||||||
return {"error": f"Failed to unpublish shout: {str(e)}"}
|
return {"error": f"Failed to unpublish shout: {str(e)}"}
|
||||||
|
|
||||||
# Возвращаем сформированный словарь вместо объекта
|
# Возвращаем сформированный словарь вместо объекта
|
||||||
logger.info(f"Shout {shout_id} unpublished successfully by author {author_id}")
|
logger.info(f"Shout {shout_id} unpublished successfully by author {author_id}")
|
||||||
return {"shout": shout_dict}
|
return {"shout": shout_dict}
|
||||||
|
|
|
@ -2,7 +2,7 @@ from typing import List
|
||||||
|
|
||||||
from sqlalchemy import and_, select
|
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.shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic
|
||||||
from orm.topic import Topic, TopicFollower
|
from orm.topic import Topic, TopicFollower
|
||||||
from resolvers.reader import (
|
from resolvers.reader import (
|
||||||
|
@ -71,7 +71,9 @@ def shouts_by_follower(info, follower_id: int, options):
|
||||||
q = query_with_stat(info)
|
q = query_with_stat(info)
|
||||||
reader_followed_authors = select(AuthorFollower.author).where(AuthorFollower.follower == follower_id)
|
reader_followed_authors = select(AuthorFollower.author).where(AuthorFollower.follower == follower_id)
|
||||||
reader_followed_topics = select(TopicFollower.topic).where(TopicFollower.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 = (
|
followed_subquery = (
|
||||||
select(Shout.id)
|
select(Shout.id)
|
||||||
.join(ShoutAuthor, ShoutAuthor.shout == 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 = (
|
q = (
|
||||||
query_with_stat(info)
|
query_with_stat(info)
|
||||||
if has_field(info, "stat")
|
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 = q.filter(Shout.authors.any(id=author_id))
|
||||||
q, limit, offset = apply_options(q, options, 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 = (
|
q = (
|
||||||
query_with_stat(info)
|
query_with_stat(info)
|
||||||
if has_field(info, "stat")
|
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 = q.filter(Shout.topics.any(id=topic_id))
|
||||||
q, limit, offset = apply_options(q, options)
|
q, limit, offset = apply_options(q, options)
|
||||||
|
|
|
@ -10,7 +10,7 @@ from cache.cache import (
|
||||||
get_cached_follower_authors,
|
get_cached_follower_authors,
|
||||||
get_cached_follower_topics,
|
get_cached_follower_topics,
|
||||||
)
|
)
|
||||||
from orm.author import Author, AuthorFollower
|
from auth.orm import Author, AuthorFollower
|
||||||
from orm.community import Community, CommunityFollower
|
from orm.community import Community, CommunityFollower
|
||||||
from orm.reaction import Reaction
|
from orm.reaction import Reaction
|
||||||
from orm.shout import Shout, ShoutReactionsFollower
|
from orm.shout import Shout, ShoutReactionsFollower
|
||||||
|
@ -71,11 +71,16 @@ async def follow(_, info, what, slug="", entity_id=0):
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
existing_sub = (
|
existing_sub = (
|
||||||
session.query(follower_class)
|
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()
|
.first()
|
||||||
)
|
)
|
||||||
if existing_sub:
|
if existing_sub:
|
||||||
logger.info(f"Пользователь {follower_id} уже подписан на {what.lower()} с ID {entity_id}")
|
logger.info(
|
||||||
|
f"Пользователь {follower_id} уже подписан на {what.lower()} с ID {entity_id}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.debug("Добавление новой записи в базу данных")
|
logger.debug("Добавление новой записи в базу данных")
|
||||||
sub = follower_class(follower=follower_id, **{entity_type: entity_id})
|
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.orm import aliased
|
||||||
from sqlalchemy.sql import not_
|
from sqlalchemy.sql import not_
|
||||||
|
|
||||||
from orm.author import Author
|
from auth.orm import Author
|
||||||
from orm.notification import (
|
from orm.notification import (
|
||||||
Notification,
|
Notification,
|
||||||
NotificationAction,
|
NotificationAction,
|
||||||
|
@ -66,7 +66,9 @@ def query_notifications(author_id: int, after: int = 0) -> Tuple[int, int, List[
|
||||||
return total, unread, notifications
|
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 []
|
reactions = reactions or []
|
||||||
authors = authors or []
|
authors = authors or []
|
||||||
return {
|
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()
|
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
|
# patch all the proposals' quotes
|
||||||
proposals = (
|
proposals = (
|
||||||
session.query(Reaction)
|
session.query(Reaction)
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from sqlalchemy import and_, case, func, select, true
|
from sqlalchemy import and_, case, func, select, true
|
||||||
from sqlalchemy.orm import aliased
|
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.reaction import Reaction, ReactionKind
|
||||||
from orm.shout import Shout
|
from orm.shout import Shout
|
||||||
from services.auth import login_required
|
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):
|
def get_author_rating_old(session, author: Author):
|
||||||
likes_count = (
|
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 = (
|
dislikes_count = (
|
||||||
session.query(AuthorRating)
|
session.query(AuthorRating)
|
||||||
|
|
|
@ -3,7 +3,7 @@ import time
|
||||||
from sqlalchemy import and_, asc, case, desc, func, select
|
from sqlalchemy import and_, asc, case, desc, func, select
|
||||||
from sqlalchemy.orm import aliased
|
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.rating import PROPOSAL_REACTIONS, RATING_REACTIONS, is_negative, is_positive
|
||||||
from orm.reaction import Reaction, ReactionKind
|
from orm.reaction import Reaction, ReactionKind
|
||||||
from orm.shout import Shout, ShoutAuthor
|
from orm.shout import Shout, ShoutAuthor
|
||||||
|
@ -334,7 +334,9 @@ async def create_reaction(_, info, reaction):
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
authors = session.query(ShoutAuthor.author).filter(ShoutAuthor.shout == shout_id).scalar()
|
authors = session.query(ShoutAuthor.author).filter(ShoutAuthor.shout == shout_id).scalar()
|
||||||
is_author = (
|
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
|
reaction_input["created_by"] = author_id
|
||||||
kind = reaction_input.get("kind")
|
kind = reaction_input.get("kind")
|
||||||
|
@ -487,7 +489,7 @@ def apply_reaction_filters(by, q):
|
||||||
shout_slug = by.get("shout")
|
shout_slug = by.get("shout")
|
||||||
if shout_slug:
|
if shout_slug:
|
||||||
q = q.filter(Shout.slug == shout_slug)
|
q = q.filter(Shout.slug == shout_slug)
|
||||||
|
|
||||||
shout_id = by.get("shout_id")
|
shout_id = by.get("shout_id")
|
||||||
if shout_id:
|
if shout_id:
|
||||||
q = q.filter(Shout.id == shout_id)
|
q = q.filter(Shout.id == shout_id)
|
||||||
|
|
|
@ -4,7 +4,7 @@ from sqlalchemy import and_, nulls_last, text
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
from sqlalchemy.sql.expression import asc, case, desc, func, select
|
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.reaction import Reaction, ReactionKind
|
||||||
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
||||||
from orm.topic import Topic
|
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.join(main_topic, main_topic.id == main_topic_join.topic)
|
||||||
q = q.add_columns(
|
q = q.add_columns(
|
||||||
json_builder(
|
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")
|
).label("main_topic")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -131,7 +138,9 @@ def query_with_stat(info):
|
||||||
select(
|
select(
|
||||||
ShoutTopic.shout,
|
ShoutTopic.shout,
|
||||||
json_array_builder(
|
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"),
|
).label("topics"),
|
||||||
)
|
)
|
||||||
.outerjoin(Topic, ShoutTopic.topic == Topic.id)
|
.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"):
|
if hasattr(row, "main_topic"):
|
||||||
# logger.debug(f"Raw main_topic for shout#{shout_id}: {row.main_topic}")
|
# logger.debug(f"Raw main_topic for shout#{shout_id}: {row.main_topic}")
|
||||||
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}")
|
# 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:
|
elif not main_topic:
|
||||||
logger.warning(f"No main_topic and no topics found for shout#{shout_id}")
|
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
|
shout_dict["main_topic"] = main_topic
|
||||||
# logger.debug(f"Final main_topic for shout#{shout_id}: {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)
|
media_data = orjson.loads(media_data)
|
||||||
except orjson.JSONDecodeError:
|
except orjson.JSONDecodeError:
|
||||||
media_data = []
|
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)
|
shouts.append(shout_dict)
|
||||||
|
|
||||||
|
@ -358,7 +376,9 @@ def apply_sorting(q, options):
|
||||||
"""
|
"""
|
||||||
order_str = options.get("order_by")
|
order_str = options.get("order_by")
|
||||||
if order_str in ["rating", "comments_count", "last_commented_at"]:
|
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 включает поле сортировки
|
q = q.distinct(text(order_str), Shout.id).order_by( # DISTINCT ON включает поле сортировки
|
||||||
nulls_last(query_order_by), Shout.id
|
nulls_last(query_order_by), Shout.id
|
||||||
)
|
)
|
||||||
|
@ -442,7 +462,8 @@ async def load_shouts_unrated(_, info, options):
|
||||||
select(Reaction.shout)
|
select(Reaction.shout)
|
||||||
.where(
|
.where(
|
||||||
and_(
|
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)
|
.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 = 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.join(Author, Author.id == Shout.created_by)
|
||||||
q = q.add_columns(
|
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(ShoutTopic, and_(ShoutTopic.shout == Shout.id, ShoutTopic.main.is_(True)))
|
||||||
q = q.join(Topic, Topic.id == ShoutTopic.topic)
|
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.where(Shout.id.not_in(rated_shouts))
|
||||||
q = q.order_by(func.random())
|
q = q.order_by(func.random())
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ from sqlalchemy import and_, distinct, func, join, select
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
from cache.cache import cache_author
|
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.reaction import Reaction, ReactionKind
|
||||||
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
||||||
from orm.topic import Topic, TopicFollower
|
from orm.topic import Topic, TopicFollower
|
||||||
|
@ -177,7 +177,9 @@ def get_topic_comments_stat(topic_id: int) -> int:
|
||||||
.subquery()
|
.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)
|
q = q.outerjoin(sub_comments, ShoutTopic.shout == sub_comments.c.shout_id)
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
result = session.execute(q).first()
|
result = session.execute(q).first()
|
||||||
|
@ -237,7 +239,9 @@ def get_author_followers_stat(author_id: int) -> int:
|
||||||
:return: Количество уникальных подписчиков автора.
|
:return: Количество уникальных подписчиков автора.
|
||||||
"""
|
"""
|
||||||
aliased_followers = aliased(AuthorFollower)
|
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:
|
with local_session() as session:
|
||||||
result = session.execute(q).first()
|
result = session.execute(q).first()
|
||||||
return result[0] if result else 0
|
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)
|
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:
|
for cols in result:
|
||||||
entity = cols[0]
|
entity = cols[0]
|
||||||
stat = dict()
|
stat = dict()
|
||||||
stat["shouts"] = cols[1] # Статистика по публикациям
|
stat["shouts"] = cols[1] # Статистика по публикациям
|
||||||
stat["followers"] = cols[2] # Статистика по подписчикам
|
stat["followers"] = cols[2] # Статистика по подписчикам
|
||||||
if is_author:
|
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) # Статистика по комментариям
|
stat["comments"] = get_author_comments_stat(entity.id) # Статистика по комментариям
|
||||||
else:
|
else:
|
||||||
stat["authors"] = get_topic_authors_stat(entity.id) # Статистика по авторам темы
|
stat["authors"] = get_topic_authors_stat(entity.id) # Статистика по авторам темы
|
||||||
|
|
|
@ -8,7 +8,7 @@ from cache.cache import (
|
||||||
get_cached_topic_followers,
|
get_cached_topic_followers,
|
||||||
invalidate_cache_by_prefix,
|
invalidate_cache_by_prefix,
|
||||||
)
|
)
|
||||||
from orm.author import Author
|
from auth.orm import Author
|
||||||
from orm.topic import Topic
|
from orm.topic import Topic
|
||||||
from orm.reaction import ReactionKind
|
from orm.reaction import ReactionKind
|
||||||
from resolvers.stat import get_with_stat
|
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
|
ACCEPTED
|
||||||
REJECTED
|
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
|
desc: String
|
||||||
pic: 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 {
|
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
|
# author
|
||||||
rate_author(rated_slug: String!, value: Int!): CommonResult!
|
rate_author(rated_slug: String!, value: Int!): CommonResult!
|
||||||
update_author(profile: ProfileInput!): CommonResult!
|
update_author(profile: ProfileInput!): CommonResult!
|
||||||
|
|
|
@ -6,6 +6,14 @@ type Query {
|
||||||
load_authors_by(by: AuthorsBy!, limit: Int, offset: Int): [Author]
|
load_authors_by(by: AuthorsBy!, limit: Int, offset: Int): [Author]
|
||||||
# search_authors(what: String!): [Author]
|
# search_authors(what: String!): [Author]
|
||||||
|
|
||||||
|
# Auth queries
|
||||||
|
signOut: AuthSuccess!
|
||||||
|
me: AuthResult!
|
||||||
|
isEmailUsed(email: String!): Boolean!
|
||||||
|
isAdmin: Boolean!
|
||||||
|
getOAuthProviders: [OAuthProvider!]!
|
||||||
|
getRoles: [RolesInfo!]!
|
||||||
|
|
||||||
# community
|
# community
|
||||||
get_community: Community
|
get_community: Community
|
||||||
get_communities_all: [Community]
|
get_communities_all: [Community]
|
||||||
|
|
|
@ -23,10 +23,14 @@ type Author {
|
||||||
last_seen: Int
|
last_seen: Int
|
||||||
updated_at: Int
|
updated_at: Int
|
||||||
deleted_at: Int
|
deleted_at: Int
|
||||||
|
email: String
|
||||||
seo: String
|
seo: String
|
||||||
# synthetic
|
# synthetic
|
||||||
stat: AuthorStat # ratings inside
|
stat: AuthorStat # ratings inside
|
||||||
communities: [Community]
|
communities: [Community]
|
||||||
|
# Auth fields
|
||||||
|
roles: [String!]
|
||||||
|
email_verified: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReactionUpdating {
|
type ReactionUpdating {
|
||||||
|
@ -280,3 +284,39 @@ type MyRateComment {
|
||||||
my_rate: ReactionKind
|
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!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
|
158
services/auth.py
158
services/auth.py
|
@ -1,120 +1,90 @@
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
from cache.cache import get_cached_author_by_user_id
|
from cache.cache import get_cached_author_by_user_id
|
||||||
from resolvers.stat import get_with_stat
|
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 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"]
|
ALLOWED_HEADERS = ["Authorization", "Content-Type"]
|
||||||
|
|
||||||
|
|
||||||
async def check_auth(req):
|
async def check_auth(req) -> Tuple[str, list[str]]:
|
||||||
"""
|
"""
|
||||||
Проверка авторизации пользователя.
|
Проверка авторизации пользователя.
|
||||||
|
|
||||||
Эта функция проверяет токен авторизации, переданный в заголовках запроса,
|
Проверяет токен и получает данные из локальной БД.
|
||||||
и возвращает идентификатор пользователя и его роли.
|
|
||||||
|
|
||||||
Параметры:
|
Параметры:
|
||||||
- req: Входящий GraphQL запрос, содержащий заголовок авторизации.
|
- req: Входящий GraphQL запрос, содержащий заголовок авторизации.
|
||||||
|
|
||||||
Возвращает:
|
Возвращает:
|
||||||
- user_id: str - Идентификатор пользователя.
|
- user_id: str - Идентификатор пользователя
|
||||||
- user_roles: list[str] - Список ролей пользователя.
|
- user_roles: list[str] - Список ролей пользователя
|
||||||
"""
|
"""
|
||||||
|
# Проверяем наличие токена
|
||||||
token = req.headers.get("Authorization")
|
token = req.headers.get("Authorization")
|
||||||
|
if not token:
|
||||||
|
return "", []
|
||||||
|
|
||||||
host = req.headers.get("host", "")
|
# Очищаем токен от префикса Bearer если он есть
|
||||||
logger.debug(f"check_auth: host={host}")
|
if token.startswith("Bearer "):
|
||||||
auth_url = AUTH_URL
|
token = token.split("Bearer ")[-1].strip()
|
||||||
if ".dscrs.site" in host or "localhost" in host:
|
|
||||||
auth_url = "https://auth.dscrs.site/graphql"
|
|
||||||
user_id = ""
|
|
||||||
user_roles = []
|
|
||||||
if token:
|
|
||||||
# Проверяем и очищаем токен от префикса Bearer если он есть
|
|
||||||
if token.startswith("Bearer "):
|
|
||||||
token = token.split("Bearer ")[-1].strip()
|
|
||||||
# Logging the authentication token
|
|
||||||
logger.debug(f"TOKEN: {token}")
|
|
||||||
query_name = "validate_jwt_token"
|
|
||||||
operation = "ValidateToken"
|
|
||||||
variables = {"params": {"token_type": "access_token", "token": token}}
|
|
||||||
|
|
||||||
# Только необходимые заголовки для GraphQL запроса
|
logger.debug(f"Checking auth token: {token[:10]}...")
|
||||||
headers = {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
gql = {
|
# Проверяем авторизацию внутренним механизмом
|
||||||
"query": f"query {operation}($params: ValidateJWTTokenInput!)"
|
logger.debug("Using internal authentication")
|
||||||
+ "{"
|
return await verify_internal_auth(token)
|
||||||
+ 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
|
|
||||||
|
|
||||||
|
|
||||||
async def add_user_role(user_id):
|
async def add_user_role(user_id: str, roles: list[str] = None):
|
||||||
"""
|
"""
|
||||||
Добавление роли пользователя.
|
Добавление ролей пользователю в локальной БД.
|
||||||
|
|
||||||
Эта функция добавляет роли "author" и "reader" для указанного пользователя
|
Args:
|
||||||
в системе авторизации.
|
user_id: ID пользователя
|
||||||
|
roles: Список ролей для добавления. По умолчанию ["author", "reader"]
|
||||||
Параметры:
|
|
||||||
- user_id: str - Идентификатор пользователя, которому нужно добавить роли.
|
|
||||||
|
|
||||||
Возвращает:
|
|
||||||
- user_id: str - Идентификатор пользователя, если операция прошла успешно.
|
|
||||||
"""
|
"""
|
||||||
logger.info(f"add author role for user_id: {user_id}")
|
if not roles:
|
||||||
query_name = "_update_user"
|
roles = ["author", "reader"]
|
||||||
operation = "UpdateUserRoles"
|
|
||||||
headers = {
|
logger.info(f"Adding roles {roles} to user {user_id}")
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-authorizer-admin-secret": ADMIN_SECRET,
|
logger.debug("Using local authentication")
|
||||||
}
|
with local_session() as session:
|
||||||
variables = {"params": {"roles": "author, reader", "id": user_id}}
|
try:
|
||||||
gql = {
|
author = session.query(Author).filter(Author.id == user_id).one()
|
||||||
"query": f"mutation {operation}($params: UpdateUserInput!) {{ {query_name}(params: $params) {{ id roles }} }}",
|
|
||||||
"variables": variables,
|
# Получаем существующие роли
|
||||||
"operationName": operation,
|
existing_roles = set(role.name for role in author.roles)
|
||||||
}
|
|
||||||
data = await request_graphql_data(gql, headers=headers)
|
# Добавляем новые роли
|
||||||
if data:
|
for role_name in roles:
|
||||||
user_id = data.get("data", {}).get(query_name, {}).get("id")
|
if role_name not in existing_roles:
|
||||||
return user_id
|
# Получаем или создаем роль
|
||||||
|
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):
|
def login_required(f):
|
||||||
"""
|
"""Декоратор для проверки авторизации пользователя."""
|
||||||
Декоратор для проверки авторизации пользователя.
|
|
||||||
|
|
||||||
Этот декоратор проверяет, авторизован ли пользователь, <EFBFBD><EFBFBD> добавляет
|
|
||||||
информацию о пользователе в контекст функции.
|
|
||||||
|
|
||||||
Параметры:
|
|
||||||
- f: Функция, которую нужно декорировать.
|
|
||||||
|
|
||||||
Возвращает:
|
|
||||||
- Обернутую функцию с добавленной проверкой авторизации.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
async def decorated_function(*args, **kwargs):
|
async def decorated_function(*args, **kwargs):
|
||||||
|
@ -135,18 +105,7 @@ def login_required(f):
|
||||||
|
|
||||||
|
|
||||||
def login_accepted(f):
|
def login_accepted(f):
|
||||||
"""
|
"""Декоратор для добавления данных авторизации в контекст."""
|
||||||
Декоратор для добавления данных авторизации в контекст.
|
|
||||||
|
|
||||||
Этот декоратор добавляет данные авторизации в контекст, если они доступны,
|
|
||||||
но не блокирует доступ для неавторизованных пользователей.
|
|
||||||
|
|
||||||
Параметры:
|
|
||||||
- f: Функция, которую нужно декорировать.
|
|
||||||
|
|
||||||
Возвращает:
|
|
||||||
- Обернутую функцию с добавленной проверкой авторизации.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
async def decorated_function(*args, **kwargs):
|
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)
|
author = await get_cached_author_by_user_id(user_id, get_with_stat)
|
||||||
if author:
|
if author:
|
||||||
logger.debug(f"login_accepted: Найден профиль автора: {author}")
|
logger.debug(f"login_accepted: Найден профиль автора: {author}")
|
||||||
# Предполагается, что `author` является объектом с атрибутом `id`
|
|
||||||
info.context["author"] = author.dict()
|
info.context["author"] = author.dict()
|
||||||
else:
|
else:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"login_accepted: Профиль автора не найден для пользователя {user_id}. Используем базовые данные."
|
f"login_accepted: Профиль автора не найден для пользователя {user_id}. Используем базовые данные."
|
||||||
) # Используем базовую информацию об автор
|
)
|
||||||
else:
|
else:
|
||||||
logger.debug("login_accepted: Пользователь не авторизован. Очищаем контекст.")
|
logger.debug("login_accepted: Пользователь не авторизован. Очищаем контекст.")
|
||||||
info.context["user_id"] = None
|
info.context["user_id"] = None
|
||||||
|
|
|
@ -50,10 +50,25 @@ FILTERED_FIELDS = ["_sa_instance_state", "search_vector"]
|
||||||
|
|
||||||
|
|
||||||
def create_table_if_not_exists(engine, table):
|
def create_table_if_not_exists(engine, table):
|
||||||
|
"""
|
||||||
|
Создает таблицу, если она не существует в базе данных.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
engine: SQLAlchemy движок базы данных
|
||||||
|
table: Класс модели SQLAlchemy
|
||||||
|
"""
|
||||||
inspector = inspect(engine)
|
inspector = inspect(engine)
|
||||||
if table and not inspector.has_table(table.__tablename__):
|
if table and not inspector.has_table(table.__tablename__):
|
||||||
table.__table__.create(engine)
|
try:
|
||||||
logger.info(f"Table '{table.__tablename__}' created.")
|
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:
|
else:
|
||||||
logger.info(f"Table '{table.__tablename__}' ok.")
|
logger.info(f"Table '{table.__tablename__}' ok.")
|
||||||
|
|
||||||
|
@ -154,21 +169,43 @@ class Base(declarative_base()):
|
||||||
REGISTRY[cls.__name__] = cls
|
REGISTRY[cls.__name__] = cls
|
||||||
|
|
||||||
def dict(self) -> Dict[str, Any]:
|
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())
|
column_names = filter(lambda x: x not in FILTERED_FIELDS, self.__table__.columns.keys())
|
||||||
data = {}
|
data = {}
|
||||||
try:
|
try:
|
||||||
for column_name in column_names:
|
for column_name in column_names:
|
||||||
value = getattr(self, column_name)
|
try:
|
||||||
# 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):
|
if hasattr(self, column_name):
|
||||||
try:
|
value = getattr(self, column_name)
|
||||||
data[column_name] = orjson.loads(value)
|
# Проверяем, является ли значение JSON и декодируем его при необходимости
|
||||||
except (TypeError, orjson.JSONDecodeError) as e:
|
if isinstance(value, (str, bytes)) and isinstance(
|
||||||
logger.error(f"Error decoding JSON for column '{column_name}': {e}")
|
self.__table__.columns[column_name].type, JSON
|
||||||
data[column_name] = value
|
):
|
||||||
else:
|
try:
|
||||||
data[column_name] = value
|
data[column_name] = orjson.loads(value)
|
||||||
# Add synthetic field .stat if it exists
|
except (TypeError, orjson.JSONDecodeError) as e:
|
||||||
|
logger.error(f"Error decoding JSON for column '{column_name}': {e}")
|
||||||
|
data[column_name] = value
|
||||||
|
else:
|
||||||
|
data[column_name] = value
|
||||||
|
else:
|
||||||
|
# Пропускаем атрибут, если его нет в объекте (может быть добавлен после миграции)
|
||||||
|
logger.debug(
|
||||||
|
f"Skipping missing attribute '{column_name}' for {self.__class__.__name__}"
|
||||||
|
)
|
||||||
|
except AttributeError as e:
|
||||||
|
logger.warning(f"Attribute error for column '{column_name}': {e}")
|
||||||
|
# Добавляем синтетическое поле .stat если оно существует
|
||||||
if hasattr(self, "stat"):
|
if hasattr(self, "stat"):
|
||||||
data["stat"] = self.stat
|
data["stat"] = self.stat
|
||||||
except Exception as e:
|
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 = traceback.format_stack()
|
||||||
tb_str = "".join(tb)
|
tb_str = "".join(tb)
|
||||||
return f"{message} ({filename}, {lineno}): {category.__name__}\n{tb_str}"
|
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
|
return response
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception(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:
|
if hasattr(draft_data, "topics") and draft_data.topics is not None:
|
||||||
draft_payload["topics"] = [
|
draft_payload["topics"] = [
|
||||||
{"id": t.id, "name": t.name, "slug": t.slug}
|
{"id": t.id, "name": t.name, "slug": t.slug} for t in draft_data.topics
|
||||||
for t in draft_data.topics
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if hasattr(draft_data, "authors") and draft_data.authors is not None:
|
if hasattr(draft_data, "authors") and draft_data.authors is not None:
|
||||||
|
|
|
@ -40,6 +40,17 @@ class RedisService:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(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):
|
async def subscribe(self, *channels):
|
||||||
if self._client:
|
if self._client:
|
||||||
async with self._client.pubsub() as pubsub:
|
async with self._client.pubsub() as pubsub:
|
||||||
|
@ -75,6 +86,82 @@ class RedisService:
|
||||||
async def get(self, key):
|
async def get(self, key):
|
||||||
return await self.execute("get", 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()
|
redis = RedisService()
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
from asyncio.log import logger
|
from asyncio.log import logger
|
||||||
|
|
||||||
import httpx
|
|
||||||
from ariadne import MutationType, ObjectType, QueryType
|
from ariadne import MutationType, ObjectType, QueryType
|
||||||
|
|
||||||
from services.db import create_table_if_not_exists, local_session
|
from services.db import create_table_if_not_exists, local_session
|
||||||
from settings import AUTH_URL
|
|
||||||
|
|
||||||
query = QueryType()
|
query = QueryType()
|
||||||
mutation = MutationType()
|
mutation = MutationType()
|
||||||
|
@ -12,50 +10,19 @@ type_draft = ObjectType("Draft")
|
||||||
resolvers = [query, mutation, type_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():
|
def create_all_tables():
|
||||||
"""Create all database tables in the correct order."""
|
"""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 = [
|
models_in_order = [
|
||||||
# user.User, # Базовая таблица auth
|
# user.User, # Базовая таблица auth
|
||||||
author.Author, # Базовая таблица
|
Author, # Базовая таблица
|
||||||
community.Community, # Базовая таблица
|
community.Community, # Базовая таблица
|
||||||
topic.Topic, # Базовая таблица
|
topic.Topic, # Базовая таблица
|
||||||
# Связи для базовых таблиц
|
# Связи для базовых таблиц
|
||||||
author.AuthorFollower, # Зависит от Author
|
AuthorFollower, # Зависит от Author
|
||||||
community.CommunityFollower, # Зависит от Community
|
community.CommunityFollower, # Зависит от Community
|
||||||
topic.TopicFollower, # Зависит от Topic
|
topic.TopicFollower, # Зависит от Topic
|
||||||
# Черновики (теперь без зависимости от Shout)
|
# Черновики (теперь без зависимости от Shout)
|
||||||
|
@ -70,7 +37,8 @@ def create_all_tables():
|
||||||
reaction.Reaction, # Зависит от Author и Shout
|
reaction.Reaction, # Зависит от Author и Shout
|
||||||
shout.ShoutReactionsFollower, # Зависит от Shout и Reaction
|
shout.ShoutReactionsFollower, # Зависит от Shout и Reaction
|
||||||
# Дополнительные таблицы
|
# Дополнительные таблицы
|
||||||
author.AuthorRating, # Зависит от Author
|
AuthorRating, # Зависит от Author
|
||||||
|
AuthorBookmark, # Зависит от Author
|
||||||
notification.Notification, # Зависит от Author
|
notification.Notification, # Зависит от Author
|
||||||
notification.NotificationSeen, # Зависит от Notification
|
notification.NotificationSeen, # Зависит от Notification
|
||||||
# collection.Collection,
|
# collection.Collection,
|
||||||
|
|
|
@ -171,11 +171,16 @@ class SearchService:
|
||||||
}
|
}
|
||||||
asyncio.create_task(self.perform_index(shout, index_body))
|
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):
|
async def perform_index(self, shout, index_body):
|
||||||
if self.client:
|
if self.client:
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(
|
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:
|
except asyncio.TimeoutError:
|
||||||
logger.error(f"Indexing timeout for shout {shout.id}")
|
logger.error(f"Indexing timeout for shout {shout.id}")
|
||||||
|
@ -188,7 +193,9 @@ class SearchService:
|
||||||
|
|
||||||
logger.info(f"Ищем: {text} {offset}+{limit}")
|
logger.info(f"Ищем: {text} {offset}+{limit}")
|
||||||
search_body = {
|
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:
|
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 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.shout import Shout, ShoutAuthor, ShoutTopic
|
||||||
from orm.topic import Topic
|
from orm.topic import Topic
|
||||||
from services.db import local_session
|
from services.db import local_session
|
||||||
|
@ -228,12 +228,20 @@ class ViewedStorage:
|
||||||
|
|
||||||
# Обновление тем и авторов с использованием вспомогательной функции
|
# Обновление тем и авторов с использованием вспомогательной функции
|
||||||
for [_st, topic] in (
|
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)
|
update_groups(self.shouts_by_topic, topic.slug, shout_slug)
|
||||||
|
|
||||||
for [_st, author] in (
|
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)
|
update_groups(self.shouts_by_author, author.slug, shout_slug)
|
||||||
|
|
||||||
|
@ -266,7 +274,9 @@ class ViewedStorage:
|
||||||
if failed == 0:
|
if failed == 0:
|
||||||
when = datetime.now(timezone.utc) + timedelta(seconds=self.period)
|
when = datetime.now(timezone.utc) + timedelta(seconds=self.period)
|
||||||
t = format(when.astimezone().isoformat())
|
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)
|
await asyncio.sleep(self.period)
|
||||||
else:
|
else:
|
||||||
await asyncio.sleep(10)
|
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
|
import sys
|
||||||
from os import environ
|
from os import environ
|
||||||
|
|
||||||
|
@ -17,13 +20,50 @@ REDIS_URL = environ.get("REDIS_URL") or "redis://127.0.0.1"
|
||||||
# debug
|
# debug
|
||||||
GLITCHTIP_DSN = environ.get("GLITCHTIP_DSN")
|
GLITCHTIP_DSN = environ.get("GLITCHTIP_DSN")
|
||||||
|
|
||||||
# authorizer.dev
|
# auth
|
||||||
AUTH_URL = environ.get("AUTH_URL") or "https://auth.discours.io/graphql"
|
|
||||||
ADMIN_SECRET = environ.get("AUTH_SECRET") or "nothing"
|
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
|
# own auth
|
||||||
ONETIME_TOKEN_LIFE_SPAN = 60 * 60 * 24 * 3 # 3 days
|
ONETIME_TOKEN_LIFE_SPAN = 60 * 15 # 15 минут
|
||||||
SESSION_TOKEN_LIFE_SPAN = 60 * 60 * 24 * 30 # 30 days
|
SESSION_TOKEN_LIFE_SPAN = 60 * 60 * 24 * 30 # 30 дней
|
||||||
|
SESSION_TOKEN_HEADER = "Authorization"
|
||||||
JWT_ALGORITHM = "HS256"
|
JWT_ALGORITHM = "HS256"
|
||||||
JWT_SECRET_KEY = environ.get("JWT_SECRET") or "nothing-else-jwt-secret-matters"
|
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,26 +2,48 @@
|
||||||
Модуль для обработки HTML-фрагментов
|
Модуль для обработки 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:
|
def wrap_html_fragment(fragment: str) -> str:
|
||||||
"""
|
"""
|
||||||
Оборачивает HTML-фрагмент в полную HTML-структуру для корректной обработки.
|
Оборачивает HTML-фрагмент в полную HTML-структуру для корректной обработки.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
fragment: HTML-фрагмент для обработки
|
fragment: HTML-фрагмент для обработки
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Полный HTML-документ
|
str: Полный HTML-документ
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> wrap_html_fragment("<p>Текст параграфа</p>")
|
>>> wrap_html_fragment("<p>Текст параграфа</p>")
|
||||||
'<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><p>Текст параграфа</p></body></html>'
|
'<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><p>Текст параграфа</p></body></html>'
|
||||||
"""
|
"""
|
||||||
if not fragment or not fragment.strip():
|
if not fragment or not fragment.strip():
|
||||||
return fragment
|
return fragment
|
||||||
|
|
||||||
# Проверяем, является ли контент полным HTML-документом
|
# Проверяем, является ли контент полным 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-документ
|
# Если это фрагмент, оборачиваем его в полный HTML-документ
|
||||||
if not is_full_html:
|
if not is_full_html:
|
||||||
return f"""<!DOCTYPE html>
|
return f"""<!DOCTYPE html>
|
||||||
|
@ -34,5 +56,5 @@ def wrap_html_fragment(fragment: str) -> str:
|
||||||
{fragment}
|
{fragment}
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
|
|
||||||
return fragment
|
return fragment
|
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