From 2d382be794efad9552f8eefc9a807ca939203652 Mon Sep 17 00:00:00 2001 From: Untone Date: Fri, 16 May 2025 09:23:48 +0300 Subject: [PATCH] upgrade schema, resolvers, panel added --- .gitignore | 4 +- CHANGELOG.md | 170 +- README.md | 3 + alembic/env.py | 2 +- auth/__init__.py | 122 ++ auth/authenticate.py | 113 +- auth/credentials.py | 101 +- auth/decorators.py | 142 ++ auth/identity.py | 127 +- auth/internal.py | 168 ++ auth/jwtcodec.py | 14 +- auth/middleware.py | 110 + auth/oauth.py | 257 ++- auth/orm.py | 259 +++ auth/permissions.py | 242 +++ auth/resolvers.py | 504 +++-- auth/sessions.py | 228 ++ auth/tokenstorage.py | 204 +- biome.json | 93 + cache/cache.py | 14 +- cache/precache.py | 17 +- cache/revalidator.py | 5 +- cache/triggers.py | 2 +- docs/README.md | 34 + docs/auth.md | 757 +++++++ docs/features.md | 9 - env.d.ts | 9 + index.html | 20 + main.py | 271 ++- orm/author.py | 136 -- orm/community.py | 14 +- orm/draft.py | 25 +- orm/notification.py | 2 +- orm/shout.py | 2 +- package-lock.json | 2236 ++++++++++++++++++++ package.json | 39 + panel/App.tsx | 111 + panel/admin.tsx | 676 ++++++ panel/auth.ts | 143 ++ panel/graphql.ts | 189 ++ panel/index.tsx | 12 + panel/login.tsx | 112 + panel/styles.css | 587 +++++ resolvers/author.py | 8 +- resolvers/bookmark.py | 6 +- resolvers/collab.py | 2 +- resolvers/community.py | 12 +- resolvers/draft.py | 121 +- resolvers/editor.py | 99 +- resolvers/feed.py | 14 +- resolvers/follower.py | 11 +- resolvers/notifier.py | 6 +- resolvers/proposals.py | 6 +- resolvers/rating.py | 6 +- resolvers/reaction.py | 8 +- resolvers/reader.py | 45 +- resolvers/stat.py | 16 +- resolvers/topic.py | 2 +- schema/admin.graphql | 71 + schema/enum.graphql | 15 + schema/input.graphql | 22 + schema/mutation.graphql | 10 + schema/query.graphql | 8 + schema/type.graphql | 40 + services/auth.py | 158 +- services/db.py | 67 +- services/env.py | 111 + services/exception.py | 5 +- services/notify.py | 3 +- services/redis.py | 87 + services/schema.py | 44 +- services/search.py | 11 +- services/viewed.py | 18 +- services/webhook.py | 175 -- settings.py | 50 +- tsconfig.json | 24 + utils/__init__.py | 1 - utils/{html_wrapper.py => extract_text.py} | 38 +- utils/generate_slug.py | 65 + vite.config.ts | 71 + 80 files changed, 8641 insertions(+), 1100 deletions(-) create mode 100644 auth/__init__.py create mode 100644 auth/decorators.py create mode 100644 auth/internal.py create mode 100644 auth/middleware.py create mode 100644 auth/orm.py create mode 100644 auth/permissions.py create mode 100644 auth/sessions.py create mode 100644 biome.json create mode 100644 docs/README.md create mode 100644 docs/auth.md create mode 100644 env.d.ts create mode 100644 index.html delete mode 100644 orm/author.py create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 panel/App.tsx create mode 100644 panel/admin.tsx create mode 100644 panel/auth.ts create mode 100644 panel/graphql.ts create mode 100644 panel/index.tsx create mode 100644 panel/login.tsx create mode 100644 panel/styles.css create mode 100644 schema/admin.graphql create mode 100644 services/env.py delete mode 100644 services/webhook.py create mode 100644 tsconfig.json rename utils/{html_wrapper.py => extract_text.py} (64%) create mode 100644 utils/generate_slug.py create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore index 4db9e7e4..fe42d48f 100644 --- a/.gitignore +++ b/.gitignore @@ -161,4 +161,6 @@ views.json *.key *.crt *cache.json -.cursor \ No newline at end of file +.cursor + +node_modules/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 90589770..6074c0c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,163 @@ -#### [0.4.20] - 2025-05-03 -- Исправлена ошибка в классе `CacheRevalidationManager`: добавлена инициализация атрибута `_redis` -- Улучшена обработка соединения с Redis в менеджере ревалидации кэша: - - Автоматическое восстановление соединения в случае его потери - - Проверка соединения перед выполнением операций с кэшем - - Дополнительное логирование для упрощения диагностики проблем -- Исправлен резолвер `unpublish_shout`: - - Корректное формирование синтетического поля `publication` с `published_at: null` - - Возвращение полноценного словаря с данными вместо объекта модели - - Улучшена загрузка связанных данных (авторы, темы) для правильного формирования ответа +# 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 + - Оптимизированы стили для минимального набора компонентов + +### Добавлено +- Создана панель управления пользователями в админке: + - Добавлен компонент UsersList для управления пользователями + - Реализованы функции блокировки/разблокировки пользователей + - Добавлена возможность отключения звука (mute) для пользователей + - Реализовано управление ролями пользователей через модальное окно + - Добавлены GraphQL мутации для управления пользователями в schema/admin.graphql + - Улучшен интерфейс админ-панели с табами для навигации +- Расширена схема GraphQL для админки: + - Добавлены типы AdminUserInfo и AdminUserUpdateInput + - Добавлены мутации adminUpdateUser, adminToggleUserBlock, adminToggleUserMute + - Добавлены запросы adminGetUsers и adminGetRoles +- Пагинация списка пользователей в админ-панели +- Серверная поддержка пагинации в API для админ-панели +- Поиск пользователей по email, имени и ID + +### Улучшено +- Улучшен интерфейс админ-панели: + - Добавлены вкладки для переключения между разделами + - Оптимизирован компонент UsersList для работы с большим количеством пользователей + - Добавлены индикаторы статуса для заблокированных и отключенных пользователей + - Улучшена обработка ошибок при выполнении операций с пользователями + - Добавлены подтверждения для критичных операций (блокировка, изменение ролей) + +### Полностью переработан клиентский код: +- Создан компактный API клиент с изолированным кодом для доступа к API +- Реализована модульная архитектура с четким разделением ответственности +- Добавлены типизированные интерфейсы для всех компонентов и модулей +- Реализована система маршрутизации с защищенными маршрутами +- Добавлен компонент AuthProvider для управления авторизацией +- Оптимизирована загрузка компонентов с использованием ленивой загрузки +- Унифицирован стиль кода и именования + +### Исправлено +- Исправлена проблема с перенаправлением в SolidJS, которое сбрасывало состояние приложения: + - Обновлена функция logout для использования колбэка навигации вместо жесткого редиректа + - Добавлен компонент LoginPage для авторизации без перезагрузки страницы + - Реализована ленивая загрузка компонентов с использованием Suspense + - Улучшена структура роутинга в админ-панели + - Оптимизирован код согласно принципам DRY и KISS + +### Улучшения для авторизации в админ-панели + +- Исправлена проблема с авторизацией в админ-панели +- Добавлена поддержка 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-форма на странице входа в админ-панель: + - Добавлен тег `
` для устранения предупреждения браузера о полях пароля вне формы + - Улучшена доступность и 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` + - Запросы: `signOut`, `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 + +### Изменено +- Переработана пагинация в админ-панели: переход с модели page/perPage на limit/offset +- Улучшена производительность при работе с большими списками пользователей +- Оптимизирован GraphQL API для управления пользователями + +### Исправлено +- Исправлена ошибка GraphQL "Unknown argument 'page' on field 'Query.adminGetUsers'" +- Согласованы параметры пагинации между клиентом и сервером + +#### [0.4.20] - 2023-09-01 + +### Добавлено +- Пагинация списка пользователей в админ-панели +- Серверная поддержка пагинации в API для админ-панели +- Поиск пользователей по email, имени и ID + +### Изменено +- Улучшен интерфейс админ-панели +- Переработана обработка GraphQL запросов для списка пользователей + +### Исправлено +- Проблемы с авторизацией и проверкой токенов +- Обработка ошибок в API модулях #### [0.4.19] - 2025-04-14 - dropped `Shout.description` and `Draft.description` to be UX-generated diff --git a/README.md b/README.md index 68fb1e4f..fe7241da 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,9 @@ pytest # Type checking mypy . + +# dev run +python -m granian main:app --interface asgi ``` ### Code Style diff --git a/alembic/env.py b/alembic/env.py index 1957c1d3..69ba16e5 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -11,7 +11,7 @@ from settings import DB_URL config = context.config # override DB_URL -config.set_section_option(config.config_ini_section, "DB_URL", DB_URL) +config.set_main_option("sqlalchemy.url", DB_URL) # Interpret the config file for Python logging. # This line sets up loggers basically. diff --git a/auth/__init__.py b/auth/__init__.py new file mode 100644 index 00000000..3e4a05aa --- /dev/null +++ b/auth/__init__.py @@ -0,0 +1,122 @@ +from starlette.requests import Request +from starlette.responses import JSONResponse, RedirectResponse +from starlette.routing import Route + +from auth.sessions import SessionManager +from auth.internal import verify_internal_auth +from auth.orm import Author +from services.db import local_session +from utils.logger import root_logger as logger +from settings import ( + SESSION_COOKIE_NAME, + SESSION_COOKIE_HTTPONLY, + SESSION_COOKIE_SECURE, + SESSION_COOKIE_SAMESITE, + SESSION_COOKIE_MAX_AGE, +) + + +async def logout(request: Request): + """ + Выход из системы с удалением сессии и cookie. + """ + # Получаем токен из cookie или заголовка + token = request.cookies.get(SESSION_COOKIE_NAME) + if not token: + # Проверяем заголовок авторизации + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header[7:] # Отрезаем "Bearer " + + # Если токен найден, отзываем его + if token: + try: + # Декодируем токен для получения user_id + user_id, _ = await verify_internal_auth(token) + if user_id: + # Отзываем сессию + await SessionManager.revoke_session(user_id, token) + logger.info(f"[auth] logout: Токен успешно отозван для пользователя {user_id}") + else: + logger.warning("[auth] logout: Не удалось получить user_id из токена") + except Exception as e: + logger.error(f"[auth] logout: Ошибка при отзыве токена: {e}") + + # Создаем ответ с редиректом на страницу входа + response = RedirectResponse(url="/login") + + # Удаляем cookie с токеном + response.delete_cookie(SESSION_COOKIE_NAME) + logger.info("[auth] logout: Cookie успешно удалена") + + return response + + +async def refresh_token(request: Request): + """ + Обновление токена аутентификации. + """ + # Получаем текущий токен из cookie или заголовка + token = request.cookies.get(SESSION_COOKIE_NAME) + if not token: + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header[7:] # Отрезаем "Bearer " + + if not token: + return JSONResponse({"success": False, "error": "Токен не найден"}, status_code=401) + + try: + # Получаем информацию о пользователе из токена + user_id, _ = await verify_internal_auth(token) + if not user_id: + return JSONResponse({"success": False, "error": "Недействительный токен"}, status_code=401) + + # Получаем пользователя из базы данных + with local_session() as session: + author = session.query(Author).filter(Author.id == user_id).first() + + if not author: + return JSONResponse({"success": False, "error": "Пользователь не найден"}, status_code=404) + + # Обновляем сессию (создаем новую и отзываем старую) + device_info = {"ip": request.client.host, "user_agent": request.headers.get("user-agent")} + new_token = await SessionManager.refresh_session(user_id, token, device_info) + + if not new_token: + return JSONResponse( + {"success": False, "error": "Не удалось обновить токен"}, status_code=500 + ) + + # Создаем ответ + response = JSONResponse( + { + "success": True, + "token": new_token, + "author": {"id": author.id, "email": author.email, "name": author.name}, + } + ) + + # Устанавливаем cookie с новым токеном + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=new_token, + httponly=SESSION_COOKIE_HTTPONLY, + secure=SESSION_COOKIE_SECURE, + samesite=SESSION_COOKIE_SAMESITE, + max_age=SESSION_COOKIE_MAX_AGE, + ) + + logger.info(f"[auth] refresh_token: Токен успешно обновлен для пользователя {user_id}") + return response + + except Exception as e: + logger.error(f"[auth] refresh_token: Ошибка при обновлении токена: {e}") + return JSONResponse({"success": False, "error": str(e)}, status_code=401) + + +# Маршруты для авторизации +routes = [ + Route("/auth/logout", logout, methods=["GET", "POST"]), + Route("/auth/refresh", refresh_token, methods=["POST"]), +] diff --git a/auth/authenticate.py b/auth/authenticate.py index b1b250ed..8a9599af 100644 --- a/auth/authenticate.py +++ b/auth/authenticate.py @@ -1,54 +1,72 @@ from functools import wraps -from typing import Optional, Tuple +from typing import Optional from graphql.type import GraphQLResolveInfo -from sqlalchemy.orm import exc, joinedload +from sqlalchemy.orm import exc from starlette.authentication import AuthenticationBackend from starlette.requests import HTTPConnection -from auth.credentials import AuthCredentials, AuthUser +from auth.credentials import AuthCredentials from auth.exceptions import OperationNotAllowed -from auth.tokenstorage import SessionToken -from auth.usermodel import Role, User +from auth.sessions import SessionManager +from auth.orm import Author from services.db import local_session from settings import SESSION_TOKEN_HEADER class JWTAuthenticate(AuthenticationBackend): - async def authenticate(self, request: HTTPConnection) -> Optional[Tuple[AuthCredentials, AuthUser]]: + async def authenticate(self, request: HTTPConnection) -> Optional[AuthCredentials]: + """ + Аутентификация пользователя по JWT токену. + + Args: + request: HTTP запрос + + Returns: + AuthCredentials при успешной аутентификации или None при ошибке + """ if SESSION_TOKEN_HEADER not in request.headers: - return AuthCredentials(scopes={}), AuthUser(user_id=None, username="") + return None - token = request.headers.get(SESSION_TOKEN_HEADER) - if not token: + auth_header = request.headers.get(SESSION_TOKEN_HEADER) + if not auth_header: print("[auth.authenticate] no token in header %s" % SESSION_TOKEN_HEADER) - return AuthCredentials(scopes={}, error_message=str("no token")), AuthUser(user_id=None, username="") + return None - if len(token.split(".")) > 1: - payload = await SessionToken.verify(token) + # Обработка формата "Bearer " + token = auth_header + if auth_header.startswith("Bearer "): + token = auth_header.replace("Bearer ", "", 1).strip() - with local_session() as session: - try: - user = ( - session.query(User) - .options( - joinedload(User.roles).options(joinedload(Role.permissions)), - joinedload(User.ratings), - ) - .filter(User.id == payload.user_id) - .one() - ) + if not token: + print("[auth.authenticate] empty token after Bearer prefix removal") + return None - scopes = {} # TODO: integrate await user.get_permission() + # Проверяем сессию в Redis + payload = await SessionManager.verify_session(token) + if not payload: + return None - return ( - AuthCredentials(user_id=payload.user_id, scopes=scopes, logged_in=True), - AuthUser(user_id=user.id, username=""), - ) - except exc.NoResultFound: - pass + with local_session() as session: + try: + author = ( + session.query(Author) + .filter(Author.id == payload.user_id) + .filter(Author.is_active == True) # noqa + .one() + ) - return AuthCredentials(scopes={}, error_message=str("Invalid token")), AuthUser(user_id=None, username="") + if author.is_locked(): + return None + + # Получаем разрешения из ролей + scopes = author.get_permissions() + + return AuthCredentials( + author_id=author.id, scopes=scopes, logged_in=True, email=author.email + ) + except exc.NoResultFound: + return None def login_required(func): @@ -62,15 +80,34 @@ def login_required(func): return wrap -def permission_required(resource, operation, func): +def permission_required(resource: str, operation: str, func): + """ + Декоратор для проверки разрешений. + + Args: + resource (str): Ресурс для проверки + operation (str): Операция для проверки + func: Декорируемая функция + """ + @wraps(func) async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs): - print("[auth.authenticate] permission_required for %r with info %r" % (func, info)) # debug only auth: AuthCredentials = info.context["request"].auth if not auth.logged_in: raise OperationNotAllowed(auth.error_message or "Please login") - # TODO: add actual check permission logix here + with local_session() as session: + author = session.query(Author).filter(Author.id == auth.author_id).one() + + # Проверяем базовые условия + if not author.is_active: + raise OperationNotAllowed("Account is not active") + if author.is_locked(): + raise OperationNotAllowed("Account is locked") + + # Проверяем разрешение + if not author.has_permission(resource, operation): + raise OperationNotAllowed(f"No permission for {operation} on {resource}") return await func(parent, info, *args, **kwargs) @@ -82,12 +119,12 @@ def login_accepted(func): async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs): auth: AuthCredentials = info.context["request"].auth - # Если есть авторизация, добавляем данные автора в контекст if auth and auth.logged_in: - info.context["author"] = auth.author - info.context["user_id"] = auth.author.get("id") + with local_session() as session: + author = session.query(Author).filter(Author.id == auth.author_id).one() + info.context["author"] = author.dict() + info.context["user_id"] = author.id else: - # Очищаем данные автора из контекста если авторизация отсутствует info.context["author"] = None info.context["user_id"] = None diff --git a/auth/credentials.py b/auth/credentials.py index 3d7d5a36..c02bc481 100644 --- a/auth/credentials.py +++ b/auth/credentials.py @@ -1,43 +1,94 @@ -from typing import List, Optional, Text +from typing import Dict, List, Optional, Set, Any -from pydantic import BaseModel +from pydantic import BaseModel, Field # from base.exceptions import Unauthorized +from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST + +ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",") class Permission(BaseModel): - name: Text + """Модель разрешения для RBAC""" + + resource: str + operation: str + + def __str__(self) -> str: + return f"{self.resource}:{self.operation}" class AuthCredentials(BaseModel): - user_id: Optional[int] = None - scopes: Optional[dict] = {} - logged_in: bool = False - error_message: str = "" + """ + Модель учетных данных авторизации. + Используется как часть механизма аутентификации Starlette. + """ + + author_id: Optional[int] = Field(None, description="ID автора") + scopes: Dict[str, Set[str]] = Field(default_factory=dict, description="Разрешения пользователя") + logged_in: bool = Field(False, description="Флаг, указывающий, авторизован ли пользователь") + error_message: str = Field("", description="Сообщение об ошибке аутентификации") + email: Optional[str] = Field(None, description="Email пользователя") + + def get_permissions(self) -> List[str]: + """ + Возвращает список строковых представлений разрешений. + Например: ["posts:read", "posts:write", "comments:create"]. + + Returns: + List[str]: Список разрешений + """ + result = [] + for resource, operations in self.scopes.items(): + for operation in operations: + result.append(f"{resource}:{operation}") + return result + + def has_permission(self, resource: str, operation: str) -> bool: + """ + Проверяет наличие определенного разрешения. + + Args: + resource: Ресурс (например, "posts") + operation: Операция (например, "read") + + Returns: + bool: True, если пользователь имеет указанное разрешение + """ + if not self.logged_in: + return False + + return resource in self.scopes and operation in self.scopes[resource] @property - def is_admin(self): - # TODO: check admin logix - return True + def is_admin(self) -> bool: + """ + Проверяет, является ли пользователь администратором. + + Returns: + bool: True, если email пользователя находится в списке ADMIN_EMAILS + """ + return self.email in ADMIN_EMAILS if self.email else False + + def to_dict(self) -> Dict[str, Any]: + """ + Преобразует учетные данные в словарь + + Returns: + Dict[str, Any]: Словарь с данными учетных данных + """ + return { + "author_id": self.author_id, + "logged_in": self.logged_in, + "is_admin": self.is_admin, + "permissions": self.get_permissions(), + } async def permissions(self) -> List[Permission]: - if self.user_id is None: + if self.author_id is None: # raise Unauthorized("Please login first") return {"error": "Please login first"} else: # TODO: implement permissions logix - print(self.user_id) + print(self.author_id) return NotImplemented - - -class AuthUser(BaseModel): - user_id: Optional[int] - username: Optional[str] - - @property - def is_authenticated(self) -> bool: - return self.user_id is not None - - # @property - # def display_id(self) -> int: - # return self.user_id diff --git a/auth/decorators.py b/auth/decorators.py new file mode 100644 index 00000000..9e70124b --- /dev/null +++ b/auth/decorators.py @@ -0,0 +1,142 @@ +from functools import wraps +from typing import Callable, Any +from graphql import GraphQLError +from services.db import local_session +from auth.orm import Author +from auth.exceptions import OperationNotAllowed +from utils.logger import root_logger as logger +from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST + +ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",") + + +def admin_auth_required(resolver: Callable) -> Callable: + """ + Декоратор для защиты админских эндпоинтов. + Проверяет принадлежность к списку разрешенных email-адресов. + + Args: + resolver: GraphQL резолвер для защиты + + Returns: + Обернутый резолвер, который проверяет права доступа + + Raises: + GraphQLError: если пользователь не авторизован или не имеет доступа администратора + """ + + @wraps(resolver) + async def wrapper(root: Any = None, info: Any = None, **kwargs): + try: + # Проверяем наличие info и контекста + if info is None or not hasattr(info, "context"): + logger.error("Missing GraphQL context information") + raise GraphQLError("Internal server error: missing context") + + # Получаем ID пользователя из контекста запроса + request = info.context.get("request") + if not request or not hasattr(request, "auth"): + logger.error("Missing request or auth object in context") + raise GraphQLError("Internal server error: missing auth") + + auth = request.auth + if not auth or not auth.logged_in: + client_info = { + "ip": request.client.host if hasattr(request, "client") else "unknown", + "headers": dict(request.headers), + } + logger.error(f"Unauthorized access attempt for admin endpoint: {client_info}") + raise GraphQLError("Unauthorized") + + # Проверяем принадлежность к списку админов + with local_session() as session: + try: + author = session.query(Author).filter(Author.id == auth.author_id).one() + + # Проверка по email + if author.email in ADMIN_EMAILS: + logger.info( + f"Admin access granted for {author.email} (special admin, ID: {author.id})" + ) + return await resolver(root, info, **kwargs) + else: + logger.warning( + f"Admin access denied for {author.email} (ID: {author.id}) - not in admin list" + ) + raise GraphQLError("Unauthorized - not an admin") + except Exception as db_error: + logger.error(f"Error fetching author with ID {auth.author_id}: {str(db_error)}") + raise GraphQLError("Unauthorized - user not found") + + except Exception as e: + # Если ошибка уже GraphQLError, просто перебрасываем её + if isinstance(e, GraphQLError): + logger.error(f"GraphQL error in admin_auth_required: {str(e)}") + raise e + + # Иначе, создаем новую GraphQLError + logger.error(f"Error in admin_auth_required: {str(e)}") + raise GraphQLError(f"Admin access error: {str(e)}") + + return wrapper + + +def require_permission(permission_string: str): + """ + Декоратор для проверки наличия указанного разрешения. + Принимает строку в формате "resource:permission". + + Args: + permission_string: Строка в формате "resource:permission" + + Returns: + Декоратор, проверяющий наличие указанного разрешения + + Raises: + ValueError: если строка разрешения имеет неверный формат + """ + if ":" not in permission_string: + raise ValueError('Permission string must be in format "resource:permission"') + + resource, operation = permission_string.split(":", 1) + + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper(parent, info: Any = None, *args, **kwargs): + # Проверяем наличие info и контекста + if info is None or not hasattr(info, "context"): + logger.error("Missing GraphQL context information in require_permission") + raise OperationNotAllowed("Internal server error: missing context") + + auth = info.context["request"].auth + if not auth or not auth.logged_in: + raise OperationNotAllowed("Unauthorized - please login") + + with local_session() as session: + try: + author = session.query(Author).filter(Author.id == auth.author_id).one() + + # Проверяем базовые условия + if not author.is_active: + raise OperationNotAllowed("Account is not active") + if author.is_locked(): + raise OperationNotAllowed("Account is locked") + + # Проверяем разрешение + if not author.has_permission(resource, operation): + logger.warning( + f"Access denied for user {auth.author_id} - no permission {resource}:{operation}" + ) + raise OperationNotAllowed(f"No permission for {operation} on {resource}") + + # Пользователь аутентифицирован и имеет необходимое разрешение + return await func(parent, info, *args, **kwargs) + except Exception as e: + logger.error(f"Error in require_permission: {e}") + if isinstance(e, OperationNotAllowed): + raise e + raise OperationNotAllowed(str(e)) + + return wrapper + + return decorator diff --git a/auth/identity.py b/auth/identity.py index 63dc4bc9..0ac4d37e 100644 --- a/auth/identity.py +++ b/auth/identity.py @@ -1,16 +1,21 @@ from binascii import hexlify from hashlib import sha256 +from typing import Any, Dict, TypeVar, TYPE_CHECKING from passlib.hash import bcrypt -from auth.exceptions import ExpiredToken, InvalidToken +from auth.exceptions import ExpiredToken, InvalidToken, InvalidPassword from auth.jwtcodec import JWTCodec from auth.tokenstorage import TokenStorage -from orm.user import User -# from base.exceptions import InvalidPassword, InvalidToken from services.db import local_session +# Для типизации +if TYPE_CHECKING: + from auth.orm import Author + +AuthorType = TypeVar("AuthorType", bound="Author") + class Password: @staticmethod @@ -24,6 +29,15 @@ class Password: @staticmethod def encode(password: str) -> str: + """ + Кодирует пароль пользователя + + Args: + password (str): Пароль пользователя + + Returns: + str: Закодированный пароль + """ password_sha256 = Password._get_sha256(password) return bcrypt.using(rounds=10).hash(password_sha256) @@ -52,28 +66,93 @@ class Password: class Identity: @staticmethod - def password(orm_user: User, password: str) -> User: - user = User(**orm_user.dict()) - if not user.password: - # raise InvalidPassword("User password is empty") - return {"error": "User password is empty"} - if not Password.verify(password, user.password): - # raise InvalidPassword("Wrong user password") - return {"error": "Wrong user password"} - return user + def password(orm_author: Any, password: str) -> Any: + """ + Проверяет пароль пользователя + + Args: + orm_author (Author): Объект пользователя + password (str): Пароль пользователя + + Returns: + Author: Объект автора при успешной проверке + + Raises: + InvalidPassword: Если пароль не соответствует хешу или отсутствует + """ + # Импортируем внутри функции для избежания циклических импортов + from auth.orm import Author + from utils.logger import root_logger as logger + + # Проверим исходный пароль в orm_author + if not orm_author.password: + logger.warning( + f"[auth.identity] Пароль в исходном объекте автора пуст: email={orm_author.email}" + ) + 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): + logger.warning(f"[auth.identity] Неверный пароль для {orm_author.email}") + raise InvalidPassword("Неверный пароль пользователя") + + # Возвращаем исходный объект, чтобы сохранить все связи + return orm_author @staticmethod - def oauth(inp) -> User: + def oauth(inp: Dict[str, Any]) -> Any: + """ + Создает нового пользователя OAuth, если он не существует + + Args: + inp (dict): Данные OAuth пользователя + + Returns: + Author: Объект пользователя + """ + # Импортируем внутри функции для избежания циклических импортов + from auth.orm import Author + with local_session() as session: - user = session.query(User).filter(User.email == inp["email"]).first() - if not user: - user = User.create(**inp, emailConfirmed=True) + author = session.query(Author).filter(Author.email == inp["email"]).first() + if not author: + author = Author(**inp) + author.email_verified = True + session.add(author) session.commit() - return user + return author @staticmethod - async def onetime(token: str) -> User: + async def onetime(token: str) -> Any: + """ + Проверяет одноразовый токен + + Args: + token (str): Одноразовый токен + + Returns: + Author: Объект пользователя + """ + # Импортируем внутри функции для избежания циклических импортов + from auth.orm import Author + try: print("[auth.identity] using one time token") payload = JWTCodec.decode(token) @@ -87,11 +166,11 @@ class Identity: # raise InvalidToken("token format error") from e return {"error": "Token format error"} with local_session() as session: - user = session.query(User).filter_by(id=payload.user_id).first() - if not user: + author = session.query(Author).filter_by(id=payload.user_id).first() + if not author: # raise Exception("user not exist") - return {"error": "User does not exist"} - if not user.emailConfirmed: - user.emailConfirmed = True + return {"error": "Author does not exist"} + if not author.email_verified: + author.email_verified = True session.commit() - return user + return author diff --git a/auth/internal.py b/auth/internal.py new file mode 100644 index 00000000..637d2c1a --- /dev/null +++ b/auth/internal.py @@ -0,0 +1,168 @@ +from typing import Optional, Tuple +import time + +from sqlalchemy.orm import exc +from starlette.authentication import AuthenticationBackend, BaseUser, UnauthenticatedUser +from starlette.requests import HTTPConnection + +from auth.credentials import AuthCredentials +from auth.orm import Author +from auth.sessions import SessionManager +from services.db import local_session +from settings import SESSION_TOKEN_HEADER +from utils.logger import root_logger as logger + + +class AuthenticatedUser(BaseUser): + """Аутентифицированный пользователь для Starlette""" + + def __init__(self, user_id: str, username: str = "", roles: list = None, permissions: dict = None): + self.user_id = user_id + self.username = username + self.roles = roles or [] + self.permissions = permissions or {} + + @property + def is_authenticated(self) -> bool: + return True + + @property + def display_name(self) -> str: + return self.username + + @property + def identity(self) -> str: + return self.user_id + + +class InternalAuthentication(AuthenticationBackend): + """Внутренняя аутентификация через базу данных и Redis""" + + async def authenticate(self, request: HTTPConnection): + """ + Аутентифицирует пользователя по токену из заголовка. + Токен должен быть обработан заранее AuthorizationMiddleware, + который извлекает Bearer токен и преобразует его в чистый токен. + + Возвращает: + tuple: (AuthCredentials, BaseUser) + """ + if SESSION_TOKEN_HEADER not in request.headers: + return AuthCredentials(scopes={}), UnauthenticatedUser() + + token = request.headers.get(SESSION_TOKEN_HEADER) + if not token: + logger.debug("[auth.authenticate] Пустой токен в заголовке") + return AuthCredentials(scopes={}, error_message="no token"), UnauthenticatedUser() + + # Проверяем сессию в Redis + payload = await SessionManager.verify_session(token) + if not payload: + logger.debug("[auth.authenticate] Недействительный токен") + return AuthCredentials(scopes={}, error_message="Invalid token"), UnauthenticatedUser() + + with local_session() as session: + try: + author = ( + session.query(Author) + .filter(Author.id == payload.user_id) + .filter(Author.is_active == True) # noqa + .one() + ) + + if author.is_locked(): + logger.debug(f"[auth.authenticate] Аккаунт заблокирован: {author.id}") + return AuthCredentials( + scopes={}, error_message="Account is locked" + ), UnauthenticatedUser() + + # Получаем разрешения из ролей + scopes = author.get_permissions() + + # Получаем роли для пользователя + roles = [role.id for role in author.roles] if author.roles else [] + + # Обновляем last_seen + author.last_seen = int(time.time()) + session.commit() + + # Создаем объекты авторизации + credentials = AuthCredentials( + author_id=author.id, scopes=scopes, logged_in=True, email=author.email + ) + + user = AuthenticatedUser( + user_id=str(author.id), + username=author.slug or author.email or "", + roles=roles, + permissions=scopes, + ) + + logger.debug(f"[auth.authenticate] Успешная аутентификация: {author.email}") + return credentials, user + + except exc.NoResultFound: + logger.debug("[auth.authenticate] Пользователь не найден") + return AuthCredentials(scopes={}, error_message="User not found"), UnauthenticatedUser() + + +async def verify_internal_auth(token: str) -> Tuple[str, list]: + """ + Проверяет локальную авторизацию. + Возвращает user_id и список ролей. + + Args: + token: Токен авторизации (может быть как с Bearer, так и без) + + Returns: + tuple: (user_id, roles) + """ + # Обработка формата "Bearer " (если токен не был обработан ранее) + if token.startswith("Bearer "): + token = token.replace("Bearer ", "", 1).strip() + + # Проверяем сессию + payload = await SessionManager.verify_session(token) + if not payload: + return "", [] + + with local_session() as session: + try: + author = ( + session.query(Author) + .filter(Author.id == payload.user_id) + .filter(Author.is_active == True) # noqa + .one() + ) + + # Получаем роли + roles = [role.id for role in author.roles] + + return str(author.id), roles + except exc.NoResultFound: + return "", [] + + +async def create_internal_session(author: Author, device_info: Optional[dict] = None) -> str: + """ + Создает новую сессию для автора + + Args: + author: Объект автора + device_info: Информация об устройстве (опционально) + + Returns: + str: Токен сессии + """ + # Сбрасываем счетчик неудачных попыток + author.reset_failed_login() + + # Обновляем last_login + author.last_login = int(time.time()) + + # Создаем сессию, используя token для идентификации + return await SessionManager.create_session( + user_id=str(author.id), + username=author.slug or author.email or author.phone or "", + device_info=device_info, + ) diff --git a/auth/jwtcodec.py b/auth/jwtcodec.py index cad30686..d83cb313 100644 --- a/auth/jwtcodec.py +++ b/auth/jwtcodec.py @@ -20,7 +20,7 @@ class JWTCodec: def encode(user, exp: datetime) -> str: payload = { "user_id": user.id, - "username": user.email or user.phone, + "username": user.slug or user.email or user.phone or "", "exp": exp, "iat": datetime.now(tz=timezone.utc), "iss": "discours", @@ -50,11 +50,13 @@ class JWTCodec: return r except jwt.InvalidIssuedAtError: print("[auth.jwtcodec] invalid issued at: %r" % payload) - raise ExpiredToken("check token issued time") + raise ExpiredToken("jwt check token issued time") except jwt.ExpiredSignatureError: print("[auth.jwtcodec] expired signature %r" % payload) - raise ExpiredToken("check token lifetime") - except jwt.InvalidTokenError: - raise InvalidToken("token is not valid") + raise ExpiredToken("jwt check token lifetime") except jwt.InvalidSignatureError: - raise InvalidToken("token is not valid") + raise InvalidToken("jwt check signature is not valid") + except jwt.InvalidTokenError: + raise InvalidToken("jwt check token is not valid") + except jwt.InvalidKeyError: + raise InvalidToken("jwt check key is not valid") diff --git a/auth/middleware.py b/auth/middleware.py new file mode 100644 index 00000000..25cb7632 --- /dev/null +++ b/auth/middleware.py @@ -0,0 +1,110 @@ +""" +Middleware для обработки авторизации в GraphQL запросах +""" + +from starlette.datastructures import Headers +from starlette.types import ASGIApp, Scope, Receive, Send +from utils.logger import root_logger as logger +from settings import SESSION_TOKEN_HEADER, SESSION_COOKIE_NAME + + +class AuthorizationMiddleware: + """ + Middleware для обработки заголовка Authorization и cookie авторизации. + Извлекает Bearer токен из заголовка или cookie и добавляет его в заголовки + запроса для обработки стандартным AuthenticationMiddleware Starlette. + """ + + def __init__(self, app: ASGIApp): + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + # Извлекаем заголовки + headers = Headers(scope=scope) + auth_header = headers.get(SESSION_TOKEN_HEADER) + token = None + + # Сначала пробуем получить токен из заголовка Authorization + if auth_header: + if auth_header.startswith("Bearer "): + token = auth_header.replace("Bearer ", "", 1).strip() + logger.debug( + f"[middleware] Извлечен Bearer токен из заголовка, длина: {len(token) if token else 0}" + ) + + # Если токен не получен из заголовка, пробуем взять из cookie + if not token: + cookies = headers.get("cookie", "") + cookie_items = cookies.split(";") + for item in cookie_items: + if "=" in item: + name, value = item.split("=", 1) + if name.strip() == SESSION_COOKIE_NAME: + token = value.strip() + logger.debug( + f"[middleware] Извлечен токен из cookie, длина: {len(token) if token else 0}" + ) + break + + # Если токен получен, обновляем заголовки в scope + if token: + # Создаем новый список заголовков + new_headers = [] + for name, value in scope["headers"]: + # Пропускаем оригинальный заголовок авторизации + if name.decode("latin1").lower() != SESSION_TOKEN_HEADER.lower(): + new_headers.append((name, value)) + + # Добавляем заголовок с чистым токеном + new_headers.append((SESSION_TOKEN_HEADER.encode("latin1"), token.encode("latin1"))) + + # Обновляем заголовки в scope + scope["headers"] = new_headers + + # Также добавляем информацию о типе аутентификации для дальнейшего использования + if "auth" not in scope: + scope["auth"] = {"type": "bearer", "token": token} + + await self.app(scope, receive, send) + + +class GraphQLExtensionsMiddleware: + """ + Утилиты для расширения контекста GraphQL запросов + """ + + def set_cookie(self, key, value, **options): + """Устанавливает cookie в ответе""" + context = getattr(self, "_context", None) + if context and "response" in context and hasattr(context["response"], "set_cookie"): + context["response"].set_cookie(key, value, **options) + + def delete_cookie(self, key, **options): + """Удаляет cookie из ответа""" + context = getattr(self, "_context", None) + if context and "response" in context and hasattr(context["response"], "delete_cookie"): + context["response"].delete_cookie(key, **options) + + async def resolve(self, next, root, info, *args, **kwargs): + """ + Middleware для обработки запросов GraphQL. + Добавляет методы для установки cookie в контекст. + """ + try: + # Получаем доступ к контексту запроса + context = info.context + + # Сохраняем ссылку на контекст + self._context = context + + # Добавляем себя как объект, содержащий утилитные методы + context["extensions"] = self + + return await next(root, info, *args, **kwargs) + except Exception as e: + logger.error(f"[GraphQLExtensionsMiddleware] Ошибка: {str(e)}") + raise diff --git a/auth/oauth.py b/auth/oauth.py index 25cc280a..f91e9f96 100644 --- a/auth/oauth.py +++ b/auth/oauth.py @@ -1,98 +1,189 @@ from authlib.integrations.starlette_client import OAuth -from starlette.responses import RedirectResponse +from authlib.oauth2.rfc7636 import create_s256_code_challenge +from starlette.responses import RedirectResponse, JSONResponse +from secrets import token_urlsafe +import time -from auth.identity import Identity from auth.tokenstorage import TokenStorage +from auth.orm import Author +from services.db import local_session from settings import FRONTEND_URL, OAUTH_CLIENTS oauth = OAuth() -oauth.register( - name="facebook", - client_id=OAUTH_CLIENTS["FACEBOOK"]["id"], - client_secret=OAUTH_CLIENTS["FACEBOOK"]["key"], - access_token_url="https://graph.facebook.com/v11.0/oauth/access_token", - access_token_params=None, - authorize_url="https://www.facebook.com/v11.0/dialog/oauth", - authorize_params=None, - api_base_url="https://graph.facebook.com/", - client_kwargs={"scope": "public_profile email"}, -) - -oauth.register( - name="github", - client_id=OAUTH_CLIENTS["GITHUB"]["id"], - client_secret=OAUTH_CLIENTS["GITHUB"]["key"], - access_token_url="https://github.com/login/oauth/access_token", - access_token_params=None, - authorize_url="https://github.com/login/oauth/authorize", - authorize_params=None, - api_base_url="https://api.github.com/", - client_kwargs={"scope": "user:email"}, -) - -oauth.register( - name="google", - client_id=OAUTH_CLIENTS["GOOGLE"]["id"], - client_secret=OAUTH_CLIENTS["GOOGLE"]["key"], - server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", - client_kwargs={"scope": "openid email profile"}, - authorize_state="test", -) - - -async def google_profile(client, request, token): - userinfo = token["userinfo"] - - profile = {"name": userinfo["name"], "email": userinfo["email"], "id": userinfo["sub"]} - - if userinfo["picture"]: - userpic = userinfo["picture"].replace("=s96", "=s600") - profile["userpic"] = userpic - - return profile - - -async def facebook_profile(client, request, token): - profile = await client.get("me?fields=name,id,email", token=token) - return profile.json() - - -async def github_profile(client, request, token): - profile = await client.get("user", token=token) - return profile.json() - - -profile_callbacks = { - "google": google_profile, - "facebook": facebook_profile, - "github": github_profile, +# Конфигурация провайдеров +PROVIDERS = { + "google": { + "name": "google", + "server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration", + "client_kwargs": {"scope": "openid email profile", "prompt": "select_account"}, + }, + "github": { + "name": "github", + "access_token_url": "https://github.com/login/oauth/access_token", + "authorize_url": "https://github.com/login/oauth/authorize", + "api_base_url": "https://api.github.com/", + "client_kwargs": {"scope": "user:email"}, + }, + "facebook": { + "name": "facebook", + "access_token_url": "https://graph.facebook.com/v13.0/oauth/access_token", + "authorize_url": "https://www.facebook.com/v13.0/dialog/oauth", + "api_base_url": "https://graph.facebook.com/", + "client_kwargs": {"scope": "public_profile email"}, + }, } +# Регистрация провайдеров +for provider, config in PROVIDERS.items(): + if provider in OAUTH_CLIENTS: + oauth.register( + name=config["name"], + client_id=OAUTH_CLIENTS[provider.upper()]["id"], + client_secret=OAUTH_CLIENTS[provider.upper()]["key"], + **config, + ) + + +async def get_user_profile(provider: str, client, token) -> dict: + """Получает профиль пользователя от провайдера OAuth""" + if provider == "google": + userinfo = token.get("userinfo", {}) + return { + "id": userinfo.get("sub"), + "email": userinfo.get("email"), + "name": userinfo.get("name"), + "picture": userinfo.get("picture", "").replace("=s96", "=s600"), + } + elif provider == "github": + profile = await client.get("user", token=token) + profile_data = profile.json() + emails = await client.get("user/emails", token=token) + emails_data = emails.json() + primary_email = next((email["email"] for email in emails_data if email["primary"]), None) + return { + "id": str(profile_data["id"]), + "email": primary_email or profile_data.get("email"), + "name": profile_data.get("name") or profile_data.get("login"), + "picture": profile_data.get("avatar_url"), + } + elif provider == "facebook": + profile = await client.get("me?fields=id,name,email,picture.width(600)", token=token) + profile_data = profile.json() + return { + "id": profile_data["id"], + "email": profile_data.get("email"), + "name": profile_data.get("name"), + "picture": profile_data.get("picture", {}).get("data", {}).get("url"), + } + return {} + async def oauth_login(request): + """Начинает процесс OAuth авторизации""" provider = request.path_params["provider"] + if provider not in PROVIDERS: + return JSONResponse({"error": "Invalid provider"}, status_code=400) + + client = oauth.create_client(provider) + if not client: + return JSONResponse({"error": "Provider not configured"}, status_code=400) + + # Генерируем PKCE challenge + code_verifier = token_urlsafe(32) + code_challenge = create_s256_code_challenge(code_verifier) + + # Сохраняем code_verifier в сессии + request.session["code_verifier"] = code_verifier request.session["provider"] = provider - client = oauth.create_client(provider) - redirect_uri = "https://v2.discours.io/oauth-authorize" - return await client.authorize_redirect(request, redirect_uri) + request.session["state"] = token_urlsafe(16) + + redirect_uri = f"{FRONTEND_URL}/oauth/callback" + + try: + return await client.authorize_redirect( + request, + redirect_uri, + code_challenge=code_challenge, + code_challenge_method="S256", + state=request.session["state"], + ) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) -async def oauth_authorize(request): - provider = request.session["provider"] - client = oauth.create_client(provider) - token = await client.authorize_access_token(request) - get_profile = profile_callbacks[provider] - profile = await get_profile(client, request, token) - user_oauth_info = "%s:%s" % (provider, profile["id"]) - user_input = { - "oauth": user_oauth_info, - "email": profile["email"], - "username": profile["name"], - "userpic": profile["userpic"], - } - user = Identity.oauth(user_input) - session_token = await TokenStorage.create_session(user) - response = RedirectResponse(url=FRONTEND_URL + "/confirm") - response.set_cookie("token", session_token) - return response +async def oauth_callback(request): + """Обрабатывает callback от OAuth провайдера""" + try: + provider = request.session.get("provider") + if not provider: + return JSONResponse({"error": "No active OAuth session"}, status_code=400) + + # Проверяем state + state = request.query_params.get("state") + if state != request.session.get("state"): + return JSONResponse({"error": "Invalid state"}, status_code=400) + + client = oauth.create_client(provider) + if not client: + return JSONResponse({"error": "Provider not configured"}, status_code=400) + + # Получаем токен с PKCE verifier + token = await client.authorize_access_token( + request, code_verifier=request.session.get("code_verifier") + ) + + # Получаем профиль пользователя + profile = await get_user_profile(provider, client, token) + if not profile.get("email"): + return JSONResponse({"error": "Email not provided"}, status_code=400) + + # Создаем или обновляем пользователя + with local_session() as session: + author = session.query(Author).filter(Author.email == profile["email"]).first() + + if not author: + author = Author( + email=profile["email"], + name=profile["name"], + username=profile["name"], + pic=profile.get("picture"), + oauth=f"{provider}:{profile['id']}", + email_verified=True, + created_at=int(time.time()), + updated_at=int(time.time()), + last_seen=int(time.time()), + ) + session.add(author) + else: + author.name = profile["name"] + author.pic = profile.get("picture") or author.pic + author.oauth = f"{provider}:{profile['id']}" + author.email_verified = True + author.updated_at = int(time.time()) + author.last_seen = int(time.time()) + + session.commit() + + # Создаем сессию + session_token = await TokenStorage.create_session(author) + + # Очищаем сессию OAuth + request.session.pop("code_verifier", None) + request.session.pop("provider", None) + request.session.pop("state", None) + + # Возвращаем токен через cookie + response = RedirectResponse(url=f"{FRONTEND_URL}/auth/success") + response.set_cookie( + "session_token", + session_token, + httponly=True, + secure=True, + samesite="lax", + max_age=30 * 24 * 60 * 60, # 30 days + ) + return response + + except Exception as e: + return RedirectResponse(url=f"{FRONTEND_URL}/auth/error?message={str(e)}") diff --git a/auth/orm.py b/auth/orm.py new file mode 100644 index 00000000..4c6a00a4 --- /dev/null +++ b/auth/orm.py @@ -0,0 +1,259 @@ +import time +from typing import Dict, Set +from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String +from sqlalchemy.orm import relationship + +from auth.identity import Password +from services.db import Base + +# from sqlalchemy_utils import TSVectorType + +# Общие table_args для всех моделей +DEFAULT_TABLE_ARGS = {"extend_existing": True} + + +""" +Модель закладок автора +""" + + +class AuthorBookmark(Base): + """ + Закладка автора на публикацию. + + Attributes: + author (int): ID автора + shout (int): ID публикации + """ + + __tablename__ = "author_bookmark" + __table_args__ = ( + Index("idx_author_bookmark_author", "author"), + Index("idx_author_bookmark_shout", "shout"), + {"extend_existing": True}, + ) + + id = None # type: ignore + author = Column(ForeignKey("author.id"), primary_key=True) + shout = Column(ForeignKey("shout.id"), primary_key=True) + + +class AuthorRating(Base): + """ + Рейтинг автора от другого автора. + + Attributes: + rater (int): ID оценивающего автора + author (int): ID оцениваемого автора + plus (bool): Положительная/отрицательная оценка + """ + + __tablename__ = "author_rating" + __table_args__ = ( + Index("idx_author_rating_author", "author"), + Index("idx_author_rating_rater", "rater"), + {"extend_existing": True}, + ) + + id = None # type: ignore + rater = Column(ForeignKey("author.id"), primary_key=True) + author = Column(ForeignKey("author.id"), primary_key=True) + plus = Column(Boolean) + + +class AuthorFollower(Base): + """ + Подписка одного автора на другого. + + Attributes: + follower (int): ID подписчика + author (int): ID автора, на которого подписываются + created_at (int): Время создания подписки + auto (bool): Признак автоматической подписки + """ + + __tablename__ = "author_follower" + __table_args__ = ( + Index("idx_author_follower_author", "author"), + Index("idx_author_follower_follower", "follower"), + {"extend_existing": True}, + ) + + id = None # type: ignore + follower = Column(ForeignKey("author.id"), primary_key=True) + author = Column(ForeignKey("author.id"), primary_key=True) + created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) + auto = Column(Boolean, nullable=False, default=False) + + +class RolePermission(Base): + """Связь роли с разрешениями""" + + __tablename__ = "role_permission" + __table_args__ = {"extend_existing": True} + + id = None + role = Column(ForeignKey("role.id"), primary_key=True, index=True) + permission = Column(ForeignKey("permission.id"), primary_key=True, index=True) + + +class Permission(Base): + """Модель разрешения в системе RBAC""" + + __tablename__ = "permission" + __table_args__ = {"extend_existing": True} + + id = Column(String, primary_key=True, unique=True, nullable=False, default=None) + resource = Column(String, nullable=False) + operation = Column(String, nullable=False) + + +class Role(Base): + """Модель роли в системе RBAC""" + + __tablename__ = "role" + __table_args__ = {"extend_existing": True} + + id = Column(String, primary_key=True, unique=True, nullable=False, default=None) + name = Column(String, nullable=False) + permissions = relationship(Permission, secondary="role_permission", lazy="joined") + + +class AuthorRole(Base): + """Связь автора с ролями""" + + __tablename__ = "author_role" + __table_args__ = {"extend_existing": True} + + id = None + community = Column(ForeignKey("community.id"), primary_key=True, index=True) + author = Column(ForeignKey("author.id"), primary_key=True, index=True) + role = Column(ForeignKey("role.id"), primary_key=True, index=True) + + +class Author(Base): + """ + Расширенная модель автора с функциями аутентификации и авторизации + """ + + __tablename__ = "author" + __table_args__ = ( + Index("idx_author_slug", "slug"), + Index("idx_author_email", "email"), + Index("idx_author_phone", "phone"), + {"extend_existing": True}, + ) + + # Базовые поля автора + id = Column(Integer, primary_key=True) + name = Column(String, nullable=True, comment="Display name") + slug = Column(String, unique=True, comment="Author's slug") + bio = Column(String, nullable=True, comment="Bio") # короткое описание + about = Column(String, nullable=True, comment="About") # длинное форматированное описание + pic = Column(String, nullable=True, comment="Picture") + links = Column(JSON, nullable=True, comment="Links") + + # Дополнительные поля из User + oauth = Column(String, nullable=True, comment="OAuth provider") + oid = Column(String, nullable=True, comment="OAuth ID") + muted = Column(Boolean, default=False, comment="Is author muted") + + # Поля аутентификации + email = Column(String, unique=True, nullable=True, comment="Email") + phone = Column(String, unique=True, nullable=True, comment="Phone") + password = Column(String, nullable=True, comment="Password hash") + 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) + + # Временные метки + created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) + updated_at = Column(Integer, nullable=False, default=lambda: int(time.time())) + last_seen = Column(Integer, nullable=False, default=lambda: int(time.time())) + deleted_at = Column(Integer, nullable=True) + + # Связи с ролями + roles = relationship(Role, secondary="author_role", lazy="joined") + + # search_vector = Column( + # TSVectorType("name", "slug", "bio", "about", regconfig="pg_catalog.russian") + # ) + + @property + def is_authenticated(self) -> bool: + """Проверяет, аутентифицирован ли пользователь""" + return self.id is not None + + def get_permissions(self) -> Dict[str, Set[str]]: + """Получает все разрешения пользователя""" + permissions: Dict[str, Set[str]] = {} + for role in self.roles: + for permission in role.permissions: + if permission.resource not in permissions: + permissions[permission.resource] = set() + permissions[permission.resource].add(permission.operation) + return permissions + + def has_permission(self, resource: str, operation: str) -> bool: + """Проверяет наличие разрешения у пользователя""" + permissions = self.get_permissions() + return resource in permissions and operation in permissions[resource] + + def verify_password(self, password: str) -> bool: + """Проверяет пароль пользователя""" + return Password.verify(password, self.password) if self.password else False + + def set_password(self, password: str): + """Устанавливает пароль пользователя""" + self.password = Password.encode(password) + + def increment_failed_login(self): + """Увеличивает счетчик неудачных попыток входа""" + self.failed_login_attempts += 1 + if self.failed_login_attempts >= 5: + self.account_locked_until = int(time.time()) + 300 # 5 минут + + def reset_failed_login(self): + """Сбрасывает счетчик неудачных попыток входа""" + self.failed_login_attempts = 0 + self.account_locked_until = None + + def is_locked(self) -> bool: + """Проверяет, заблокирован ли аккаунт""" + if not self.account_locked_until: + return False + return self.account_locked_until > int(time.time()) + + @property + def username(self) -> str: + """ + Возвращает имя пользователя для использования в токенах. + Необходимо для совместимости с TokenStorage и JWTCodec. + + Returns: + str: slug, email или phone пользователя + """ + 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, + } diff --git a/auth/permissions.py b/auth/permissions.py new file mode 100644 index 00000000..5ce227ad --- /dev/null +++ b/auth/permissions.py @@ -0,0 +1,242 @@ +""" +Модуль для проверки разрешений пользователей в контексте сообществ. + +Позволяет проверять доступ пользователя к определенным операциям в сообществе +на основе его роли в этом сообществе. +""" + +from typing import List, Union + +from sqlalchemy.orm import Session + +from auth.orm import Author, Role, RolePermission, Permission +from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST +from orm.community import Community, CommunityFollower, CommunityRole + +ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",") + + +class ContextualPermissionCheck: + """ + Класс для проверки контекстно-зависимых разрешений. + + Позволяет проверять разрешения пользователя в контексте сообщества, + учитывая как глобальные роли пользователя, так и его роли внутри сообщества. + """ + + # Маппинг из ролей сообщества в системные роли RBAC + COMMUNITY_ROLE_MAP = { + CommunityRole.READER: "community_reader", + CommunityRole.AUTHOR: "community_author", + CommunityRole.EXPERT: "community_expert", + CommunityRole.EDITOR: "community_editor", + } + + # Обратное отображение для отображения системных ролей в роли сообщества + RBAC_TO_COMMUNITY_ROLE = {v: k for k, v in COMMUNITY_ROLE_MAP.items()} + + @staticmethod + def check_community_permission( + session: Session, author_id: int, community_slug: str, resource: str, operation: str + ) -> bool: + """ + Проверяет наличие разрешения у пользователя в контексте сообщества. + + Args: + session: Сессия SQLAlchemy + author_id: ID автора/пользователя + community_slug: Slug сообщества + resource: Ресурс для доступа + operation: Операция над ресурсом + + Returns: + bool: True, если пользователь имеет разрешение, иначе False + """ + # 1. Проверка глобальных разрешений (например, администратор) + author = session.query(Author).filter(Author.id == author_id).one_or_none() + if not author: + return False + + # Если это администратор (по списку email) или у него есть глобальное разрешение + if author.has_permission(resource, operation) or author.email in ADMIN_EMAILS: + return True + + # 2. Проверка разрешений в контексте сообщества + # Получаем информацию о сообществе + community = session.query(Community).filter(Community.slug == community_slug).one_or_none() + if not community: + return False + + # Если автор является создателем сообщества, то у него есть полные права + if community.created_by == author_id: + return True + + # Получаем роли пользователя в этом сообществе + community_follower = ( + session.query(CommunityFollower) + .filter(CommunityFollower.author == author_id, CommunityFollower.community == community.id) + .one_or_none() + ) + + if not community_follower or not community_follower.roles: + # Пользователь не является членом сообщества или у него нет ролей + return False + + # Преобразуем роли сообщества в RBAC роли + rbac_roles = [] + community_roles = community_follower.get_roles() + + for role in community_roles: + if role in ContextualPermissionCheck.COMMUNITY_ROLE_MAP: + rbac_role_id = ContextualPermissionCheck.COMMUNITY_ROLE_MAP[role] + rbac_roles.append(rbac_role_id) + + if not rbac_roles: + return False + + # Проверяем наличие разрешения для этих ролей + permission_id = f"{resource}:{operation}" + + # Запрос на проверку разрешений для указанных ролей + has_permission = ( + session.query(RolePermission) + .join(Role, Role.id == RolePermission.role) + .join(Permission, Permission.id == RolePermission.permission) + .filter(Role.id.in_(rbac_roles), Permission.id == permission_id) + .first() + is not None + ) + + return has_permission + + @staticmethod + def get_user_community_roles( + session: Session, author_id: int, community_slug: str + ) -> List[CommunityRole]: + """ + Получает список ролей пользователя в сообществе. + + Args: + session: Сессия SQLAlchemy + author_id: ID автора/пользователя + community_slug: Slug сообщества + + Returns: + List[CommunityRole]: Список ролей пользователя в сообществе + """ + # Получаем информацию о сообществе + community = session.query(Community).filter(Community.slug == community_slug).one_or_none() + if not community: + return [] + + # Если автор является создателем сообщества, то у него есть роль владельца + if community.created_by == author_id: + return [CommunityRole.EDITOR] # Владелец имеет роль редактора по умолчанию + + # Получаем роли пользователя в этом сообществе + community_follower = ( + session.query(CommunityFollower) + .filter(CommunityFollower.author == author_id, CommunityFollower.community == community.id) + .one_or_none() + ) + + if not community_follower or not community_follower.roles: + return [] + + return community_follower.get_roles() + + @staticmethod + def assign_role_to_user( + session: Session, author_id: int, community_slug: str, role: Union[CommunityRole, str] + ) -> bool: + """ + Назначает роль пользователю в сообществе. + + Args: + session: Сессия SQLAlchemy + author_id: ID автора/пользователя + community_slug: Slug сообщества + role: Роль для назначения (CommunityRole или строковое представление) + + Returns: + bool: True если роль успешно назначена, иначе False + """ + # Преобразуем строковую роль в CommunityRole если нужно + if isinstance(role, str): + try: + role = CommunityRole(role) + except ValueError: + return False + + # Получаем информацию о сообществе + community = session.query(Community).filter(Community.slug == community_slug).one_or_none() + if not community: + return False + + # Проверяем существование связи автор-сообщество + community_follower = ( + session.query(CommunityFollower) + .filter(CommunityFollower.author == author_id, CommunityFollower.community == community.id) + .one_or_none() + ) + + if not community_follower: + # Создаем новую запись CommunityFollower + community_follower = CommunityFollower(author=author_id, community=community.id) + session.add(community_follower) + + # Назначаем роль + current_roles = community_follower.get_roles() if community_follower.roles else [] + if role not in current_roles: + current_roles.append(role) + community_follower.set_roles(current_roles) + session.commit() + + return True + + @staticmethod + def revoke_role_from_user( + session: Session, author_id: int, community_slug: str, role: Union[CommunityRole, str] + ) -> bool: + """ + Отзывает роль у пользователя в сообществе. + + Args: + session: Сессия SQLAlchemy + author_id: ID автора/пользователя + community_slug: Slug сообщества + role: Роль для отзыва (CommunityRole или строковое представление) + + Returns: + bool: True если роль успешно отозвана, иначе False + """ + # Преобразуем строковую роль в CommunityRole если нужно + if isinstance(role, str): + try: + role = CommunityRole(role) + except ValueError: + return False + + # Получаем информацию о сообществе + community = session.query(Community).filter(Community.slug == community_slug).one_or_none() + if not community: + return False + + # Проверяем существование связи автор-сообщество + community_follower = ( + session.query(CommunityFollower) + .filter(CommunityFollower.author == author_id, CommunityFollower.community == community.id) + .one_or_none() + ) + + if not community_follower or not community_follower.roles: + return False + + # Отзываем роль + current_roles = community_follower.get_roles() + if role in current_roles: + current_roles.remove(role) + community_follower.set_roles(current_roles) + session.commit() + + return True diff --git a/auth/resolvers.py b/auth/resolvers.py index 1ea1a149..8814a8ca 100644 --- a/auth/resolvers.py +++ b/auth/resolvers.py @@ -1,22 +1,34 @@ # -*- coding: utf-8 -*- - -import re -from datetime import datetime, timezone -from urllib.parse import quote_plus +import time +import traceback +from utils.logger import root_logger as logger from graphql.type import GraphQLResolveInfo +# import asyncio # Убираем, так как резолвер будет синхронным from auth.authenticate import login_required from auth.credentials import AuthCredentials +from auth.decorators import admin_auth_required from auth.email import send_auth_email -from auth.exceptions import InvalidPassword, InvalidToken, ObjectNotExist, Unauthorized +from auth.exceptions import InvalidToken, ObjectNotExist from auth.identity import Identity, Password from auth.jwtcodec import JWTCodec from auth.tokenstorage import TokenStorage -from orm import Role, User +from auth.orm import Author, Role from services.db import local_session from services.schema import mutation, query -from settings import SESSION_TOKEN_HEADER +from settings import ( + SESSION_TOKEN_HEADER, + SESSION_COOKIE_NAME, + SESSION_COOKIE_SECURE, + SESSION_COOKIE_SAMESITE, + SESSION_COOKIE_MAX_AGE, + SESSION_COOKIE_HTTPONLY, +) +from utils.generate_slug import generate_unique_slug +from graphql.error import GraphQLError +from math import ceil +from sqlalchemy import or_ @mutation.field("getSession") @@ -26,129 +38,138 @@ async def get_current_user(_, info): token = info.context["request"].headers.get(SESSION_TOKEN_HEADER) with local_session() as session: - user = session.query(User).where(User.id == auth.user_id).one() - user.lastSeen = datetime.now(tz=timezone.utc) + author = session.query(Author).where(Author.id == auth.author_id).one() + author.last_seen = int(time.time()) session.commit() - return {"token": token, "user": user} + return {"token": token, "author": author} @mutation.field("confirmEmail") async def confirm_email(_, info, token): """confirm owning email address""" try: - print("[resolvers.auth] confirm email by token") + logger.info("[auth] confirmEmail: Начало подтверждения email по токену.") payload = JWTCodec.decode(token) user_id = payload.user_id + # Если TokenStorage.get асинхронный, это нужно будет переделать или вызывать синхронно + # Для теста пока оставим, но это потенциальная точка отказа в синхронном резолвере await TokenStorage.get(f"{user_id}-{payload.username}-{token}") with local_session() as session: - user = session.query(User).where(User.id == user_id).first() + user = session.query(Author).where(Author.id == user_id).first() + if not user: + logger.warning(f"[auth] confirmEmail: Пользователь с ID {user_id} не найден.") + return {"success": False, "error": "Пользователь не найден"} + # Если TokenStorage.create_session асинхронный... session_token = await TokenStorage.create_session(user) - user.emailConfirmed = True - user.lastSeen = datetime.now(tz=timezone.utc) + user.email_verified = True + user.last_seen = int(time.time()) session.add(user) session.commit() - return {"token": session_token, "user": user} + logger.info(f"[auth] confirmEmail: Email для пользователя {user_id} успешно подтвержден.") + return {"success": True, "token": session_token, "author": user, "error": None} except InvalidToken as e: - raise InvalidToken(e.message) + logger.warning(f"[auth] confirmEmail: Невалидный токен - {e.message}") + return {"success": False, "token": None, "author": None, "error": f"Невалидный токен: {e.message}"} except Exception as e: - print(e) # FIXME: debug only - return {"error": "email is not confirmed"} + logger.error(f"[auth] confirmEmail: Общая ошибка - {str(e)}\n{traceback.format_exc()}") + return { + "success": False, + "token": None, + "author": None, + "error": f"Ошибка подтверждения email: {str(e)}", + } def create_user(user_dict): - user = User(**user_dict) + user = Author(**user_dict) with local_session() as session: - user.roles.append(session.query(Role).first()) + # Добавляем пользователя в БД session.add(user) + session.flush() # Получаем ID пользователя + + # Получаем или создаём стандартную роль "reader" + reader_role = session.query(Role).filter(Role.id == "reader").first() + if not reader_role: + reader_role = Role(id="reader", name="Читатель") + session.add(reader_role) + session.flush() + + # Получаем основное сообщество + from orm.community import Community + + main_community = session.query(Community).filter(Community.id == 1).first() + if not main_community: + main_community = Community( + id=1, + name="Discours", + slug="discours", + desc="Cообщество Discours", + created_by=user.id, + ) + session.add(main_community) + session.flush() + + # Создаём связь автор-роль-сообщество + from auth.orm import AuthorRole + + author_role = AuthorRole(author=user.id, role=reader_role.id, community=main_community.id) + session.add(author_role) session.commit() return user -def replace_translit(src): - ruchars = "абвгдеёжзийклмнопрстуфхцчшщъыьэюя." - enchars = [ - "a", - "b", - "v", - "g", - "d", - "e", - "yo", - "zh", - "z", - "i", - "y", - "k", - "l", - "m", - "n", - "o", - "p", - "r", - "s", - "t", - "u", - "f", - "h", - "c", - "ch", - "sh", - "sch", - "", - "y", - "'", - "e", - "yu", - "ya", - "-", - ] - return src.translate(str.maketrans(ruchars, enchars)) - - -def generate_unique_slug(src): - print("[resolvers.auth] generating slug from: " + src) - slug = replace_translit(src.lower()) - slug = re.sub("[^0-9a-zA-Z]+", "-", slug) - if slug != src: - print("[resolvers.auth] translited name: " + slug) - c = 1 - with local_session() as session: - user = session.query(User).where(User.slug == slug).first() - while user: - user = session.query(User).where(User.slug == slug).first() - slug = slug + "-" + str(c) - c += 1 - if not user: - unique_slug = slug - print("[resolvers.auth] " + unique_slug) - return quote_plus(unique_slug.replace("'", "")).replace("+", "-") - - @mutation.field("registerUser") async def register_by_email(_, _info, email: str, password: str = "", name: str = ""): email = email.lower() """creates new user account""" + logger.info(f"[auth] registerUser: Попытка регистрации для {email}") with local_session() as session: - user = session.query(User).filter(User.email == email).first() + user = session.query(Author).filter(Author.email == email).first() if user: - raise Unauthorized("User already exist") - else: - slug = generate_unique_slug(name) - user = session.query(User).where(User.slug == slug).first() - if user: - slug = generate_unique_slug(email.split("@")[0]) - user_dict = { - "email": email, - "username": email, # will be used to store phone number or some messenger network id - "name": name, - "slug": slug, + logger.warning(f"[auth] registerUser: Пользователь {email} уже существует.") + # raise Unauthorized("User already exist") # Это вызовет ошибку GraphQL, но не "cannot return null" + return {"success": False, "token": None, "author": None, "error": "Пользователь уже существует"} + + slug = generate_unique_slug(name if name else email.split("@")[0]) + + user_dict = { + "email": email, + "username": email, + "name": name if name else email.split("@")[0], + "slug": slug, + } + if password: + user_dict["password"] = Password.encode(password) + + new_user = create_user(user_dict) + # Предполагается, что auth_send_link вернет объект Author или вызовет исключение + # Для AuthResult нам также нужен токен и статус. + # После регистрации обычно либо сразу логинят, либо просто сообщают об успехе. + # Сейчас auth_send_link используется, что не логично для AuthResult. + # Вернем успешную регистрацию без токена, предполагая, что пользователь должен будет залогиниться или подтвердить email. + + # Попытка отправить ссылку для подтверждения email + try: + # Если auth_send_link асинхронный... + await auth_send_link(_, _info, email) + logger.info( + f"[auth] registerUser: Пользователь {email} зарегистрирован, ссылка для подтверждения отправлена." + ) + return { + "success": True, + "token": None, + "author": new_user, + "error": "Требуется подтверждение email.", + } + except Exception as e: + logger.error(f"[auth] registerUser: Ошибка при отправке ссылки подтверждения для {email}: {str(e)}") + return { + "success": True, + "token": None, + "author": new_user, + "error": f"Пользователь зарегистрирован, но произошла ошибка при отправке ссылки подтверждения: {str(e)}", } - if password: - user_dict["password"] = Password.encode(password) - user = create_user(user_dict) - user = await auth_send_link(_, _info, email) - return {"user": user} @mutation.field("sendLink") @@ -156,53 +177,168 @@ async def auth_send_link(_, _info, email, lang="ru", template="email_confirmatio email = email.lower() """send link with confirm code to email""" with local_session() as session: - user = session.query(User).filter(User.email == email).first() + user = session.query(Author).filter(Author.email == email).first() if not user: raise ObjectNotExist("User not found") else: + # Если TokenStorage.create_onetime асинхронный... token = await TokenStorage.create_onetime(user) + # Если send_auth_email асинхронный... await send_auth_email(user, token, lang, template) return user -@query.field("signIn") -async def login(_, info, email: str, password: str = "", lang: str = "ru"): - email = email.lower() - with local_session() as session: - orm_user = session.query(User).filter(User.email == email).first() - if orm_user is None: - print(f"[auth] {email}: email not found") - # return {"error": "email not found"} - raise ObjectNotExist("User not found") # contains webserver status +@mutation.field("login") +async def login_mutation(_, info, email: str, password: str): + """ + Авторизация пользователя с помощью email и пароля. - if not password: - print(f"[auth] send confirm link to {email}") - token = await TokenStorage.create_onetime(orm_user) - await send_auth_email(orm_user, token, lang) - # FIXME: not an error, warning - return {"error": "no password, email link was sent"} + Args: + info: Контекст GraphQL запроса + email: Email пользователя + password: Пароль пользователя - else: - # sign in using password - if not orm_user.emailConfirmed: - # not an error, warns users - return {"error": "please, confirm email"} - else: + Returns: + AuthResult с данными пользователя и токеном или сообщением об ошибке + """ + logger.info(f"[auth] login: Попытка входа для {email}") + + # Гарантируем, что всегда возвращаем непустой объект AuthResult + default_response = {"success": False, "token": None, "author": None, "error": "Неизвестная ошибка"} + + try: + # Нормализуем email + email = email.lower() + + # Получаем пользователя из базы + with local_session() as session: + author = session.query(Author).filter(Author.email == email).first() + + if not author: + logger.warning(f"[auth] login: Пользователь {email} не найден") + return { + "success": False, + "token": None, + "author": None, + "error": "Пользователь с таким email не найден", + } + + # Логируем информацию о найденном авторе + logger.info( + 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 'успешно'}" + ) + + 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", "Ошибка авторизации"), + } + + # Получаем правильный объект автора - результат verify_result + valid_author = verify_result if not isinstance(verify_result, dict) else author + + # Создаем токен через правильную функцию вместо прямого кодирования + try: + # Убедимся, что у автора есть нужные поля для создания токена + if ( + not hasattr(valid_author, "id") + or not hasattr(valid_author, "username") + and not hasattr(valid_author, "email") + ): + logger.error( + f"[auth] login: Объект автора не содержит необходимых атрибутов: {valid_author}" + ) + return { + "success": False, + "token": None, + "author": None, + "error": "Внутренняя ошибка: некорректный объект автора", + } + + # Создаем сессионный токен + logger.info(f"[auth] login: СОЗДАНИЕ ТОКЕНА для {email}, id={valid_author.id}") + token = await TokenStorage.create_session(valid_author) + logger.info(f"[auth] login: токен успешно создан, длина: {len(token) if token else 0}") + + # Обновляем время последнего входа + valid_author.last_seen = int(time.time()) + session.commit() + + # Устанавливаем httponly cookie с помощью GraphQLExtensionsMiddleware try: - user = Identity.password(orm_user, password) - session_token = await TokenStorage.create_session(user) - print(f"[auth] user {email} authorized") - return {"token": session_token, "user": user} - except InvalidPassword: - print(f"[auth] {email}: invalid password") - raise InvalidPassword("invalid password") # contains webserver status - # return {"error": "invalid password"} + # Используем extensions для установки cookie + if hasattr(info.context, "extensions") and hasattr( + info.context.extensions, "set_cookie" + ): + logger.info("[auth] login: Устанавливаем httponly cookie через extensions") + info.context.extensions.set_cookie( + SESSION_COOKIE_NAME, + token, + httponly=SESSION_COOKIE_HTTPONLY, + secure=SESSION_COOKIE_SECURE, + samesite=SESSION_COOKIE_SAMESITE, + max_age=SESSION_COOKIE_MAX_AGE, + ) + elif hasattr(info.context, "response") and hasattr(info.context.response, "set_cookie"): + logger.info("[auth] login: Устанавливаем httponly cookie через response") + info.context.response.set_cookie( + key=SESSION_COOKIE_NAME, + value=token, + httponly=SESSION_COOKIE_HTTPONLY, + secure=SESSION_COOKIE_SECURE, + samesite=SESSION_COOKIE_SAMESITE, + max_age=SESSION_COOKIE_MAX_AGE, + ) + else: + logger.warning( + "[auth] login: Невозможно установить cookie - объекты extensions/response недоступны" + ) + except Exception as e: + # В случае ошибки при установке cookie просто логируем, но продолжаем авторизацию + logger.error(f"[auth] login: Ошибка при установке cookie: {str(e)}") + logger.debug(traceback.format_exc()) + + # Возвращаем успешный результат + logger.info(f"[auth] login: Успешный вход для {email}") + result = {"success": True, "token": token, "author": valid_author, "error": None} + logger.info( + f"[auth] login: Возвращаемый результат: {{success: {result['success']}, token_length: {len(token) if token else 0}}}" + ) + return result + except Exception as token_error: + logger.error(f"[auth] login: Ошибка при создании токена: {str(token_error)}") + logger.error(traceback.format_exc()) + return { + "success": False, + "token": None, + "author": None, + "error": f"Ошибка авторизации: {str(token_error)}", + } + + except Exception as e: + logger.error(f"[auth] login: Ошибка при авторизации {email}: {str(e)}") + logger.error(traceback.format_exc()) + return {"success": False, "token": None, "author": None, "error": str(e)} + + # Если по какой-то причине мы дошли до этой точки, вернем безопасный результат + return default_response @query.field("signOut") @login_required async def sign_out(_, info: GraphQLResolveInfo): token = info.context["request"].headers.get(SESSION_TOKEN_HEADER, "") + # Если TokenStorage.revoke асинхронный... status = await TokenStorage.revoke(token) return status @@ -211,5 +347,117 @@ async def sign_out(_, info: GraphQLResolveInfo): async def is_email_used(_, _info, email): email = email.lower() with local_session() as session: - user = session.query(User).filter(User.email == email).first() + user = session.query(Author).filter(Author.email == email).first() return user is not None + + +@query.field("adminGetUsers") +@admin_auth_required +async def admin_get_users(_, info, limit=10, offset=0, search=None): + """ + Получает список пользователей для админ-панели с поддержкой пагинации и поиска + + Args: + info: Контекст GraphQL запроса + limit: Максимальное количество записей для получения + offset: Смещение в списке результатов + search: Строка поиска (по email, имени или ID) + + Returns: + Пагинированный список пользователей + """ + try: + # Нормализуем параметры + limit = max(1, min(100, limit or 10)) # Ограничиваем количество записей от 1 до 100 + offset = max(0, offset or 0) # Смещение не может быть отрицательным + + with local_session() as session: + # Базовый запрос + query = session.query(Author) + + # Применяем фильтр поиска, если указан + if search and search.strip(): + search_term = f"%{search.strip().lower()}%" + query = query.filter( + or_( + Author.email.ilike(search_term), + Author.name.ilike(search_term), + Author.id.cast(str).ilike(search_term), + ) + ) + + # Получаем общее количество записей + total_count = query.count() + + # Вычисляем информацию о пагинации + per_page = limit + total_pages = ceil(total_count / per_page) + current_page = (offset // per_page) + 1 if per_page > 0 else 1 + + # Применяем пагинацию + users = query.order_by(Author.id).offset(offset).limit(limit).all() + + # Преобразуем в формат для API + result = { + "users": [ + { + "id": user.id, + "email": user.email, + "name": user.name, + "slug": user.slug, + "roles": [role.role for role in user.roles] + 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, + } + for user in users + ], + "total": total_count, + "page": current_page, + "perPage": per_page, + "totalPages": total_pages, + } + + return result + except Exception as e: + logger.error(f"Ошибка при получении списка пользователей: {str(e)}") + logger.error(traceback.format_exc()) + raise GraphQLError(f"Не удалось получить список пользователей: {str(e)}") + + +@query.field("adminGetRoles") +@admin_auth_required +async def admin_get_roles(_, info): + """ + Получает список всех ролей для админ-панели + + Args: + info: Контекст GraphQL запроса + + Returns: + Список ролей с их описаниями + """ + try: + with local_session() as session: + # Получаем все роли из базы данных + roles = session.query(Role).all() + + # Преобразуем их в формат для API + result = [ + { + "id": role.id, + "name": role.name, + "description": f"Роль с правами: {', '.join(p.resource + ':' + p.operation for p in role.permissions)}" + if role.permissions + else "Роль без особых прав", + } + for role in roles + ] + + return result + except Exception as e: + logger.error(f"Ошибка при получении списка ролей: {str(e)}") + raise GraphQLError(f"Не удалось получить список ролей: {str(e)}") diff --git a/auth/sessions.py b/auth/sessions.py new file mode 100644 index 00000000..de1c87c7 --- /dev/null +++ b/auth/sessions.py @@ -0,0 +1,228 @@ +from datetime import datetime, timedelta, timezone +from typing import Optional, Dict, Any + +from pydantic import BaseModel +from services.redis import redis +from auth.jwtcodec import JWTCodec, TokenPayload +from settings import SESSION_TOKEN_LIFE_SPAN +from utils.logger import root_logger as logger + + +class SessionData(BaseModel): + """Модель данных сессии""" + + user_id: str + username: str + created_at: datetime + expires_at: datetime + device_info: Optional[dict] = None + + +class SessionManager: + """ + Менеджер сессий в Redis. + Управляет созданием, проверкой и отзывом сессий пользователей. + """ + + @staticmethod + def _make_session_key(user_id: str, token: str) -> str: + """Формирует ключ сессии в Redis""" + return f"session:{user_id}:{token}" + + @staticmethod + def _make_user_sessions_key(user_id: str) -> str: + """Формирует ключ для списка сессий пользователя в Redis""" + return f"user_sessions:{user_id}" + + @classmethod + async def create_session(cls, user_id: str, username: str, device_info: dict = None) -> str: + """ + Создает новую сессию для пользователя. + + Args: + user_id: ID пользователя + username: Имя пользователя/логин + device_info: Информация об устройстве (опционально) + + Returns: + str: Токен сессии + """ + try: + # Создаем JWT токен + exp = datetime.now(tz=timezone.utc) + timedelta(seconds=SESSION_TOKEN_LIFE_SPAN) + session_token = JWTCodec.encode({"id": user_id, "email": username}, exp) + + # Создаем данные сессии + session_data = SessionData( + user_id=user_id, + username=username, + created_at=datetime.now(tz=timezone.utc), + expires_at=exp, + device_info=device_info, + ) + + # Ключи в Redis + session_key = cls._make_session_key(user_id, session_token) + user_sessions_key = cls._make_user_sessions_key(user_id) + + # Сохраняем в Redis + pipe = redis.pipeline() + await pipe.hset(session_key, mapping=session_data.dict()) + await pipe.expire(session_key, SESSION_TOKEN_LIFE_SPAN) + await pipe.sadd(user_sessions_key, session_token) + await pipe.expire(user_sessions_key, SESSION_TOKEN_LIFE_SPAN) + await pipe.execute() + + return session_token + except Exception as e: + logger.error(f"[SessionManager.create_session] Ошибка: {str(e)}") + raise + + @classmethod + async def verify_session(cls, token: str) -> Optional[TokenPayload]: + """ + Проверяет валидность сессии. + + Args: + token: Токен сессии + + Returns: + TokenPayload: Данные токена или None, если токен недействителен + """ + try: + # Декодируем JWT + payload = JWTCodec.decode(token) + + # Формируем ключ сессии + session_key = cls._make_session_key(payload.user_id, token) + + # Проверяем существование сессии в Redis + session_exists = await redis.exists(session_key) + if not session_exists: + logger.debug(f"[SessionManager.verify_session] Сессия не найдена: {payload.user_id}") + return None + + return payload + + except Exception as e: + logger.error(f"[SessionManager.verify_session] Ошибка: {str(e)}") + return None + + @classmethod + async def get_session_data(cls, user_id: str, token: str) -> Optional[Dict[str, Any]]: + """ + Получает данные сессии. + + Args: + user_id: ID пользователя + token: Токен сессии + + Returns: + dict: Данные сессии или None, если сессия не найдена + """ + try: + session_key = cls._make_session_key(user_id, token) + session_data = await redis.hgetall(session_key) + return session_data if session_data else None + except Exception as e: + logger.error(f"[SessionManager.get_session_data] Ошибка: {str(e)}") + return None + + @classmethod + async def revoke_session(cls, user_id: str, token: str) -> bool: + """ + Отзывает конкретную сессию. + + Args: + user_id: ID пользователя + token: Токен сессии + + Returns: + bool: True, если сессия успешно отозвана + """ + try: + session_key = cls._make_session_key(user_id, token) + user_sessions_key = cls._make_user_sessions_key(user_id) + + # Удаляем сессию и запись из списка сессий пользователя + pipe = redis.pipeline() + await pipe.delete(session_key) + await pipe.srem(user_sessions_key, token) + await pipe.execute() + return True + except Exception as e: + logger.error(f"[SessionManager.revoke_session] Ошибка: {str(e)}") + return False + + @classmethod + async def revoke_all_sessions(cls, user_id: str) -> bool: + """ + Отзывает все сессии пользователя. + + Args: + user_id: ID пользователя + + Returns: + bool: True, если все сессии успешно отозваны + """ + try: + user_sessions_key = cls._make_user_sessions_key(user_id) + + # Получаем все токены пользователя + tokens = await redis.smembers(user_sessions_key) + if not tokens: + return True + + # Создаем команды для удаления всех сессий + pipe = redis.pipeline() + + # Формируем список ключей для удаления + for token in tokens: + session_key = cls._make_session_key(user_id, token) + await pipe.delete(session_key) + + # Удаляем список сессий + await pipe.delete(user_sessions_key) + await pipe.execute() + + return True + except Exception as e: + logger.error(f"[SessionManager.revoke_all_sessions] Ошибка: {str(e)}") + return False + + @classmethod + async def refresh_session(cls, user_id: str, old_token: str, device_info: dict = None) -> Optional[str]: + """ + Обновляет сессию пользователя, заменяя старый токен новым. + + Args: + user_id: ID пользователя + old_token: Старый токен сессии + device_info: Информация об устройстве (опционально) + + Returns: + str: Новый токен сессии или None в случае ошибки + """ + try: + # Получаем данные старой сессии + old_session_key = cls._make_session_key(user_id, old_token) + old_session_data = await redis.hgetall(old_session_key) + + if not old_session_data: + logger.warning(f"[SessionManager.refresh_session] Сессия не найдена: {user_id}") + return None + + # Используем старые данные устройства, если новые не предоставлены + if not device_info and "device_info" in old_session_data: + device_info = old_session_data.get("device_info") + + # Создаем новую сессию + new_token = await cls.create_session(user_id, old_session_data.get("username", ""), device_info) + + # Отзываем старую сессию + await cls.revoke_session(user_id, old_token) + + return new_token + except Exception as e: + logger.error(f"[SessionManager.refresh_session] Ошибка: {str(e)}") + return None diff --git a/auth/tokenstorage.py b/auth/tokenstorage.py index 7e9fcaf8..fa522d4f 100644 --- a/auth/tokenstorage.py +++ b/auth/tokenstorage.py @@ -1,73 +1,193 @@ from datetime import datetime, timedelta, timezone +import json +from typing import Dict, Any, Optional from auth.jwtcodec import JWTCodec from auth.validations import AuthInput from services.redis import redis from settings import ONETIME_TOKEN_LIFE_SPAN, SESSION_TOKEN_LIFE_SPAN - - -async def save(token_key, life_span, auto_delete=True): - await redis.execute("SET", token_key, "True") - if auto_delete: - expire_at = (datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)).timestamp() - await redis.execute("EXPIREAT", token_key, int(expire_at)) - - -class SessionToken: - @classmethod - async def verify(cls, token: str): - """ - Rules for a token to be valid. - - token format is legal - - token exists in redis database - - token is not expired - """ - try: - return JWTCodec.decode(token) - except Exception as e: - raise e - - @classmethod - async def get(cls, payload, token): - return await TokenStorage.get(f"{payload.user_id}-{payload.username}-{token}") +from utils.logger import root_logger as logger class TokenStorage: + """ + Хранилище токенов в Redis. + Обеспечивает создание, проверку и отзыв токенов. + """ + @staticmethod - async def get(token_key): - print("[tokenstorage.get] " + token_key) - # 2041-user@domain.zn-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyMDQxLCJ1c2VybmFtZSI6ImFudG9uLnJld2luK3Rlc3QtbG9hZGNoYXRAZ21haWwuY29tIiwiZXhwIjoxNjcxNzgwNjE2LCJpYXQiOjE2NjkxODg2MTYsImlzcyI6ImRpc2NvdXJzIn0.Nml4oV6iMjMmc6xwM7lTKEZJKBXvJFEIZ-Up1C1rITQ - return await redis.execute("GET", token_key) + async def get(token_key: str) -> Optional[str]: + """ + Получает токен из хранилища. + + Args: + token_key: Ключ токена + + Returns: + str или None, если токен не найден + """ + logger.debug(f"[tokenstorage.get] Запрос токена: {token_key}") + return await redis.get(token_key) + + @staticmethod + async def exists(token_key: str) -> bool: + """ + Проверяет наличие токена в хранилище. + + Args: + token_key: Ключ токена + + Returns: + bool: True, если токен существует + """ + return bool(await redis.execute("EXISTS", token_key)) + + @staticmethod + async def save_token(token_key: str, data: Dict[str, Any], life_span: int) -> bool: + """ + Сохраняет токен в хранилище с указанным временем жизни. + + Args: + token_key: Ключ токена + data: Данные токена + life_span: Время жизни токена в секундах + + Returns: + bool: True, если токен успешно сохранен + """ + try: + # Если данные не строка, преобразуем их в JSON + value = json.dumps(data) if isinstance(data, dict) else data + + # Сохраняем токен и устанавливаем время жизни + await redis.set(token_key, value, ex=life_span) + + return True + except Exception as e: + logger.error(f"[tokenstorage.save_token] Ошибка сохранения токена: {str(e)}") + return False @staticmethod async def create_onetime(user: AuthInput) -> str: + """ + Создает одноразовый токен для пользователя. + + Args: + user: Объект пользователя + + Returns: + str: Сгенерированный токен + """ life_span = ONETIME_TOKEN_LIFE_SPAN exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span) one_time_token = JWTCodec.encode(user, exp) - await save(f"{user.id}-{user.username}-{one_time_token}", life_span) + + # Сохраняем токен в Redis + token_key = f"{user.id}-{user.username}-{one_time_token}" + await TokenStorage.save_token(token_key, "TRUE", life_span) + return one_time_token @staticmethod async def create_session(user: AuthInput) -> str: + """ + Создает сессионный токен для пользователя. + + Args: + user: Объект пользователя + + Returns: + str: Сгенерированный токен + """ life_span = SESSION_TOKEN_LIFE_SPAN exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span) session_token = JWTCodec.encode(user, exp) - await save(f"{user.id}-{user.username}-{session_token}", life_span) + + # Сохраняем токен в Redis + token_key = f"{user.id}-{user.username}-{session_token}" + user_sessions_key = f"user_sessions:{user.id}" + + # Создаем данные сессии + session_data = { + "user_id": str(user.id), + "username": user.username, + "created_at": datetime.now(tz=timezone.utc).timestamp(), + "expires_at": exp.timestamp(), + } + + # Сохраняем токен и добавляем его в список сессий пользователя + pipe = redis.pipeline() + await pipe.hmset(token_key, session_data) + await pipe.expire(token_key, life_span) + await pipe.sadd(user_sessions_key, session_token) + await pipe.expire(user_sessions_key, life_span) + await pipe.execute() + return session_token @staticmethod async def revoke(token: str) -> bool: - payload = None + """ + Отзывает токен. + + Args: + token: Токен для отзыва + + Returns: + bool: True, если токен успешно отозван + """ try: - print("[auth.tokenstorage] revoke token") + logger.debug("[tokenstorage.revoke] Отзыв токена") + + # Декодируем токен payload = JWTCodec.decode(token) - except: # noqa - pass - else: - await redis.execute("DEL", f"{payload.user_id}-{payload.username}-{token}") - return True + if not payload: + logger.warning("[tokenstorage.revoke] Невозможно декодировать токен") + return False + + # Формируем ключи + token_key = f"{payload.user_id}-{payload.username}-{token}" + user_sessions_key = f"user_sessions:{payload.user_id}" + + # Удаляем токен и запись из списка сессий пользователя + pipe = redis.pipeline() + await pipe.delete(token_key) + await pipe.srem(user_sessions_key, token) + await pipe.execute() + + return True + except Exception as e: + logger.error(f"[tokenstorage.revoke] Ошибка отзыва токена: {str(e)}") + return False @staticmethod - async def revoke_all(user: AuthInput): - tokens = await redis.execute("KEYS", f"{user.id}-*") - await redis.execute("DEL", *tokens) + async def revoke_all(user: AuthInput) -> bool: + """ + Отзывает все токены пользователя. + + Args: + user: Объект пользователя + + Returns: + bool: True, если все токены успешно отозваны + """ + try: + # Формируем ключи + user_sessions_key = f"user_sessions:{user.id}" + + # Получаем все токены пользователя + tokens = await redis.smembers(user_sessions_key) + if not tokens: + return True + + # Формируем список ключей для удаления + keys_to_delete = [f"{user.id}-{user.username}-{token}" for token in tokens] + keys_to_delete.append(user_sessions_key) + + # Удаляем все токены и список сессий + await redis.delete(*keys_to_delete) + + return True + except Exception as e: + logger.error(f"[tokenstorage.revoke_all] Ошибка отзыва всех токенов: {str(e)}") + return False diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..be37bc30 --- /dev/null +++ b/biome.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "files": { + "include": ["*.tsx", "*.ts", "*.js", "*.json"], + "ignore": ["./dist", "./node_modules", ".husky", "docs", "gen", "*.gen.ts", "*.d.ts"] + }, + "vcs": { + "enabled": true, + "defaultBranch": "dev", + "useIgnoreFile": true, + "clientKind": "git" + }, + "organizeImports": { + "enabled": true, + "ignore": ["./gen"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 108, + "ignore": ["./src/graphql/schema", "./gen"] + }, + "javascript": { + "formatter": { + "enabled": true, + "semicolons": "asNeeded", + "quoteStyle": "single", + "jsxQuoteStyle": "double", + "arrowParentheses": "always", + "trailingCommas": "none" + } + }, + "linter": { + "enabled": true, + "ignore": ["*.scss", "*.md", ".DS_Store", "*.svg", "*.d.ts"], + "rules": { + "all": true, + "complexity": { + "noForEach": "off", + "useOptionalChain": "warn", + "useLiteralKeys": "off", + "noExcessiveCognitiveComplexity": "off", + "useSimplifiedLogicExpression": "off" + }, + "correctness": { + "useHookAtTopLevel": "off", + "useImportExtensions": "off", + "noUndeclaredDependencies": "off", + "noNodejsModules": { + "level": "off" + } + }, + "a11y": { + "useHeadingContent": "off", + "useKeyWithClickEvents": "off", + "useKeyWithMouseEvents": "off", + "useAnchorContent": "off", + "useValidAnchor": "off", + "useMediaCaption": "off", + "useAltText": "off", + "useButtonType": "off", + "noRedundantAlt": "off", + "noSvgWithoutTitle": "off", + "noLabelWithoutControl": "off" + }, + "nursery": { + "useImportRestrictions": "off" + }, + "performance": { + "noBarrelFile": "off" + }, + "style": { + "noNonNullAssertion": "off", + "noNamespaceImport": "warn", + "noUselessElse": "off", + "useBlockStatements": "off", + "noImplicitBoolean": "off", + "useNamingConvention": "off", + "useImportType": "off", + "noDefaultExport": "off", + "useFilenamingConvention": "off", + "useExplicitLengthCheck": "off", + "useNodejsImportProtocol": "off" + }, + "suspicious": { + "noConsole": "off", + "noConsoleLog": "off", + "noAssignInExpressions": "off" + } + } + } +} diff --git a/cache/cache.py b/cache/cache.py index de140be6..1fae099b 100644 --- a/cache/cache.py +++ b/cache/cache.py @@ -29,12 +29,12 @@ for new cache operations. import asyncio import json -from typing import Any, Dict, List, Optional, Union +from typing import Any, List, Optional import orjson from sqlalchemy import and_, join, select -from orm.author import Author, AuthorFollower +from auth.orm import Author, AuthorFollower from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.topic import Topic, TopicFollower from services.db import local_session @@ -78,7 +78,7 @@ async def cache_topic(topic: dict): async def cache_author(author: dict): payload = json.dumps(author, cls=CustomJSONEncoder) await asyncio.gather( - redis.execute("SET", f"author:user:{author['user'].strip()}", str(author["id"])), + redis.execute("SET", f"author:slug:{author['slug'].strip()}", str(author["id"])), redis.execute("SET", f"author:id:{author['id']}", payload), ) @@ -359,7 +359,13 @@ async def get_cached_topic_authors(topic_id: int): select(ShoutAuthor.author) .select_from(join(ShoutTopic, Shout, ShoutTopic.shout == Shout.id)) .join(ShoutAuthor, ShoutAuthor.shout == Shout.id) - .where(and_(ShoutTopic.topic == topic_id, Shout.published_at.is_not(None), Shout.deleted_at.is_(None))) + .where( + and_( + ShoutTopic.topic == topic_id, + Shout.published_at.is_not(None), + Shout.deleted_at.is_(None), + ) + ) ) authors_ids = [author_id for (author_id,) in session.execute(query).all()] # Cache the retrieved author IDs diff --git a/cache/precache.py b/cache/precache.py index 23844024..8871be7f 100644 --- a/cache/precache.py +++ b/cache/precache.py @@ -4,7 +4,7 @@ import json from sqlalchemy import and_, join, select from cache.cache import cache_author, cache_topic -from orm.author import Author, AuthorFollower +from auth.orm import Author, AuthorFollower from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic from orm.topic import Topic, TopicFollower from resolvers.stat import get_with_stat @@ -29,7 +29,9 @@ async def precache_authors_followers(author_id, session): async def precache_authors_follows(author_id, session): follows_topics_query = select(TopicFollower.topic).where(TopicFollower.follower == author_id) follows_authors_query = select(AuthorFollower.author).where(AuthorFollower.follower == author_id) - follows_shouts_query = select(ShoutReactionsFollower.shout).where(ShoutReactionsFollower.follower == author_id) + follows_shouts_query = select(ShoutReactionsFollower.shout).where( + ShoutReactionsFollower.follower == author_id + ) follows_topics = {row[0] for row in session.execute(follows_topics_query) if row[0]} follows_authors = {row[0] for row in session.execute(follows_authors_query) if row[0]} @@ -111,17 +113,18 @@ async def precache_data(): logger.info(f"{len(topics)} topics and their followings precached") # authors - authors = get_with_stat(select(Author).where(Author.user.is_not(None))) - logger.info(f"{len(authors)} authors found in database") + authors = get_with_stat(select(Author)) + # logger.info(f"{len(authors)} authors found in database") for author in authors: if isinstance(author, Author): profile = author.dict() author_id = profile.get("id") - user_id = profile.get("user", "").strip() - if author_id and user_id: + # user_id = profile.get("user", "").strip() + if author_id: # and user_id: await cache_author(profile) await asyncio.gather( - precache_authors_followers(author_id, session), precache_authors_follows(author_id, session) + precache_authors_followers(author_id, session), + precache_authors_follows(author_id, session), ) else: logger.error(f"fail caching {author}") diff --git a/cache/revalidator.py b/cache/revalidator.py index ac0c1ba1..6553940c 100644 --- a/cache/revalidator.py +++ b/cache/revalidator.py @@ -28,13 +28,12 @@ class CacheRevalidationManager: """Запуск фонового воркера для ревалидации кэша.""" # Проверяем, что у нас есть соединение с Redis if not self._redis._client: - logger.warning("Redis connection not established. Waiting for connection...") try: await self._redis.connect() logger.info("Redis connection established for revalidation manager") except Exception as e: logger.error(f"Failed to connect to Redis: {e}") - + self.task = asyncio.create_task(self.revalidate_cache()) async def revalidate_cache(self): @@ -53,7 +52,7 @@ class CacheRevalidationManager: # Проверяем соединение с Redis if not self._redis._client: return # Выходим из метода, если не удалось подключиться - + async with self.lock: # Ревалидация кэша авторов if self.items_to_revalidate["authors"]: diff --git a/cache/triggers.py b/cache/triggers.py index 23b226ec..647acc91 100644 --- a/cache/triggers.py +++ b/cache/triggers.py @@ -1,7 +1,7 @@ from sqlalchemy import event from cache.revalidator import revalidation_manager -from orm.author import Author, AuthorFollower +from auth.orm import Author, AuthorFollower from orm.reaction import Reaction, ReactionKind from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower from orm.topic import Topic, TopicFollower diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..494ceab1 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,34 @@ +# Документация проекта + +## Модули + +### Аутентификация и авторизация + +Подробная документация: [auth.md](auth.md) + +Основные возможности: +- Гибкая система аутентификации с использованием локальной БД и Redis +- Система ролей и разрешений (RBAC) +- OAuth интеграция (Google, Facebook, GitHub) +- Защита от брутфорс атак +- Управление сессиями через Redis +- Мультиязычные email уведомления +- Страница авторизации для админ-панели + +Конфигурация: +```python +# settings.py +JWT_SECRET_KEY = "your-secret-key" # секретный ключ для JWT токенов +SESSION_TOKEN_LIFE_SPAN = 60 * 60 * 24 * 30 # время жизни сессии (30 дней) +``` + +### Административный интерфейс + +Основные возможности: +- Защищенный доступ только для авторизованных пользователей с ролью admin +- Автоматическая проверка прав пользователя +- Отдельная страница входа для неавторизованных пользователей +- Проверка доступа по email или правам в системе RBAC + +Маршруты: +- `/admin` - административная панель с проверкой прав доступа \ No newline at end of file diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 00000000..5b5f583d --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,757 @@ +# Модуль аутентификации и авторизации + +## Общее описание + +Модуль реализует полноценную систему аутентификации с использованием локальной БД и Redis. + +## Компоненты + +### Модели данных + +#### Author (orm.py) +- Основная модель пользователя с расширенным функционалом аутентификации +- Поддерживает: + - Локальную аутентификацию по email/телефону + - Систему ролей и разрешений (RBAC) + - Блокировку аккаунта при множественных неудачных попытках входа + - Верификацию email/телефона + +#### Role и Permission (orm.py) +- Реализация RBAC (Role-Based Access Control) +- Роли содержат наборы разрешений +- Разрешения определяются как пары resource:operation + +### Аутентификация + +#### Внутренняя аутентификация +- Проверка токена в Redis +- Получение данных пользователя из локальной БД +- Проверка статуса аккаунта и разрешений + +### Управление сессиями (sessions.py) + +- Хранение сессий в Redis +- Поддержка: + - Создание сессий + - Верификация + - Отзыв отдельных сессий + - Отзыв всех сессий пользователя +- Автоматическое удаление истекших сессий + +### JWT токены (jwtcodec.py) + +- Кодирование/декодирование JWT токенов +- Проверка: + - Срока действия + - Подписи + - Издателя +- Поддержка пользовательских claims + +### OAuth интеграция (oauth.py) + +Поддерживаемые провайдеры: +- Google +- Facebook +- GitHub + +Функционал: +- Авторизация через OAuth провайдеров +- Получение профиля пользователя +- Создание/обновление локального профиля + +### Валидация (validations.py) + +Модели валидации для: +- Регистрации пользователей +- Входа в систему +- OAuth данных +- JWT payload +- Ответов API + +### Email функционал (email.py) + +- Отправка писем через Mailgun +- Поддержка шаблонов +- Мультиязычность (ru/en) +- Подтверждение email +- Сброс пароля + +## API Endpoints (resolvers.py) + +### Мутации +- `login` - вход в систему +- `getSession` - получение текущей сессии +- `confirmEmail` - подтверждение email +- `registerUser` - регистрация пользователя +- `sendLink` - отправка ссылки для входа + +### Запросы +- `signOut` - выход из системы +- `isEmailUsed` - проверка использования email + +## Безопасность + +### Хеширование паролей (identity.py) +- Использование bcrypt с SHA-256 +- Настраиваемое количество раундов +- Защита от timing-атак + +### Защита от брутфорса +- Блокировка аккаунта после 5 неудачных попыток +- Время блокировки: 30 минут +- Сброс счетчика после успешного входа + +## Конфигурация + +Основные настройки в settings.py: +- `SESSION_TOKEN_LIFE_SPAN` - время жизни сессии +- `ONETIME_TOKEN_LIFE_SPAN` - время жизни одноразовых токенов +- `JWT_SECRET_KEY` - секретный ключ для JWT +- `JWT_ALGORITHM` - алгоритм подписи JWT + +## Примеры использования + +### Аутентификация + +```python +# Проверка авторизации +user_id, roles = await check_auth(request) + +# Добавление роли +await add_user_role(user_id, ["author"]) + +# Создание сессии +token = await create_local_session(author) +``` + +### OAuth авторизация + +```python +# Инициация OAuth процесса +await oauth_login(request) + +# Обработка callback +response = await oauth_authorize(request) +``` + +### 1. Базовая авторизация на фронтенде + +```typescript +// pages/Login.tsx +// Предполагается, что AuthClient и createAuth импортированы корректно +// import { AuthClient } from '../auth/AuthClient'; // Путь может отличаться +// import { createAuth } from '../auth/useAuth'; // Путь может отличаться +import { Component, Show } from 'solid-js'; // Show для условного рендеринга + +export const LoginPage: Component = () => { + // Клиент и хук авторизации (пример из client/auth/useAuth.ts) + // const authClient = new AuthClient(/* baseUrl or other config */); + // const auth = createAuth(authClient); + // Для простоты примера, предположим, что auth уже доступен через контекст или пропсы + // В реальном приложении используйте useAuthContext() если он настроен + const { store, login } = useAuthContext(); // Пример, если используется контекст + + const handleSubmit = async (event: SubmitEvent) => { + event.preventDefault(); + const form = event.currentTarget as HTMLFormElement; + const emailInput = form.elements.namedItem('email') as HTMLInputElement; + const passwordInput = form.elements.namedItem('password') as HTMLInputElement; + + if (!emailInput || !passwordInput) { + console.error("Email or password input not found"); + return; + } + + const success = await login({ + email: emailInput.value, + password: passwordInput.value + }); + + if (success) { + console.log('Login successful, redirecting...'); + // window.location.href = '/'; // Раскомментируйте для реального редиректа + } else { + // Ошибка уже должна быть в store().error, обработанная в useAuth + console.error('Login failed:', store().error); + } + }; + + return ( + +
+ + +
+
+ + +
+ + +

{store().error}

+
+ + ); +} +``` + +### 2. Защита компонента с помощью ролей + +```typescript +// components/AdminPanel.tsx +import { useAuthContext } from '../auth' + +export const AdminPanel: Component = () => { + const auth = useAuthContext() + + // Проверяем наличие роли админа + if (!auth.hasRole('admin')) { + return
Доступ запрещен
+ } + + return ( +
+

Панель администратора

+ {/* Контент админки */} +
+ ) +} +``` + +### 3. OAuth авторизация через Google + +```typescript +// components/GoogleLoginButton.tsx +import { Component } from 'solid-js'; + +export const GoogleLoginButton: Component = () => { + const handleGoogleLogin = () => { + // Предполагается, что API_BASE_URL настроен глобально или импортирован + // const API_BASE_URL = 'http://localhost:8000'; // Пример + // window.location.href = `${API_BASE_URL}/auth/login/google`; + // Или если пути относительные и сервер на том же домене: + window.location.href = '/auth/login/google'; + }; + + return ( + + ); +} +``` + +### 4. Работа с пользователем на бэкенде + +```python +# routes/articles.py +# Предполагаемые импорты: +# from starlette.requests import Request +# from starlette.responses import JSONResponse +# from sqlalchemy.orm import Session +# from ..dependencies import get_db_session # Пример получения сессии БД +# from ..auth.decorators import login_required # Ваш декоратор +# from ..auth.orm import Author # Модель пользователя +# from ..models.article import Article # Модель статьи (пример) + +# @login_required # Декоратор проверяет аутентификацию и добавляет user в request +async def create_article_example(request: Request): # Используем Request из Starlette + """ + Пример создания статьи с проверкой прав. + В реальном приложении используйте DI для сессии БД (например, FastAPI Depends). + """ + user: Author = request.user # request.user добавляется декоратором @login_required + + # Проверяем право на создание статей (метод из модели auth.orm.Author) + if not user.has_permission('articles', 'create'): + return JSONResponse({'error': 'Недостаточно прав для создания статьи'}, status_code=403) + + try: + article_data = await request.json() + title = article_data.get('title') + content = article_data.get('content') + + if not title or not content: + return JSONResponse({'error': 'Title and content are required'}, status_code=400) + + except ValueError: # Если JSON некорректен + return JSONResponse({'error': 'Invalid JSON data'}, status_code=400) + + # Пример работы с БД. В реальном приложении сессия db будет получена через DI. + # Здесь db - это заглушка, замените на вашу реальную логику работы с БД. + # Пример: + # with get_db_session() as db: # Получение сессии SQLAlchemy + # new_article = Article( + # title=title, + # content=content, + # author_id=user.id # Связываем статью с автором + # ) + # db.add(new_article) + # db.commit() + # db.refresh(new_article) + # return JSONResponse({'id': new_article.id, 'title': new_article.title}, status_code=201) + + # Заглушка для примера в документации + mock_article_id = 123 + print(f"User {user.id} ({user.email}) is creating article '{title}'.") + return JSONResponse({'id': mock_article_id, 'title': title}, status_code=201) +``` + +### 5. Проверка прав в GraphQL резолверах + +```python +# resolvers/mutations.py +from auth.decorators import login_required +from auth.models import Author + +@login_required +async def update_article(_, info, article_id: int, data: dict): + """ + Обновление статьи с проверкой прав + """ + user: Author = info.context.user + + # Получаем статью + article = db.query(Article).get(article_id) + if not article: + raise GraphQLError('Статья не найдена') + + # Проверяем права на редактирование + if not user.has_permission('articles', 'edit'): + raise GraphQLError('Недостаточно прав') + + # Обновляем поля + article.title = data.get('title', article.title) + article.content = data.get('content', article.content) + + db.commit() + return article +``` + +### 6. Создание пользователя с ролями + +```python +# scripts/create_admin.py +from auth.models import Author, Role +from auth.password import hash_password + +def create_admin(email: str, password: str): + """Создание администратора""" + + # Получаем роль админа + admin_role = db.query(Role).filter(Role.id == 'admin').first() + + # Создаем пользователя + admin = Author( + email=email, + password=hash_password(password), + is_active=True, + email_verified=True + ) + + # Назначаем роль + admin.roles.append(admin_role) + + # Сохраняем + db.add(admin) + db.commit() + + return admin +``` + +### 7. Работа с сессиями + +```python +# auth/session_management.py (примерное название файла) +# Предполагаемые импорты: +# from starlette.responses import RedirectResponse +# from starlette.requests import Request +# from ..auth.orm import Author # Модель пользователя +# from ..auth.token import TokenStorage # Ваш модуль для работы с токенами +# from ..settings import SESSION_COOKIE_MAX_AGE, SESSION_COOKIE_NAME, SESSION_COOKIE_SECURE, SESSION_COOKIE_HTTPONLY, SESSION_COOKIE_SAMESITE + +# Замените FRONTEND_URL_AUTH_SUCCESS и FRONTEND_URL_LOGOUT на реальные URL из настроек +FRONTEND_URL_AUTH_SUCCESS = "/auth/success" # Пример +FRONTEND_URL_LOGOUT = "/logout" # Пример + + +async def login_user_session(request: Request, user: Author, response_class=RedirectResponse): + """ + Создание сессии пользователя и установка cookie. + """ + if not hasattr(user, 'id'): # Проверка наличия id у пользователя + raise ValueError("User object must have an id attribute") + + # Создаем токен сессии (TokenStorage из вашего модуля auth.token) + session_token = TokenStorage.create_session(str(user.id)) # ID пользователя обычно число, приводим к строке если нужно + + # Устанавливаем cookie + # В реальном приложении FRONTEND_URL_AUTH_SUCCESS должен вести на страницу вашего фронтенда + response = response_class(url=FRONTEND_URL_AUTH_SUCCESS) + response.set_cookie( + key=SESSION_COOKIE_NAME, # 'session_token' из settings.py + value=session_token, + httponly=SESSION_COOKIE_HTTPONLY, # True из settings.py + secure=SESSION_COOKIE_SECURE, # True для HTTPS из settings.py + samesite=SESSION_COOKIE_SAMESITE, # 'lax' из settings.py + max_age=SESSION_COOKIE_MAX_AGE # 30 дней в секундах из settings.py + ) + print(f"Session created for user {user.id}. Token: {session_token[:10]}...") # Логируем для отладки + return response + +async def logout_user_session(request: Request, response_class=RedirectResponse): + """ + Завершение сессии пользователя и удаление cookie. + """ + session_token = request.cookies.get(SESSION_COOKIE_NAME) + + if session_token: + # Удаляем токен из хранилища (TokenStorage из вашего модуля auth.token) + TokenStorage.delete_session(session_token) + print(f"Session token {session_token[:10]}... deleted from storage.") + + # Удаляем cookie + # В реальном приложении FRONTEND_URL_LOGOUT должен вести на страницу вашего фронтенда + response = response_class(url=FRONTEND_URL_LOGOUT) + response.delete_cookie(SESSION_COOKIE_NAME) + print(f"Cookie {SESSION_COOKIE_NAME} deleted.") + return response +``` + +### 8. Проверка CSRF в формах + +```typescript +// components/ProfileForm.tsx +// import { useAuthContext } from '../auth'; // Предполагаем, что auth есть в контексте +import { Component, createSignal, Show } from 'solid-js'; + +export const ProfileForm: Component = () => { + const { store, checkAuth } = useAuthContext(); // Пример получения из контекста + const [message, setMessage] = createSignal(null); + const [error, setError] = createSignal(null); + + const handleSubmit = async (event: SubmitEvent) => { + event.preventDefault(); + setMessage(null); + setError(null); + const form = event.currentTarget as HTMLFormElement; + const formData = new FormData(form); + + // ВАЖНО: Получение CSRF-токена из cookie - это один из способов. + // Если CSRF-токен устанавливается как httpOnly cookie, то он будет автоматически + // отправляться браузером, и его не нужно доставать вручную для fetch, + // если сервер настроен на его проверку из заголовка (например, X-CSRF-Token), + // который fetch *не* устанавливает автоматически для httpOnly cookie. + // Либо сервер может предоставлять CSRF-токен через специальный эндпоинт. + // Представленный ниже способ подходит, если CSRF-токен доступен для JS. + const csrfToken = document.cookie + .split('; ') + .find(row => row.startsWith('csrf_token=')) // Имя cookie может отличаться + ?.split('=')[1]; + + if (!csrfToken) { + // setError('CSRF token not found. Please refresh the page.'); + // В продакшене CSRF-токен должен быть всегда. Этот лог для отладки. + console.warn('CSRF token not found in cookies. Ensure it is set by the server.'); + // Для данного примера, если токен не найден, можно либо прервать, либо положиться на серверную проверку. + // Для большей безопасности, прерываем, если CSRF-защита критична на клиенте. + } + + try { + // Замените '/api/profile' на ваш реальный эндпоинт + const response = await fetch('/api/profile', { + method: 'POST', + headers: { + // Сервер должен быть настроен на чтение этого заголовка + // если CSRF токен не отправляется автоматически с httpOnly cookie. + ...(csrfToken && { 'X-CSRF-Token': csrfToken }), + // 'Content-Type': 'application/json' // Если отправляете JSON + }, + body: formData // FormData отправится как 'multipart/form-data' + // Если нужно JSON: body: JSON.stringify(Object.fromEntries(formData)) + }); + + if (response.ok) { + const result = await response.json(); + setMessage(result.message || 'Профиль успешно обновлен!'); + checkAuth(); // Обновить данные пользователя в сторе + } else { + const errData = await response.json(); + setError(errData.error || `Ошибка: ${response.status}`); + } + } catch (err) { + console.error('Profile update error:', err); + setError('Не удалось обновить профиль. Попробуйте позже.'); + } + }; + + return ( +
+
+ + +
+ {/* Другие поля профиля */} + + +

{message()}

+
+ +

{error()}

+
+
+ ); +} +``` + +### 9. Кастомные валидаторы для форм + +```typescript +// validators/auth.ts +export const validatePassword = (password: string): string[] => { + const errors: string[] = [] + + if (password.length < 8) { + errors.push('Пароль должен быть не менее 8 символов') + } + + if (!/[A-Z]/.test(password)) { + errors.push('Пароль должен содержать заглавную букву') + } + + if (!/[0-9]/.test(password)) { + errors.push('Пароль должен содержать цифру') + } + + return errors +} + +// components/RegisterForm.tsx +import { validatePassword } from '../validators/auth' + +export const RegisterForm: Component = () => { + const [errors, setErrors] = createSignal([]) + + const handleSubmit = async (e: Event) => { + e.preventDefault() + const form = e.target as HTMLFormElement + const data = new FormData(form) + + // Валидация пароля + const password = data.get('password') as string + const passwordErrors = validatePassword(password) + + if (passwordErrors.length > 0) { + setErrors(passwordErrors) + return + } + + // Отправка формы... + } + + return ( +
+ + {errors().map(error => ( +
{error}
+ ))} + +
+ ) +} +``` + +### 10. Интеграция с внешними сервисами + +```python +# services/notifications.py +from auth.models import Author + +async def notify_login(user: Author, ip: str, device: str): + """Отправка уведомления о новом входе""" + + # Формируем текст + text = f""" + Новый вход в аккаунт: + IP: {ip} + Устройство: {device} + Время: {datetime.now()} + """ + + # Отправляем email + await send_email( + to=user.email, + subject='Новый вход в аккаунт', + text=text + ) + + # Логируем + logger.info(f'New login for user {user.id} from {ip}') +``` + +## Тестирование + +### 1. Тест OAuth авторизации + +```python +# tests/test_oauth.py +@pytest.mark.asyncio +async def test_google_oauth_success(client, mock_google): + # Мокаем ответ от Google + mock_google.return_value = { + 'id': '123', + 'email': 'test@gmail.com', + 'name': 'Test User' + } + + # Запрос на авторизацию + response = await client.get('/auth/login/google') + assert response.status_code == 302 + + # Проверяем редирект + assert 'accounts.google.com' in response.headers['location'] + + # Проверяем сессию + assert 'state' in client.session + assert 'code_verifier' in client.session +``` + +### 2. Тест ролей и разрешений + +```python +# tests/test_permissions.py +def test_user_permissions(): + # Создаем тестовые данные + role = Role(id='editor', name='Editor') + permission = Permission( + id='articles:edit', + resource='articles', + operation='edit' + ) + role.permissions.append(permission) + + user = Author(email='test@test.com') + user.roles.append(role) + + # Проверяем разрешения + assert user.has_permission('articles', 'edit') + assert not user.has_permission('articles', 'delete') +``` + +## Безопасность + +### 1. Rate Limiting + +```python +# middleware/rate_limit.py +from starlette.middleware import Middleware +from starlette.middleware.base import BaseHTTPMiddleware +from redis import Redis + +class RateLimitMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + # Получаем IP + ip = request.client.host + + # Проверяем лимиты в Redis + redis = Redis() + key = f'rate_limit:{ip}' + + # Увеличиваем счетчик + count = redis.incr(key) + if count == 1: + redis.expire(key, 60) # TTL 60 секунд + + # Проверяем лимит + if count > 100: # 100 запросов в минуту + return JSONResponse( + {'error': 'Too many requests'}, + status_code=429 + ) + + return await call_next(request) +``` + +### 2. Защита от брутфорса + +```python +# auth/login.py +async def handle_login_attempt(user: Author, success: bool): + """Обработка попытки входа""" + + if not success: + # Увеличиваем счетчик неудачных попыток + user.increment_failed_login() + + if user.is_locked(): + # Аккаунт заблокирован + raise AuthError( + 'Account is locked. Try again later.', + 'ACCOUNT_LOCKED' + ) + else: + # Сбрасываем счетчик при успешном входе + user.reset_failed_login() +``` + +## Мониторинг + +### 1. Логирование событий авторизации + +```python +# auth/logging.py +import structlog + +logger = structlog.get_logger() + +def log_auth_event( + event_type: str, + user_id: int = None, + success: bool = True, + **kwargs +): + """ + Логирование событий авторизации + + Args: + event_type: Тип события (login, logout, etc) + user_id: ID пользователя + success: Успешность операции + **kwargs: Дополнительные поля + """ + logger.info( + 'auth_event', + event_type=event_type, + user_id=user_id, + success=success, + **kwargs + ) +``` + +### 2. Метрики для Prometheus + +```python +# metrics/auth.py +from prometheus_client import Counter, Histogram + +# Счетчики +login_attempts = Counter( + 'auth_login_attempts_total', + 'Number of login attempts', + ['success'] +) + +oauth_logins = Counter( + 'auth_oauth_logins_total', + 'Number of OAuth logins', + ['provider'] +) + +# Гистограммы +login_duration = Histogram( + 'auth_login_duration_seconds', + 'Time spent processing login' +) +``` \ No newline at end of file diff --git a/docs/features.md b/docs/features.md index 4837f870..1a5a7678 100644 --- a/docs/features.md +++ b/docs/features.md @@ -20,15 +20,6 @@ - Настраиваемое время жизни кеша (TTL) - Возможность ручной инвалидации кеша для конкретных функций и аргументов -## Webhooks - -- Автоматическая регистрация вебхука для события user.login -- Предотвращение создания дублирующихся вебхуков -- Автоматическая очистка устаревших вебхуков -- Поддержка авторизации вебхуков через WEBHOOK_SECRET -- Обработка ошибок при операциях с вебхуками -- Динамическое определение endpoint'а на основе окружения - ## CORS Configuration - Поддерживаемые методы: GET, POST, OPTIONS diff --git a/env.d.ts b/env.d.ts new file mode 100644 index 00000000..da5a5e5d --- /dev/null +++ b/env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_URL: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 00000000..8feec1e6 --- /dev/null +++ b/index.html @@ -0,0 +1,20 @@ + + + + + + + Admin Panel + + + + +
+ + + + \ No newline at end of file diff --git a/main.py b/main.py index ff64c974..f2ee9679 100644 --- a/main.py +++ b/main.py @@ -1,16 +1,19 @@ import asyncio import os -import sys from importlib import import_module -from os.path import exists +from os.path import exists, join from ariadne import load_schema_from_path, make_executable_schema from ariadne.asgi import GraphQL +from ariadne.asgi.handlers import GraphQLHTTPHandler from starlette.applications import Starlette from starlette.middleware.cors import CORSMiddleware +from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.middleware import Middleware from starlette.requests import Request -from starlette.responses import JSONResponse, Response -from starlette.routing import Route +from starlette.responses import FileResponse, JSONResponse, HTMLResponse, RedirectResponse +from starlette.routing import Route, Mount +from starlette.staticfiles import StaticFiles from cache.precache import precache_data from cache.revalidator import revalidation_manager @@ -18,78 +21,220 @@ from services.exception import ExceptionHandlerMiddleware from services.redis import redis from services.schema import create_all_tables, resolvers from services.search import search_service -from services.viewed import ViewedStorage -from services.webhook import WebhookEndpoint, create_webhook_endpoint -from settings import DEV_SERVER_PID_FILE_NAME, MODE + +from settings import DEV_SERVER_PID_FILE_NAME, MODE, ADMIN_EMAILS +from utils.logger import root_logger as logger +from auth.internal import InternalAuthentication +from auth import routes as auth_routes # Импортируем маршруты авторизации +from auth.middleware import ( + AuthorizationMiddleware, + GraphQLExtensionsMiddleware, +) # Импортируем middleware для авторизации import_module("resolvers") +import_module("auth.resolvers") + +# Создаем схему GraphQL schema = make_executable_schema(load_schema_from_path("schema/"), resolvers) - -async def start(): - if MODE == "development": - if not exists(DEV_SERVER_PID_FILE_NAME): - # pid file management - with open(DEV_SERVER_PID_FILE_NAME, "w", encoding="utf-8") as f: - f.write(str(os.getpid())) - print(f"[main] process started in {MODE} mode") +# Пути к клиентским файлам +CLIENT_DIR = join(os.path.dirname(__file__), "client") +DIST_DIR = join(os.path.dirname(__file__), "dist") # Директория для собранных файлов +INDEX_HTML = join(os.path.dirname(__file__), "index.html") -async def lifespan(_app): - try: - create_all_tables() - await asyncio.gather( - redis.connect(), - precache_data(), - ViewedStorage.init(), - create_webhook_endpoint(), - search_service.info(), - start(), - revalidation_manager.start(), - ) - yield - finally: - tasks = [redis.disconnect(), ViewedStorage.stop(), revalidation_manager.stop()] - await asyncio.gather(*tasks, return_exceptions=True) +async def index_handler(request: Request): + """ + Раздача основного HTML файла + """ + return FileResponse(INDEX_HTML) -# Создаем экземпляр GraphQL -graphql_app = GraphQL(schema, debug=True) +# GraphQL API +class CustomGraphQLHTTPHandler(GraphQLHTTPHandler): + """ + Кастомный GraphQL HTTP обработчик, который добавляет объект response в контекст + """ + + async def get_context_for_request(self, request: Request, data: dict) -> dict: + """ + Переопределяем метод для добавления объекта response и extensions в контекст + """ + context = await super().get_context_for_request(request, data) + # Создаем объект ответа, который будем использовать для установки cookie + response = JSONResponse({}) + context["response"] = response + + # Добавляем extensions в контекст + if "extensions" not in context: + context["extensions"] = GraphQLExtensionsMiddleware() + + return context -# Оборачиваем GraphQL-обработчик для лучшей обработки ошибок -async def graphql_handler(request: Request): - if request.method not in ["GET", "POST"]: - return JSONResponse({"error": "Method Not Allowed"}, status_code=405) +graphql_app = GraphQL(schema, debug=MODE == "development", http_handler=CustomGraphQLHTTPHandler()) - try: - result = await graphql_app.handle_request(request) - if isinstance(result, Response): - return result + +async def graphql_handler(request): + """Обработчик GraphQL запросов""" + # Проверяем заголовок Content-Type + content_type = request.headers.get("content-type", "") + if not content_type.startswith("application/json") and "application/json" in request.headers.get( + "accept", "" + ): + # Если не application/json, но клиент принимает JSON + request._headers["content-type"] = "application/json" + + # Обрабатываем GraphQL запрос + result = await graphql_app.handle_request(request) + + # Если result - это ответ от сервера, возвращаем его как есть + if hasattr(result, "body"): + return result + + # Если результат - это словарь, значит нужно его сконвертировать в JSONResponse + if isinstance(result, dict): return JSONResponse(result) - except asyncio.CancelledError: - return JSONResponse({"error": "Request cancelled"}, status_code=499) - except Exception as e: - print(f"GraphQL error: {str(e)}") - return JSONResponse({"error": str(e)}, status_code=500) + + return result -# Обновляем маршрут в Starlette -app = Starlette( - routes=[ - Route("/", graphql_handler, methods=["GET", "POST"]), - Route("/new-author", WebhookEndpoint), - ], - lifespan=lifespan, - debug=True, +async def admin_handler(request: Request): + """ + Обработчик для маршрута /admin с серверной проверкой прав доступа + """ + # Проверяем авторизован ли пользователь + if not request.user.is_authenticated: + # Если пользователь не авторизован, перенаправляем на страницу входа + return RedirectResponse(url="/login", status_code=303) + + # Проверяем является ли пользователь администратором + auth = getattr(request, "auth", None) + is_admin = False + + # Проверяем наличие объекта auth и метода is_admin + if auth: + try: + # Проверяем имеет ли пользователь права администратора + is_admin = auth.is_admin + except Exception as e: + logger.error(f"Ошибка при проверке прав администратора: {e}") + + # Дополнительная проверка email (для случаев, когда нет метода is_admin) + admin_emails = ADMIN_EMAILS.split(",") + if not is_admin and hasattr(auth, "email") and auth.email in admin_emails: + is_admin = True + + if is_admin: + # Если пользователь - администратор, возвращаем HTML-файл + return FileResponse(INDEX_HTML) + else: + # Для авторизованных пользователей без прав администратора показываем страницу с ошибкой доступа + return HTMLResponse( + """ + + + + + + Доступ запрещен + + + +
+

Доступ запрещен

+

У вас нет прав для доступа к административной панели. Обратитесь к администратору системы для получения необходимых разрешений.

+ Вернуться на главную +
+ + + """, + status_code=403 + ) + + +# Функция запуска сервера +async def start(): + """Запуск сервера и инициализация данных""" + logger.info(f"Запуск сервера в режиме: {MODE}") + + # Создаем все таблицы в БД + create_all_tables() + + # Запускаем предварительное кеширование данных + asyncio.create_task(precache_data()) + + # Запускаем задачу ревалидации кеша + asyncio.create_task(revalidation_manager.start()) + + # Выводим сообщение о запуске сервера и доступности API + logger.info("Сервер запущен и готов принимать запросы") + logger.info("GraphQL API доступно по адресу: /graphql") + logger.info("Админ-панель доступна по адресу: /admin") + + +# Функция остановки сервера +async def shutdown(): + """Остановка сервера и освобождение ресурсов""" + logger.info("Остановка сервера") + + # Закрываем соединение с Redis + await redis.disconnect() + + # Останавливаем поисковый сервис + search_service.close() + + # Удаляем PID-файл, если он существует + if exists(DEV_SERVER_PID_FILE_NAME): + os.unlink(DEV_SERVER_PID_FILE_NAME) + + +# Добавляем маршруты статических файлов, если директория существует +routes = [] +if exists(DIST_DIR): + # Добавляем маршруты для статических ресурсов, если директория dist существует + routes.append(Mount("/assets", app=StaticFiles(directory=join(DIST_DIR, "assets")))) + routes.append(Mount("/chunks", app=StaticFiles(directory=join(DIST_DIR, "chunks")))) + +# Маршруты для API и веб-приложения +routes.extend( + [ + Route("/graphql", graphql_handler, methods=["GET", "POST"]), + # Добавляем специальный маршрут для админ-панели с проверкой прав доступа + Route("/admin", admin_handler, methods=["GET"]), + # Маршрут для обработки всех остальных запросов - SPA + Route("/{path:path}", index_handler, methods=["GET"]), + Route("/", index_handler, methods=["GET"]), + ] ) -app.add_middleware(ExceptionHandlerMiddleware) -if "dev" in sys.argv: - app.add_middleware( - CORSMiddleware, - allow_origins=["https://localhost:3000"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) +# Добавляем маршруты авторизации +routes.extend(auth_routes) + +app = Starlette( + debug=MODE == "development", + routes=routes, + middleware=[ + Middleware(ExceptionHandlerMiddleware), + Middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + allow_credentials=True, + ), + # Добавляем middleware для обработки Authorization заголовка с Bearer токеном + Middleware(AuthorizationMiddleware), + # Добавляем middleware для аутентификации после обработки токенов + Middleware(AuthenticationMiddleware, backend=InternalAuthentication()), + ], + on_startup=[start], + on_shutdown=[shutdown], +) diff --git a/orm/author.py b/orm/author.py deleted file mode 100644 index 88d00241..00000000 --- a/orm/author.py +++ /dev/null @@ -1,136 +0,0 @@ -import time - -from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String - -from services.db import Base - -# from sqlalchemy_utils import TSVectorType - - -class AuthorRating(Base): - """ - Рейтинг автора от другого автора. - - Attributes: - rater (int): ID оценивающего автора - author (int): ID оцениваемого автора - plus (bool): Положительная/отрицательная оценка - """ - - __tablename__ = "author_rating" - - id = None # type: ignore - rater = Column(ForeignKey("author.id"), primary_key=True) - author = Column(ForeignKey("author.id"), primary_key=True) - plus = Column(Boolean) - - # Определяем индексы - __table_args__ = ( - # Индекс для быстрого поиска всех оценок конкретного автора - Index("idx_author_rating_author", "author"), - # Индекс для быстрого поиска всех оценок, оставленных конкретным автором - Index("idx_author_rating_rater", "rater"), - ) - - -class AuthorFollower(Base): - """ - Подписка одного автора на другого. - - Attributes: - follower (int): ID подписчика - author (int): ID автора, на которого подписываются - created_at (int): Время создания подписки - auto (bool): Признак автоматической подписки - """ - - __tablename__ = "author_follower" - - id = None # type: ignore - follower = Column(ForeignKey("author.id"), primary_key=True) - author = Column(ForeignKey("author.id"), primary_key=True) - created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) - auto = Column(Boolean, nullable=False, default=False) - - # Определяем индексы - __table_args__ = ( - # Индекс для быстрого поиска всех подписчиков автора - Index("idx_author_follower_author", "author"), - # Индекс для быстрого поиска всех авторов, на которых подписан конкретный автор - Index("idx_author_follower_follower", "follower"), - ) - - -class AuthorBookmark(Base): - """ - Закладка автора на публикацию. - - Attributes: - author (int): ID автора - shout (int): ID публикации - """ - - __tablename__ = "author_bookmark" - - id = None # type: ignore - author = Column(ForeignKey("author.id"), primary_key=True) - shout = Column(ForeignKey("shout.id"), primary_key=True) - - # Определяем индексы - __table_args__ = ( - # Индекс для быстрого поиска всех закладок автора - Index("idx_author_bookmark_author", "author"), - # Индекс для быстрого поиска всех авторов, добавивших публикацию в закладки - Index("idx_author_bookmark_shout", "shout"), - ) - - -class Author(Base): - """ - Модель автора в системе. - - Attributes: - name (str): Отображаемое имя - slug (str): Уникальный строковый идентификатор - bio (str): Краткая биография/статус - about (str): Полное описание - pic (str): URL изображения профиля - links (dict): Ссылки на социальные сети и сайты - created_at (int): Время создания профиля - last_seen (int): Время последнего посещения - updated_at (int): Время последнего обновления - deleted_at (int): Время удаления (если профиль удален) - """ - - __tablename__ = "author" - - name = Column(String, nullable=True, comment="Display name") - slug = Column(String, unique=True, comment="Author's slug") - bio = Column(String, nullable=True, comment="Bio") # status description - about = Column(String, nullable=True, comment="About") # long and formatted - pic = Column(String, nullable=True, comment="Picture") - links = Column(JSON, nullable=True, comment="Links") - created_at = Column(Integer, nullable=False, default=lambda: int(time.time())) - last_seen = Column(Integer, nullable=False, default=lambda: int(time.time())) - updated_at = Column(Integer, nullable=False, default=lambda: int(time.time())) - deleted_at = Column(Integer, nullable=True, comment="Deleted at") - - # search_vector = Column( - # TSVectorType("name", "slug", "bio", "about", regconfig="pg_catalog.russian") - # ) - - # Определяем индексы - __table_args__ = ( - # Индекс для быстрого поиска по имени - Index("idx_author_name", "name"), - # Индекс для быстрого поиска по slug - Index("idx_author_slug", "slug"), - # Индекс для фильтрации неудаленных авторов - Index( - "idx_author_deleted_at", "deleted_at", postgresql_where=deleted_at.is_(None) - ), - # Индекс для сортировки по времени создания (для новых авторов) - Index("idx_author_created_at", "created_at"), - # Индекс для сортировки по времени последнего посещения - Index("idx_author_last_seen", "last_seen"), - ) diff --git a/orm/community.py b/orm/community.py index 6e1f2b76..0aac4172 100644 --- a/orm/community.py +++ b/orm/community.py @@ -4,7 +4,7 @@ import time from sqlalchemy import Column, ForeignKey, Integer, String, Text, distinct, func from sqlalchemy.ext.hybrid import hybrid_property -from orm.author import Author +from auth.orm import Author from services.db import Base @@ -66,7 +66,11 @@ class CommunityStats: def shouts(self): from orm.shout import Shout - return self.community.session.query(func.count(Shout.id)).filter(Shout.community == self.community.id).scalar() + return ( + self.community.session.query(func.count(Shout.id)) + .filter(Shout.community == self.community.id) + .scalar() + ) @property def followers(self): @@ -84,7 +88,11 @@ class CommunityStats: return ( self.community.session.query(func.count(distinct(Author.id))) .join(Shout) - .filter(Shout.community == self.community.id, Shout.featured_at.is_not(None), Author.id.in_(Shout.authors)) + .filter( + Shout.community == self.community.id, + Shout.featured_at.is_not(None), + Author.id.in_(Shout.authors), + ) .scalar() ) diff --git a/orm/draft.py b/orm/draft.py index 76d5f385..1933d80f 100644 --- a/orm/draft.py +++ b/orm/draft.py @@ -3,7 +3,7 @@ import time from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String from sqlalchemy.orm import relationship -from orm.author import Author +from auth.orm import Author from orm.topic import Topic from services.db import Base @@ -26,7 +26,6 @@ class DraftAuthor(Base): caption = Column(String, nullable=True, default="") - class Draft(Base): __tablename__ = "draft" # required @@ -53,12 +52,12 @@ class Draft(Base): deleted_at: int | None = Column(Integer, nullable=True, index=True) updated_by: int | None = Column("updated_by", ForeignKey("author.id"), nullable=True) deleted_by: int | None = Column("deleted_by", ForeignKey("author.id"), nullable=True) - - # --- Relationships --- + + # --- Relationships --- # Только many-to-many связи через вспомогательные таблицы authors = relationship(Author, secondary="draft_author", lazy="select") topics = relationship(Topic, secondary="draft_topic", lazy="select") - + # Связь с Community (если нужна как объект, а не ID) # community = relationship("Community", foreign_keys=[community_id], lazy="joined") # Пока оставляем community_id как ID @@ -66,12 +65,12 @@ class Draft(Base): # Связь с публикацией (один-к-одному или один-к-нулю) # Загружается через joinedload в резолвере publication = relationship( - "Shout", - primaryjoin="Draft.id == Shout.draft", - foreign_keys="Shout.draft", - uselist=False, - lazy="noload", # Не грузим по умолчанию, только через options - viewonly=True # Указываем, что это связь только для чтения + "Shout", + primaryjoin="Draft.id == Shout.draft", + foreign_keys="Shout.draft", + uselist=False, + lazy="noload", # Не грузим по умолчанию, только через options + viewonly=True, # Указываем, что это связь только для чтения ) def dict(self): @@ -101,5 +100,5 @@ class Draft(Base): "deleted_by": self.deleted_by, # Гарантируем, что topics и authors всегда будут списками "topics": [topic.dict() for topic in (self.topics or [])], - "authors": [author.dict() for author in (self.authors or [])] - } \ No newline at end of file + "authors": [author.dict() for author in (self.authors or [])], + } diff --git a/orm/notification.py b/orm/notification.py index a68b1525..b52e25de 100644 --- a/orm/notification.py +++ b/orm/notification.py @@ -4,7 +4,7 @@ import time from sqlalchemy import JSON, Column, ForeignKey, Integer, String from sqlalchemy.orm import relationship -from orm.author import Author +from auth.orm import Author from services.db import Base diff --git a/orm/shout.py b/orm/shout.py index d74e84d4..5d445184 100644 --- a/orm/shout.py +++ b/orm/shout.py @@ -3,7 +3,7 @@ import time from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String from sqlalchemy.orm import relationship -from orm.author import Author +from auth.orm import Author from orm.reaction import Reaction from orm.topic import Topic from services.db import Base diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..2e2cc39e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2236 @@ +{ + "name": "publy-admin", + "version": "0.4.20", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "publy-admin", + "version": "0.4.20", + "dependencies": { + "@solid-primitives/storage": "^4.3.0", + "@solidjs/router": "^0.15.0", + "graphql": "^16.8.0", + "graphql-request": "^6.1.0", + "solid-js": "^1.9.6", + "solid-styled-components": "^0.28.0" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@types/node": "^22.15.0", + "terser": "^5.39.0", + "typescript": "^5.8.0", + "vite": "^6.3.0", + "vite-plugin-solid": "^2.11.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.2.tgz", + "integrity": "sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz", + "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.1", + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helpers": "^7.27.1", + "@babel/parser": "^7.27.1", + "@babel/template": "^7.27.1", + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz", + "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.27.1", + "@babel/types": "^7.27.1", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz", + "integrity": "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz", + "integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz", + "integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz", + "integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.1", + "@babel/parser": "^7.27.1", + "@babel/template": "^7.27.1", + "@babel/types": "^7.27.1", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", + "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", + "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", + "dev": true, + "hasInstallScript": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "1.9.4", + "@biomejs/cli-darwin-x64": "1.9.4", + "@biomejs/cli-linux-arm64": "1.9.4", + "@biomejs/cli-linux-arm64-musl": "1.9.4", + "@biomejs/cli-linux-x64": "1.9.4", + "@biomejs/cli-linux-x64-musl": "1.9.4", + "@biomejs/cli-win32-arm64": "1.9.4", + "@biomejs/cli-win32-x64": "1.9.4" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", + "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", + "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", + "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", + "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", + "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", + "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", + "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", + "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", + "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz", + "integrity": "sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.2.tgz", + "integrity": "sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.2.tgz", + "integrity": "sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.2.tgz", + "integrity": "sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.2.tgz", + "integrity": "sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.2.tgz", + "integrity": "sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.2.tgz", + "integrity": "sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.2.tgz", + "integrity": "sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.2.tgz", + "integrity": "sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.2.tgz", + "integrity": "sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.2.tgz", + "integrity": "sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.2.tgz", + "integrity": "sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.2.tgz", + "integrity": "sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.2.tgz", + "integrity": "sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.2.tgz", + "integrity": "sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.2.tgz", + "integrity": "sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.2.tgz", + "integrity": "sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.2.tgz", + "integrity": "sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.2.tgz", + "integrity": "sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.2.tgz", + "integrity": "sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@solid-primitives/storage": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@solid-primitives/storage/-/storage-4.3.2.tgz", + "integrity": "sha512-Vkuk/AqgUOjz6k7Mo5yDFQBQg8KMQcZfaac/bxLApaza3e5c/iNllNvxZWPM9Vf+Gf4m5SgRbvgsm6dSLJ27Jw==", + "license": "MIT", + "dependencies": { + "@solid-primitives/utils": "^6.3.1" + }, + "peerDependencies": { + "@tauri-apps/plugin-store": "*", + "solid-js": "^1.6.12" + }, + "peerDependenciesMeta": { + "@tauri-apps/plugin-store": { + "optional": true + }, + "solid-start": { + "optional": true + } + } + }, + "node_modules/@solid-primitives/utils": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@solid-primitives/utils/-/utils-6.3.1.tgz", + "integrity": "sha512-4/Z59nnwu4MPR//zWZmZm2yftx24jMqQ8CSd/JobL26TPfbn4Ph8GKNVJfGJWShg1QB98qObJSskqizbTvcLLA==", + "license": "MIT", + "peerDependencies": { + "solid-js": "^1.6.12" + } + }, + "node_modules/@solidjs/router": { + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/@solidjs/router/-/router-0.15.3.tgz", + "integrity": "sha512-iEbW8UKok2Oio7o6Y4VTzLj+KFCmQPGEpm1fS3xixwFBdclFVBvaQVeibl1jys4cujfAK5Kn6+uG2uBm3lxOMw==", + "license": "MIT", + "peerDependencies": { + "solid-js": "^1.8.6" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.15.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.14.tgz", + "integrity": "sha512-BL1eyu/XWsFGTtDWOYULQEs4KR0qdtYfCxYAUYRoB7JP7h9ETYLgQTww6kH8Sj2C0pFGgrpM0XKv6/kbIzYJ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/acorn": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/babel-plugin-jsx-dom-expressions": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.39.8.tgz", + "integrity": "sha512-/MVOIIjonylDXnrWmG23ZX82m9mtKATsVHB7zYlPfDR9Vdd/NBE48if+wv27bSkBtyO7EPMUlcUc4J63QwuACQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "7.18.6", + "@babel/plugin-syntax-jsx": "^7.18.6", + "@babel/types": "^7.20.7", + "html-entities": "2.3.3", + "parse5": "^7.1.2", + "validate-html-nesting": "^1.2.1" + }, + "peerDependencies": { + "@babel/core": "^7.20.12" + } + }, + "node_modules/babel-plugin-jsx-dom-expressions/node_modules/@babel/helper-module-imports": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz", + "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/babel-preset-solid": { + "version": "1.9.6", + "resolved": "https://registry.npmjs.org/babel-preset-solid/-/babel-preset-solid-1.9.6.tgz", + "integrity": "sha512-HXTK9f93QxoH8dYn1M2mJdOlWgMsR88Lg/ul6QCZGkNTktjTE5HAf93YxQumHoCudLEtZrU1cFCMFOVho6GqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jsx-dom-expressions": "^0.39.8" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.24.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", + "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001716", + "electron-to-chromium": "^1.5.149", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001717", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001717.tgz", + "integrity": "sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.150", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.150.tgz", + "integrity": "sha512-rOOkP2ZUMx1yL4fCxXQKDHQ8ZXwisb2OycOQVKHgvB3ZI4CvehOd4y2tfnnLDieJ3Zs1RL1Dlp3cMkyIn7nnXA==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", + "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", + "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.4", + "@esbuild/android-arm": "0.25.4", + "@esbuild/android-arm64": "0.25.4", + "@esbuild/android-x64": "0.25.4", + "@esbuild/darwin-arm64": "0.25.4", + "@esbuild/darwin-x64": "0.25.4", + "@esbuild/freebsd-arm64": "0.25.4", + "@esbuild/freebsd-x64": "0.25.4", + "@esbuild/linux-arm": "0.25.4", + "@esbuild/linux-arm64": "0.25.4", + "@esbuild/linux-ia32": "0.25.4", + "@esbuild/linux-loong64": "0.25.4", + "@esbuild/linux-mips64el": "0.25.4", + "@esbuild/linux-ppc64": "0.25.4", + "@esbuild/linux-riscv64": "0.25.4", + "@esbuild/linux-s390x": "0.25.4", + "@esbuild/linux-x64": "0.25.4", + "@esbuild/netbsd-arm64": "0.25.4", + "@esbuild/netbsd-x64": "0.25.4", + "@esbuild/openbsd-arm64": "0.25.4", + "@esbuild/openbsd-x64": "0.25.4", + "@esbuild/sunos-x64": "0.25.4", + "@esbuild/win32-arm64": "0.25.4", + "@esbuild/win32-ia32": "0.25.4", + "@esbuild/win32-x64": "0.25.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/goober": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", + "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/graphql": { + "version": "16.11.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", + "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-request": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-6.1.0.tgz", + "integrity": "sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0", + "cross-fetch": "^3.1.5" + }, + "peerDependencies": { + "graphql": "14 - 16" + } + }, + "node_modules/html-entities": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", + "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-what": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge-anything": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/merge-anything/-/merge-anything-5.1.7.tgz", + "integrity": "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.2.tgz", + "integrity": "sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.7" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.40.2", + "@rollup/rollup-android-arm64": "4.40.2", + "@rollup/rollup-darwin-arm64": "4.40.2", + "@rollup/rollup-darwin-x64": "4.40.2", + "@rollup/rollup-freebsd-arm64": "4.40.2", + "@rollup/rollup-freebsd-x64": "4.40.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.40.2", + "@rollup/rollup-linux-arm-musleabihf": "4.40.2", + "@rollup/rollup-linux-arm64-gnu": "4.40.2", + "@rollup/rollup-linux-arm64-musl": "4.40.2", + "@rollup/rollup-linux-loongarch64-gnu": "4.40.2", + "@rollup/rollup-linux-powerpc64le-gnu": "4.40.2", + "@rollup/rollup-linux-riscv64-gnu": "4.40.2", + "@rollup/rollup-linux-riscv64-musl": "4.40.2", + "@rollup/rollup-linux-s390x-gnu": "4.40.2", + "@rollup/rollup-linux-x64-gnu": "4.40.2", + "@rollup/rollup-linux-x64-musl": "4.40.2", + "@rollup/rollup-win32-arm64-msvc": "4.40.2", + "@rollup/rollup-win32-ia32-msvc": "4.40.2", + "@rollup/rollup-win32-x64-msvc": "4.40.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/seroval": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.0.tgz", + "integrity": "sha512-4tYQDy3HVM0JjJ1CfDK3K8FhBKIDDri27oc2AyabuuHfQw6/yTDPp2Abt1h2cNtf1R0T+7AQYAzPhUgqXztaXw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.0.tgz", + "integrity": "sha512-FFu/UE3uA8L1vj0CXXZo2Nlh10MtYoOs0G//ptwlQMjfPFSeIVYUNy0zewfV8iM0CrOebAfHEG6J3xA9c+lsaQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/solid-js": { + "version": "1.9.6", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.6.tgz", + "integrity": "sha512-PoasAJvLk60hRtOTe9ulvALOdLjjqxuxcGZRolBQqxOnXrBXHGzqMT4ijNhGsDAYdOgEa8ZYaAE94PSldrFSkA==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.0", + "seroval": "^1.1.0", + "seroval-plugins": "^1.1.0" + } + }, + "node_modules/solid-refresh": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/solid-refresh/-/solid-refresh-0.6.3.tgz", + "integrity": "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.23.6", + "@babel/helper-module-imports": "^7.22.15", + "@babel/types": "^7.23.6" + }, + "peerDependencies": { + "solid-js": "^1.3" + } + }, + "node_modules/solid-styled-components": { + "version": "0.28.5", + "resolved": "https://registry.npmjs.org/solid-styled-components/-/solid-styled-components-0.28.5.tgz", + "integrity": "sha512-vwTcdp76wZNnESIzB6rRZ3U55NgcSAQXCiiRIiEFhxTFqT0bEh/warNT1qaRZu4OkAzrBkViOngF35ktI8sc4A==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.0", + "goober": "^2.1.10" + }, + "peerDependencies": { + "solid-js": "^1.4.4" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/terser": { + "version": "5.39.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", + "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/validate-html-nesting": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/validate-html-nesting/-/validate-html-nesting-1.2.2.tgz", + "integrity": "sha512-hGdgQozCsQJMyfK5urgFcWEqsSSrK63Awe0t/IMR0bZ0QMtnuaiHzThW81guu3qx9abLi99NEuiaN6P9gVYsNg==", + "dev": true, + "license": "ISC" + }, + "node_modules/vite": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-solid": { + "version": "2.11.6", + "resolved": "https://registry.npmjs.org/vite-plugin-solid/-/vite-plugin-solid-2.11.6.tgz", + "integrity": "sha512-Sl5CTqJTGyEeOsmdH6BOgalIZlwH3t4/y0RQuFLMGnvWMBvxb4+lq7x3BSiAw6etf0QexfNJW7HSOO/Qf7pigg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.3", + "@types/babel__core": "^7.20.4", + "babel-preset-solid": "^1.8.4", + "merge-anything": "^5.1.7", + "solid-refresh": "^0.6.3", + "vitefu": "^1.0.4" + }, + "peerDependencies": { + "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", + "solid-js": "^1.7.2", + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "@testing-library/jest-dom": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.6.tgz", + "integrity": "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..e27628dd --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "publy-admin", + "version": "0.4.20", + "private": true, + "description": "admin panel", + "scripts": { + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "lint": "biome check .", + "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" + }, + "dependencies": { + "@solidjs/router": "^0.15.0", + "@solid-primitives/storage": "^4.3.0", + "graphql": "^16.8.0", + "graphql-request": "^6.1.0", + "solid-js": "^1.9.6", + "solid-styled-components": "^0.28.0" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "@biomejs/biome": "^1.9.4", + "typescript": "^5.8.0", + "vite": "^6.3.0", + "vite-plugin-solid": "^2.11.0", + "terser": "^5.39.0" + }, + "exports": { + ".": { + "import": "./dist/auth.es.js", + "require": "./dist/auth.umd.js" + } + } +} diff --git a/panel/App.tsx b/panel/App.tsx new file mode 100644 index 00000000..8199930a --- /dev/null +++ b/panel/App.tsx @@ -0,0 +1,111 @@ +import { Route, Router, RouteSectionProps } from '@solidjs/router' +import { Component, Suspense, lazy } from 'solid-js' +import { isAuthenticated } from './auth' + +// Ленивая загрузка компонентов +const LoginPage = lazy(() => import('./login')) +const AdminPage = lazy(() => import('./admin')) + +/** + * Компонент корневого шаблона приложения + * @param props - Свойства маршрута, включающие дочерние элементы + */ +const RootLayout: Component = (props) => { + return ( +
+ {/* Здесь может быть общий хедер, футер или другие элементы */} + {props.children} +
+ ) +} + +/** + * Компонент защиты маршрутов + * Проверяет авторизацию и либо показывает дочерние элементы, + * либо перенаправляет на страницу входа + */ +const RequireAuth: Component = (props) => { + const authed = isAuthenticated() + + if (!authed) { + // Если не авторизован, перенаправляем на /login + window.location.href = '/login' + return ( +
+
+

Перенаправление на страницу входа...

+
+ ) + } + + return <>{props.children} +} + +/** + * Компонент для публичных маршрутов с редиректом, + * если пользователь уже авторизован + */ +const PublicOnlyRoute: Component = (props) => { + // Если пользователь авторизован, перенаправляем на админ-панель + if (isAuthenticated()) { + window.location.href = '/admin' + return ( +
+
+

Перенаправление в админ-панель...

+
+ ) + } + + return <>{props.children} +} + +/** + * Компонент перенаправления с корневого маршрута + */ +const RootRedirect: Component = () => { + const authenticated = isAuthenticated() + + // Выполняем перенаправление сразу после рендеринга + setTimeout(() => { + window.location.href = authenticated ? '/admin' : '/login' + }, 100) + + return ( +
+
+

Перенаправление...

+
+ ) +} + +/** + * Корневой компонент приложения с настроенными маршрутами + */ +const App: Component = () => { + return ( + + +
+

Загрузка...

+ + }> + {/* Корневой маршрут с перенаправлением */} + + + {/* Маршрут логина (только для неавторизованных) */} + + + + + {/* Защищенные маршруты (только для авторизованных) */} + + + +
+
+ ) +} + +export default App diff --git a/panel/admin.tsx b/panel/admin.tsx new file mode 100644 index 00000000..16d831a1 --- /dev/null +++ b/panel/admin.tsx @@ -0,0 +1,676 @@ +/** + * Компонент страницы администратора + * @module AdminPage + */ + +import { useNavigate } from '@solidjs/router' +import { Component, For, Show, createEffect, createSignal, onCleanup, onMount } from 'solid-js' +import { query } from './graphql' +import { isAuthenticated, logout } from './auth' + +/** + * Интерфейс для данных пользователя + */ +interface User { + id: number + email: string + name?: string + slug?: string + roles: string[] + created_at?: number + last_seen?: number + muted: boolean + is_active: boolean +} + +/** + * Интерфейс для роли пользователя + */ +interface Role { + id: number + name: string + description?: string +} + +/** + * Интерфейс для ответа API с пользователями + */ +interface AdminGetUsersResponse { + adminGetUsers: { + users: User[] + total: number + page: number + perPage: number + totalPages: number + } +} + +/** + * Интерфейс для ответа API с ролями + */ +interface AdminGetRolesResponse { + adminGetRoles: Role[] +} + +/** + * Компонент страницы администратора + */ +const AdminPage: Component = () => { + const [activeTab, setActiveTab] = createSignal('users') + const [users, setUsers] = createSignal([]) + const [roles, setRoles] = createSignal([]) + const [loading, setLoading] = createSignal(true) + const [error, setError] = createSignal(null) + const [selectedUser, setSelectedUser] = createSignal(null) + const [showRolesModal, setShowRolesModal] = createSignal(false) + const [successMessage, setSuccessMessage] = createSignal(null) + + // Параметры пагинации + const [pagination, setPagination] = createSignal<{ + page: number + limit: number + total: number + totalPages: number + }>({ + page: 1, + limit: 10, + total: 0, + totalPages: 1 + }) + + // Поиск + const [searchQuery, setSearchQuery] = createSignal('') + + const navigate = useNavigate() + + // Периодическая проверка авторизации + onMount(() => { + // Загружаем данные при монтировании + loadUsers() + loadRoles() + }) + + /** + * Загрузка списка пользователей с учетом пагинации и поиска + */ + async function loadUsers() { + setLoading(true) + setError(null) + + try { + const { page, limit } = pagination() + const offset = (page - 1) * limit + const search = searchQuery().trim() + + const data = await query( + ` + query AdminGetUsers($limit: Int, $offset: Int, $search: String) { + adminGetUsers(limit: $limit, offset: $offset, search: $search) { + users { + id + email + name + slug + roles + created_at + last_seen + muted + is_active + } + total + page + perPage + totalPages + } + } + `, + { limit, offset, search: search || null } + ) + + if (data?.adminGetUsers) { + setUsers(data.adminGetUsers.users) + setPagination({ + page: data.adminGetUsers.page, + limit: data.adminGetUsers.perPage, + total: data.adminGetUsers.total, + totalPages: data.adminGetUsers.totalPages + }) + } + } catch (err) { + console.error('Ошибка загрузки пользователей:', err) + setError(err instanceof Error ? err.message : 'Неизвестная ошибка') + + // Если ошибка авторизации - перенаправляем на логин + if ( + err instanceof Error && + (err.message.includes('401') || + err.message.includes('авторизации') || + err.message.includes('unauthorized') || + err.message.includes('Unauthorized')) + ) { + handleLogout() + } + } finally { + setLoading(false) + } + } + + /** + * Загрузка списка ролей + */ + async function loadRoles() { + try { + const data = await query(` + query AdminGetRoles { + adminGetRoles { + id + name + description + } + } + `) + + if (data?.adminGetRoles) { + setRoles(data.adminGetRoles) + } + } catch (err) { + console.error('Ошибка загрузки ролей:', err) + // Если ошибка авторизации - перенаправляем на логин + if ( + err instanceof Error && + (err.message.includes('401') || + err.message.includes('авторизации') || + err.message.includes('unauthorized') || + err.message.includes('Unauthorized')) + ) { + handleLogout() + } + } + } + + /** + * Обработчик изменения страницы + * @param page - Номер страницы + */ + function handlePageChange(page: number) { + if (page < 1 || page > pagination().totalPages) return + setPagination((prev) => ({ ...prev, page })) + loadUsers() + } + + /** + * Обработчик изменения количества записей на странице + * @param limit - Количество записей на странице + */ + function handlePerPageChange(limit: number) { + setPagination((prev) => ({ ...prev, page: 1, limit })) + loadUsers() + } + + /** + * Обработчик изменения поискового запроса + * @param e - Событие изменения ввода + */ + function handleSearchChange(e: Event) { + const target = e.target as HTMLInputElement + setSearchQuery(target.value) + } + + /** + * Выполняет поиск при нажатии Enter или кнопки поиска + */ + function handleSearch() { + setPagination((prev) => ({ ...prev, page: 1 })) // Сбрасываем на первую страницу при поиске + loadUsers() + } + + /** + * Обработчик нажатия клавиши в поле поиска + * @param e - Событие нажатия клавиши + */ + function handleSearchKeyDown(e: KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault() + handleSearch() + } + } + + /** + * Блокировка/разблокировка пользователя + * @param userId - ID пользователя + * @param isActive - Текущий статус активности + */ + async function toggleUserBlock(userId: number, isActive: boolean) { + // Запрашиваем подтверждение + const action = isActive ? 'заблокировать' : 'разблокировать' + if (!confirm(`Вы действительно хотите ${action} этого пользователя?`)) { + return + } + + try { + await query( + ` + mutation AdminToggleUserBlock($userId: Int!) { + adminToggleUserBlock(userId: $userId) { + success + error + } + } + `, + { userId } + ) + + // Обновляем статус пользователя + setUsers((prev) => + prev.map((user) => { + if (user.id === userId) { + return { ...user, is_active: !isActive } + } + return user + }) + ) + + // Показываем сообщение об успехе + setSuccessMessage(`Пользователь успешно ${isActive ? 'заблокирован' : 'разблокирован'}`) + + // Скрываем сообщение через 3 секунды + setTimeout(() => setSuccessMessage(null), 3000) + } catch (err) { + console.error('Ошибка изменения статуса блокировки:', err) + setError(err instanceof Error ? err.message : 'Ошибка изменения статуса блокировки') + } + } + + /** + * Включение/отключение режима "mute" для пользователя + * @param userId - ID пользователя + * @param isMuted - Текущий статус mute + */ + async function toggleUserMute(userId: number, isMuted: boolean) { + // Запрашиваем подтверждение + const action = isMuted ? 'включить звук' : 'отключить звук' + if (!confirm(`Вы действительно хотите ${action} для этого пользователя?`)) { + return + } + + try { + await query( + ` + mutation AdminToggleUserMute($userId: Int!) { + adminToggleUserMute(userId: $userId) { + success + error + } + } + `, + { userId } + ) + + // Обновляем статус пользователя + setUsers((prev) => + prev.map((user) => { + if (user.id === userId) { + return { ...user, muted: !isMuted } + } + return user + }) + ) + + // Показываем сообщение об успехе + setSuccessMessage(`Звук для пользователя успешно ${isMuted ? 'включен' : 'отключен'}`) + + // Скрываем сообщение через 3 секунды + setTimeout(() => setSuccessMessage(null), 3000) + } catch (err) { + console.error('Ошибка изменения статуса mute:', err) + setError(err instanceof Error ? err.message : 'Ошибка изменения статуса mute') + } + } + + /** + * Закрывает модальное окно управления ролями + */ + function closeRolesModal() { + setShowRolesModal(false) + setSelectedUser(null) + } + + /** + * Обновляет роли пользователя + * @param userId - ID пользователя + * @param roles - Новый список ролей + */ + async function updateUserRoles(userId: number, newRoles: string[]) { + try { + await query( + ` + mutation AdminUpdateUser($userId: Int!, $input: AdminUserUpdateInput!) { + adminUpdateUser(userId: $userId, input: $input) { + success + error + } + } + `, + { + userId, + input: { roles: newRoles } + } + ) + + // Обновляем роли пользователя в списке + setUsers((prev) => + prev.map((user) => { + if (user.id === userId) { + return { ...user, roles: newRoles } + } + return user + }) + ) + + // Закрываем модальное окно + closeRolesModal() + + // Показываем сообщение об успехе + setSuccessMessage('Роли пользователя успешно обновлены') + + // Скрываем сообщение через 3 секунды + setTimeout(() => setSuccessMessage(null), 3000) + } catch (err) { + console.error('Ошибка обновления ролей:', err) + setError(err instanceof Error ? err.message : 'Ошибка обновления ролей') + } + } + + /** + * Выход из системы + */ + function handleLogout() { + // Сначала выполняем локальные действия по очистке данных + setUsers([]) + setRoles([]) + + // Затем выполняем выход + logout(() => { + // Для гарантии перенаправления после выхода + window.location.href = '/login' + }) + } + + /** + * Форматирование даты + * @param timestamp - Временная метка + */ + function formatDate(timestamp?: number): string { + if (!timestamp) return 'Н/Д' + return new Date(timestamp * 1000).toLocaleString('ru') + } + + /** + * Формирует массив номеров страниц для отображения в пагинации + * @returns Массив номеров страниц + */ + function getPageNumbers(): number[] { + const result: number[] = [] + const maxVisible = 5 // Максимальное количество видимых номеров страниц + + const paginationData = pagination() + const currentPage = paginationData.page + const totalPages = paginationData.totalPages + + let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2)) + const endPage = Math.min(totalPages, startPage + maxVisible - 1) + + // Если endPage достиг предела, сдвигаем startPage назад + if (endPage - startPage + 1 < maxVisible && startPage > 1) { + startPage = Math.max(1, endPage - maxVisible + 1) + } + + // Генерируем номера страниц + for (let i = startPage; i <= endPage; i++) { + result.push(i) + } + + return result + } + + /** + * Компонент пагинации + */ + const Pagination: Component = () => { + const paginationData = pagination() + const currentPage = paginationData.page + const total = paginationData.totalPages + + return ( + + ) + } + + /** + * Компонент модального окна для управления ролями + */ + const RolesModal: Component = () => { + const user = selectedUser() + const [selectedRoles, setSelectedRoles] = createSignal(user ? [...user.roles] : []) + + const toggleRole = (role: string) => { + const current = selectedRoles() + if (current.includes(role)) { + setSelectedRoles(current.filter((r) => r !== role)) + } else { + setSelectedRoles([...current, role]) + } + } + + const saveRoles = () => { + if (user) { + updateUserRoles(user.id, selectedRoles()) + } + } + + if (!user) return null + + return ( + + ) + } + + return ( +
+
+
+

Панель администратора

+ +
+ + +
+ +
+ +
{error()}
+
+ + +
{successMessage()}
+
+ + +
Загрузка данных...
+
+ + +
Нет данных для отображения
+
+ + 0}> +
+
+
+ + +
+
+
+ +
+ + + + + + + + + + + + + + + + {(user) => ( + + + + + + + + + + + )} + + +
IDEmailИмяРолиСозданПоследний входСтатусДействия
{user.id}{user.email}{user.name || '-'}{user.roles.join(', ') || '-'}{formatDate(user.created_at)}{formatDate(user.last_seen)} + + {user.is_active ? 'Активен' : 'Заблокирован'} + + + + +
+
+ + +
+
+ + + + +
+ ) +} + +export default AdminPage diff --git a/panel/auth.ts b/panel/auth.ts new file mode 100644 index 00000000..ae27b2f8 --- /dev/null +++ b/panel/auth.ts @@ -0,0 +1,143 @@ +/** + * Модуль авторизации + * @module auth + */ + +import { query } from './graphql' + +/** + * Интерфейс для учетных данных + */ +export interface Credentials { + email: string + password: string +} + +/** + * Интерфейс для результата авторизации + */ +export interface LoginResult { + success: boolean + token?: string + error?: string +} + +/** + * Интерфейс для ответа API при логине + */ +interface LoginResponse { + login: LoginResult +} + +/** + * Константа для имени ключа токена в localStorage + */ +const AUTH_TOKEN_KEY = 'auth_token' + +/** + * Константа для имени ключа токена в cookie + */ +const AUTH_COOKIE_NAME = 'auth_token' + +/** + * Получает токен авторизации из cookie + * @returns Токен или пустую строку, если токен не найден + */ +function getAuthTokenFromCookie(): string { + const cookieItems = document.cookie.split(';') + for (const item of cookieItems) { + const [name, value] = item.trim().split('=') + if (name === AUTH_COOKIE_NAME) { + return value + } + } + return '' +} + +/** + * Проверяет, авторизован ли пользователь + * @returns Статус авторизации + */ +export function isAuthenticated(): boolean { + // Проверяем наличие cookie auth_token + const cookieToken = getAuthTokenFromCookie() + const hasCookie = !!cookieToken && cookieToken.length > 10 + + // Проверяем наличие токена в localStorage + const localToken = localStorage.getItem(AUTH_TOKEN_KEY) + const hasLocalToken = !!localToken && localToken.length > 10 + + // Пользователь авторизован, если есть cookie или токен в localStorage + return hasCookie || hasLocalToken +} + +/** + * Выполняет выход из системы + * @param callback - Функция обратного вызова после выхода + */ +export function logout(callback?: () => void): void { + // Очищаем токен из localStorage + localStorage.removeItem(AUTH_TOKEN_KEY) + + // Для удаления cookie устанавливаем ей истекшее время жизни + document.cookie = `${AUTH_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;` + + // Дополнительно пытаемся сделать запрос на сервер для удаления серверных сессий + try { + fetch('/logout', { + method: 'GET', + credentials: 'include' + }).catch(e => { + console.error('Ошибка при запросе на выход:', e) + }) + } catch (e) { + console.error('Ошибка при выходе:', e) + } + + // Вызываем функцию обратного вызова после очистки токенов + if (callback) callback() +} + +/** + * Выполняет вход в систему + * @param credentials - Учетные данные + * @returns Результат авторизации + */ +export async function login(credentials: Credentials): Promise { + try { + // Используем query из graphql.ts для выполнения запроса + const data = await query( + ` + mutation Login($email: String!, $password: String!) { + login(email: $email, password: $password) { + success + token + error + } + } + `, + { + email: credentials.email, + password: credentials.password + } + ) + + if (data?.login?.success) { + // Проверяем, установил ли сервер cookie + const cookieToken = getAuthTokenFromCookie() + const hasCookie = !!cookieToken && cookieToken.length > 10 + + // Если cookie не установлена, но есть токен в ответе, сохраняем его в localStorage + if (!hasCookie && data.login.token) { + localStorage.setItem(AUTH_TOKEN_KEY, data.login.token) + } + + return true + } + + throw new Error(data?.login?.error || 'Ошибка авторизации') + } catch (error) { + console.error('Ошибка при входе:', error) + throw error + } +} diff --git a/panel/graphql.ts b/panel/graphql.ts new file mode 100644 index 00000000..7368fcbf --- /dev/null +++ b/panel/graphql.ts @@ -0,0 +1,189 @@ +/** + * API-клиент для работы с GraphQL + * @module api + */ + +/** + * Базовый URL для API + */ +// Всегда используем абсолютный путь к API +const API_URL = window.location.origin + '/graphql' + +/** + * Константа для имени ключа токена в localStorage + */ +const AUTH_TOKEN_KEY = 'auth_token' + +/** + * Тип для произвольных данных GraphQL + */ +type GraphQLData = Record + +/** + * Получает токен авторизации из cookie + * @returns Токен или пустую строку, если токен не найден + */ +function getAuthTokenFromCookie(): string { + const cookieItems = document.cookie.split(';') + for (const item of cookieItems) { + const [name, value] = item.trim().split('=') + if (name === 'auth_token') { + return value + } + } + return '' +} + +/** + * Обрабатывает ошибки от API + * @param response - Ответ от сервера + * @returns Обработанный текст ошибки + */ +async function handleApiError(response: Response): Promise { + try { + const contentType = response.headers.get('content-type') + + if (contentType?.includes('application/json')) { + const errorData = await response.json() + + // Проверяем GraphQL ошибки + if (errorData.errors && errorData.errors.length > 0) { + return errorData.errors[0].message + } + + // Проверяем сообщение об ошибке + if (errorData.error || errorData.message) { + return errorData.error || errorData.message + } + } + + // Если не JSON или нет структурированной ошибки, читаем как текст + const errorText = await response.text() + return `Ошибка сервера: ${response.status} ${response.statusText}. ${errorText.substring(0, 100)}...` + } catch (_e) { + // Если не можем прочитать ответ + return `Ошибка сервера: ${response.status} ${response.statusText}` + } +} + +/** + * Проверяет наличие ошибок авторизации в ответе GraphQL + * @param errors - Массив ошибок GraphQL + * @returns true если есть ошибки авторизации + */ +function hasAuthErrors(errors: Array<{ message?: string; extensions?: { code?: string } }>): boolean { + return errors.some( + (error) => + (error.message && ( + error.message.toLowerCase().includes('unauthorized') || + error.message.toLowerCase().includes('авторизации') || + error.message.toLowerCase().includes('authentication') || + error.message.toLowerCase().includes('unauthenticated') || + error.message.toLowerCase().includes('token') + )) || + error.extensions?.code === 'UNAUTHENTICATED' || + error.extensions?.code === 'FORBIDDEN' + ) +} + +/** + * Выполняет GraphQL запрос + * @param query - GraphQL запрос + * @param variables - Переменные запроса + * @returns Результат запроса + */ +export async function query( + query: string, + variables: Record = {} +): Promise { + try { + const headers: Record = { + 'Content-Type': 'application/json' + } + + // Проверяем наличие токена в localStorage + const localToken = localStorage.getItem(AUTH_TOKEN_KEY) + + // Проверяем наличие токена в cookie + const cookieToken = getAuthTokenFromCookie() + + // Используем токен из localStorage или cookie + const token = localToken || cookieToken + + // Если есть токен, добавляем его в заголовок Authorization с префиксом Bearer + if (token && token.length > 10) { + // В соответствии с логами сервера, формат должен быть: Bearer + headers['Authorization'] = `Bearer ${token}` + // Для отладки + console.debug('Отправка запроса с токеном авторизации') + } + + const response = await fetch(API_URL, { + method: 'POST', + headers, + // Важно: credentials: 'include' - для передачи cookies с запросом + credentials: 'include', + body: JSON.stringify({ + query, + variables + }) + }) + + // Проверяем статус ответа + if (!response.ok) { + const errorMessage = await handleApiError(response) + console.error('Ошибка API:', { + status: response.status, + statusText: response.statusText, + error: errorMessage + }) + + // Если получен 401 Unauthorized, перенаправляем на страницу входа + if (response.status === 401) { + localStorage.removeItem(AUTH_TOKEN_KEY) + window.location.href = '/login' + throw new Error('Unauthorized') + } + + throw new Error(errorMessage) + } + + // Проверяем, что ответ содержит JSON + const contentType = response.headers.get('content-type') + if (!contentType?.includes('application/json')) { + const text = await response.text() + throw new Error(`Неверный формат ответа: ${text.substring(0, 100)}...`) + } + + const result = await response.json() + + if (result.errors) { + // Проверяем ошибки на признаки проблем с авторизацией + if (hasAuthErrors(result.errors)) { + localStorage.removeItem(AUTH_TOKEN_KEY) + window.location.href = '/login' + throw new Error('Unauthorized') + } + + throw new Error(result.errors[0].message) + } + + return result.data as T + } catch (error) { + console.error('API Error:', error) + throw error + } +} + +/** + * Выполняет GraphQL мутацию + * @param mutation - GraphQL мутация + * @param variables - Переменные мутации + * @returns Результат мутации + */ +export function mutate( + mutation: string, + variables: Record = {} +): Promise { + return query(mutation, variables) +} diff --git a/panel/index.tsx b/panel/index.tsx new file mode 100644 index 00000000..3590e57e --- /dev/null +++ b/panel/index.tsx @@ -0,0 +1,12 @@ +/** + * Точка входа в клиентское приложение + * @module index + */ + +import { render } from 'solid-js/web' +import App from './App' + +import './styles.css' + +// Рендеринг приложения в корневой элемент +render(() => , document.getElementById('root') as HTMLElement) diff --git a/panel/login.tsx b/panel/login.tsx new file mode 100644 index 00000000..da94d288 --- /dev/null +++ b/panel/login.tsx @@ -0,0 +1,112 @@ +/** + * Компонент страницы входа + * @module LoginPage + */ + +import { useNavigate } from '@solidjs/router' +import { Component, createSignal, onMount } from 'solid-js' +import { login, isAuthenticated } from './auth' + +/** + * Компонент страницы входа + */ +const LoginPage: Component = () => { + const [email, setEmail] = createSignal('') + const [password, setPassword] = createSignal('') + const [isLoading, setIsLoading] = createSignal(false) + const [error, setError] = createSignal(null) + const navigate = useNavigate() + + /** + * Проверка авторизации при загрузке компонента + * и перенаправление если пользователь уже авторизован + */ + onMount(() => { + // Если пользователь уже авторизован, перенаправляем на админ-панель + if (isAuthenticated()) { + window.location.href = '/admin' + } + }) + + /** + * Обработчик отправки формы входа + * @param e - Событие отправки формы + */ + const handleSubmit = async (e: Event) => { + e.preventDefault() + + // Очищаем пробелы в email + const cleanEmail = email().trim() + + if (!cleanEmail || !password()) { + setError('Пожалуйста, заполните все поля') + return + } + + setIsLoading(true) + setError(null) + + try { + // Используем функцию login из модуля auth + const loginSuccessful = await login({ + email: cleanEmail, + password: password() + }) + + if (loginSuccessful) { + // Используем прямое перенаправление для надежности + window.location.href = '/admin' + } else { + throw new Error('Вход не выполнен') + } + } catch (err) { + console.error('Ошибка при входе:', err) + setError(err instanceof Error ? err.message : 'Неизвестная ошибка') + setIsLoading(false) + } + } + + return ( + + ) +} + +export default LoginPage diff --git a/panel/styles.css b/panel/styles.css new file mode 100644 index 00000000..990465e1 --- /dev/null +++ b/panel/styles.css @@ -0,0 +1,587 @@ +/** + * Основные стили приложения + */ + +/* Сброс стилей */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* Общие стили */ +:root { + --primary-color: #3498db; + --primary-dark: #2980b9; + --success-color: #2ecc71; + --success-light: #d1fae5; + --danger-color: #e74c3c; + --danger-light: #fee2e2; + --warning-color: #f39c12; + --warning-light: #fef3c7; + --text-color: #333; + --bg-color: #f5f5f5; + --card-bg: #fff; + --border-color: #ddd; +} + +body { + font-family: system-ui, -apple-system, sans-serif; + margin: 0; + padding: 0; + background-color: var(--bg-color); + color: var(--text-color); +} + +/* Общие элементы интерфейса */ +.loading-screen, .loading { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 200px; + padding: 20px; + text-align: center; + color: var(--primary-color); +} + +.loading-spinner { + border: 4px solid rgba(0, 0, 0, 0.1); + border-left-color: var(--primary-color); + border-radius: 50%; + width: 40px; + height: 40px; + margin-bottom: 20px; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.error-message { + background-color: var(--danger-light); + border-left: 4px solid var(--danger-color); + color: var(--danger-color); + padding: 10px; + margin-bottom: 20px; + border-radius: 4px; +} + +.success-message { + background-color: var(--success-light); + border-left: 4px solid var(--success-color); + color: var(--success-color); + padding: 10px; + margin-bottom: 20px; + border-radius: 4px; +} + +.empty-state { + text-align: center; + padding: 40px; + color: #999; + font-style: italic; +} + +/* Стили для формы и кнопок */ +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + font-weight: 500; +} + +.form-group input { + width: 100%; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 16px; +} + +button { + background-color: var(--primary-color); + color: white; + border: none; + border-radius: 4px; + padding: 10px 15px; + font-size: 16px; + cursor: pointer; + transition: background-color 0.2s; + width: 100%; +} + +button:hover { + background-color: var(--primary-dark); +} + +button:disabled { + background-color: #ccc; + cursor: not-allowed; +} + +/* Стили для страницы входа */ +.login-page { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + padding: 20px; +} + +.login-container { + background-color: var(--card-bg); + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + padding: 30px; + width: 100%; + max-width: 400px; +} + +.login-container h1 { + margin-top: 0; + margin-bottom: 20px; + text-align: center; + color: var(--primary-color); +} + +/* Стили для админ-панели */ +.admin-page { + max-width: 1200px; + margin: 0 auto; + padding: 20px; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +header { + background-color: var(--card-bg); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + padding: 15px 20px; +} + +.header-container { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + max-width: 1200px; + margin-left: auto; + margin-right: auto; + width: 100%; +} + +header h1 { + margin: 0; + color: var(--primary-color); + font-size: 24px; +} + +.logout-button { + background-color: transparent; + color: var(--danger-color); + border: 1px solid var(--danger-color); + width: auto; + padding: 8px 16px; + font-size: 14px; +} + +.logout-button:hover { + background-color: var(--danger-color); + color: white; +} + +.admin-tabs { + display: flex; + border-bottom: 1px solid #ddd; + margin-bottom: 1.5rem; + gap: 10px; + max-width: 1200px; + margin-left: auto; + margin-right: auto; +} + +.admin-tabs button { + background: none; + border: none; + padding: 8px 16px; + cursor: pointer; + font-size: 16px; + border-bottom: 3px solid transparent; + transition: all 0.2s; + width: auto; + color: var(--text-color); +} + +.admin-tabs button.active { + border-bottom-color: var(--primary-color); + color: var(--primary-color); + font-weight: 600; + background-color: transparent; +} + +.admin-tabs button:hover { + background-color: rgba(52, 152, 219, 0.1); +} + +main { + padding: 20px; + max-width: 1200px; + margin: 0 auto; + width: 100%; + flex-grow: 1; +} + +/* Таблица пользователей */ +.users-list { + overflow-x: auto; + margin-top: 1rem; +} + +table { + width: 100%; + border-collapse: collapse; + border: 1px solid var(--border-color); +} + +thead { + background-color: #f3f4f6; +} + +th, td { + padding: 10px; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +th { + font-weight: 600; + background-color: #f9f9f9; +} + +tr:hover { + background-color: rgba(52, 152, 219, 0.05); +} + +tr.blocked { + background-color: rgba(231, 76, 60, 0.05); +} + +/* Статусы пользователей */ +.status { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + margin-right: 4px; +} + +.status.active { + background-color: var(--success-light); + color: var(--success-color); +} + +.status.blocked { + background-color: var(--danger-light); + color: var(--danger-color); +} + +.status.muted { + background-color: var(--warning-light); + color: var(--warning-color); +} + +/* Кнопки действий */ +.actions { + display: flex; + gap: 5px; +} + +.actions button { + padding: 5px 10px; + font-size: 12px; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; + width: auto; +} + +button.block { + background-color: var(--danger-color); +} + +button.unblock { + background-color: var(--success-color); +} + +button.mute { + background-color: var(--warning-color); +} + +button.unmute { + background-color: var(--primary-color); +} + +/* Стили для редактирования ролей */ +.roles-container { + display: flex; + align-items: center; + gap: 8px; +} + +.roles-text { + flex: 1; +} + +.edit-roles-button { + background: none; + border: none; + cursor: pointer; + font-size: 16px; + padding: 0; + opacity: 0.6; + transition: opacity 0.2s; + width: auto; + color: var(--primary-color); +} + +.edit-roles-button:hover { + opacity: 1; + background-color: rgba(52, 152, 219, 0.1); + border-radius: 4px; +} + +/* Модальное окно */ +.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: white; + padding: 20px; + border-radius: 8px; + width: 90%; + max-width: 500px; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.modal-content h2 { + margin-top: 0; + color: var(--primary-color); +} + +.roles-list { + margin: 16px 0; +} + +.role-item { + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-color); +} + +.role-item:last-child { + border-bottom: none; +} + +.role-item label { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; +} + +.role-description { + margin-top: 4px; + margin-left: 24px; + font-size: 14px; + color: #6b7280; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; +} + +.cancel-button { + padding: 8px 16px; + background-color: #ccc; + color: #333; + width: auto; +} + +.save-button { + padding: 8px 16px; + background-color: var(--primary-color); + width: auto; +} + +.save-button:hover { + background-color: var(--primary-dark); +} + +/* Стили для пагинации */ +.pagination { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 20px; + padding: 10px 0; + flex-wrap: wrap; + gap: 10px; +} + +.pagination-info { + color: #6b7280; + font-size: 14px; +} + +.pagination-controls { + display: flex; + gap: 5px; + align-items: center; +} + +.pagination-button { + min-width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 5px; + border: 1px solid var(--border-color); + background-color: white; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.pagination-button:hover:not(:disabled) { + background-color: #f3f4f6; + border-color: #d1d5db; +} + +.pagination-button.active { + background-color: var(--primary-color); + color: white; + border-color: var(--primary-color); +} + +.pagination-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pagination-ellipsis { + padding: 0 8px; + color: #6b7280; +} + +.pagination-per-page { + display: flex; + align-items: center; + font-size: 14px; + color: #6b7280; +} + +.pagination-per-page select { + margin-left: 8px; + padding: 4px 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + background-color: white; +} + +/* Поиск */ +.users-controls { + margin-bottom: 16px; +} + +.search-container { + max-width: 500px; + width: 100%; +} + +.search-input-group { + display: flex; + width: 100%; +} + +.search-input { + flex: 1; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: 4px 0 0 4px; + font-size: 14px; +} + +.search-input:focus { + border-color: var(--primary-color); + outline: none; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3); +} + +.search-button { + padding: 8px 16px; + background-color: var(--primary-color); + color: white; + border: none; + border-radius: 0 4px 4px 0; + cursor: pointer; + transition: background-color 0.2s; + font-size: 14px; +} + +.search-button:hover { + background-color: var(--primary-dark); +} + +/* Адаптивные стили */ +@media (max-width: 768px) { + .pagination { + flex-direction: column; + align-items: start; + } + + .actions { + flex-direction: column; + } + + .users-list { + font-size: 14px; + } + + th, td { + padding: 8px 5px; + } + + .pagination-per-page { + margin-top: 10px; + } + + .header-container { + flex-direction: column; + gap: 10px; + } +} \ No newline at end of file diff --git a/resolvers/author.py b/resolvers/author.py index 91bad2e5..aa866b94 100644 --- a/resolvers/author.py +++ b/resolvers/author.py @@ -14,7 +14,7 @@ from cache.cache import ( get_cached_follower_topics, invalidate_cache_by_prefix, ) -from orm.author import Author +from auth.orm import Author from resolvers.stat import get_with_stat from services.auth import login_required from services.db import local_session @@ -70,7 +70,9 @@ async def get_authors_with_stats(limit=50, offset=0, by: Optional[str] = None): # Функция для получения авторов из БД async def fetch_authors_with_stats(): - logger.debug(f"Выполняем запрос на получение авторов со статистикой: limit={limit}, offset={offset}, by={by}") + logger.debug( + f"Выполняем запрос на получение авторов со статистикой: limit={limit}, offset={offset}, by={by}" + ) with local_session() as session: # Базовый запрос для получения авторов @@ -80,7 +82,7 @@ async def get_authors_with_stats(limit=50, offset=0, by: Optional[str] = None): if by: if isinstance(by, dict): # Обработка словаря параметров сортировки - from sqlalchemy import asc, desc + from sqlalchemy import desc for field, direction in by.items(): column = getattr(Author, field, None) diff --git a/resolvers/bookmark.py b/resolvers/bookmark.py index c0d996ae..8d21a4ef 100644 --- a/resolvers/bookmark.py +++ b/resolvers/bookmark.py @@ -3,7 +3,7 @@ from operator import and_ from graphql import GraphQLError from sqlalchemy import delete, insert -from orm.author import AuthorBookmark +from auth.orm import AuthorBookmark from orm.shout import Shout from resolvers.feed import apply_options from resolvers.reader import get_shouts_with_links, query_with_stat @@ -72,7 +72,9 @@ def toggle_bookmark_shout(_, info, slug: str) -> CommonResult: if existing_bookmark: db.execute( - delete(AuthorBookmark).where(AuthorBookmark.author == author_id, AuthorBookmark.shout == shout.id) + delete(AuthorBookmark).where( + AuthorBookmark.author == author_id, AuthorBookmark.shout == shout.id + ) ) result = False else: diff --git a/resolvers/collab.py b/resolvers/collab.py index aa1d6bb3..53784f2a 100644 --- a/resolvers/collab.py +++ b/resolvers/collab.py @@ -1,4 +1,4 @@ -from orm.author import Author +from auth.orm import Author from orm.invite import Invite, InviteStatus from orm.shout import Shout from services.auth import login_required diff --git a/resolvers/community.py b/resolvers/community.py index faeaa2dc..6dfbf311 100644 --- a/resolvers/community.py +++ b/resolvers/community.py @@ -1,4 +1,4 @@ -from orm.author import Author +from auth.orm import Author from orm.community import Community, CommunityFollower from services.db import local_session from services.schema import mutation, query @@ -74,9 +74,9 @@ async def update_community(_, info, community_data): if slug: with local_session() as session: try: - session.query(Community).where(Community.created_by == author_id, Community.slug == slug).update( - community_data - ) + session.query(Community).where( + Community.created_by == author_id, Community.slug == slug + ).update(community_data) session.commit() except Exception as e: return {"ok": False, "error": str(e)} @@ -90,7 +90,9 @@ async def delete_community(_, info, slug: str): author_id = author_dict.get("id") with local_session() as session: try: - session.query(Community).where(Community.slug == slug, Community.created_by == author_id).delete() + session.query(Community).where( + Community.slug == slug, Community.created_by == author_id + ).delete() session.commit() return {"ok": True} except Exception as e: diff --git a/resolvers/draft.py b/resolvers/draft.py index a33a0546..63734e40 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -1,26 +1,22 @@ import time -import trafilatura from sqlalchemy.orm import joinedload from cache.cache import ( - cache_author, - cache_by_id, - cache_topic, invalidate_shout_related_cache, invalidate_shouts_cache, ) -from orm.author import Author +from auth.orm import Author from orm.draft import Draft, DraftAuthor, DraftTopic from orm.shout import Shout, ShoutAuthor, ShoutTopic -from orm.topic import Topic from services.auth import login_required from services.db import local_session from services.notify import notify_shout from services.schema import mutation, query from services.search import search_service -from utils.html_wrapper import wrap_html_fragment +from utils.extract_text import extract_text from utils.logger import root_logger as logger + def create_shout_from_draft(session, draft, author_id): """ Создаёт новый объект публикации (Shout) на основе черновика. @@ -62,11 +58,11 @@ def create_shout_from_draft(session, draft, author_id): draft=draft.id, deleted_at=None, ) - + # Инициализируем пустые массивы для связей shout.topics = [] shout.authors = [] - + return shout @@ -75,10 +71,10 @@ def create_shout_from_draft(session, draft, author_id): async def load_drafts(_, info): """ Загружает все черновики, доступные текущему пользователю. - + Предварительно загружает связанные объекты (topics, authors, publication), чтобы избежать ошибок с отсоединенными объектами при сериализации. - + Returns: dict: Список черновиков или сообщение об ошибке """ @@ -97,12 +93,12 @@ async def load_drafts(_, info): .options( joinedload(Draft.topics), joinedload(Draft.authors), - joinedload(Draft.publication) # Загружаем связанную публикацию + joinedload(Draft.publication), # Загружаем связанную публикацию ) .filter(Draft.authors.any(Author.id == author_id)) ) drafts = drafts_query.all() - + # Преобразуем объекты в словари, пока они в контексте сессии drafts_data = [] for draft in drafts: @@ -110,19 +106,19 @@ async def load_drafts(_, info): # Всегда возвращаем массив для topics, даже если он пустой draft_dict["topics"] = [topic.dict() for topic in (draft.topics or [])] draft_dict["authors"] = [author.dict() for author in (draft.authors or [])] - + # Добавляем информацию о публикации, если она есть if draft.publication: draft_dict["publication"] = { "id": draft.publication.id, "slug": draft.publication.slug, - "published_at": draft.publication.published_at + "published_at": draft.publication.published_at, } else: - draft_dict["publication"] = None - + draft_dict["publication"] = None + drafts_data.append(draft_dict) - + return {"drafts": drafts_data} except Exception as e: logger.error(f"Failed to load drafts: {e}", exc_info=True) @@ -180,27 +176,27 @@ async def create_draft(_, info, draft_input): # Remove id from input if present since it's auto-generated if "id" in draft_input: del draft_input["id"] - + # Добавляем текущее время создания и ID автора draft_input["created_at"] = int(time.time()) draft_input["created_by"] = author_id draft = Draft(**draft_input) session.add(draft) session.flush() - + # Добавляем создателя как автора da = DraftAuthor(shout=draft.id, author=author_id) session.add(da) - + session.commit() return {"draft": draft} except Exception as e: logger.error(f"Failed to create draft: {e}", exc_info=True) return {"error": f"Failed to create draft: {str(e)}"} + def generate_teaser(body, limit=300): - body_html = wrap_html_fragment(body) - body_text = trafilatura.extract(body_html, include_comments=False, include_tables=False) + body_text = extract_text(body) body_teaser = ". ".join(body_text[:limit].split(". ")[:-1]) return body_teaser @@ -246,9 +242,20 @@ async def update_draft(_, info, draft_id: int, draft_input): # Фильтруем входные данные, оставляя только разрешенные поля allowed_fields = { - "layout", "author_ids", "topic_ids", "main_topic_id", - "media", "lead", "subtitle", "lang", "seo", "body", - "title", "slug", "cover", "cover_caption" + "layout", + "author_ids", + "topic_ids", + "main_topic_id", + "media", + "lead", + "subtitle", + "lang", + "seo", + "body", + "title", + "slug", + "cover", + "cover_caption", } filtered_input = {k: v for k, v in draft_input.items() if k in allowed_fields} @@ -277,9 +284,9 @@ async def update_draft(_, info, draft_id: int, draft_input): # Добавляем новые связи for tid in topic_ids: dt = DraftTopic( - shout=draft_id, + shout=draft_id, topic=tid, - main=(tid == main_topic_id) if main_topic_id else False + main=(tid == main_topic_id) if main_topic_id else False, ) session.add(dt) @@ -287,13 +294,10 @@ async def update_draft(_, info, draft_id: int, draft_input): if "seo" not in filtered_input and not draft.seo: body_src = filtered_input.get("body", draft.body) lead_src = filtered_input.get("lead", draft.lead) - body_html = wrap_html_fragment(body_src) - lead_html = wrap_html_fragment(lead_src) - + try: - body_text = trafilatura.extract(body_html, include_comments=False, include_tables=False) if body_src else None - lead_text = trafilatura.extract(lead_html, include_comments=False, include_tables=False) if lead_src else None - + body_text = extract_text(body_src) if body_src else None + lead_text = extract_text(lead_src) if lead_src else None body_teaser = generate_teaser(body_text, 300) if body_text else "" filtered_input["seo"] = lead_text if lead_text else body_teaser except Exception as e: @@ -308,14 +312,14 @@ async def update_draft(_, info, draft_id: int, draft_input): draft.updated_by = author_id session.commit() - + # Преобразуем объект в словарь для ответа draft_dict = draft.dict() draft_dict["topics"] = [topic.dict() for topic in draft.topics] draft_dict["authors"] = [author.dict() for author in draft.authors] # Добавляем объект автора в updated_by draft_dict["updated_by"] = author_dict - + return {"draft": draft_dict} except Exception as e: @@ -343,13 +347,13 @@ async def delete_draft(_, info, draft_id: int): def validate_html_content(html_content: str) -> tuple[bool, str]: """ Проверяет валидность HTML контента через trafilatura. - + Args: html_content: HTML строка для проверки - + Returns: tuple[bool, str]: (валидность, сообщение об ошибке) - + Example: >>> is_valid, error = validate_html_content("

Valid HTML

") >>> is_valid @@ -364,13 +368,10 @@ def validate_html_content(html_content: str) -> tuple[bool, str]: """ if not html_content or not html_content.strip(): return False, "Content is empty" - + try: - html_content = wrap_html_fragment(html_content) - extracted = trafilatura.extract(html_content) - if not extracted: - return False, "Invalid HTML structure or empty content" - return True, "" + extracted = extract_text(html_content) + return bool(extracted), extracted or "" except Exception as e: logger.error(f"HTML validation error: {e}", exc_info=True) return False, f"Invalid HTML content: {str(e)}" @@ -381,10 +382,10 @@ def validate_html_content(html_content: str) -> tuple[bool, str]: async def publish_draft(_, info, draft_id: int): """ Публикует черновик, создавая новый Shout или обновляя существующий. - + Args: draft_id (int): ID черновика для публикации - + Returns: dict: Результат публикации с shout или сообщением об ошибке """ @@ -400,11 +401,7 @@ async def publish_draft(_, info, draft_id: int): # Загружаем черновик со всеми связями draft = ( session.query(Draft) - .options( - joinedload(Draft.topics), - joinedload(Draft.authors), - joinedload(Draft.publication) - ) + .options(joinedload(Draft.topics), joinedload(Draft.authors), joinedload(Draft.publication)) .filter(Draft.id == draft_id) .first() ) @@ -421,7 +418,17 @@ async def publish_draft(_, info, draft_id: int): if draft.publication: shout = draft.publication # Обновляем существующую публикацию - for field in ["body", "title", "subtitle", "lead", "cover", "cover_caption", "media", "lang", "seo"]: + for field in [ + "body", + "title", + "subtitle", + "lead", + "cover", + "cover_caption", + "media", + "lang", + "seo", + ]: if hasattr(draft, field): setattr(shout, field, getattr(draft, field)) shout.updated_at = int(time.time()) @@ -440,16 +447,14 @@ async def publish_draft(_, info, draft_id: int): session.query(ShoutTopic).filter(ShoutTopic.shout == shout.id).delete() # Добавляем авторов - for author in (draft.authors or []): + for author in draft.authors or []: sa = ShoutAuthor(shout=shout.id, author=author.id) session.add(sa) # Добавляем темы - for topic in (draft.topics or []): + for topic in draft.topics or []: st = ShoutTopic( - topic=topic.id, - shout=shout.id, - main=topic.main if hasattr(topic, "main") else False + topic=topic.id, shout=shout.id, main=topic.main if hasattr(topic, "main") else False ) session.add(st) diff --git a/resolvers/editor.py b/resolvers/editor.py index 0fd67c6e..88ca020e 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -1,7 +1,6 @@ import time import orjson -import trafilatura from sqlalchemy import and_, desc, select from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.sql.functions import coalesce @@ -12,7 +11,7 @@ from cache.cache import ( invalidate_shout_related_cache, invalidate_shouts_cache, ) -from orm.author import Author +from auth.orm import Author from orm.draft import Draft from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.topic import Topic @@ -23,7 +22,7 @@ from services.db import local_session from services.notify import notify_shout from services.schema import mutation, query from services.search import search_service -from utils.html_wrapper import wrap_html_fragment +from utils.extract_text import extract_text from utils.logger import root_logger as logger @@ -181,11 +180,11 @@ async def create_shout(_, info, inp): # Создаем публикацию без topics body = inp.get("body", "") lead = inp.get("lead", "") - body_html = wrap_html_fragment(body) - lead_html = wrap_html_fragment(lead) - body_text = trafilatura.extract(body_html) - lead_text = trafilatura.extract(lead_html) - seo = inp.get("seo", lead_text.strip() or body_text.strip()[:300].split(". ")[:-1].join(". ")) + body_text = extract_text(body) + lead_text = extract_text(lead) + seo = inp.get( + "seo", lead_text.strip() or body_text.strip()[:300].split(". ")[:-1].join(". ") + ) new_shout = Shout( slug=slug, body=body, @@ -282,7 +281,9 @@ def patch_main_topic(session, main_topic_slug, shout): with session.begin(): # Получаем текущий главный топик old_main = ( - session.query(ShoutTopic).filter(and_(ShoutTopic.shout == shout.id, ShoutTopic.main.is_(True))).first() + session.query(ShoutTopic) + .filter(and_(ShoutTopic.shout == shout.id, ShoutTopic.main.is_(True))) + .first() ) if old_main: logger.info(f"Found current main topic: {old_main.topic.slug}") @@ -316,7 +317,9 @@ def patch_main_topic(session, main_topic_slug, shout): session.flush() logger.info(f"Main topic updated for shout#{shout.id}") else: - logger.warning(f"No changes needed for main topic (old={old_main is not None}, new={new_main is not None})") + logger.warning( + f"No changes needed for main topic (old={old_main is not None}, new={new_main is not None})" + ) def patch_topics(session, shout, topics_input): @@ -417,7 +420,9 @@ async def update_shout(_, info, shout_id: int, shout_input=None, publish=False): logger.info(f"Processing update for shout#{shout_id} by author #{author_id}") shout_by_id = ( session.query(Shout) - .options(joinedload(Shout.topics).joinedload(ShoutTopic.topic), joinedload(Shout.authors)) + .options( + joinedload(Shout.topics).joinedload(ShoutTopic.topic), joinedload(Shout.authors) + ) .filter(Shout.id == shout_id) .first() ) @@ -446,7 +451,10 @@ async def update_shout(_, info, shout_id: int, shout_input=None, publish=False): shout_input["slug"] = slug logger.info(f"shout#{shout_id} slug patched") - if filter(lambda x: x.id == author_id, [x for x in shout_by_id.authors]) or "editor" in roles: + if ( + filter(lambda x: x.id == author_id, [x for x in shout_by_id.authors]) + or "editor" in roles + ): logger.info(f"Author #{author_id} has permission to edit shout#{shout_id}") # topics patch @@ -560,7 +568,9 @@ async def update_shout(_, info, shout_id: int, shout_input=None, publish=False): # Получаем полные данные шаута со связями shout_with_relations = ( session.query(Shout) - .options(joinedload(Shout.topics).joinedload(ShoutTopic.topic), joinedload(Shout.authors)) + .options( + joinedload(Shout.topics).joinedload(ShoutTopic.topic), joinedload(Shout.authors) + ) .filter(Shout.id == shout_id) .first() ) @@ -648,19 +658,17 @@ async def delete_shout(_, info, shout_id: int): def get_main_topic(topics): """Get the main topic from a list of ShoutTopic objects.""" logger.info(f"Starting get_main_topic with {len(topics) if topics else 0} topics") - logger.debug( - f"Topics data: {[(t.slug, getattr(t, 'main', False)) for t in topics] if topics else []}" - ) + logger.debug(f"Topics data: {[(t.slug, getattr(t, 'main', False)) for t in topics] if topics else []}") if not topics: logger.warning("No topics provided to get_main_topic") return {"id": 0, "title": "no topic", "slug": "notopic", "is_main": True} # Проверяем, является ли topics списком объектов ShoutTopic или Topic - if hasattr(topics[0], 'topic') and topics[0].topic: + if hasattr(topics[0], "topic") and topics[0].topic: # Для ShoutTopic объектов (старый формат) # Find first main topic in original order - main_topic_rel = next((st for st in topics if getattr(st, 'main', False)), None) + main_topic_rel = next((st for st in topics if getattr(st, "main", False)), None) logger.debug( f"Found main topic relation: {main_topic_rel.topic.slug if main_topic_rel and main_topic_rel.topic else None}" ) @@ -701,6 +709,7 @@ def get_main_topic(topics): logger.warning("No valid topics found, returning default") return {"slug": "notopic", "title": "no topic", "id": 0, "is_main": True} + @mutation.field("unpublish_shout") @login_required async def unpublish_shout(_, info, shout_id: int): @@ -727,31 +736,25 @@ async def unpublish_shout(_, info, shout_id: int): # Загружаем Shout со всеми связями для правильного формирования ответа shout = ( session.query(Shout) - .options( - joinedload(Shout.authors), - selectinload(Shout.topics) - ) + .options(joinedload(Shout.authors), selectinload(Shout.topics)) .filter(Shout.id == shout_id) .first() ) - + if not shout: - logger.warning(f"Shout not found for unpublish: ID {shout_id}") - return {"error": "Shout not found"} - + logger.warning(f"Shout not found for unpublish: ID {shout_id}") + return {"error": "Shout not found"} + # Если у публикации есть связанный черновик, загружаем его с relationships if shout.draft: # Отдельно загружаем черновик с его связями draft = ( session.query(Draft) - .options( - selectinload(Draft.authors), - selectinload(Draft.topics) - ) + .options(selectinload(Draft.authors), selectinload(Draft.topics)) .filter(Draft.id == shout.draft) .first() ) - + # Связываем черновик с публикацией вручную для доступа через API if draft: shout.draft_obj = draft @@ -768,38 +771,32 @@ async def unpublish_shout(_, info, shout_id: int): # Снимаем с публикации (устанавливаем published_at в None) shout.published_at = None session.commit() - + # Формируем полноценный словарь для ответа shout_dict = shout.dict() - + # Добавляем связанные данные shout_dict["topics"] = ( - [ - {"id": topic.id, "slug": topic.slug, "title": topic.title} - for topic in shout.topics - ] + [{"id": topic.id, "slug": topic.slug, "title": topic.title} for topic in shout.topics] if shout.topics else [] ) - - # Добавляем main_topic + + # Добавляем main_topic shout_dict["main_topic"] = get_main_topic(shout.topics) - + # Добавляем авторов shout_dict["authors"] = ( - [ - {"id": author.id, "name": author.name, "slug": author.slug} - for author in shout.authors - ] + [{"id": author.id, "name": author.name, "slug": author.slug} for author in shout.authors] if shout.authors else [] ) - + # Важно! Обновляем поле publication, отражая состояние "снят с публикации" shout_dict["publication"] = { "id": shout_id_for_publication, "slug": shout_slug, - "published_at": None # Ключевое изменение - устанавливаем published_at в None + "published_at": None, # Ключевое изменение - устанавливаем published_at в None } # Инвалидация кэша @@ -810,17 +807,17 @@ async def unpublish_shout(_, info, shout_id: int): "random_top", # случайные топовые "unrated", # неоцененные ] - await invalidate_shout_related_cache(shout, author_id) + await invalidate_shout_related_cache(shout, author_id) await invalidate_shouts_cache(cache_keys) logger.info(f"Cache invalidated after unpublishing shout {shout_id}") except Exception as cache_err: - logger.error(f"Failed to invalidate cache for unpublish shout {shout_id}: {cache_err}") + logger.error(f"Failed to invalidate cache for unpublish shout {shout_id}: {cache_err}") - except Exception as e: + except Exception as e: session.rollback() - logger.error(f"Failed to unpublish shout {shout_id}: {e}", exc_info=True) + logger.error(f"Failed to unpublish shout {shout_id}: {e}", exc_info=True) return {"error": f"Failed to unpublish shout: {str(e)}"} # Возвращаем сформированный словарь вместо объекта logger.info(f"Shout {shout_id} unpublished successfully by author {author_id}") - return {"shout": shout_dict} \ No newline at end of file + return {"shout": shout_dict} diff --git a/resolvers/feed.py b/resolvers/feed.py index b745038f..69a5fa55 100644 --- a/resolvers/feed.py +++ b/resolvers/feed.py @@ -2,7 +2,7 @@ from typing import List from sqlalchemy import and_, select -from orm.author import Author, AuthorFollower +from auth.orm import Author, AuthorFollower from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic from orm.topic import Topic, TopicFollower from resolvers.reader import ( @@ -71,7 +71,9 @@ def shouts_by_follower(info, follower_id: int, options): q = query_with_stat(info) reader_followed_authors = select(AuthorFollower.author).where(AuthorFollower.follower == follower_id) reader_followed_topics = select(TopicFollower.topic).where(TopicFollower.follower == follower_id) - reader_followed_shouts = select(ShoutReactionsFollower.shout).where(ShoutReactionsFollower.follower == follower_id) + reader_followed_shouts = select(ShoutReactionsFollower.shout).where( + ShoutReactionsFollower.follower == follower_id + ) followed_subquery = ( select(Shout.id) .join(ShoutAuthor, ShoutAuthor.shout == Shout.id) @@ -140,7 +142,9 @@ async def load_shouts_authored_by(_, info, slug: str, options) -> List[Shout]: q = ( query_with_stat(info) if has_field(info, "stat") - else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None))) + else select(Shout).filter( + and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)) + ) ) q = q.filter(Shout.authors.any(id=author_id)) q, limit, offset = apply_options(q, options, author_id) @@ -169,7 +173,9 @@ async def load_shouts_with_topic(_, info, slug: str, options) -> List[Shout]: q = ( query_with_stat(info) if has_field(info, "stat") - else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None))) + else select(Shout).filter( + and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)) + ) ) q = q.filter(Shout.topics.any(id=topic_id)) q, limit, offset = apply_options(q, options) diff --git a/resolvers/follower.py b/resolvers/follower.py index e6349e70..610f7341 100644 --- a/resolvers/follower.py +++ b/resolvers/follower.py @@ -10,7 +10,7 @@ from cache.cache import ( get_cached_follower_authors, get_cached_follower_topics, ) -from orm.author import Author, AuthorFollower +from auth.orm import Author, AuthorFollower from orm.community import Community, CommunityFollower from orm.reaction import Reaction from orm.shout import Shout, ShoutReactionsFollower @@ -71,11 +71,16 @@ async def follow(_, info, what, slug="", entity_id=0): with local_session() as session: existing_sub = ( session.query(follower_class) - .filter(follower_class.follower == follower_id, getattr(follower_class, entity_type) == entity_id) + .filter( + follower_class.follower == follower_id, + getattr(follower_class, entity_type) == entity_id, + ) .first() ) if existing_sub: - logger.info(f"Пользователь {follower_id} уже подписан на {what.lower()} с ID {entity_id}") + logger.info( + f"Пользователь {follower_id} уже подписан на {what.lower()} с ID {entity_id}" + ) else: logger.debug("Добавление новой записи в базу данных") sub = follower_class(follower=follower_id, **{entity_type: entity_id}) diff --git a/resolvers/notifier.py b/resolvers/notifier.py index 569f0f7a..9fe4d08f 100644 --- a/resolvers/notifier.py +++ b/resolvers/notifier.py @@ -7,7 +7,7 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import aliased from sqlalchemy.sql import not_ -from orm.author import Author +from auth.orm import Author from orm.notification import ( Notification, NotificationAction, @@ -66,7 +66,9 @@ def query_notifications(author_id: int, after: int = 0) -> Tuple[int, int, List[ return total, unread, notifications -def group_notification(thread, authors=None, shout=None, reactions=None, entity="follower", action="follow"): +def group_notification( + thread, authors=None, shout=None, reactions=None, entity="follower", action="follow" +): reactions = reactions or [] authors = authors or [] return { diff --git a/resolvers/proposals.py b/resolvers/proposals.py index 25218add..f541732a 100644 --- a/resolvers/proposals.py +++ b/resolvers/proposals.py @@ -14,7 +14,11 @@ def handle_proposing(kind: ReactionKind, reply_to: int, shout_id: int): session.query(Reaction).filter(Reaction.id == reply_to, Reaction.shout == shout_id).first() ) - if replied_reaction and replied_reaction.kind is ReactionKind.PROPOSE.value and replied_reaction.quote: + if ( + replied_reaction + and replied_reaction.kind is ReactionKind.PROPOSE.value + and replied_reaction.quote + ): # patch all the proposals' quotes proposals = ( session.query(Reaction) diff --git a/resolvers/rating.py b/resolvers/rating.py index 23a2acd4..cf848904 100644 --- a/resolvers/rating.py +++ b/resolvers/rating.py @@ -1,7 +1,7 @@ from sqlalchemy import and_, case, func, select, true from sqlalchemy.orm import aliased -from orm.author import Author, AuthorRating +from auth.orm import Author, AuthorRating from orm.reaction import Reaction, ReactionKind from orm.shout import Shout from services.auth import login_required @@ -187,7 +187,9 @@ def count_author_shouts_rating(session, author_id) -> int: def get_author_rating_old(session, author: Author): likes_count = ( - session.query(AuthorRating).filter(and_(AuthorRating.author == author.id, AuthorRating.plus.is_(True))).count() + session.query(AuthorRating) + .filter(and_(AuthorRating.author == author.id, AuthorRating.plus.is_(True))) + .count() ) dislikes_count = ( session.query(AuthorRating) diff --git a/resolvers/reaction.py b/resolvers/reaction.py index b10de0bf..6f90c2cc 100644 --- a/resolvers/reaction.py +++ b/resolvers/reaction.py @@ -3,7 +3,7 @@ import time from sqlalchemy import and_, asc, case, desc, func, select from sqlalchemy.orm import aliased -from orm.author import Author +from auth.orm import Author from orm.rating import PROPOSAL_REACTIONS, RATING_REACTIONS, is_negative, is_positive from orm.reaction import Reaction, ReactionKind from orm.shout import Shout, ShoutAuthor @@ -334,7 +334,9 @@ async def create_reaction(_, info, reaction): with local_session() as session: authors = session.query(ShoutAuthor.author).filter(ShoutAuthor.shout == shout_id).scalar() is_author = ( - bool(list(filter(lambda x: x == int(author_id), authors))) if isinstance(authors, list) else False + bool(list(filter(lambda x: x == int(author_id), authors))) + if isinstance(authors, list) + else False ) reaction_input["created_by"] = author_id kind = reaction_input.get("kind") @@ -487,7 +489,7 @@ def apply_reaction_filters(by, q): shout_slug = by.get("shout") if shout_slug: q = q.filter(Shout.slug == shout_slug) - + shout_id = by.get("shout_id") if shout_id: q = q.filter(Shout.id == shout_id) diff --git a/resolvers/reader.py b/resolvers/reader.py index a8d6b026..579e3a63 100644 --- a/resolvers/reader.py +++ b/resolvers/reader.py @@ -4,7 +4,7 @@ from sqlalchemy import and_, nulls_last, text from sqlalchemy.orm import aliased from sqlalchemy.sql.expression import asc, case, desc, func, select -from orm.author import Author +from auth.orm import Author from orm.reaction import Reaction, ReactionKind from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.topic import Topic @@ -93,7 +93,14 @@ def query_with_stat(info): q = q.join(main_topic, main_topic.id == main_topic_join.topic) q = q.add_columns( json_builder( - "id", main_topic.id, "title", main_topic.title, "slug", main_topic.slug, "is_main", main_topic_join.main + "id", + main_topic.id, + "title", + main_topic.title, + "slug", + main_topic.slug, + "is_main", + main_topic_join.main, ).label("main_topic") ) @@ -131,7 +138,9 @@ def query_with_stat(info): select( ShoutTopic.shout, json_array_builder( - json_builder("id", Topic.id, "title", Topic.title, "slug", Topic.slug, "is_main", ShoutTopic.main) + json_builder( + "id", Topic.id, "title", Topic.title, "slug", Topic.slug, "is_main", ShoutTopic.main + ) ).label("topics"), ) .outerjoin(Topic, ShoutTopic.topic == Topic.id) @@ -239,7 +248,9 @@ def get_shouts_with_links(info, q, limit=20, offset=0): if hasattr(row, "main_topic"): # logger.debug(f"Raw main_topic for shout#{shout_id}: {row.main_topic}") main_topic = ( - orjson.loads(row.main_topic) if isinstance(row.main_topic, str) else row.main_topic + orjson.loads(row.main_topic) + if isinstance(row.main_topic, str) + else row.main_topic ) # logger.debug(f"Parsed main_topic for shout#{shout_id}: {main_topic}") @@ -253,7 +264,12 @@ def get_shouts_with_links(info, q, limit=20, offset=0): } elif not main_topic: logger.warning(f"No main_topic and no topics found for shout#{shout_id}") - main_topic = {"id": 0, "title": "no topic", "slug": "notopic", "is_main": True} + main_topic = { + "id": 0, + "title": "no topic", + "slug": "notopic", + "is_main": True, + } shout_dict["main_topic"] = main_topic # logger.debug(f"Final main_topic for shout#{shout_id}: {main_topic}") @@ -270,7 +286,9 @@ def get_shouts_with_links(info, q, limit=20, offset=0): media_data = orjson.loads(media_data) except orjson.JSONDecodeError: media_data = [] - shout_dict["media"] = [media_data] if isinstance(media_data, dict) else media_data + shout_dict["media"] = ( + [media_data] if isinstance(media_data, dict) else media_data + ) shouts.append(shout_dict) @@ -358,7 +376,9 @@ def apply_sorting(q, options): """ order_str = options.get("order_by") if order_str in ["rating", "comments_count", "last_commented_at"]: - query_order_by = desc(text(order_str)) if options.get("order_by_desc", True) else asc(text(order_str)) + query_order_by = ( + desc(text(order_str)) if options.get("order_by_desc", True) else asc(text(order_str)) + ) q = q.distinct(text(order_str), Shout.id).order_by( # DISTINCT ON включает поле сортировки nulls_last(query_order_by), Shout.id ) @@ -442,7 +462,8 @@ async def load_shouts_unrated(_, info, options): select(Reaction.shout) .where( and_( - Reaction.deleted_at.is_(None), Reaction.kind.in_([ReactionKind.LIKE.value, ReactionKind.DISLIKE.value]) + Reaction.deleted_at.is_(None), + Reaction.kind.in_([ReactionKind.LIKE.value, ReactionKind.DISLIKE.value]), ) ) .group_by(Reaction.shout) @@ -453,11 +474,15 @@ async def load_shouts_unrated(_, info, options): q = select(Shout).where(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None))) q = q.join(Author, Author.id == Shout.created_by) q = q.add_columns( - json_builder("id", Author.id, "name", Author.name, "slug", Author.slug, "pic", Author.pic).label("main_author") + json_builder("id", Author.id, "name", Author.name, "slug", Author.slug, "pic", Author.pic).label( + "main_author" + ) ) q = q.join(ShoutTopic, and_(ShoutTopic.shout == Shout.id, ShoutTopic.main.is_(True))) q = q.join(Topic, Topic.id == ShoutTopic.topic) - q = q.add_columns(json_builder("id", Topic.id, "title", Topic.title, "slug", Topic.slug).label("main_topic")) + q = q.add_columns( + json_builder("id", Topic.id, "title", Topic.title, "slug", Topic.slug).label("main_topic") + ) q = q.where(Shout.id.not_in(rated_shouts)) q = q.order_by(func.random()) diff --git a/resolvers/stat.py b/resolvers/stat.py index 85ad69b3..35e8327c 100644 --- a/resolvers/stat.py +++ b/resolvers/stat.py @@ -4,7 +4,7 @@ from sqlalchemy import and_, distinct, func, join, select from sqlalchemy.orm import aliased from cache.cache import cache_author -from orm.author import Author, AuthorFollower +from auth.orm import Author, AuthorFollower from orm.reaction import Reaction, ReactionKind from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.topic import Topic, TopicFollower @@ -177,7 +177,9 @@ def get_topic_comments_stat(topic_id: int) -> int: .subquery() ) # Запрос для суммирования количества комментариев по теме - q = select(func.coalesce(func.sum(sub_comments.c.comments_count), 0)).filter(ShoutTopic.topic == topic_id) + q = select(func.coalesce(func.sum(sub_comments.c.comments_count), 0)).filter( + ShoutTopic.topic == topic_id + ) q = q.outerjoin(sub_comments, ShoutTopic.shout == sub_comments.c.shout_id) with local_session() as session: result = session.execute(q).first() @@ -237,7 +239,9 @@ def get_author_followers_stat(author_id: int) -> int: :return: Количество уникальных подписчиков автора. """ aliased_followers = aliased(AuthorFollower) - q = select(func.count(distinct(aliased_followers.follower))).filter(aliased_followers.author == author_id) + q = select(func.count(distinct(aliased_followers.follower))).filter( + aliased_followers.author == author_id + ) with local_session() as session: result = session.execute(q).first() return result[0] if result else 0 @@ -282,14 +286,16 @@ def get_with_stat(q): q = add_author_stat_columns(q) if is_author else add_topic_stat_columns(q) # Выполняем запрос - result = session.execute(q) + result = session.execute(q).unique() for cols in result: entity = cols[0] stat = dict() stat["shouts"] = cols[1] # Статистика по публикациям stat["followers"] = cols[2] # Статистика по подписчикам if is_author: - stat["authors"] = get_author_authors_stat(entity.id) # Статистика по подпискам на авторов + stat["authors"] = get_author_authors_stat( + entity.id + ) # Статистика по подпискам на авторов stat["comments"] = get_author_comments_stat(entity.id) # Статистика по комментариям else: stat["authors"] = get_topic_authors_stat(entity.id) # Статистика по авторам темы diff --git a/resolvers/topic.py b/resolvers/topic.py index 000f6d07..cc7375c2 100644 --- a/resolvers/topic.py +++ b/resolvers/topic.py @@ -8,7 +8,7 @@ from cache.cache import ( get_cached_topic_followers, invalidate_cache_by_prefix, ) -from orm.author import Author +from auth.orm import Author from orm.topic import Topic from orm.reaction import ReactionKind from resolvers.stat import get_with_stat diff --git a/schema/admin.graphql b/schema/admin.graphql new file mode 100644 index 00000000..f99c101c --- /dev/null +++ b/schema/admin.graphql @@ -0,0 +1,71 @@ +type EnvVariable { + key: String! + value: String! + description: String + type: String! + isSecret: Boolean +} + +type EnvSection { + name: String! + description: String + variables: [EnvVariable!]! +} + +input EnvVariableInput { + key: String! + value: String! + type: String! +} + +# Типы для управления пользователями +type AdminUserInfo { + id: Int! + email: String + name: String + slug: String + 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 { + id: String! + name: String! + description: String +} + +# Тип для пагинированного ответа пользователей +type AdminUserListResponse { + users: [AdminUserInfo!]! + total: Int! + page: Int! + perPage: Int! + totalPages: Int! +} + +extend type Query { + getEnvVariables: [EnvSection!]! + # Запросы для управления пользователями + adminGetUsers(limit: Int, offset: Int, search: String): AdminUserListResponse! + adminGetRoles: [Role!]! +} + +extend type Mutation { + updateEnvVariable(key: String!, value: String!): Boolean! + updateEnvVariables(variables: [EnvVariableInput!]!): Boolean! + + # Мутации для управления пользователями + adminUpdateUser(user: AdminUserUpdateInput!): Boolean! + adminToggleUserBlock(userId: Int!): Boolean! + adminToggleUserMute(userId: Int!): Boolean! +} \ No newline at end of file diff --git a/schema/enum.graphql b/schema/enum.graphql index 31d619e2..2a2eeb0f 100644 --- a/schema/enum.graphql +++ b/schema/enum.graphql @@ -51,3 +51,18 @@ enum InviteStatus { ACCEPTED REJECTED } + +# Auth enums +enum AuthAction { + LOGIN + REGISTER + CONFIRM_EMAIL + RESET_PASSWORD + CHANGE_PASSWORD +} + +enum RoleType { + SYSTEM + COMMUNITY + CUSTOM +} diff --git a/schema/input.graphql b/schema/input.graphql index fba7b144..3e322b10 100644 --- a/schema/input.graphql +++ b/schema/input.graphql @@ -116,3 +116,25 @@ input CommunityInput { desc: String pic: String } + +# Auth inputs +input LoginCredentials { + email: String! + password: String! +} + +input RegisterInput { + email: String! + password: String + name: String +} + +input ChangePasswordInput { + oldPassword: String! + newPassword: String! +} + +input ResetPasswordInput { + token: String! + newPassword: String! +} diff --git a/schema/mutation.graphql b/schema/mutation.graphql index c5f48fde..deb29d9f 100644 --- a/schema/mutation.graphql +++ b/schema/mutation.graphql @@ -1,4 +1,14 @@ type Mutation { + # Auth mutations + login(email: String!, password: String!): AuthResult! + registerUser(email: String!, password: String, name: String): AuthResult! + sendLink(email: String!, lang: String, template: String): Author! + confirmEmail(token: String!): AuthResult! + getSession: SessionInfo! + changePassword(oldPassword: String!, newPassword: String!): AuthSuccess! + resetPassword(token: String!, newPassword: String!): AuthSuccess! + requestPasswordReset(email: String!, lang: String): AuthSuccess! + # author rate_author(rated_slug: String!, value: Int!): CommonResult! update_author(profile: ProfileInput!): CommonResult! diff --git a/schema/query.graphql b/schema/query.graphql index e07954ae..44d5ead4 100644 --- a/schema/query.graphql +++ b/schema/query.graphql @@ -6,6 +6,14 @@ type Query { load_authors_by(by: AuthorsBy!, limit: Int, offset: Int): [Author] # search_authors(what: String!): [Author] + # Auth queries + signOut: AuthSuccess! + me: AuthResult! + isEmailUsed(email: String!): Boolean! + isAdmin: Boolean! + getOAuthProviders: [OAuthProvider!]! + getRoles: [RolesInfo!]! + # community get_community: Community get_communities_all: [Community] diff --git a/schema/type.graphql b/schema/type.graphql index d1656826..bc3800cf 100644 --- a/schema/type.graphql +++ b/schema/type.graphql @@ -23,10 +23,14 @@ type Author { last_seen: Int updated_at: Int deleted_at: Int + email: String seo: String # synthetic stat: AuthorStat # ratings inside communities: [Community] + # Auth fields + roles: [String!] + email_verified: Boolean } type ReactionUpdating { @@ -280,3 +284,39 @@ type MyRateComment { my_rate: ReactionKind } +# Auth types +type AuthResult { + success: Boolean! + error: String + token: String + author: Author +} + +type Permission { + resource: String! + action: String! + conditions: String +} + +type SessionInfo { + token: String! + author: Author! +} + +type AuthSuccess { + success: Boolean! +} + +type OAuthProvider { + id: String! + name: String! + url: String! +} + +type RolesInfo { + id: String! + name: String! + description: String + permissions: [Permission!]! +} + diff --git a/services/auth.py b/services/auth.py index e15961b1..4eec2cba 100644 --- a/services/auth.py +++ b/services/auth.py @@ -1,120 +1,90 @@ from functools import wraps +from typing import Tuple from cache.cache import get_cached_author_by_user_id from resolvers.stat import get_with_stat -from services.schema import request_graphql_data -from settings import ADMIN_SECRET, AUTH_URL from utils.logger import root_logger as logger +from auth.internal import verify_internal_auth +from sqlalchemy import exc +from services.db import local_session +from auth.orm import Author, Role # Список разрешенных заголовков ALLOWED_HEADERS = ["Authorization", "Content-Type"] -async def check_auth(req): +async def check_auth(req) -> Tuple[str, list[str]]: """ Проверка авторизации пользователя. - Эта функция проверяет токен авторизации, переданный в заголовках запроса, - и возвращает идентификатор пользователя и его роли. + Проверяет токен и получает данные из локальной БД. Параметры: - req: Входящий GraphQL запрос, содержащий заголовок авторизации. Возвращает: - - user_id: str - Идентификатор пользователя. - - user_roles: list[str] - Список ролей пользователя. + - user_id: str - Идентификатор пользователя + - user_roles: list[str] - Список ролей пользователя """ + # Проверяем наличие токена token = req.headers.get("Authorization") + if not token: + return "", [] - host = req.headers.get("host", "") - logger.debug(f"check_auth: host={host}") - auth_url = AUTH_URL - if ".dscrs.site" in host or "localhost" in host: - auth_url = "https://auth.dscrs.site/graphql" - user_id = "" - user_roles = [] - if token: - # Проверяем и очищаем токен от префикса Bearer если он есть - if token.startswith("Bearer "): - token = token.split("Bearer ")[-1].strip() - # Logging the authentication token - logger.debug(f"TOKEN: {token}") - query_name = "validate_jwt_token" - operation = "ValidateToken" - variables = {"params": {"token_type": "access_token", "token": token}} + # Очищаем токен от префикса Bearer если он есть + if token.startswith("Bearer "): + token = token.split("Bearer ")[-1].strip() - # Только необходимые заголовки для GraphQL запроса - headers = {"Content-Type": "application/json"} + logger.debug(f"Checking auth token: {token[:10]}...") - gql = { - "query": f"query {operation}($params: ValidateJWTTokenInput!)" - + "{" - + f"{query_name}(params: $params) {{ is_valid claims }} " - + "}", - "variables": variables, - "operationName": operation, - } - data = await request_graphql_data(gql, url=auth_url, headers=headers) - if data: - logger.debug(f"Auth response: {data}") - validation_result = data.get("data", {}).get(query_name, {}) - logger.debug(f"Validation result: {validation_result}") - is_valid = validation_result.get("is_valid", False) - if not is_valid: - logger.error(f"Token validation failed: {validation_result}") - return "", [] - user_data = validation_result.get("claims", {}) - logger.debug(f"User claims: {user_data}") - user_id = user_data.get("sub", "") - user_roles = user_data.get("allowed_roles", []) - return user_id, user_roles + # Проверяем авторизацию внутренним механизмом + logger.debug("Using internal authentication") + return await verify_internal_auth(token) -async def add_user_role(user_id): +async def add_user_role(user_id: str, roles: list[str] = None): """ - Добавление роли пользователя. + Добавление ролей пользователю в локальной БД. - Эта функция добавляет роли "author" и "reader" для указанного пользователя - в системе авторизации. - - Параметры: - - user_id: str - Идентификатор пользователя, которому нужно добавить роли. - - Возвращает: - - user_id: str - Идентификатор пользователя, если операция прошла успешно. + Args: + user_id: ID пользователя + roles: Список ролей для добавления. По умолчанию ["author", "reader"] """ - logger.info(f"add author role for user_id: {user_id}") - query_name = "_update_user" - operation = "UpdateUserRoles" - headers = { - "Content-Type": "application/json", - "x-authorizer-admin-secret": ADMIN_SECRET, - } - variables = {"params": {"roles": "author, reader", "id": user_id}} - gql = { - "query": f"mutation {operation}($params: UpdateUserInput!) {{ {query_name}(params: $params) {{ id roles }} }}", - "variables": variables, - "operationName": operation, - } - data = await request_graphql_data(gql, headers=headers) - if data: - user_id = data.get("data", {}).get(query_name, {}).get("id") - return user_id + if not roles: + roles = ["author", "reader"] + + logger.info(f"Adding roles {roles} to user {user_id}") + + logger.debug("Using local authentication") + with local_session() as session: + try: + author = session.query(Author).filter(Author.id == user_id).one() + + # Получаем существующие роли + existing_roles = set(role.name for role in author.roles) + + # Добавляем новые роли + for role_name in roles: + if role_name not in existing_roles: + # Получаем или создаем роль + role = session.query(Role).filter(Role.name == role_name).first() + if not role: + role = Role(id=role_name, name=role_name) + session.add(role) + + # Добавляем роль автору + author.roles.append(role) + + session.commit() + return user_id + + except exc.NoResultFound: + logger.error(f"Author {user_id} not found") + return None def login_required(f): - """ - Декоратор для проверки авторизации пользователя. - - Этот декоратор проверяет, авторизован ли пользователь, �� добавляет - информацию о пользователе в контекст функции. - - Параметры: - - f: Функция, которую нужно декорировать. - - Возвращает: - - Обернутую функцию с добавленной проверкой авторизации. - """ + """Декоратор для проверки авторизации пользователя.""" @wraps(f) async def decorated_function(*args, **kwargs): @@ -135,18 +105,7 @@ def login_required(f): def login_accepted(f): - """ - Декоратор для добавления данных авторизации в контекст. - - Этот декоратор добавляет данные авторизации в контекст, если они доступны, - но не блокирует доступ для неавторизованных пользователей. - - Параметры: - - f: Функция, которую нужно декорировать. - - Возвращает: - - Обернутую функцию с добавленной проверкой авторизации. - """ + """Декоратор для добавления данных авторизации в контекст.""" @wraps(f) async def decorated_function(*args, **kwargs): @@ -166,12 +125,11 @@ def login_accepted(f): author = await get_cached_author_by_user_id(user_id, get_with_stat) if author: logger.debug(f"login_accepted: Найден профиль автора: {author}") - # Предполагается, что `author` является объектом с атрибутом `id` info.context["author"] = author.dict() else: logger.error( f"login_accepted: Профиль автора не найден для пользователя {user_id}. Используем базовые данные." - ) # Используем базовую информацию об автор + ) else: logger.debug("login_accepted: Пользователь не авторизован. Очищаем контекст.") info.context["user_id"] = None diff --git a/services/db.py b/services/db.py index e9a6b58c..17981644 100644 --- a/services/db.py +++ b/services/db.py @@ -50,10 +50,25 @@ FILTERED_FIELDS = ["_sa_instance_state", "search_vector"] def create_table_if_not_exists(engine, table): + """ + Создает таблицу, если она не существует в базе данных. + + Args: + engine: SQLAlchemy движок базы данных + table: Класс модели SQLAlchemy + """ inspector = inspect(engine) if table and not inspector.has_table(table.__tablename__): - table.__table__.create(engine) - logger.info(f"Table '{table.__tablename__}' created.") + try: + table.__table__.create(engine) + logger.info(f"Table '{table.__tablename__}' created.") + except exc.OperationalError as e: + # Проверяем, содержит ли ошибка упоминание о том, что индекс уже существует + if "already exists" in str(e): + logger.warning(f"Skipping index creation for table '{table.__tablename__}': {e}") + else: + # Перевыбрасываем ошибку, если она не связана с дублированием + raise else: logger.info(f"Table '{table.__tablename__}' ok.") @@ -154,21 +169,43 @@ class Base(declarative_base()): REGISTRY[cls.__name__] = cls def dict(self) -> Dict[str, Any]: + """ + Конвертирует ORM объект в словарь. + + Пропускает атрибуты, которые отсутствуют в объекте, но присутствуют в колонках таблицы. + Преобразует JSON поля в словари. + Добавляет синтетическое поле .stat, если оно существует. + + Returns: + Dict[str, Any]: Словарь с атрибутами объекта + """ column_names = filter(lambda x: x not in FILTERED_FIELDS, self.__table__.columns.keys()) data = {} try: for column_name in column_names: - value = getattr(self, column_name) - # Check if the value is JSON and decode it if necessary - if isinstance(value, (str, bytes)) and isinstance(self.__table__.columns[column_name].type, JSON): - try: - data[column_name] = orjson.loads(value) - except (TypeError, orjson.JSONDecodeError) as e: - logger.error(f"Error decoding JSON for column '{column_name}': {e}") - data[column_name] = value - else: - data[column_name] = value - # Add synthetic field .stat if it exists + try: + # Проверяем, существует ли атрибут в объекте + if hasattr(self, column_name): + value = getattr(self, column_name) + # Проверяем, является ли значение JSON и декодируем его при необходимости + if isinstance(value, (str, bytes)) and isinstance( + self.__table__.columns[column_name].type, JSON + ): + try: + data[column_name] = orjson.loads(value) + except (TypeError, orjson.JSONDecodeError) as e: + logger.error(f"Error decoding JSON for column '{column_name}': {e}") + data[column_name] = value + else: + data[column_name] = value + else: + # Пропускаем атрибут, если его нет в объекте (может быть добавлен после миграции) + logger.debug( + f"Skipping missing attribute '{column_name}' for {self.__class__.__name__}" + ) + except AttributeError as e: + logger.warning(f"Attribute error for column '{column_name}': {e}") + # Добавляем синтетическое поле .stat если оно существует if hasattr(self, "stat"): data["stat"] = self.stat except Exception as e: @@ -186,7 +223,9 @@ class Base(declarative_base()): # Функция для вывода полного трейсбека при предупреждениях -def warning_with_traceback(message: Warning | str, category, filename: str, lineno: int, file=None, line=None): +def warning_with_traceback( + message: Warning | str, category, filename: str, lineno: int, file=None, line=None +): tb = traceback.format_stack() tb_str = "".join(tb) return f"{message} ({filename}, {lineno}): {category.__name__}\n{tb_str}" diff --git a/services/env.py b/services/env.py new file mode 100644 index 00000000..6662394b --- /dev/null +++ b/services/env.py @@ -0,0 +1,111 @@ +from typing import Dict, List, Optional +from dataclasses import dataclass +from redis import Redis +from settings import REDIS_URL +from utils.logger import root_logger as logger + + +@dataclass +class EnvVariable: + key: str + value: str + description: Optional[str] = None + type: str = "string" + is_secret: bool = False + + +@dataclass +class EnvSection: + name: str + variables: List[EnvVariable] + description: Optional[str] = None + + +class EnvManager: + """ + Менеджер переменных окружения с хранением в Redis + """ + + def __init__(self): + self.redis = Redis.from_url(REDIS_URL) + self.prefix = "env:" + + def get_all_variables(self) -> List[EnvSection]: + """ + Получение всех переменных окружения, сгруппированных по секциям + """ + try: + # Получаем все ключи с префиксом env: + keys = self.redis.keys(f"{self.prefix}*") + variables: Dict[str, str] = {} + + 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") + + # Группируем переменные по секциям + 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 + except Exception as e: + logger.error(f"Ошибка получения переменных: {e}") + return [] + + def update_variable(self, key: str, value: str) -> bool: + """ + Обновление значения переменной + """ + try: + full_key = f"{self.prefix}{key}" + self.redis.set(full_key, value) + return True + except Exception as e: + logger.error(f"Ошибка обновления переменной {key}: {e}") + return False + + def update_variables(self, variables: List[EnvVariable]) -> bool: + """ + Массовое обновление переменных + """ + try: + pipe = self.redis.pipeline() + for var in variables: + full_key = f"{self.prefix}{var.key}" + pipe.set(full_key, var.value) + pipe.execute() + return True + except Exception as e: + logger.error(f"Ошибка массового обновления переменных: {e}") + return False + + +env_manager = EnvManager() diff --git a/services/exception.py b/services/exception.py index dbe4c30c..4d85557c 100644 --- a/services/exception.py +++ b/services/exception.py @@ -14,4 +14,7 @@ class ExceptionHandlerMiddleware(BaseHTTPMiddleware): return response except Exception as exc: logger.exception(exc) - return JSONResponse({"detail": "An error occurred. Please try again later."}, status_code=500) + return JSONResponse( + {"detail": "An error occurred. Please try again later."}, + status_code=500, + ) diff --git a/services/notify.py b/services/notify.py index 4a29deff..cf2b2553 100644 --- a/services/notify.py +++ b/services/notify.py @@ -94,8 +94,7 @@ async def notify_draft(draft_data, action: str = "publish"): # Если переданы связанные атрибуты, добавим их if hasattr(draft_data, "topics") and draft_data.topics is not None: draft_payload["topics"] = [ - {"id": t.id, "name": t.name, "slug": t.slug} - for t in draft_data.topics + {"id": t.id, "name": t.name, "slug": t.slug} for t in draft_data.topics ] if hasattr(draft_data, "authors") and draft_data.authors is not None: diff --git a/services/redis.py b/services/redis.py index ed10527d..38dcce5f 100644 --- a/services/redis.py +++ b/services/redis.py @@ -40,6 +40,17 @@ class RedisService: except Exception as e: logger.error(e) + def pipeline(self): + """ + Возвращает пайплайн Redis для выполнения нескольких команд в одной транзакции. + + Returns: + Pipeline: объект pipeline Redis + """ + if self._client: + return self._client.pipeline() + raise Exception("Redis client is not initialized") + async def subscribe(self, *channels): if self._client: async with self._client.pubsub() as pubsub: @@ -75,6 +86,82 @@ class RedisService: async def get(self, key): return await self.execute("get", key) + async def delete(self, *keys): + """ + Удаляет ключи из Redis. + + Args: + *keys: Ключи для удаления + + Returns: + int: Количество удаленных ключей + """ + if not self._client or not keys: + return 0 + return await self._client.delete(*keys) + + async def hmset(self, key, mapping): + """ + Устанавливает несколько полей хеша. + + Args: + key: Ключ хеша + mapping: Словарь с полями и значениями + """ + if not self._client: + return + await self._client.hset(key, mapping=mapping) + + async def expire(self, key, seconds): + """ + Устанавливает время жизни ключа. + + Args: + key: Ключ + seconds: Время жизни в секундах + """ + if not self._client: + return + await self._client.expire(key, seconds) + + async def sadd(self, key, *values): + """ + Добавляет значения в множество. + + Args: + key: Ключ множества + *values: Значения для добавления + """ + if not self._client: + return + await self._client.sadd(key, *values) + + async def srem(self, key, *values): + """ + Удаляет значения из множества. + + Args: + key: Ключ множества + *values: Значения для удаления + """ + if not self._client: + return + await self._client.srem(key, *values) + + async def smembers(self, key): + """ + Получает все элементы множества. + + Args: + key: Ключ множества + + Returns: + set: Множество элементов + """ + if not self._client: + return set() + return await self._client.smembers(key) + redis = RedisService() diff --git a/services/schema.py b/services/schema.py index 8137bd64..ac167914 100644 --- a/services/schema.py +++ b/services/schema.py @@ -1,10 +1,8 @@ from asyncio.log import logger -import httpx from ariadne import MutationType, ObjectType, QueryType from services.db import create_table_if_not_exists, local_session -from settings import AUTH_URL query = QueryType() mutation = MutationType() @@ -12,50 +10,19 @@ type_draft = ObjectType("Draft") resolvers = [query, mutation, type_draft] -async def request_graphql_data(gql, url=AUTH_URL, headers=None): - """ - Выполняет GraphQL запрос к указанному URL - - :param gql: GraphQL запрос - :param url: URL для запроса, по умолчанию AUTH_URL - :param headers: Заголовки запроса - :return: Результат запроса или None в случае ошибки - """ - if not url: - return None - if headers is None: - headers = {"Content-Type": "application/json"} - try: - async with httpx.AsyncClient() as client: - response = await client.post(url, json=gql, headers=headers) - if response.status_code == 200: - data = response.json() - errors = data.get("errors") - if errors: - logger.error(f"{url} response: {data}") - else: - return data - else: - logger.error(f"{url}: {response.status_code} {response.text}") - except Exception as _e: - import traceback - - logger.error(f"request_graphql_data error: {traceback.format_exc()}") - return None - - def create_all_tables(): """Create all database tables in the correct order.""" - from orm import author, community, draft, notification, reaction, shout, topic + from auth.orm import Author, AuthorFollower, AuthorBookmark, AuthorRating + from orm import community, draft, notification, reaction, shout, topic # Порядок важен - сначала таблицы без внешних ключей, затем зависимые таблицы models_in_order = [ # user.User, # Базовая таблица auth - author.Author, # Базовая таблица + Author, # Базовая таблица community.Community, # Базовая таблица topic.Topic, # Базовая таблица # Связи для базовых таблиц - author.AuthorFollower, # Зависит от Author + AuthorFollower, # Зависит от Author community.CommunityFollower, # Зависит от Community topic.TopicFollower, # Зависит от Topic # Черновики (теперь без зависимости от Shout) @@ -70,7 +37,8 @@ def create_all_tables(): reaction.Reaction, # Зависит от Author и Shout shout.ShoutReactionsFollower, # Зависит от Shout и Reaction # Дополнительные таблицы - author.AuthorRating, # Зависит от Author + AuthorRating, # Зависит от Author + AuthorBookmark, # Зависит от Author notification.Notification, # Зависит от Author notification.NotificationSeen, # Зависит от Notification # collection.Collection, diff --git a/services/search.py b/services/search.py index e9257436..402cf558 100644 --- a/services/search.py +++ b/services/search.py @@ -171,11 +171,16 @@ class SearchService: } asyncio.create_task(self.perform_index(shout, index_body)) + def close(self): + if self.client: + self.client.close() + async def perform_index(self, shout, index_body): if self.client: try: await asyncio.wait_for( - self.client.index(index=self.index_name, id=str(shout.id), body=index_body), timeout=40.0 + self.client.index(index=self.index_name, id=str(shout.id), body=index_body), + timeout=40.0, ) except asyncio.TimeoutError: logger.error(f"Indexing timeout for shout {shout.id}") @@ -188,7 +193,9 @@ class SearchService: logger.info(f"Ищем: {text} {offset}+{limit}") search_body = { - "query": {"multi_match": {"query": text, "fields": ["title", "lead", "subtitle", "body", "media"]}} + "query": { + "multi_match": {"query": text, "fields": ["title", "lead", "subtitle", "body", "media"]} + } } if self.client: diff --git a/services/viewed.py b/services/viewed.py index a9ddeed1..d02cd59e 100644 --- a/services/viewed.py +++ b/services/viewed.py @@ -14,7 +14,7 @@ from google.analytics.data_v1beta.types import ( ) from google.analytics.data_v1beta.types import Filter as GAFilter -from orm.author import Author +from auth.orm import Author from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.topic import Topic from services.db import local_session @@ -228,12 +228,20 @@ class ViewedStorage: # Обновление тем и авторов с использованием вспомогательной функции for [_st, topic] in ( - session.query(ShoutTopic, Topic).join(Topic).join(Shout).where(Shout.slug == shout_slug).all() + session.query(ShoutTopic, Topic) + .join(Topic) + .join(Shout) + .where(Shout.slug == shout_slug) + .all() ): update_groups(self.shouts_by_topic, topic.slug, shout_slug) for [_st, author] in ( - session.query(ShoutAuthor, Author).join(Author).join(Shout).where(Shout.slug == shout_slug).all() + session.query(ShoutAuthor, Author) + .join(Author) + .join(Shout) + .where(Shout.slug == shout_slug) + .all() ): update_groups(self.shouts_by_author, author.slug, shout_slug) @@ -266,7 +274,9 @@ class ViewedStorage: if failed == 0: when = datetime.now(timezone.utc) + timedelta(seconds=self.period) t = format(when.astimezone().isoformat()) - logger.info(" ⎩ next update: %s" % (t.split("T")[0] + " " + t.split("T")[1].split(".")[0])) + logger.info( + " ⎩ next update: %s" % (t.split("T")[0] + " " + t.split("T")[1].split(".")[0]) + ) await asyncio.sleep(self.period) else: await asyncio.sleep(10) diff --git a/services/webhook.py b/services/webhook.py deleted file mode 100644 index c97e8485..00000000 --- a/services/webhook.py +++ /dev/null @@ -1,175 +0,0 @@ -import asyncio -import os -import re -from asyncio.log import logger - -from sqlalchemy import select -from starlette.endpoints import HTTPEndpoint -from starlette.exceptions import HTTPException -from starlette.requests import Request -from starlette.responses import JSONResponse - -from cache.cache import cache_author -from orm.author import Author -from resolvers.stat import get_with_stat -from services.db import local_session -from services.schema import request_graphql_data -from settings import ADMIN_SECRET, WEBHOOK_SECRET - - -async def check_webhook_existence(): - """ - Проверяет существование вебхука для user.login события - - Returns: - tuple: (bool, str, str) - существует ли вебхук, его id и endpoint если существует - """ - logger.info("check_webhook_existence called") - if not ADMIN_SECRET: - logger.error("ADMIN_SECRET is not set") - return False, None, None - - headers = {"Content-Type": "application/json", "X-Authorizer-Admin-Secret": ADMIN_SECRET} - - operation = "GetWebhooks" - query_name = "_webhooks" - variables = {"params": {}} - # https://docs.authorizer.dev/core/graphql-api#_webhooks - gql = { - "query": f"query {operation}($params: PaginatedInput!)" - + "{" - + f"{query_name}(params: $params) {{ webhooks {{ id event_name endpoint }} }} " - + "}", - "variables": variables, - "operationName": operation, - } - result = await request_graphql_data(gql, headers=headers) - if result: - webhooks = result.get("data", {}).get(query_name, {}).get("webhooks", []) - logger.info(webhooks) - for webhook in webhooks: - if webhook["event_name"].startswith("user.login"): - return True, webhook["id"], webhook["endpoint"] - return False, None, None - - -async def create_webhook_endpoint(): - """ - Создает вебхук для user.login события. - Если существует старый вебхук - удаляет его и создает новый. - """ - logger.info("create_webhook_endpoint called") - - headers = {"Content-Type": "application/json", "X-Authorizer-Admin-Secret": ADMIN_SECRET} - - exists, webhook_id, current_endpoint = await check_webhook_existence() - - # Определяем endpoint в зависимости от окружения - host = os.environ.get("HOST", "core.dscrs.site") - endpoint = f"https://{host}/new-author" - - if exists: - # Если вебхук существует, но с другим endpoint или с модифицированным именем - if current_endpoint != endpoint or webhook_id: - # https://docs.authorizer.dev/core/graphql-api#_delete_webhook - operation = "DeleteWebhook" - query_name = "_delete_webhook" - variables = {"params": {"id": webhook_id}} # Изменено с id на webhook_id - gql = { - "query": f"mutation {operation}($params: WebhookRequest!)" - + "{" - + f"{query_name}(params: $params) {{ message }} " - + "}", - "variables": variables, - "operationName": operation, - } - try: - await request_graphql_data(gql, headers=headers) - exists = False - except Exception as e: - logger.error(f"Failed to delete webhook: {e}") - # Продолжаем выполнение даже при ошибке удаления - exists = False - else: - logger.info(f"Webhook already exists and configured correctly: {webhook_id}") - return - - if not exists: - # https://docs.authorizer.dev/core/graphql-api#_add_webhook - operation = "AddWebhook" - query_name = "_add_webhook" - variables = { - "params": { - "event_name": "user.login", - "endpoint": endpoint, - "enabled": True, - "headers": {"Authorization": WEBHOOK_SECRET}, - } - } - gql = { - "query": f"mutation {operation}($params: AddWebhookRequest!)" - + "{" - + f"{query_name}(params: $params) {{ message }} " - + "}", - "variables": variables, - "operationName": operation, - } - try: - result = await request_graphql_data(gql, headers=headers) - logger.info(result) - except Exception as e: - logger.error(f"Failed to create webhook: {e}") - - -class WebhookEndpoint(HTTPEndpoint): - async def post(self, request: Request) -> JSONResponse: - try: - data = await request.json() - if not data: - raise HTTPException(status_code=400, detail="Request body is empty") - auth = request.headers.get("Authorization") - if not auth or auth != os.environ.get("WEBHOOK_SECRET"): - raise HTTPException(status_code=401, detail="Invalid Authorization header") - # logger.debug(data) - user = data.get("user") - if not isinstance(user, dict): - raise HTTPException(status_code=400, detail="User data is not a dictionary") - # - name: str = ( - f"{user.get('given_name', user.get('slug'))} {user.get('middle_name', '')}" - + f"{user.get('family_name', '')}".strip() - ) or "Аноним" - user_id: str = user.get("id", "") - email: str = user.get("email", "") - pic: str = user.get("picture", "") - if user_id: - with local_session() as session: - author = session.query(Author).filter(Author.user == user_id).first() - if not author: - # If the author does not exist, create a new one - slug: str = email.split("@")[0].replace(".", "-").lower() - slug: str = re.sub("[^0-9a-z]+", "-", slug) - while True: - author = session.query(Author).filter(Author.slug == slug).first() - if not author: - break - slug = f"{slug}-{len(session.query(Author).filter(Author.email == email).all()) + 1}" - author = Author(user=user_id, slug=slug, name=name, pic=pic) - session.add(author) - session.commit() - author_query = select(Author).filter(Author.user == user_id) - result = get_with_stat(author_query) - if result: - author_with_stat = result[0] - author_dict = author_with_stat.dict() - # await cache_author(author_with_stat) - asyncio.create_task(cache_author(author_dict)) - - return JSONResponse({"status": "success"}) - except HTTPException as e: - return JSONResponse({"status": "error", "message": str(e.detail)}, status_code=e.status_code) - except Exception as e: - import traceback - - traceback.print_exc() - return JSONResponse({"status": "error", "message": str(e)}, status_code=500) diff --git a/settings.py b/settings.py index 6453b9e3..b0d39711 100644 --- a/settings.py +++ b/settings.py @@ -1,3 +1,6 @@ +"""Настройки приложения""" + +import os import sys from os import environ @@ -17,13 +20,50 @@ REDIS_URL = environ.get("REDIS_URL") or "redis://127.0.0.1" # debug GLITCHTIP_DSN = environ.get("GLITCHTIP_DSN") -# authorizer.dev -AUTH_URL = environ.get("AUTH_URL") or "https://auth.discours.io/graphql" +# auth ADMIN_SECRET = environ.get("AUTH_SECRET") or "nothing" -WEBHOOK_SECRET = environ.get("WEBHOOK_SECRET") or "nothing-else" +ADMIN_EMAILS = environ.get("ADMIN_EMAILS") or "services@discours.io,guests@discours.io,welcome@discours.io" # own auth -ONETIME_TOKEN_LIFE_SPAN = 60 * 60 * 24 * 3 # 3 days -SESSION_TOKEN_LIFE_SPAN = 60 * 60 * 24 * 30 # 30 days +ONETIME_TOKEN_LIFE_SPAN = 60 * 15 # 15 минут +SESSION_TOKEN_LIFE_SPAN = 60 * 60 * 24 * 30 # 30 дней +SESSION_TOKEN_HEADER = "Authorization" JWT_ALGORITHM = "HS256" JWT_SECRET_KEY = environ.get("JWT_SECRET") or "nothing-else-jwt-secret-matters" + +# URL фронтенда +FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000") + +# Настройки OAuth провайдеров +OAUTH_CLIENTS = { + "GOOGLE": { + "id": os.getenv("GOOGLE_CLIENT_ID", ""), + "key": os.getenv("GOOGLE_CLIENT_SECRET", ""), + }, + "GITHUB": { + "id": os.getenv("GITHUB_CLIENT_ID", ""), + "key": os.getenv("GITHUB_CLIENT_SECRET", ""), + }, + "FACEBOOK": { + "id": os.getenv("FACEBOOK_CLIENT_ID", ""), + "key": os.getenv("FACEBOOK_CLIENT_SECRET", ""), + }, +} + +# Настройки базы данных +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/discours") + +# Настройки JWT +JWT_SECRET = os.getenv("JWT_SECRET", "your-secret-key") +JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 30 +JWT_REFRESH_TOKEN_EXPIRE_DAYS = 30 + +# Настройки сессии +SESSION_COOKIE_NAME = "session_token" +SESSION_COOKIE_SECURE = True +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = "lax" +SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 days + +MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY", "") +MAILGUN_DOMAIN = os.getenv("MAILGUN_DOMAIN", "discours.io") diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..46481316 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "node", + "esModuleInterop": true, + "allowJs": true, + "strict": true, + "noEmit": true, + "types": [], + "resolveJsonModule": true, + "skipLibCheck": true, + "isolatedModules": true, + "lib": ["DOM", "ESNext"], + "paths": { + "~/*": ["panel/admin/*"], + "@/*": ["panel/auth/*"] + } + }, + "exclude": [] +} diff --git a/utils/__init__.py b/utils/__init__.py index 0519ecba..e69de29b 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/utils/html_wrapper.py b/utils/extract_text.py similarity index 64% rename from utils/html_wrapper.py rename to utils/extract_text.py index fb9e4ba4..09e0d656 100644 --- a/utils/html_wrapper.py +++ b/utils/extract_text.py @@ -2,26 +2,48 @@ Модуль для обработки HTML-фрагментов """ +import trafilatura + + +def extract_text(html: str) -> str: + """ + Извлекает текст из HTML-фрагмента. + + Args: + html: HTML-фрагмент + + Returns: + str: Текст из HTML-фрагмента + """ + return trafilatura.extract( + wrap_html_fragment(html), + include_comments=False, + include_tables=False, + include_images=False, + include_formatting=False, + ) + + def wrap_html_fragment(fragment: str) -> str: """ Оборачивает HTML-фрагмент в полную HTML-структуру для корректной обработки. - + Args: fragment: HTML-фрагмент для обработки - + Returns: str: Полный HTML-документ - + Example: >>> wrap_html_fragment("

Текст параграфа

") '

Текст параграфа

' """ if not fragment or not fragment.strip(): return fragment - + # Проверяем, является ли контент полным HTML-документом - is_full_html = fragment.strip().startswith(' @@ -34,5 +56,5 @@ def wrap_html_fragment(fragment: str) -> str: {fragment} """ - - return fragment \ No newline at end of file + + return fragment diff --git a/utils/generate_slug.py b/utils/generate_slug.py new file mode 100644 index 00000000..7c628a46 --- /dev/null +++ b/utils/generate_slug.py @@ -0,0 +1,65 @@ +import re +from urllib.parse import quote_plus + +from auth.orm import Author +from services.db import local_session + + +def replace_translit(src): + ruchars = "абвгдеёжзийклмнопрстуфхцчшщъыьэюя." + enchars = [ + "a", + "b", + "v", + "g", + "d", + "e", + "yo", + "zh", + "z", + "i", + "y", + "k", + "l", + "m", + "n", + "o", + "p", + "r", + "s", + "t", + "u", + "f", + "h", + "c", + "ch", + "sh", + "sch", + "", + "y", + "'", + "e", + "yu", + "ya", + "-", + ] + return src.translate(str.maketrans(ruchars, enchars)) + + +def generate_unique_slug(src): + print("[resolvers.auth] generating slug from: " + src) + slug = replace_translit(src.lower()) + slug = re.sub("[^0-9a-zA-Z]+", "-", slug) + if slug != src: + print("[resolvers.auth] translited name: " + slug) + c = 1 + with local_session() as session: + user = session.query(Author).where(Author.slug == slug).first() + while user: + user = session.query(Author).where(Author.slug == slug).first() + slug = slug + "-" + str(c) + c += 1 + if not user: + unique_slug = slug + print("[resolvers.auth] " + unique_slug) + return quote_plus(unique_slug.replace("'", "")).replace("+", "-") diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000..f733b1c5 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,71 @@ +import { resolve } from 'path' +import { defineConfig } from 'vite' +import solidPlugin from 'vite-plugin-solid' + +// Конфигурация для разных окружений +const isProd = process.env.NODE_ENV === 'production' + +export default defineConfig({ + plugins: [solidPlugin()], + base: '/', + + build: { + target: 'esnext', + outDir: 'dist', + minify: isProd, + sourcemap: !isProd, + + rollupOptions: { + input: { + main: resolve(__dirname, 'client/index.tsx') + }, + + output: { + // Настройка выходных файлов + entryFileNames: '[name].js', + chunkFileNames: 'chunks/[name].[hash].js', + assetFileNames: 'assets/[name].[hash][extname]', + + // Настройка разделения кода + manualChunks: { + vendor: ['solid-js', '@solidjs/router'], + graphql: ['./client/graphql.ts'], + auth: ['./client/auth.ts'] + } + } + }, + + // Оптимизация сборки + cssCodeSplit: true, + assetsInlineLimit: 4096, + chunkSizeWarningLimit: 500 + }, + + // Настройка dev сервера + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true + }, + '/graphql': { + target: 'http://localhost:8000', + changeOrigin: true + } + } + }, + + // Оптимизация зависимостей + optimizeDeps: { + include: ['solid-js', '@solidjs/router'], + exclude: [] + }, + + // Настройка алиасов для путей + resolve: { + alias: { + '@': resolve(__dirname, 'client') + } + } +})