auth-wip
This commit is contained in:
parent
1d64811880
commit
d3a760b6ba
184
CHANGELOG.md
184
CHANGELOG.md
|
@ -1,150 +1,66 @@
|
|||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Изменено
|
||||
- Радикально упрощена структура клиентской части приложения:
|
||||
- Удалены все избыточные файлы и директории
|
||||
- Перемещены модули auth.ts и api.ts из директории client/lib в корень директории client
|
||||
- Обновлены импорты во всех компонентах для использования модулей из корня директории
|
||||
- Создана минималистичная архитектура с 5 файлами (App, login, admin, auth, api)
|
||||
- Следование принципу DRY - устранено дублирование кода
|
||||
- Выделены общие модули для авторизации и работы с API
|
||||
- Единый стиль кода и документации для всех компонентов
|
||||
- Устранены все жесткие редиректы в пользу SolidJS Router
|
||||
- Упрощена структура проекта для лучшей поддерживаемости
|
||||
- Упрощена структура клиентской части приложения:
|
||||
- Оставлены только два основных ресурса: логин и панель управления пользователями
|
||||
- Удалены избыточные компоненты и файлы
|
||||
- Упрощена логика авторизации и навигации
|
||||
- Устранены жесткие редиректы в пользу SolidJS Router
|
||||
- Созданы компактные и автономные компоненты login.tsx и admin.tsx
|
||||
- Оптимизированы стили для минимального набора компонентов
|
||||
#### [0.4.22] - 2025-05-21
|
||||
|
||||
### Добавлено
|
||||
- Создана панель управления пользователями в админке:
|
||||
- Добавлен компонент UsersList для управления пользователями
|
||||
- Реализованы функции блокировки/разблокировки пользователей
|
||||
- Добавлена возможность отключения звука (mute) для пользователей
|
||||
- Реализовано управление ролями пользователей через модальное окно
|
||||
- Добавлены GraphQL мутации для управления пользователями в schema/admin.graphql
|
||||
- Улучшен интерфейс админ-панели с табами для навигации
|
||||
- Расширена схема GraphQL для админки:
|
||||
- Добавлены типы AdminUserInfo и AdminUserUpdateInput
|
||||
- Добавлены мутации adminUpdateUser, adminToggleUserBlock, adminToggleUserMute
|
||||
- Добавлены запросы adminGetUsers и adminGetRoles
|
||||
- Пагинация списка пользователей в админ-панели
|
||||
- Серверная поддержка пагинации в API для админ-панели
|
||||
- Поиск пользователей по email, имени и ID
|
||||
- Поддержка локального запуска сервера с HTTPS через `python run.py --https` с использованием Granian
|
||||
- Интеграция с инструментом mkcert для генерации доверенных локальных SSL-сертификатов
|
||||
- Поддержка запуска нескольких рабочих процессов через параметр `--workers`
|
||||
- Возможность указать произвольный домен для сертификата через `--domain`
|
||||
- Панель управления:
|
||||
- Управление переменными окружения с группировкой по категориям
|
||||
- Управление пользователями (блокировка, изменение ролей, отключение звука)
|
||||
- Пагинация и поиск пользователей по email, имени и ID
|
||||
- Расширение GraphQL схемы для админки:
|
||||
- Типы AdminUserInfo, AdminUserUpdateInput, AuthResult, Permission, SessionInfo
|
||||
- Мутации для управления пользователями и авторизации
|
||||
- Улучшения серверной части:
|
||||
- Поддержка HTTPS через Granian с помощью mkcert
|
||||
- Параметры запуска `--https`, `--workers`, `--domain`
|
||||
- Система авторизации и аутентификации:
|
||||
- Локальная система аутентификации с сессиями в Redis
|
||||
- Система ролей и разрешений (RBAC)
|
||||
- Защита от брутфорс атак
|
||||
- Поддержка httpOnly cookies для токенов
|
||||
- Мультиязычные email уведомления
|
||||
|
||||
### Улучшено
|
||||
- Улучшен интерфейс админ-панели:
|
||||
- Добавлены вкладки для переключения между разделами
|
||||
- Оптимизирован компонент UsersList для работы с большим количеством пользователей
|
||||
- Добавлены индикаторы статуса для заблокированных и отключенных пользователей
|
||||
- Улучшена обработка ошибок при выполнении операций с пользователями
|
||||
- Добавлены подтверждения для критичных операций (блокировка, изменение ролей)
|
||||
|
||||
### Полностью переработан клиентский код:
|
||||
- Создан компактный API клиент с изолированным кодом для доступа к API
|
||||
- Реализована модульная архитектура с четким разделением ответственности
|
||||
- Добавлены типизированные интерфейсы для всех компонентов и модулей
|
||||
- Реализована система маршрутизации с защищенными маршрутами
|
||||
- Добавлен компонент AuthProvider для управления авторизацией
|
||||
- Оптимизирована загрузка компонентов с использованием ленивой загрузки
|
||||
- Унифицирован стиль кода и именования
|
||||
### Изменено
|
||||
- Упрощена структура клиентской части приложения:
|
||||
- Минималистичная архитектура с основными компонентами (авторизация и админка)
|
||||
- Оптимизированы и унифицированы компоненты, следуя принципу DRY
|
||||
- Реализована система маршрутизации с защищенными маршрутами
|
||||
- Разделение ответственности между компонентами
|
||||
- Типизированные интерфейсы для всех модулей
|
||||
- Отказ от жестких редиректов в пользу SolidJS Router
|
||||
- Переработан модуль авторизации:
|
||||
- Унификация типов для работы с пользователями
|
||||
- Использование единого типа Author во всех запросах
|
||||
- Расширенное логирование для отладки
|
||||
- Оптимизированное хранение и проверка токенов
|
||||
- Унифицированная обработка сессий
|
||||
|
||||
### Исправлено
|
||||
- Исправлена критическая проблема с JWT-токенами авторизации:
|
||||
- Устранена ошибка декодирования токенов `int() argument must be a string, a bytes-like object or a real number, not 'NoneType'`
|
||||
- Обновлен механизм создания токенов для гарантированного задания срока истечения (exp)
|
||||
- Улучшена обработка ошибок в модуле аутентификации для предотвращения создания невалидных токенов
|
||||
- Стандартизован формат параметра exp в JWT: теперь всегда используется timestamp вместо datetime
|
||||
- Добавлена проверка наличия обязательных полей при декодировании токенов
|
||||
- Оптимизирована совместимость между разными способами хранения сессий
|
||||
- Исправлена проблема с перенаправлением в SolidJS, которое сбрасывало состояние приложения:
|
||||
- Обновлена функция logout для использования колбэка навигации вместо жесткого редиректа
|
||||
- Добавлен компонент LoginPage для авторизации без перезагрузки страницы
|
||||
- Реализована ленивая загрузка компонентов с использованием Suspense
|
||||
- Улучшена структура роутинга в админ-панели
|
||||
- Оптимизирован код согласно принципам DRY и KISS
|
||||
- Критические проблемы с JWT-токенами:
|
||||
- Корректная генерация срока истечения токенов (exp)
|
||||
- Стандартизованный формат параметров в JWT
|
||||
- Проверка обязательных полей при декодировании
|
||||
- Ошибки авторизации:
|
||||
- "Cannot return null for non-nullable field Mutation.login"
|
||||
- "Author password is empty" при авторизации
|
||||
- "Author object has no attribute username"
|
||||
- Обработка ошибок:
|
||||
- Улучшена валидация email и username
|
||||
- Исправлена обработка истекших токенов
|
||||
- Добавлены проверки на NULL объекты в декораторах
|
||||
- Вспомогательные компоненты:
|
||||
- Исправлен метод 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`
|
||||
- Описание OAuth интеграции
|
||||
- Руководство по RBAC
|
||||
- Примеры использования на фронтенде
|
||||
- Инструкции по безопасности
|
||||
- Документация по тестированию
|
||||
- Страница входа для неавторизованных пользователей в админке
|
||||
- Публичное GraphQL API для модуля аутентификации:
|
||||
- Типы: `AuthResult`, `Permission`, `SessionInfo`, `OAuthProvider`
|
||||
- Мутации: `login`, `registerUser`, `sendLink`, `confirmEmail`, `getSession`, `changePassword`, `refreshToken`
|
||||
- Запросы: `logout`, `me`, `isEmailUsed`, `getOAuthProviders`
|
||||
|
||||
### Changed
|
||||
- Переработана структура модуля auth для лучшей модульности
|
||||
- Улучшена обработка ошибок в auth endpoints
|
||||
- Оптимизировано хранение сессий в Redis
|
||||
- Усилена безопасность хеширования паролей
|
||||
- Удалена поддержка удаленной аутентификации в пользу единой локальной системы аутентификации
|
||||
- Удалены настройки `AUTH_MODE` и `AUTH_URL`
|
||||
- Удалены зависимости от внешнего сервиса авторизации
|
||||
- Упрощен код аутентификации
|
||||
- Консолидация типов для авторизации:
|
||||
- Удален дублирующий тип `UserInfo`
|
||||
- Расширен тип `Author` полями для работы с авторизацией (`roles`, `email_verified`)
|
||||
- Использование единого типа `Author` во всех запросах авторизации
|
||||
|
||||
### Fixed
|
||||
- Исправлена проблема с кэшированием разрешений
|
||||
- Улучшена валидация email и username
|
||||
- Исправлена обработка истекших токенов
|
||||
- Исправлена ошибка в функции `get_with_stat` в модуле resolvers/stat.py: добавлен вызов метода `.unique()` для результатов запросов с joined eager loads
|
||||
- Исправлены ошибки в декораторах auth:
|
||||
- Добавлены проверки на None для объекта `info` в декораторах `admin_auth_required` и `require_permission`
|
||||
- Улучшена обработка ошибок в GraphQL контексте
|
||||
- Добавлен AuthenticationMiddleware с использованием InternalAuthentication для работы с request.auth
|
||||
- Исправлена ошибка с классом InternalAuthentication:
|
||||
- Добавлен класс AuthenticatedUser
|
||||
- Реализован корректный возврат кортежа (AuthCredentials, BaseUser) из метода authenticate
|
||||
|
||||
#### [0.4.21] - 2023-09-10
|
||||
#### [0.4.21] - 2025-05-10
|
||||
|
||||
### Изменено
|
||||
- Переработана пагинация в админ-панели: переход с модели page/perPage на limit/offset
|
||||
|
@ -155,7 +71,7 @@
|
|||
- Исправлена ошибка 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("Пароль не установлен для данного пользователя")
|
||||
|
||||
# Проверим словарь до создания нового объекта
|
||||
author_dict = orm_author.dict()
|
||||
if "password" not in author_dict or not author_dict["password"]:
|
||||
logger.warning(
|
||||
f"[auth.identity] Пароль отсутствует в dict() или пуст: email={orm_author.email}"
|
||||
)
|
||||
raise InvalidPassword("Пароль отсутствует в данных пользователя")
|
||||
|
||||
# Создаем новый объект автора
|
||||
author = Author(**author_dict)
|
||||
if not author.password:
|
||||
logger.warning(
|
||||
f"[auth.identity] Пароль в созданном объекте автора пуст: email={orm_author.email}"
|
||||
)
|
||||
raise InvalidPassword("Пароль не установлен для данного пользователя")
|
||||
|
||||
# Проверяем пароль
|
||||
if not Password.verify(password, author.password):
|
||||
# Проверяем пароль напрямую, не используя dict()
|
||||
if not Password.verify(password, orm_author.password):
|
||||
logger.warning(f"[auth.identity] Неверный пароль для {orm_author.email}")
|
||||
raise InvalidPassword("Неверный пароль пользователя")
|
||||
|
||||
|
|
|
@ -151,16 +151,16 @@ class InternalAuthentication(AuthenticationBackend):
|
|||
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:
|
||||
token: Токен авторизации (может быть как с Bearer, так и без)
|
||||
|
||||
Returns:
|
||||
tuple: (user_id, roles)
|
||||
tuple: (user_id, roles, is_admin)
|
||||
"""
|
||||
# Обработка формата "Bearer <token>" (если токен не был обработан ранее)
|
||||
if token.startswith("Bearer "):
|
||||
|
@ -169,7 +169,7 @@ async def verify_internal_auth(token: str) -> Tuple[str, list]:
|
|||
# Проверяем сессию
|
||||
payload = await SessionManager.verify_session(token)
|
||||
if not payload:
|
||||
return "", []
|
||||
return "", [], False
|
||||
|
||||
with local_session() as session:
|
||||
try:
|
||||
|
@ -183,9 +183,12 @@ async def verify_internal_auth(token: str) -> Tuple[str, list]:
|
|||
# Получаем роли
|
||||
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:
|
||||
return "", []
|
||||
return "", [], False
|
||||
|
||||
|
||||
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()
|
||||
|
||||
# Обновляем last_login
|
||||
author.last_login = int(time.time())
|
||||
# Обновляем last_seen
|
||||
author.last_seen = int(time.time())
|
||||
|
||||
# Создаем сессию, используя token для идентификации
|
||||
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 services.db import Base
|
||||
from settings import ADMIN_EMAILS
|
||||
|
||||
# from sqlalchemy_utils import TSVectorType
|
||||
|
||||
|
@ -165,7 +166,6 @@ class Author(Base):
|
|||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
email_verified = Column(Boolean, default=False)
|
||||
phone_verified = Column(Boolean, default=False)
|
||||
last_login = Column(Integer, nullable=True)
|
||||
failed_login_attempts = Column(Integer, default=0)
|
||||
account_locked_until = Column(Integer, nullable=True)
|
||||
|
||||
|
@ -182,6 +182,9 @@ class Author(Base):
|
|||
# TSVectorType("name", "slug", "bio", "about", regconfig="pg_catalog.russian")
|
||||
# )
|
||||
|
||||
# Список защищенных полей, которые видны только владельцу и администраторам
|
||||
_protected_fields = ['email', 'password', 'provider_access_token', 'provider_refresh_token']
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Проверяет, аутентифицирован ли пользователь"""
|
||||
|
@ -238,22 +241,27 @@ class Author(Base):
|
|||
"""
|
||||
return self.slug or self.email or self.phone or ""
|
||||
|
||||
def dict(self) -> Dict:
|
||||
"""Преобразует объект Author в словарь"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"slug": self.slug,
|
||||
"name": self.name,
|
||||
"bio": self.bio,
|
||||
"about": self.about,
|
||||
"pic": self.pic,
|
||||
"links": self.links,
|
||||
"email": self.email,
|
||||
"password": self.password,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"last_seen": self.last_seen,
|
||||
"deleted_at": self.deleted_at,
|
||||
"roles": [role.id for role in self.roles],
|
||||
"email_verified": self.email_verified,
|
||||
}
|
||||
def dict(self, access=False) -> Dict:
|
||||
"""
|
||||
Сериализует объект Author в словарь с учетом прав доступа.
|
||||
|
||||
Args:
|
||||
access (bool, optional): Флаг, указывающий, доступны ли защищенные поля
|
||||
|
||||
Returns:
|
||||
dict: Словарь с атрибутами Author, отфильтрованный по правам доступа
|
||||
"""
|
||||
# Получаем все атрибуты объекта
|
||||
result = {c.name: getattr(self, c.name) for c in self.__table__.columns}
|
||||
|
||||
# Добавляем роли, если они есть
|
||||
if hasattr(self, 'roles') and self.roles:
|
||||
result['roles'] = [role.id for role in self.roles]
|
||||
|
||||
# скрываем защищенные поля
|
||||
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)
|
||||
|
||||
# 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)
|
||||
if authors:
|
||||
# Cache the retrieved author data
|
||||
|
|
10
main.py
10
main.py
|
@ -151,7 +151,14 @@ middleware = [
|
|||
# CORS должен быть перед другими middleware для корректной обработки preflight-запросов
|
||||
Middleware(
|
||||
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_headers=["*"],
|
||||
allow_credentials=True,
|
||||
|
@ -183,6 +190,7 @@ async def graphql_handler(request: Request):
|
|||
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
response.headers["Access-Control-Allow-Methods"] = "POST, GET, OPTIONS"
|
||||
response.headers["Access-Control-Allow-Headers"] = "*"
|
||||
response.headers["Access-Control-Allow-Credentials"] = "true"
|
||||
response.headers["Access-Control-Max-Age"] = "86400" # 24 hours
|
||||
return response
|
||||
|
||||
|
|
10
package.json
10
package.json
|
@ -1,18 +1,14 @@
|
|||
{
|
||||
"name": "publy-admin",
|
||||
"version": "0.4.20",
|
||||
"name": "admin-panel",
|
||||
"version": "0.4.22",
|
||||
"private": true,
|
||||
"description": "admin panel",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"lint": "biome check . --fix",
|
||||
"format": "biome format . --write",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"build:auth": "vite build -c client/auth/vite.config.ts",
|
||||
"watch:auth": "vite build -c client/auth/vite.config.ts --watch"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Component, Show, Suspense, createSignal, lazy, onMount } from 'solid-js'
|
||||
import { isAuthenticated } from './auth'
|
||||
import { Component, Show, Suspense, createSignal, lazy, onMount, createEffect } from 'solid-js'
|
||||
import { isAuthenticated, getAuthTokenFromCookie } from './auth'
|
||||
|
||||
// Ленивая загрузка компонентов
|
||||
const AdminPage = lazy(() => import('./admin'))
|
||||
|
@ -11,14 +11,58 @@ const LoginPage = lazy(() => import('./login'))
|
|||
const App: Component = () => {
|
||||
const [authenticated, setAuthenticated] = createSignal<boolean | null>(null)
|
||||
const [loading, setLoading] = createSignal(true)
|
||||
const [checkingAuth, setCheckingAuth] = createSignal(true)
|
||||
|
||||
// Проверяем авторизацию при монтировании
|
||||
onMount(() => {
|
||||
const authed = isAuthenticated()
|
||||
setAuthenticated(authed)
|
||||
setLoading(false)
|
||||
checkAuthentication()
|
||||
})
|
||||
|
||||
// Периодическая проверка авторизации
|
||||
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 = () => {
|
||||
setAuthenticated(true)
|
||||
|
@ -35,7 +79,7 @@ const App: Component = () => {
|
|||
fallback={
|
||||
<div class="loading-screen">
|
||||
<div class="loading-spinner" />
|
||||
<h2>Загрузка...</h2>
|
||||
<h2>Загрузка компонентов...</h2>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
@ -44,12 +88,12 @@ const App: Component = () => {
|
|||
fallback={
|
||||
<div class="loading-screen">
|
||||
<div class="loading-spinner" />
|
||||
<h2>Загрузка...</h2>
|
||||
<h2>Проверка авторизации...</h2>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{authenticated() ? (
|
||||
<AdminPage onLogout={handleLogout} />
|
||||
<AdminPage apiUrl={`${location.origin}/graphql`} onLogout={handleLogout} />
|
||||
) : (
|
||||
<LoginPage onLoginSuccess={handleLoginSuccess} />
|
||||
)}
|
||||
|
|
759
panel/admin.tsx
759
panel/admin.tsx
|
@ -18,15 +18,13 @@ interface User {
|
|||
roles: string[]
|
||||
created_at?: number
|
||||
last_seen?: number
|
||||
muted: boolean
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс для роли пользователя
|
||||
*/
|
||||
interface Role {
|
||||
id: number
|
||||
id: string // ID роли - строка, не число
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
@ -52,27 +50,37 @@ interface AdminGetRolesResponse {
|
|||
}
|
||||
|
||||
/**
|
||||
* Интерфейс для ответа изменения статуса пользователя
|
||||
* Интерфейс для ответа обновления пользователя
|
||||
*/
|
||||
interface AdminSetUserStatusResponse {
|
||||
adminSetUserStatus: {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
interface AdminUpdateUserResponse {
|
||||
adminUpdateUser: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс для ответа изменения статуса блокировки чата
|
||||
* Интерфейс для переменной окружения
|
||||
*/
|
||||
interface AdminMuteUserResponse {
|
||||
adminMuteUser: {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
interface EnvVariable {
|
||||
key: string
|
||||
value: string
|
||||
description?: string
|
||||
type: string
|
||||
isSecret: boolean
|
||||
}
|
||||
|
||||
// Интерфейс для пропсов AdminPage
|
||||
/**
|
||||
* Интерфейс для секции переменных окружения
|
||||
*/
|
||||
interface EnvSection {
|
||||
name: string
|
||||
description?: string
|
||||
variables: EnvVariable[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс свойств компонента AdminPage
|
||||
*/
|
||||
interface AdminPageProps {
|
||||
apiUrl: string
|
||||
onLogout?: () => void
|
||||
}
|
||||
|
||||
|
@ -89,6 +97,12 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
|||
const [showRolesModal, setShowRolesModal] = createSignal(false)
|
||||
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<{
|
||||
page: number
|
||||
|
@ -137,8 +151,6 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
|||
roles
|
||||
created_at
|
||||
last_seen
|
||||
muted
|
||||
is_active
|
||||
}
|
||||
total
|
||||
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[]) {
|
||||
try {
|
||||
await query(
|
||||
await query<AdminUpdateUserResponse>(
|
||||
`${location.origin}/graphql`,
|
||||
`
|
||||
mutation AdminUpdateUser($userId: Int!, $input: AdminUserUpdateInput!) {
|
||||
adminUpdateUser(userId: $userId, input: $input) {
|
||||
success
|
||||
error
|
||||
}
|
||||
mutation AdminUpdateUser($user: AdminUserUpdateInput!) {
|
||||
adminUpdateUser(user: $user)
|
||||
}
|
||||
`,
|
||||
{
|
||||
userId,
|
||||
input: { roles: newRoles }
|
||||
user: {
|
||||
id: userId,
|
||||
roles: newRoles
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -414,20 +327,171 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* Выход из системы
|
||||
* Обрабатывает выход из системы
|
||||
*/
|
||||
function handleLogout() {
|
||||
// Сначала выполняем локальные действия по очистке данных
|
||||
setUsers([])
|
||||
setRoles([])
|
||||
|
||||
// Затем выполняем выход
|
||||
logout(() => {
|
||||
// Вызываем коллбэк для оповещения родителя о выходе
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout()
|
||||
if (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 [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 current = selectedRoles()
|
||||
if (current.includes(role)) {
|
||||
|
@ -560,6 +643,11 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
|||
<h2>Управление ролями пользователя</h2>
|
||||
<p>Пользователь: {user.email}</p>
|
||||
|
||||
<div class="role-info">
|
||||
<p><strong>Внимание:</strong> Снятие роли "reader" блокирует доступ пользователя к системе.</p>
|
||||
<p>Роль "author" дает возможность голосовать за публикации для размещения на главной странице.</p>
|
||||
</div>
|
||||
|
||||
<div class="roles-list">
|
||||
<For each={roles()}>
|
||||
{(role) => (
|
||||
|
@ -567,14 +655,12 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
|||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRoles().includes(role.name)}
|
||||
onChange={() => toggleRole(role.name)}
|
||||
checked={selectedRoles().includes(role.id)}
|
||||
onChange={() => toggleRole(role.id)}
|
||||
/>
|
||||
{role.name}
|
||||
{role.id}
|
||||
</label>
|
||||
<Show when={role.description}>
|
||||
<p class="role-description">{role.description}</p>
|
||||
</Show>
|
||||
<p class="role-description">{getRoleDescription(role.id)}</p>
|
||||
</div>
|
||||
)}
|
||||
</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 (
|
||||
<div class="admin-page">
|
||||
<header>
|
||||
|
@ -604,9 +934,12 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
|||
</div>
|
||||
|
||||
<nav class="admin-tabs">
|
||||
<button class={activeTab() === 'users' ? 'active' : ''} onClick={() => setActiveTab('users')}>
|
||||
<button class={activeTab() === 'users' ? 'active' : ''} onClick={() => handleTabChange('users')}>
|
||||
Пользователи
|
||||
</button>
|
||||
<button class={activeTab() === 'env' ? 'active' : ''} onClick={() => handleTabChange('env')}>
|
||||
Переменные среды
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
@ -619,90 +952,90 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
|||
<div class="success-message">{successMessage()}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={loading()}>
|
||||
<div class="loading">Загрузка данных...</div>
|
||||
</Show>
|
||||
<Show when={activeTab() === 'users'}>
|
||||
<Show when={loading()}>
|
||||
<div class="loading">Загрузка данных...</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && users().length === 0 && !error()}>
|
||||
<div class="empty-state">Нет данных для отображения</div>
|
||||
</Show>
|
||||
<Show when={!loading() && users().length === 0 && !error()}>
|
||||
<div class="empty-state">Нет данных для отображения</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && users().length > 0}>
|
||||
<div class="users-controls">
|
||||
<div class="search-container">
|
||||
<div class="search-input-group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по email, имени или ID..."
|
||||
value={searchQuery()}
|
||||
onInput={handleSearchChange}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
class="search-input"
|
||||
/>
|
||||
<button class="search-button" onClick={handleSearch}>
|
||||
Поиск
|
||||
</button>
|
||||
<Show when={!loading() && users().length > 0}>
|
||||
<div class="users-controls">
|
||||
<div class="search-container">
|
||||
<div class="search-input-group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по email, имени или ID..."
|
||||
value={searchQuery()}
|
||||
onInput={handleSearchChange}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
class="search-input"
|
||||
/>
|
||||
<button class="search-button" onClick={handleSearch}>
|
||||
Поиск
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="users-list">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Email</th>
|
||||
<th>Имя</th>
|
||||
<th>Роли</th>
|
||||
<th>Создан</th>
|
||||
<th>Последний вход</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={users()}>
|
||||
{(user) => (
|
||||
<tr class={user.is_active ? '' : 'blocked'}>
|
||||
<td>{user.id}</td>
|
||||
<td>{user.email}</td>
|
||||
<td>{user.name || '-'}</td>
|
||||
<td>{user.roles.join(', ') || '-'}</td>
|
||||
<td>{formatDate(user.created_at)}</td>
|
||||
<td>{formatDate(user.last_seen)}</td>
|
||||
<td>
|
||||
<span class={`status ${user.is_active ? 'active' : 'inactive'}`}>
|
||||
{user.is_active ? 'Активен' : 'Заблокирован'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class={user.is_active ? 'block' : 'unblock'}
|
||||
onClick={() => toggleUserBlock(user.id, user.is_active)}
|
||||
>
|
||||
{user.is_active ? 'Блокировать' : 'Разблокировать'}
|
||||
</button>
|
||||
<button
|
||||
class={user.muted ? 'unmute' : 'mute'}
|
||||
onClick={() => toggleUserMute(user.id, user.muted)}
|
||||
>
|
||||
{user.muted ? 'Unmute' : 'Mute'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="users-list">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Email</th>
|
||||
<th>Имя</th>
|
||||
<th>Роли</th>
|
||||
<th>Создан</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={users()}>
|
||||
{(user) => (
|
||||
<tr>
|
||||
<td>{user.id}</td>
|
||||
<td>{user.email}</td>
|
||||
<td>{user.name || '-'}</td>
|
||||
<td class="roles-cell">
|
||||
<div class="roles-container">
|
||||
<For each={user.roles}>
|
||||
{(role) => <RoleBadge role={role} />}
|
||||
</For>
|
||||
<div class="role-badge" onClick={() => {
|
||||
setSelectedUser(user)
|
||||
setShowRolesModal(true)
|
||||
}}
|
||||
>
|
||||
🎭
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatDateRelative(user.created_at)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination />
|
||||
<Pagination />
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === 'env'}>
|
||||
<EnvVariablesTab />
|
||||
</Show>
|
||||
</main>
|
||||
|
||||
<Show when={showRolesModal()}>
|
||||
<RolesModal />
|
||||
</Show>
|
||||
|
||||
<Show when={showVariableModal()}>
|
||||
<VariableModal />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -67,6 +67,11 @@ function hasAuthErrors(errors: Array<{ message?: string; extensions?: { code?: s
|
|||
* @returns Полный URL для запроса
|
||||
*/
|
||||
function prepareUrl(url: string): string {
|
||||
// В режиме локальной разработки всегда используем /graphql
|
||||
if (location.hostname === 'localhost') {
|
||||
return `${location.origin}/graphql`
|
||||
}
|
||||
|
||||
// Если это относительный путь, добавляем к нему origin
|
||||
if (url.startsWith('/')) {
|
||||
return `${location.origin}${url}`
|
||||
|
|
286
panel/styles.css
286
panel/styles.css
|
@ -425,10 +425,11 @@ button.unmute {
|
|||
}
|
||||
|
||||
.cancel-button {
|
||||
color: #333 !important;
|
||||
padding: 8px 16px;
|
||||
background-color: #ccc;
|
||||
color: #333;
|
||||
width: auto;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
|
@ -598,3 +599,286 @@ button.unmute {
|
|||
0% { transform: rotate(0deg); }
|
||||
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 sqlalchemy import or_
|
||||
from sqlalchemy import or_, cast, String
|
||||
from graphql.error import GraphQLError
|
||||
|
||||
from auth.decorators import admin_auth_required
|
||||
from services.db import local_session
|
||||
from services.schema import query
|
||||
from auth.orm import Author, Role
|
||||
from services.schema import query, mutation
|
||||
from auth.orm import Author, Role, AuthorRole
|
||||
from services.env import EnvManager, EnvVariable
|
||||
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_(
|
||||
Author.email.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
|
||||
else [],
|
||||
"created_at": user.created_at,
|
||||
"last_seen": user.last_seen,
|
||||
"muted": user.muted or False,
|
||||
"is_active": not user.blocked if hasattr(user, "blocked") else True,
|
||||
"last_seen": user.last_seen
|
||||
}
|
||||
for user in users
|
||||
],
|
||||
|
@ -120,3 +119,179 @@ async def admin_get_roles(_, info):
|
|||
except Exception as e:
|
||||
logger.error(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())
|
||||
session.commit()
|
||||
|
||||
# Здесь можно не применять фильтрацию, так как пользователь получает свои данные
|
||||
return {"token": token, "author": author}
|
||||
|
||||
|
||||
|
@ -76,6 +77,7 @@ async def confirm_email(_, info, token):
|
|||
session.add(user)
|
||||
session.commit()
|
||||
logger.info(f"[auth] confirmEmail: Email для пользователя {user_id} успешно подтвержден.")
|
||||
# Здесь можно не применять фильтрацию, так как пользователь получает свои данные
|
||||
return {"success": True, "token": session_token, "author": user, "error": None}
|
||||
except InvalidToken as e:
|
||||
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(
|
||||
f"[auth] registerUser: Пользователь {email} зарегистрирован, ссылка для подтверждения отправлена."
|
||||
)
|
||||
# При регистрации возвращаем данные самому пользователю, поэтому не фильтруем
|
||||
return {
|
||||
"success": True,
|
||||
"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)}"
|
||||
)
|
||||
|
||||
# Проверяем пароль
|
||||
logger.info(f"[auth] login: НАЧАЛО ПРОВЕРКИ ПАРОЛЯ для {email}")
|
||||
verify_result = Identity.password(author, password)
|
||||
logger.info(
|
||||
f"[auth] login: РЕЗУЛЬТАТ ПРОВЕРКИ ПАРОЛЯ: {verify_result if isinstance(verify_result, dict) else 'успешно'}"
|
||||
)
|
||||
# Проверяем наличие роли reader
|
||||
has_reader_role = False
|
||||
if hasattr(author, "roles") and author.roles:
|
||||
for role in author.roles:
|
||||
if role.id == "reader":
|
||||
has_reader_role = True
|
||||
break
|
||||
|
||||
if isinstance(verify_result, dict) and verify_result.get("error"):
|
||||
logger.warning(f"[auth] login: Неверный пароль для {email}: {verify_result.get('error')}")
|
||||
# Если у пользователя нет роли reader и он не админ, запрещаем вход
|
||||
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 {
|
||||
"success": False,
|
||||
"token": None,
|
||||
"author": None,
|
||||
"error": verify_result.get("error", "Ошибка авторизации"),
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
# Получаем правильный объект автора - результат verify_result
|
||||
|
@ -346,9 +380,12 @@ async def login(_, info, email: str, password: str):
|
|||
if not cookie_set:
|
||||
logger.warning(f"[auth] login: Не удалось установить cookie никаким способом")
|
||||
|
||||
# Возвращаем успешный результат
|
||||
# Возвращаем успешный результат с данными для клиента
|
||||
# Для ответа клиенту используем dict() с параметром access=True,
|
||||
# чтобы получить полный доступ к данным для самого пользователя
|
||||
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(
|
||||
f"[auth] login: Возвращаемый результат: {{success: {result['success']}, token_length: {len(token) if token else 0}}}"
|
||||
)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import asyncio
|
||||
import time
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
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:
|
||||
list: Список всех авторов без статистики
|
||||
"""
|
||||
|
@ -45,15 +49,15 @@ async def get_all_authors():
|
|||
authors_query = select(Author).where(Author.deleted_at.is_(None))
|
||||
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)
|
||||
|
||||
|
||||
# Вспомогательная функция для получения авторов со статистикой с пагинацией
|
||||
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: Максимальное количество возвращаемых авторов
|
||||
offset: Смещение для пагинации
|
||||
by: Опциональный параметр сортировки (new/active)
|
||||
|
||||
current_user_id: ID текущего пользователя
|
||||
Returns:
|
||||
list: Список авторов с их статистикой
|
||||
"""
|
||||
|
@ -133,15 +137,18 @@ async def get_authors_with_stats(limit=50, offset=0, by: Optional[str] = None):
|
|||
# Формируем результат с добавлением статистики
|
||||
result = []
|
||||
for author in authors:
|
||||
# Получаем словарь с учетом прав доступа
|
||||
author_dict = author.dict()
|
||||
author_dict["stat"] = {
|
||||
"shouts": shouts_stats.get(author.id, 0),
|
||||
"followers": followers_stats.get(author.id, 0),
|
||||
}
|
||||
|
||||
result.append(author_dict)
|
||||
|
||||
# Кешируем каждого автора отдельно для использования в других функциях
|
||||
await cache_author(author_dict)
|
||||
# Важно: кэшируем полный словарь для админов
|
||||
await cache_author(author.dict())
|
||||
|
||||
return result
|
||||
|
||||
|
@ -172,8 +179,8 @@ async def invalidate_authors_cache(author_id=None):
|
|||
# Получаем user_id автора, если есть
|
||||
with local_session() as session:
|
||||
author = session.query(Author).filter(Author.id == author_id).first()
|
||||
if author and author.user:
|
||||
specific_keys.append(f"author:user:{author.user.strip()}")
|
||||
if author and Author.id:
|
||||
specific_keys.append(f"author:user:{Author.id.strip()}")
|
||||
|
||||
# Удаляем конкретные ключи
|
||||
for key in specific_keys:
|
||||
|
@ -198,24 +205,28 @@ async def invalidate_authors_cache(author_id=None):
|
|||
@login_required
|
||||
async def update_author(_, info, profile):
|
||||
user_id = info.context.get("user_id")
|
||||
is_admin = info.context.get("is_admin", False)
|
||||
|
||||
if not user_id:
|
||||
return {"error": "unauthorized", "author": None}
|
||||
try:
|
||||
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:
|
||||
Author.update(author, profile)
|
||||
session.add(author)
|
||||
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)
|
||||
if result:
|
||||
author_with_stat = result[0]
|
||||
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))
|
||||
return {"error": None, "author": author}
|
||||
|
||||
# Возвращаем обычную полную версию, т.к. это владелец
|
||||
return {"error": None, "author": author}
|
||||
except Exception as exc:
|
||||
import traceback
|
||||
|
||||
|
@ -224,24 +235,46 @@ async def update_author(_, info, profile):
|
|||
|
||||
|
||||
@query.field("get_authors_all")
|
||||
async def get_authors_all(_, _info):
|
||||
async def get_authors_all(_, info):
|
||||
"""
|
||||
Получает список всех авторов без статистики.
|
||||
|
||||
Returns:
|
||||
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")
|
||||
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
|
||||
try:
|
||||
author_id = get_author_id_from(slug=slug, user="", author_id=author_id)
|
||||
if not author_id:
|
||||
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"):
|
||||
# update stat from db
|
||||
|
@ -250,9 +283,15 @@ async def get_author(_, _info, slug="", author_id=0):
|
|||
if result:
|
||||
author_with_stat = result[0]
|
||||
if isinstance(author_with_stat, Author):
|
||||
author_dict = author_with_stat.dict()
|
||||
# await cache_author(author_dict)
|
||||
asyncio.create_task(cache_author(author_dict))
|
||||
# Кэшируем полные данные для админов
|
||||
original_dict = author_with_stat.dict(is_admin=True)
|
||||
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:
|
||||
pass
|
||||
except Exception as exc:
|
||||
|
@ -263,31 +302,43 @@ async def get_author(_, _info, slug="", author_id=0):
|
|||
|
||||
|
||||
@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()
|
||||
logger.info(f"getting author id for {user_id}")
|
||||
author = None
|
||||
try:
|
||||
author = await get_cached_author_by_user_id(user_id, get_with_stat)
|
||||
if author:
|
||||
return author
|
||||
cached_author = await get_cached_author_by_user_id(user_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)
|
||||
# Возвращаем отфильтрованную версию
|
||||
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)
|
||||
if result:
|
||||
author_with_stat = result[0]
|
||||
if isinstance(author_with_stat, Author):
|
||||
author_dict = author_with_stat.dict()
|
||||
# await cache_author(author_dict)
|
||||
asyncio.create_task(cache_author(author_dict))
|
||||
return author_with_stat
|
||||
# Кэшируем полную версию данных
|
||||
original_dict = author_with_stat.dict(is_admin=True)
|
||||
asyncio.create_task(cache_author(original_dict))
|
||||
|
||||
# Возвращаем отфильтрованную версию
|
||||
return author_with_stat.dict(current_user_id, is_admin)
|
||||
except Exception as exc:
|
||||
logger.error(f"Error getting author: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
@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:
|
||||
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):
|
||||
|
@ -316,7 +371,7 @@ def get_author_id_from(slug="", user=None, author_id=None):
|
|||
author_id = author.id
|
||||
return author_id
|
||||
if user:
|
||||
author = session.query(Author).filter(Author.user == user).first()
|
||||
author = session.query(Author).filter(Author.id == user).first()
|
||||
if author:
|
||||
author_id = author.id
|
||||
except Exception as exc:
|
||||
|
@ -325,15 +380,31 @@ def get_author_id_from(slug="", user=None, author_id=None):
|
|||
|
||||
|
||||
@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}")
|
||||
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
|
||||
if not author_id:
|
||||
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_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
|
||||
return {
|
||||
"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")
|
||||
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}")
|
||||
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
|
||||
if not author_id:
|
||||
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
|
||||
|
||||
|
||||
def create_author(user_id: str, slug: str, name: str = ""):
|
||||
author = Author()
|
||||
author.user = user_id # Связь с user_id из системы авторизации
|
||||
Author.id = user_id # Связь с user_id из системы авторизации
|
||||
author.slug = slug # Идентификатор из системы авторизации
|
||||
author.created_at = author.updated_at = int(time.time())
|
||||
author.name = name or slug # если не указано
|
||||
|
@ -377,10 +466,28 @@ def create_author(user_id: str, slug: str, name: str = ""):
|
|||
|
||||
|
||||
@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}")
|
||||
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
|
||||
if not author_id:
|
||||
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
|
||||
|
|
|
@ -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
|
||||
with local_session() as session:
|
||||
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:
|
||||
# Check if an invite already exists
|
||||
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):
|
||||
user_id = info.context["user_id"]
|
||||
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:
|
||||
shout = session.query(Shout).filter(Shout.slug == slug).first()
|
||||
# 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
|
||||
q = q.where(CommunityFollower.author == author_id)
|
||||
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)
|
||||
if 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:
|
||||
await cache_by_id(Author, author.id, cache_author)
|
||||
info.context["author"] = author.dict()
|
||||
info.context["user_id"] = author.user
|
||||
info.context["user_id"] = author.id
|
||||
unfollow(None, info, "shout", shout.slug)
|
||||
|
||||
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"}
|
||||
if not entity_id and entity:
|
||||
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}")
|
||||
|
||||
if entity_id:
|
||||
|
@ -96,7 +103,35 @@ async def follow(_, info, what, slug="", entity_id=0):
|
|||
if get_cached_follows_method:
|
||||
logger.debug("Получение подписок из кэша")
|
||||
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("Обновлен список подписок")
|
||||
|
||||
if what == "AUTHOR" and not existing_sub:
|
||||
|
@ -171,11 +206,38 @@ async def unfollow(_, info, what, slug="", entity_id=0):
|
|||
|
||||
if cache_method:
|
||||
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:
|
||||
logger.debug("Получение подписок из кэша")
|
||||
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("Обновлен список подписок")
|
||||
|
||||
if what == "AUTHOR":
|
||||
|
|
|
@ -215,7 +215,7 @@ async def set_featured(session, shout_id):
|
|||
session.commit()
|
||||
author = session.query(Author).filter(Author.id == s.created_by).first()
|
||||
if author:
|
||||
await add_user_role(str(author.user))
|
||||
await add_user_role(str(author.id))
|
||||
session.add(s)
|
||||
session.commit()
|
||||
|
||||
|
@ -446,7 +446,7 @@ async def delete_reaction(_, info, reaction_id: int):
|
|||
|
||||
with local_session() as session:
|
||||
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()
|
||||
|
||||
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:
|
||||
topics_by_author_query = topics_by_author_query.join(Author).where(Author.slug == slug)
|
||||
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)
|
||||
|
||||
|
@ -320,7 +320,7 @@ async def delete_topic(_, info, slug: str):
|
|||
t: Topic = session.query(Topic).filter(Topic.slug == slug).first()
|
||||
if not t:
|
||||
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 t.created_by != author.id:
|
||||
return {"error": "access denied"}
|
||||
|
|
|
@ -27,15 +27,11 @@ type AdminUserInfo {
|
|||
roles: [String!]
|
||||
created_at: Int
|
||||
last_seen: Int
|
||||
muted: Boolean
|
||||
is_active: Boolean
|
||||
}
|
||||
|
||||
input AdminUserUpdateInput {
|
||||
id: Int!
|
||||
roles: [String!]
|
||||
muted: Boolean
|
||||
is_active: Boolean
|
||||
}
|
||||
|
||||
type Role {
|
||||
|
@ -66,6 +62,4 @@ extend type Mutation {
|
|||
|
||||
# Мутации для управления пользователями
|
||||
adminUpdateUser(user: AdminUserUpdateInput!): Boolean!
|
||||
adminToggleUserBlock(userId: Int!): Boolean!
|
||||
adminToggleUserMute(userId: Int!): Boolean!
|
||||
}
|
|
@ -12,9 +12,8 @@ type AuthorStat {
|
|||
|
||||
type Author {
|
||||
id: Int!
|
||||
user: String! # user.id
|
||||
slug: String! # user.nickname
|
||||
name: String # user.preferred_username
|
||||
slug: String!
|
||||
name: String
|
||||
pic: String
|
||||
bio: String
|
||||
about: String
|
||||
|
@ -25,10 +24,8 @@ type Author {
|
|||
deleted_at: Int
|
||||
email: String
|
||||
seo: String
|
||||
# synthetic
|
||||
stat: AuthorStat # ratings inside
|
||||
communities: [Community]
|
||||
# Auth fields
|
||||
roles: [String!]
|
||||
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"]
|
||||
|
||||
|
||||
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_roles: list[str] - Список ролей пользователя
|
||||
- is_admin: bool - Флаг наличия у пользователя административных прав
|
||||
"""
|
||||
# Проверяем наличие токена
|
||||
token = req.headers.get("Authorization")
|
||||
if not token:
|
||||
return "", []
|
||||
return "", [], False
|
||||
|
||||
# Очищаем токен от префикса Bearer если он есть
|
||||
if token.startswith("Bearer "):
|
||||
|
@ -39,8 +40,39 @@ async def check_auth(req) -> Tuple[str, list[str]]:
|
|||
|
||||
# Проверяем авторизацию внутренним механизмом
|
||||
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):
|
||||
"""
|
||||
|
@ -84,21 +116,36 @@ async def add_user_role(user_id: str, roles: list[str] = None):
|
|||
|
||||
|
||||
def login_required(f):
|
||||
"""Декоратор для проверки авторизации пользователя."""
|
||||
"""Декоратор для проверки авторизации пользователя. Требуется наличие роли 'reader'."""
|
||||
|
||||
@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 = await check_auth(req)
|
||||
if user_id and user_roles:
|
||||
logger.info(f" got {user_id} roles: {user_roles}")
|
||||
info.context["user_id"] = user_id.strip()
|
||||
info.context["roles"] = user_roles
|
||||
author = await get_cached_author_by_user_id(user_id, get_with_stat)
|
||||
if not author:
|
||||
logger.error(f"author profile not found for user {user_id}")
|
||||
info.context["author"] = author
|
||||
user_id, user_roles, is_admin = await check_auth(req)
|
||||
|
||||
if not user_id:
|
||||
raise GraphQLError("Требуется авторизация")
|
||||
|
||||
# Проверяем наличие роли reader
|
||||
if 'reader' not in user_roles and not is_admin:
|
||||
logger.error(f"Пользователь {user_id} не имеет роли 'reader'")
|
||||
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 decorated_function
|
||||
|
@ -113,7 +160,7 @@ def login_accepted(f):
|
|||
req = info.context.get("request")
|
||||
|
||||
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}")
|
||||
|
||||
if user_id and user_roles:
|
||||
|
@ -121,11 +168,16 @@ def login_accepted(f):
|
|||
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 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:
|
||||
logger.error(
|
||||
f"login_accepted: Профиль автора не найден для пользователя {user_id}. Используем базовые данные."
|
||||
|
@ -135,6 +187,42 @@ def login_accepted(f):
|
|||
info.context["user_id"] = None
|
||||
info.context["roles"] = 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)
|
||||
|
||||
|
|
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
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from redis import Redis
|
||||
from settings import REDIS_URL
|
||||
from settings import REDIS_URL, ROOT_DIR
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
|
||||
|
@ -23,85 +26,326 @@ class EnvSection:
|
|||
|
||||
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):
|
||||
self.redis = Redis.from_url(REDIS_URL)
|
||||
self.prefix = "env:"
|
||||
self.env_file_path = os.path.join(ROOT_DIR, '.env')
|
||||
|
||||
def get_all_variables(self) -> List[EnvSection]:
|
||||
"""
|
||||
Получение всех переменных окружения, сгруппированных по секциям
|
||||
"""
|
||||
try:
|
||||
# Получаем все ключи с префиксом env:
|
||||
keys = self.redis.keys(f"{self.prefix}*")
|
||||
variables: Dict[str, str] = {}
|
||||
# Получаем все переменные окружения из системы
|
||||
system_env = self._get_system_env_vars()
|
||||
|
||||
for key in keys:
|
||||
var_key = key.decode("utf-8").replace(self.prefix, "")
|
||||
value = self.redis.get(key)
|
||||
if value:
|
||||
variables[var_key] = value.decode("utf-8")
|
||||
# Получаем переменные из .env файла, если он существует
|
||||
dotenv_vars = self._get_dotenv_vars()
|
||||
|
||||
# Получаем все переменные из Redis
|
||||
redis_vars = self._get_redis_env_vars()
|
||||
|
||||
# Объединяем переменные, при этом redis_vars имеют наивысший приоритет,
|
||||
# за ними следуют переменные из .env, затем системные
|
||||
env_vars = {**system_env, **dotenv_vars, **redis_vars}
|
||||
|
||||
# Группируем переменные по секциям
|
||||
sections = [
|
||||
EnvSection(
|
||||
name="Авторизация",
|
||||
description="Настройки системы авторизации",
|
||||
variables=[
|
||||
EnvVariable(
|
||||
key="JWT_SECRET",
|
||||
value=variables.get("JWT_SECRET", ""),
|
||||
description="Секретный ключ для JWT токенов",
|
||||
type="string",
|
||||
is_secret=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
EnvSection(
|
||||
name="Redis",
|
||||
description="Настройки подключения к Redis",
|
||||
variables=[
|
||||
EnvVariable(
|
||||
key="REDIS_URL",
|
||||
value=variables.get("REDIS_URL", ""),
|
||||
description="URL подключения к Redis",
|
||||
type="string",
|
||||
)
|
||||
],
|
||||
),
|
||||
# Добавьте другие секции по необходимости
|
||||
]
|
||||
|
||||
return sections
|
||||
return self._group_variables_by_sections(env_vars)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения переменных: {e}")
|
||||
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:
|
||||
"""
|
||||
Обновление значения переменной
|
||||
Обновление значения переменной в Redis и .env файле
|
||||
"""
|
||||
try:
|
||||
# Сохраняем в Redis
|
||||
full_key = f"{self.prefix}{key}"
|
||||
self.redis.set(full_key, value)
|
||||
|
||||
# Обновляем значение в .env файле
|
||||
self._update_dotenv_var(key, value)
|
||||
|
||||
# Обновляем переменную в текущем процессе
|
||||
os.environ[key] = value
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления переменной {key}: {e}")
|
||||
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:
|
||||
"""
|
||||
Массовое обновление переменных
|
||||
"""
|
||||
try:
|
||||
# Обновляем переменные в Redis
|
||||
pipe = self.redis.pipeline()
|
||||
for var in variables:
|
||||
full_key = f"{self.prefix}{var.key}"
|
||||
pipe.set(full_key, var.value)
|
||||
pipe.execute()
|
||||
|
||||
# Обновляем переменные в .env файле
|
||||
for var in variables:
|
||||
self._update_dotenv_var(var.key, var.value)
|
||||
|
||||
# Обновляем переменную в текущем процессе
|
||||
os.environ[var.key] = var.value
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка массового обновления переменных: {e}")
|
||||
|
|
|
@ -3,6 +3,10 @@
|
|||
import os
|
||||
import sys
|
||||
from os import environ
|
||||
from pathlib import Path
|
||||
|
||||
# Корневая директория проекта
|
||||
ROOT_DIR = Path(__file__).parent.absolute()
|
||||
|
||||
DEV_SERVER_PID_FILE_NAME = "dev-server.pid"
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user