From 5874d3ccaecea3a1b1898418b9a3754afa68db39 Mon Sep 17 00:00:00 2001 From: Untone Date: Wed, 21 May 2025 18:29:46 +0300 Subject: [PATCH] create_draft fix --- CHANGELOG.md | 31 +++++++++--- cache/cache.py | 9 ++-- docs/caching.md | 121 ++++++++++++++++++++++++++++++++++++++++++-- resolvers/auth.py | 58 ++++++++++++++++----- resolvers/author.py | 18 +++++-- resolvers/draft.py | 5 +- services/auth.py | 121 ++++++++++++++++++-------------------------- services/search.py | 15 +++++- 8 files changed, 272 insertions(+), 106 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 148d533a..76081170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,16 +8,16 @@ - Управление пользователями (блокировка, изменение ролей, отключение звука) - Пагинация и поиск пользователей по email, имени и ID - Расширение GraphQL схемы для админки: - - Типы AdminUserInfo, AdminUserUpdateInput, AuthResult, Permission, SessionInfo + - Типы `AdminUserInfo`, `AdminUserUpdateInput`, `AuthResult`, `Permission`, `SessionInfo` - Мутации для управления пользователями и авторизации - Улучшения серверной части: - - Поддержка HTTPS через Granian с помощью mkcert + - Поддержка HTTPS через `Granian` с помощью `mkcert` - Параметры запуска `--https`, `--workers`, `--domain` - Система авторизации и аутентификации: - - Локальная система аутентификации с сессиями в Redis + - Локальная система аутентификации с сессиями в `Redis` - Система ролей и разрешений (RBAC) - Защита от брутфорс атак - - Поддержка httpOnly cookies для токенов + - Поддержка `httpOnly` cookies для токенов - Мультиязычные email уведомления ### Изменено @@ -44,6 +44,7 @@ - "Cannot return null for non-nullable field Mutation.login" - "Author password is empty" при авторизации - "Author object has no attribute username" + - Метод dict() класса Author теперь корректно сериализует роли как список словарей - Обработка ошибок: - Улучшена валидация email и username - Исправлена обработка истекших токенов @@ -258,7 +259,7 @@ #### [0.4.4] - `followers_stat` removed for shout - sqlite3 support added -- `rating_stat` and `comments_count` fixes +- `rating_stat` and `commented_stat` fixes #### [0.4.3] - cache reimplemented @@ -414,4 +415,22 @@ #### [0.2.7] -- `loadFollowedReactions` now with ` \ No newline at end of file +- `loadFollowedReactions` now with `login_required` +- notifier service api draft +- added `shout` visibility kind in schema +- community isolated from author in orm + + +#### [0.2.6] +- redis connection pool +- auth context fixes +- communities orm, resolvers, schema + + +#### [0.2.5] +- restructured +- all users have their profiles as authors in core +- `gittask`, `inbox` and `auth` logics removed +- `settings` moved to base and now smaller +- new outside auth schema +- removed `gittask`, `auth`, `inbox`, `migration` diff --git a/cache/cache.py b/cache/cache.py index 742a56e6..0e94fff4 100644 --- a/cache/cache.py +++ b/cache/cache.py @@ -384,11 +384,8 @@ async def invalidate_shouts_cache(cache_keys: List[str]): """ Инвалидирует кэш выборок публикаций по переданным ключам. """ - for key in cache_keys: + for cache_key in cache_keys: try: - # Формируем полный ключ кэша - cache_key = f"shouts:{key}" - # Удаляем основной кэш await redis.execute("DEL", cache_key) logger.debug(f"Invalidated cache key: {cache_key}") @@ -397,8 +394,8 @@ async def invalidate_shouts_cache(cache_keys: List[str]): await redis.execute("SETEX", f"{cache_key}:invalidated", CACHE_TTL, "1") # Если это кэш темы, инвалидируем также связанные ключи - if key.startswith("topic_"): - topic_id = key.split("_")[1] + if cache_key.startswith("topic_"): + topic_id = cache_key.split("_")[1] related_keys = [ f"topic:id:{topic_id}", f"topic:authors:{topic_id}", diff --git a/docs/caching.md b/docs/caching.md index 0a179764..e8b55a58 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -249,16 +249,129 @@ async def get_topics_with_stats(limit=10, offset=0, by="title"): ### Точечная инвалидация кеша при изменении данных ```python -async def update_topic(topic_id, new_data): +async def update_author(author_id, data): # Обновление данных в базе # ... - # Точечная инвалидация кеша только для измененной темы - await invalidate_topics_cache(topic_id) + # Инвалидация только кеша этого автора + await invalidate_authors_cache(author_id) - return updated_topic + return result ``` +## Ключи кеширования + +Ниже приведен полный список форматов ключей, используемых в системе кеширования Discours. + +### Ключи для публикаций (Shout) + +| Формат ключа | Описание | Пример | +|--------------|----------|--------| +| `shouts:{id}` | Публикация по ID | `shouts:123` | +| `shouts:{id}:invalidated` | Флаг инвалидации публикации | `shouts:123:invalidated` | +| `shouts:feed:limit={n}:offset={m}` | Основная лента публикаций | `shouts:feed:limit=20:offset=0` | +| `shouts:recent:limit={n}` | Последние публикации | `shouts:recent:limit=10` | +| `shouts:random_top:limit={n}` | Случайные топовые публикации | `shouts:random_top:limit=5` | +| `shouts:unrated:limit={n}` | Неоцененные публикации | `shouts:unrated:limit=20` | +| `shouts:coauthored:limit={n}` | Совместные публикации | `shouts:coauthored:limit=10` | + +### Ключи для авторов (Author) + +| Формат ключа | Описание | Пример | +|--------------|----------|--------| +| `author:id:{id}` | Автор по ID | `author:id:123` | +| `author:slug:{slug}` | Автор по слагу | `author:slug:john-doe` | +| `author:user_id:{user_id}` | Автор по ID пользователя | `author:user_id:abc123` | +| `author:{id}` | Публикации автора | `author:123` | +| `authored:{id}` | Публикации, созданные автором | `authored:123` | +| `authors:all:basic` | Базовый список всех авторов | `authors:all:basic` | +| `authors:stats:limit={n}:offset={m}:sort={field}` | Список авторов с пагинацией и сортировкой | `authors:stats:limit=20:offset=0:sort=name` | +| `author:followers:{id}` | Подписчики автора | `author:followers:123` | +| `author:following:{id}` | Авторы, на которых подписан автор | `author:following:123` | + +### Ключи для тем (Topic) + +| Формат ключа | Описание | Пример | +|--------------|----------|--------| +| `topic:id:{id}` | Тема по ID | `topic:id:123` | +| `topic:slug:{slug}` | Тема по слагу | `topic:slug:technology` | +| `topic:{id}` | Публикации по теме | `topic:123` | +| `topic_shouts_{id}` | Публикации по теме (старый формат) | `topic_shouts_123` | +| `topics:all:basic` | Базовый список всех тем | `topics:all:basic` | +| `topics:stats:limit={n}:offset={m}:sort={field}` | Список тем с пагинацией и сортировкой | `topics:stats:limit=20:offset=0:sort=name` | +| `topic:authors:{id}` | Авторы темы | `topic:authors:123` | +| `topic:followers:{id}` | Подписчики темы | `topic:followers:123` | +| `topic:stats:{id}` | Статистика темы | `topic:stats:123` | + +### Ключи для реакций (Reaction) + +| Формат ключа | Описание | Пример | +|--------------|----------|--------| +| `reactions:shout:{id}:limit={n}:offset={m}` | Реакции на публикацию | `reactions:shout:123:limit=20:offset=0` | +| `reactions:comment:{id}:limit={n}:offset={m}` | Реакции на комментарий | `reactions:comment:456:limit=20:offset=0` | +| `reactions:author:{id}:limit={n}:offset={m}` | Реакции автора | `reactions:author:123:limit=20:offset=0` | +| `reactions:followed:author:{id}:limit={n}` | Реакции авторов, на которых подписан пользователь | `reactions:followed:author:123:limit=20` | + +### Ключи для сообществ (Community) + +| Формат ключа | Описание | Пример | +|--------------|----------|--------| +| `community:id:{id}` | Сообщество по ID | `community:id:123` | +| `community:slug:{slug}` | Сообщество по слагу | `community:slug:tech-club` | +| `communities:all:basic` | Базовый список всех сообществ | `communities:all:basic` | +| `community:authors:{id}` | Авторы сообщества | `community:authors:123` | +| `community:shouts:{id}:limit={n}:offset={m}` | Публикации сообщества | `community:shouts:123:limit=20:offset=0` | + +### Ключи для подписок (Follow) + +| Формат ключа | Описание | Пример | +|--------------|----------|--------| +| `follow:author:{follower_id}:authors` | Авторы, на которых подписан пользователь | `follow:author:123:authors` | +| `follow:author:{follower_id}:topics` | Темы, на которые подписан пользователь | `follow:author:123:topics` | +| `follow:topic:{topic_id}:authors` | Авторы, подписанные на тему | `follow:topic:456:authors` | +| `follow:author:{author_id}:followers` | Подписчики автора | `follow:author:123:followers` | + +### Ключи для черновиков (Draft) + +| Формат ключа | Описание | Пример | +|--------------|----------|--------| +| `draft:id:{id}` | Черновик по ID | `draft:id:123` | +| `drafts:author:{id}` | Черновики автора | `drafts:author:123` | +| `drafts:all:limit={n}:offset={m}` | Список всех черновиков с пагинацией | `drafts:all:limit=20:offset=0` | + +### Ключи для статистики + +| Формат ключа | Описание | Пример | +|--------------|----------|--------| +| `stats:shout:{id}` | Статистика публикации | `stats:shout:123` | +| `stats:author:{id}` | Статистика автора | `stats:author:123` | +| `stats:topic:{id}` | Статистика темы | `stats:topic:123` | +| `stats:community:{id}` | Статистика сообщества | `stats:community:123` | + +### Ключи для поиска + +| Формат ключа | Описание | Пример | +|--------------|----------|--------| +| `search:query:{query}:limit={n}:offset={m}` | Результаты поиска | `search:query:технологии:limit=20:offset=0` | +| `search:author:{query}:limit={n}` | Результаты поиска авторов | `search:author:иван:limit=10` | +| `search:topic:{query}:limit={n}` | Результаты поиска тем | `search:topic:наука:limit=10` | + +### Служебные ключи + +| Формат ключа | Описание | Пример | +|--------------|----------|--------| +| `revalidation:{entity_type}:{entity_id}` | Метка для ревалидации | `revalidation:author:123` | +| `revalidation:batch:{entity_type}` | Батчевая ревалидация | `revalidation:batch:shouts` | +| `lock:{resource}` | Блокировка ресурса | `lock:precache` | +| `views:shout:{id}` | Счетчик просмотров публикации | `views:shout:123` | + +### Важные замечания по использованию ключей + +1. При инвалидации кеша публикаций через `invalidate_shouts_cache()` необходимо передавать список ID публикаций, а не ключи кеша. +2. Функция `invalidate_shout_related_cache()` автоматически инвалидирует все связанные ключи для публикации, включая ключи авторов и тем. +3. Для большинства операций с кешем следует использовать асинхронные функции с префиксом `await`. +4. При создании новых ключей кеша следует придерживаться существующих конвенций именования. + ## Отладка и мониторинг Система кеширования использует логгер для отслеживания операций: diff --git a/resolvers/auth.py b/resolvers/auth.py index 06d3a0da..ef6cb3b2 100644 --- a/resolvers/auth.py +++ b/resolvers/auth.py @@ -32,17 +32,51 @@ from auth.internal import verify_internal_auth @mutation.field("getSession") @login_required async def get_current_user(_, info): - """get current user""" - auth: AuthCredentials = info.context["request"].auth - token = info.context["request"].headers.get(SESSION_TOKEN_HEADER) - - with local_session() as session: - author = session.query(Author).where(Author.id == auth.author_id).one() - author.last_seen = int(time.time()) - session.commit() - - # Здесь можно не применять фильтрацию, так как пользователь получает свои данные - return {"token": token, "author": author} + """ + Получает информацию о текущем пользователе. + + Требует авторизации через декоратор login_required. + + Args: + _: Родительский объект (не используется) + info: Контекст GraphQL запроса + + Returns: + dict: Объект с токеном и данными автора + """ + # Получаем данные авторизации из контекста запроса + user_id = info.context.get("user_id") + if not user_id: + logger.error("[getSession] Пользователь не авторизован") + from graphql.error import GraphQLError + raise GraphQLError("Требуется авторизация") + + # Получаем токен из заголовка + req = info.context.get("request") + token = req.headers.get(SESSION_TOKEN_HEADER) + if token and token.startswith("Bearer "): + token = token.split("Bearer ")[-1].strip() + + # Получаем данные автора + author = info.context.get("author") + + # Если автор не найден в контексте, пробуем получить из БД + if not author: + logger.debug(f"[getSession] Автор не найден в контексте для пользователя {user_id}, получаем из БД") + with local_session() as session: + try: + db_author = session.query(Author).filter(Author.id == user_id).one() + db_author.last_seen = int(time.time()) + session.commit() + author = db_author + except Exception as e: + logger.error(f"[getSession] Ошибка при получении автора из БД: {e}") + from graphql.error import GraphQLError + raise GraphQLError("Ошибка при получении данных пользователя") + + # Возвращаем данные сессии + logger.info(f"[getSession] Успешно получена сессия для пользователя {user_id}") + return {"token": token or '', "author": author} @mutation.field("confirmEmail") @@ -63,7 +97,7 @@ async def confirm_email(_, info, token): 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": "Пользователь не найден"} + return {"success": False, "token": None, "author": None, "error": "Пользователь не найден"} # Создаем сессионный токен с новым форматом вызова и явным временем истечения device_info = {"email": user.email} if hasattr(user, "email") else None diff --git a/resolvers/author.py b/resolvers/author.py index d41440c6..d3ec1c26 100644 --- a/resolvers/author.py +++ b/resolvers/author.py @@ -403,7 +403,11 @@ async def get_author_follows(_, info, slug="", user=None, author_id=0): if hasattr(temp_author, key): setattr(temp_author, key, value) # Добавляем отфильтрованную версию - followed_authors.append(temp_author.dict(current_user_id, is_admin)) + # temp_author - это объект Author, который мы хотим сериализовать + # current_user_id - ID текущего авторизованного пользователя (может быть None) + # is_admin - булево значение, является ли текущий пользователь админом + has_access = is_admin or (current_user_id is not None and str(current_user_id) == str(temp_author.id)) + followed_authors.append(temp_author.dict(access=has_access)) # TODO: Get followed communities too return { @@ -447,7 +451,11 @@ async def get_author_follows_authors(_, info, slug="", user=None, author_id=None if hasattr(temp_author, key): setattr(temp_author, key, value) # Добавляем отфильтрованную версию - followed_authors.append(temp_author.dict(current_user_id, is_admin)) + # temp_author - это объект Author, который мы хотим сериализовать + # current_user_id - ID текущего авторизованного пользователя (может быть None) + # is_admin - булево значение, является ли текущий пользователь админом + has_access = is_admin or (current_user_id is not None and str(current_user_id) == str(temp_author.id)) + followed_authors.append(temp_author.dict(access=has_access)) return followed_authors @@ -488,6 +496,10 @@ async def get_author_followers(_, info, slug: str = "", user: str = "", author_i if hasattr(temp_author, key): setattr(temp_author, key, value) # Добавляем отфильтрованную версию - followers.append(temp_author.dict(current_user_id, is_admin)) + # temp_author - это объект Author, который мы хотим сериализовать + # current_user_id - ID текущего авторизованного пользователя (может быть None) + # is_admin - булево значение, является ли текущий пользователь админом + has_access = is_admin or (current_user_id is not None and str(current_user_id) == str(temp_author.id)) + followers.append(temp_author.dict(access=has_access)) return followers diff --git a/resolvers/draft.py b/resolvers/draft.py index 63734e40..2b4216c6 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -461,8 +461,9 @@ async def publish_draft(_, info, draft_id: int): session.commit() # Инвалидируем кеш - invalidate_shouts_cache() - invalidate_shout_related_cache(shout.id) + cache_keys = [f"shouts:{shout.id}", ] + await invalidate_shouts_cache(cache_keys) + await invalidate_shout_related_cache(shout, author_id) # Уведомляем о публикации await notify_shout(shout.id) diff --git a/services/auth.py b/services/auth.py index c1d17ffa..fa11cbef 100644 --- a/services/auth.py +++ b/services/auth.py @@ -1,6 +1,8 @@ from functools import wraps from typing import Tuple +from starlette.requests import Request + from cache.cache import get_cached_author_by_user_id from resolvers.stat import get_with_stat from utils.logger import root_logger as logger @@ -8,12 +10,13 @@ from auth.internal import verify_internal_auth from sqlalchemy import exc from services.db import local_session from auth.orm import Author, Role +from settings import SESSION_TOKEN_HEADER # Список разрешенных заголовков ALLOWED_HEADERS = ["Authorization", "Content-Type"] -async def check_auth(req) -> Tuple[str, list[str], bool]: +async def check_auth(req: Request) -> Tuple[str, list[str], bool]: """ Проверка авторизации пользователя. @@ -27,50 +30,54 @@ async def check_auth(req) -> Tuple[str, list[str], bool]: - user_roles: list[str] - Список ролей пользователя - is_admin: bool - Флаг наличия у пользователя административных прав """ - # Проверяем наличие токена - token = req.headers.get("Authorization") + logger.debug(f"[check_auth] Проверка авторизации...") + + # Получаем заголовок авторизации + token = None + + # Проверяем заголовок с учетом регистра + headers_dict = dict(req.headers.items()) + logger.debug(f"[check_auth] Все заголовки: {headers_dict}") + + # Ищем заголовок Authorization независимо от регистра + for header_name, header_value in headers_dict.items(): + if header_name.lower() == SESSION_TOKEN_HEADER.lower(): + token = header_value + logger.debug(f"[check_auth] Найден заголовок {header_name}: {token[:10]}...") + break + if not token: + logger.debug(f"[check_auth] Токен не найден в заголовках") return "", [], False # Очищаем токен от префикса Bearer если он есть if token.startswith("Bearer "): token = token.split("Bearer ")[-1].strip() - logger.debug(f"Checking auth token: {token[:10]}...") - # Проверяем авторизацию внутренним механизмом - logger.debug("Using internal authentication") - user_id, user_roles = await verify_internal_auth(token) + logger.debug("[check_auth] Вызов verify_internal_auth...") + user_id, user_roles, is_admin = await verify_internal_auth(token) + logger.debug(f"[check_auth] Результат verify_internal_auth: user_id={user_id}, roles={user_roles}, is_admin={is_admin}") - # Проверяем наличие административных прав у пользователя - is_admin = False - if user_id: - # Быстрая проверка на админ роли в кэше - admin_roles = ['admin', 'super'] - for role in user_roles: - if role in admin_roles: - is_admin = True - break - - # Если в ролях нет админа, но есть ID - проверяем в БД - if not is_admin: - try: - with local_session() as session: - # Преобразуем user_id в число - try: - user_id_int = int(user_id.strip()) - except (ValueError, TypeError): - logger.error(f"Невозможно преобразовать user_id {user_id} в число") - else: - # Проверяем наличие админских прав через БД - from auth.orm import AuthorRole - admin_role = session.query(AuthorRole).filter( - AuthorRole.author == user_id_int, - AuthorRole.role.in_(["admin", "super"]) - ).first() - is_admin = admin_role is not None - except Exception as e: - logger.error(f"Ошибка при проверке прав администратора: {e}") + # Если в ролях нет админа, но есть ID - проверяем в БД + if user_id and not is_admin: + try: + with local_session() as session: + # Преобразуем user_id в число + try: + user_id_int = int(user_id.strip()) + except (ValueError, TypeError): + logger.error(f"Невозможно преобразовать user_id {user_id} в число") + else: + # Проверяем наличие админских прав через БД + from auth.orm import AuthorRole + admin_role = session.query(AuthorRole).filter( + AuthorRole.author == user_id_int, + AuthorRole.role.in_(["admin", "super"]) + ).first() + is_admin = admin_role is not None + except Exception as e: + logger.error(f"Ошибка при проверке прав администратора: {e}") return user_id, user_roles, is_admin @@ -124,13 +131,18 @@ def login_required(f): info = args[1] req = info.context.get("request") + + logger.debug(f"[login_required] Проверка авторизации для запроса: {req.method} {req.url.path}") + logger.debug(f"[login_required] Заголовки: {req.headers}") + user_id, user_roles, is_admin = await check_auth(req) if not user_id: + logger.debug(f"[login_required] Пользователь не авторизован, {dict(req)}, {info}") raise GraphQLError("Требуется авторизация") # Проверяем наличие роли reader - if 'reader' not in user_roles and not is_admin: + if 'reader' not in user_roles: logger.error(f"Пользователь {user_id} не имеет роли 'reader'") raise GraphQLError("У вас нет необходимых прав для доступа") @@ -192,38 +204,3 @@ def login_accepted(f): return await f(*args, **kwargs) return decorated_function - -def author_required(f): - """Декоратор для проверки наличия роли 'author' у пользователя.""" - - @wraps(f) - async def decorated_function(*args, **kwargs): - from graphql.error import GraphQLError - - info = args[1] - req = info.context.get("request") - user_id, user_roles, is_admin = await check_auth(req) - - if not user_id: - raise GraphQLError("Требуется авторизация") - - # Проверяем наличие роли author - if 'author' not in user_roles and not is_admin: - logger.error(f"Пользователь {user_id} не имеет роли 'author'") - raise GraphQLError("Для выполнения этого действия необходимы права автора") - - logger.info(f"Авторизован автор {user_id} с ролями: {user_roles}") - info.context["user_id"] = user_id.strip() - info.context["roles"] = user_roles - - # Проверяем права администратора - info.context["is_admin"] = is_admin - - author = await get_cached_author_by_user_id(user_id, get_with_stat) - if not author: - logger.error(f"Профиль автора не найден для пользователя {user_id}") - info.context["author"] = author - - return await f(*args, **kwargs) - - return decorated_function diff --git a/services/search.py b/services/search.py index 402cf558..adf62789 100644 --- a/services/search.py +++ b/services/search.py @@ -2,10 +2,12 @@ import asyncio import json import logging import os +from typing import List import orjson from opensearchpy import OpenSearch +from orm.shout import Shout from services.redis import redis from utils.encoders import CustomJSONEncoder @@ -156,7 +158,18 @@ class SearchService: else: logger.error("клиент не инициализован, невозможно проверить индекс") - def index(self, shout): + def index_shouts(self, shouts: List[Shout]): + if not SEARCH_ENABLED: + return + + if self.client: + for shout in shouts: + self.index(shout) + + def index(self, shout: Shout): + return self.index_shout(shout) + + def index_shout(self, shout: Shout): if not SEARCH_ENABLED: return