From 4ce7a7831f6f5221b758de0dac360c4c5ba4f0eb Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 21:47:19 +0300 Subject: [PATCH] Enforce chat access for previews and socket delivery --- chats/admin.py | 1 + chats/consumers/chat.py | 142 +++++++++++---------- chats/consumers/event_types/DirectEvent.py | 63 +++++---- chats/models.py | 8 +- chats/permissions.py | 6 + chats/search.py | 2 +- chats/serializers.py | 10 +- chats/services/messages.py | 29 ++++- chats/tests/test_permissions.py | 118 ++++++++++++++++- chats/utils.py | 13 -- chats/views.py | 23 ++-- core/auth/middleware.py | 75 +---------- 12 files changed, 289 insertions(+), 201 deletions(-) diff --git a/chats/admin.py b/chats/admin.py index 746bf64a..bab1f592 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -211,6 +211,7 @@ def publish_change(self, chat, event_type, content): event = { "type": event_type, "content": {"chat_id": chat.pk, "chat_type": ChatType.DIRECT, **content}, + "_direct_chat_id": chat.pk, } for user in chat.users.all(): channel = cache.get(get_user_channel_cache_key(user)) diff --git a/chats/consumers/chat.py b/chats/consumers/chat.py index 3fadc458..bd1b5bb9 100644 --- a/chats/consumers/chat.py +++ b/chats/consumers/chat.py @@ -7,12 +7,12 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.conf import settings from django.core.cache import cache -from django.db.models import Q from django.utils import timezone from chats.exceptions import ChatException -from chats.models import BaseChat +from chats.permissions import accessible_chats, direct_chat_is_accessible from chats.project_reads import personalize_project_message_event +from chats.services.messages import get_direct_chat_for_participants from chats.utils import get_user_channel_cache_key from chats.websockets_settings import ( Event, @@ -22,41 +22,26 @@ ) from core.constants import ONE_DAY_IN_SECONDS, ONE_WEEK_IN_SECONDS from core.utils import get_user_online_cache_key, get_users_online_cache_key -from projects.models import Collaborator from users.models import CustomUser from chats.consumers.event_types import DirectEvent, ProjectEvent from chats.utils import get_chat_and_user_ids_from_content -from chats.models import DirectChat, ProjectChat +from chats.models import ProjectChat @database_sync_to_async -def get_user_project_ids(user_id: int) -> list[int]: - return list( - Collaborator.objects.filter(user_id=user_id).values_list("project", flat=True) - ) +def get_user_project_ids(user) -> list[int]: + return list(accessible_chats(user, ProjectChat).values_list("pk", flat=True)) @database_sync_to_async -def can_access_project_chat(user_id, chat_id): - try: - chat_id = int(chat_id) - except (TypeError, ValueError): - return False - return ( - ProjectChat.objects.filter(pk=chat_id) - .filter(Q(project__leader_id=user_id) | Q(project__collaborator__user_id=user_id)) - .exists() - ) +def can_access_project_chat(user, chat_id): + return accessible_chats(user, ProjectChat).filter(pk=chat_id).exists() class ChatConsumer(AsyncJsonWebsocketConsumer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.room_name: str = "" self.user: Optional[CustomUser] = None - self.chat_type = None - self.chat: Optional[BaseChat] = None - self.event = None self.joined_rooms: set[str] = set() async def connect(self): @@ -72,17 +57,11 @@ async def connect(self): ) if not settings.RUNNING_TESTS: - # get all projects that user is a member of - project_ids_list = await get_user_project_ids(self.user.id) + project_ids_list = await get_user_project_ids(self.user) for project_id in project_ids_list: - # FIXME: if a user is a leader but not a collaborator, this doesn't work - # upd: it seems not possible to be a leader without being a collaborator - # join room for each project - - # It's currently not possible to do this in a single call, - - # so we have to do it in a loop (e.g. that's O(N) calls to layer backend, redis cache that would be) - - room_name = f"{EventGroupType.CHATS_RELATED}_{project_id}" - await self.channel_layer.group_add(room_name, self.channel_name) - self.joined_rooms.add(room_name) + await self._ensure_room_subscription( + f"{EventGroupType.CHATS_RELATED}_{project_id}" + ) # set user online user_cache_key = get_user_online_cache_key(self.user) @@ -120,6 +99,16 @@ async def disconnect(self, close_code): # don't need to proccess logic for disconnect # if we are not logged in! return + for room_name in self.joined_rooms: + await self.channel_layer.group_discard(room_name, self.channel_name) + self.joined_rooms.clear() + await self.channel_layer.group_discard( + EventGroupType.GENERAL_EVENTS, self.channel_name + ) + user_channel_key = get_user_channel_cache_key(self.user) + if cache.get(user_channel_key) != self.channel_name: + return + cache.delete(user_channel_key) online_users = cache.get(get_users_online_cache_key(), set()) online_users.discard(self.user.id) cache.set(get_users_online_cache_key(), online_users) @@ -135,7 +124,9 @@ async def disconnect(self, close_code): async def receive_json(self, content, **kwargs): """Receive message from WebSocket in JSON format""" - # todo reply_to key is not required + if not isinstance(content, dict) or not isinstance(content.get("type"), str): + await self.send_json({"error": "Некорректный формат события."}) + return event = Event(type=content["type"], content=content.get("content")) # two event types - related to group chat and related to leave/connect @@ -146,26 +137,33 @@ async def receive_json(self, content, **kwargs): EventType.DELETE_MESSAGE, EventType.EDIT_MESSAGE, ]: - if event.content["chat_type"] == ChatType.DIRECT: - self.event = DirectEvent(self.user, self.channel_layer, self.channel_name) - elif event.content["chat_type"] == ChatType.PROJECT: - if not await can_access_project_chat( - self.user.pk, event.content.get("chat_id") - ): + if not isinstance(event.content, dict): + await self.send_json({"error": "Некорректные данные события."}) + return + chat_type = event.content.get("chat_type") + chat_id = event.content.get("chat_id") + room_name = "" + if chat_type == ChatType.DIRECT: + handler = DirectEvent(self.user, self.channel_layer, self.channel_name) + elif chat_type == ChatType.PROJECT: + try: + if type(chat_id) not in (int, str): + raise ValueError + project_id = int(chat_id) + except ValueError: + await self.send_json({"error": "Некорректный идентификатор чата."}) + return + if not await can_access_project_chat(self.user, project_id): await self.send_json({"error": "У вас нет доступа к чату проекта."}) return - self.event = ProjectEvent( - self.user, self.channel_layer, self.channel_name - ) - - chat_id = event.content.get("chat_id") - if event.content["chat_type"] == ChatType.PROJECT: - chat_id = int(chat_id) - room_name = f"{EventGroupType.CHATS_RELATED}_{chat_id}" - if event.content["chat_type"] == ChatType.PROJECT: + handler = ProjectEvent(self.user, self.channel_layer, self.channel_name) + room_name = f"{EventGroupType.CHATS_RELATED}_{project_id}" await self._ensure_room_subscription(room_name) + else: + await self.send_json({"error": "Неизвестный тип чата."}) + return try: - await self.__process_chat_related_event(event, room_name) + await self.__process_chat_related_event(handler, event, room_name) except ChatException as e: await self.send_json({"error": str(e.get_error())}) except KeyError as e: @@ -175,20 +173,19 @@ async def receive_json(self, content, **kwargs): room_name = EventGroupType.GENERAL_EVENTS await self.__process_general_event(event, room_name) else: - return self.disconnect(400) + await self.send_json({"error": "Неизвестный тип события."}) - async def __process_chat_related_event(self, event, room_name): - self.event: DirectEvent | ProjectEvent + async def __process_chat_related_event(self, handler, event, room_name): if event.type == EventType.NEW_MESSAGE: - await self.event.process_new_message_event(event, room_name) + await handler.process_new_message_event(event, room_name) elif event.type == EventType.TYPING: await self.__process_typing_event(event, room_name) elif event.type == EventType.READ_MESSAGE: - await self.event.process_read_message_event(event, room_name) + await handler.process_read_message_event(event, room_name) elif event.type == EventType.DELETE_MESSAGE: - await self.event.process_delete_message_event(event, room_name) + await handler.process_delete_message_event(event, room_name) elif event.type == EventType.EDIT_MESSAGE: - await self.event.process_edit_message_event(event, room_name) + await handler.process_edit_message_event(event, room_name) async def __process_typing_event(self, event: Event, room_name: str): """Send typing event to room group.""" @@ -204,29 +201,37 @@ async def __process_typing_event(self, event: Event, room_name: str): if event.content["chat_type"] == ChatType.PROJECT: event_data["_project_chat_id"] = event.content["chat_id"] + await self.channel_layer.group_send(room_name, event_data) - if event.content["chat_type"] == ChatType.DIRECT: + else: _, other_user = await get_chat_and_user_ids_from_content( event.content, self.user ) - await database_sync_to_async(DirectChat.get_chat)(self.user, other_user) + chat = await database_sync_to_async(get_direct_chat_for_participants)( + self.user, other_user + ) + event_data["content"]["chat_id"] = chat.pk + event_data["_direct_chat_id"] = chat.pk # send message to user's channel other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) - if other_user_channel: + if other_user_channel and other_user_channel != self.channel_name: await self.channel_layer.send(other_user_channel, event_data) - await self.channel_layer.group_send( - room_name, - event_data, - ) - async def _send_chat_event(self, event): + direct_chat_id = event.get("_direct_chat_id") + if direct_chat_id is not None and ( + not self.user + or not await database_sync_to_async(direct_chat_is_accessible)( + self.user, direct_chat_id + ) + ): + return chat_id = event.get("_project_chat_id") if chat_id is not None: - if not self.user or not await can_access_project_chat(self.user.pk, chat_id): + if not self.user or not await can_access_project_chat(self.user, chat_id): room_name = f"{EventGroupType.CHATS_RELATED}_{int(chat_id)}" await self.channel_layer.group_discard(room_name, self.channel_name) self.joined_rooms.discard(room_name) @@ -285,7 +290,7 @@ async def __process_general_event(self, event: Event, room_name: str): elif event.type == EventType.SET_OFFLINE: cache.delete(cache_key) users_online_list = cache.get_or_set(users_online_list_key, set()) - users_online_list.remove(self.user.pk) + users_online_list.discard(self.user.pk) cache.set(users_online_list_key, users_online_list, ONE_DAY_IN_SECONDS) # sent everyone online event that user X is offline @@ -301,6 +306,5 @@ async def __process_general_event(self, event: Event, room_name: str): async def decode_json(self, text_data) -> dict: try: return json.loads(text_data) - except JSONDecodeError as error: - await self.disconnect(400) - raise error + except JSONDecodeError: + return {} diff --git a/chats/consumers/event_types/DirectEvent.py b/chats/consumers/event_types/DirectEvent.py index f234e625..9220fd92 100644 --- a/chats/consumers/event_types/DirectEvent.py +++ b/chats/consumers/event_types/DirectEvent.py @@ -1,16 +1,18 @@ from channels.db import database_sync_to_async -from chats.models import DirectChatMessage, DirectChat +from chats.models import DirectChat from chats.websockets_settings import ChatType, Event, EventType -from chats.exceptions import WrongChatIdException from django.core.cache import cache from chats.utils import ( get_user_channel_cache_key, get_chat_and_user_ids_from_content, - orm_get, - orm_save, ) -from chats.services.messages import create_direct_message, delete_message, edit_message +from chats.services.messages import ( + create_direct_message, + delete_message, + edit_message, + mark_direct_message_read, +) class DirectEvent: @@ -41,7 +43,11 @@ async def process_new_message_event(self, event: Event, room_name: str): # send message to user's channel other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) - event_data = {"type": EventType.NEW_MESSAGE, "content": content} + event_data = { + "type": EventType.NEW_MESSAGE, + "content": content, + "_direct_chat_id": chat_id, + } await self.channel_layer.send(self.channel_name, event_data) @@ -49,31 +55,29 @@ async def process_new_message_event(self, event: Event, room_name: str): await self.channel_layer.send(other_user_channel, event_data) async def process_read_message_event(self, event: Event, room_name: str): - chat_id, other_user = await get_chat_and_user_ids_from_content( - event.content, self.user + _, other_user = await get_chat_and_user_ids_from_content(event.content, self.user) + chat_id = DirectChat.get_chat_id_from_users(self.user, other_user) + message_id = await database_sync_to_async(mark_direct_message_read)( + chat_id=chat_id, + message_id=event.content["message_id"], + user=self.user, + author=other_user, ) - msg = await orm_get(DirectChatMessage.objects, pk=event.content["message_id"]) - if msg.chat_id != chat_id or msg.author_id != other_user.id: - raise WrongChatIdException( - "Some of chat/message ids are wrong, you can't access this message" - ) - msg.is_read = True - await orm_save(msg, update_fields=["is_read"]) # send 2 events to user's channel other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) - json_thingy = { + event_data = { "type": EventType.READ_MESSAGE, "content": { - "chat_id": event.content["chat_id"], - "chat_type": event.content["chat_type"], + "chat_id": chat_id, + "chat_type": ChatType.DIRECT, "user_id": self.user.id, - "message_id": event.content["message_id"], + "message_id": message_id, }, + "_direct_chat_id": chat_id, } - await self.channel_layer.send(self.channel_name, json_thingy) - if other_user_channel is None: - return - await self.channel_layer.send(other_user_channel, json_thingy) + await self.channel_layer.send(self.channel_name, event_data) + if other_user_channel and other_user_channel != self.channel_name: + await self.channel_layer.send(other_user_channel, event_data) async def process_delete_message_event(self, event: Event, room_name: str): _, other_user = await get_chat_and_user_ids_from_content(event.content, self.user) @@ -87,18 +91,19 @@ async def process_delete_message_event(self, event: Event, room_name: str): # send message to user's channel other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) - content = { + event_data = { "type": EventType.DELETE_MESSAGE, "content": { "message_id": message_id, "chat_id": chat_id, "chat_type": ChatType.DIRECT, }, + "_direct_chat_id": chat_id, } - await self.channel_layer.send(self.channel_name, content) + await self.channel_layer.send(self.channel_name, event_data) if other_user_channel and other_user_channel != self.channel_name: - await self.channel_layer.send(other_user_channel, content) + await self.channel_layer.send(other_user_channel, event_data) async def process_edit_message_event(self, event, room_name): _, other_user = await get_chat_and_user_ids_from_content(event.content, self.user) @@ -118,7 +123,11 @@ async def process_edit_message_event(self, event, room_name): # send message to user's channel other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) - event_data = {"type": EventType.EDIT_MESSAGE, "content": content} + event_data = { + "type": EventType.EDIT_MESSAGE, + "content": content, + "_direct_chat_id": chat_id, + } if other_user_channel and other_user_channel != self.channel_name: await self.channel_layer.send(other_user_channel, event_data) diff --git a/chats/models.py b/chats/models.py index b577b041..22509ffe 100644 --- a/chats/models.py +++ b/chats/models.py @@ -44,8 +44,8 @@ def save( update_fields=update_fields, ) - def get_last_message(self): - return self.messages.last() + def visible_messages(self): + return self.messages.filter(is_deleted=False).order_by("-created_at", "-pk") def get_users_str(self): """Returns string of users separated by a comma, who are in chat @@ -122,7 +122,7 @@ def get_avatar(self, user): return self.project.image_address def get_last_messages(self, message_count) -> List["BaseMessage"]: - return self.messages.order_by("-created_at")[:message_count] + return self.visible_messages()[:message_count] def __str__(self): return f"ProjectChat<{self.project.id}> - {self.project.name}" @@ -180,7 +180,7 @@ def get_chat(cls, user1, user2) -> "DirectChat": return chat def get_last_messages(self, message_count): - return self.messages.order_by("-created_at")[:message_count] + return self.visible_messages()[:message_count] def get_other_user(self, user) -> User: return self.users.exclude(pk=user.pk).first() diff --git a/chats/permissions.py b/chats/permissions.py index 83cc789a..1f621c28 100644 --- a/chats/permissions.py +++ b/chats/permissions.py @@ -16,6 +16,12 @@ def accessible_chats(user, chat_model): return user.get_project_chats() +def direct_chat_is_accessible(user, chat_id): + from chats.models import DirectChat + + return accessible_chats(user, DirectChat).filter(pk=chat_id).exists() + + def get_lookup_kwarg(request, view, *names): view_kwargs = getattr(view, "kwargs", {}) or {} parser_kwargs = (getattr(request, "parser_context", None) or {}).get("kwargs", {}) diff --git a/chats/search.py b/chats/search.py index 67bee0f8..7b910a52 100644 --- a/chats/search.py +++ b/chats/search.py @@ -48,7 +48,7 @@ def search_chat_messages(chat, filters): query = filters["q"] scope = filters["scope"] author_id = filters["author_id"] - messages = chat.messages.filter(is_deleted=False).order_by("-created_at", "-pk") + messages = chat.visible_messages() if author_id is not None: messages = messages.filter(author_id=author_id) if not query: diff --git a/chats/serializers.py b/chats/serializers.py index 79396048..b91e5d38 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -72,7 +72,13 @@ def get_image_address(self, chat: DirectChat): @classmethod def get_last_message(cls, chat: DirectChat): - return DirectChatMessageListSerializer(chat.get_last_message()).data + message = ( + chat.visible_messages() + .select_related("author", "reply_to__author") + .prefetch_related("file_to_message__file") + .first() + ) + return DirectChatMessageListSerializer(message).data class Meta: model = DirectChat @@ -113,7 +119,7 @@ def get_name(cls, chat: ProjectChat): def get_last_message(self, chat: ProjectChat): user_id = self.context["request"].user.pk - messages = chat.messages.filter(is_deleted=False).order_by("-created_at", "-pk") + messages = chat.visible_messages() message = with_project_message_reads( messages, user_id, include_reply=True ).first() diff --git a/chats/services/messages.py b/chats/services/messages.py index cf11628d..32b75661 100644 --- a/chats/services/messages.py +++ b/chats/services/messages.py @@ -150,7 +150,7 @@ def create_direct_message( file_urls=None, client_message_id=None, ): - chat = DirectChat.get_chat(author, other_user) + chat = get_direct_chat_for_participants(author, other_user) # Serialize sends in this chat before checking for a repeat or locking files. DirectChat.objects.select_for_update().get(pk=chat.pk) return _create_message( @@ -166,6 +166,33 @@ def create_direct_message( ) +@transaction.atomic +def get_direct_chat_for_participants(user, other_user): + chat = DirectChat.get_chat(user, other_user) + participants = set(chat.users.values_list("pk", flat=True)) + if participants != {user.pk, other_user.pk}: + raise MessageValidationException("Чат недоступен.") + return chat + + +def mark_direct_message_read(*, chat_id, message_id, user, author): + if type(message_id) not in (str, int): + raise MessageValidationException("Некорректный идентификатор сообщения.") + try: + messages = DirectChatMessage.objects.filter( + pk=message_id, + chat_id=chat_id, + chat__users=user, + author=author, + is_deleted=False, + ).filter(chat__users=author) + if user.pk != author.pk and messages.update(is_read=True): + return int(message_id) + except (TypeError, ValueError): + pass + raise MessageValidationException("Сообщение недоступно для прочтения.") + + @transaction.atomic def create_project_message( *, diff --git a/chats/tests/test_permissions.py b/chats/tests/test_permissions.py index 525971e1..f558b9bb 100644 --- a/chats/tests/test_permissions.py +++ b/chats/tests/test_permissions.py @@ -1,11 +1,17 @@ +from channels.db import database_sync_to_async +from channels.testing import WebsocketCommunicator from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings -from rest_framework.test import APIRequestFactory, force_authenticate +from django.test import TestCase, TransactionTestCase, override_settings +from django.urls import reverse +from rest_framework.test import APIClient, APIRequestFactory, force_authenticate +from rest_framework_simplejwt.tokens import AccessToken -from chats.models import ProjectChat +from chats.models import DirectChat, ProjectChat from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from chats.tests.test_project_reads import create_project_chat from chats.views import ProjectChatDetail from projects.models import Collaborator, Project +from procollab.asgi import application @override_settings(ALLOWED_HOSTS=["testserver", "dev.procollab.ru", "127.0.0.1"]) @@ -71,3 +77,109 @@ def test_project_chat_detail_is_forbidden_for_outsider(self): response = ProjectChatDetail.as_view()(request, pk=self.chat.id) self.assertEqual(response.status_code, 403) + + def test_direct_chat_preview_and_unreads_use_visible_incoming_messages(self): + chat = DirectChat.get_chat(self.leader, self.collaborator) + chat.messages.create(author=self.leader, text="Свое сообщение") + unread = chat.messages.create(author=self.collaborator, text="Новое сообщение") + chat.messages.create( + author=self.collaborator, text="Удаленное сообщение", is_deleted=True + ) + api = APIClient() + api.force_authenticate(self.leader) + listing_url = reverse("chats:direct-chat-list") + unreads_url = reverse("chats:has-chat-unreads") + + preview = api.get(listing_url, secure=True).json()[0]["last_message"] + self.assertEqual(preview["id"], unread.pk) + self.assertTrue(api.get(unreads_url, secure=True).json()["has_unreads"]) + unread.is_deleted = True + unread.save(update_fields=["is_deleted"]) + preview = api.get(listing_url, secure=True).json()[0]["last_message"] + self.assertEqual(preview["text"], "Свое сообщение") + self.assertFalse(api.get(unreads_url, secure=True).json()["has_unreads"]) + + +@override_settings(RUNNING_TESTS=False) +class ChatSocketAccessTests(TransactionTestCase): + def setUp(self): + users = get_user_model().objects + self.leader = users.create(**TEST_USER1, is_active=True) + self.member = users.create(**TEST_USER2, is_active=True) + self.chat = create_project_chat(self.leader, self.member) + Collaborator.objects.filter(project_id=self.chat.pk, user=self.leader).delete() + + def open_chat(self, user): + return WebsocketCommunicator( + application, + "/ws/chat/", + subprotocols=["Bearer", str(AccessToken.for_user(user))], + ) + + async def receive_chat_event(self, socket): + while True: + event = await socket.receive_json_from(timeout=5) + if event.get("type") not in {"set_online", "set_offline"}: + return event + + async def send_project_message(self, socket, text): + await socket.send_json_to( + { + "type": "new_message", + "content": { + "chat_type": "project", + "chat_id": self.chat.pk, + "text": text, + }, + } + ) + + async def test_leader_receives_updates_and_removed_member_stops_receiving_them(self): + leader = self.open_chat(self.leader) + member = self.open_chat(self.member) + self.assertEqual(await leader.connect(), (True, "Bearer")) + self.assertEqual(await member.connect(), (True, "Bearer")) + try: + await self.send_project_message(member, "Первое сообщение") + self.assertEqual( + (await self.receive_chat_event(leader))["content"]["message"]["text"], + "Первое сообщение", + ) + self.assertEqual( + (await self.receive_chat_event(member))["type"], "new_message" + ) + await database_sync_to_async( + Collaborator.objects.filter( + project_id=self.chat.pk, user=self.member + ).delete + )() + await self.send_project_message(leader, "Сообщение после изменения состава") + self.assertEqual( + (await self.receive_chat_event(leader))["type"], "new_message" + ) + self.assertTrue(await member.receive_nothing()) + finally: + await member.disconnect() + await leader.disconnect() + + async def test_inactive_account_cannot_open_chat_socket(self): + await database_sync_to_async( + get_user_model().objects.filter(pk=self.member.pk).update + )(is_active=False) + socket = self.open_chat(self.member) + connected, _ = await socket.connect() + self.assertFalse(connected) + await socket.disconnect() + + async def test_connection_stays_available_after_invalid_event(self): + socket = self.open_chat(self.leader) + self.assertEqual(await socket.connect(), (True, "Bearer")) + try: + await socket.send_to(text_data="{") + self.assertIn("error", await self.receive_chat_event(socket)) + await self.send_project_message(socket, "Сообщение проекта") + self.assertEqual( + (await self.receive_chat_event(socket))["type"], "new_message" + ) + finally: + await socket.disconnect() diff --git a/chats/utils.py b/chats/utils.py index bda114ed..c31d9198 100644 --- a/chats/utils.py +++ b/chats/utils.py @@ -15,19 +15,6 @@ async def orm_get(queryset, **kwargs): return await queryset.aget(**kwargs) -async def orm_exists(queryset) -> bool: - if settings.RUNNING_TESTS: - return queryset.exists() - return await queryset.aexists() - - -async def orm_save(instance, update_fields=None): - if settings.RUNNING_TESTS: - instance.save(update_fields=update_fields) - return - await instance.asave(update_fields=update_fields) - - def get_user_channel_cache_key(user: User) -> str: return f"user_channel_{user.pk}" diff --git a/chats/views.py b/chats/views.py index cb7239c4..5c27a8c7 100644 --- a/chats/views.py +++ b/chats/views.py @@ -23,12 +23,13 @@ download_chat_attachment, get_accessible_chat, ) +from chats.exceptions import MessageValidationException from chats.message_context import ( MAX_CONTEXT_LIMIT, MessageContextForm, get_message_context, ) -from chats.models import ProjectChat, DirectChat +from chats.models import DirectChat, DirectChatMessage, ProjectChat from chats.pagination import MessageListPagination from chats.permissions import IsProjectChatMember, IsChatMember, accessible_chats from chats.project_reads import ( @@ -46,6 +47,7 @@ DirectChatDetailSerializer, ChatAttachmentSerializer, ) +from chats.services.messages import get_direct_chat_for_participants User = get_user_model() @@ -126,9 +128,8 @@ def get(self, request, *args, **kwargs) -> Response: else: opponent = user1 context = {"opponent": opponent} - data = DirectChatDetailSerializer( - DirectChat.get_chat(user1, user2), context=context - ).data + chat = get_direct_chat_for_participants(user1, user2) + data = DirectChatDetailSerializer(chat, context=context).data data["name"] = f"{opponent.first_name} {opponent.last_name}" data["image_address"] = opponent.avatar @@ -157,6 +158,8 @@ def get(self, request, *args, **kwargs) -> Response: status=status.HTTP_404_NOT_FOUND, data={"detail": "Direct chat does not exist"}, ) + except MessageValidationException as error: + return Response({"detail": str(error)}, status=status.HTTP_404_NOT_FOUND) class ChatMessageHistory(ListCreateAPIView): @@ -455,13 +458,13 @@ class HasChatUnreadsView(GenericAPIView): ) def get(self, request, *args, **kwargs): user = request.user - # get all user chats - direct_messages = user.direct_chats.all().prefetch_related("messages") - has_direct_messages_unread = ( - direct_messages.filter(messages__is_read=False) - .exclude(messages__is_deleted=True) - .distinct() + DirectChatMessage.objects.filter( + chat_id__in=user.direct_chats.values("pk"), + is_read=False, + is_deleted=False, + ) + .exclude(author=user) .exists() ) has_project_messages_unread = ( diff --git a/core/auth/middleware.py b/core/auth/middleware.py index 247baeea..6f77dc74 100644 --- a/core/auth/middleware.py +++ b/core/auth/middleware.py @@ -1,71 +1,6 @@ -import jwt from channels.db import database_sync_to_async -from django.conf import settings -from django.contrib.auth import get_user_model -from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import AuthenticationFailed - -User = get_user_model() - - -class TokenAuthentication: - """ - Simple token based authentication. - - Clients should authenticate by passing the token key in the query parameters. - For example: - - ?token=401f7ac837da42b97f613d789819ff93537bee6a - """ - - model = None - - def get_model(self): - if self.model is not None: - return self.model - from rest_framework.authtoken.models import Token - - return Token - - """ - A custom token model may be used, but must have the following properties. - - * key -- The string identifying the token - * user -- The user to which the token belongs - """ - - def authenticate_credentials(self, key): - model = self.get_model() - try: - token = model.objects.select_related("user").get(key=key) - except model.DoesNotExist: - raise AuthenticationFailed(_("Invalid token.")) - - if not token.user.is_active: - raise AuthenticationFailed(_("User inactive or deleted.")) - - return token.user - - def authenticate(self, token): - """ - Returns a `User` if a correct username and password have been supplied - Args: - token: token key - - Returns: - User: A user instance. - """ - try: - user_id = jwt.decode( - jwt=token, key=settings.SECRET_KEY, algorithms=["HS256"] - )["user_id"] - except jwt.exceptions.DecodeError: - raise AuthenticationFailed(_("Invalid token.")) - except jwt.exceptions.ExpiredSignatureError: - raise AuthenticationFailed(_("Token expired.")) - - user = User.objects.get(pk=user_id) - return user +from rest_framework_simplejwt.authentication import JWTAuthentication @database_sync_to_async @@ -84,13 +19,11 @@ def get_user(scope): "TokenAuthMiddleware." ) token = scope["token"] - user = None try: - auth = TokenAuthentication() - user = auth.authenticate(token) + auth = JWTAuthentication() + return auth.get_user(auth.get_validated_token(token)) except AuthenticationFailed: - pass - return user or AnonymousUser() + return AnonymousUser() class TokenAuthMiddleware: