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

View File

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

View File

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

View File

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

2
cache/cache.py vendored
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)
# If data is not found in cache, query the database
author_query = select(Author).where(Author.user == user_id)
author_query = select(Author).where(Author.id == user_id)
authors = get_with_stat(author_query)
if authors:
# Cache the retrieved author data

10
main.py
View File

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

View File

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

View File

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

View File

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

View File

@ -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}`

View File

@ -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;
}

View File

@ -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

View File

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

View File

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

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
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

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
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)

View File

@ -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:

View File

@ -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":

View File

@ -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:

View File

@ -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"}

View File

@ -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!
}

View File

@ -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
}

View File

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

View File

@ -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}")

View File

@ -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"