auth-wip
This commit is contained in:
parent
1d64811880
commit
d3a760b6ba
184
CHANGELOG.md
184
CHANGELOG.md
|
@ -1,150 +1,66 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [Unreleased]
|
#### [0.4.22] - 2025-05-21
|
||||||
|
|
||||||
### Изменено
|
|
||||||
- Радикально упрощена структура клиентской части приложения:
|
|
||||||
- Удалены все избыточные файлы и директории
|
|
||||||
- Перемещены модули 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) для пользователей
|
- Пагинация и поиск пользователей по email, имени и ID
|
||||||
- Реализовано управление ролями пользователей через модальное окно
|
- Расширение GraphQL схемы для админки:
|
||||||
- Добавлены GraphQL мутации для управления пользователями в schema/admin.graphql
|
- Типы AdminUserInfo, AdminUserUpdateInput, AuthResult, Permission, SessionInfo
|
||||||
- Улучшен интерфейс админ-панели с табами для навигации
|
- Мутации для управления пользователями и авторизации
|
||||||
- Расширена схема GraphQL для админки:
|
- Улучшения серверной части:
|
||||||
- Добавлены типы AdminUserInfo и AdminUserUpdateInput
|
- Поддержка HTTPS через Granian с помощью mkcert
|
||||||
- Добавлены мутации adminUpdateUser, adminToggleUserBlock, adminToggleUserMute
|
- Параметры запуска `--https`, `--workers`, `--domain`
|
||||||
- Добавлены запросы adminGetUsers и adminGetRoles
|
- Система авторизации и аутентификации:
|
||||||
- Пагинация списка пользователей в админ-панели
|
- Локальная система аутентификации с сессиями в Redis
|
||||||
- Серверная поддержка пагинации в API для админ-панели
|
- Система ролей и разрешений (RBAC)
|
||||||
- Поиск пользователей по email, имени и ID
|
- Защита от брутфорс атак
|
||||||
- Поддержка локального запуска сервера с HTTPS через `python run.py --https` с использованием Granian
|
- Поддержка httpOnly cookies для токенов
|
||||||
- Интеграция с инструментом mkcert для генерации доверенных локальных SSL-сертификатов
|
- Мультиязычные email уведомления
|
||||||
- Поддержка запуска нескольких рабочих процессов через параметр `--workers`
|
|
||||||
- Возможность указать произвольный домен для сертификата через `--domain`
|
|
||||||
|
|
||||||
### Улучшено
|
### Изменено
|
||||||
- Улучшен интерфейс админ-панели:
|
- Упрощена структура клиентской части приложения:
|
||||||
- Добавлены вкладки для переключения между разделами
|
- Минималистичная архитектура с основными компонентами (авторизация и админка)
|
||||||
- Оптимизирован компонент UsersList для работы с большим количеством пользователей
|
- Оптимизированы и унифицированы компоненты, следуя принципу DRY
|
||||||
- Добавлены индикаторы статуса для заблокированных и отключенных пользователей
|
- Реализована система маршрутизации с защищенными маршрутами
|
||||||
- Улучшена обработка ошибок при выполнении операций с пользователями
|
- Разделение ответственности между компонентами
|
||||||
- Добавлены подтверждения для критичных операций (блокировка, изменение ролей)
|
- Типизированные интерфейсы для всех модулей
|
||||||
|
- Отказ от жестких редиректов в пользу SolidJS Router
|
||||||
### Полностью переработан клиентский код:
|
- Переработан модуль авторизации:
|
||||||
- Создан компактный API клиент с изолированным кодом для доступа к API
|
- Унификация типов для работы с пользователями
|
||||||
- Реализована модульная архитектура с четким разделением ответственности
|
- Использование единого типа Author во всех запросах
|
||||||
- Добавлены типизированные интерфейсы для всех компонентов и модулей
|
- Расширенное логирование для отладки
|
||||||
- Реализована система маршрутизации с защищенными маршрутами
|
- Оптимизированное хранение и проверка токенов
|
||||||
- Добавлен компонент AuthProvider для управления авторизацией
|
- Унифицированная обработка сессий
|
||||||
- Оптимизирована загрузка компонентов с использованием ленивой загрузки
|
|
||||||
- Унифицирован стиль кода и именования
|
|
||||||
|
|
||||||
### Исправлено
|
### Исправлено
|
||||||
- Исправлена критическая проблема с JWT-токенами авторизации:
|
- Критические проблемы с JWT-токенами:
|
||||||
- Устранена ошибка декодирования токенов `int() argument must be a string, a bytes-like object or a real number, not 'NoneType'`
|
- Корректная генерация срока истечения токенов (exp)
|
||||||
- Обновлен механизм создания токенов для гарантированного задания срока истечения (exp)
|
- Стандартизованный формат параметров в JWT
|
||||||
- Улучшена обработка ошибок в модуле аутентификации для предотвращения создания невалидных токенов
|
- Проверка обязательных полей при декодировании
|
||||||
- Стандартизован формат параметра exp в JWT: теперь всегда используется timestamp вместо datetime
|
- Ошибки авторизации:
|
||||||
- Добавлена проверка наличия обязательных полей при декодировании токенов
|
- "Cannot return null for non-nullable field Mutation.login"
|
||||||
- Оптимизирована совместимость между разными способами хранения сессий
|
- "Author password is empty" при авторизации
|
||||||
- Исправлена проблема с перенаправлением в SolidJS, которое сбрасывало состояние приложения:
|
- "Author object has no attribute username"
|
||||||
- Обновлена функция logout для использования колбэка навигации вместо жесткого редиректа
|
- Обработка ошибок:
|
||||||
- Добавлен компонент LoginPage для авторизации без перезагрузки страницы
|
- Улучшена валидация email и username
|
||||||
- Реализована ленивая загрузка компонентов с использованием Suspense
|
- Исправлена обработка истекших токенов
|
||||||
- Улучшена структура роутинга в админ-панели
|
- Добавлены проверки на NULL объекты в декораторах
|
||||||
- Оптимизирован код согласно принципам DRY и KISS
|
- Вспомогательные компоненты:
|
||||||
|
- Исправлен метод dict() класса Author
|
||||||
|
- Добавлен AuthenticationMiddleware
|
||||||
|
- Реализован класс AuthenticatedUser
|
||||||
|
|
||||||
### Улучшения для авторизации в админ-панели
|
### Документировано
|
||||||
|
|
||||||
- Исправлена проблема с авторизацией в админ-панели
|
|
||||||
- Добавлена поддержка 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`
|
- Подробная документация по системе авторизации в `docs/auth.md`
|
||||||
- Описание OAuth интеграции
|
- Описание OAuth интеграции
|
||||||
- Руководство по RBAC
|
- Руководство по RBAC
|
||||||
- Примеры использования на фронтенде
|
- Примеры использования на фронтенде
|
||||||
- Инструкции по безопасности
|
- Инструкции по безопасности
|
||||||
- Документация по тестированию
|
|
||||||
- Страница входа для неавторизованных пользователей в админке
|
|
||||||
- Публичное GraphQL API для модуля аутентификации:
|
|
||||||
- Типы: `AuthResult`, `Permission`, `SessionInfo`, `OAuthProvider`
|
|
||||||
- Мутации: `login`, `registerUser`, `sendLink`, `confirmEmail`, `getSession`, `changePassword`, `refreshToken`
|
|
||||||
- Запросы: `logout`, `me`, `isEmailUsed`, `getOAuthProviders`
|
|
||||||
|
|
||||||
### Changed
|
#### [0.4.21] - 2025-05-10
|
||||||
- Переработана структура модуля 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
|
- Переработана пагинация в админ-панели: переход с модели page/perPage на limit/offset
|
||||||
|
@ -155,7 +71,7 @@
|
||||||
- Исправлена ошибка GraphQL "Unknown argument 'page' on field 'Query.adminGetUsers'"
|
- Исправлена ошибка GraphQL "Unknown argument 'page' on field 'Query.adminGetUsers'"
|
||||||
- Согласованы параметры пагинации между клиентом и сервером
|
- Согласованы параметры пагинации между клиентом и сервером
|
||||||
|
|
||||||
#### [0.4.20] - 2023-09-01
|
#### [0.4.20] - 2025-05-01
|
||||||
|
|
||||||
### Добавлено
|
### Добавлено
|
||||||
- Пагинация списка пользователей в админ-панели
|
- Пагинация списка пользователей в админ-панели
|
||||||
|
|
|
@ -91,24 +91,8 @@ class Identity:
|
||||||
)
|
)
|
||||||
raise InvalidPassword("Пароль не установлен для данного пользователя")
|
raise InvalidPassword("Пароль не установлен для данного пользователя")
|
||||||
|
|
||||||
# Проверим словарь до создания нового объекта
|
# Проверяем пароль напрямую, не используя dict()
|
||||||
author_dict = orm_author.dict()
|
if not Password.verify(password, orm_author.password):
|
||||||
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}")
|
logger.warning(f"[auth.identity] Неверный пароль для {orm_author.email}")
|
||||||
raise InvalidPassword("Неверный пароль пользователя")
|
raise InvalidPassword("Неверный пароль пользователя")
|
||||||
|
|
||||||
|
|
|
@ -151,16 +151,16 @@ class InternalAuthentication(AuthenticationBackend):
|
||||||
return AuthCredentials(scopes={}, error_message="User not found"), UnauthenticatedUser()
|
return AuthCredentials(scopes={}, error_message="User not found"), UnauthenticatedUser()
|
||||||
|
|
||||||
|
|
||||||
async def verify_internal_auth(token: str) -> Tuple[str, list]:
|
async def verify_internal_auth(token: str) -> Tuple[str, list, bool]:
|
||||||
"""
|
"""
|
||||||
Проверяет локальную авторизацию.
|
Проверяет локальную авторизацию.
|
||||||
Возвращает user_id и список ролей.
|
Возвращает user_id, список ролей и флаг администратора.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
token: Токен авторизации (может быть как с Bearer, так и без)
|
token: Токен авторизации (может быть как с Bearer, так и без)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple: (user_id, roles)
|
tuple: (user_id, roles, is_admin)
|
||||||
"""
|
"""
|
||||||
# Обработка формата "Bearer <token>" (если токен не был обработан ранее)
|
# Обработка формата "Bearer <token>" (если токен не был обработан ранее)
|
||||||
if token.startswith("Bearer "):
|
if token.startswith("Bearer "):
|
||||||
|
@ -169,7 +169,7 @@ async def verify_internal_auth(token: str) -> Tuple[str, list]:
|
||||||
# Проверяем сессию
|
# Проверяем сессию
|
||||||
payload = await SessionManager.verify_session(token)
|
payload = await SessionManager.verify_session(token)
|
||||||
if not payload:
|
if not payload:
|
||||||
return "", []
|
return "", [], False
|
||||||
|
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
try:
|
try:
|
||||||
|
@ -183,9 +183,12 @@ async def verify_internal_auth(token: str) -> Tuple[str, list]:
|
||||||
# Получаем роли
|
# Получаем роли
|
||||||
roles = [role.id for role in author.roles]
|
roles = [role.id for role in author.roles]
|
||||||
|
|
||||||
return str(author.id), roles
|
# Определяем, является ли пользователь администратором
|
||||||
|
is_admin = any(role in ['admin', 'super'] for role in roles) or author.email in ADMIN_EMAILS
|
||||||
|
|
||||||
|
return str(author.id), roles, is_admin
|
||||||
except exc.NoResultFound:
|
except exc.NoResultFound:
|
||||||
return "", []
|
return "", [], False
|
||||||
|
|
||||||
|
|
||||||
async def create_internal_session(author: Author, device_info: Optional[dict] = None) -> str:
|
async def create_internal_session(author: Author, device_info: Optional[dict] = None) -> str:
|
||||||
|
@ -202,8 +205,8 @@ async def create_internal_session(author: Author, device_info: Optional[dict] =
|
||||||
# Сбрасываем счетчик неудачных попыток
|
# Сбрасываем счетчик неудачных попыток
|
||||||
author.reset_failed_login()
|
author.reset_failed_login()
|
||||||
|
|
||||||
# Обновляем last_login
|
# Обновляем last_seen
|
||||||
author.last_login = int(time.time())
|
author.last_seen = int(time.time())
|
||||||
|
|
||||||
# Создаем сессию, используя token для идентификации
|
# Создаем сессию, используя token для идентификации
|
||||||
return await SessionManager.create_session(
|
return await SessionManager.create_session(
|
||||||
|
|
48
auth/orm.py
48
auth/orm.py
|
@ -5,6 +5,7 @@ from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from auth.identity import Password
|
from auth.identity import Password
|
||||||
from services.db import Base
|
from services.db import Base
|
||||||
|
from settings import ADMIN_EMAILS
|
||||||
|
|
||||||
# from sqlalchemy_utils import TSVectorType
|
# from sqlalchemy_utils import TSVectorType
|
||||||
|
|
||||||
|
@ -165,7 +166,6 @@ class Author(Base):
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
email_verified = Column(Boolean, default=False)
|
email_verified = Column(Boolean, default=False)
|
||||||
phone_verified = Column(Boolean, default=False)
|
phone_verified = Column(Boolean, default=False)
|
||||||
last_login = Column(Integer, nullable=True)
|
|
||||||
failed_login_attempts = Column(Integer, default=0)
|
failed_login_attempts = Column(Integer, default=0)
|
||||||
account_locked_until = Column(Integer, nullable=True)
|
account_locked_until = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
@ -182,6 +182,9 @@ class Author(Base):
|
||||||
# TSVectorType("name", "slug", "bio", "about", regconfig="pg_catalog.russian")
|
# TSVectorType("name", "slug", "bio", "about", regconfig="pg_catalog.russian")
|
||||||
# )
|
# )
|
||||||
|
|
||||||
|
# Список защищенных полей, которые видны только владельцу и администраторам
|
||||||
|
_protected_fields = ['email', 'password', 'provider_access_token', 'provider_refresh_token']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_authenticated(self) -> bool:
|
def is_authenticated(self) -> bool:
|
||||||
"""Проверяет, аутентифицирован ли пользователь"""
|
"""Проверяет, аутентифицирован ли пользователь"""
|
||||||
|
@ -238,22 +241,27 @@ class Author(Base):
|
||||||
"""
|
"""
|
||||||
return self.slug or self.email or self.phone or ""
|
return self.slug or self.email or self.phone or ""
|
||||||
|
|
||||||
def dict(self) -> Dict:
|
def dict(self, access=False) -> Dict:
|
||||||
"""Преобразует объект Author в словарь"""
|
"""
|
||||||
return {
|
Сериализует объект Author в словарь с учетом прав доступа.
|
||||||
"id": self.id,
|
|
||||||
"slug": self.slug,
|
Args:
|
||||||
"name": self.name,
|
access (bool, optional): Флаг, указывающий, доступны ли защищенные поля
|
||||||
"bio": self.bio,
|
|
||||||
"about": self.about,
|
Returns:
|
||||||
"pic": self.pic,
|
dict: Словарь с атрибутами Author, отфильтрованный по правам доступа
|
||||||
"links": self.links,
|
"""
|
||||||
"email": self.email,
|
# Получаем все атрибуты объекта
|
||||||
"password": self.password,
|
result = {c.name: getattr(self, c.name) for c in self.__table__.columns}
|
||||||
"created_at": self.created_at,
|
|
||||||
"updated_at": self.updated_at,
|
# Добавляем роли, если они есть
|
||||||
"last_seen": self.last_seen,
|
if hasattr(self, 'roles') and self.roles:
|
||||||
"deleted_at": self.deleted_at,
|
result['roles'] = [role.id for role in self.roles]
|
||||||
"roles": [role.id for role in self.roles],
|
|
||||||
"email_verified": self.email_verified,
|
# скрываем защищенные поля
|
||||||
}
|
if not access:
|
||||||
|
for field in self._protected_fields:
|
||||||
|
if field in result:
|
||||||
|
result[field] = None
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
2
cache/cache.py
vendored
2
cache/cache.py
vendored
|
@ -320,7 +320,7 @@ async def get_cached_author_by_user_id(user_id: str, get_with_stat):
|
||||||
return orjson.loads(author_data)
|
return orjson.loads(author_data)
|
||||||
|
|
||||||
# If data is not found in cache, query the database
|
# If data is not found in cache, query the database
|
||||||
author_query = select(Author).where(Author.user == user_id)
|
author_query = select(Author).where(Author.id == user_id)
|
||||||
authors = get_with_stat(author_query)
|
authors = get_with_stat(author_query)
|
||||||
if authors:
|
if authors:
|
||||||
# Cache the retrieved author data
|
# Cache the retrieved author data
|
||||||
|
|
10
main.py
10
main.py
|
@ -151,7 +151,14 @@ middleware = [
|
||||||
# CORS должен быть перед другими middleware для корректной обработки preflight-запросов
|
# CORS должен быть перед другими middleware для корректной обработки preflight-запросов
|
||||||
Middleware(
|
Middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=[
|
||||||
|
"https://localhost:3000",
|
||||||
|
"https://testing.discours.io",
|
||||||
|
"https://discours.io",
|
||||||
|
"https://new.discours.io",
|
||||||
|
"https://discours.ru",
|
||||||
|
"https://new.discours.ru"
|
||||||
|
],
|
||||||
allow_methods=["GET", "POST", "OPTIONS"], # Явно указываем OPTIONS
|
allow_methods=["GET", "POST", "OPTIONS"], # Явно указываем OPTIONS
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
|
@ -183,6 +190,7 @@ async def graphql_handler(request: Request):
|
||||||
response.headers["Access-Control-Allow-Origin"] = "*"
|
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
response.headers["Access-Control-Allow-Methods"] = "POST, GET, OPTIONS"
|
response.headers["Access-Control-Allow-Methods"] = "POST, GET, OPTIONS"
|
||||||
response.headers["Access-Control-Allow-Headers"] = "*"
|
response.headers["Access-Control-Allow-Headers"] = "*"
|
||||||
|
response.headers["Access-Control-Allow-Credentials"] = "true"
|
||||||
response.headers["Access-Control-Max-Age"] = "86400" # 24 hours
|
response.headers["Access-Control-Max-Age"] = "86400" # 24 hours
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
10
package.json
10
package.json
|
@ -1,18 +1,14 @@
|
||||||
{
|
{
|
||||||
"name": "publy-admin",
|
"name": "admin-panel",
|
||||||
"version": "0.4.20",
|
"version": "0.4.22",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "admin panel",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"lint": "biome check . --fix",
|
"lint": "biome check . --fix",
|
||||||
"format": "biome format . --write",
|
"format": "biome format . --write",
|
||||||
"type-check": "tsc --noEmit",
|
"typecheck": "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"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "^1.9.4",
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Component, Show, Suspense, createSignal, lazy, onMount } from 'solid-js'
|
import { Component, Show, Suspense, createSignal, lazy, onMount, createEffect } from 'solid-js'
|
||||||
import { isAuthenticated } from './auth'
|
import { isAuthenticated, getAuthTokenFromCookie } from './auth'
|
||||||
|
|
||||||
// Ленивая загрузка компонентов
|
// Ленивая загрузка компонентов
|
||||||
const AdminPage = lazy(() => import('./admin'))
|
const AdminPage = lazy(() => import('./admin'))
|
||||||
|
@ -11,14 +11,58 @@ const LoginPage = lazy(() => import('./login'))
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
const [authenticated, setAuthenticated] = createSignal<boolean | null>(null)
|
const [authenticated, setAuthenticated] = createSignal<boolean | null>(null)
|
||||||
const [loading, setLoading] = createSignal(true)
|
const [loading, setLoading] = createSignal(true)
|
||||||
|
const [checkingAuth, setCheckingAuth] = createSignal(true)
|
||||||
|
|
||||||
// Проверяем авторизацию при монтировании
|
// Проверяем авторизацию при монтировании
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const authed = isAuthenticated()
|
checkAuthentication()
|
||||||
setAuthenticated(authed)
|
|
||||||
setLoading(false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Периодическая проверка авторизации
|
||||||
|
createEffect(() => {
|
||||||
|
const authCheckInterval = setInterval(() => {
|
||||||
|
// Перепроверяем статус авторизации каждые 60 секунд
|
||||||
|
if (!checkingAuth()) {
|
||||||
|
const authed = isAuthenticated()
|
||||||
|
if (!authed && authenticated()) {
|
||||||
|
console.log('Сессия истекла, требуется повторная авторизация')
|
||||||
|
setAuthenticated(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 60000)
|
||||||
|
|
||||||
|
return () => clearInterval(authCheckInterval)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Функция проверки авторизации
|
||||||
|
const checkAuthentication = async () => {
|
||||||
|
setCheckingAuth(true)
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Проверяем состояние авторизации
|
||||||
|
const authed = isAuthenticated()
|
||||||
|
|
||||||
|
// Если токен есть, но он невалидный, авторизация не удалась
|
||||||
|
if (authed) {
|
||||||
|
const token = getAuthTokenFromCookie() || localStorage.getItem('auth_token')
|
||||||
|
if (!token || token.length < 10) {
|
||||||
|
setAuthenticated(false)
|
||||||
|
} else {
|
||||||
|
setAuthenticated(true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setAuthenticated(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при проверке авторизации:', error)
|
||||||
|
setAuthenticated(false)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setCheckingAuth(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Обработчик успешной авторизации
|
// Обработчик успешной авторизации
|
||||||
const handleLoginSuccess = () => {
|
const handleLoginSuccess = () => {
|
||||||
setAuthenticated(true)
|
setAuthenticated(true)
|
||||||
|
@ -35,7 +79,7 @@ const App: Component = () => {
|
||||||
fallback={
|
fallback={
|
||||||
<div class="loading-screen">
|
<div class="loading-screen">
|
||||||
<div class="loading-spinner" />
|
<div class="loading-spinner" />
|
||||||
<h2>Загрузка...</h2>
|
<h2>Загрузка компонентов...</h2>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
@ -44,12 +88,12 @@ const App: Component = () => {
|
||||||
fallback={
|
fallback={
|
||||||
<div class="loading-screen">
|
<div class="loading-screen">
|
||||||
<div class="loading-spinner" />
|
<div class="loading-spinner" />
|
||||||
<h2>Загрузка...</h2>
|
<h2>Проверка авторизации...</h2>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{authenticated() ? (
|
{authenticated() ? (
|
||||||
<AdminPage onLogout={handleLogout} />
|
<AdminPage apiUrl={`${location.origin}/graphql`} onLogout={handleLogout} />
|
||||||
) : (
|
) : (
|
||||||
<LoginPage onLoginSuccess={handleLoginSuccess} />
|
<LoginPage onLoginSuccess={handleLoginSuccess} />
|
||||||
)}
|
)}
|
||||||
|
|
759
panel/admin.tsx
759
panel/admin.tsx
|
@ -18,15 +18,13 @@ interface User {
|
||||||
roles: string[]
|
roles: string[]
|
||||||
created_at?: number
|
created_at?: number
|
||||||
last_seen?: number
|
last_seen?: number
|
||||||
muted: boolean
|
|
||||||
is_active: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Интерфейс для роли пользователя
|
* Интерфейс для роли пользователя
|
||||||
*/
|
*/
|
||||||
interface Role {
|
interface Role {
|
||||||
id: number
|
id: string // ID роли - строка, не число
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
|
@ -52,27 +50,37 @@ interface AdminGetRolesResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Интерфейс для ответа изменения статуса пользователя
|
* Интерфейс для ответа обновления пользователя
|
||||||
*/
|
*/
|
||||||
interface AdminSetUserStatusResponse {
|
interface AdminUpdateUserResponse {
|
||||||
adminSetUserStatus: {
|
adminUpdateUser: boolean
|
||||||
success: boolean
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Интерфейс для ответа изменения статуса блокировки чата
|
* Интерфейс для переменной окружения
|
||||||
*/
|
*/
|
||||||
interface AdminMuteUserResponse {
|
interface EnvVariable {
|
||||||
adminMuteUser: {
|
key: string
|
||||||
success: boolean
|
value: string
|
||||||
error?: string
|
description?: string
|
||||||
}
|
type: string
|
||||||
|
isSecret: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Интерфейс для пропсов AdminPage
|
/**
|
||||||
|
* Интерфейс для секции переменных окружения
|
||||||
|
*/
|
||||||
|
interface EnvSection {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
variables: EnvVariable[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интерфейс свойств компонента AdminPage
|
||||||
|
*/
|
||||||
interface AdminPageProps {
|
interface AdminPageProps {
|
||||||
|
apiUrl: string
|
||||||
onLogout?: () => void
|
onLogout?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -89,6 +97,12 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
||||||
const [showRolesModal, setShowRolesModal] = createSignal(false)
|
const [showRolesModal, setShowRolesModal] = createSignal(false)
|
||||||
const [successMessage, setSuccessMessage] = createSignal<string | null>(null)
|
const [successMessage, setSuccessMessage] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
// Переменные среды
|
||||||
|
const [envSections, setEnvSections] = createSignal<EnvSection[]>([])
|
||||||
|
const [envLoading, setEnvLoading] = createSignal(false)
|
||||||
|
const [editingVariable, setEditingVariable] = createSignal<EnvVariable | null>(null)
|
||||||
|
const [showVariableModal, setShowVariableModal] = createSignal(false)
|
||||||
|
|
||||||
// Параметры пагинации
|
// Параметры пагинации
|
||||||
const [pagination, setPagination] = createSignal<{
|
const [pagination, setPagination] = createSignal<{
|
||||||
page: number
|
page: number
|
||||||
|
@ -137,8 +151,6 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
||||||
roles
|
roles
|
||||||
created_at
|
created_at
|
||||||
last_seen
|
last_seen
|
||||||
muted
|
|
||||||
is_active
|
|
||||||
}
|
}
|
||||||
total
|
total
|
||||||
page
|
page
|
||||||
|
@ -260,104 +272,6 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Блокирует/разблокирует пользователя
|
|
||||||
* @param userId - ID пользователя
|
|
||||||
* @param isActive - Текущий статус активности
|
|
||||||
*/
|
|
||||||
async function toggleUserBlock(userId: number, isActive: boolean) {
|
|
||||||
try {
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
// Устанавливаем новый статус (противоположный текущему)
|
|
||||||
const newStatus = !isActive
|
|
||||||
|
|
||||||
// Выполняем мутацию
|
|
||||||
const result = await query<AdminSetUserStatusResponse>(
|
|
||||||
`${location.origin}/graphql`,
|
|
||||||
`
|
|
||||||
mutation AdminSetUserStatus($userId: Int!, $isActive: Boolean!) {
|
|
||||||
adminSetUserStatus(userId: $userId, isActive: $isActive) {
|
|
||||||
success
|
|
||||||
error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
{ userId, isActive: newStatus }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Проверяем результат
|
|
||||||
if (result?.adminSetUserStatus?.success) {
|
|
||||||
// Обновляем список пользователей
|
|
||||||
setSuccessMessage(`Пользователь ${newStatus ? 'разблокирован' : 'заблокирован'}`)
|
|
||||||
|
|
||||||
// Обновляем пользователя в текущем списке
|
|
||||||
setUsers(
|
|
||||||
users().map((user) =>
|
|
||||||
user.id === userId ? { ...user, is_active: newStatus } : user
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Скрываем сообщение через 3 секунды
|
|
||||||
setTimeout(() => setSuccessMessage(null), 3000)
|
|
||||||
} else {
|
|
||||||
setError(result?.adminSetUserStatus?.error || 'Ошибка обновления статуса пользователя')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Ошибка при изменении статуса пользователя:', err)
|
|
||||||
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Включает/отключает режим блокировки чата для пользователя
|
|
||||||
* @param userId - ID пользователя
|
|
||||||
* @param isMuted - Текущий статус блокировки чата
|
|
||||||
*/
|
|
||||||
async function toggleUserMute(userId: number, isMuted: boolean) {
|
|
||||||
try {
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
// Устанавливаем новый статус (противоположный текущему)
|
|
||||||
const newMuteStatus = !isMuted
|
|
||||||
|
|
||||||
// Выполняем мутацию
|
|
||||||
const result = await query<AdminMuteUserResponse>(
|
|
||||||
`${location.origin}/graphql`,
|
|
||||||
`
|
|
||||||
mutation AdminMuteUser($userId: Int!, $muted: Boolean!) {
|
|
||||||
adminMuteUser(userId: $userId, muted: $muted) {
|
|
||||||
success
|
|
||||||
error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
{ userId, muted: newMuteStatus }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Проверяем результат
|
|
||||||
if (result?.adminMuteUser?.success) {
|
|
||||||
// Обновляем сообщение об успехе
|
|
||||||
setSuccessMessage(`${newMuteStatus ? 'Блокировка' : 'Разблокировка'} чата выполнена`)
|
|
||||||
|
|
||||||
// Обновляем пользователя в текущем списке
|
|
||||||
setUsers(
|
|
||||||
users().map((user) =>
|
|
||||||
user.id === userId ? { ...user, muted: newMuteStatus } : user
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Скрываем сообщение через 3 секунды
|
|
||||||
setTimeout(() => setSuccessMessage(null), 3000)
|
|
||||||
} else {
|
|
||||||
setError(result?.adminMuteUser?.error || 'Ошибка обновления статуса блокировки чата')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Ошибка при изменении статуса блокировки чата:', err)
|
|
||||||
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Закрывает модальное окно ролей
|
* Закрывает модальное окно ролей
|
||||||
*/
|
*/
|
||||||
|
@ -373,19 +287,18 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
||||||
*/
|
*/
|
||||||
async function updateUserRoles(userId: number, newRoles: string[]) {
|
async function updateUserRoles(userId: number, newRoles: string[]) {
|
||||||
try {
|
try {
|
||||||
await query(
|
await query<AdminUpdateUserResponse>(
|
||||||
`${location.origin}/graphql`,
|
`${location.origin}/graphql`,
|
||||||
`
|
`
|
||||||
mutation AdminUpdateUser($userId: Int!, $input: AdminUserUpdateInput!) {
|
mutation AdminUpdateUser($user: AdminUserUpdateInput!) {
|
||||||
adminUpdateUser(userId: $userId, input: $input) {
|
adminUpdateUser(user: $user)
|
||||||
success
|
|
||||||
error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
{
|
{
|
||||||
userId,
|
user: {
|
||||||
input: { roles: newRoles }
|
id: userId,
|
||||||
|
roles: newRoles
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -414,20 +327,171 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Выход из системы
|
* Обрабатывает выход из системы
|
||||||
*/
|
*/
|
||||||
function handleLogout() {
|
const handleLogout = async () => {
|
||||||
// Сначала выполняем локальные действия по очистке данных
|
try {
|
||||||
setUsers([])
|
await logout()
|
||||||
setRoles([])
|
|
||||||
|
|
||||||
// Затем выполняем выход
|
|
||||||
logout(() => {
|
|
||||||
// Вызываем коллбэк для оповещения родителя о выходе
|
|
||||||
if (props.onLogout) {
|
if (props.onLogout) {
|
||||||
props.onLogout()
|
props.onLogout()
|
||||||
}
|
}
|
||||||
})
|
} catch (error) {
|
||||||
|
setError('Ошибка при выходе: ' + (error as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирование даты в формате "X дней назад"
|
||||||
|
* @param timestamp - Временная метка
|
||||||
|
* @returns Форматированная строка с относительной датой
|
||||||
|
*/
|
||||||
|
function formatDateRelative(timestamp?: number): string {
|
||||||
|
if (!timestamp) return 'Н/Д'
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const diff = now - timestamp
|
||||||
|
|
||||||
|
// Меньше минуты
|
||||||
|
if (diff < 60) {
|
||||||
|
return 'только что'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Меньше часа
|
||||||
|
if (diff < 3600) {
|
||||||
|
const minutes = Math.floor(diff / 60)
|
||||||
|
return `${minutes} ${getMinutesForm(minutes)} назад`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Меньше суток
|
||||||
|
if (diff < 86400) {
|
||||||
|
const hours = Math.floor(diff / 3600)
|
||||||
|
return `${hours} ${getHoursForm(hours)} назад`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Меньше 30 дней
|
||||||
|
if (diff < 2592000) {
|
||||||
|
const days = Math.floor(diff / 86400)
|
||||||
|
return `${days} ${getDaysForm(days)} назад`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Меньше года
|
||||||
|
if (diff < 31536000) {
|
||||||
|
const months = Math.floor(diff / 2592000)
|
||||||
|
return `${months} ${getMonthsForm(months)} назад`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Больше года
|
||||||
|
const years = Math.floor(diff / 31536000)
|
||||||
|
return `${years} ${getYearsForm(years)} назад`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение правильной формы слова "минута" в зависимости от числа
|
||||||
|
* @param minutes - Количество минут
|
||||||
|
*/
|
||||||
|
function getMinutesForm(minutes: number): string {
|
||||||
|
if (minutes % 10 === 1 && minutes % 100 !== 11) {
|
||||||
|
return 'минуту'
|
||||||
|
} else if ([2, 3, 4].includes(minutes % 10) && ![12, 13, 14].includes(minutes % 100)) {
|
||||||
|
return 'минуты'
|
||||||
|
}
|
||||||
|
return 'минут'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение правильной формы слова "час" в зависимости от числа
|
||||||
|
* @param hours - Количество часов
|
||||||
|
*/
|
||||||
|
function getHoursForm(hours: number): string {
|
||||||
|
if (hours % 10 === 1 && hours % 100 !== 11) {
|
||||||
|
return 'час'
|
||||||
|
} else if ([2, 3, 4].includes(hours % 10) && ![12, 13, 14].includes(hours % 100)) {
|
||||||
|
return 'часа'
|
||||||
|
}
|
||||||
|
return 'часов'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение правильной формы слова "день" в зависимости от числа
|
||||||
|
* @param days - Количество дней
|
||||||
|
*/
|
||||||
|
function getDaysForm(days: number): string {
|
||||||
|
if (days % 10 === 1 && days % 100 !== 11) {
|
||||||
|
return 'день'
|
||||||
|
} else if ([2, 3, 4].includes(days % 10) && ![12, 13, 14].includes(days % 100)) {
|
||||||
|
return 'дня'
|
||||||
|
}
|
||||||
|
return 'дней'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение правильной формы слова "месяц" в зависимости от числа
|
||||||
|
* @param months - Количество месяцев
|
||||||
|
*/
|
||||||
|
function getMonthsForm(months: number): string {
|
||||||
|
if (months % 10 === 1 && months % 100 !== 11) {
|
||||||
|
return 'месяц'
|
||||||
|
} else if ([2, 3, 4].includes(months % 10) && ![12, 13, 14].includes(months % 100)) {
|
||||||
|
return 'месяца'
|
||||||
|
}
|
||||||
|
return 'месяцев'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение правильной формы слова "год" в зависимости от числа
|
||||||
|
* @param years - Количество лет
|
||||||
|
*/
|
||||||
|
function getYearsForm(years: number): string {
|
||||||
|
if (years % 10 === 1 && years % 100 !== 11) {
|
||||||
|
return 'год'
|
||||||
|
} else if ([2, 3, 4].includes(years % 10) && ![12, 13, 14].includes(years % 100)) {
|
||||||
|
return 'года'
|
||||||
|
}
|
||||||
|
return 'лет'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает иконку для роли пользователя
|
||||||
|
* @param role - Название роли
|
||||||
|
* @returns Иконка для роли
|
||||||
|
*/
|
||||||
|
function getRoleIcon(role: string): string {
|
||||||
|
switch (role.toLowerCase()) {
|
||||||
|
case 'admin':
|
||||||
|
return '👑' // корона для администратора
|
||||||
|
case 'moderator':
|
||||||
|
return '🛡️' // щит для модератора
|
||||||
|
case 'editor':
|
||||||
|
return '✏️' // карандаш для редактора
|
||||||
|
case 'author':
|
||||||
|
return '📝' // блокнот для автора
|
||||||
|
case 'user':
|
||||||
|
return '👤' // фигура для обычного пользователя
|
||||||
|
case 'subscriber':
|
||||||
|
return '📬' // почтовый ящик для подписчика
|
||||||
|
case 'guest':
|
||||||
|
return '👋' // рука для гостя
|
||||||
|
case 'banned':
|
||||||
|
return '🚫' // знак запрета для заблокированного
|
||||||
|
case 'vip':
|
||||||
|
return '⭐' // звезда для VIP
|
||||||
|
case 'verified':
|
||||||
|
return '✓' // галочка для верифицированного
|
||||||
|
default:
|
||||||
|
return '🔹' // точка для прочих ролей
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Компонент для отображения роли с иконкой
|
||||||
|
*/
|
||||||
|
const RoleBadge: Component<{ role: string }> = (props) => {
|
||||||
|
return (
|
||||||
|
<span class="role-badge" title={props.role}>
|
||||||
|
<span class="role-icon">{getRoleIcon(props.role)}</span>
|
||||||
|
<span class="role-name">{props.role}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -537,6 +601,25 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
||||||
const user = selectedUser()
|
const user = selectedUser()
|
||||||
const [selectedRoles, setSelectedRoles] = createSignal<string[]>(user ? [...user.roles] : [])
|
const [selectedRoles, setSelectedRoles] = createSignal<string[]>(user ? [...user.roles] : [])
|
||||||
|
|
||||||
|
// Получаем дополнительные описания ролей
|
||||||
|
const getRoleDescription = (roleId: string): string => {
|
||||||
|
// Если есть описание в списке ролей, используем его
|
||||||
|
const roleFromList = roles().find(r => r.id === roleId);
|
||||||
|
if (roleFromList?.description) {
|
||||||
|
return roleFromList.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Иначе возвращаем стандартное описание
|
||||||
|
switch(roleId) {
|
||||||
|
case 'reader':
|
||||||
|
return 'Базовая роль. Позволяет авторизоваться и оставлять реакции.';
|
||||||
|
case 'author':
|
||||||
|
return 'Расширенная роль. Позволяет создавать контент и голосовать за публикации для вывода на главную страницу.';
|
||||||
|
default:
|
||||||
|
return 'Нет описания';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toggleRole = (role: string) => {
|
const toggleRole = (role: string) => {
|
||||||
const current = selectedRoles()
|
const current = selectedRoles()
|
||||||
if (current.includes(role)) {
|
if (current.includes(role)) {
|
||||||
|
@ -560,6 +643,11 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
||||||
<h2>Управление ролями пользователя</h2>
|
<h2>Управление ролями пользователя</h2>
|
||||||
<p>Пользователь: {user.email}</p>
|
<p>Пользователь: {user.email}</p>
|
||||||
|
|
||||||
|
<div class="role-info">
|
||||||
|
<p><strong>Внимание:</strong> Снятие роли "reader" блокирует доступ пользователя к системе.</p>
|
||||||
|
<p>Роль "author" дает возможность голосовать за публикации для размещения на главной странице.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="roles-list">
|
<div class="roles-list">
|
||||||
<For each={roles()}>
|
<For each={roles()}>
|
||||||
{(role) => (
|
{(role) => (
|
||||||
|
@ -567,14 +655,12 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedRoles().includes(role.name)}
|
checked={selectedRoles().includes(role.id)}
|
||||||
onChange={() => toggleRole(role.name)}
|
onChange={() => toggleRole(role.id)}
|
||||||
/>
|
/>
|
||||||
{role.name}
|
{role.id}
|
||||||
</label>
|
</label>
|
||||||
<Show when={role.description}>
|
<p class="role-description">{getRoleDescription(role.id)}</p>
|
||||||
<p class="role-description">{role.description}</p>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
@ -593,6 +679,250 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает переменные окружения
|
||||||
|
*/
|
||||||
|
const loadEnvVariables = async () => {
|
||||||
|
try {
|
||||||
|
setEnvLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const result = await query(props.apiUrl, `
|
||||||
|
query GetEnvVariables {
|
||||||
|
getEnvVariables {
|
||||||
|
name
|
||||||
|
description
|
||||||
|
variables {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
description
|
||||||
|
type
|
||||||
|
isSecret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
if (result.getEnvVariables) {
|
||||||
|
setEnvSections(result.getEnvVariables as EnvSection[])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка загрузки переменных окружения:', err)
|
||||||
|
setError('Не удалось загрузить переменные окружения: ' + (err as Error).message)
|
||||||
|
|
||||||
|
// Если ошибка авторизации - перенаправляем на логин
|
||||||
|
if (
|
||||||
|
err instanceof Error &&
|
||||||
|
(err.message.includes('401') ||
|
||||||
|
err.message.includes('авторизации') ||
|
||||||
|
err.message.includes('unauthorized') ||
|
||||||
|
err.message.includes('Unauthorized'))
|
||||||
|
) {
|
||||||
|
handleLogout()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setEnvLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет значение переменной окружения
|
||||||
|
*/
|
||||||
|
const updateEnvVariable = async (key: string, value: string) => {
|
||||||
|
try {
|
||||||
|
setError(null)
|
||||||
|
setSuccessMessage(null)
|
||||||
|
|
||||||
|
const result = await query(props.apiUrl, `
|
||||||
|
mutation UpdateEnvVariable($key: String!, $value: String!) {
|
||||||
|
updateEnvVariable(key: $key, value: $value)
|
||||||
|
}
|
||||||
|
`, { key, value })
|
||||||
|
|
||||||
|
if (result.updateEnvVariable) {
|
||||||
|
setSuccessMessage(`Переменная ${key} успешно обновлена`)
|
||||||
|
// Обновляем список переменных
|
||||||
|
await loadEnvVariables()
|
||||||
|
} else {
|
||||||
|
setError('Не удалось обновить переменную')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка обновления переменной:', err)
|
||||||
|
setError('Ошибка при обновлении переменной: ' + (err as Error).message)
|
||||||
|
|
||||||
|
// Если ошибка авторизации - перенаправляем на логин
|
||||||
|
if (
|
||||||
|
err instanceof Error &&
|
||||||
|
(err.message.includes('401') ||
|
||||||
|
err.message.includes('авторизации') ||
|
||||||
|
err.message.includes('unauthorized') ||
|
||||||
|
err.message.includes('Unauthorized'))
|
||||||
|
) {
|
||||||
|
handleLogout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработчик открытия модального окна редактирования переменной
|
||||||
|
*/
|
||||||
|
const openVariableModal = (variable: EnvVariable) => {
|
||||||
|
setEditingVariable({ ...variable })
|
||||||
|
setShowVariableModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработчик закрытия модального окна редактирования переменной
|
||||||
|
*/
|
||||||
|
const closeVariableModal = () => {
|
||||||
|
setEditingVariable(null)
|
||||||
|
setShowVariableModal(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработчик сохранения переменной
|
||||||
|
*/
|
||||||
|
const saveVariable = async () => {
|
||||||
|
const variable = editingVariable()
|
||||||
|
if (!variable) return
|
||||||
|
|
||||||
|
await updateEnvVariable(variable.key, variable.value)
|
||||||
|
closeVariableModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработчик изменения значения в модальном окне
|
||||||
|
*/
|
||||||
|
const handleVariableValueChange = (value: string) => {
|
||||||
|
const variable = editingVariable()
|
||||||
|
if (variable) {
|
||||||
|
setEditingVariable({ ...variable, value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает список переменных среды при переключении на соответствующую вкладку
|
||||||
|
*/
|
||||||
|
const handleTabChange = (tab: string) => {
|
||||||
|
setActiveTab(tab)
|
||||||
|
|
||||||
|
if (tab === 'env' && envSections().length === 0) {
|
||||||
|
loadEnvVariables()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Компонент модального окна для редактирования переменной окружения
|
||||||
|
*/
|
||||||
|
const VariableModal: Component = () => {
|
||||||
|
const variable = editingVariable()
|
||||||
|
|
||||||
|
if (!variable) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="modal-overlay">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Редактирование переменной</h2>
|
||||||
|
<p>Переменная: {variable.key}</p>
|
||||||
|
|
||||||
|
<div class="variable-edit-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Значение:</label>
|
||||||
|
<input
|
||||||
|
type={variable.isSecret ? 'password' : 'text'}
|
||||||
|
value={variable.value}
|
||||||
|
onInput={(e) => handleVariableValueChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={variable.description}>
|
||||||
|
<div class="variable-description">
|
||||||
|
<p>{variable.description}</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="cancel-button" onClick={closeVariableModal}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button class="save-button" onClick={saveVariable}>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Компонент для отображения переменных окружения
|
||||||
|
*/
|
||||||
|
const EnvVariablesTab: Component = () => {
|
||||||
|
return (
|
||||||
|
<div class="env-variables-container">
|
||||||
|
<Show when={envLoading()}>
|
||||||
|
<div class="loading">Загрузка переменных окружения...</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!envLoading() && envSections().length === 0}>
|
||||||
|
<div class="empty-state">Нет доступных переменных окружения</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!envLoading() && envSections().length > 0}>
|
||||||
|
<div class="env-sections">
|
||||||
|
<For each={envSections()}>
|
||||||
|
{(section) => (
|
||||||
|
<div class="env-section">
|
||||||
|
<h3 class="section-name">{section.name}</h3>
|
||||||
|
<Show when={section.description}>
|
||||||
|
<p class="section-description">{section.description}</p>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="variables-list">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Ключ</th>
|
||||||
|
<th>Значение</th>
|
||||||
|
<th>Описание</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={section.variables}>
|
||||||
|
{(variable) => (
|
||||||
|
<tr>
|
||||||
|
<td>{variable.key}</td>
|
||||||
|
<td>
|
||||||
|
{variable.isSecret
|
||||||
|
? '••••••••'
|
||||||
|
: (variable.value || <span class="empty-value">не задано</span>)}
|
||||||
|
</td>
|
||||||
|
<td>{variable.description || '-'}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button
|
||||||
|
class="edit-button"
|
||||||
|
onClick={() => openVariableModal(variable)}
|
||||||
|
>
|
||||||
|
Изменить
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="admin-page">
|
<div class="admin-page">
|
||||||
<header>
|
<header>
|
||||||
|
@ -604,9 +934,12 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="admin-tabs">
|
<nav class="admin-tabs">
|
||||||
<button class={activeTab() === 'users' ? 'active' : ''} onClick={() => setActiveTab('users')}>
|
<button class={activeTab() === 'users' ? 'active' : ''} onClick={() => handleTabChange('users')}>
|
||||||
Пользователи
|
Пользователи
|
||||||
</button>
|
</button>
|
||||||
|
<button class={activeTab() === 'env' ? 'active' : ''} onClick={() => handleTabChange('env')}>
|
||||||
|
Переменные среды
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
@ -619,90 +952,90 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
||||||
<div class="success-message">{successMessage()}</div>
|
<div class="success-message">{successMessage()}</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={loading()}>
|
<Show when={activeTab() === 'users'}>
|
||||||
<div class="loading">Загрузка данных...</div>
|
<Show when={loading()}>
|
||||||
</Show>
|
<div class="loading">Загрузка данных...</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={!loading() && users().length === 0 && !error()}>
|
<Show when={!loading() && users().length === 0 && !error()}>
|
||||||
<div class="empty-state">Нет данных для отображения</div>
|
<div class="empty-state">Нет данных для отображения</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={!loading() && users().length > 0}>
|
<Show when={!loading() && users().length > 0}>
|
||||||
<div class="users-controls">
|
<div class="users-controls">
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<div class="search-input-group">
|
<div class="search-input-group">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Поиск по email, имени или ID..."
|
placeholder="Поиск по email, имени или ID..."
|
||||||
value={searchQuery()}
|
value={searchQuery()}
|
||||||
onInput={handleSearchChange}
|
onInput={handleSearchChange}
|
||||||
onKeyDown={handleSearchKeyDown}
|
onKeyDown={handleSearchKeyDown}
|
||||||
class="search-input"
|
class="search-input"
|
||||||
/>
|
/>
|
||||||
<button class="search-button" onClick={handleSearch}>
|
<button class="search-button" onClick={handleSearch}>
|
||||||
Поиск
|
Поиск
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="users-list">
|
<div class="users-list">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Имя</th>
|
<th>Имя</th>
|
||||||
<th>Роли</th>
|
<th>Роли</th>
|
||||||
<th>Создан</th>
|
<th>Создан</th>
|
||||||
<th>Последний вход</th>
|
</tr>
|
||||||
<th>Статус</th>
|
</thead>
|
||||||
<th>Действия</th>
|
<tbody>
|
||||||
</tr>
|
<For each={users()}>
|
||||||
</thead>
|
{(user) => (
|
||||||
<tbody>
|
<tr>
|
||||||
<For each={users()}>
|
<td>{user.id}</td>
|
||||||
{(user) => (
|
<td>{user.email}</td>
|
||||||
<tr class={user.is_active ? '' : 'blocked'}>
|
<td>{user.name || '-'}</td>
|
||||||
<td>{user.id}</td>
|
<td class="roles-cell">
|
||||||
<td>{user.email}</td>
|
<div class="roles-container">
|
||||||
<td>{user.name || '-'}</td>
|
<For each={user.roles}>
|
||||||
<td>{user.roles.join(', ') || '-'}</td>
|
{(role) => <RoleBadge role={role} />}
|
||||||
<td>{formatDate(user.created_at)}</td>
|
</For>
|
||||||
<td>{formatDate(user.last_seen)}</td>
|
<div class="role-badge" onClick={() => {
|
||||||
<td>
|
setSelectedUser(user)
|
||||||
<span class={`status ${user.is_active ? 'active' : 'inactive'}`}>
|
setShowRolesModal(true)
|
||||||
{user.is_active ? 'Активен' : 'Заблокирован'}
|
}}
|
||||||
</span>
|
>
|
||||||
</td>
|
🎭
|
||||||
<td class="actions">
|
</div>
|
||||||
<button
|
</div>
|
||||||
class={user.is_active ? 'block' : 'unblock'}
|
</td>
|
||||||
onClick={() => toggleUserBlock(user.id, user.is_active)}
|
<td>{formatDateRelative(user.created_at)}</td>
|
||||||
>
|
</tr>
|
||||||
{user.is_active ? 'Блокировать' : 'Разблокировать'}
|
)}
|
||||||
</button>
|
</For>
|
||||||
<button
|
</tbody>
|
||||||
class={user.muted ? 'unmute' : 'mute'}
|
</table>
|
||||||
onClick={() => toggleUserMute(user.id, user.muted)}
|
</div>
|
||||||
>
|
|
||||||
{user.muted ? 'Unmute' : 'Mute'}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Pagination />
|
<Pagination />
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={activeTab() === 'env'}>
|
||||||
|
<EnvVariablesTab />
|
||||||
</Show>
|
</Show>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Show when={showRolesModal()}>
|
<Show when={showRolesModal()}>
|
||||||
<RolesModal />
|
<RolesModal />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={showVariableModal()}>
|
||||||
|
<VariableModal />
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -67,6 +67,11 @@ function hasAuthErrors(errors: Array<{ message?: string; extensions?: { code?: s
|
||||||
* @returns Полный URL для запроса
|
* @returns Полный URL для запроса
|
||||||
*/
|
*/
|
||||||
function prepareUrl(url: string): string {
|
function prepareUrl(url: string): string {
|
||||||
|
// В режиме локальной разработки всегда используем /graphql
|
||||||
|
if (location.hostname === 'localhost') {
|
||||||
|
return `${location.origin}/graphql`
|
||||||
|
}
|
||||||
|
|
||||||
// Если это относительный путь, добавляем к нему origin
|
// Если это относительный путь, добавляем к нему origin
|
||||||
if (url.startsWith('/')) {
|
if (url.startsWith('/')) {
|
||||||
return `${location.origin}${url}`
|
return `${location.origin}${url}`
|
||||||
|
|
286
panel/styles.css
286
panel/styles.css
|
@ -425,10 +425,11 @@ button.unmute {
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancel-button {
|
.cancel-button {
|
||||||
|
color: #333 !important;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background-color: #ccc;
|
background-color: #ccc;
|
||||||
color: #333;
|
|
||||||
width: auto;
|
width: auto;
|
||||||
|
border: 1px solid #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-button {
|
.save-button {
|
||||||
|
@ -598,3 +599,286 @@ button.unmute {
|
||||||
0% { transform: rotate(0deg); }
|
0% { transform: rotate(0deg); }
|
||||||
100% { transform: rotate(360deg); }
|
100% { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Стили для вкладки с переменными окружения */
|
||||||
|
.env-variables-container {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-section {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-name {
|
||||||
|
margin-top: 0;
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variable-edit-form {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variable-description {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-value {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.edit-button {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.edit-button:hover {
|
||||||
|
background-color: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
background-color: var(--success-light);
|
||||||
|
color: var(--success-color);
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background-color: var(--danger-light);
|
||||||
|
color: var(--danger-color);
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для модального окна редактирования */
|
||||||
|
.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-color: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.cancel-button {
|
||||||
|
background-color: var(--text-secondary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.save-button {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.cancel-button:hover {
|
||||||
|
background-color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.save-button:hover {
|
||||||
|
background-color: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для компонентов ролей */
|
||||||
|
.roles-cell {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
margin: 2px 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-icon {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-roles {
|
||||||
|
background-color: #8a2be2;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-roles:hover {
|
||||||
|
background-color: #7b1fa2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили компонентов ролей */
|
||||||
|
.roles-cell {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
margin: 2px 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-icon {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-roles {
|
||||||
|
background-color: #8a2be2;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-roles:hover {
|
||||||
|
background-color: #7b1fa2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для сортировки таблицы */
|
||||||
|
th.sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.sortable:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
th.sortable.sorted {
|
||||||
|
background-color: rgba(65, 105, 225, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-icon {
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.sortable.sorted .sort-icon {
|
||||||
|
color: #4169e1;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для сортировки таблицы */
|
||||||
|
th.sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.sortable:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
th.sortable.sorted {
|
||||||
|
background-color: rgba(65, 105, 225, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-icon {
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.sortable.sorted .sort-icon {
|
||||||
|
color: #4169e1;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
|
@ -1,11 +1,12 @@
|
||||||
from math import ceil
|
from math import ceil
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_, cast, String
|
||||||
from graphql.error import GraphQLError
|
from graphql.error import GraphQLError
|
||||||
|
|
||||||
from auth.decorators import admin_auth_required
|
from auth.decorators import admin_auth_required
|
||||||
from services.db import local_session
|
from services.db import local_session
|
||||||
from services.schema import query
|
from services.schema import query, mutation
|
||||||
from auth.orm import Author, Role
|
from auth.orm import Author, Role, AuthorRole
|
||||||
|
from services.env import EnvManager, EnvVariable
|
||||||
from utils.logger import root_logger as logger
|
from utils.logger import root_logger as logger
|
||||||
|
|
||||||
|
|
||||||
|
@ -40,7 +41,7 @@ async def admin_get_users(_, info, limit=10, offset=0, search=None):
|
||||||
or_(
|
or_(
|
||||||
Author.email.ilike(search_term),
|
Author.email.ilike(search_term),
|
||||||
Author.name.ilike(search_term),
|
Author.name.ilike(search_term),
|
||||||
Author.id.cast(str).ilike(search_term),
|
cast(Author.id, String).ilike(search_term),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -67,9 +68,7 @@ async def admin_get_users(_, info, limit=10, offset=0, search=None):
|
||||||
if hasattr(user, "roles") and user.roles
|
if hasattr(user, "roles") and user.roles
|
||||||
else [],
|
else [],
|
||||||
"created_at": user.created_at,
|
"created_at": user.created_at,
|
||||||
"last_seen": user.last_seen,
|
"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
|
for user in users
|
||||||
],
|
],
|
||||||
|
@ -120,3 +119,179 @@ async def admin_get_roles(_, info):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при получении списка ролей: {str(e)}")
|
logger.error(f"Ошибка при получении списка ролей: {str(e)}")
|
||||||
raise GraphQLError(f"Не удалось получить список ролей: {str(e)}")
|
raise GraphQLError(f"Не удалось получить список ролей: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@query.field("getEnvVariables")
|
||||||
|
@admin_auth_required
|
||||||
|
async def get_env_variables(_, info):
|
||||||
|
"""
|
||||||
|
Получает список переменных окружения, сгруппированных по секциям
|
||||||
|
|
||||||
|
Args:
|
||||||
|
info: Контекст GraphQL запроса
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список секций с переменными окружения
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Создаем экземпляр менеджера переменных окружения
|
||||||
|
env_manager = EnvManager()
|
||||||
|
|
||||||
|
# Получаем все переменные
|
||||||
|
sections = env_manager.get_all_variables()
|
||||||
|
|
||||||
|
# Преобразуем к формату GraphQL API
|
||||||
|
result = [
|
||||||
|
{
|
||||||
|
"name": section.name,
|
||||||
|
"description": section.description,
|
||||||
|
"variables": [
|
||||||
|
{
|
||||||
|
"key": var.key,
|
||||||
|
"value": var.value,
|
||||||
|
"description": var.description,
|
||||||
|
"type": var.type,
|
||||||
|
"isSecret": var.is_secret,
|
||||||
|
}
|
||||||
|
for var in section.variables
|
||||||
|
]
|
||||||
|
}
|
||||||
|
for section in sections
|
||||||
|
]
|
||||||
|
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении переменных окружения: {str(e)}")
|
||||||
|
raise GraphQLError(f"Не удалось получить переменные окружения: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@mutation.field("updateEnvVariable")
|
||||||
|
@admin_auth_required
|
||||||
|
async def update_env_variable(_, info, key, value):
|
||||||
|
"""
|
||||||
|
Обновляет значение переменной окружения
|
||||||
|
|
||||||
|
Args:
|
||||||
|
info: Контекст GraphQL запроса
|
||||||
|
key: Ключ переменной
|
||||||
|
value: Новое значение
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Boolean: результат операции
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Создаем экземпляр менеджера переменных окружения
|
||||||
|
env_manager = EnvManager()
|
||||||
|
|
||||||
|
# Обновляем переменную
|
||||||
|
result = env_manager.update_variable(key, value)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
logger.info(f"Переменная окружения '{key}' успешно обновлена")
|
||||||
|
else:
|
||||||
|
logger.error(f"Не удалось обновить переменную окружения '{key}'")
|
||||||
|
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при обновлении переменной окружения: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@mutation.field("updateEnvVariables")
|
||||||
|
@admin_auth_required
|
||||||
|
async def update_env_variables(_, info, variables):
|
||||||
|
"""
|
||||||
|
Массовое обновление переменных окружения
|
||||||
|
|
||||||
|
Args:
|
||||||
|
info: Контекст GraphQL запроса
|
||||||
|
variables: Список переменных для обновления
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Boolean: результат операции
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Создаем экземпляр менеджера переменных окружения
|
||||||
|
env_manager = EnvManager()
|
||||||
|
|
||||||
|
# Преобразуем входные данные в формат для менеджера
|
||||||
|
env_variables = [
|
||||||
|
EnvVariable(
|
||||||
|
key=var.get("key", ""),
|
||||||
|
value=var.get("value", ""),
|
||||||
|
type=var.get("type", "string")
|
||||||
|
)
|
||||||
|
for var in variables
|
||||||
|
]
|
||||||
|
|
||||||
|
# Обновляем переменные
|
||||||
|
result = env_manager.update_variables(env_variables)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
logger.info(f"Переменные окружения успешно обновлены ({len(variables)} шт.)")
|
||||||
|
else:
|
||||||
|
logger.error(f"Не удалось обновить переменные окружения")
|
||||||
|
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при массовом обновлении переменных окружения: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@mutation.field("adminUpdateUser")
|
||||||
|
@admin_auth_required
|
||||||
|
async def admin_update_user(_, info, user):
|
||||||
|
"""
|
||||||
|
Обновляет роли пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
info: Контекст GraphQL запроса
|
||||||
|
user: Данные для обновления пользователя (содержит id и roles)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Boolean: результат операции
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user_id = user.get("id")
|
||||||
|
roles = user.get("roles", [])
|
||||||
|
|
||||||
|
if not roles:
|
||||||
|
logger.warning(f"Пользователю {user_id} не назначено ни одной роли. Доступ в систему будет заблокирован.")
|
||||||
|
|
||||||
|
with local_session() as session:
|
||||||
|
# Получаем пользователя из базы данных
|
||||||
|
author = session.query(Author).filter(Author.id == user_id).first()
|
||||||
|
|
||||||
|
if not author:
|
||||||
|
logger.error(f"Пользователь с ID {user_id} не найден")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Получаем текущие роли пользователя
|
||||||
|
current_roles = {role.id for role in author.roles} if author.roles else set()
|
||||||
|
|
||||||
|
# Обновляем роли только если они изменились
|
||||||
|
if set(roles) != current_roles:
|
||||||
|
# Получаем все существующие роли, которые указаны для обновления
|
||||||
|
role_objects = session.query(Role).filter(Role.id.in_(roles)).all()
|
||||||
|
|
||||||
|
# Очищаем текущие роли и добавляем новые
|
||||||
|
author.roles = role_objects
|
||||||
|
|
||||||
|
# Сохраняем изменения в базе данных
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Проверяем, добавлена ли пользователю роль reader
|
||||||
|
has_reader = 'reader' in roles
|
||||||
|
if not has_reader:
|
||||||
|
logger.warning(f"Пользователю {author.email or author.id} не назначена роль 'reader'. Доступ в систему будет ограничен.")
|
||||||
|
|
||||||
|
logger.info(f"Роли пользователя {author.email or author.id} обновлены: {', '.join(roles)}")
|
||||||
|
else:
|
||||||
|
logger.info(f"Роли пользователя {author.email or author.id} не изменились")
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
logger.error(f"Ошибка при обновлении ролей пользователя: {str(e)}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return False
|
||||||
|
|
|
@ -40,6 +40,7 @@ async def get_current_user(_, info):
|
||||||
author.last_seen = int(time.time())
|
author.last_seen = int(time.time())
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
# Здесь можно не применять фильтрацию, так как пользователь получает свои данные
|
||||||
return {"token": token, "author": author}
|
return {"token": token, "author": author}
|
||||||
|
|
||||||
|
|
||||||
|
@ -76,6 +77,7 @@ async def confirm_email(_, info, token):
|
||||||
session.add(user)
|
session.add(user)
|
||||||
session.commit()
|
session.commit()
|
||||||
logger.info(f"[auth] confirmEmail: Email для пользователя {user_id} успешно подтвержден.")
|
logger.info(f"[auth] confirmEmail: Email для пользователя {user_id} успешно подтвержден.")
|
||||||
|
# Здесь можно не применять фильтрацию, так как пользователь получает свои данные
|
||||||
return {"success": True, "token": session_token, "author": user, "error": None}
|
return {"success": True, "token": session_token, "author": user, "error": None}
|
||||||
except InvalidToken as e:
|
except InvalidToken as e:
|
||||||
logger.warning(f"[auth] confirmEmail: Невалидный токен - {e.message}")
|
logger.warning(f"[auth] confirmEmail: Невалидный токен - {e.message}")
|
||||||
|
@ -166,6 +168,7 @@ async def register_by_email(_, _info, email: str, password: str = "", name: str
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[auth] registerUser: Пользователь {email} зарегистрирован, ссылка для подтверждения отправлена."
|
f"[auth] registerUser: Пользователь {email} зарегистрирован, ссылка для подтверждения отправлена."
|
||||||
)
|
)
|
||||||
|
# При регистрации возвращаем данные самому пользователю, поэтому не фильтруем
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"token": None,
|
"token": None,
|
||||||
|
@ -238,20 +241,51 @@ async def login(_, info, email: str, password: str):
|
||||||
f"[auth] login: Найден автор {email}, id={author.id}, имя={author.name}, пароль есть: {bool(author.password)}"
|
f"[auth] login: Найден автор {email}, id={author.id}, имя={author.name}, пароль есть: {bool(author.password)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Проверяем пароль
|
# Проверяем наличие роли reader
|
||||||
logger.info(f"[auth] login: НАЧАЛО ПРОВЕРКИ ПАРОЛЯ для {email}")
|
has_reader_role = False
|
||||||
verify_result = Identity.password(author, password)
|
if hasattr(author, "roles") and author.roles:
|
||||||
logger.info(
|
for role in author.roles:
|
||||||
f"[auth] login: РЕЗУЛЬТАТ ПРОВЕРКИ ПАРОЛЯ: {verify_result if isinstance(verify_result, dict) else 'успешно'}"
|
if role.id == "reader":
|
||||||
)
|
has_reader_role = True
|
||||||
|
break
|
||||||
|
|
||||||
if isinstance(verify_result, dict) and verify_result.get("error"):
|
# Если у пользователя нет роли reader и он не админ, запрещаем вход
|
||||||
logger.warning(f"[auth] login: Неверный пароль для {email}: {verify_result.get('error')}")
|
if not has_reader_role:
|
||||||
|
# Проверяем, есть ли роль admin или super
|
||||||
|
is_admin = author.email in ADMIN_EMAILS.split(",")
|
||||||
|
|
||||||
|
if not is_admin:
|
||||||
|
logger.warning(f"[auth] login: У пользователя {email} нет роли 'reader', в доступе отказано")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"token": None,
|
||||||
|
"author": None,
|
||||||
|
"error": "У вас нет необходимых прав для входа. Обратитесь к администратору.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Проверяем пароль - важно использовать непосредственно объект author, а не его dict
|
||||||
|
logger.info(f"[auth] login: НАЧАЛО ПРОВЕРКИ ПАРОЛЯ для {email}")
|
||||||
|
try:
|
||||||
|
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", "Ошибка авторизации"),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[auth] login: Ошибка при проверке пароля: {str(e)}")
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"token": None,
|
"token": None,
|
||||||
"author": None,
|
"author": None,
|
||||||
"error": verify_result.get("error", "Ошибка авторизации"),
|
"error": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Получаем правильный объект автора - результат verify_result
|
# Получаем правильный объект автора - результат verify_result
|
||||||
|
@ -346,9 +380,12 @@ async def login(_, info, email: str, password: str):
|
||||||
if not cookie_set:
|
if not cookie_set:
|
||||||
logger.warning(f"[auth] login: Не удалось установить cookie никаким способом")
|
logger.warning(f"[auth] login: Не удалось установить cookie никаким способом")
|
||||||
|
|
||||||
# Возвращаем успешный результат
|
# Возвращаем успешный результат с данными для клиента
|
||||||
|
# Для ответа клиенту используем dict() с параметром access=True,
|
||||||
|
# чтобы получить полный доступ к данным для самого пользователя
|
||||||
logger.info(f"[auth] login: Успешный вход для {email}")
|
logger.info(f"[auth] login: Успешный вход для {email}")
|
||||||
result = {"success": True, "token": token, "author": valid_author, "error": None}
|
author_dict = valid_author.dict(access=True)
|
||||||
|
result = {"success": True, "token": token, "author": author_dict, "error": None}
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[auth] login: Возвращаемый результат: {{success: {result['success']}, token_length: {len(token) if token else 0}}}"
|
f"[auth] login: Возвращаемый результат: {{success: {result['success']}, token_length: {len(token) if token else 0}}}"
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
|
@ -26,11 +26,15 @@ DEFAULT_COMMUNITIES = [1]
|
||||||
|
|
||||||
|
|
||||||
# Вспомогательная функция для получения всех авторов без статистики
|
# Вспомогательная функция для получения всех авторов без статистики
|
||||||
async def get_all_authors():
|
async def get_all_authors(current_user_id=None):
|
||||||
"""
|
"""
|
||||||
Получает всех авторов без статистики.
|
Получает всех авторов без статистики.
|
||||||
Используется для случаев, когда нужен полный список авторов без дополнительной информации.
|
Используется для случаев, когда нужен полный список авторов без дополнительной информации.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_user_id: ID текущего пользователя для проверки прав доступа
|
||||||
|
is_admin: Флаг, указывающий, является ли пользователь администратором
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list: Список всех авторов без статистики
|
list: Список всех авторов без статистики
|
||||||
"""
|
"""
|
||||||
|
@ -45,15 +49,15 @@ async def get_all_authors():
|
||||||
authors_query = select(Author).where(Author.deleted_at.is_(None))
|
authors_query = select(Author).where(Author.deleted_at.is_(None))
|
||||||
authors = session.execute(authors_query).scalars().all()
|
authors = session.execute(authors_query).scalars().all()
|
||||||
|
|
||||||
# Преобразуем авторов в словари
|
# Преобразуем авторов в словари с учетом прав доступа
|
||||||
return [author.dict() for author in authors]
|
return [author.dict(current_user_id, False) for author in authors]
|
||||||
|
|
||||||
# Используем универсальную функцию для кеширования запросов
|
# Используем универсальную функцию для кеширования запросов
|
||||||
return await cached_query(cache_key, fetch_all_authors)
|
return await cached_query(cache_key, fetch_all_authors)
|
||||||
|
|
||||||
|
|
||||||
# Вспомогательная функция для получения авторов со статистикой с пагинацией
|
# Вспомогательная функция для получения авторов со статистикой с пагинацией
|
||||||
async def get_authors_with_stats(limit=50, offset=0, by: Optional[str] = None):
|
async def get_authors_with_stats(limit=50, offset=0, by: Optional[str] = None, current_user_id: Optional[int] = None):
|
||||||
"""
|
"""
|
||||||
Получает авторов со статистикой с пагинацией.
|
Получает авторов со статистикой с пагинацией.
|
||||||
|
|
||||||
|
@ -61,7 +65,7 @@ async def get_authors_with_stats(limit=50, offset=0, by: Optional[str] = None):
|
||||||
limit: Максимальное количество возвращаемых авторов
|
limit: Максимальное количество возвращаемых авторов
|
||||||
offset: Смещение для пагинации
|
offset: Смещение для пагинации
|
||||||
by: Опциональный параметр сортировки (new/active)
|
by: Опциональный параметр сортировки (new/active)
|
||||||
|
current_user_id: ID текущего пользователя
|
||||||
Returns:
|
Returns:
|
||||||
list: Список авторов с их статистикой
|
list: Список авторов с их статистикой
|
||||||
"""
|
"""
|
||||||
|
@ -133,15 +137,18 @@ async def get_authors_with_stats(limit=50, offset=0, by: Optional[str] = None):
|
||||||
# Формируем результат с добавлением статистики
|
# Формируем результат с добавлением статистики
|
||||||
result = []
|
result = []
|
||||||
for author in authors:
|
for author in authors:
|
||||||
|
# Получаем словарь с учетом прав доступа
|
||||||
author_dict = author.dict()
|
author_dict = author.dict()
|
||||||
author_dict["stat"] = {
|
author_dict["stat"] = {
|
||||||
"shouts": shouts_stats.get(author.id, 0),
|
"shouts": shouts_stats.get(author.id, 0),
|
||||||
"followers": followers_stats.get(author.id, 0),
|
"followers": followers_stats.get(author.id, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
result.append(author_dict)
|
result.append(author_dict)
|
||||||
|
|
||||||
# Кешируем каждого автора отдельно для использования в других функциях
|
# Кешируем каждого автора отдельно для использования в других функциях
|
||||||
await cache_author(author_dict)
|
# Важно: кэшируем полный словарь для админов
|
||||||
|
await cache_author(author.dict())
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@ -172,8 +179,8 @@ async def invalidate_authors_cache(author_id=None):
|
||||||
# Получаем user_id автора, если есть
|
# Получаем user_id автора, если есть
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
author = session.query(Author).filter(Author.id == author_id).first()
|
author = session.query(Author).filter(Author.id == author_id).first()
|
||||||
if author and author.user:
|
if author and Author.id:
|
||||||
specific_keys.append(f"author:user:{author.user.strip()}")
|
specific_keys.append(f"author:user:{Author.id.strip()}")
|
||||||
|
|
||||||
# Удаляем конкретные ключи
|
# Удаляем конкретные ключи
|
||||||
for key in specific_keys:
|
for key in specific_keys:
|
||||||
|
@ -198,24 +205,28 @@ async def invalidate_authors_cache(author_id=None):
|
||||||
@login_required
|
@login_required
|
||||||
async def update_author(_, info, profile):
|
async def update_author(_, info, profile):
|
||||||
user_id = info.context.get("user_id")
|
user_id = info.context.get("user_id")
|
||||||
|
is_admin = info.context.get("is_admin", False)
|
||||||
|
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return {"error": "unauthorized", "author": None}
|
return {"error": "unauthorized", "author": None}
|
||||||
try:
|
try:
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
author = session.query(Author).where(Author.user == user_id).first()
|
author = session.query(Author).where(Author.id == user_id).first()
|
||||||
if author:
|
if author:
|
||||||
Author.update(author, profile)
|
Author.update(author, profile)
|
||||||
session.add(author)
|
session.add(author)
|
||||||
session.commit()
|
session.commit()
|
||||||
author_query = select(Author).where(Author.user == user_id)
|
author_query = select(Author).where(Author.id == user_id)
|
||||||
result = get_with_stat(author_query)
|
result = get_with_stat(author_query)
|
||||||
if result:
|
if result:
|
||||||
author_with_stat = result[0]
|
author_with_stat = result[0]
|
||||||
if isinstance(author_with_stat, Author):
|
if isinstance(author_with_stat, Author):
|
||||||
author_dict = author_with_stat.dict()
|
# Кэшируем полную версию для админов
|
||||||
# await cache_author(author_dict)
|
author_dict = author_with_stat.dict(is_admin=True)
|
||||||
asyncio.create_task(cache_author(author_dict))
|
asyncio.create_task(cache_author(author_dict))
|
||||||
return {"error": None, "author": author}
|
|
||||||
|
# Возвращаем обычную полную версию, т.к. это владелец
|
||||||
|
return {"error": None, "author": author}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
@ -224,24 +235,46 @@ async def update_author(_, info, profile):
|
||||||
|
|
||||||
|
|
||||||
@query.field("get_authors_all")
|
@query.field("get_authors_all")
|
||||||
async def get_authors_all(_, _info):
|
async def get_authors_all(_, info):
|
||||||
"""
|
"""
|
||||||
Получает список всех авторов без статистики.
|
Получает список всех авторов без статистики.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list: Список всех авторов
|
list: Список всех авторов
|
||||||
"""
|
"""
|
||||||
return await get_all_authors()
|
# Получаем ID текущего пользователя и флаг админа из контекста
|
||||||
|
current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
|
||||||
|
authors = await get_all_authors(current_user_id, False)
|
||||||
|
return authors
|
||||||
|
|
||||||
|
|
||||||
@query.field("get_author")
|
@query.field("get_author")
|
||||||
async def get_author(_, _info, slug="", author_id=0):
|
async def get_author(_, info, slug="", author_id=0):
|
||||||
|
# Получаем ID текущего пользователя и флаг админа из контекста
|
||||||
|
current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
|
||||||
|
is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False
|
||||||
|
|
||||||
author_dict = None
|
author_dict = None
|
||||||
try:
|
try:
|
||||||
author_id = get_author_id_from(slug=slug, user="", author_id=author_id)
|
author_id = get_author_id_from(slug=slug, user="", author_id=author_id)
|
||||||
if not author_id:
|
if not author_id:
|
||||||
raise ValueError("cant find")
|
raise ValueError("cant find")
|
||||||
author_dict = await get_cached_author(int(author_id), get_with_stat)
|
|
||||||
|
# Получаем данные автора из кэша (полные данные)
|
||||||
|
cached_author = await get_cached_author(int(author_id), get_with_stat)
|
||||||
|
|
||||||
|
# Применяем фильтрацию на стороне клиента, так как в кэше хранится полная версия
|
||||||
|
if cached_author:
|
||||||
|
# Создаем объект автора для использования метода dict
|
||||||
|
temp_author = Author()
|
||||||
|
for key, value in cached_author.items():
|
||||||
|
if hasattr(temp_author, key):
|
||||||
|
setattr(temp_author, key, value)
|
||||||
|
# Получаем отфильтрованную версию
|
||||||
|
author_dict = temp_author.dict(current_user_id, is_admin)
|
||||||
|
# Добавляем статистику, которая могла быть в кэшированной версии
|
||||||
|
if "stat" in cached_author:
|
||||||
|
author_dict["stat"] = cached_author["stat"]
|
||||||
|
|
||||||
if not author_dict or not author_dict.get("stat"):
|
if not author_dict or not author_dict.get("stat"):
|
||||||
# update stat from db
|
# update stat from db
|
||||||
|
@ -250,9 +283,15 @@ async def get_author(_, _info, slug="", author_id=0):
|
||||||
if result:
|
if result:
|
||||||
author_with_stat = result[0]
|
author_with_stat = result[0]
|
||||||
if isinstance(author_with_stat, Author):
|
if isinstance(author_with_stat, Author):
|
||||||
author_dict = author_with_stat.dict()
|
# Кэшируем полные данные для админов
|
||||||
# await cache_author(author_dict)
|
original_dict = author_with_stat.dict(is_admin=True)
|
||||||
asyncio.create_task(cache_author(author_dict))
|
asyncio.create_task(cache_author(original_dict))
|
||||||
|
|
||||||
|
# Возвращаем отфильтрованную версию
|
||||||
|
author_dict = author_with_stat.dict(current_user_id, is_admin)
|
||||||
|
# Добавляем статистику
|
||||||
|
if hasattr(author_with_stat, "stat"):
|
||||||
|
author_dict["stat"] = author_with_stat.stat
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
@ -263,31 +302,43 @@ async def get_author(_, _info, slug="", author_id=0):
|
||||||
|
|
||||||
|
|
||||||
@query.field("get_author_id")
|
@query.field("get_author_id")
|
||||||
async def get_author_id(_, _info, user: str):
|
async def get_author_id(_, info, user: str):
|
||||||
|
# Получаем ID текущего пользователя и флаг админа из контекста
|
||||||
|
current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
|
||||||
|
is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False
|
||||||
|
|
||||||
user_id = user.strip()
|
user_id = user.strip()
|
||||||
logger.info(f"getting author id for {user_id}")
|
logger.info(f"getting author id for {user_id}")
|
||||||
author = None
|
author = None
|
||||||
try:
|
try:
|
||||||
author = await get_cached_author_by_user_id(user_id, get_with_stat)
|
cached_author = await get_cached_author_by_user_id(user_id, get_with_stat)
|
||||||
if author:
|
if cached_author:
|
||||||
return author
|
# Создаем объект автора для использования метода dict
|
||||||
|
temp_author = Author()
|
||||||
|
for key, value in cached_author.items():
|
||||||
|
if hasattr(temp_author, key):
|
||||||
|
setattr(temp_author, key, value)
|
||||||
|
# Возвращаем отфильтрованную версию
|
||||||
|
return temp_author.dict(current_user_id, is_admin)
|
||||||
|
|
||||||
author_query = select(Author).filter(Author.user == user_id)
|
author_query = select(Author).filter(Author.id == user_id)
|
||||||
result = get_with_stat(author_query)
|
result = get_with_stat(author_query)
|
||||||
if result:
|
if result:
|
||||||
author_with_stat = result[0]
|
author_with_stat = result[0]
|
||||||
if isinstance(author_with_stat, Author):
|
if isinstance(author_with_stat, Author):
|
||||||
author_dict = author_with_stat.dict()
|
# Кэшируем полную версию данных
|
||||||
# await cache_author(author_dict)
|
original_dict = author_with_stat.dict(is_admin=True)
|
||||||
asyncio.create_task(cache_author(author_dict))
|
asyncio.create_task(cache_author(original_dict))
|
||||||
return author_with_stat
|
|
||||||
|
# Возвращаем отфильтрованную версию
|
||||||
|
return author_with_stat.dict(current_user_id, is_admin)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"Error getting author: {exc}")
|
logger.error(f"Error getting author: {exc}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@query.field("load_authors_by")
|
@query.field("load_authors_by")
|
||||||
async def load_authors_by(_, _info, by, limit, offset):
|
async def load_authors_by(_, info, by, limit, offset):
|
||||||
"""
|
"""
|
||||||
Загружает авторов по заданному критерию с пагинацией.
|
Загружает авторов по заданному критерию с пагинацией.
|
||||||
|
|
||||||
|
@ -299,8 +350,12 @@ async def load_authors_by(_, _info, by, limit, offset):
|
||||||
Returns:
|
Returns:
|
||||||
list: Список авторов с учетом критерия
|
list: Список авторов с учетом критерия
|
||||||
"""
|
"""
|
||||||
|
# Получаем ID текущего пользователя и флаг админа из контекста
|
||||||
|
current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
|
||||||
|
is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False
|
||||||
|
|
||||||
# Используем оптимизированную функцию для получения авторов
|
# Используем оптимизированную функцию для получения авторов
|
||||||
return await get_authors_with_stats(limit, offset, by)
|
return await get_authors_with_stats(limit, offset, by, current_user_id, is_admin)
|
||||||
|
|
||||||
|
|
||||||
def get_author_id_from(slug="", user=None, author_id=None):
|
def get_author_id_from(slug="", user=None, author_id=None):
|
||||||
|
@ -316,7 +371,7 @@ def get_author_id_from(slug="", user=None, author_id=None):
|
||||||
author_id = author.id
|
author_id = author.id
|
||||||
return author_id
|
return author_id
|
||||||
if user:
|
if user:
|
||||||
author = session.query(Author).filter(Author.user == user).first()
|
author = session.query(Author).filter(Author.id == user).first()
|
||||||
if author:
|
if author:
|
||||||
author_id = author.id
|
author_id = author.id
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
@ -325,15 +380,31 @@ def get_author_id_from(slug="", user=None, author_id=None):
|
||||||
|
|
||||||
|
|
||||||
@query.field("get_author_follows")
|
@query.field("get_author_follows")
|
||||||
async def get_author_follows(_, _info, slug="", user=None, author_id=0):
|
async def get_author_follows(_, info, slug="", user=None, author_id=0):
|
||||||
|
# Получаем ID текущего пользователя и флаг админа из контекста
|
||||||
|
current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
|
||||||
|
is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False
|
||||||
|
|
||||||
logger.debug(f"getting follows for @{slug}")
|
logger.debug(f"getting follows for @{slug}")
|
||||||
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
|
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
|
||||||
if not author_id:
|
if not author_id:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
followed_authors = await get_cached_follower_authors(author_id)
|
# Получаем данные из кэша
|
||||||
|
followed_authors_raw = await get_cached_follower_authors(author_id)
|
||||||
followed_topics = await get_cached_follower_topics(author_id)
|
followed_topics = await get_cached_follower_topics(author_id)
|
||||||
|
|
||||||
|
# Фильтруем чувствительные данные авторов
|
||||||
|
followed_authors = []
|
||||||
|
for author_data in followed_authors_raw:
|
||||||
|
# Создаем объект автора для использования метода dict
|
||||||
|
temp_author = Author()
|
||||||
|
for key, value in author_data.items():
|
||||||
|
if hasattr(temp_author, key):
|
||||||
|
setattr(temp_author, key, value)
|
||||||
|
# Добавляем отфильтрованную версию
|
||||||
|
followed_authors.append(temp_author.dict(current_user_id, is_admin))
|
||||||
|
|
||||||
# TODO: Get followed communities too
|
# TODO: Get followed communities too
|
||||||
return {
|
return {
|
||||||
"authors": followed_authors,
|
"authors": followed_authors,
|
||||||
|
@ -354,18 +425,36 @@ async def get_author_follows_topics(_, _info, slug="", user=None, author_id=None
|
||||||
|
|
||||||
|
|
||||||
@query.field("get_author_follows_authors")
|
@query.field("get_author_follows_authors")
|
||||||
async def get_author_follows_authors(_, _info, slug="", user=None, author_id=None):
|
async def get_author_follows_authors(_, info, slug="", user=None, author_id=None):
|
||||||
|
# Получаем ID текущего пользователя и флаг админа из контекста
|
||||||
|
current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
|
||||||
|
is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False
|
||||||
|
|
||||||
logger.debug(f"getting followed authors for @{slug}")
|
logger.debug(f"getting followed authors for @{slug}")
|
||||||
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
|
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
|
||||||
if not author_id:
|
if not author_id:
|
||||||
return []
|
return []
|
||||||
followed_authors = await get_cached_follower_authors(author_id)
|
|
||||||
|
# Получаем данные из кэша
|
||||||
|
followed_authors_raw = await get_cached_follower_authors(author_id)
|
||||||
|
|
||||||
|
# Фильтруем чувствительные данные авторов
|
||||||
|
followed_authors = []
|
||||||
|
for author_data in followed_authors_raw:
|
||||||
|
# Создаем объект автора для использования метода dict
|
||||||
|
temp_author = Author()
|
||||||
|
for key, value in author_data.items():
|
||||||
|
if hasattr(temp_author, key):
|
||||||
|
setattr(temp_author, key, value)
|
||||||
|
# Добавляем отфильтрованную версию
|
||||||
|
followed_authors.append(temp_author.dict(current_user_id, is_admin))
|
||||||
|
|
||||||
return followed_authors
|
return followed_authors
|
||||||
|
|
||||||
|
|
||||||
def create_author(user_id: str, slug: str, name: str = ""):
|
def create_author(user_id: str, slug: str, name: str = ""):
|
||||||
author = Author()
|
author = Author()
|
||||||
author.user = user_id # Связь с user_id из системы авторизации
|
Author.id = user_id # Связь с user_id из системы авторизации
|
||||||
author.slug = slug # Идентификатор из системы авторизации
|
author.slug = slug # Идентификатор из системы авторизации
|
||||||
author.created_at = author.updated_at = int(time.time())
|
author.created_at = author.updated_at = int(time.time())
|
||||||
author.name = name or slug # если не указано
|
author.name = name or slug # если не указано
|
||||||
|
@ -377,10 +466,28 @@ def create_author(user_id: str, slug: str, name: str = ""):
|
||||||
|
|
||||||
|
|
||||||
@query.field("get_author_followers")
|
@query.field("get_author_followers")
|
||||||
async def get_author_followers(_, _info, slug: str = "", user: str = "", author_id: int = 0):
|
async def get_author_followers(_, info, slug: str = "", user: str = "", author_id: int = 0):
|
||||||
|
# Получаем ID текущего пользователя и флаг админа из контекста
|
||||||
|
current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
|
||||||
|
is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False
|
||||||
|
|
||||||
logger.debug(f"getting followers for author @{slug} or ID:{author_id}")
|
logger.debug(f"getting followers for author @{slug} or ID:{author_id}")
|
||||||
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
|
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
|
||||||
if not author_id:
|
if not author_id:
|
||||||
return []
|
return []
|
||||||
followers = await get_cached_author_followers(author_id)
|
|
||||||
|
# Получаем данные из кэша
|
||||||
|
followers_raw = await get_cached_author_followers(author_id)
|
||||||
|
|
||||||
|
# Фильтруем чувствительные данные авторов
|
||||||
|
followers = []
|
||||||
|
for follower_data in followers_raw:
|
||||||
|
# Создаем объект автора для использования метода dict
|
||||||
|
temp_author = Author()
|
||||||
|
for key, value in follower_data.items():
|
||||||
|
if hasattr(temp_author, key):
|
||||||
|
setattr(temp_author, key, value)
|
||||||
|
# Добавляем отфильтрованную версию
|
||||||
|
followers.append(temp_author.dict(current_user_id, is_admin))
|
||||||
|
|
||||||
return followers
|
return followers
|
||||||
|
|
|
@ -71,7 +71,7 @@ async def create_invite(_, info, slug: str = "", author_id: int = 0):
|
||||||
# Check if the inviter is the owner of the shout
|
# Check if the inviter is the owner of the shout
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
shout = session.query(Shout).filter(Shout.slug == slug).first()
|
shout = session.query(Shout).filter(Shout.slug == slug).first()
|
||||||
inviter = session.query(Author).filter(Author.user == user_id).first()
|
inviter = session.query(Author).filter(Author.id == user_id).first()
|
||||||
if inviter and shout and shout.authors and inviter.id is shout.created_by:
|
if inviter and shout and shout.authors and inviter.id is shout.created_by:
|
||||||
# Check if an invite already exists
|
# Check if an invite already exists
|
||||||
existing_invite = (
|
existing_invite = (
|
||||||
|
@ -109,7 +109,7 @@ async def create_invite(_, info, slug: str = "", author_id: int = 0):
|
||||||
async def remove_author(_, info, slug: str = "", author_id: int = 0):
|
async def remove_author(_, info, slug: str = "", author_id: int = 0):
|
||||||
user_id = info.context["user_id"]
|
user_id = info.context["user_id"]
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
author = session.query(Author).filter(Author.user == user_id).first()
|
author = session.query(Author).filter(Author.id == user_id).first()
|
||||||
if author:
|
if author:
|
||||||
shout = session.query(Shout).filter(Shout.slug == slug).first()
|
shout = session.query(Shout).filter(Shout.slug == slug).first()
|
||||||
# NOTE: owner should be first in a list
|
# NOTE: owner should be first in a list
|
||||||
|
|
|
@ -23,7 +23,7 @@ async def get_communities_by_author(_, _info, slug="", user="", author_id=0):
|
||||||
author_id = session.query(Author).where(Author.slug == slug).first().id
|
author_id = session.query(Author).where(Author.slug == slug).first().id
|
||||||
q = q.where(CommunityFollower.author == author_id)
|
q = q.where(CommunityFollower.author == author_id)
|
||||||
if user:
|
if user:
|
||||||
author_id = session.query(Author).where(Author.user == user).first().id
|
author_id = session.query(Author).where(Author.id == user).first().id
|
||||||
q = q.where(CommunityFollower.author == author_id)
|
q = q.where(CommunityFollower.author == author_id)
|
||||||
if author_id:
|
if author_id:
|
||||||
q = q.where(CommunityFollower.author == author_id)
|
q = q.where(CommunityFollower.author == author_id)
|
||||||
|
|
|
@ -643,7 +643,7 @@ async def delete_shout(_, info, shout_id: int):
|
||||||
for author in shout.authors:
|
for author in shout.authors:
|
||||||
await cache_by_id(Author, author.id, cache_author)
|
await cache_by_id(Author, author.id, cache_author)
|
||||||
info.context["author"] = author.dict()
|
info.context["author"] = author.dict()
|
||||||
info.context["user_id"] = author.user
|
info.context["user_id"] = author.id
|
||||||
unfollow(None, info, "shout", shout.slug)
|
unfollow(None, info, "shout", shout.slug)
|
||||||
|
|
||||||
for topic in shout.topics:
|
for topic in shout.topics:
|
||||||
|
|
|
@ -63,7 +63,14 @@ async def follow(_, info, what, slug="", entity_id=0):
|
||||||
return {"error": f"{what.lower()} not found"}
|
return {"error": f"{what.lower()} not found"}
|
||||||
if not entity_id and entity:
|
if not entity_id and entity:
|
||||||
entity_id = entity.id
|
entity_id = entity.id
|
||||||
entity_dict = entity.dict()
|
|
||||||
|
# Если это автор, учитываем фильтрацию данных
|
||||||
|
if what == "AUTHOR":
|
||||||
|
# Полная версия для кэширования
|
||||||
|
entity_dict = entity.dict(is_admin=True)
|
||||||
|
else:
|
||||||
|
entity_dict = entity.dict()
|
||||||
|
|
||||||
logger.debug(f"entity_id: {entity_id}, entity_dict: {entity_dict}")
|
logger.debug(f"entity_id: {entity_id}, entity_dict: {entity_dict}")
|
||||||
|
|
||||||
if entity_id:
|
if entity_id:
|
||||||
|
@ -96,7 +103,35 @@ async def follow(_, info, what, slug="", entity_id=0):
|
||||||
if get_cached_follows_method:
|
if get_cached_follows_method:
|
||||||
logger.debug("Получение подписок из кэша")
|
logger.debug("Получение подписок из кэша")
|
||||||
existing_follows = await get_cached_follows_method(follower_id)
|
existing_follows = await get_cached_follows_method(follower_id)
|
||||||
follows = [*existing_follows, entity_dict] if not existing_sub else existing_follows
|
|
||||||
|
# Если это авторы, получаем безопасную версию
|
||||||
|
if what == "AUTHOR":
|
||||||
|
# Получаем ID текущего пользователя и фильтруем данные
|
||||||
|
current_user_id = user_id
|
||||||
|
follows_filtered = []
|
||||||
|
|
||||||
|
for author_data in existing_follows:
|
||||||
|
# Создаем объект автора для использования метода dict
|
||||||
|
temp_author = Author()
|
||||||
|
for key, value in author_data.items():
|
||||||
|
if hasattr(temp_author, key):
|
||||||
|
setattr(temp_author, key, value)
|
||||||
|
# Добавляем отфильтрованную версию
|
||||||
|
follows_filtered.append(temp_author.dict(current_user_id, False))
|
||||||
|
|
||||||
|
if not existing_sub:
|
||||||
|
# Создаем объект автора для entity_dict
|
||||||
|
temp_author = Author()
|
||||||
|
for key, value in entity_dict.items():
|
||||||
|
if hasattr(temp_author, key):
|
||||||
|
setattr(temp_author, key, value)
|
||||||
|
# Добавляем отфильтрованную версию
|
||||||
|
follows = [*follows_filtered, temp_author.dict(current_user_id, False)]
|
||||||
|
else:
|
||||||
|
follows = follows_filtered
|
||||||
|
else:
|
||||||
|
follows = [*existing_follows, entity_dict] if not existing_sub else existing_follows
|
||||||
|
|
||||||
logger.debug("Обновлен список подписок")
|
logger.debug("Обновлен список подписок")
|
||||||
|
|
||||||
if what == "AUTHOR" and not existing_sub:
|
if what == "AUTHOR" and not existing_sub:
|
||||||
|
@ -171,11 +206,38 @@ async def unfollow(_, info, what, slug="", entity_id=0):
|
||||||
|
|
||||||
if cache_method:
|
if cache_method:
|
||||||
logger.debug("Обновление кэша после отписки")
|
logger.debug("Обновление кэша после отписки")
|
||||||
await cache_method(entity.dict())
|
# Если это автор, кэшируем полную версию
|
||||||
|
if what == "AUTHOR":
|
||||||
|
await cache_method(entity.dict(is_admin=True))
|
||||||
|
else:
|
||||||
|
await cache_method(entity.dict())
|
||||||
|
|
||||||
if get_cached_follows_method:
|
if get_cached_follows_method:
|
||||||
logger.debug("Получение подписок из кэша")
|
logger.debug("Получение подписок из кэша")
|
||||||
existing_follows = await get_cached_follows_method(follower_id)
|
existing_follows = await get_cached_follows_method(follower_id)
|
||||||
follows = filter(lambda x: x["id"] != entity_id, existing_follows)
|
|
||||||
|
# Если это авторы, получаем безопасную версию
|
||||||
|
if what == "AUTHOR":
|
||||||
|
# Получаем ID текущего пользователя и фильтруем данные
|
||||||
|
current_user_id = user_id
|
||||||
|
follows_filtered = []
|
||||||
|
|
||||||
|
for author_data in existing_follows:
|
||||||
|
if author_data["id"] == entity_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Создаем объект автора для использования метода dict
|
||||||
|
temp_author = Author()
|
||||||
|
for key, value in author_data.items():
|
||||||
|
if hasattr(temp_author, key):
|
||||||
|
setattr(temp_author, key, value)
|
||||||
|
# Добавляем отфильтрованную версию
|
||||||
|
follows_filtered.append(temp_author.dict(current_user_id, False))
|
||||||
|
|
||||||
|
follows = follows_filtered
|
||||||
|
else:
|
||||||
|
follows = [item for item in existing_follows if item["id"] != entity_id]
|
||||||
|
|
||||||
logger.debug("Обновлен список подписок")
|
logger.debug("Обновлен список подписок")
|
||||||
|
|
||||||
if what == "AUTHOR":
|
if what == "AUTHOR":
|
||||||
|
|
|
@ -215,7 +215,7 @@ async def set_featured(session, shout_id):
|
||||||
session.commit()
|
session.commit()
|
||||||
author = session.query(Author).filter(Author.id == s.created_by).first()
|
author = session.query(Author).filter(Author.id == s.created_by).first()
|
||||||
if author:
|
if author:
|
||||||
await add_user_role(str(author.user))
|
await add_user_role(str(author.id))
|
||||||
session.add(s)
|
session.add(s)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
@ -446,7 +446,7 @@ async def delete_reaction(_, info, reaction_id: int):
|
||||||
|
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
try:
|
try:
|
||||||
author = session.query(Author).filter(Author.user == user_id).one()
|
author = session.query(Author).filter(Author.id == user_id).one()
|
||||||
r = session.query(Reaction).filter(Reaction.id == reaction_id).one()
|
r = session.query(Reaction).filter(Reaction.id == reaction_id).one()
|
||||||
|
|
||||||
if r.created_by != author_id and "editor" not in roles:
|
if r.created_by != author_id and "editor" not in roles:
|
||||||
|
|
|
@ -255,7 +255,7 @@ async def get_topics_by_author(_, _info, author_id=0, slug="", user=""):
|
||||||
elif slug:
|
elif slug:
|
||||||
topics_by_author_query = topics_by_author_query.join(Author).where(Author.slug == slug)
|
topics_by_author_query = topics_by_author_query.join(Author).where(Author.slug == slug)
|
||||||
elif user:
|
elif user:
|
||||||
topics_by_author_query = topics_by_author_query.join(Author).where(Author.user == user)
|
topics_by_author_query = topics_by_author_query.join(Author).where(Author.id == user)
|
||||||
|
|
||||||
return get_with_stat(topics_by_author_query)
|
return get_with_stat(topics_by_author_query)
|
||||||
|
|
||||||
|
@ -320,7 +320,7 @@ async def delete_topic(_, info, slug: str):
|
||||||
t: Topic = session.query(Topic).filter(Topic.slug == slug).first()
|
t: Topic = session.query(Topic).filter(Topic.slug == slug).first()
|
||||||
if not t:
|
if not t:
|
||||||
return {"error": "invalid topic slug"}
|
return {"error": "invalid topic slug"}
|
||||||
author = session.query(Author).filter(Author.user == user_id).first()
|
author = session.query(Author).filter(Author.id == user_id).first()
|
||||||
if author:
|
if author:
|
||||||
if t.created_by != author.id:
|
if t.created_by != author.id:
|
||||||
return {"error": "access denied"}
|
return {"error": "access denied"}
|
||||||
|
|
|
@ -27,15 +27,11 @@ type AdminUserInfo {
|
||||||
roles: [String!]
|
roles: [String!]
|
||||||
created_at: Int
|
created_at: Int
|
||||||
last_seen: Int
|
last_seen: Int
|
||||||
muted: Boolean
|
|
||||||
is_active: Boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input AdminUserUpdateInput {
|
input AdminUserUpdateInput {
|
||||||
id: Int!
|
id: Int!
|
||||||
roles: [String!]
|
roles: [String!]
|
||||||
muted: Boolean
|
|
||||||
is_active: Boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Role {
|
type Role {
|
||||||
|
@ -66,6 +62,4 @@ extend type Mutation {
|
||||||
|
|
||||||
# Мутации для управления пользователями
|
# Мутации для управления пользователями
|
||||||
adminUpdateUser(user: AdminUserUpdateInput!): Boolean!
|
adminUpdateUser(user: AdminUserUpdateInput!): Boolean!
|
||||||
adminToggleUserBlock(userId: Int!): Boolean!
|
|
||||||
adminToggleUserMute(userId: Int!): Boolean!
|
|
||||||
}
|
}
|
|
@ -12,9 +12,8 @@ type AuthorStat {
|
||||||
|
|
||||||
type Author {
|
type Author {
|
||||||
id: Int!
|
id: Int!
|
||||||
user: String! # user.id
|
slug: String!
|
||||||
slug: String! # user.nickname
|
name: String
|
||||||
name: String # user.preferred_username
|
|
||||||
pic: String
|
pic: String
|
||||||
bio: String
|
bio: String
|
||||||
about: String
|
about: String
|
||||||
|
@ -25,10 +24,8 @@ type Author {
|
||||||
deleted_at: Int
|
deleted_at: Int
|
||||||
email: String
|
email: String
|
||||||
seo: String
|
seo: String
|
||||||
# synthetic
|
|
||||||
stat: AuthorStat # ratings inside
|
stat: AuthorStat # ratings inside
|
||||||
communities: [Community]
|
communities: [Community]
|
||||||
# Auth fields
|
|
||||||
roles: [String!]
|
roles: [String!]
|
||||||
email_verified: Boolean
|
email_verified: Boolean
|
||||||
}
|
}
|
||||||
|
|
118
services/auth.py
118
services/auth.py
|
@ -13,7 +13,7 @@ from auth.orm import Author, Role
|
||||||
ALLOWED_HEADERS = ["Authorization", "Content-Type"]
|
ALLOWED_HEADERS = ["Authorization", "Content-Type"]
|
||||||
|
|
||||||
|
|
||||||
async def check_auth(req) -> Tuple[str, list[str]]:
|
async def check_auth(req) -> Tuple[str, list[str], bool]:
|
||||||
"""
|
"""
|
||||||
Проверка авторизации пользователя.
|
Проверка авторизации пользователя.
|
||||||
|
|
||||||
|
@ -25,11 +25,12 @@ async def check_auth(req) -> Tuple[str, list[str]]:
|
||||||
Возвращает:
|
Возвращает:
|
||||||
- user_id: str - Идентификатор пользователя
|
- user_id: str - Идентификатор пользователя
|
||||||
- user_roles: list[str] - Список ролей пользователя
|
- user_roles: list[str] - Список ролей пользователя
|
||||||
|
- is_admin: bool - Флаг наличия у пользователя административных прав
|
||||||
"""
|
"""
|
||||||
# Проверяем наличие токена
|
# Проверяем наличие токена
|
||||||
token = req.headers.get("Authorization")
|
token = req.headers.get("Authorization")
|
||||||
if not token:
|
if not token:
|
||||||
return "", []
|
return "", [], False
|
||||||
|
|
||||||
# Очищаем токен от префикса Bearer если он есть
|
# Очищаем токен от префикса Bearer если он есть
|
||||||
if token.startswith("Bearer "):
|
if token.startswith("Bearer "):
|
||||||
|
@ -39,8 +40,39 @@ async def check_auth(req) -> Tuple[str, list[str]]:
|
||||||
|
|
||||||
# Проверяем авторизацию внутренним механизмом
|
# Проверяем авторизацию внутренним механизмом
|
||||||
logger.debug("Using internal authentication")
|
logger.debug("Using internal authentication")
|
||||||
return await verify_internal_auth(token)
|
user_id, user_roles = await verify_internal_auth(token)
|
||||||
|
|
||||||
|
# Проверяем наличие административных прав у пользователя
|
||||||
|
is_admin = False
|
||||||
|
if user_id:
|
||||||
|
# Быстрая проверка на админ роли в кэше
|
||||||
|
admin_roles = ['admin', 'super']
|
||||||
|
for role in user_roles:
|
||||||
|
if role in admin_roles:
|
||||||
|
is_admin = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Если в ролях нет админа, но есть ID - проверяем в БД
|
||||||
|
if not is_admin:
|
||||||
|
try:
|
||||||
|
with local_session() as session:
|
||||||
|
# Преобразуем user_id в число
|
||||||
|
try:
|
||||||
|
user_id_int = int(user_id.strip())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.error(f"Невозможно преобразовать user_id {user_id} в число")
|
||||||
|
else:
|
||||||
|
# Проверяем наличие админских прав через БД
|
||||||
|
from auth.orm import AuthorRole
|
||||||
|
admin_role = session.query(AuthorRole).filter(
|
||||||
|
AuthorRole.author == user_id_int,
|
||||||
|
AuthorRole.role.in_(["admin", "super"])
|
||||||
|
).first()
|
||||||
|
is_admin = admin_role is not None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при проверке прав администратора: {e}")
|
||||||
|
|
||||||
|
return user_id, user_roles, is_admin
|
||||||
|
|
||||||
async def add_user_role(user_id: str, roles: list[str] = None):
|
async def add_user_role(user_id: str, roles: list[str] = None):
|
||||||
"""
|
"""
|
||||||
|
@ -84,21 +116,36 @@ async def add_user_role(user_id: str, roles: list[str] = None):
|
||||||
|
|
||||||
|
|
||||||
def login_required(f):
|
def login_required(f):
|
||||||
"""Декоратор для проверки авторизации пользователя."""
|
"""Декоратор для проверки авторизации пользователя. Требуется наличие роли 'reader'."""
|
||||||
|
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
async def decorated_function(*args, **kwargs):
|
async def decorated_function(*args, **kwargs):
|
||||||
|
from graphql.error import GraphQLError
|
||||||
|
|
||||||
info = args[1]
|
info = args[1]
|
||||||
req = info.context.get("request")
|
req = info.context.get("request")
|
||||||
user_id, user_roles = await check_auth(req)
|
user_id, user_roles, is_admin = await check_auth(req)
|
||||||
if user_id and user_roles:
|
|
||||||
logger.info(f" got {user_id} roles: {user_roles}")
|
if not user_id:
|
||||||
info.context["user_id"] = user_id.strip()
|
raise GraphQLError("Требуется авторизация")
|
||||||
info.context["roles"] = user_roles
|
|
||||||
author = await get_cached_author_by_user_id(user_id, get_with_stat)
|
# Проверяем наличие роли reader
|
||||||
if not author:
|
if 'reader' not in user_roles and not is_admin:
|
||||||
logger.error(f"author profile not found for user {user_id}")
|
logger.error(f"Пользователь {user_id} не имеет роли 'reader'")
|
||||||
info.context["author"] = author
|
raise GraphQLError("У вас нет необходимых прав для доступа")
|
||||||
|
|
||||||
|
logger.info(f"Авторизован пользователь {user_id} с ролями: {user_roles}")
|
||||||
|
info.context["user_id"] = user_id.strip()
|
||||||
|
info.context["roles"] = user_roles
|
||||||
|
|
||||||
|
# Проверяем права администратора
|
||||||
|
info.context["is_admin"] = is_admin
|
||||||
|
|
||||||
|
author = await get_cached_author_by_user_id(user_id, get_with_stat)
|
||||||
|
if not author:
|
||||||
|
logger.error(f"Профиль автора не найден для пользователя {user_id}")
|
||||||
|
info.context["author"] = author
|
||||||
|
|
||||||
return await f(*args, **kwargs)
|
return await f(*args, **kwargs)
|
||||||
|
|
||||||
return decorated_function
|
return decorated_function
|
||||||
|
@ -113,7 +160,7 @@ def login_accepted(f):
|
||||||
req = info.context.get("request")
|
req = info.context.get("request")
|
||||||
|
|
||||||
logger.debug("login_accepted: Проверка авторизации пользователя.")
|
logger.debug("login_accepted: Проверка авторизации пользователя.")
|
||||||
user_id, user_roles = await check_auth(req)
|
user_id, user_roles, is_admin = await check_auth(req)
|
||||||
logger.debug(f"login_accepted: user_id={user_id}, user_roles={user_roles}")
|
logger.debug(f"login_accepted: user_id={user_id}, user_roles={user_roles}")
|
||||||
|
|
||||||
if user_id and user_roles:
|
if user_id and user_roles:
|
||||||
|
@ -121,11 +168,16 @@ def login_accepted(f):
|
||||||
info.context["user_id"] = user_id.strip()
|
info.context["user_id"] = user_id.strip()
|
||||||
info.context["roles"] = user_roles
|
info.context["roles"] = user_roles
|
||||||
|
|
||||||
|
# Проверяем права администратора
|
||||||
|
info.context["is_admin"] = is_admin
|
||||||
|
|
||||||
# Пробуем получить профиль автора
|
# Пробуем получить профиль автора
|
||||||
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}")
|
||||||
info.context["author"] = author.dict()
|
# Используем флаг is_admin из контекста или передаем права владельца для собственных данных
|
||||||
|
is_owner = True # Пользователь всегда является владельцем собственного профиля
|
||||||
|
info.context["author"] = author.dict(access=is_owner or is_admin)
|
||||||
else:
|
else:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"login_accepted: Профиль автора не найден для пользователя {user_id}. Используем базовые данные."
|
f"login_accepted: Профиль автора не найден для пользователя {user_id}. Используем базовые данные."
|
||||||
|
@ -135,6 +187,42 @@ def login_accepted(f):
|
||||||
info.context["user_id"] = None
|
info.context["user_id"] = None
|
||||||
info.context["roles"] = None
|
info.context["roles"] = None
|
||||||
info.context["author"] = None
|
info.context["author"] = None
|
||||||
|
info.context["is_admin"] = False
|
||||||
|
|
||||||
|
return await f(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
def author_required(f):
|
||||||
|
"""Декоратор для проверки наличия роли 'author' у пользователя."""
|
||||||
|
|
||||||
|
@wraps(f)
|
||||||
|
async def decorated_function(*args, **kwargs):
|
||||||
|
from graphql.error import GraphQLError
|
||||||
|
|
||||||
|
info = args[1]
|
||||||
|
req = info.context.get("request")
|
||||||
|
user_id, user_roles, is_admin = await check_auth(req)
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
raise GraphQLError("Требуется авторизация")
|
||||||
|
|
||||||
|
# Проверяем наличие роли author
|
||||||
|
if 'author' not in user_roles and not is_admin:
|
||||||
|
logger.error(f"Пользователь {user_id} не имеет роли 'author'")
|
||||||
|
raise GraphQLError("Для выполнения этого действия необходимы права автора")
|
||||||
|
|
||||||
|
logger.info(f"Авторизован автор {user_id} с ролями: {user_roles}")
|
||||||
|
info.context["user_id"] = user_id.strip()
|
||||||
|
info.context["roles"] = user_roles
|
||||||
|
|
||||||
|
# Проверяем права администратора
|
||||||
|
info.context["is_admin"] = is_admin
|
||||||
|
|
||||||
|
author = await get_cached_author_by_user_id(user_id, get_with_stat)
|
||||||
|
if not author:
|
||||||
|
logger.error(f"Профиль автора не найден для пользователя {user_id}")
|
||||||
|
info.context["author"] = author
|
||||||
|
|
||||||
return await f(*args, **kwargs)
|
return await f(*args, **kwargs)
|
||||||
|
|
||||||
|
|
328
services/env.py
328
services/env.py
|
@ -1,7 +1,10 @@
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional, Set
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
from redis import Redis
|
from redis import Redis
|
||||||
from settings import REDIS_URL
|
from settings import REDIS_URL, ROOT_DIR
|
||||||
from utils.logger import root_logger as logger
|
from utils.logger import root_logger as logger
|
||||||
|
|
||||||
|
|
||||||
|
@ -23,85 +26,326 @@ class EnvSection:
|
||||||
|
|
||||||
class EnvManager:
|
class EnvManager:
|
||||||
"""
|
"""
|
||||||
Менеджер переменных окружения с хранением в Redis
|
Менеджер переменных окружения с хранением в Redis и синхронизацией с .env файлом
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Стандартные переменные окружения, которые следует исключить
|
||||||
|
EXCLUDED_ENV_VARS: Set[str] = {
|
||||||
|
"PATH", "SHELL", "USER", "HOME", "PWD", "TERM", "LANG",
|
||||||
|
"PYTHONPATH", "_", "TMPDIR", "TERM_PROGRAM", "TERM_SESSION_ID",
|
||||||
|
"XPC_SERVICE_NAME", "XPC_FLAGS", "SHLVL", "SECURITYSESSIONID",
|
||||||
|
"LOGNAME", "OLDPWD", "ZSH", "PAGER", "LESS", "LC_CTYPE", "LSCOLORS",
|
||||||
|
"SSH_AUTH_SOCK", "DISPLAY", "COLORTERM", "EDITOR", "VISUAL",
|
||||||
|
"PYTHONDONTWRITEBYTECODE", "VIRTUAL_ENV", "PYTHONUNBUFFERED"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Секции для группировки переменных
|
||||||
|
SECTIONS = {
|
||||||
|
"AUTH": {
|
||||||
|
"pattern": r"^(JWT|AUTH|SESSION|OAUTH|GITHUB|GOOGLE|FACEBOOK)_",
|
||||||
|
"name": "Авторизация",
|
||||||
|
"description": "Настройки системы авторизации"
|
||||||
|
},
|
||||||
|
"DATABASE": {
|
||||||
|
"pattern": r"^(DB|DATABASE|POSTGRES|MYSQL|SQL)_",
|
||||||
|
"name": "База данных",
|
||||||
|
"description": "Настройки подключения к базам данных"
|
||||||
|
},
|
||||||
|
"CACHE": {
|
||||||
|
"pattern": r"^(REDIS|CACHE|MEMCACHED)_",
|
||||||
|
"name": "Кэширование",
|
||||||
|
"description": "Настройки систем кэширования"
|
||||||
|
},
|
||||||
|
"SEARCH": {
|
||||||
|
"pattern": r"^(ELASTIC|SEARCH|OPENSEARCH)_",
|
||||||
|
"name": "Поиск",
|
||||||
|
"description": "Настройки поисковых систем"
|
||||||
|
},
|
||||||
|
"APP": {
|
||||||
|
"pattern": r"^(APP|PORT|HOST|DEBUG|DOMAIN|ENVIRONMENT|ENV|FRONTEND)_",
|
||||||
|
"name": "Приложение",
|
||||||
|
"description": "Основные настройки приложения"
|
||||||
|
},
|
||||||
|
"LOGGING": {
|
||||||
|
"pattern": r"^(LOG|LOGGING|SENTRY|GLITCH|GLITCHTIP)_",
|
||||||
|
"name": "Логирование",
|
||||||
|
"description": "Настройки логирования и мониторинга"
|
||||||
|
},
|
||||||
|
"EMAIL": {
|
||||||
|
"pattern": r"^(MAIL|EMAIL|SMTP)_",
|
||||||
|
"name": "Электронная почта",
|
||||||
|
"description": "Настройки отправки электронной почты"
|
||||||
|
},
|
||||||
|
"ANALYTICS": {
|
||||||
|
"pattern": r"^(GA|GOOGLE_ANALYTICS|ANALYTICS)_",
|
||||||
|
"name": "Аналитика",
|
||||||
|
"description": "Настройки систем аналитики"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Переменные, которые следует всегда помечать как секретные
|
||||||
|
SECRET_VARS_PATTERNS = [
|
||||||
|
r".*TOKEN.*", r".*SECRET.*", r".*PASSWORD.*", r".*KEY.*",
|
||||||
|
r".*PWD.*", r".*PASS.*", r".*CRED.*"
|
||||||
|
]
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.redis = Redis.from_url(REDIS_URL)
|
self.redis = Redis.from_url(REDIS_URL)
|
||||||
self.prefix = "env:"
|
self.prefix = "env:"
|
||||||
|
self.env_file_path = os.path.join(ROOT_DIR, '.env')
|
||||||
|
|
||||||
def get_all_variables(self) -> List[EnvSection]:
|
def get_all_variables(self) -> List[EnvSection]:
|
||||||
"""
|
"""
|
||||||
Получение всех переменных окружения, сгруппированных по секциям
|
Получение всех переменных окружения, сгруппированных по секциям
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Получаем все ключи с префиксом env:
|
# Получаем все переменные окружения из системы
|
||||||
keys = self.redis.keys(f"{self.prefix}*")
|
system_env = self._get_system_env_vars()
|
||||||
variables: Dict[str, str] = {}
|
|
||||||
|
|
||||||
for key in keys:
|
# Получаем переменные из .env файла, если он существует
|
||||||
var_key = key.decode("utf-8").replace(self.prefix, "")
|
dotenv_vars = self._get_dotenv_vars()
|
||||||
value = self.redis.get(key)
|
|
||||||
if value:
|
# Получаем все переменные из Redis
|
||||||
variables[var_key] = value.decode("utf-8")
|
redis_vars = self._get_redis_env_vars()
|
||||||
|
|
||||||
|
# Объединяем переменные, при этом redis_vars имеют наивысший приоритет,
|
||||||
|
# за ними следуют переменные из .env, затем системные
|
||||||
|
env_vars = {**system_env, **dotenv_vars, **redis_vars}
|
||||||
|
|
||||||
# Группируем переменные по секциям
|
# Группируем переменные по секциям
|
||||||
sections = [
|
return self._group_variables_by_sections(env_vars)
|
||||||
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:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка получения переменных: {e}")
|
logger.error(f"Ошибка получения переменных: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def _get_system_env_vars(self) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Получает переменные окружения из системы, исключая стандартные
|
||||||
|
"""
|
||||||
|
env_vars = {}
|
||||||
|
for key, value in os.environ.items():
|
||||||
|
# Пропускаем стандартные переменные
|
||||||
|
if key in self.EXCLUDED_ENV_VARS:
|
||||||
|
continue
|
||||||
|
# Пропускаем переменные с пустыми значениями
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
env_vars[key] = value
|
||||||
|
return env_vars
|
||||||
|
|
||||||
|
def _get_dotenv_vars(self) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Получает переменные из .env файла, если он существует
|
||||||
|
"""
|
||||||
|
env_vars = {}
|
||||||
|
if os.path.exists(self.env_file_path):
|
||||||
|
try:
|
||||||
|
with open(self.env_file_path, 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
# Пропускаем пустые строки и комментарии
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
continue
|
||||||
|
# Разделяем строку на ключ и значение
|
||||||
|
if '=' in line:
|
||||||
|
key, value = line.split('=', 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip()
|
||||||
|
# Удаляем кавычки, если они есть
|
||||||
|
if value.startswith('"') and value.endswith('"'):
|
||||||
|
value = value[1:-1]
|
||||||
|
env_vars[key] = value
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка чтения .env файла: {e}")
|
||||||
|
return env_vars
|
||||||
|
|
||||||
|
def _get_redis_env_vars(self) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Получает переменные окружения из Redis
|
||||||
|
"""
|
||||||
|
redis_vars = {}
|
||||||
|
try:
|
||||||
|
# Получаем все ключи с префиксом env:
|
||||||
|
keys = self.redis.keys(f"{self.prefix}*")
|
||||||
|
for key in keys:
|
||||||
|
var_key = key.decode("utf-8").replace(self.prefix, "")
|
||||||
|
value = self.redis.get(key)
|
||||||
|
if value:
|
||||||
|
redis_vars[var_key] = value.decode("utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка получения переменных из Redis: {e}")
|
||||||
|
return redis_vars
|
||||||
|
|
||||||
|
def _is_secret_variable(self, key: str) -> bool:
|
||||||
|
"""
|
||||||
|
Проверяет, является ли переменная секретной
|
||||||
|
"""
|
||||||
|
key_upper = key.upper()
|
||||||
|
return any(re.match(pattern, key_upper) for pattern in self.SECRET_VARS_PATTERNS)
|
||||||
|
|
||||||
|
def _determine_variable_type(self, value: str) -> str:
|
||||||
|
"""
|
||||||
|
Определяет тип переменной на основе ее значения
|
||||||
|
"""
|
||||||
|
if value.lower() in ('true', 'false'):
|
||||||
|
return "boolean"
|
||||||
|
if value.isdigit():
|
||||||
|
return "integer"
|
||||||
|
if re.match(r"^\d+\.\d+$", value):
|
||||||
|
return "float"
|
||||||
|
# Проверяем на JSON объект или массив
|
||||||
|
if (value.startswith('{') and value.endswith('}')) or (value.startswith('[') and value.endswith(']')):
|
||||||
|
return "json"
|
||||||
|
# Проверяем на URL
|
||||||
|
if value.startswith(('http://', 'https://', 'redis://', 'postgresql://')):
|
||||||
|
return "url"
|
||||||
|
return "string"
|
||||||
|
|
||||||
|
def _group_variables_by_sections(self, variables: Dict[str, str]) -> List[EnvSection]:
|
||||||
|
"""
|
||||||
|
Группирует переменные по секциям
|
||||||
|
"""
|
||||||
|
# Создаем словарь для группировки переменных
|
||||||
|
sections_dict = {section: [] for section in self.SECTIONS}
|
||||||
|
other_variables = [] # Для переменных, которые не попали ни в одну секцию
|
||||||
|
|
||||||
|
# Распределяем переменные по секциям
|
||||||
|
for key, value in variables.items():
|
||||||
|
is_secret = self._is_secret_variable(key)
|
||||||
|
var_type = self._determine_variable_type(value)
|
||||||
|
|
||||||
|
var = EnvVariable(
|
||||||
|
key=key,
|
||||||
|
value=value,
|
||||||
|
type=var_type,
|
||||||
|
is_secret=is_secret
|
||||||
|
)
|
||||||
|
|
||||||
|
# Определяем секцию для переменной
|
||||||
|
placed = False
|
||||||
|
for section_id, section_config in self.SECTIONS.items():
|
||||||
|
if re.match(section_config["pattern"], key, re.IGNORECASE):
|
||||||
|
sections_dict[section_id].append(var)
|
||||||
|
placed = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Если переменная не попала ни в одну секцию
|
||||||
|
if not placed:
|
||||||
|
other_variables.append(var)
|
||||||
|
|
||||||
|
# Формируем результат
|
||||||
|
result = []
|
||||||
|
for section_id, variables in sections_dict.items():
|
||||||
|
if variables: # Добавляем только непустые секции
|
||||||
|
section_config = self.SECTIONS[section_id]
|
||||||
|
result.append(
|
||||||
|
EnvSection(
|
||||||
|
name=section_config["name"],
|
||||||
|
description=section_config["description"],
|
||||||
|
variables=variables
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Добавляем прочие переменные, если они есть
|
||||||
|
if other_variables:
|
||||||
|
result.append(
|
||||||
|
EnvSection(
|
||||||
|
name="Прочие переменные",
|
||||||
|
description="Переменные, не вошедшие в основные категории",
|
||||||
|
variables=other_variables
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def update_variable(self, key: str, value: str) -> bool:
|
def update_variable(self, key: str, value: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Обновление значения переменной
|
Обновление значения переменной в Redis и .env файле
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Сохраняем в Redis
|
||||||
full_key = f"{self.prefix}{key}"
|
full_key = f"{self.prefix}{key}"
|
||||||
self.redis.set(full_key, value)
|
self.redis.set(full_key, value)
|
||||||
|
|
||||||
|
# Обновляем значение в .env файле
|
||||||
|
self._update_dotenv_var(key, value)
|
||||||
|
|
||||||
|
# Обновляем переменную в текущем процессе
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка обновления переменной {key}: {e}")
|
logger.error(f"Ошибка обновления переменной {key}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _update_dotenv_var(self, key: str, value: str) -> bool:
|
||||||
|
"""
|
||||||
|
Обновляет переменную в .env файле
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Если файл .env не существует, создаем его
|
||||||
|
if not os.path.exists(self.env_file_path):
|
||||||
|
with open(self.env_file_path, 'w') as f:
|
||||||
|
f.write(f"{key}={value}\n")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Если файл существует, читаем его содержимое
|
||||||
|
lines = []
|
||||||
|
found = False
|
||||||
|
|
||||||
|
with open(self.env_file_path, 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
if line.strip() and not line.strip().startswith('#'):
|
||||||
|
if line.strip().startswith(f"{key}="):
|
||||||
|
# Экранируем значение, если необходимо
|
||||||
|
if ' ' in value or ',' in value or '"' in value or "'" in value:
|
||||||
|
escaped_value = f'"{value}"'
|
||||||
|
else:
|
||||||
|
escaped_value = value
|
||||||
|
lines.append(f"{key}={escaped_value}\n")
|
||||||
|
found = True
|
||||||
|
else:
|
||||||
|
lines.append(line)
|
||||||
|
else:
|
||||||
|
lines.append(line)
|
||||||
|
|
||||||
|
# Если переменной не было в файле, добавляем ее
|
||||||
|
if not found:
|
||||||
|
# Экранируем значение, если необходимо
|
||||||
|
if ' ' in value or ',' in value or '"' in value or "'" in value:
|
||||||
|
escaped_value = f'"{value}"'
|
||||||
|
else:
|
||||||
|
escaped_value = value
|
||||||
|
lines.append(f"{key}={escaped_value}\n")
|
||||||
|
|
||||||
|
# Записываем обновленный файл
|
||||||
|
with open(self.env_file_path, 'w') as f:
|
||||||
|
f.writelines(lines)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка обновления .env файла: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def update_variables(self, variables: List[EnvVariable]) -> bool:
|
def update_variables(self, variables: List[EnvVariable]) -> bool:
|
||||||
"""
|
"""
|
||||||
Массовое обновление переменных
|
Массовое обновление переменных
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Обновляем переменные в Redis
|
||||||
pipe = self.redis.pipeline()
|
pipe = self.redis.pipeline()
|
||||||
for var in variables:
|
for var in variables:
|
||||||
full_key = f"{self.prefix}{var.key}"
|
full_key = f"{self.prefix}{var.key}"
|
||||||
pipe.set(full_key, var.value)
|
pipe.set(full_key, var.value)
|
||||||
pipe.execute()
|
pipe.execute()
|
||||||
|
|
||||||
|
# Обновляем переменные в .env файле
|
||||||
|
for var in variables:
|
||||||
|
self._update_dotenv_var(var.key, var.value)
|
||||||
|
|
||||||
|
# Обновляем переменную в текущем процессе
|
||||||
|
os.environ[var.key] = var.value
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка массового обновления переменных: {e}")
|
logger.error(f"Ошибка массового обновления переменных: {e}")
|
||||||
|
|
|
@ -3,6 +3,10 @@
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from os import environ
|
from os import environ
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Корневая директория проекта
|
||||||
|
ROOT_DIR = Path(__file__).parent.absolute()
|
||||||
|
|
||||||
DEV_SERVER_PID_FILE_NAME = "dev-server.pid"
|
DEV_SERVER_PID_FILE_NAME = "dev-server.pid"
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user