This commit is contained in:
Untone 2025-05-21 01:34:02 +03:00
parent 1d64811880
commit d3a760b6ba
25 changed files with 1839 additions and 550 deletions

View File

@ -1,150 +1,66 @@
# Changelog # Changelog
## [Unreleased] #### [0.4.22] - 2025-05-21
### Изменено
- Радикально упрощена структура клиентской части приложения:
- Удалены все избыточные файлы и директории
- Перемещены модули auth.ts и api.ts из директории client/lib в корень директории client
- Обновлены импорты во всех компонентах для использования модулей из корня директории
- Создана минималистичная архитектура с 5 файлами (App, login, admin, auth, api)
- Следование принципу DRY - устранено дублирование кода
- Выделены общие модули для авторизации и работы с API
- Единый стиль кода и документации для всех компонентов
- Устранены все жесткие редиректы в пользу SolidJS Router
- Упрощена структура проекта для лучшей поддерживаемости
- Упрощена структура клиентской части приложения:
- Оставлены только два основных ресурса: логин и панель управления пользователями
- Удалены избыточные компоненты и файлы
- Упрощена логика авторизации и навигации
- Устранены жесткие редиректы в пользу SolidJS Router
- Созданы компактные и автономные компоненты login.tsx и admin.tsx
- Оптимизированы стили для минимального набора компонентов
### Добавлено ### Добавлено
- Создана панель управления пользователями в админке: - Панель управления:
- Добавлен компонент UsersList для управления пользователями - Управление переменными окружения с группировкой по категориям
- Реализованы функции блокировки/разблокировки пользователей - Управление пользователями (блокировка, изменение ролей, отключение звука)
- Добавлена возможность отключения звука (mute) для пользователей - Пагинация и поиск пользователей по email, имени и ID
- Реализовано управление ролями пользователей через модальное окно - Расширение GraphQL схемы для админки:
- Добавлены GraphQL мутации для управления пользователями в schema/admin.graphql - Типы AdminUserInfo, AdminUserUpdateInput, AuthResult, Permission, SessionInfo
- Улучшен интерфейс админ-панели с табами для навигации - Мутации для управления пользователями и авторизации
- Расширена схема GraphQL для админки: - Улучшения серверной части:
- Добавлены типы AdminUserInfo и AdminUserUpdateInput - Поддержка HTTPS через Granian с помощью mkcert
- Добавлены мутации adminUpdateUser, adminToggleUserBlock, adminToggleUserMute - Параметры запуска `--https`, `--workers`, `--domain`
- Добавлены запросы adminGetUsers и adminGetRoles - Система авторизации и аутентификации:
- Пагинация списка пользователей в админ-панели - Локальная система аутентификации с сессиями в Redis
- Серверная поддержка пагинации в API для админ-панели - Система ролей и разрешений (RBAC)
- Поиск пользователей по email, имени и ID - Защита от брутфорс атак
- Поддержка локального запуска сервера с HTTPS через `python run.py --https` с использованием Granian - Поддержка httpOnly cookies для токенов
- Интеграция с инструментом mkcert для генерации доверенных локальных SSL-сертификатов - Мультиязычные email уведомления
- Поддержка запуска нескольких рабочих процессов через параметр `--workers`
- Возможность указать произвольный домен для сертификата через `--domain`
### Улучшено ### Изменено
- Улучшен интерфейс админ-панели: - Упрощена структура клиентской части приложения:
- Добавлены вкладки для переключения между разделами - Минималистичная архитектура с основными компонентами (авторизация и админка)
- Оптимизирован компонент UsersList для работы с большим количеством пользователей - Оптимизированы и унифицированы компоненты, следуя принципу DRY
- Добавлены индикаторы статуса для заблокированных и отключенных пользователей - Реализована система маршрутизации с защищенными маршрутами
- Улучшена обработка ошибок при выполнении операций с пользователями - Разделение ответственности между компонентами
- Добавлены подтверждения для критичных операций (блокировка, изменение ролей) - Типизированные интерфейсы для всех модулей
- Отказ от жестких редиректов в пользу SolidJS Router
### Полностью переработан клиентский код: - Переработан модуль авторизации:
- Создан компактный API клиент с изолированным кодом для доступа к API - Унификация типов для работы с пользователями
- Реализована модульная архитектура с четким разделением ответственности - Использование единого типа Author во всех запросах
- Добавлены типизированные интерфейсы для всех компонентов и модулей - Расширенное логирование для отладки
- Реализована система маршрутизации с защищенными маршрутами - Оптимизированное хранение и проверка токенов
- Добавлен компонент AuthProvider для управления авторизацией - Унифицированная обработка сессий
- Оптимизирована загрузка компонентов с использованием ленивой загрузки
- Унифицирован стиль кода и именования
### Исправлено ### Исправлено
- Исправлена критическая проблема с JWT-токенами авторизации: - Критические проблемы с JWT-токенами:
- Устранена ошибка декодирования токенов `int() argument must be a string, a bytes-like object or a real number, not 'NoneType'` - Корректная генерация срока истечения токенов (exp)
- Обновлен механизм создания токенов для гарантированного задания срока истечения (exp) - Стандартизованный формат параметров в JWT
- Улучшена обработка ошибок в модуле аутентификации для предотвращения создания невалидных токенов - Проверка обязательных полей при декодировании
- Стандартизован формат параметра exp в JWT: теперь всегда используется timestamp вместо datetime - Ошибки авторизации:
- Добавлена проверка наличия обязательных полей при декодировании токенов - "Cannot return null for non-nullable field Mutation.login"
- Оптимизирована совместимость между разными способами хранения сессий - "Author password is empty" при авторизации
- Исправлена проблема с перенаправлением в SolidJS, которое сбрасывало состояние приложения: - "Author object has no attribute username"
- Обновлена функция logout для использования колбэка навигации вместо жесткого редиректа - Обработка ошибок:
- Добавлен компонент LoginPage для авторизации без перезагрузки страницы - Улучшена валидация email и username
- Реализована ленивая загрузка компонентов с использованием Suspense - Исправлена обработка истекших токенов
- Улучшена структура роутинга в админ-панели - Добавлены проверки на NULL объекты в декораторах
- Оптимизирован код согласно принципам DRY и KISS - Вспомогательные компоненты:
- Исправлен метод dict() класса Author
- Добавлен AuthenticationMiddleware
- Реализован класс AuthenticatedUser
### Улучшения для авторизации в админ-панели ### Документировано
- Исправлена проблема с авторизацией в админ-панели
- Добавлена поддержка httpOnly cookies для безопасного хранения токена авторизации
- Реализован механизм выхода из системы через отзыв токенов
- Добавлен компонент для отображения списка пользователей в админке
- Добавлена постраничная навигация между управлением переменными окружения и списком пользователей
- Улучшена обработка сессий в API GraphQL
### Исправлено
- Переработан резолвер login_mutation для соответствия общему стилю других мутаций в кодбазе
- Реализована корректная обработка логина через `AuthResult`, устранена ошибка GraphQL "Cannot return null for non-nullable field Mutation.login"
- Улучшена обработка ошибок в модуле авторизации:
- Добавлена проверка корректности объекта автора перед созданием токена
- Исправлен порядок импорта резолверов для корректной регистрации обработчиков
- Добавлено расширенное логирование для отладки авторизации
- Гарантирован непустой возврат из резолвера login для предотвращения GraphQL ошибки
- Исправлена ошибка "Author password is empty" при авторизации:
- Добавлено поле password в метод dict() класса Author для корректной передачи при создании экземпляра из словаря
- Устранена ошибка `Author object has no attribute username` при создании токена авторизации:
- Добавлено свойство username в класс Author для совместимости с `TokenStorage`
- Исправлена HTML-форма на странице входа в админ-панель:
- Добавлен тег `<form>` для устранения предупреждения браузера о полях пароля вне формы
- Улучшена доступность и UX формы логина
- Добавлены атрибуты `autocomplete` для улучшения работы с менеджерами паролей
- Внедрена более строгая валидация полей и фокусировка на ошибках
### Added
- Подробная документация модуля аутентификации в `docs/auth.md`
- Система ролей и разрешений (RBAC)
- Защита от брутфорс атак
- Мультиязычная поддержка в email уведомлениях
- Подробная документация по системе авторизации в `docs/auth.md` - Подробная документация по системе авторизации в `docs/auth.md`
- Описание OAuth интеграции - Описание OAuth интеграции
- Руководство по RBAC - Руководство по RBAC
- Примеры использования на фронтенде - Примеры использования на фронтенде
- Инструкции по безопасности - Инструкции по безопасности
- Документация по тестированию
- Страница входа для неавторизованных пользователей в админке
- Публичное GraphQL API для модуля аутентификации:
- Типы: `AuthResult`, `Permission`, `SessionInfo`, `OAuthProvider`
- Мутации: `login`, `registerUser`, `sendLink`, `confirmEmail`, `getSession`, `changePassword`, `refreshToken`
- Запросы: `logout`, `me`, `isEmailUsed`, `getOAuthProviders`
### Changed #### [0.4.21] - 2025-05-10
- Переработана структура модуля auth для лучшей модульности
- Улучшена обработка ошибок в auth endpoints
- Оптимизировано хранение сессий в Redis
- Усилена безопасность хеширования паролей
- Удалена поддержка удаленной аутентификации в пользу единой локальной системы аутентификации
- Удалены настройки `AUTH_MODE` и `AUTH_URL`
- Удалены зависимости от внешнего сервиса авторизации
- Упрощен код аутентификации
- Консолидация типов для авторизации:
- Удален дублирующий тип `UserInfo`
- Расширен тип `Author` полями для работы с авторизацией (`roles`, `email_verified`)
- Использование единого типа `Author` во всех запросах авторизации
### Fixed
- Исправлена проблема с кэшированием разрешений
- Улучшена валидация email и username
- Исправлена обработка истекших токенов
- Исправлена ошибка в функции `get_with_stat` в модуле resolvers/stat.py: добавлен вызов метода `.unique()` для результатов запросов с joined eager loads
- Исправлены ошибки в декораторах auth:
- Добавлены проверки на None для объекта `info` в декораторах `admin_auth_required` и `require_permission`
- Улучшена обработка ошибок в GraphQL контексте
- Добавлен AuthenticationMiddleware с использованием InternalAuthentication для работы с request.auth
- Исправлена ошибка с классом InternalAuthentication:
- Добавлен класс AuthenticatedUser
- Реализован корректный возврат кортежа (AuthCredentials, BaseUser) из метода authenticate
#### [0.4.21] - 2023-09-10
### Изменено ### Изменено
- Переработана пагинация в админ-панели: переход с модели page/perPage на limit/offset - Переработана пагинация в админ-панели: переход с модели page/perPage на limit/offset
@ -155,7 +71,7 @@
- Исправлена ошибка GraphQL "Unknown argument 'page' on field 'Query.adminGetUsers'" - Исправлена ошибка GraphQL "Unknown argument 'page' on field 'Query.adminGetUsers'"
- Согласованы параметры пагинации между клиентом и сервером - Согласованы параметры пагинации между клиентом и сервером
#### [0.4.20] - 2023-09-01 #### [0.4.20] - 2025-05-01
### Добавлено ### Добавлено
- Пагинация списка пользователей в админ-панели - Пагинация списка пользователей в админ-панели

View File

@ -91,24 +91,8 @@ class Identity:
) )
raise InvalidPassword("Пароль не установлен для данного пользователя") raise InvalidPassword("Пароль не установлен для данного пользователя")
# Проверим словарь до создания нового объекта # Проверяем пароль напрямую, не используя dict()
author_dict = orm_author.dict() if not Password.verify(password, orm_author.password):
if "password" not in author_dict or not author_dict["password"]:
logger.warning(
f"[auth.identity] Пароль отсутствует в dict() или пуст: email={orm_author.email}"
)
raise InvalidPassword("Пароль отсутствует в данных пользователя")
# Создаем новый объект автора
author = Author(**author_dict)
if not author.password:
logger.warning(
f"[auth.identity] Пароль в созданном объекте автора пуст: email={orm_author.email}"
)
raise InvalidPassword("Пароль не установлен для данного пользователя")
# Проверяем пароль
if not Password.verify(password, author.password):
logger.warning(f"[auth.identity] Неверный пароль для {orm_author.email}") logger.warning(f"[auth.identity] Неверный пароль для {orm_author.email}")
raise InvalidPassword("Неверный пароль пользователя") raise InvalidPassword("Неверный пароль пользователя")

View File

@ -151,16 +151,16 @@ class InternalAuthentication(AuthenticationBackend):
return AuthCredentials(scopes={}, error_message="User not found"), UnauthenticatedUser() return AuthCredentials(scopes={}, error_message="User not found"), UnauthenticatedUser()
async def verify_internal_auth(token: str) -> Tuple[str, list]: async def verify_internal_auth(token: str) -> Tuple[str, list, bool]:
""" """
Проверяет локальную авторизацию. Проверяет локальную авторизацию.
Возвращает user_id и список ролей. Возвращает user_id, список ролей и флаг администратора.
Args: Args:
token: Токен авторизации (может быть как с Bearer, так и без) token: Токен авторизации (может быть как с Bearer, так и без)
Returns: Returns:
tuple: (user_id, roles) tuple: (user_id, roles, is_admin)
""" """
# Обработка формата "Bearer <token>" (если токен не был обработан ранее) # Обработка формата "Bearer <token>" (если токен не был обработан ранее)
if token.startswith("Bearer "): if token.startswith("Bearer "):
@ -169,7 +169,7 @@ async def verify_internal_auth(token: str) -> Tuple[str, list]:
# Проверяем сессию # Проверяем сессию
payload = await SessionManager.verify_session(token) payload = await SessionManager.verify_session(token)
if not payload: if not payload:
return "", [] return "", [], False
with local_session() as session: with local_session() as session:
try: try:
@ -183,9 +183,12 @@ async def verify_internal_auth(token: str) -> Tuple[str, list]:
# Получаем роли # Получаем роли
roles = [role.id for role in author.roles] roles = [role.id for role in author.roles]
return str(author.id), roles # Определяем, является ли пользователь администратором
is_admin = any(role in ['admin', 'super'] for role in roles) or author.email in ADMIN_EMAILS
return str(author.id), roles, is_admin
except exc.NoResultFound: except exc.NoResultFound:
return "", [] return "", [], False
async def create_internal_session(author: Author, device_info: Optional[dict] = None) -> str: async def create_internal_session(author: Author, device_info: Optional[dict] = None) -> str:
@ -202,8 +205,8 @@ async def create_internal_session(author: Author, device_info: Optional[dict] =
# Сбрасываем счетчик неудачных попыток # Сбрасываем счетчик неудачных попыток
author.reset_failed_login() author.reset_failed_login()
# Обновляем last_login # Обновляем last_seen
author.last_login = int(time.time()) author.last_seen = int(time.time())
# Создаем сессию, используя token для идентификации # Создаем сессию, используя token для идентификации
return await SessionManager.create_session( return await SessionManager.create_session(

View File

@ -5,6 +5,7 @@ from sqlalchemy.orm import relationship
from auth.identity import Password from auth.identity import Password
from services.db import Base from services.db import Base
from settings import ADMIN_EMAILS
# from sqlalchemy_utils import TSVectorType # from sqlalchemy_utils import TSVectorType
@ -165,7 +166,6 @@ class Author(Base):
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
email_verified = Column(Boolean, default=False) email_verified = Column(Boolean, default=False)
phone_verified = Column(Boolean, default=False) phone_verified = Column(Boolean, default=False)
last_login = Column(Integer, nullable=True)
failed_login_attempts = Column(Integer, default=0) failed_login_attempts = Column(Integer, default=0)
account_locked_until = Column(Integer, nullable=True) account_locked_until = Column(Integer, nullable=True)
@ -182,6 +182,9 @@ class Author(Base):
# TSVectorType("name", "slug", "bio", "about", regconfig="pg_catalog.russian") # TSVectorType("name", "slug", "bio", "about", regconfig="pg_catalog.russian")
# ) # )
# Список защищенных полей, которые видны только владельцу и администраторам
_protected_fields = ['email', 'password', 'provider_access_token', 'provider_refresh_token']
@property @property
def is_authenticated(self) -> bool: def is_authenticated(self) -> bool:
"""Проверяет, аутентифицирован ли пользователь""" """Проверяет, аутентифицирован ли пользователь"""
@ -238,22 +241,27 @@ class Author(Base):
""" """
return self.slug or self.email or self.phone or "" return self.slug or self.email or self.phone or ""
def dict(self) -> Dict: def dict(self, access=False) -> Dict:
"""Преобразует объект Author в словарь""" """
return { Сериализует объект Author в словарь с учетом прав доступа.
"id": self.id,
"slug": self.slug, Args:
"name": self.name, access (bool, optional): Флаг, указывающий, доступны ли защищенные поля
"bio": self.bio,
"about": self.about, Returns:
"pic": self.pic, dict: Словарь с атрибутами Author, отфильтрованный по правам доступа
"links": self.links, """
"email": self.email, # Получаем все атрибуты объекта
"password": self.password, result = {c.name: getattr(self, c.name) for c in self.__table__.columns}
"created_at": self.created_at,
"updated_at": self.updated_at, # Добавляем роли, если они есть
"last_seen": self.last_seen, if hasattr(self, 'roles') and self.roles:
"deleted_at": self.deleted_at, result['roles'] = [role.id for role in self.roles]
"roles": [role.id for role in self.roles],
"email_verified": self.email_verified, # скрываем защищенные поля
} if not access:
for field in self._protected_fields:
if field in result:
result[field] = None
return result

2
cache/cache.py vendored
View File

@ -320,7 +320,7 @@ async def get_cached_author_by_user_id(user_id: str, get_with_stat):
return orjson.loads(author_data) return orjson.loads(author_data)
# If data is not found in cache, query the database # If data is not found in cache, query the database
author_query = select(Author).where(Author.user == user_id) author_query = select(Author).where(Author.id == user_id)
authors = get_with_stat(author_query) authors = get_with_stat(author_query)
if authors: if authors:
# Cache the retrieved author data # Cache the retrieved author data

10
main.py
View File

@ -151,7 +151,14 @@ middleware = [
# CORS должен быть перед другими middleware для корректной обработки preflight-запросов # CORS должен быть перед другими middleware для корректной обработки preflight-запросов
Middleware( Middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=[
"https://localhost:3000",
"https://testing.discours.io",
"https://discours.io",
"https://new.discours.io",
"https://discours.ru",
"https://new.discours.ru"
],
allow_methods=["GET", "POST", "OPTIONS"], # Явно указываем OPTIONS allow_methods=["GET", "POST", "OPTIONS"], # Явно указываем OPTIONS
allow_headers=["*"], allow_headers=["*"],
allow_credentials=True, allow_credentials=True,
@ -183,6 +190,7 @@ async def graphql_handler(request: Request):
response.headers["Access-Control-Allow-Origin"] = "*" response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = "POST, GET, OPTIONS" response.headers["Access-Control-Allow-Methods"] = "POST, GET, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "*" response.headers["Access-Control-Allow-Headers"] = "*"
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Max-Age"] = "86400" # 24 hours response.headers["Access-Control-Max-Age"] = "86400" # 24 hours
return response return response

View File

@ -1,18 +1,14 @@
{ {
"name": "publy-admin", "name": "admin-panel",
"version": "0.4.20", "version": "0.4.22",
"private": true, "private": true,
"description": "admin panel",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"lint": "biome check . --fix", "lint": "biome check . --fix",
"format": "biome format . --write", "format": "biome format . --write",
"type-check": "tsc --noEmit", "typecheck": "tsc --noEmit"
"test": "vitest",
"build:auth": "vite build -c client/auth/vite.config.ts",
"watch:auth": "vite build -c client/auth/vite.config.ts --watch"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",

View File

@ -1,5 +1,5 @@
import { Component, Show, Suspense, createSignal, lazy, onMount } from 'solid-js' import { Component, Show, Suspense, createSignal, lazy, onMount, createEffect } from 'solid-js'
import { isAuthenticated } from './auth' import { isAuthenticated, getAuthTokenFromCookie } from './auth'
// Ленивая загрузка компонентов // Ленивая загрузка компонентов
const AdminPage = lazy(() => import('./admin')) const AdminPage = lazy(() => import('./admin'))
@ -11,14 +11,58 @@ const LoginPage = lazy(() => import('./login'))
const App: Component = () => { const App: Component = () => {
const [authenticated, setAuthenticated] = createSignal<boolean | null>(null) const [authenticated, setAuthenticated] = createSignal<boolean | null>(null)
const [loading, setLoading] = createSignal(true) const [loading, setLoading] = createSignal(true)
const [checkingAuth, setCheckingAuth] = createSignal(true)
// Проверяем авторизацию при монтировании // Проверяем авторизацию при монтировании
onMount(() => { onMount(() => {
const authed = isAuthenticated() checkAuthentication()
setAuthenticated(authed)
setLoading(false)
}) })
// Периодическая проверка авторизации
createEffect(() => {
const authCheckInterval = setInterval(() => {
// Перепроверяем статус авторизации каждые 60 секунд
if (!checkingAuth()) {
const authed = isAuthenticated()
if (!authed && authenticated()) {
console.log('Сессия истекла, требуется повторная авторизация')
setAuthenticated(false)
}
}
}, 60000)
return () => clearInterval(authCheckInterval)
})
// Функция проверки авторизации
const checkAuthentication = async () => {
setCheckingAuth(true)
setLoading(true)
try {
// Проверяем состояние авторизации
const authed = isAuthenticated()
// Если токен есть, но он невалидный, авторизация не удалась
if (authed) {
const token = getAuthTokenFromCookie() || localStorage.getItem('auth_token')
if (!token || token.length < 10) {
setAuthenticated(false)
} else {
setAuthenticated(true)
}
} else {
setAuthenticated(false)
}
} catch (error) {
console.error('Ошибка при проверке авторизации:', error)
setAuthenticated(false)
} finally {
setLoading(false)
setCheckingAuth(false)
}
}
// Обработчик успешной авторизации // Обработчик успешной авторизации
const handleLoginSuccess = () => { const handleLoginSuccess = () => {
setAuthenticated(true) setAuthenticated(true)
@ -35,7 +79,7 @@ const App: Component = () => {
fallback={ fallback={
<div class="loading-screen"> <div class="loading-screen">
<div class="loading-spinner" /> <div class="loading-spinner" />
<h2>Загрузка...</h2> <h2>Загрузка компонентов...</h2>
</div> </div>
} }
> >
@ -44,12 +88,12 @@ const App: Component = () => {
fallback={ fallback={
<div class="loading-screen"> <div class="loading-screen">
<div class="loading-spinner" /> <div class="loading-spinner" />
<h2>Загрузка...</h2> <h2>Проверка авторизации...</h2>
</div> </div>
} }
> >
{authenticated() ? ( {authenticated() ? (
<AdminPage onLogout={handleLogout} /> <AdminPage apiUrl={`${location.origin}/graphql`} onLogout={handleLogout} />
) : ( ) : (
<LoginPage onLoginSuccess={handleLoginSuccess} /> <LoginPage onLoginSuccess={handleLoginSuccess} />
)} )}

View File

@ -18,15 +18,13 @@ interface User {
roles: string[] roles: string[]
created_at?: number created_at?: number
last_seen?: number last_seen?: number
muted: boolean
is_active: boolean
} }
/** /**
* Интерфейс для роли пользователя * Интерфейс для роли пользователя
*/ */
interface Role { interface Role {
id: number id: string // ID роли - строка, не число
name: string name: string
description?: string description?: string
} }
@ -52,27 +50,37 @@ interface AdminGetRolesResponse {
} }
/** /**
* Интерфейс для ответа изменения статуса пользователя * Интерфейс для ответа обновления пользователя
*/ */
interface AdminSetUserStatusResponse { interface AdminUpdateUserResponse {
adminSetUserStatus: { adminUpdateUser: boolean
success: boolean
error?: string
}
} }
/** /**
* Интерфейс для ответа изменения статуса блокировки чата * Интерфейс для переменной окружения
*/ */
interface AdminMuteUserResponse { interface EnvVariable {
adminMuteUser: { key: string
success: boolean value: string
error?: string description?: string
} type: string
isSecret: boolean
} }
// Интерфейс для пропсов AdminPage /**
* Интерфейс для секции переменных окружения
*/
interface EnvSection {
name: string
description?: string
variables: EnvVariable[]
}
/**
* Интерфейс свойств компонента AdminPage
*/
interface AdminPageProps { interface AdminPageProps {
apiUrl: string
onLogout?: () => void onLogout?: () => void
} }
@ -89,6 +97,12 @@ const AdminPage: Component<AdminPageProps> = (props) => {
const [showRolesModal, setShowRolesModal] = createSignal(false) const [showRolesModal, setShowRolesModal] = createSignal(false)
const [successMessage, setSuccessMessage] = createSignal<string | null>(null) const [successMessage, setSuccessMessage] = createSignal<string | null>(null)
// Переменные среды
const [envSections, setEnvSections] = createSignal<EnvSection[]>([])
const [envLoading, setEnvLoading] = createSignal(false)
const [editingVariable, setEditingVariable] = createSignal<EnvVariable | null>(null)
const [showVariableModal, setShowVariableModal] = createSignal(false)
// Параметры пагинации // Параметры пагинации
const [pagination, setPagination] = createSignal<{ const [pagination, setPagination] = createSignal<{
page: number page: number
@ -137,8 +151,6 @@ const AdminPage: Component<AdminPageProps> = (props) => {
roles roles
created_at created_at
last_seen last_seen
muted
is_active
} }
total total
page page
@ -260,104 +272,6 @@ const AdminPage: Component<AdminPageProps> = (props) => {
} }
} }
/**
* Блокирует/разблокирует пользователя
* @param userId - ID пользователя
* @param isActive - Текущий статус активности
*/
async function toggleUserBlock(userId: number, isActive: boolean) {
try {
setError(null)
// Устанавливаем новый статус (противоположный текущему)
const newStatus = !isActive
// Выполняем мутацию
const result = await query<AdminSetUserStatusResponse>(
`${location.origin}/graphql`,
`
mutation AdminSetUserStatus($userId: Int!, $isActive: Boolean!) {
adminSetUserStatus(userId: $userId, isActive: $isActive) {
success
error
}
}
`,
{ userId, isActive: newStatus }
)
// Проверяем результат
if (result?.adminSetUserStatus?.success) {
// Обновляем список пользователей
setSuccessMessage(`Пользователь ${newStatus ? 'разблокирован' : 'заблокирован'}`)
// Обновляем пользователя в текущем списке
setUsers(
users().map((user) =>
user.id === userId ? { ...user, is_active: newStatus } : user
)
)
// Скрываем сообщение через 3 секунды
setTimeout(() => setSuccessMessage(null), 3000)
} else {
setError(result?.adminSetUserStatus?.error || 'Ошибка обновления статуса пользователя')
}
} catch (err) {
console.error('Ошибка при изменении статуса пользователя:', err)
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
}
}
/**
* Включает/отключает режим блокировки чата для пользователя
* @param userId - ID пользователя
* @param isMuted - Текущий статус блокировки чата
*/
async function toggleUserMute(userId: number, isMuted: boolean) {
try {
setError(null)
// Устанавливаем новый статус (противоположный текущему)
const newMuteStatus = !isMuted
// Выполняем мутацию
const result = await query<AdminMuteUserResponse>(
`${location.origin}/graphql`,
`
mutation AdminMuteUser($userId: Int!, $muted: Boolean!) {
adminMuteUser(userId: $userId, muted: $muted) {
success
error
}
}
`,
{ userId, muted: newMuteStatus }
)
// Проверяем результат
if (result?.adminMuteUser?.success) {
// Обновляем сообщение об успехе
setSuccessMessage(`${newMuteStatus ? 'Блокировка' : 'Разблокировка'} чата выполнена`)
// Обновляем пользователя в текущем списке
setUsers(
users().map((user) =>
user.id === userId ? { ...user, muted: newMuteStatus } : user
)
)
// Скрываем сообщение через 3 секунды
setTimeout(() => setSuccessMessage(null), 3000)
} else {
setError(result?.adminMuteUser?.error || 'Ошибка обновления статуса блокировки чата')
}
} catch (err) {
console.error('Ошибка при изменении статуса блокировки чата:', err)
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
}
}
/** /**
* Закрывает модальное окно ролей * Закрывает модальное окно ролей
*/ */
@ -373,19 +287,18 @@ const AdminPage: Component<AdminPageProps> = (props) => {
*/ */
async function updateUserRoles(userId: number, newRoles: string[]) { async function updateUserRoles(userId: number, newRoles: string[]) {
try { try {
await query( await query<AdminUpdateUserResponse>(
`${location.origin}/graphql`, `${location.origin}/graphql`,
` `
mutation AdminUpdateUser($userId: Int!, $input: AdminUserUpdateInput!) { mutation AdminUpdateUser($user: AdminUserUpdateInput!) {
adminUpdateUser(userId: $userId, input: $input) { adminUpdateUser(user: $user)
success
error
}
} }
`, `,
{ {
userId, user: {
input: { roles: newRoles } id: userId,
roles: newRoles
}
} }
) )
@ -414,20 +327,171 @@ const AdminPage: Component<AdminPageProps> = (props) => {
} }
/** /**
* Выход из системы * Обрабатывает выход из системы
*/ */
function handleLogout() { const handleLogout = async () => {
// Сначала выполняем локальные действия по очистке данных try {
setUsers([]) await logout()
setRoles([])
// Затем выполняем выход
logout(() => {
// Вызываем коллбэк для оповещения родителя о выходе
if (props.onLogout) { if (props.onLogout) {
props.onLogout() props.onLogout()
} }
}) } catch (error) {
setError('Ошибка при выходе: ' + (error as Error).message)
}
}
/**
* Форматирование даты в формате "X дней назад"
* @param timestamp - Временная метка
* @returns Форматированная строка с относительной датой
*/
function formatDateRelative(timestamp?: number): string {
if (!timestamp) return 'Н/Д'
const now = Math.floor(Date.now() / 1000)
const diff = now - timestamp
// Меньше минуты
if (diff < 60) {
return 'только что'
}
// Меньше часа
if (diff < 3600) {
const minutes = Math.floor(diff / 60)
return `${minutes} ${getMinutesForm(minutes)} назад`
}
// Меньше суток
if (diff < 86400) {
const hours = Math.floor(diff / 3600)
return `${hours} ${getHoursForm(hours)} назад`
}
// Меньше 30 дней
if (diff < 2592000) {
const days = Math.floor(diff / 86400)
return `${days} ${getDaysForm(days)} назад`
}
// Меньше года
if (diff < 31536000) {
const months = Math.floor(diff / 2592000)
return `${months} ${getMonthsForm(months)} назад`
}
// Больше года
const years = Math.floor(diff / 31536000)
return `${years} ${getYearsForm(years)} назад`
}
/**
* Получение правильной формы слова "минута" в зависимости от числа
* @param minutes - Количество минут
*/
function getMinutesForm(minutes: number): string {
if (minutes % 10 === 1 && minutes % 100 !== 11) {
return 'минуту'
} else if ([2, 3, 4].includes(minutes % 10) && ![12, 13, 14].includes(minutes % 100)) {
return 'минуты'
}
return 'минут'
}
/**
* Получение правильной формы слова "час" в зависимости от числа
* @param hours - Количество часов
*/
function getHoursForm(hours: number): string {
if (hours % 10 === 1 && hours % 100 !== 11) {
return 'час'
} else if ([2, 3, 4].includes(hours % 10) && ![12, 13, 14].includes(hours % 100)) {
return 'часа'
}
return 'часов'
}
/**
* Получение правильной формы слова "день" в зависимости от числа
* @param days - Количество дней
*/
function getDaysForm(days: number): string {
if (days % 10 === 1 && days % 100 !== 11) {
return 'день'
} else if ([2, 3, 4].includes(days % 10) && ![12, 13, 14].includes(days % 100)) {
return 'дня'
}
return 'дней'
}
/**
* Получение правильной формы слова "месяц" в зависимости от числа
* @param months - Количество месяцев
*/
function getMonthsForm(months: number): string {
if (months % 10 === 1 && months % 100 !== 11) {
return 'месяц'
} else if ([2, 3, 4].includes(months % 10) && ![12, 13, 14].includes(months % 100)) {
return 'месяца'
}
return 'месяцев'
}
/**
* Получение правильной формы слова "год" в зависимости от числа
* @param years - Количество лет
*/
function getYearsForm(years: number): string {
if (years % 10 === 1 && years % 100 !== 11) {
return 'год'
} else if ([2, 3, 4].includes(years % 10) && ![12, 13, 14].includes(years % 100)) {
return 'года'
}
return 'лет'
}
/**
* Получает иконку для роли пользователя
* @param role - Название роли
* @returns Иконка для роли
*/
function getRoleIcon(role: string): string {
switch (role.toLowerCase()) {
case 'admin':
return '👑' // корона для администратора
case 'moderator':
return '🛡️' // щит для модератора
case 'editor':
return '✏️' // карандаш для редактора
case 'author':
return '📝' // блокнот для автора
case 'user':
return '👤' // фигура для обычного пользователя
case 'subscriber':
return '📬' // почтовый ящик для подписчика
case 'guest':
return '👋' // рука для гостя
case 'banned':
return '🚫' // знак запрета для заблокированного
case 'vip':
return '⭐' // звезда для VIP
case 'verified':
return '✓' // галочка для верифицированного
default:
return '🔹' // точка для прочих ролей
}
}
/**
* Компонент для отображения роли с иконкой
*/
const RoleBadge: Component<{ role: string }> = (props) => {
return (
<span class="role-badge" title={props.role}>
<span class="role-icon">{getRoleIcon(props.role)}</span>
<span class="role-name">{props.role}</span>
</span>
)
} }
/** /**
@ -537,6 +601,25 @@ const AdminPage: Component<AdminPageProps> = (props) => {
const user = selectedUser() const user = selectedUser()
const [selectedRoles, setSelectedRoles] = createSignal<string[]>(user ? [...user.roles] : []) const [selectedRoles, setSelectedRoles] = createSignal<string[]>(user ? [...user.roles] : [])
// Получаем дополнительные описания ролей
const getRoleDescription = (roleId: string): string => {
// Если есть описание в списке ролей, используем его
const roleFromList = roles().find(r => r.id === roleId);
if (roleFromList?.description) {
return roleFromList.description;
}
// Иначе возвращаем стандартное описание
switch(roleId) {
case 'reader':
return 'Базовая роль. Позволяет авторизоваться и оставлять реакции.';
case 'author':
return 'Расширенная роль. Позволяет создавать контент и голосовать за публикации для вывода на главную страницу.';
default:
return 'Нет описания';
}
};
const toggleRole = (role: string) => { const toggleRole = (role: string) => {
const current = selectedRoles() const current = selectedRoles()
if (current.includes(role)) { if (current.includes(role)) {
@ -560,6 +643,11 @@ const AdminPage: Component<AdminPageProps> = (props) => {
<h2>Управление ролями пользователя</h2> <h2>Управление ролями пользователя</h2>
<p>Пользователь: {user.email}</p> <p>Пользователь: {user.email}</p>
<div class="role-info">
<p><strong>Внимание:</strong> Снятие роли "reader" блокирует доступ пользователя к системе.</p>
<p>Роль "author" дает возможность голосовать за публикации для размещения на главной странице.</p>
</div>
<div class="roles-list"> <div class="roles-list">
<For each={roles()}> <For each={roles()}>
{(role) => ( {(role) => (
@ -567,14 +655,12 @@ const AdminPage: Component<AdminPageProps> = (props) => {
<label> <label>
<input <input
type="checkbox" type="checkbox"
checked={selectedRoles().includes(role.name)} checked={selectedRoles().includes(role.id)}
onChange={() => toggleRole(role.name)} onChange={() => toggleRole(role.id)}
/> />
{role.name} {role.id}
</label> </label>
<Show when={role.description}> <p class="role-description">{getRoleDescription(role.id)}</p>
<p class="role-description">{role.description}</p>
</Show>
</div> </div>
)} )}
</For> </For>
@ -593,6 +679,250 @@ const AdminPage: Component<AdminPageProps> = (props) => {
) )
} }
/**
* Загружает переменные окружения
*/
const loadEnvVariables = async () => {
try {
setEnvLoading(true)
setError(null)
const result = await query(props.apiUrl, `
query GetEnvVariables {
getEnvVariables {
name
description
variables {
key
value
description
type
isSecret
}
}
}
`)
if (result.getEnvVariables) {
setEnvSections(result.getEnvVariables as EnvSection[])
}
} catch (err) {
console.error('Ошибка загрузки переменных окружения:', err)
setError('Не удалось загрузить переменные окружения: ' + (err as Error).message)
// Если ошибка авторизации - перенаправляем на логин
if (
err instanceof Error &&
(err.message.includes('401') ||
err.message.includes('авторизации') ||
err.message.includes('unauthorized') ||
err.message.includes('Unauthorized'))
) {
handleLogout()
}
} finally {
setEnvLoading(false)
}
}
/**
* Обновляет значение переменной окружения
*/
const updateEnvVariable = async (key: string, value: string) => {
try {
setError(null)
setSuccessMessage(null)
const result = await query(props.apiUrl, `
mutation UpdateEnvVariable($key: String!, $value: String!) {
updateEnvVariable(key: $key, value: $value)
}
`, { key, value })
if (result.updateEnvVariable) {
setSuccessMessage(`Переменная ${key} успешно обновлена`)
// Обновляем список переменных
await loadEnvVariables()
} else {
setError('Не удалось обновить переменную')
}
} catch (err) {
console.error('Ошибка обновления переменной:', err)
setError('Ошибка при обновлении переменной: ' + (err as Error).message)
// Если ошибка авторизации - перенаправляем на логин
if (
err instanceof Error &&
(err.message.includes('401') ||
err.message.includes('авторизации') ||
err.message.includes('unauthorized') ||
err.message.includes('Unauthorized'))
) {
handleLogout()
}
}
}
/**
* Обработчик открытия модального окна редактирования переменной
*/
const openVariableModal = (variable: EnvVariable) => {
setEditingVariable({ ...variable })
setShowVariableModal(true)
}
/**
* Обработчик закрытия модального окна редактирования переменной
*/
const closeVariableModal = () => {
setEditingVariable(null)
setShowVariableModal(false)
}
/**
* Обработчик сохранения переменной
*/
const saveVariable = async () => {
const variable = editingVariable()
if (!variable) return
await updateEnvVariable(variable.key, variable.value)
closeVariableModal()
}
/**
* Обработчик изменения значения в модальном окне
*/
const handleVariableValueChange = (value: string) => {
const variable = editingVariable()
if (variable) {
setEditingVariable({ ...variable, value })
}
}
/**
* Загружает список переменных среды при переключении на соответствующую вкладку
*/
const handleTabChange = (tab: string) => {
setActiveTab(tab)
if (tab === 'env' && envSections().length === 0) {
loadEnvVariables()
}
}
/**
* Компонент модального окна для редактирования переменной окружения
*/
const VariableModal: Component = () => {
const variable = editingVariable()
if (!variable) return null
return (
<div class="modal-overlay">
<div class="modal-content">
<h2>Редактирование переменной</h2>
<p>Переменная: {variable.key}</p>
<div class="variable-edit-form">
<div class="form-group">
<label>Значение:</label>
<input
type={variable.isSecret ? 'password' : 'text'}
value={variable.value}
onInput={(e) => handleVariableValueChange(e.target.value)}
/>
</div>
<Show when={variable.description}>
<div class="variable-description">
<p>{variable.description}</p>
</div>
</Show>
</div>
<div class="modal-actions">
<button class="cancel-button" onClick={closeVariableModal}>
Отмена
</button>
<button class="save-button" onClick={saveVariable}>
Сохранить
</button>
</div>
</div>
</div>
)
}
/**
* Компонент для отображения переменных окружения
*/
const EnvVariablesTab: Component = () => {
return (
<div class="env-variables-container">
<Show when={envLoading()}>
<div class="loading">Загрузка переменных окружения...</div>
</Show>
<Show when={!envLoading() && envSections().length === 0}>
<div class="empty-state">Нет доступных переменных окружения</div>
</Show>
<Show when={!envLoading() && envSections().length > 0}>
<div class="env-sections">
<For each={envSections()}>
{(section) => (
<div class="env-section">
<h3 class="section-name">{section.name}</h3>
<Show when={section.description}>
<p class="section-description">{section.description}</p>
</Show>
<div class="variables-list">
<table>
<thead>
<tr>
<th>Ключ</th>
<th>Значение</th>
<th>Описание</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<For each={section.variables}>
{(variable) => (
<tr>
<td>{variable.key}</td>
<td>
{variable.isSecret
? '••••••••'
: (variable.value || <span class="empty-value">не задано</span>)}
</td>
<td>{variable.description || '-'}</td>
<td class="actions">
<button
class="edit-button"
onClick={() => openVariableModal(variable)}
>
Изменить
</button>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
)
}
return ( return (
<div class="admin-page"> <div class="admin-page">
<header> <header>
@ -604,9 +934,12 @@ const AdminPage: Component<AdminPageProps> = (props) => {
</div> </div>
<nav class="admin-tabs"> <nav class="admin-tabs">
<button class={activeTab() === 'users' ? 'active' : ''} onClick={() => setActiveTab('users')}> <button class={activeTab() === 'users' ? 'active' : ''} onClick={() => handleTabChange('users')}>
Пользователи Пользователи
</button> </button>
<button class={activeTab() === 'env' ? 'active' : ''} onClick={() => handleTabChange('env')}>
Переменные среды
</button>
</nav> </nav>
</header> </header>
@ -619,90 +952,90 @@ const AdminPage: Component<AdminPageProps> = (props) => {
<div class="success-message">{successMessage()}</div> <div class="success-message">{successMessage()}</div>
</Show> </Show>
<Show when={loading()}> <Show when={activeTab() === 'users'}>
<div class="loading">Загрузка данных...</div> <Show when={loading()}>
</Show> <div class="loading">Загрузка данных...</div>
</Show>
<Show when={!loading() && users().length === 0 && !error()}> <Show when={!loading() && users().length === 0 && !error()}>
<div class="empty-state">Нет данных для отображения</div> <div class="empty-state">Нет данных для отображения</div>
</Show> </Show>
<Show when={!loading() && users().length > 0}> <Show when={!loading() && users().length > 0}>
<div class="users-controls"> <div class="users-controls">
<div class="search-container"> <div class="search-container">
<div class="search-input-group"> <div class="search-input-group">
<input <input
type="text" type="text"
placeholder="Поиск по email, имени или ID..." placeholder="Поиск по email, имени или ID..."
value={searchQuery()} value={searchQuery()}
onInput={handleSearchChange} onInput={handleSearchChange}
onKeyDown={handleSearchKeyDown} onKeyDown={handleSearchKeyDown}
class="search-input" class="search-input"
/> />
<button class="search-button" onClick={handleSearch}> <button class="search-button" onClick={handleSearch}>
Поиск Поиск
</button> </button>
</div>
</div> </div>
</div> </div>
</div>
<div class="users-list"> <div class="users-list">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Email</th> <th>Email</th>
<th>Имя</th> <th>Имя</th>
<th>Роли</th> <th>Роли</th>
<th>Создан</th> <th>Создан</th>
<th>Последний вход</th> </tr>
<th>Статус</th> </thead>
<th>Действия</th> <tbody>
</tr> <For each={users()}>
</thead> {(user) => (
<tbody> <tr>
<For each={users()}> <td>{user.id}</td>
{(user) => ( <td>{user.email}</td>
<tr class={user.is_active ? '' : 'blocked'}> <td>{user.name || '-'}</td>
<td>{user.id}</td> <td class="roles-cell">
<td>{user.email}</td> <div class="roles-container">
<td>{user.name || '-'}</td> <For each={user.roles}>
<td>{user.roles.join(', ') || '-'}</td> {(role) => <RoleBadge role={role} />}
<td>{formatDate(user.created_at)}</td> </For>
<td>{formatDate(user.last_seen)}</td> <div class="role-badge" onClick={() => {
<td> setSelectedUser(user)
<span class={`status ${user.is_active ? 'active' : 'inactive'}`}> setShowRolesModal(true)
{user.is_active ? 'Активен' : 'Заблокирован'} }}
</span> >
</td> 🎭
<td class="actions"> </div>
<button </div>
class={user.is_active ? 'block' : 'unblock'} </td>
onClick={() => toggleUserBlock(user.id, user.is_active)} <td>{formatDateRelative(user.created_at)}</td>
> </tr>
{user.is_active ? 'Блокировать' : 'Разблокировать'} )}
</button> </For>
<button </tbody>
class={user.muted ? 'unmute' : 'mute'} </table>
onClick={() => toggleUserMute(user.id, user.muted)} </div>
>
{user.muted ? 'Unmute' : 'Mute'}
</button>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
<Pagination /> <Pagination />
</Show>
</Show>
<Show when={activeTab() === 'env'}>
<EnvVariablesTab />
</Show> </Show>
</main> </main>
<Show when={showRolesModal()}> <Show when={showRolesModal()}>
<RolesModal /> <RolesModal />
</Show> </Show>
<Show when={showVariableModal()}>
<VariableModal />
</Show>
</div> </div>
) )
} }

View File

@ -67,6 +67,11 @@ function hasAuthErrors(errors: Array<{ message?: string; extensions?: { code?: s
* @returns Полный URL для запроса * @returns Полный URL для запроса
*/ */
function prepareUrl(url: string): string { function prepareUrl(url: string): string {
// В режиме локальной разработки всегда используем /graphql
if (location.hostname === 'localhost') {
return `${location.origin}/graphql`
}
// Если это относительный путь, добавляем к нему origin // Если это относительный путь, добавляем к нему origin
if (url.startsWith('/')) { if (url.startsWith('/')) {
return `${location.origin}${url}` return `${location.origin}${url}`

View File

@ -425,10 +425,11 @@ button.unmute {
} }
.cancel-button { .cancel-button {
color: #333 !important;
padding: 8px 16px; padding: 8px 16px;
background-color: #ccc; background-color: #ccc;
color: #333;
width: auto; width: auto;
border: 1px solid #ccc;
} }
.save-button { .save-button {
@ -598,3 +599,286 @@ button.unmute {
0% { transform: rotate(0deg); } 0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); } 100% { transform: rotate(360deg); }
} }
/* Стили для вкладки с переменными окружения */
.env-variables-container {
margin-top: 1.5rem;
}
.env-section {
background-color: var(--card-bg);
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
padding: 20px;
margin-bottom: 20px;
}
.section-name {
margin-top: 0;
color: var(--primary-color);
font-size: 20px;
margin-bottom: 10px;
}
.section-description {
color: var(--text-secondary);
margin-bottom: 15px;
font-size: 14px;
}
.variable-edit-form {
margin-bottom: 20px;
}
.variable-description {
margin-top: 10px;
font-style: italic;
color: var(--text-secondary);
font-size: 14px;
}
.empty-value {
color: var(--text-secondary);
font-style: italic;
}
button.edit-button {
background-color: var(--primary-color);
color: white;
padding: 5px 10px;
font-size: 12px;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
width: auto;
}
button.edit-button:hover {
background-color: var(--primary-dark);
}
.success-message {
background-color: var(--success-light);
color: var(--success-color);
padding: 10px 15px;
border-radius: 4px;
margin-bottom: 15px;
}
.error-message {
background-color: var(--danger-light);
color: var(--danger-color);
padding: 10px 15px;
border-radius: 4px;
margin-bottom: 15px;
}
/* Стили для модального окна редактирования */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 500px;
}
.modal-content h2 {
margin-top: 0;
color: var(--primary-color);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
button.cancel-button {
background-color: var(--text-secondary);
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
width: auto;
}
button.save-button {
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
width: auto;
}
button.cancel-button:hover {
background-color: #999;
}
button.save-button:hover {
background-color: var(--primary-dark);
}
/* Стили для компонентов ролей */
.roles-cell {
max-width: 200px;
}
.roles-container {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.role-badge {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 12px;
background-color: rgba(0, 0, 0, 0.05);
margin: 2px 0;
white-space: nowrap;
font-size: 0.85em;
}
.role-icon {
margin-right: 4px;
font-size: 1.1em;
}
.edit-roles {
background-color: #8a2be2;
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
margin-left: 4px;
}
.edit-roles:hover {
background-color: #7b1fa2;
}
/* Стили компонентов ролей */
.roles-cell {
max-width: 200px;
}
.roles-container {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.role-badge {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 12px;
background-color: rgba(0, 0, 0, 0.05);
margin: 2px 0;
white-space: nowrap;
font-size: 0.85em;
}
.role-icon {
margin-right: 4px;
font-size: 1.1em;
}
.edit-roles {
background-color: #8a2be2;
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
margin-left: 4px;
}
.edit-roles:hover {
background-color: #7b1fa2;
}
/* Стили для сортировки таблицы */
th.sortable {
cursor: pointer;
user-select: none;
position: relative;
padding-right: 20px;
}
th.sortable:hover {
background-color: rgba(0, 0, 0, 0.05);
}
th.sortable.sorted {
background-color: rgba(65, 105, 225, 0.1);
}
.sort-icon {
display: inline-block;
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
color: #888;
font-size: 14px;
}
th.sortable.sorted .sort-icon {
color: #4169e1;
font-weight: bold;
}
/* Стили для сортировки таблицы */
th.sortable {
cursor: pointer;
user-select: none;
position: relative;
padding-right: 20px;
}
th.sortable:hover {
background-color: rgba(0, 0, 0, 0.05);
}
th.sortable.sorted {
background-color: rgba(65, 105, 225, 0.1);
}
.sort-icon {
display: inline-block;
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
color: #888;
font-size: 14px;
}
th.sortable.sorted .sort-icon {
color: #4169e1;
font-weight: bold;
}

View File

@ -1,11 +1,12 @@
from math import ceil from math import ceil
from sqlalchemy import or_ from sqlalchemy import or_, cast, String
from graphql.error import GraphQLError from graphql.error import GraphQLError
from auth.decorators import admin_auth_required from auth.decorators import admin_auth_required
from services.db import local_session from services.db import local_session
from services.schema import query from services.schema import query, mutation
from auth.orm import Author, Role from auth.orm import Author, Role, AuthorRole
from services.env import EnvManager, EnvVariable
from utils.logger import root_logger as logger from utils.logger import root_logger as logger
@ -40,7 +41,7 @@ async def admin_get_users(_, info, limit=10, offset=0, search=None):
or_( or_(
Author.email.ilike(search_term), Author.email.ilike(search_term),
Author.name.ilike(search_term), Author.name.ilike(search_term),
Author.id.cast(str).ilike(search_term), cast(Author.id, String).ilike(search_term),
) )
) )
@ -67,9 +68,7 @@ async def admin_get_users(_, info, limit=10, offset=0, search=None):
if hasattr(user, "roles") and user.roles if hasattr(user, "roles") and user.roles
else [], else [],
"created_at": user.created_at, "created_at": user.created_at,
"last_seen": user.last_seen, "last_seen": user.last_seen
"muted": user.muted or False,
"is_active": not user.blocked if hasattr(user, "blocked") else True,
} }
for user in users for user in users
], ],
@ -120,3 +119,179 @@ async def admin_get_roles(_, info):
except Exception as e: except Exception as e:
logger.error(f"Ошибка при получении списка ролей: {str(e)}") logger.error(f"Ошибка при получении списка ролей: {str(e)}")
raise GraphQLError(f"Не удалось получить список ролей: {str(e)}") raise GraphQLError(f"Не удалось получить список ролей: {str(e)}")
@query.field("getEnvVariables")
@admin_auth_required
async def get_env_variables(_, info):
"""
Получает список переменных окружения, сгруппированных по секциям
Args:
info: Контекст GraphQL запроса
Returns:
Список секций с переменными окружения
"""
try:
# Создаем экземпляр менеджера переменных окружения
env_manager = EnvManager()
# Получаем все переменные
sections = env_manager.get_all_variables()
# Преобразуем к формату GraphQL API
result = [
{
"name": section.name,
"description": section.description,
"variables": [
{
"key": var.key,
"value": var.value,
"description": var.description,
"type": var.type,
"isSecret": var.is_secret,
}
for var in section.variables
]
}
for section in sections
]
return result
except Exception as e:
logger.error(f"Ошибка при получении переменных окружения: {str(e)}")
raise GraphQLError(f"Не удалось получить переменные окружения: {str(e)}")
@mutation.field("updateEnvVariable")
@admin_auth_required
async def update_env_variable(_, info, key, value):
"""
Обновляет значение переменной окружения
Args:
info: Контекст GraphQL запроса
key: Ключ переменной
value: Новое значение
Returns:
Boolean: результат операции
"""
try:
# Создаем экземпляр менеджера переменных окружения
env_manager = EnvManager()
# Обновляем переменную
result = env_manager.update_variable(key, value)
if result:
logger.info(f"Переменная окружения '{key}' успешно обновлена")
else:
logger.error(f"Не удалось обновить переменную окружения '{key}'")
return result
except Exception as e:
logger.error(f"Ошибка при обновлении переменной окружения: {str(e)}")
return False
@mutation.field("updateEnvVariables")
@admin_auth_required
async def update_env_variables(_, info, variables):
"""
Массовое обновление переменных окружения
Args:
info: Контекст GraphQL запроса
variables: Список переменных для обновления
Returns:
Boolean: результат операции
"""
try:
# Создаем экземпляр менеджера переменных окружения
env_manager = EnvManager()
# Преобразуем входные данные в формат для менеджера
env_variables = [
EnvVariable(
key=var.get("key", ""),
value=var.get("value", ""),
type=var.get("type", "string")
)
for var in variables
]
# Обновляем переменные
result = env_manager.update_variables(env_variables)
if result:
logger.info(f"Переменные окружения успешно обновлены ({len(variables)} шт.)")
else:
logger.error(f"Не удалось обновить переменные окружения")
return result
except Exception as e:
logger.error(f"Ошибка при массовом обновлении переменных окружения: {str(e)}")
return False
@mutation.field("adminUpdateUser")
@admin_auth_required
async def admin_update_user(_, info, user):
"""
Обновляет роли пользователя
Args:
info: Контекст GraphQL запроса
user: Данные для обновления пользователя (содержит id и roles)
Returns:
Boolean: результат операции
"""
try:
user_id = user.get("id")
roles = user.get("roles", [])
if not roles:
logger.warning(f"Пользователю {user_id} не назначено ни одной роли. Доступ в систему будет заблокирован.")
with local_session() as session:
# Получаем пользователя из базы данных
author = session.query(Author).filter(Author.id == user_id).first()
if not author:
logger.error(f"Пользователь с ID {user_id} не найден")
return False
# Получаем текущие роли пользователя
current_roles = {role.id for role in author.roles} if author.roles else set()
# Обновляем роли только если они изменились
if set(roles) != current_roles:
# Получаем все существующие роли, которые указаны для обновления
role_objects = session.query(Role).filter(Role.id.in_(roles)).all()
# Очищаем текущие роли и добавляем новые
author.roles = role_objects
# Сохраняем изменения в базе данных
session.commit()
# Проверяем, добавлена ли пользователю роль reader
has_reader = 'reader' in roles
if not has_reader:
logger.warning(f"Пользователю {author.email or author.id} не назначена роль 'reader'. Доступ в систему будет ограничен.")
logger.info(f"Роли пользователя {author.email or author.id} обновлены: {', '.join(roles)}")
else:
logger.info(f"Роли пользователя {author.email or author.id} не изменились")
return True
except Exception as e:
import traceback
logger.error(f"Ошибка при обновлении ролей пользователя: {str(e)}")
logger.error(traceback.format_exc())
return False

View File

@ -40,6 +40,7 @@ async def get_current_user(_, info):
author.last_seen = int(time.time()) author.last_seen = int(time.time())
session.commit() session.commit()
# Здесь можно не применять фильтрацию, так как пользователь получает свои данные
return {"token": token, "author": author} return {"token": token, "author": author}
@ -76,6 +77,7 @@ async def confirm_email(_, info, token):
session.add(user) session.add(user)
session.commit() session.commit()
logger.info(f"[auth] confirmEmail: Email для пользователя {user_id} успешно подтвержден.") logger.info(f"[auth] confirmEmail: Email для пользователя {user_id} успешно подтвержден.")
# Здесь можно не применять фильтрацию, так как пользователь получает свои данные
return {"success": True, "token": session_token, "author": user, "error": None} return {"success": True, "token": session_token, "author": user, "error": None}
except InvalidToken as e: except InvalidToken as e:
logger.warning(f"[auth] confirmEmail: Невалидный токен - {e.message}") logger.warning(f"[auth] confirmEmail: Невалидный токен - {e.message}")
@ -166,6 +168,7 @@ async def register_by_email(_, _info, email: str, password: str = "", name: str
logger.info( logger.info(
f"[auth] registerUser: Пользователь {email} зарегистрирован, ссылка для подтверждения отправлена." f"[auth] registerUser: Пользователь {email} зарегистрирован, ссылка для подтверждения отправлена."
) )
# При регистрации возвращаем данные самому пользователю, поэтому не фильтруем
return { return {
"success": True, "success": True,
"token": None, "token": None,
@ -238,20 +241,51 @@ async def login(_, info, email: str, password: str):
f"[auth] login: Найден автор {email}, id={author.id}, имя={author.name}, пароль есть: {bool(author.password)}" f"[auth] login: Найден автор {email}, id={author.id}, имя={author.name}, пароль есть: {bool(author.password)}"
) )
# Проверяем пароль # Проверяем наличие роли reader
logger.info(f"[auth] login: НАЧАЛО ПРОВЕРКИ ПАРОЛЯ для {email}") has_reader_role = False
verify_result = Identity.password(author, password) if hasattr(author, "roles") and author.roles:
logger.info( for role in author.roles:
f"[auth] login: РЕЗУЛЬТАТ ПРОВЕРКИ ПАРОЛЯ: {verify_result if isinstance(verify_result, dict) else 'успешно'}" if role.id == "reader":
) has_reader_role = True
break
if isinstance(verify_result, dict) and verify_result.get("error"): # Если у пользователя нет роли reader и он не админ, запрещаем вход
logger.warning(f"[auth] login: Неверный пароль для {email}: {verify_result.get('error')}") if not has_reader_role:
# Проверяем, есть ли роль admin или super
is_admin = author.email in ADMIN_EMAILS.split(",")
if not is_admin:
logger.warning(f"[auth] login: У пользователя {email} нет роли 'reader', в доступе отказано")
return {
"success": False,
"token": None,
"author": None,
"error": "У вас нет необходимых прав для входа. Обратитесь к администратору.",
}
# Проверяем пароль - важно использовать непосредственно объект author, а не его dict
logger.info(f"[auth] login: НАЧАЛО ПРОВЕРКИ ПАРОЛЯ для {email}")
try:
verify_result = Identity.password(author, password)
logger.info(
f"[auth] login: РЕЗУЛЬТАТ ПРОВЕРКИ ПАРОЛЯ: {verify_result if isinstance(verify_result, dict) else 'успешно'}"
)
if isinstance(verify_result, dict) and verify_result.get("error"):
logger.warning(f"[auth] login: Неверный пароль для {email}: {verify_result.get('error')}")
return {
"success": False,
"token": None,
"author": None,
"error": verify_result.get("error", "Ошибка авторизации"),
}
except Exception as e:
logger.error(f"[auth] login: Ошибка при проверке пароля: {str(e)}")
return { return {
"success": False, "success": False,
"token": None, "token": None,
"author": None, "author": None,
"error": verify_result.get("error", "Ошибка авторизации"), "error": str(e),
} }
# Получаем правильный объект автора - результат verify_result # Получаем правильный объект автора - результат verify_result
@ -346,9 +380,12 @@ async def login(_, info, email: str, password: str):
if not cookie_set: if not cookie_set:
logger.warning(f"[auth] login: Не удалось установить cookie никаким способом") logger.warning(f"[auth] login: Не удалось установить cookie никаким способом")
# Возвращаем успешный результат # Возвращаем успешный результат с данными для клиента
# Для ответа клиенту используем dict() с параметром access=True,
# чтобы получить полный доступ к данным для самого пользователя
logger.info(f"[auth] login: Успешный вход для {email}") logger.info(f"[auth] login: Успешный вход для {email}")
result = {"success": True, "token": token, "author": valid_author, "error": None} author_dict = valid_author.dict(access=True)
result = {"success": True, "token": token, "author": author_dict, "error": None}
logger.info( logger.info(
f"[auth] login: Возвращаемый результат: {{success: {result['success']}, token_length: {len(token) if token else 0}}}" f"[auth] login: Возвращаемый результат: {{success: {result['success']}, token_length: {len(token) if token else 0}}}"
) )

View File

@ -1,6 +1,6 @@
import asyncio import asyncio
import time import time
from typing import Optional from typing import Optional, List, Dict, Any
from sqlalchemy import select, text from sqlalchemy import select, text
@ -26,11 +26,15 @@ DEFAULT_COMMUNITIES = [1]
# Вспомогательная функция для получения всех авторов без статистики # Вспомогательная функция для получения всех авторов без статистики
async def get_all_authors(): async def get_all_authors(current_user_id=None):
""" """
Получает всех авторов без статистики. Получает всех авторов без статистики.
Используется для случаев, когда нужен полный список авторов без дополнительной информации. Используется для случаев, когда нужен полный список авторов без дополнительной информации.
Args:
current_user_id: ID текущего пользователя для проверки прав доступа
is_admin: Флаг, указывающий, является ли пользователь администратором
Returns: Returns:
list: Список всех авторов без статистики list: Список всех авторов без статистики
""" """
@ -45,15 +49,15 @@ async def get_all_authors():
authors_query = select(Author).where(Author.deleted_at.is_(None)) authors_query = select(Author).where(Author.deleted_at.is_(None))
authors = session.execute(authors_query).scalars().all() authors = session.execute(authors_query).scalars().all()
# Преобразуем авторов в словари # Преобразуем авторов в словари с учетом прав доступа
return [author.dict() for author in authors] return [author.dict(current_user_id, False) for author in authors]
# Используем универсальную функцию для кеширования запросов # Используем универсальную функцию для кеширования запросов
return await cached_query(cache_key, fetch_all_authors) return await cached_query(cache_key, fetch_all_authors)
# Вспомогательная функция для получения авторов со статистикой с пагинацией # Вспомогательная функция для получения авторов со статистикой с пагинацией
async def get_authors_with_stats(limit=50, offset=0, by: Optional[str] = None): async def get_authors_with_stats(limit=50, offset=0, by: Optional[str] = None, current_user_id: Optional[int] = None):
""" """
Получает авторов со статистикой с пагинацией. Получает авторов со статистикой с пагинацией.
@ -61,7 +65,7 @@ async def get_authors_with_stats(limit=50, offset=0, by: Optional[str] = None):
limit: Максимальное количество возвращаемых авторов limit: Максимальное количество возвращаемых авторов
offset: Смещение для пагинации offset: Смещение для пагинации
by: Опциональный параметр сортировки (new/active) by: Опциональный параметр сортировки (new/active)
current_user_id: ID текущего пользователя
Returns: Returns:
list: Список авторов с их статистикой list: Список авторов с их статистикой
""" """
@ -133,15 +137,18 @@ async def get_authors_with_stats(limit=50, offset=0, by: Optional[str] = None):
# Формируем результат с добавлением статистики # Формируем результат с добавлением статистики
result = [] result = []
for author in authors: for author in authors:
# Получаем словарь с учетом прав доступа
author_dict = author.dict() author_dict = author.dict()
author_dict["stat"] = { author_dict["stat"] = {
"shouts": shouts_stats.get(author.id, 0), "shouts": shouts_stats.get(author.id, 0),
"followers": followers_stats.get(author.id, 0), "followers": followers_stats.get(author.id, 0),
} }
result.append(author_dict) result.append(author_dict)
# Кешируем каждого автора отдельно для использования в других функциях # Кешируем каждого автора отдельно для использования в других функциях
await cache_author(author_dict) # Важно: кэшируем полный словарь для админов
await cache_author(author.dict())
return result return result
@ -172,8 +179,8 @@ async def invalidate_authors_cache(author_id=None):
# Получаем user_id автора, если есть # Получаем user_id автора, если есть
with local_session() as session: with local_session() as session:
author = session.query(Author).filter(Author.id == author_id).first() author = session.query(Author).filter(Author.id == author_id).first()
if author and author.user: if author and Author.id:
specific_keys.append(f"author:user:{author.user.strip()}") specific_keys.append(f"author:user:{Author.id.strip()}")
# Удаляем конкретные ключи # Удаляем конкретные ключи
for key in specific_keys: for key in specific_keys:
@ -198,24 +205,28 @@ async def invalidate_authors_cache(author_id=None):
@login_required @login_required
async def update_author(_, info, profile): async def update_author(_, info, profile):
user_id = info.context.get("user_id") user_id = info.context.get("user_id")
is_admin = info.context.get("is_admin", False)
if not user_id: if not user_id:
return {"error": "unauthorized", "author": None} return {"error": "unauthorized", "author": None}
try: try:
with local_session() as session: with local_session() as session:
author = session.query(Author).where(Author.user == user_id).first() author = session.query(Author).where(Author.id == user_id).first()
if author: if author:
Author.update(author, profile) Author.update(author, profile)
session.add(author) session.add(author)
session.commit() session.commit()
author_query = select(Author).where(Author.user == user_id) author_query = select(Author).where(Author.id == user_id)
result = get_with_stat(author_query) result = get_with_stat(author_query)
if result: if result:
author_with_stat = result[0] author_with_stat = result[0]
if isinstance(author_with_stat, Author): if isinstance(author_with_stat, Author):
author_dict = author_with_stat.dict() # Кэшируем полную версию для админов
# await cache_author(author_dict) author_dict = author_with_stat.dict(is_admin=True)
asyncio.create_task(cache_author(author_dict)) asyncio.create_task(cache_author(author_dict))
return {"error": None, "author": author}
# Возвращаем обычную полную версию, т.к. это владелец
return {"error": None, "author": author}
except Exception as exc: except Exception as exc:
import traceback import traceback
@ -224,24 +235,46 @@ async def update_author(_, info, profile):
@query.field("get_authors_all") @query.field("get_authors_all")
async def get_authors_all(_, _info): async def get_authors_all(_, info):
""" """
Получает список всех авторов без статистики. Получает список всех авторов без статистики.
Returns: Returns:
list: Список всех авторов list: Список всех авторов
""" """
return await get_all_authors() # Получаем ID текущего пользователя и флаг админа из контекста
current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
authors = await get_all_authors(current_user_id, False)
return authors
@query.field("get_author") @query.field("get_author")
async def get_author(_, _info, slug="", author_id=0): async def get_author(_, info, slug="", author_id=0):
# Получаем ID текущего пользователя и флаг админа из контекста
current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False
author_dict = None author_dict = None
try: try:
author_id = get_author_id_from(slug=slug, user="", author_id=author_id) author_id = get_author_id_from(slug=slug, user="", author_id=author_id)
if not author_id: if not author_id:
raise ValueError("cant find") raise ValueError("cant find")
author_dict = await get_cached_author(int(author_id), get_with_stat)
# Получаем данные автора из кэша (полные данные)
cached_author = await get_cached_author(int(author_id), get_with_stat)
# Применяем фильтрацию на стороне клиента, так как в кэше хранится полная версия
if cached_author:
# Создаем объект автора для использования метода dict
temp_author = Author()
for key, value in cached_author.items():
if hasattr(temp_author, key):
setattr(temp_author, key, value)
# Получаем отфильтрованную версию
author_dict = temp_author.dict(current_user_id, is_admin)
# Добавляем статистику, которая могла быть в кэшированной версии
if "stat" in cached_author:
author_dict["stat"] = cached_author["stat"]
if not author_dict or not author_dict.get("stat"): if not author_dict or not author_dict.get("stat"):
# update stat from db # update stat from db
@ -250,9 +283,15 @@ async def get_author(_, _info, slug="", author_id=0):
if result: if result:
author_with_stat = result[0] author_with_stat = result[0]
if isinstance(author_with_stat, Author): if isinstance(author_with_stat, Author):
author_dict = author_with_stat.dict() # Кэшируем полные данные для админов
# await cache_author(author_dict) original_dict = author_with_stat.dict(is_admin=True)
asyncio.create_task(cache_author(author_dict)) asyncio.create_task(cache_author(original_dict))
# Возвращаем отфильтрованную версию
author_dict = author_with_stat.dict(current_user_id, is_admin)
# Добавляем статистику
if hasattr(author_with_stat, "stat"):
author_dict["stat"] = author_with_stat.stat
except ValueError: except ValueError:
pass pass
except Exception as exc: except Exception as exc:
@ -263,31 +302,43 @@ async def get_author(_, _info, slug="", author_id=0):
@query.field("get_author_id") @query.field("get_author_id")
async def get_author_id(_, _info, user: str): async def get_author_id(_, info, user: str):
# Получаем ID текущего пользователя и флаг админа из контекста
current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False
user_id = user.strip() user_id = user.strip()
logger.info(f"getting author id for {user_id}") logger.info(f"getting author id for {user_id}")
author = None author = None
try: try:
author = await get_cached_author_by_user_id(user_id, get_with_stat) cached_author = await get_cached_author_by_user_id(user_id, get_with_stat)
if author: if cached_author:
return author # Создаем объект автора для использования метода dict
temp_author = Author()
for key, value in cached_author.items():
if hasattr(temp_author, key):
setattr(temp_author, key, value)
# Возвращаем отфильтрованную версию
return temp_author.dict(current_user_id, is_admin)
author_query = select(Author).filter(Author.user == user_id) author_query = select(Author).filter(Author.id == user_id)
result = get_with_stat(author_query) result = get_with_stat(author_query)
if result: if result:
author_with_stat = result[0] author_with_stat = result[0]
if isinstance(author_with_stat, Author): if isinstance(author_with_stat, Author):
author_dict = author_with_stat.dict() # Кэшируем полную версию данных
# await cache_author(author_dict) original_dict = author_with_stat.dict(is_admin=True)
asyncio.create_task(cache_author(author_dict)) asyncio.create_task(cache_author(original_dict))
return author_with_stat
# Возвращаем отфильтрованную версию
return author_with_stat.dict(current_user_id, is_admin)
except Exception as exc: except Exception as exc:
logger.error(f"Error getting author: {exc}") logger.error(f"Error getting author: {exc}")
return None return None
@query.field("load_authors_by") @query.field("load_authors_by")
async def load_authors_by(_, _info, by, limit, offset): async def load_authors_by(_, info, by, limit, offset):
""" """
Загружает авторов по заданному критерию с пагинацией. Загружает авторов по заданному критерию с пагинацией.
@ -299,8 +350,12 @@ async def load_authors_by(_, _info, by, limit, offset):
Returns: Returns:
list: Список авторов с учетом критерия list: Список авторов с учетом критерия
""" """
# Получаем ID текущего пользователя и флаг админа из контекста
current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False
# Используем оптимизированную функцию для получения авторов # Используем оптимизированную функцию для получения авторов
return await get_authors_with_stats(limit, offset, by) return await get_authors_with_stats(limit, offset, by, current_user_id, is_admin)
def get_author_id_from(slug="", user=None, author_id=None): def get_author_id_from(slug="", user=None, author_id=None):
@ -316,7 +371,7 @@ def get_author_id_from(slug="", user=None, author_id=None):
author_id = author.id author_id = author.id
return author_id return author_id
if user: if user:
author = session.query(Author).filter(Author.user == user).first() author = session.query(Author).filter(Author.id == user).first()
if author: if author:
author_id = author.id author_id = author.id
except Exception as exc: except Exception as exc:
@ -325,15 +380,31 @@ def get_author_id_from(slug="", user=None, author_id=None):
@query.field("get_author_follows") @query.field("get_author_follows")
async def get_author_follows(_, _info, slug="", user=None, author_id=0): async def get_author_follows(_, info, slug="", user=None, author_id=0):
# Получаем ID текущего пользователя и флаг админа из контекста
current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False
logger.debug(f"getting follows for @{slug}") logger.debug(f"getting follows for @{slug}")
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id) author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
if not author_id: if not author_id:
return {} return {}
followed_authors = await get_cached_follower_authors(author_id) # Получаем данные из кэша
followed_authors_raw = await get_cached_follower_authors(author_id)
followed_topics = await get_cached_follower_topics(author_id) followed_topics = await get_cached_follower_topics(author_id)
# Фильтруем чувствительные данные авторов
followed_authors = []
for author_data in followed_authors_raw:
# Создаем объект автора для использования метода dict
temp_author = Author()
for key, value in author_data.items():
if hasattr(temp_author, key):
setattr(temp_author, key, value)
# Добавляем отфильтрованную версию
followed_authors.append(temp_author.dict(current_user_id, is_admin))
# TODO: Get followed communities too # TODO: Get followed communities too
return { return {
"authors": followed_authors, "authors": followed_authors,
@ -354,18 +425,36 @@ async def get_author_follows_topics(_, _info, slug="", user=None, author_id=None
@query.field("get_author_follows_authors") @query.field("get_author_follows_authors")
async def get_author_follows_authors(_, _info, slug="", user=None, author_id=None): async def get_author_follows_authors(_, info, slug="", user=None, author_id=None):
# Получаем ID текущего пользователя и флаг админа из контекста
current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False
logger.debug(f"getting followed authors for @{slug}") logger.debug(f"getting followed authors for @{slug}")
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id) author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
if not author_id: if not author_id:
return [] return []
followed_authors = await get_cached_follower_authors(author_id)
# Получаем данные из кэша
followed_authors_raw = await get_cached_follower_authors(author_id)
# Фильтруем чувствительные данные авторов
followed_authors = []
for author_data in followed_authors_raw:
# Создаем объект автора для использования метода dict
temp_author = Author()
for key, value in author_data.items():
if hasattr(temp_author, key):
setattr(temp_author, key, value)
# Добавляем отфильтрованную версию
followed_authors.append(temp_author.dict(current_user_id, is_admin))
return followed_authors return followed_authors
def create_author(user_id: str, slug: str, name: str = ""): def create_author(user_id: str, slug: str, name: str = ""):
author = Author() author = Author()
author.user = user_id # Связь с user_id из системы авторизации Author.id = user_id # Связь с user_id из системы авторизации
author.slug = slug # Идентификатор из системы авторизации author.slug = slug # Идентификатор из системы авторизации
author.created_at = author.updated_at = int(time.time()) author.created_at = author.updated_at = int(time.time())
author.name = name or slug # если не указано author.name = name or slug # если не указано
@ -377,10 +466,28 @@ def create_author(user_id: str, slug: str, name: str = ""):
@query.field("get_author_followers") @query.field("get_author_followers")
async def get_author_followers(_, _info, slug: str = "", user: str = "", author_id: int = 0): async def get_author_followers(_, info, slug: str = "", user: str = "", author_id: int = 0):
# Получаем ID текущего пользователя и флаг админа из контекста
current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False
logger.debug(f"getting followers for author @{slug} or ID:{author_id}") logger.debug(f"getting followers for author @{slug} or ID:{author_id}")
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id) author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
if not author_id: if not author_id:
return [] return []
followers = await get_cached_author_followers(author_id)
# Получаем данные из кэша
followers_raw = await get_cached_author_followers(author_id)
# Фильтруем чувствительные данные авторов
followers = []
for follower_data in followers_raw:
# Создаем объект автора для использования метода dict
temp_author = Author()
for key, value in follower_data.items():
if hasattr(temp_author, key):
setattr(temp_author, key, value)
# Добавляем отфильтрованную версию
followers.append(temp_author.dict(current_user_id, is_admin))
return followers return followers

View File

@ -71,7 +71,7 @@ async def create_invite(_, info, slug: str = "", author_id: int = 0):
# Check if the inviter is the owner of the shout # Check if the inviter is the owner of the shout
with local_session() as session: with local_session() as session:
shout = session.query(Shout).filter(Shout.slug == slug).first() shout = session.query(Shout).filter(Shout.slug == slug).first()
inviter = session.query(Author).filter(Author.user == user_id).first() inviter = session.query(Author).filter(Author.id == user_id).first()
if inviter and shout and shout.authors and inviter.id is shout.created_by: if inviter and shout and shout.authors and inviter.id is shout.created_by:
# Check if an invite already exists # Check if an invite already exists
existing_invite = ( existing_invite = (
@ -109,7 +109,7 @@ async def create_invite(_, info, slug: str = "", author_id: int = 0):
async def remove_author(_, info, slug: str = "", author_id: int = 0): async def remove_author(_, info, slug: str = "", author_id: int = 0):
user_id = info.context["user_id"] user_id = info.context["user_id"]
with local_session() as session: with local_session() as session:
author = session.query(Author).filter(Author.user == user_id).first() author = session.query(Author).filter(Author.id == user_id).first()
if author: if author:
shout = session.query(Shout).filter(Shout.slug == slug).first() shout = session.query(Shout).filter(Shout.slug == slug).first()
# NOTE: owner should be first in a list # NOTE: owner should be first in a list

View File

@ -23,7 +23,7 @@ async def get_communities_by_author(_, _info, slug="", user="", author_id=0):
author_id = session.query(Author).where(Author.slug == slug).first().id author_id = session.query(Author).where(Author.slug == slug).first().id
q = q.where(CommunityFollower.author == author_id) q = q.where(CommunityFollower.author == author_id)
if user: if user:
author_id = session.query(Author).where(Author.user == user).first().id author_id = session.query(Author).where(Author.id == user).first().id
q = q.where(CommunityFollower.author == author_id) q = q.where(CommunityFollower.author == author_id)
if author_id: if author_id:
q = q.where(CommunityFollower.author == author_id) q = q.where(CommunityFollower.author == author_id)

View File

@ -643,7 +643,7 @@ async def delete_shout(_, info, shout_id: int):
for author in shout.authors: for author in shout.authors:
await cache_by_id(Author, author.id, cache_author) await cache_by_id(Author, author.id, cache_author)
info.context["author"] = author.dict() info.context["author"] = author.dict()
info.context["user_id"] = author.user info.context["user_id"] = author.id
unfollow(None, info, "shout", shout.slug) unfollow(None, info, "shout", shout.slug)
for topic in shout.topics: for topic in shout.topics:

View File

@ -63,7 +63,14 @@ async def follow(_, info, what, slug="", entity_id=0):
return {"error": f"{what.lower()} not found"} return {"error": f"{what.lower()} not found"}
if not entity_id and entity: if not entity_id and entity:
entity_id = entity.id entity_id = entity.id
entity_dict = entity.dict()
# Если это автор, учитываем фильтрацию данных
if what == "AUTHOR":
# Полная версия для кэширования
entity_dict = entity.dict(is_admin=True)
else:
entity_dict = entity.dict()
logger.debug(f"entity_id: {entity_id}, entity_dict: {entity_dict}") logger.debug(f"entity_id: {entity_id}, entity_dict: {entity_dict}")
if entity_id: if entity_id:
@ -96,7 +103,35 @@ async def follow(_, info, what, slug="", entity_id=0):
if get_cached_follows_method: if get_cached_follows_method:
logger.debug("Получение подписок из кэша") logger.debug("Получение подписок из кэша")
existing_follows = await get_cached_follows_method(follower_id) existing_follows = await get_cached_follows_method(follower_id)
follows = [*existing_follows, entity_dict] if not existing_sub else existing_follows
# Если это авторы, получаем безопасную версию
if what == "AUTHOR":
# Получаем ID текущего пользователя и фильтруем данные
current_user_id = user_id
follows_filtered = []
for author_data in existing_follows:
# Создаем объект автора для использования метода dict
temp_author = Author()
for key, value in author_data.items():
if hasattr(temp_author, key):
setattr(temp_author, key, value)
# Добавляем отфильтрованную версию
follows_filtered.append(temp_author.dict(current_user_id, False))
if not existing_sub:
# Создаем объект автора для entity_dict
temp_author = Author()
for key, value in entity_dict.items():
if hasattr(temp_author, key):
setattr(temp_author, key, value)
# Добавляем отфильтрованную версию
follows = [*follows_filtered, temp_author.dict(current_user_id, False)]
else:
follows = follows_filtered
else:
follows = [*existing_follows, entity_dict] if not existing_sub else existing_follows
logger.debug("Обновлен список подписок") logger.debug("Обновлен список подписок")
if what == "AUTHOR" and not existing_sub: if what == "AUTHOR" and not existing_sub:
@ -171,11 +206,38 @@ async def unfollow(_, info, what, slug="", entity_id=0):
if cache_method: if cache_method:
logger.debug("Обновление кэша после отписки") logger.debug("Обновление кэша после отписки")
await cache_method(entity.dict()) # Если это автор, кэшируем полную версию
if what == "AUTHOR":
await cache_method(entity.dict(is_admin=True))
else:
await cache_method(entity.dict())
if get_cached_follows_method: if get_cached_follows_method:
logger.debug("Получение подписок из кэша") logger.debug("Получение подписок из кэша")
existing_follows = await get_cached_follows_method(follower_id) existing_follows = await get_cached_follows_method(follower_id)
follows = filter(lambda x: x["id"] != entity_id, existing_follows)
# Если это авторы, получаем безопасную версию
if what == "AUTHOR":
# Получаем ID текущего пользователя и фильтруем данные
current_user_id = user_id
follows_filtered = []
for author_data in existing_follows:
if author_data["id"] == entity_id:
continue
# Создаем объект автора для использования метода dict
temp_author = Author()
for key, value in author_data.items():
if hasattr(temp_author, key):
setattr(temp_author, key, value)
# Добавляем отфильтрованную версию
follows_filtered.append(temp_author.dict(current_user_id, False))
follows = follows_filtered
else:
follows = [item for item in existing_follows if item["id"] != entity_id]
logger.debug("Обновлен список подписок") logger.debug("Обновлен список подписок")
if what == "AUTHOR": if what == "AUTHOR":

View File

@ -215,7 +215,7 @@ async def set_featured(session, shout_id):
session.commit() session.commit()
author = session.query(Author).filter(Author.id == s.created_by).first() author = session.query(Author).filter(Author.id == s.created_by).first()
if author: if author:
await add_user_role(str(author.user)) await add_user_role(str(author.id))
session.add(s) session.add(s)
session.commit() session.commit()
@ -446,7 +446,7 @@ async def delete_reaction(_, info, reaction_id: int):
with local_session() as session: with local_session() as session:
try: try:
author = session.query(Author).filter(Author.user == user_id).one() author = session.query(Author).filter(Author.id == user_id).one()
r = session.query(Reaction).filter(Reaction.id == reaction_id).one() r = session.query(Reaction).filter(Reaction.id == reaction_id).one()
if r.created_by != author_id and "editor" not in roles: if r.created_by != author_id and "editor" not in roles:

View File

@ -255,7 +255,7 @@ async def get_topics_by_author(_, _info, author_id=0, slug="", user=""):
elif slug: elif slug:
topics_by_author_query = topics_by_author_query.join(Author).where(Author.slug == slug) topics_by_author_query = topics_by_author_query.join(Author).where(Author.slug == slug)
elif user: elif user:
topics_by_author_query = topics_by_author_query.join(Author).where(Author.user == user) topics_by_author_query = topics_by_author_query.join(Author).where(Author.id == user)
return get_with_stat(topics_by_author_query) return get_with_stat(topics_by_author_query)
@ -320,7 +320,7 @@ async def delete_topic(_, info, slug: str):
t: Topic = session.query(Topic).filter(Topic.slug == slug).first() t: Topic = session.query(Topic).filter(Topic.slug == slug).first()
if not t: if not t:
return {"error": "invalid topic slug"} return {"error": "invalid topic slug"}
author = session.query(Author).filter(Author.user == user_id).first() author = session.query(Author).filter(Author.id == user_id).first()
if author: if author:
if t.created_by != author.id: if t.created_by != author.id:
return {"error": "access denied"} return {"error": "access denied"}

View File

@ -27,15 +27,11 @@ type AdminUserInfo {
roles: [String!] roles: [String!]
created_at: Int created_at: Int
last_seen: Int last_seen: Int
muted: Boolean
is_active: Boolean
} }
input AdminUserUpdateInput { input AdminUserUpdateInput {
id: Int! id: Int!
roles: [String!] roles: [String!]
muted: Boolean
is_active: Boolean
} }
type Role { type Role {
@ -66,6 +62,4 @@ extend type Mutation {
# Мутации для управления пользователями # Мутации для управления пользователями
adminUpdateUser(user: AdminUserUpdateInput!): Boolean! adminUpdateUser(user: AdminUserUpdateInput!): Boolean!
adminToggleUserBlock(userId: Int!): Boolean!
adminToggleUserMute(userId: Int!): Boolean!
} }

View File

@ -12,9 +12,8 @@ type AuthorStat {
type Author { type Author {
id: Int! id: Int!
user: String! # user.id slug: String!
slug: String! # user.nickname name: String
name: String # user.preferred_username
pic: String pic: String
bio: String bio: String
about: String about: String
@ -25,10 +24,8 @@ type Author {
deleted_at: Int deleted_at: Int
email: String email: String
seo: String seo: String
# synthetic
stat: AuthorStat # ratings inside stat: AuthorStat # ratings inside
communities: [Community] communities: [Community]
# Auth fields
roles: [String!] roles: [String!]
email_verified: Boolean email_verified: Boolean
} }

View File

@ -13,7 +13,7 @@ from auth.orm import Author, Role
ALLOWED_HEADERS = ["Authorization", "Content-Type"] ALLOWED_HEADERS = ["Authorization", "Content-Type"]
async def check_auth(req) -> Tuple[str, list[str]]: async def check_auth(req) -> Tuple[str, list[str], bool]:
""" """
Проверка авторизации пользователя. Проверка авторизации пользователя.
@ -25,11 +25,12 @@ async def check_auth(req) -> Tuple[str, list[str]]:
Возвращает: Возвращает:
- user_id: str - Идентификатор пользователя - user_id: str - Идентификатор пользователя
- user_roles: list[str] - Список ролей пользователя - user_roles: list[str] - Список ролей пользователя
- is_admin: bool - Флаг наличия у пользователя административных прав
""" """
# Проверяем наличие токена # Проверяем наличие токена
token = req.headers.get("Authorization") token = req.headers.get("Authorization")
if not token: if not token:
return "", [] return "", [], False
# Очищаем токен от префикса Bearer если он есть # Очищаем токен от префикса Bearer если он есть
if token.startswith("Bearer "): if token.startswith("Bearer "):
@ -39,8 +40,39 @@ async def check_auth(req) -> Tuple[str, list[str]]:
# Проверяем авторизацию внутренним механизмом # Проверяем авторизацию внутренним механизмом
logger.debug("Using internal authentication") logger.debug("Using internal authentication")
return await verify_internal_auth(token) user_id, user_roles = await verify_internal_auth(token)
# Проверяем наличие административных прав у пользователя
is_admin = False
if user_id:
# Быстрая проверка на админ роли в кэше
admin_roles = ['admin', 'super']
for role in user_roles:
if role in admin_roles:
is_admin = True
break
# Если в ролях нет админа, но есть ID - проверяем в БД
if not is_admin:
try:
with local_session() as session:
# Преобразуем user_id в число
try:
user_id_int = int(user_id.strip())
except (ValueError, TypeError):
logger.error(f"Невозможно преобразовать user_id {user_id} в число")
else:
# Проверяем наличие админских прав через БД
from auth.orm import AuthorRole
admin_role = session.query(AuthorRole).filter(
AuthorRole.author == user_id_int,
AuthorRole.role.in_(["admin", "super"])
).first()
is_admin = admin_role is not None
except Exception as e:
logger.error(f"Ошибка при проверке прав администратора: {e}")
return user_id, user_roles, is_admin
async def add_user_role(user_id: str, roles: list[str] = None): async def add_user_role(user_id: str, roles: list[str] = None):
""" """
@ -84,21 +116,36 @@ async def add_user_role(user_id: str, roles: list[str] = None):
def login_required(f): def login_required(f):
"""Декоратор для проверки авторизации пользователя.""" """Декоратор для проверки авторизации пользователя. Требуется наличие роли 'reader'."""
@wraps(f) @wraps(f)
async def decorated_function(*args, **kwargs): async def decorated_function(*args, **kwargs):
from graphql.error import GraphQLError
info = args[1] info = args[1]
req = info.context.get("request") req = info.context.get("request")
user_id, user_roles = await check_auth(req) user_id, user_roles, is_admin = await check_auth(req)
if user_id and user_roles:
logger.info(f" got {user_id} roles: {user_roles}") if not user_id:
info.context["user_id"] = user_id.strip() raise GraphQLError("Требуется авторизация")
info.context["roles"] = user_roles
author = await get_cached_author_by_user_id(user_id, get_with_stat) # Проверяем наличие роли reader
if not author: if 'reader' not in user_roles and not is_admin:
logger.error(f"author profile not found for user {user_id}") logger.error(f"Пользователь {user_id} не имеет роли 'reader'")
info.context["author"] = author raise GraphQLError("У вас нет необходимых прав для доступа")
logger.info(f"Авторизован пользователь {user_id} с ролями: {user_roles}")
info.context["user_id"] = user_id.strip()
info.context["roles"] = user_roles
# Проверяем права администратора
info.context["is_admin"] = is_admin
author = await get_cached_author_by_user_id(user_id, get_with_stat)
if not author:
logger.error(f"Профиль автора не найден для пользователя {user_id}")
info.context["author"] = author
return await f(*args, **kwargs) return await f(*args, **kwargs)
return decorated_function return decorated_function
@ -113,7 +160,7 @@ def login_accepted(f):
req = info.context.get("request") req = info.context.get("request")
logger.debug("login_accepted: Проверка авторизации пользователя.") logger.debug("login_accepted: Проверка авторизации пользователя.")
user_id, user_roles = await check_auth(req) user_id, user_roles, is_admin = await check_auth(req)
logger.debug(f"login_accepted: user_id={user_id}, user_roles={user_roles}") logger.debug(f"login_accepted: user_id={user_id}, user_roles={user_roles}")
if user_id and user_roles: if user_id and user_roles:
@ -121,11 +168,16 @@ def login_accepted(f):
info.context["user_id"] = user_id.strip() info.context["user_id"] = user_id.strip()
info.context["roles"] = user_roles info.context["roles"] = user_roles
# Проверяем права администратора
info.context["is_admin"] = is_admin
# Пробуем получить профиль автора # Пробуем получить профиль автора
author = await get_cached_author_by_user_id(user_id, get_with_stat) author = await get_cached_author_by_user_id(user_id, get_with_stat)
if author: if author:
logger.debug(f"login_accepted: Найден профиль автора: {author}") logger.debug(f"login_accepted: Найден профиль автора: {author}")
info.context["author"] = author.dict() # Используем флаг is_admin из контекста или передаем права владельца для собственных данных
is_owner = True # Пользователь всегда является владельцем собственного профиля
info.context["author"] = author.dict(access=is_owner or is_admin)
else: else:
logger.error( logger.error(
f"login_accepted: Профиль автора не найден для пользователя {user_id}. Используем базовые данные." f"login_accepted: Профиль автора не найден для пользователя {user_id}. Используем базовые данные."
@ -135,6 +187,42 @@ def login_accepted(f):
info.context["user_id"] = None info.context["user_id"] = None
info.context["roles"] = None info.context["roles"] = None
info.context["author"] = None info.context["author"] = None
info.context["is_admin"] = False
return await f(*args, **kwargs)
return decorated_function
def author_required(f):
"""Декоратор для проверки наличия роли 'author' у пользователя."""
@wraps(f)
async def decorated_function(*args, **kwargs):
from graphql.error import GraphQLError
info = args[1]
req = info.context.get("request")
user_id, user_roles, is_admin = await check_auth(req)
if not user_id:
raise GraphQLError("Требуется авторизация")
# Проверяем наличие роли author
if 'author' not in user_roles and not is_admin:
logger.error(f"Пользователь {user_id} не имеет роли 'author'")
raise GraphQLError("Для выполнения этого действия необходимы права автора")
logger.info(f"Авторизован автор {user_id} с ролями: {user_roles}")
info.context["user_id"] = user_id.strip()
info.context["roles"] = user_roles
# Проверяем права администратора
info.context["is_admin"] = is_admin
author = await get_cached_author_by_user_id(user_id, get_with_stat)
if not author:
logger.error(f"Профиль автора не найден для пользователя {user_id}")
info.context["author"] = author
return await f(*args, **kwargs) return await f(*args, **kwargs)

View File

@ -1,7 +1,10 @@
from typing import Dict, List, Optional from typing import Dict, List, Optional, Set
from dataclasses import dataclass from dataclasses import dataclass
import os
import re
from pathlib import Path
from redis import Redis from redis import Redis
from settings import REDIS_URL from settings import REDIS_URL, ROOT_DIR
from utils.logger import root_logger as logger from utils.logger import root_logger as logger
@ -23,85 +26,326 @@ class EnvSection:
class EnvManager: class EnvManager:
""" """
Менеджер переменных окружения с хранением в Redis Менеджер переменных окружения с хранением в Redis и синхронизацией с .env файлом
""" """
# Стандартные переменные окружения, которые следует исключить
EXCLUDED_ENV_VARS: Set[str] = {
"PATH", "SHELL", "USER", "HOME", "PWD", "TERM", "LANG",
"PYTHONPATH", "_", "TMPDIR", "TERM_PROGRAM", "TERM_SESSION_ID",
"XPC_SERVICE_NAME", "XPC_FLAGS", "SHLVL", "SECURITYSESSIONID",
"LOGNAME", "OLDPWD", "ZSH", "PAGER", "LESS", "LC_CTYPE", "LSCOLORS",
"SSH_AUTH_SOCK", "DISPLAY", "COLORTERM", "EDITOR", "VISUAL",
"PYTHONDONTWRITEBYTECODE", "VIRTUAL_ENV", "PYTHONUNBUFFERED"
}
# Секции для группировки переменных
SECTIONS = {
"AUTH": {
"pattern": r"^(JWT|AUTH|SESSION|OAUTH|GITHUB|GOOGLE|FACEBOOK)_",
"name": "Авторизация",
"description": "Настройки системы авторизации"
},
"DATABASE": {
"pattern": r"^(DB|DATABASE|POSTGRES|MYSQL|SQL)_",
"name": "База данных",
"description": "Настройки подключения к базам данных"
},
"CACHE": {
"pattern": r"^(REDIS|CACHE|MEMCACHED)_",
"name": "Кэширование",
"description": "Настройки систем кэширования"
},
"SEARCH": {
"pattern": r"^(ELASTIC|SEARCH|OPENSEARCH)_",
"name": "Поиск",
"description": "Настройки поисковых систем"
},
"APP": {
"pattern": r"^(APP|PORT|HOST|DEBUG|DOMAIN|ENVIRONMENT|ENV|FRONTEND)_",
"name": "Приложение",
"description": "Основные настройки приложения"
},
"LOGGING": {
"pattern": r"^(LOG|LOGGING|SENTRY|GLITCH|GLITCHTIP)_",
"name": "Логирование",
"description": "Настройки логирования и мониторинга"
},
"EMAIL": {
"pattern": r"^(MAIL|EMAIL|SMTP)_",
"name": "Электронная почта",
"description": "Настройки отправки электронной почты"
},
"ANALYTICS": {
"pattern": r"^(GA|GOOGLE_ANALYTICS|ANALYTICS)_",
"name": "Аналитика",
"description": "Настройки систем аналитики"
},
}
# Переменные, которые следует всегда помечать как секретные
SECRET_VARS_PATTERNS = [
r".*TOKEN.*", r".*SECRET.*", r".*PASSWORD.*", r".*KEY.*",
r".*PWD.*", r".*PASS.*", r".*CRED.*"
]
def __init__(self): def __init__(self):
self.redis = Redis.from_url(REDIS_URL) self.redis = Redis.from_url(REDIS_URL)
self.prefix = "env:" self.prefix = "env:"
self.env_file_path = os.path.join(ROOT_DIR, '.env')
def get_all_variables(self) -> List[EnvSection]: def get_all_variables(self) -> List[EnvSection]:
""" """
Получение всех переменных окружения, сгруппированных по секциям Получение всех переменных окружения, сгруппированных по секциям
""" """
try: try:
# Получаем все ключи с префиксом env: # Получаем все переменные окружения из системы
keys = self.redis.keys(f"{self.prefix}*") system_env = self._get_system_env_vars()
variables: Dict[str, str] = {}
for key in keys: # Получаем переменные из .env файла, если он существует
var_key = key.decode("utf-8").replace(self.prefix, "") dotenv_vars = self._get_dotenv_vars()
value = self.redis.get(key)
if value: # Получаем все переменные из Redis
variables[var_key] = value.decode("utf-8") redis_vars = self._get_redis_env_vars()
# Объединяем переменные, при этом redis_vars имеют наивысший приоритет,
# за ними следуют переменные из .env, затем системные
env_vars = {**system_env, **dotenv_vars, **redis_vars}
# Группируем переменные по секциям # Группируем переменные по секциям
sections = [ return self._group_variables_by_sections(env_vars)
EnvSection(
name="Авторизация",
description="Настройки системы авторизации",
variables=[
EnvVariable(
key="JWT_SECRET",
value=variables.get("JWT_SECRET", ""),
description="Секретный ключ для JWT токенов",
type="string",
is_secret=True,
),
],
),
EnvSection(
name="Redis",
description="Настройки подключения к Redis",
variables=[
EnvVariable(
key="REDIS_URL",
value=variables.get("REDIS_URL", ""),
description="URL подключения к Redis",
type="string",
)
],
),
# Добавьте другие секции по необходимости
]
return sections
except Exception as e: except Exception as e:
logger.error(f"Ошибка получения переменных: {e}") logger.error(f"Ошибка получения переменных: {e}")
return [] return []
def _get_system_env_vars(self) -> Dict[str, str]:
"""
Получает переменные окружения из системы, исключая стандартные
"""
env_vars = {}
for key, value in os.environ.items():
# Пропускаем стандартные переменные
if key in self.EXCLUDED_ENV_VARS:
continue
# Пропускаем переменные с пустыми значениями
if not value:
continue
env_vars[key] = value
return env_vars
def _get_dotenv_vars(self) -> Dict[str, str]:
"""
Получает переменные из .env файла, если он существует
"""
env_vars = {}
if os.path.exists(self.env_file_path):
try:
with open(self.env_file_path, 'r') as f:
for line in f:
line = line.strip()
# Пропускаем пустые строки и комментарии
if not line or line.startswith('#'):
continue
# Разделяем строку на ключ и значение
if '=' in line:
key, value = line.split('=', 1)
key = key.strip()
value = value.strip()
# Удаляем кавычки, если они есть
if value.startswith('"') and value.endswith('"'):
value = value[1:-1]
env_vars[key] = value
except Exception as e:
logger.error(f"Ошибка чтения .env файла: {e}")
return env_vars
def _get_redis_env_vars(self) -> Dict[str, str]:
"""
Получает переменные окружения из Redis
"""
redis_vars = {}
try:
# Получаем все ключи с префиксом env:
keys = self.redis.keys(f"{self.prefix}*")
for key in keys:
var_key = key.decode("utf-8").replace(self.prefix, "")
value = self.redis.get(key)
if value:
redis_vars[var_key] = value.decode("utf-8")
except Exception as e:
logger.error(f"Ошибка получения переменных из Redis: {e}")
return redis_vars
def _is_secret_variable(self, key: str) -> bool:
"""
Проверяет, является ли переменная секретной
"""
key_upper = key.upper()
return any(re.match(pattern, key_upper) for pattern in self.SECRET_VARS_PATTERNS)
def _determine_variable_type(self, value: str) -> str:
"""
Определяет тип переменной на основе ее значения
"""
if value.lower() in ('true', 'false'):
return "boolean"
if value.isdigit():
return "integer"
if re.match(r"^\d+\.\d+$", value):
return "float"
# Проверяем на JSON объект или массив
if (value.startswith('{') and value.endswith('}')) or (value.startswith('[') and value.endswith(']')):
return "json"
# Проверяем на URL
if value.startswith(('http://', 'https://', 'redis://', 'postgresql://')):
return "url"
return "string"
def _group_variables_by_sections(self, variables: Dict[str, str]) -> List[EnvSection]:
"""
Группирует переменные по секциям
"""
# Создаем словарь для группировки переменных
sections_dict = {section: [] for section in self.SECTIONS}
other_variables = [] # Для переменных, которые не попали ни в одну секцию
# Распределяем переменные по секциям
for key, value in variables.items():
is_secret = self._is_secret_variable(key)
var_type = self._determine_variable_type(value)
var = EnvVariable(
key=key,
value=value,
type=var_type,
is_secret=is_secret
)
# Определяем секцию для переменной
placed = False
for section_id, section_config in self.SECTIONS.items():
if re.match(section_config["pattern"], key, re.IGNORECASE):
sections_dict[section_id].append(var)
placed = True
break
# Если переменная не попала ни в одну секцию
if not placed:
other_variables.append(var)
# Формируем результат
result = []
for section_id, variables in sections_dict.items():
if variables: # Добавляем только непустые секции
section_config = self.SECTIONS[section_id]
result.append(
EnvSection(
name=section_config["name"],
description=section_config["description"],
variables=variables
)
)
# Добавляем прочие переменные, если они есть
if other_variables:
result.append(
EnvSection(
name="Прочие переменные",
description="Переменные, не вошедшие в основные категории",
variables=other_variables
)
)
return result
def update_variable(self, key: str, value: str) -> bool: def update_variable(self, key: str, value: str) -> bool:
""" """
Обновление значения переменной Обновление значения переменной в Redis и .env файле
""" """
try: try:
# Сохраняем в Redis
full_key = f"{self.prefix}{key}" full_key = f"{self.prefix}{key}"
self.redis.set(full_key, value) self.redis.set(full_key, value)
# Обновляем значение в .env файле
self._update_dotenv_var(key, value)
# Обновляем переменную в текущем процессе
os.environ[key] = value
return True return True
except Exception as e: except Exception as e:
logger.error(f"Ошибка обновления переменной {key}: {e}") logger.error(f"Ошибка обновления переменной {key}: {e}")
return False return False
def _update_dotenv_var(self, key: str, value: str) -> bool:
"""
Обновляет переменную в .env файле
"""
try:
# Если файл .env не существует, создаем его
if not os.path.exists(self.env_file_path):
with open(self.env_file_path, 'w') as f:
f.write(f"{key}={value}\n")
return True
# Если файл существует, читаем его содержимое
lines = []
found = False
with open(self.env_file_path, 'r') as f:
for line in f:
if line.strip() and not line.strip().startswith('#'):
if line.strip().startswith(f"{key}="):
# Экранируем значение, если необходимо
if ' ' in value or ',' in value or '"' in value or "'" in value:
escaped_value = f'"{value}"'
else:
escaped_value = value
lines.append(f"{key}={escaped_value}\n")
found = True
else:
lines.append(line)
else:
lines.append(line)
# Если переменной не было в файле, добавляем ее
if not found:
# Экранируем значение, если необходимо
if ' ' in value or ',' in value or '"' in value or "'" in value:
escaped_value = f'"{value}"'
else:
escaped_value = value
lines.append(f"{key}={escaped_value}\n")
# Записываем обновленный файл
with open(self.env_file_path, 'w') as f:
f.writelines(lines)
return True
except Exception as e:
logger.error(f"Ошибка обновления .env файла: {e}")
return False
def update_variables(self, variables: List[EnvVariable]) -> bool: def update_variables(self, variables: List[EnvVariable]) -> bool:
""" """
Массовое обновление переменных Массовое обновление переменных
""" """
try: try:
# Обновляем переменные в Redis
pipe = self.redis.pipeline() pipe = self.redis.pipeline()
for var in variables: for var in variables:
full_key = f"{self.prefix}{var.key}" full_key = f"{self.prefix}{var.key}"
pipe.set(full_key, var.value) pipe.set(full_key, var.value)
pipe.execute() pipe.execute()
# Обновляем переменные в .env файле
for var in variables:
self._update_dotenv_var(var.key, var.value)
# Обновляем переменную в текущем процессе
os.environ[var.key] = var.value
return True return True
except Exception as e: except Exception as e:
logger.error(f"Ошибка массового обновления переменных: {e}") logger.error(f"Ошибка массового обновления переменных: {e}")

View File

@ -3,6 +3,10 @@
import os import os
import sys import sys
from os import environ from os import environ
from pathlib import Path
# Корневая директория проекта
ROOT_DIR = Path(__file__).parent.absolute()
DEV_SERVER_PID_FILE_NAME = "dev-server.pid" DEV_SERVER_PID_FILE_NAME = "dev-server.pid"