-
-
-
-
-
-
- ID
- Email
- Имя
- Роли
- Создан
- Последний вход
- Статус
- Действия
-
-
-
-
- {(user) => (
-
- {user.id}
- {user.email}
- {user.name || '-'}
- {user.roles.join(', ') || '-'}
- {formatDate(user.created_at)}
- {formatDate(user.last_seen)}
-
-
- {user.is_active ? 'Активен' : 'Заблокирован'}
-
-
-
- toggleUserBlock(user.id, user.is_active)}
- >
- {user.is_active ? 'Блокировать' : 'Разблокировать'}
-
- toggleUserMute(user.id, user.muted)}
- >
- {user.muted ? 'Unmute' : 'Mute'}
-
-
-
- )}
-
-
-
-
+
+
+
+
+ ID
+ Email
+ Имя
+ Роли
+ Создан
+
+
+
+
+ {(user) => (
+
+ {user.id}
+ {user.email}
+ {user.name || '-'}
+
+
+
+ {(role) => }
+
+
{
+ setSelectedUser(user)
+ setShowRolesModal(true)
+ }}
+ >
+ 🎭
+
+
+
+ {formatDateRelative(user.created_at)}
+
+ )}
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/panel/graphql.ts b/panel/graphql.ts
index f2411e6a..41190da2 100644
--- a/panel/graphql.ts
+++ b/panel/graphql.ts
@@ -67,6 +67,11 @@ function hasAuthErrors(errors: Array<{ message?: string; extensions?: { code?: s
* @returns Полный URL для запроса
*/
function prepareUrl(url: string): string {
+ // В режиме локальной разработки всегда используем /graphql
+ if (location.hostname === 'localhost') {
+ return `${location.origin}/graphql`
+ }
+
// Если это относительный путь, добавляем к нему origin
if (url.startsWith('/')) {
return `${location.origin}${url}`
diff --git a/panel/styles.css b/panel/styles.css
index d95f0ef2..7dbfaca1 100644
--- a/panel/styles.css
+++ b/panel/styles.css
@@ -425,10 +425,11 @@ button.unmute {
}
.cancel-button {
+ color: #333 !important;
padding: 8px 16px;
background-color: #ccc;
- color: #333;
width: auto;
+ border: 1px solid #ccc;
}
.save-button {
@@ -598,3 +599,286 @@ button.unmute {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
+
+/* Стили для вкладки с переменными окружения */
+.env-variables-container {
+ margin-top: 1.5rem;
+}
+
+.env-section {
+ background-color: var(--card-bg);
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+ padding: 20px;
+ margin-bottom: 20px;
+}
+
+.section-name {
+ margin-top: 0;
+ color: var(--primary-color);
+ font-size: 20px;
+ margin-bottom: 10px;
+}
+
+.section-description {
+ color: var(--text-secondary);
+ margin-bottom: 15px;
+ font-size: 14px;
+}
+
+.variable-edit-form {
+ margin-bottom: 20px;
+}
+
+.variable-description {
+ margin-top: 10px;
+ font-style: italic;
+ color: var(--text-secondary);
+ font-size: 14px;
+}
+
+.empty-value {
+ color: var(--text-secondary);
+ font-style: italic;
+}
+
+button.edit-button {
+ background-color: var(--primary-color);
+ color: white;
+ padding: 5px 10px;
+ font-size: 12px;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ width: auto;
+}
+
+button.edit-button:hover {
+ background-color: var(--primary-dark);
+}
+
+.success-message {
+ background-color: var(--success-light);
+ color: var(--success-color);
+ padding: 10px 15px;
+ border-radius: 4px;
+ margin-bottom: 15px;
+}
+
+.error-message {
+ background-color: var(--danger-light);
+ color: var(--danger-color);
+ padding: 10px 15px;
+ border-radius: 4px;
+ margin-bottom: 15px;
+}
+
+/* Стили для модального окна редактирования */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+}
+
+.modal-content {
+ background-color: white;
+ padding: 20px;
+ border-radius: 8px;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
+ width: 100%;
+ max-width: 500px;
+}
+
+.modal-content h2 {
+ margin-top: 0;
+ color: var(--primary-color);
+}
+
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 20px;
+}
+
+button.cancel-button {
+ background-color: var(--text-secondary);
+ color: white;
+ border: none;
+ border-radius: 4px;
+ padding: 8px 16px;
+ font-size: 14px;
+ cursor: pointer;
+ width: auto;
+}
+
+button.save-button {
+ background-color: var(--primary-color);
+ color: white;
+ border: none;
+ border-radius: 4px;
+ padding: 8px 16px;
+ font-size: 14px;
+ cursor: pointer;
+ width: auto;
+}
+
+button.cancel-button:hover {
+ background-color: #999;
+}
+
+button.save-button:hover {
+ background-color: var(--primary-dark);
+}
+
+/* Стили для компонентов ролей */
+.roles-cell {
+ max-width: 200px;
+}
+
+.roles-container {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.role-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 6px;
+ border-radius: 12px;
+ background-color: rgba(0, 0, 0, 0.05);
+ margin: 2px 0;
+ white-space: nowrap;
+ font-size: 0.85em;
+}
+
+.role-icon {
+ margin-right: 4px;
+ font-size: 1.1em;
+}
+
+.edit-roles {
+ background-color: #8a2be2;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ padding: 4px 8px;
+ cursor: pointer;
+ margin-left: 4px;
+}
+
+.edit-roles:hover {
+ background-color: #7b1fa2;
+}
+
+/* Стили компонентов ролей */
+.roles-cell {
+ max-width: 200px;
+}
+
+.roles-container {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.role-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 6px;
+ border-radius: 12px;
+ background-color: rgba(0, 0, 0, 0.05);
+ margin: 2px 0;
+ white-space: nowrap;
+ font-size: 0.85em;
+}
+
+.role-icon {
+ margin-right: 4px;
+ font-size: 1.1em;
+}
+
+.edit-roles {
+ background-color: #8a2be2;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ padding: 4px 8px;
+ cursor: pointer;
+ margin-left: 4px;
+}
+
+.edit-roles:hover {
+ background-color: #7b1fa2;
+}
+
+/* Стили для сортировки таблицы */
+th.sortable {
+ cursor: pointer;
+ user-select: none;
+ position: relative;
+ padding-right: 20px;
+}
+
+th.sortable:hover {
+ background-color: rgba(0, 0, 0, 0.05);
+}
+
+th.sortable.sorted {
+ background-color: rgba(65, 105, 225, 0.1);
+}
+
+.sort-icon {
+ display: inline-block;
+ position: absolute;
+ right: 5px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: #888;
+ font-size: 14px;
+}
+
+th.sortable.sorted .sort-icon {
+ color: #4169e1;
+ font-weight: bold;
+}
+
+/* Стили для сортировки таблицы */
+th.sortable {
+ cursor: pointer;
+ user-select: none;
+ position: relative;
+ padding-right: 20px;
+}
+
+th.sortable:hover {
+ background-color: rgba(0, 0, 0, 0.05);
+}
+
+th.sortable.sorted {
+ background-color: rgba(65, 105, 225, 0.1);
+}
+
+.sort-icon {
+ display: inline-block;
+ position: absolute;
+ right: 5px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: #888;
+ font-size: 14px;
+}
+
+th.sortable.sorted .sort-icon {
+ color: #4169e1;
+ font-weight: bold;
+}
\ No newline at end of file
diff --git a/resolvers/admin.py b/resolvers/admin.py
index 64fb5529..b555973f 100644
--- a/resolvers/admin.py
+++ b/resolvers/admin.py
@@ -1,11 +1,12 @@
from math import ceil
-from sqlalchemy import or_
+from sqlalchemy import or_, cast, String
from graphql.error import GraphQLError
from auth.decorators import admin_auth_required
from services.db import local_session
-from services.schema import query
-from auth.orm import Author, Role
+from services.schema import query, mutation
+from auth.orm import Author, Role, AuthorRole
+from services.env import EnvManager, EnvVariable
from utils.logger import root_logger as logger
@@ -40,7 +41,7 @@ async def admin_get_users(_, info, limit=10, offset=0, search=None):
or_(
Author.email.ilike(search_term),
Author.name.ilike(search_term),
- Author.id.cast(str).ilike(search_term),
+ cast(Author.id, String).ilike(search_term),
)
)
@@ -67,9 +68,7 @@ async def admin_get_users(_, info, limit=10, offset=0, search=None):
if hasattr(user, "roles") and user.roles
else [],
"created_at": user.created_at,
- "last_seen": user.last_seen,
- "muted": user.muted or False,
- "is_active": not user.blocked if hasattr(user, "blocked") else True,
+ "last_seen": user.last_seen
}
for user in users
],
@@ -120,3 +119,179 @@ async def admin_get_roles(_, info):
except Exception as e:
logger.error(f"Ошибка при получении списка ролей: {str(e)}")
raise GraphQLError(f"Не удалось получить список ролей: {str(e)}")
+
+
+@query.field("getEnvVariables")
+@admin_auth_required
+async def get_env_variables(_, info):
+ """
+ Получает список переменных окружения, сгруппированных по секциям
+
+ Args:
+ info: Контекст GraphQL запроса
+
+ Returns:
+ Список секций с переменными окружения
+ """
+ try:
+ # Создаем экземпляр менеджера переменных окружения
+ env_manager = EnvManager()
+
+ # Получаем все переменные
+ sections = env_manager.get_all_variables()
+
+ # Преобразуем к формату GraphQL API
+ result = [
+ {
+ "name": section.name,
+ "description": section.description,
+ "variables": [
+ {
+ "key": var.key,
+ "value": var.value,
+ "description": var.description,
+ "type": var.type,
+ "isSecret": var.is_secret,
+ }
+ for var in section.variables
+ ]
+ }
+ for section in sections
+ ]
+
+ return result
+ except Exception as e:
+ logger.error(f"Ошибка при получении переменных окружения: {str(e)}")
+ raise GraphQLError(f"Не удалось получить переменные окружения: {str(e)}")
+
+
+@mutation.field("updateEnvVariable")
+@admin_auth_required
+async def update_env_variable(_, info, key, value):
+ """
+ Обновляет значение переменной окружения
+
+ Args:
+ info: Контекст GraphQL запроса
+ key: Ключ переменной
+ value: Новое значение
+
+ Returns:
+ Boolean: результат операции
+ """
+ try:
+ # Создаем экземпляр менеджера переменных окружения
+ env_manager = EnvManager()
+
+ # Обновляем переменную
+ result = env_manager.update_variable(key, value)
+
+ if result:
+ logger.info(f"Переменная окружения '{key}' успешно обновлена")
+ else:
+ logger.error(f"Не удалось обновить переменную окружения '{key}'")
+
+ return result
+ except Exception as e:
+ logger.error(f"Ошибка при обновлении переменной окружения: {str(e)}")
+ return False
+
+
+@mutation.field("updateEnvVariables")
+@admin_auth_required
+async def update_env_variables(_, info, variables):
+ """
+ Массовое обновление переменных окружения
+
+ Args:
+ info: Контекст GraphQL запроса
+ variables: Список переменных для обновления
+
+ Returns:
+ Boolean: результат операции
+ """
+ try:
+ # Создаем экземпляр менеджера переменных окружения
+ env_manager = EnvManager()
+
+ # Преобразуем входные данные в формат для менеджера
+ env_variables = [
+ EnvVariable(
+ key=var.get("key", ""),
+ value=var.get("value", ""),
+ type=var.get("type", "string")
+ )
+ for var in variables
+ ]
+
+ # Обновляем переменные
+ result = env_manager.update_variables(env_variables)
+
+ if result:
+ logger.info(f"Переменные окружения успешно обновлены ({len(variables)} шт.)")
+ else:
+ logger.error(f"Не удалось обновить переменные окружения")
+
+ return result
+ except Exception as e:
+ logger.error(f"Ошибка при массовом обновлении переменных окружения: {str(e)}")
+ return False
+
+
+@mutation.field("adminUpdateUser")
+@admin_auth_required
+async def admin_update_user(_, info, user):
+ """
+ Обновляет роли пользователя
+
+ Args:
+ info: Контекст GraphQL запроса
+ user: Данные для обновления пользователя (содержит id и roles)
+
+ Returns:
+ Boolean: результат операции
+ """
+ try:
+ user_id = user.get("id")
+ roles = user.get("roles", [])
+
+ if not roles:
+ logger.warning(f"Пользователю {user_id} не назначено ни одной роли. Доступ в систему будет заблокирован.")
+
+ with local_session() as session:
+ # Получаем пользователя из базы данных
+ author = session.query(Author).filter(Author.id == user_id).first()
+
+ if not author:
+ logger.error(f"Пользователь с ID {user_id} не найден")
+ return False
+
+ # Получаем текущие роли пользователя
+ current_roles = {role.id for role in author.roles} if author.roles else set()
+
+ # Обновляем роли только если они изменились
+ if set(roles) != current_roles:
+ # Получаем все существующие роли, которые указаны для обновления
+ role_objects = session.query(Role).filter(Role.id.in_(roles)).all()
+
+ # Очищаем текущие роли и добавляем новые
+ author.roles = role_objects
+
+ # Сохраняем изменения в базе данных
+ session.commit()
+
+ # Проверяем, добавлена ли пользователю роль reader
+ has_reader = 'reader' in roles
+ if not has_reader:
+ logger.warning(f"Пользователю {author.email or author.id} не назначена роль 'reader'. Доступ в систему будет ограничен.")
+
+ logger.info(f"Роли пользователя {author.email or author.id} обновлены: {', '.join(roles)}")
+ else:
+ logger.info(f"Роли пользователя {author.email or author.id} не изменились")
+
+ return True
+ except Exception as e:
+ import traceback
+ logger.error(f"Ошибка при обновлении ролей пользователя: {str(e)}")
+ logger.error(traceback.format_exc())
+ return False
diff --git a/resolvers/auth.py b/resolvers/auth.py
index 1b9bf363..d90a4dc8 100644
--- a/resolvers/auth.py
+++ b/resolvers/auth.py
@@ -40,6 +40,7 @@ async def get_current_user(_, info):
author.last_seen = int(time.time())
session.commit()
+ # Здесь можно не применять фильтрацию, так как пользователь получает свои данные
return {"token": token, "author": author}
@@ -76,6 +77,7 @@ async def confirm_email(_, info, token):
session.add(user)
session.commit()
logger.info(f"[auth] confirmEmail: Email для пользователя {user_id} успешно подтвержден.")
+ # Здесь можно не применять фильтрацию, так как пользователь получает свои данные
return {"success": True, "token": session_token, "author": user, "error": None}
except InvalidToken as e:
logger.warning(f"[auth] confirmEmail: Невалидный токен - {e.message}")
@@ -166,6 +168,7 @@ async def register_by_email(_, _info, email: str, password: str = "", name: str
logger.info(
f"[auth] registerUser: Пользователь {email} зарегистрирован, ссылка для подтверждения отправлена."
)
+ # При регистрации возвращаем данные самому пользователю, поэтому не фильтруем
return {
"success": True,
"token": None,
@@ -237,21 +240,52 @@ async def login(_, info, email: str, password: str):
logger.info(
f"[auth] login: Найден автор {email}, id={author.id}, имя={author.name}, пароль есть: {bool(author.password)}"
)
+
+ # Проверяем наличие роли reader
+ has_reader_role = False
+ if hasattr(author, "roles") and author.roles:
+ for role in author.roles:
+ if role.id == "reader":
+ has_reader_role = True
+ break
+
+ # Если у пользователя нет роли reader и он не админ, запрещаем вход
+ if not has_reader_role:
+ # Проверяем, есть ли роль admin или super
+ is_admin = author.email in ADMIN_EMAILS.split(",")
+
+ if not is_admin:
+ logger.warning(f"[auth] login: У пользователя {email} нет роли 'reader', в доступе отказано")
+ return {
+ "success": False,
+ "token": None,
+ "author": None,
+ "error": "У вас нет необходимых прав для входа. Обратитесь к администратору.",
+ }
- # Проверяем пароль
+ # Проверяем пароль - важно использовать непосредственно объект author, а не его dict
logger.info(f"[auth] login: НАЧАЛО ПРОВЕРКИ ПАРОЛЯ для {email}")
- verify_result = Identity.password(author, password)
- logger.info(
- f"[auth] login: РЕЗУЛЬТАТ ПРОВЕРКИ ПАРОЛЯ: {verify_result if isinstance(verify_result, dict) else 'успешно'}"
- )
+ 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')}")
+ if isinstance(verify_result, dict) and verify_result.get("error"):
+ logger.warning(f"[auth] login: Неверный пароль для {email}: {verify_result.get('error')}")
+ return {
+ "success": False,
+ "token": None,
+ "author": None,
+ "error": verify_result.get("error", "Ошибка авторизации"),
+ }
+ except Exception as e:
+ logger.error(f"[auth] login: Ошибка при проверке пароля: {str(e)}")
return {
"success": False,
"token": None,
"author": None,
- "error": verify_result.get("error", "Ошибка авторизации"),
+ "error": str(e),
}
# Получаем правильный объект автора - результат verify_result
@@ -346,9 +380,12 @@ async def login(_, info, email: str, password: str):
if not cookie_set:
logger.warning(f"[auth] login: Не удалось установить cookie никаким способом")
- # Возвращаем успешный результат
+ # Возвращаем успешный результат с данными для клиента
+ # Для ответа клиенту используем dict() с параметром access=True,
+ # чтобы получить полный доступ к данным для самого пользователя
logger.info(f"[auth] login: Успешный вход для {email}")
- result = {"success": True, "token": token, "author": valid_author, "error": None}
+ author_dict = valid_author.dict(access=True)
+ result = {"success": True, "token": token, "author": author_dict, "error": None}
logger.info(
f"[auth] login: Возвращаемый результат: {{success: {result['success']}, token_length: {len(token) if token else 0}}}"
)
diff --git a/resolvers/author.py b/resolvers/author.py
index aa866b94..d41440c6 100644
--- a/resolvers/author.py
+++ b/resolvers/author.py
@@ -1,6 +1,6 @@
import asyncio
import time
-from typing import Optional
+from typing import Optional, List, Dict, Any
from sqlalchemy import select, text
@@ -26,11 +26,15 @@ DEFAULT_COMMUNITIES = [1]
# Вспомогательная функция для получения всех авторов без статистики
-async def get_all_authors():
+async def get_all_authors(current_user_id=None):
"""
Получает всех авторов без статистики.
Используется для случаев, когда нужен полный список авторов без дополнительной информации.
+ Args:
+ current_user_id: ID текущего пользователя для проверки прав доступа
+ is_admin: Флаг, указывающий, является ли пользователь администратором
+
Returns:
list: Список всех авторов без статистики
"""
@@ -45,15 +49,15 @@ async def get_all_authors():
authors_query = select(Author).where(Author.deleted_at.is_(None))
authors = session.execute(authors_query).scalars().all()
- # Преобразуем авторов в словари
- return [author.dict() for author in authors]
+ # Преобразуем авторов в словари с учетом прав доступа
+ return [author.dict(current_user_id, False) for author in authors]
# Используем универсальную функцию для кеширования запросов
return await cached_query(cache_key, fetch_all_authors)
# Вспомогательная функция для получения авторов со статистикой с пагинацией
-async def get_authors_with_stats(limit=50, offset=0, by: Optional[str] = None):
+async def get_authors_with_stats(limit=50, offset=0, by: Optional[str] = None, current_user_id: Optional[int] = None):
"""
Получает авторов со статистикой с пагинацией.
@@ -61,7 +65,7 @@ async def get_authors_with_stats(limit=50, offset=0, by: Optional[str] = None):
limit: Максимальное количество возвращаемых авторов
offset: Смещение для пагинации
by: Опциональный параметр сортировки (new/active)
-
+ current_user_id: ID текущего пользователя
Returns:
list: Список авторов с их статистикой
"""
@@ -133,15 +137,18 @@ async def get_authors_with_stats(limit=50, offset=0, by: Optional[str] = None):
# Формируем результат с добавлением статистики
result = []
for author in authors:
+ # Получаем словарь с учетом прав доступа
author_dict = author.dict()
author_dict["stat"] = {
"shouts": shouts_stats.get(author.id, 0),
"followers": followers_stats.get(author.id, 0),
}
+
result.append(author_dict)
# Кешируем каждого автора отдельно для использования в других функциях
- await cache_author(author_dict)
+ # Важно: кэшируем полный словарь для админов
+ await cache_author(author.dict())
return result
@@ -172,8 +179,8 @@ async def invalidate_authors_cache(author_id=None):
# Получаем user_id автора, если есть
with local_session() as session:
author = session.query(Author).filter(Author.id == author_id).first()
- if author and author.user:
- specific_keys.append(f"author:user:{author.user.strip()}")
+ if author and Author.id:
+ specific_keys.append(f"author:user:{Author.id.strip()}")
# Удаляем конкретные ключи
for key in specific_keys:
@@ -198,24 +205,28 @@ async def invalidate_authors_cache(author_id=None):
@login_required
async def update_author(_, info, profile):
user_id = info.context.get("user_id")
+ is_admin = info.context.get("is_admin", False)
+
if not user_id:
return {"error": "unauthorized", "author": None}
try:
with local_session() as session:
- author = session.query(Author).where(Author.user == user_id).first()
+ author = session.query(Author).where(Author.id == user_id).first()
if author:
Author.update(author, profile)
session.add(author)
session.commit()
- author_query = select(Author).where(Author.user == user_id)
+ author_query = select(Author).where(Author.id == user_id)
result = get_with_stat(author_query)
if result:
author_with_stat = result[0]
if isinstance(author_with_stat, Author):
- author_dict = author_with_stat.dict()
- # await cache_author(author_dict)
+ # Кэшируем полную версию для админов
+ author_dict = author_with_stat.dict(is_admin=True)
asyncio.create_task(cache_author(author_dict))
- return {"error": None, "author": author}
+
+ # Возвращаем обычную полную версию, т.к. это владелец
+ return {"error": None, "author": author}
except Exception as exc:
import traceback
@@ -224,24 +235,46 @@ async def update_author(_, info, profile):
@query.field("get_authors_all")
-async def get_authors_all(_, _info):
+async def get_authors_all(_, info):
"""
Получает список всех авторов без статистики.
Returns:
list: Список всех авторов
"""
- return await get_all_authors()
+ # Получаем ID текущего пользователя и флаг админа из контекста
+ current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
+ authors = await get_all_authors(current_user_id, False)
+ return authors
@query.field("get_author")
-async def get_author(_, _info, slug="", author_id=0):
+async def get_author(_, info, slug="", author_id=0):
+ # Получаем ID текущего пользователя и флаг админа из контекста
+ current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
+ is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False
+
author_dict = None
try:
author_id = get_author_id_from(slug=slug, user="", author_id=author_id)
if not author_id:
raise ValueError("cant find")
- author_dict = await get_cached_author(int(author_id), get_with_stat)
+
+ # Получаем данные автора из кэша (полные данные)
+ cached_author = await get_cached_author(int(author_id), get_with_stat)
+
+ # Применяем фильтрацию на стороне клиента, так как в кэше хранится полная версия
+ if cached_author:
+ # Создаем объект автора для использования метода dict
+ temp_author = Author()
+ for key, value in cached_author.items():
+ if hasattr(temp_author, key):
+ setattr(temp_author, key, value)
+ # Получаем отфильтрованную версию
+ author_dict = temp_author.dict(current_user_id, is_admin)
+ # Добавляем статистику, которая могла быть в кэшированной версии
+ if "stat" in cached_author:
+ author_dict["stat"] = cached_author["stat"]
if not author_dict or not author_dict.get("stat"):
# update stat from db
@@ -250,9 +283,15 @@ async def get_author(_, _info, slug="", author_id=0):
if result:
author_with_stat = result[0]
if isinstance(author_with_stat, Author):
- author_dict = author_with_stat.dict()
- # await cache_author(author_dict)
- asyncio.create_task(cache_author(author_dict))
+ # Кэшируем полные данные для админов
+ original_dict = author_with_stat.dict(is_admin=True)
+ asyncio.create_task(cache_author(original_dict))
+
+ # Возвращаем отфильтрованную версию
+ author_dict = author_with_stat.dict(current_user_id, is_admin)
+ # Добавляем статистику
+ if hasattr(author_with_stat, "stat"):
+ author_dict["stat"] = author_with_stat.stat
except ValueError:
pass
except Exception as exc:
@@ -263,31 +302,43 @@ async def get_author(_, _info, slug="", author_id=0):
@query.field("get_author_id")
-async def get_author_id(_, _info, user: str):
+async def get_author_id(_, info, user: str):
+ # Получаем ID текущего пользователя и флаг админа из контекста
+ current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
+ is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False
+
user_id = user.strip()
logger.info(f"getting author id for {user_id}")
author = None
try:
- author = await get_cached_author_by_user_id(user_id, get_with_stat)
- if author:
- return author
+ cached_author = await get_cached_author_by_user_id(user_id, get_with_stat)
+ if cached_author:
+ # Создаем объект автора для использования метода dict
+ temp_author = Author()
+ for key, value in cached_author.items():
+ if hasattr(temp_author, key):
+ setattr(temp_author, key, value)
+ # Возвращаем отфильтрованную версию
+ return temp_author.dict(current_user_id, is_admin)
- author_query = select(Author).filter(Author.user == user_id)
+ author_query = select(Author).filter(Author.id == user_id)
result = get_with_stat(author_query)
if result:
author_with_stat = result[0]
if isinstance(author_with_stat, Author):
- author_dict = author_with_stat.dict()
- # await cache_author(author_dict)
- asyncio.create_task(cache_author(author_dict))
- return author_with_stat
+ # Кэшируем полную версию данных
+ original_dict = author_with_stat.dict(is_admin=True)
+ asyncio.create_task(cache_author(original_dict))
+
+ # Возвращаем отфильтрованную версию
+ return author_with_stat.dict(current_user_id, is_admin)
except Exception as exc:
logger.error(f"Error getting author: {exc}")
return None
@query.field("load_authors_by")
-async def load_authors_by(_, _info, by, limit, offset):
+async def load_authors_by(_, info, by, limit, offset):
"""
Загружает авторов по заданному критерию с пагинацией.
@@ -299,8 +350,12 @@ async def load_authors_by(_, _info, by, limit, offset):
Returns:
list: Список авторов с учетом критерия
"""
+ # Получаем ID текущего пользователя и флаг админа из контекста
+ current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
+ is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False
+
# Используем оптимизированную функцию для получения авторов
- return await get_authors_with_stats(limit, offset, by)
+ return await get_authors_with_stats(limit, offset, by, current_user_id, is_admin)
def get_author_id_from(slug="", user=None, author_id=None):
@@ -316,7 +371,7 @@ def get_author_id_from(slug="", user=None, author_id=None):
author_id = author.id
return author_id
if user:
- author = session.query(Author).filter(Author.user == user).first()
+ author = session.query(Author).filter(Author.id == user).first()
if author:
author_id = author.id
except Exception as exc:
@@ -325,14 +380,30 @@ def get_author_id_from(slug="", user=None, author_id=None):
@query.field("get_author_follows")
-async def get_author_follows(_, _info, slug="", user=None, author_id=0):
+async def get_author_follows(_, info, slug="", user=None, author_id=0):
+ # Получаем ID текущего пользователя и флаг админа из контекста
+ current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
+ is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False
+
logger.debug(f"getting follows for @{slug}")
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
if not author_id:
return {}
- followed_authors = await get_cached_follower_authors(author_id)
+ # Получаем данные из кэша
+ followed_authors_raw = await get_cached_follower_authors(author_id)
followed_topics = await get_cached_follower_topics(author_id)
+
+ # Фильтруем чувствительные данные авторов
+ followed_authors = []
+ for author_data in followed_authors_raw:
+ # Создаем объект автора для использования метода dict
+ temp_author = Author()
+ for key, value in author_data.items():
+ if hasattr(temp_author, key):
+ setattr(temp_author, key, value)
+ # Добавляем отфильтрованную версию
+ followed_authors.append(temp_author.dict(current_user_id, is_admin))
# TODO: Get followed communities too
return {
@@ -354,18 +425,36 @@ async def get_author_follows_topics(_, _info, slug="", user=None, author_id=None
@query.field("get_author_follows_authors")
-async def get_author_follows_authors(_, _info, slug="", user=None, author_id=None):
+async def get_author_follows_authors(_, info, slug="", user=None, author_id=None):
+ # Получаем ID текущего пользователя и флаг админа из контекста
+ current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
+ is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False
+
logger.debug(f"getting followed authors for @{slug}")
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
if not author_id:
return []
- followed_authors = await get_cached_follower_authors(author_id)
+
+ # Получаем данные из кэша
+ followed_authors_raw = await get_cached_follower_authors(author_id)
+
+ # Фильтруем чувствительные данные авторов
+ followed_authors = []
+ for author_data in followed_authors_raw:
+ # Создаем объект автора для использования метода dict
+ temp_author = Author()
+ for key, value in author_data.items():
+ if hasattr(temp_author, key):
+ setattr(temp_author, key, value)
+ # Добавляем отфильтрованную версию
+ followed_authors.append(temp_author.dict(current_user_id, is_admin))
+
return followed_authors
def create_author(user_id: str, slug: str, name: str = ""):
author = Author()
- author.user = user_id # Связь с user_id из системы авторизации
+ Author.id = user_id # Связь с user_id из системы авторизации
author.slug = slug # Идентификатор из системы авторизации
author.created_at = author.updated_at = int(time.time())
author.name = name or slug # если не указано
@@ -377,10 +466,28 @@ def create_author(user_id: str, slug: str, name: str = ""):
@query.field("get_author_followers")
-async def get_author_followers(_, _info, slug: str = "", user: str = "", author_id: int = 0):
+async def get_author_followers(_, info, slug: str = "", user: str = "", author_id: int = 0):
+ # Получаем ID текущего пользователя и флаг админа из контекста
+ current_user_id = info.context.get("user_id") if hasattr(info, "context") else None
+ is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False
+
logger.debug(f"getting followers for author @{slug} or ID:{author_id}")
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
if not author_id:
return []
- followers = await get_cached_author_followers(author_id)
+
+ # Получаем данные из кэша
+ followers_raw = await get_cached_author_followers(author_id)
+
+ # Фильтруем чувствительные данные авторов
+ followers = []
+ for follower_data in followers_raw:
+ # Создаем объект автора для использования метода dict
+ temp_author = Author()
+ for key, value in follower_data.items():
+ if hasattr(temp_author, key):
+ setattr(temp_author, key, value)
+ # Добавляем отфильтрованную версию
+ followers.append(temp_author.dict(current_user_id, is_admin))
+
return followers
diff --git a/resolvers/collab.py b/resolvers/collab.py
index 53784f2a..39cf03f0 100644
--- a/resolvers/collab.py
+++ b/resolvers/collab.py
@@ -71,7 +71,7 @@ async def create_invite(_, info, slug: str = "", author_id: int = 0):
# Check if the inviter is the owner of the shout
with local_session() as session:
shout = session.query(Shout).filter(Shout.slug == slug).first()
- inviter = session.query(Author).filter(Author.user == user_id).first()
+ inviter = session.query(Author).filter(Author.id == user_id).first()
if inviter and shout and shout.authors and inviter.id is shout.created_by:
# Check if an invite already exists
existing_invite = (
@@ -109,7 +109,7 @@ async def create_invite(_, info, slug: str = "", author_id: int = 0):
async def remove_author(_, info, slug: str = "", author_id: int = 0):
user_id = info.context["user_id"]
with local_session() as session:
- author = session.query(Author).filter(Author.user == user_id).first()
+ author = session.query(Author).filter(Author.id == user_id).first()
if author:
shout = session.query(Shout).filter(Shout.slug == slug).first()
# NOTE: owner should be first in a list
diff --git a/resolvers/community.py b/resolvers/community.py
index 6dfbf311..12256e2b 100644
--- a/resolvers/community.py
+++ b/resolvers/community.py
@@ -23,7 +23,7 @@ async def get_communities_by_author(_, _info, slug="", user="", author_id=0):
author_id = session.query(Author).where(Author.slug == slug).first().id
q = q.where(CommunityFollower.author == author_id)
if user:
- author_id = session.query(Author).where(Author.user == user).first().id
+ author_id = session.query(Author).where(Author.id == user).first().id
q = q.where(CommunityFollower.author == author_id)
if author_id:
q = q.where(CommunityFollower.author == author_id)
diff --git a/resolvers/editor.py b/resolvers/editor.py
index 88ca020e..6aa3f009 100644
--- a/resolvers/editor.py
+++ b/resolvers/editor.py
@@ -643,7 +643,7 @@ async def delete_shout(_, info, shout_id: int):
for author in shout.authors:
await cache_by_id(Author, author.id, cache_author)
info.context["author"] = author.dict()
- info.context["user_id"] = author.user
+ info.context["user_id"] = author.id
unfollow(None, info, "shout", shout.slug)
for topic in shout.topics:
diff --git a/resolvers/follower.py b/resolvers/follower.py
index 610f7341..e1bb3a83 100644
--- a/resolvers/follower.py
+++ b/resolvers/follower.py
@@ -63,7 +63,14 @@ async def follow(_, info, what, slug="", entity_id=0):
return {"error": f"{what.lower()} not found"}
if not entity_id and entity:
entity_id = entity.id
- entity_dict = entity.dict()
+
+ # Если это автор, учитываем фильтрацию данных
+ if what == "AUTHOR":
+ # Полная версия для кэширования
+ entity_dict = entity.dict(is_admin=True)
+ else:
+ entity_dict = entity.dict()
+
logger.debug(f"entity_id: {entity_id}, entity_dict: {entity_dict}")
if entity_id:
@@ -96,7 +103,35 @@ async def follow(_, info, what, slug="", entity_id=0):
if get_cached_follows_method:
logger.debug("Получение подписок из кэша")
existing_follows = await get_cached_follows_method(follower_id)
- follows = [*existing_follows, entity_dict] if not existing_sub else existing_follows
+
+ # Если это авторы, получаем безопасную версию
+ if what == "AUTHOR":
+ # Получаем ID текущего пользователя и фильтруем данные
+ current_user_id = user_id
+ follows_filtered = []
+
+ for author_data in existing_follows:
+ # Создаем объект автора для использования метода dict
+ temp_author = Author()
+ for key, value in author_data.items():
+ if hasattr(temp_author, key):
+ setattr(temp_author, key, value)
+ # Добавляем отфильтрованную версию
+ follows_filtered.append(temp_author.dict(current_user_id, False))
+
+ if not existing_sub:
+ # Создаем объект автора для entity_dict
+ temp_author = Author()
+ for key, value in entity_dict.items():
+ if hasattr(temp_author, key):
+ setattr(temp_author, key, value)
+ # Добавляем отфильтрованную версию
+ follows = [*follows_filtered, temp_author.dict(current_user_id, False)]
+ else:
+ follows = follows_filtered
+ else:
+ follows = [*existing_follows, entity_dict] if not existing_sub else existing_follows
+
logger.debug("Обновлен список подписок")
if what == "AUTHOR" and not existing_sub:
@@ -171,11 +206,38 @@ async def unfollow(_, info, what, slug="", entity_id=0):
if cache_method:
logger.debug("Обновление кэша после отписки")
- await cache_method(entity.dict())
+ # Если это автор, кэшируем полную версию
+ if what == "AUTHOR":
+ await cache_method(entity.dict(is_admin=True))
+ else:
+ await cache_method(entity.dict())
+
if get_cached_follows_method:
logger.debug("Получение подписок из кэша")
existing_follows = await get_cached_follows_method(follower_id)
- follows = filter(lambda x: x["id"] != entity_id, existing_follows)
+
+ # Если это авторы, получаем безопасную версию
+ if what == "AUTHOR":
+ # Получаем ID текущего пользователя и фильтруем данные
+ current_user_id = user_id
+ follows_filtered = []
+
+ for author_data in existing_follows:
+ if author_data["id"] == entity_id:
+ continue
+
+ # Создаем объект автора для использования метода dict
+ temp_author = Author()
+ for key, value in author_data.items():
+ if hasattr(temp_author, key):
+ setattr(temp_author, key, value)
+ # Добавляем отфильтрованную версию
+ follows_filtered.append(temp_author.dict(current_user_id, False))
+
+ follows = follows_filtered
+ else:
+ follows = [item for item in existing_follows if item["id"] != entity_id]
+
logger.debug("Обновлен список подписок")
if what == "AUTHOR":
diff --git a/resolvers/reaction.py b/resolvers/reaction.py
index 6f90c2cc..6812604c 100644
--- a/resolvers/reaction.py
+++ b/resolvers/reaction.py
@@ -215,7 +215,7 @@ async def set_featured(session, shout_id):
session.commit()
author = session.query(Author).filter(Author.id == s.created_by).first()
if author:
- await add_user_role(str(author.user))
+ await add_user_role(str(author.id))
session.add(s)
session.commit()
@@ -446,7 +446,7 @@ async def delete_reaction(_, info, reaction_id: int):
with local_session() as session:
try:
- author = session.query(Author).filter(Author.user == user_id).one()
+ author = session.query(Author).filter(Author.id == user_id).one()
r = session.query(Reaction).filter(Reaction.id == reaction_id).one()
if r.created_by != author_id and "editor" not in roles:
diff --git a/resolvers/topic.py b/resolvers/topic.py
index cc7375c2..fdd5fcce 100644
--- a/resolvers/topic.py
+++ b/resolvers/topic.py
@@ -255,7 +255,7 @@ async def get_topics_by_author(_, _info, author_id=0, slug="", user=""):
elif slug:
topics_by_author_query = topics_by_author_query.join(Author).where(Author.slug == slug)
elif user:
- topics_by_author_query = topics_by_author_query.join(Author).where(Author.user == user)
+ topics_by_author_query = topics_by_author_query.join(Author).where(Author.id == user)
return get_with_stat(topics_by_author_query)
@@ -320,7 +320,7 @@ async def delete_topic(_, info, slug: str):
t: Topic = session.query(Topic).filter(Topic.slug == slug).first()
if not t:
return {"error": "invalid topic slug"}
- author = session.query(Author).filter(Author.user == user_id).first()
+ author = session.query(Author).filter(Author.id == user_id).first()
if author:
if t.created_by != author.id:
return {"error": "access denied"}
diff --git a/schema/admin.graphql b/schema/admin.graphql
index f99c101c..b4390341 100644
--- a/schema/admin.graphql
+++ b/schema/admin.graphql
@@ -27,15 +27,11 @@ type AdminUserInfo {
roles: [String!]
created_at: Int
last_seen: Int
- muted: Boolean
- is_active: Boolean
}
input AdminUserUpdateInput {
id: Int!
roles: [String!]
- muted: Boolean
- is_active: Boolean
}
type Role {
@@ -66,6 +62,4 @@ extend type Mutation {
# Мутации для управления пользователями
adminUpdateUser(user: AdminUserUpdateInput!): Boolean!
- adminToggleUserBlock(userId: Int!): Boolean!
- adminToggleUserMute(userId: Int!): Boolean!
}
\ No newline at end of file
diff --git a/schema/type.graphql b/schema/type.graphql
index bc3800cf..ebf513fe 100644
--- a/schema/type.graphql
+++ b/schema/type.graphql
@@ -12,9 +12,8 @@ type AuthorStat {
type Author {
id: Int!
- user: String! # user.id
- slug: String! # user.nickname
- name: String # user.preferred_username
+ slug: String!
+ name: String
pic: String
bio: String
about: String
@@ -25,10 +24,8 @@ type Author {
deleted_at: Int
email: String
seo: String
- # synthetic
stat: AuthorStat # ratings inside
communities: [Community]
- # Auth fields
roles: [String!]
email_verified: Boolean
}
diff --git a/services/auth.py b/services/auth.py
index 4eec2cba..c1d17ffa 100644
--- a/services/auth.py
+++ b/services/auth.py
@@ -13,7 +13,7 @@ from auth.orm import Author, Role
ALLOWED_HEADERS = ["Authorization", "Content-Type"]
-async def check_auth(req) -> Tuple[str, list[str]]:
+async def check_auth(req) -> Tuple[str, list[str], bool]:
"""
Проверка авторизации пользователя.
@@ -25,11 +25,12 @@ async def check_auth(req) -> Tuple[str, list[str]]:
Возвращает:
- user_id: str - Идентификатор пользователя
- user_roles: list[str] - Список ролей пользователя
+ - is_admin: bool - Флаг наличия у пользователя административных прав
"""
# Проверяем наличие токена
token = req.headers.get("Authorization")
if not token:
- return "", []
+ return "", [], False
# Очищаем токен от префикса Bearer если он есть
if token.startswith("Bearer "):
@@ -39,8 +40,39 @@ async def check_auth(req) -> Tuple[str, list[str]]:
# Проверяем авторизацию внутренним механизмом
logger.debug("Using internal authentication")
- return await verify_internal_auth(token)
-
+ user_id, user_roles = await verify_internal_auth(token)
+
+ # Проверяем наличие административных прав у пользователя
+ is_admin = False
+ if user_id:
+ # Быстрая проверка на админ роли в кэше
+ admin_roles = ['admin', 'super']
+ for role in user_roles:
+ if role in admin_roles:
+ is_admin = True
+ break
+
+ # Если в ролях нет админа, но есть ID - проверяем в БД
+ if not is_admin:
+ try:
+ with local_session() as session:
+ # Преобразуем user_id в число
+ try:
+ user_id_int = int(user_id.strip())
+ except (ValueError, TypeError):
+ logger.error(f"Невозможно преобразовать user_id {user_id} в число")
+ else:
+ # Проверяем наличие админских прав через БД
+ from auth.orm import AuthorRole
+ admin_role = session.query(AuthorRole).filter(
+ AuthorRole.author == user_id_int,
+ AuthorRole.role.in_(["admin", "super"])
+ ).first()
+ is_admin = admin_role is not None
+ except Exception as e:
+ logger.error(f"Ошибка при проверке прав администратора: {e}")
+
+ return user_id, user_roles, is_admin
async def add_user_role(user_id: str, roles: list[str] = None):
"""
@@ -84,21 +116,36 @@ async def add_user_role(user_id: str, roles: list[str] = None):
def login_required(f):
- """Декоратор для проверки авторизации пользователя."""
+ """Декоратор для проверки авторизации пользователя. Требуется наличие роли 'reader'."""
@wraps(f)
async def decorated_function(*args, **kwargs):
+ from graphql.error import GraphQLError
+
info = args[1]
req = info.context.get("request")
- user_id, user_roles = await check_auth(req)
- if user_id and user_roles:
- logger.info(f" got {user_id} roles: {user_roles}")
- info.context["user_id"] = user_id.strip()
- info.context["roles"] = user_roles
- author = await get_cached_author_by_user_id(user_id, get_with_stat)
- if not author:
- logger.error(f"author profile not found for user {user_id}")
- info.context["author"] = author
+ user_id, user_roles, is_admin = await check_auth(req)
+
+ if not user_id:
+ raise GraphQLError("Требуется авторизация")
+
+ # Проверяем наличие роли reader
+ if 'reader' not in user_roles and not is_admin:
+ logger.error(f"Пользователь {user_id} не имеет роли 'reader'")
+ raise GraphQLError("У вас нет необходимых прав для доступа")
+
+ logger.info(f"Авторизован пользователь {user_id} с ролями: {user_roles}")
+ info.context["user_id"] = user_id.strip()
+ info.context["roles"] = user_roles
+
+ # Проверяем права администратора
+ info.context["is_admin"] = is_admin
+
+ author = await get_cached_author_by_user_id(user_id, get_with_stat)
+ if not author:
+ logger.error(f"Профиль автора не найден для пользователя {user_id}")
+ info.context["author"] = author
+
return await f(*args, **kwargs)
return decorated_function
@@ -113,19 +160,24 @@ def login_accepted(f):
req = info.context.get("request")
logger.debug("login_accepted: Проверка авторизации пользователя.")
- user_id, user_roles = await check_auth(req)
+ user_id, user_roles, is_admin = await check_auth(req)
logger.debug(f"login_accepted: user_id={user_id}, user_roles={user_roles}")
if user_id and user_roles:
logger.info(f"login_accepted: Пользователь авторизован: {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 author:
logger.debug(f"login_accepted: Найден профиль автора: {author}")
- info.context["author"] = author.dict()
+ # Используем флаг is_admin из контекста или передаем права владельца для собственных данных
+ is_owner = True # Пользователь всегда является владельцем собственного профиля
+ info.context["author"] = author.dict(access=is_owner or is_admin)
else:
logger.error(
f"login_accepted: Профиль автора не найден для пользователя {user_id}. Используем базовые данные."
@@ -135,7 +187,43 @@ def login_accepted(f):
info.context["user_id"] = None
info.context["roles"] = None
info.context["author"] = None
+ info.context["is_admin"] = False
return await f(*args, **kwargs)
return decorated_function
+
+def author_required(f):
+ """Декоратор для проверки наличия роли 'author' у пользователя."""
+
+ @wraps(f)
+ async def decorated_function(*args, **kwargs):
+ from graphql.error import GraphQLError
+
+ info = args[1]
+ req = info.context.get("request")
+ user_id, user_roles, is_admin = await check_auth(req)
+
+ if not user_id:
+ raise GraphQLError("Требуется авторизация")
+
+ # Проверяем наличие роли author
+ if 'author' not in user_roles and not is_admin:
+ logger.error(f"Пользователь {user_id} не имеет роли 'author'")
+ raise GraphQLError("Для выполнения этого действия необходимы права автора")
+
+ logger.info(f"Авторизован автор {user_id} с ролями: {user_roles}")
+ info.context["user_id"] = user_id.strip()
+ info.context["roles"] = user_roles
+
+ # Проверяем права администратора
+ info.context["is_admin"] = is_admin
+
+ author = await get_cached_author_by_user_id(user_id, get_with_stat)
+ if not author:
+ logger.error(f"Профиль автора не найден для пользователя {user_id}")
+ info.context["author"] = author
+
+ return await f(*args, **kwargs)
+
+ return decorated_function
diff --git a/services/env.py b/services/env.py
index 6662394b..a31b3a9a 100644
--- a/services/env.py
+++ b/services/env.py
@@ -1,7 +1,10 @@
-from typing import Dict, List, Optional
+from typing import Dict, List, Optional, Set
from dataclasses import dataclass
+import os
+import re
+from pathlib import Path
from redis import Redis
-from settings import REDIS_URL
+from settings import REDIS_URL, ROOT_DIR
from utils.logger import root_logger as logger
@@ -23,85 +26,326 @@ class EnvSection:
class EnvManager:
"""
- Менеджер переменных окружения с хранением в Redis
+ Менеджер переменных окружения с хранением в Redis и синхронизацией с .env файлом
"""
+ # Стандартные переменные окружения, которые следует исключить
+ EXCLUDED_ENV_VARS: Set[str] = {
+ "PATH", "SHELL", "USER", "HOME", "PWD", "TERM", "LANG",
+ "PYTHONPATH", "_", "TMPDIR", "TERM_PROGRAM", "TERM_SESSION_ID",
+ "XPC_SERVICE_NAME", "XPC_FLAGS", "SHLVL", "SECURITYSESSIONID",
+ "LOGNAME", "OLDPWD", "ZSH", "PAGER", "LESS", "LC_CTYPE", "LSCOLORS",
+ "SSH_AUTH_SOCK", "DISPLAY", "COLORTERM", "EDITOR", "VISUAL",
+ "PYTHONDONTWRITEBYTECODE", "VIRTUAL_ENV", "PYTHONUNBUFFERED"
+ }
+
+ # Секции для группировки переменных
+ SECTIONS = {
+ "AUTH": {
+ "pattern": r"^(JWT|AUTH|SESSION|OAUTH|GITHUB|GOOGLE|FACEBOOK)_",
+ "name": "Авторизация",
+ "description": "Настройки системы авторизации"
+ },
+ "DATABASE": {
+ "pattern": r"^(DB|DATABASE|POSTGRES|MYSQL|SQL)_",
+ "name": "База данных",
+ "description": "Настройки подключения к базам данных"
+ },
+ "CACHE": {
+ "pattern": r"^(REDIS|CACHE|MEMCACHED)_",
+ "name": "Кэширование",
+ "description": "Настройки систем кэширования"
+ },
+ "SEARCH": {
+ "pattern": r"^(ELASTIC|SEARCH|OPENSEARCH)_",
+ "name": "Поиск",
+ "description": "Настройки поисковых систем"
+ },
+ "APP": {
+ "pattern": r"^(APP|PORT|HOST|DEBUG|DOMAIN|ENVIRONMENT|ENV|FRONTEND)_",
+ "name": "Приложение",
+ "description": "Основные настройки приложения"
+ },
+ "LOGGING": {
+ "pattern": r"^(LOG|LOGGING|SENTRY|GLITCH|GLITCHTIP)_",
+ "name": "Логирование",
+ "description": "Настройки логирования и мониторинга"
+ },
+ "EMAIL": {
+ "pattern": r"^(MAIL|EMAIL|SMTP)_",
+ "name": "Электронная почта",
+ "description": "Настройки отправки электронной почты"
+ },
+ "ANALYTICS": {
+ "pattern": r"^(GA|GOOGLE_ANALYTICS|ANALYTICS)_",
+ "name": "Аналитика",
+ "description": "Настройки систем аналитики"
+ },
+ }
+
+ # Переменные, которые следует всегда помечать как секретные
+ SECRET_VARS_PATTERNS = [
+ r".*TOKEN.*", r".*SECRET.*", r".*PASSWORD.*", r".*KEY.*",
+ r".*PWD.*", r".*PASS.*", r".*CRED.*"
+ ]
+
def __init__(self):
self.redis = Redis.from_url(REDIS_URL)
self.prefix = "env:"
+ self.env_file_path = os.path.join(ROOT_DIR, '.env')
def get_all_variables(self) -> List[EnvSection]:
"""
Получение всех переменных окружения, сгруппированных по секциям
"""
try:
- # Получаем все ключи с префиксом env:
- keys = self.redis.keys(f"{self.prefix}*")
- variables: Dict[str, str] = {}
+ # Получаем все переменные окружения из системы
+ system_env = self._get_system_env_vars()
- for key in keys:
- var_key = key.decode("utf-8").replace(self.prefix, "")
- value = self.redis.get(key)
- if value:
- variables[var_key] = value.decode("utf-8")
+ # Получаем переменные из .env файла, если он существует
+ dotenv_vars = self._get_dotenv_vars()
+
+ # Получаем все переменные из Redis
+ redis_vars = self._get_redis_env_vars()
+
+ # Объединяем переменные, при этом redis_vars имеют наивысший приоритет,
+ # за ними следуют переменные из .env, затем системные
+ env_vars = {**system_env, **dotenv_vars, **redis_vars}
# Группируем переменные по секциям
- sections = [
- EnvSection(
- name="Авторизация",
- description="Настройки системы авторизации",
- variables=[
- EnvVariable(
- key="JWT_SECRET",
- value=variables.get("JWT_SECRET", ""),
- description="Секретный ключ для JWT токенов",
- type="string",
- is_secret=True,
- ),
- ],
- ),
- EnvSection(
- name="Redis",
- description="Настройки подключения к Redis",
- variables=[
- EnvVariable(
- key="REDIS_URL",
- value=variables.get("REDIS_URL", ""),
- description="URL подключения к Redis",
- type="string",
- )
- ],
- ),
- # Добавьте другие секции по необходимости
- ]
-
- return sections
+ return self._group_variables_by_sections(env_vars)
except Exception as e:
logger.error(f"Ошибка получения переменных: {e}")
return []
+ def _get_system_env_vars(self) -> Dict[str, str]:
+ """
+ Получает переменные окружения из системы, исключая стандартные
+ """
+ env_vars = {}
+ for key, value in os.environ.items():
+ # Пропускаем стандартные переменные
+ if key in self.EXCLUDED_ENV_VARS:
+ continue
+ # Пропускаем переменные с пустыми значениями
+ if not value:
+ continue
+ env_vars[key] = value
+ return env_vars
+
+ def _get_dotenv_vars(self) -> Dict[str, str]:
+ """
+ Получает переменные из .env файла, если он существует
+ """
+ env_vars = {}
+ if os.path.exists(self.env_file_path):
+ try:
+ with open(self.env_file_path, 'r') as f:
+ for line in f:
+ line = line.strip()
+ # Пропускаем пустые строки и комментарии
+ if not line or line.startswith('#'):
+ continue
+ # Разделяем строку на ключ и значение
+ if '=' in line:
+ key, value = line.split('=', 1)
+ key = key.strip()
+ value = value.strip()
+ # Удаляем кавычки, если они есть
+ if value.startswith('"') and value.endswith('"'):
+ value = value[1:-1]
+ env_vars[key] = value
+ except Exception as e:
+ logger.error(f"Ошибка чтения .env файла: {e}")
+ return env_vars
+
+ def _get_redis_env_vars(self) -> Dict[str, str]:
+ """
+ Получает переменные окружения из Redis
+ """
+ redis_vars = {}
+ try:
+ # Получаем все ключи с префиксом env:
+ keys = self.redis.keys(f"{self.prefix}*")
+ for key in keys:
+ var_key = key.decode("utf-8").replace(self.prefix, "")
+ value = self.redis.get(key)
+ if value:
+ redis_vars[var_key] = value.decode("utf-8")
+ except Exception as e:
+ logger.error(f"Ошибка получения переменных из Redis: {e}")
+ return redis_vars
+
+ def _is_secret_variable(self, key: str) -> bool:
+ """
+ Проверяет, является ли переменная секретной
+ """
+ key_upper = key.upper()
+ return any(re.match(pattern, key_upper) for pattern in self.SECRET_VARS_PATTERNS)
+
+ def _determine_variable_type(self, value: str) -> str:
+ """
+ Определяет тип переменной на основе ее значения
+ """
+ if value.lower() in ('true', 'false'):
+ return "boolean"
+ if value.isdigit():
+ return "integer"
+ if re.match(r"^\d+\.\d+$", value):
+ return "float"
+ # Проверяем на JSON объект или массив
+ if (value.startswith('{') and value.endswith('}')) or (value.startswith('[') and value.endswith(']')):
+ return "json"
+ # Проверяем на URL
+ if value.startswith(('http://', 'https://', 'redis://', 'postgresql://')):
+ return "url"
+ return "string"
+
+ def _group_variables_by_sections(self, variables: Dict[str, str]) -> List[EnvSection]:
+ """
+ Группирует переменные по секциям
+ """
+ # Создаем словарь для группировки переменных
+ sections_dict = {section: [] for section in self.SECTIONS}
+ other_variables = [] # Для переменных, которые не попали ни в одну секцию
+
+ # Распределяем переменные по секциям
+ for key, value in variables.items():
+ is_secret = self._is_secret_variable(key)
+ var_type = self._determine_variable_type(value)
+
+ var = EnvVariable(
+ key=key,
+ value=value,
+ type=var_type,
+ is_secret=is_secret
+ )
+
+ # Определяем секцию для переменной
+ placed = False
+ for section_id, section_config in self.SECTIONS.items():
+ if re.match(section_config["pattern"], key, re.IGNORECASE):
+ sections_dict[section_id].append(var)
+ placed = True
+ break
+
+ # Если переменная не попала ни в одну секцию
+ if not placed:
+ other_variables.append(var)
+
+ # Формируем результат
+ result = []
+ for section_id, variables in sections_dict.items():
+ if variables: # Добавляем только непустые секции
+ section_config = self.SECTIONS[section_id]
+ result.append(
+ EnvSection(
+ name=section_config["name"],
+ description=section_config["description"],
+ variables=variables
+ )
+ )
+
+ # Добавляем прочие переменные, если они есть
+ if other_variables:
+ result.append(
+ EnvSection(
+ name="Прочие переменные",
+ description="Переменные, не вошедшие в основные категории",
+ variables=other_variables
+ )
+ )
+
+ return result
+
def update_variable(self, key: str, value: str) -> bool:
"""
- Обновление значения переменной
+ Обновление значения переменной в Redis и .env файле
"""
try:
+ # Сохраняем в Redis
full_key = f"{self.prefix}{key}"
self.redis.set(full_key, value)
+
+ # Обновляем значение в .env файле
+ self._update_dotenv_var(key, value)
+
+ # Обновляем переменную в текущем процессе
+ os.environ[key] = value
+
return True
except Exception as e:
logger.error(f"Ошибка обновления переменной {key}: {e}")
return False
+ def _update_dotenv_var(self, key: str, value: str) -> bool:
+ """
+ Обновляет переменную в .env файле
+ """
+ try:
+ # Если файл .env не существует, создаем его
+ if not os.path.exists(self.env_file_path):
+ with open(self.env_file_path, 'w') as f:
+ f.write(f"{key}={value}\n")
+ return True
+
+ # Если файл существует, читаем его содержимое
+ lines = []
+ found = False
+
+ with open(self.env_file_path, 'r') as f:
+ for line in f:
+ if line.strip() and not line.strip().startswith('#'):
+ if line.strip().startswith(f"{key}="):
+ # Экранируем значение, если необходимо
+ if ' ' in value or ',' in value or '"' in value or "'" in value:
+ escaped_value = f'"{value}"'
+ else:
+ escaped_value = value
+ lines.append(f"{key}={escaped_value}\n")
+ found = True
+ else:
+ lines.append(line)
+ else:
+ lines.append(line)
+
+ # Если переменной не было в файле, добавляем ее
+ if not found:
+ # Экранируем значение, если необходимо
+ if ' ' in value or ',' in value or '"' in value or "'" in value:
+ escaped_value = f'"{value}"'
+ else:
+ escaped_value = value
+ lines.append(f"{key}={escaped_value}\n")
+
+ # Записываем обновленный файл
+ with open(self.env_file_path, 'w') as f:
+ f.writelines(lines)
+
+ return True
+ except Exception as e:
+ logger.error(f"Ошибка обновления .env файла: {e}")
+ return False
+
def update_variables(self, variables: List[EnvVariable]) -> bool:
"""
Массовое обновление переменных
"""
try:
+ # Обновляем переменные в Redis
pipe = self.redis.pipeline()
for var in variables:
full_key = f"{self.prefix}{var.key}"
pipe.set(full_key, var.value)
pipe.execute()
+
+ # Обновляем переменные в .env файле
+ for var in variables:
+ self._update_dotenv_var(var.key, var.value)
+
+ # Обновляем переменную в текущем процессе
+ os.environ[var.key] = var.value
+
return True
except Exception as e:
logger.error(f"Ошибка массового обновления переменных: {e}")
diff --git a/settings.py b/settings.py
index 92ccab17..32f7a54e 100644
--- a/settings.py
+++ b/settings.py
@@ -3,6 +3,10 @@
import os
import sys
from os import environ
+from pathlib import Path
+
+# Корневая директория проекта
+ROOT_DIR = Path(__file__).parent.absolute()
DEV_SERVER_PID_FILE_NAME = "dev-server.pid"