From 2768d2f1b368ca3f787ab2920544e5da0d724ec0 Mon Sep 17 00:00:00 2001 From: lizerk Date: Thu, 10 Sep 2026 20:23:39 +0300 Subject: [PATCH] Restore reliable chat realtime and media playback --- backend/api/v1/urls.py | 7 + backend/communications/api/media.py | 77 ++++++++ backend/communications/api/permissions.py | 6 +- backend/communications/api/viewsets.py | 51 +++++- backend/communications/media_delivery.py | 3 +- backend/communications/media_tickets.py | 77 ++++++++ .../services/chat_notifications.py | 27 ++- .../services/chat_read_marker.py | 39 +++- backend/eusrr_backend/channels_jwt.py | 26 +++ backend/eusrr_backend/logging_utils.py | 16 +- backend/eusrr_backend/settings.py | 4 +- backend/notifications/api/views.py | 2 + backend/realtime/consumers.py | 8 + .../communications/test_communications_api.py | 169 ++++++++++++++++++ .../requests_app/test_request_attachments.py | 15 +- backend/tests/api/v1/test_search.py | 14 +- backend/tests/realtime/test_user_consumer.py | 35 ++++ backend/tests/test_logging.py | 51 ++++++ frontend/AGENTS.md | 9 + frontend/CLAUDE.md | 1 + frontend/src/app/messages/[chatId]/page.tsx | 3 +- .../messages/ChatMessageAttachment.tsx | 46 +++-- .../components/messages/MessageComposer.tsx | 11 +- .../RequestAttachmentPreviewModal.tsx | 9 +- .../src/hooks/useAuthenticatedMediaUrl.ts | 84 +++++++++ frontend/src/hooks/useMessagesChatList.ts | 24 +++ frontend/src/hooks/useWebSocket.ts | 14 +- frontend/src/lib/authenticated-files.test.js | 14 ++ frontend/src/lib/authenticated-files.ts | 14 ++ frontend/src/lib/websocketManager.test.js | 7 + frontend/src/lib/websocketManager.ts | 17 +- ops/deploy/tests/test_deploy_lifecycle.py | 6 +- 32 files changed, 846 insertions(+), 40 deletions(-) create mode 100644 backend/communications/api/media.py create mode 100644 backend/communications/media_tickets.py create mode 100644 frontend/AGENTS.md create mode 100644 frontend/CLAUDE.md create mode 100644 frontend/src/hooks/useAuthenticatedMediaUrl.ts diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index 3ae2daea..8b93b7a4 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -1,6 +1,8 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter +from communications.api.media import ChatAttachmentMediaAPIView + # Communications ViewSets (перенесены в communications.api) from communications.api.viewsets import ChatViewSet, MessageViewSet, PollViewSet @@ -100,6 +102,11 @@ router.register(r"communications/polls", PollViewSet, basename="polls") urlpatterns = [ + path( + "communications/chat-media///", + ChatAttachmentMediaAPIView.as_view(), + name="communications-chat-media", + ), path("auth/", include("api.auth.urls")), path( "attendance/logstorm/analyze/", diff --git a/backend/communications/api/media.py b/backend/communications/api/media.py new file mode 100644 index 00000000..eeccfbb0 --- /dev/null +++ b/backend/communications/api/media.py @@ -0,0 +1,77 @@ +"""Credential-scoped streaming endpoints for protected chat media.""" + +from django.contrib.auth import get_user_model +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema +from rest_framework.permissions import AllowAny +from rest_framework.views import APIView + +from communications.media_delivery import chat_file_response +from communications.media_tickets import ( + ChatMediaTicketError, + consume_chat_media_ticket, +) +from communications.models import Message, MessageAttachment +from communications.utils import user_can_access_chat + +User = get_user_model() + + +class ChatAttachmentMediaAPIView(APIView): + """Serve inline audio/video after validating a short-lived scoped ticket.""" + + authentication_classes = [] + permission_classes = [AllowAny] + + @extend_schema( + parameters=[ + OpenApiParameter( + "ticket", + OpenApiTypes.STR, + OpenApiParameter.QUERY, + required=True, + ) + ], + responses={(200, "application/octet-stream"): OpenApiTypes.BINARY}, + ) + def get(self, request, message_id: int, attachment_id: int): + raw_ticket = request.query_params.get("ticket") + if not isinstance(raw_ticket, str) or not raw_ticket: + raise Http404("Медиафайл недоступен") + try: + identity = consume_chat_media_ticket( + raw_ticket, + message_id=message_id, + attachment_id=attachment_id, + ) + except ChatMediaTicketError as error: + raise Http404("Медиафайл недоступен") from error + + user = get_object_or_404(User, pk=identity.user_id, is_active=True) + message = get_object_or_404( + Message.objects.select_related("chat"), + pk=message_id, + is_deleted=False, + ) + if not user_can_access_chat(message.chat, user): + raise Http404("Медиафайл недоступен") + + attachment = get_object_or_404( + MessageAttachment, + pk=attachment_id, + message=message, + ) + if attachment.file_type not in {"audio", "video"} or not attachment.file: + raise Http404("Медиафайл недоступен") + + try: + response = chat_file_response( + attachment.file, + filename=attachment.file_name, + as_attachment=False, + ) + except (OSError, ValueError) as error: + raise Http404("Медиафайл недоступен") from error + response["Referrer-Policy"] = "no-referrer" + return response diff --git a/backend/communications/api/permissions.py b/backend/communications/api/permissions.py index e3888890..0097a864 100644 --- a/backend/communications/api/permissions.py +++ b/backend/communications/api/permissions.py @@ -103,7 +103,11 @@ class MessagePermission(permissions.BasePermission): def has_object_permission(self, request, view, obj): """Проверка прав на конкретное сообщение""" # Custom actions с POST (react, unreact) - доступны всем участникам - if hasattr(view, "action") and view.action in ["react", "unreact"]: + if hasattr(view, "action") and view.action in [ + "react", + "unreact", + "attachment_playback", + ]: # Для реакций достаточно быть участником чата # (даже гость может ставить реакции) return rules.test_rule("communications.view_message", request.user, obj) diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index 90f0c5c5..a23b620a 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -36,6 +36,10 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from communications.media_delivery import chat_file_response +from communications.media_tickets import ( + issue_chat_media_ticket, + media_ticket_max_age_seconds, +) from communications.serialization import ( serialize_message, serialize_poll, @@ -728,6 +732,7 @@ class ChatViewSet(viewsets.ModelViewSet): ChatNotificationReadService().mark_chat_notifications_as_read( user=user, chat_id=chat.id, + up_to_message_id=messages[-1].id if messages else None, ) @action(detail=True, methods=["get"]) @@ -1320,7 +1325,11 @@ class MessageViewSet(viewsets.ModelViewSet): Детальная проверка доступа выполняется через MessagePermission """ - if self.action in ("attachment", "attachment_thumbnail"): + if self.action in ( + "attachment", + "attachment_thumbnail", + "attachment_playback", + ): # Media uses object-level rules plus an explicit current chat ACL. # A participant-list prefilter misses global and contextual chats. return Message.objects.select_related("chat", "author") @@ -1412,6 +1421,46 @@ class MessageViewSet(viewsets.ModelViewSet): """Download only after current message/chat ACL checks, including comments.""" return self._attachment_response(request, attachment_id) + @extend_schema( + parameters=[ + OpenApiParameter("attachment_id", OpenApiTypes.INT, OpenApiParameter.PATH) + ], + request=None, + responses={200: OpenApiTypes.OBJECT}, + ) + @action( + detail=True, + methods=["post"], + url_path=r"attachments/(?P\d+)/playback", + ) + def attachment_playback(self, request, pk=None, attachment_id=None): + """Issue a scoped URL for native browser audio/video streaming.""" + message = self.get_object() + if message.is_deleted or not user_can_access_chat(message.chat, request.user): + raise Http404("Вложение недоступно") + attachment = get_object_or_404( + MessageAttachment, + pk=attachment_id, + message=message, + ) + if attachment.file_type not in {"audio", "video"} or not attachment.file: + raise Http404("Вложение недоступно") + + ticket = issue_chat_media_ticket( + user_id=request.user.id, + message_id=message.id, + attachment_id=attachment.id, + ) + return Response( + { + "url": ( + "/api/v1/communications/chat-media/" + f"{message.id}/{attachment.id}/?ticket={ticket}" + ), + "expires_in": media_ticket_max_age_seconds(), + } + ) + @extend_schema( parameters=[ OpenApiParameter("attachment_id", OpenApiTypes.INT, OpenApiParameter.PATH) diff --git a/backend/communications/media_delivery.py b/backend/communications/media_delivery.py index 9d48a998..1526f015 100644 --- a/backend/communications/media_delivery.py +++ b/backend/communications/media_delivery.py @@ -15,11 +15,12 @@ def attachment_endpoint(attachment, *, thumbnail=False): ) -def chat_file_response(file, *, filename): +def chat_file_response(file, *, filename, as_attachment=True): return protected_file_response( file, filename=filename, storage_directory="chat_attachments", internal_prefix="/_protected_chat_attachments/", use_x_accel=getattr(settings, "CHAT_ATTACHMENTS_USE_X_ACCEL_REDIRECT", False), + as_attachment=as_attachment, ) diff --git a/backend/communications/media_tickets.py b/backend/communications/media_tickets.py new file mode 100644 index 00000000..fa73ee37 --- /dev/null +++ b/backend/communications/media_tickets.py @@ -0,0 +1,77 @@ +"""Short-lived, scoped credentials for inline chat media playback.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from django.conf import settings +from django.core import signing + +MEDIA_TICKET_SALT = "eusrr.chat-media.ticket.v1" +DEFAULT_MEDIA_TICKET_MAX_AGE_SECONDS = 15 * 60 + + +class ChatMediaTicketError(ValueError): + pass + + +@dataclass(frozen=True) +class ChatMediaTicketIdentity: + user_id: int + message_id: int + attachment_id: int + + +def media_ticket_max_age_seconds() -> int: + configured = getattr( + settings, + "CHAT_MEDIA_TICKET_MAX_AGE_SECONDS", + DEFAULT_MEDIA_TICKET_MAX_AGE_SECONDS, + ) + try: + return max(30, min(int(configured), 60 * 60)) + except (TypeError, ValueError): + return DEFAULT_MEDIA_TICKET_MAX_AGE_SECONDS + + +def issue_chat_media_ticket( + *, user_id: int, message_id: int, attachment_id: int +) -> str: + return signing.dumps( + { + "uid": int(user_id), + "mid": int(message_id), + "aid": int(attachment_id), + }, + salt=MEDIA_TICKET_SALT, + compress=True, + ) + + +def consume_chat_media_ticket( + raw_ticket: str, + *, + message_id: int, + attachment_id: int, +) -> ChatMediaTicketIdentity: + try: + payload = signing.loads( + raw_ticket, + salt=MEDIA_TICKET_SALT, + max_age=media_ticket_max_age_seconds(), + ) + identity = ChatMediaTicketIdentity( + user_id=int(payload["uid"]), + message_id=int(payload["mid"]), + attachment_id=int(payload["aid"]), + ) + except (signing.BadSignature, KeyError, TypeError, ValueError) as error: + raise ChatMediaTicketError("invalid_or_expired_ticket") from error + + if identity.user_id <= 0: + raise ChatMediaTicketError("invalid_user") + if identity.message_id != int(message_id): + raise ChatMediaTicketError("message_mismatch") + if identity.attachment_id != int(attachment_id): + raise ChatMediaTicketError("attachment_mismatch") + return identity diff --git a/backend/communications/services/chat_notifications.py b/backend/communications/services/chat_notifications.py index c9c631f2..29a548bc 100644 --- a/backend/communications/services/chat_notifications.py +++ b/backend/communications/services/chat_notifications.py @@ -34,15 +34,27 @@ class ChatNotificationReadService: *, user, chat_id: int, + up_to_message_id: int | None = None, ) -> ChatNotificationReadResult: queryset = self._get_unread_queryset(user_id=user.id, chat_id=chat_id) - notification_ids = list(queryset.values_list("id", flat=True)) + candidates = list(queryset.only("id", "data")) + notification_ids = [ + notification.id + for notification in candidates + if self._is_at_or_before_message( + notification.data, + up_to_message_id=up_to_message_id, + ) + ] if not notification_ids: return ChatNotificationReadResult(notification_ids=[], count=0) read_at = timezone.now() - count = queryset.update(unread=False, timestamp_read=read_at) + count = queryset.filter(id__in=notification_ids).update( + unread=False, + timestamp_read=read_at, + ) invalidate_unread_summary(user.id) def notify_after_commit(): @@ -61,6 +73,17 @@ class ChatNotificationReadService: count=count, ) + @staticmethod + def _is_at_or_before_message(data, *, up_to_message_id: int | None) -> bool: + if up_to_message_id is None: + return True + if not isinstance(data, dict) or data.get("message_id") is None: + return True + try: + return int(data["message_id"]) <= up_to_message_id + except (TypeError, ValueError): + return False + def _get_unread_queryset(self, *, user_id: int, chat_id: int): return Notification.objects.filter( Q(data__chat_id=chat_id) | Q(data__chat_id=str(chat_id)), diff --git a/backend/communications/services/chat_read_marker.py b/backend/communications/services/chat_read_marker.py index af3bc9a6..52cb4ae1 100644 --- a/backend/communications/services/chat_read_marker.py +++ b/backend/communications/services/chat_read_marker.py @@ -4,6 +4,7 @@ from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from django.db import transaction +from communications.mentions import mentioned_user_ids from communications.models import ChatReadState from communications.serialization import serialize_message_reader @@ -13,6 +14,7 @@ logger = logging.getLogger(__name__) class ChatReadMarkerService: """Advance a user's chat read marker and notify every active client.""" + @transaction.atomic def mark_read(self, *, chat, user, message) -> bool: read_state, created = ChatReadState.objects.get_or_create( chat=chat, @@ -25,15 +27,30 @@ class ChatReadMarkerService: ) if not created: + read_state = ChatReadState.objects.select_for_update().get(pk=read_state.pk) if ( read_state.last_read_message_id and message.id <= read_state.last_read_message_id ): return False + newly_read = chat.messages.filter( + is_deleted=False, + id__lte=message.id, + ).exclude(author=user) + if read_state.last_read_message_id: + newly_read = newly_read.filter(id__gt=read_state.last_read_message_id) + newly_read_count = newly_read.count() + newly_read_mentions = sum( + user.id in mentioned_user_ids(mentions) + for mentions in newly_read.values_list("mentions", flat=True) + ) read_state.last_read_message = message - read_state.unread_count = 0 - read_state.unread_mention_count = 0 + read_state.unread_count = max(0, read_state.unread_count - newly_read_count) + read_state.unread_mention_count = max( + 0, + read_state.unread_mention_count - newly_read_mentions, + ) read_state.save( update_fields=[ "last_read_message", @@ -42,6 +59,24 @@ class ChatReadMarkerService: "updated_at", ] ) + else: + remaining = chat.messages.filter( + is_deleted=False, + id__gt=message.id, + ).exclude(author=user) + read_state.unread_count = remaining.count() + read_state.unread_mention_count = sum( + user.id in mentioned_user_ids(mentions) + for mentions in remaining.values_list("mentions", flat=True) + ) + if read_state.unread_count or read_state.unread_mention_count: + read_state.save( + update_fields=[ + "unread_count", + "unread_mention_count", + "updated_at", + ] + ) logger.info( "Chat read marker advanced", diff --git a/backend/eusrr_backend/channels_jwt.py b/backend/eusrr_backend/channels_jwt.py index 48da3d63..b062fb45 100644 --- a/backend/eusrr_backend/channels_jwt.py +++ b/backend/eusrr_backend/channels_jwt.py @@ -20,6 +20,31 @@ from .logging_utils import update_log_context logger = logging.getLogger(__name__) +_SAFE_TICKET_REJECTION_REASONS = { + "attachment_mismatch", + "invalid_or_expired_ticket", + "invalid_user", + "message_mismatch", + "session_revoked", + "ticket_already_used", + "ticket_store_unavailable", + "ticket_too_long", + "user_unavailable", +} + + +def _safe_rejection_reason(error: Exception) -> str: + if isinstance(error, WebSocketTicketError): + candidate = str(error) + if candidate in _SAFE_TICKET_REJECTION_REASONS: + return candidate + return "invalid_ticket" + if isinstance(error, TokenError): + return "invalid_token" + if isinstance(error, AuthenticationFailed): + return "authentication_failed" + return "invalid_credential" + class JWTAuthMiddleware(BaseMiddleware): """Auth для Channels по JWT. @@ -95,6 +120,7 @@ class JWTAuthMiddleware(BaseMiddleware): "event": "websocket.authentication.rejected", "error_type": type(error).__name__, "credential_type": credential_type, + "reason": _safe_rejection_reason(error), }, ) user = scope.get("user") diff --git a/backend/eusrr_backend/logging_utils.py b/backend/eusrr_backend/logging_utils.py index 51cc6118..c70f4c8c 100644 --- a/backend/eusrr_backend/logging_utils.py +++ b/backend/eusrr_backend/logging_utils.py @@ -475,10 +475,11 @@ class WebSocketLoggingMiddleware: ) started = monotonic() accepted = False - close_code = None + requested_close_code = None + disconnect_code = None async def logged_send(message): - nonlocal accepted, close_code + nonlocal accepted, requested_close_code message_type = message.get("type") if message_type == "websocket.accept": accepted = True @@ -487,14 +488,14 @@ class WebSocketLoggingMiddleware: extra={"event": "websocket.connection.accepted"}, ) elif message_type == "websocket.close": - close_code = message.get("code", 1000) + requested_close_code = message.get("code", 1000) await send(message) async def logged_receive(): - nonlocal close_code + nonlocal disconnect_code message = await receive() if message.get("type") == "websocket.disconnect": - close_code = message.get("code", 1000) + disconnect_code = message.get("code", 1000) return message try: @@ -510,6 +511,9 @@ class WebSocketLoggingMiddleware: raise finally: duration_ms = round((monotonic() - started) * 1000, 2) + close_code = ( + disconnect_code if disconnect_code is not None else requested_close_code + ) level = logging.WARNING if close_code not in (None, 1000) else logging.INFO self.logger.log( level, @@ -518,6 +522,8 @@ class WebSocketLoggingMiddleware: "event": "websocket.connection.closed", "accepted": accepted, "close_code": close_code, + "requested_close_code": requested_close_code, + "disconnect_code": disconnect_code, "duration_ms": duration_ms, }, ) diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 6a07de51..120aa658 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -22,7 +22,9 @@ SECRET_KEY_FALLBACKS = json.loads(os.getenv("DJANGO_SECRET_KEY_FALLBACKS", "[]") if not isinstance(SECRET_KEY_FALLBACKS, list) or not all( isinstance(key, str) and key for key in SECRET_KEY_FALLBACKS ): - raise ImproperlyConfigured("DJANGO_SECRET_KEY_FALLBACKS must be a JSON list of keys") + raise ImproperlyConfigured( + "DJANGO_SECRET_KEY_FALLBACKS must be a JSON list of keys" + ) DEBUG = os.getenv("DJANGO_DEBUG", "false").lower() == "true" diff --git a/backend/notifications/api/views.py b/backend/notifications/api/views.py index b7265577..5abfb663 100644 --- a/backend/notifications/api/views.py +++ b/backend/notifications/api/views.py @@ -718,6 +718,7 @@ def push_mark_chat_read(request): read_result = ChatNotificationReadService().mark_chat_notifications_as_read( user=notification.recipient, chat_id=chat.id, + up_to_message_id=message.id, ) return Response( { @@ -842,6 +843,7 @@ def push_reply_to_chat(request): ChatNotificationReadService().mark_chat_notifications_as_read( user=notification.recipient, chat_id=chat.id, + up_to_message_id=source_message.id, ) return Response( { diff --git a/backend/realtime/consumers.py b/backend/realtime/consumers.py index 7849db49..d486b2d2 100644 --- a/backend/realtime/consumers.py +++ b/backend/realtime/consumers.py @@ -189,6 +189,14 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer): chat = await self._get_chat(self.active_chat_id) if chat: await self._set_typing_status(chat, self.user, False) + await self.channel_layer.group_send( + f"chat_{chat.id}", + { + "type": "chat_user_stopped_typing", + "chat_id": chat.id, + "user_id": self.user.id, + }, + ) if self.user: await self._clear_active_chat_presence() diff --git a/backend/tests/api/v1/communications/test_communications_api.py b/backend/tests/api/v1/communications/test_communications_api.py index 27b2ac5a..aca70f70 100644 --- a/backend/tests/api/v1/communications/test_communications_api.py +++ b/backend/tests/api/v1/communications/test_communications_api.py @@ -2041,6 +2041,132 @@ class TestMessageAttachments: ) assert attachment["mime_type"] == mime_type + def test_video_playback_uses_scoped_reusable_ticket( + self, + auth_client, + private_chat, + user1, + settings, + ): + from django.core.files.uploadedfile import SimpleUploadedFile + + message = Message.objects.create( + chat=private_chat, + author=user1, + content="Video", + has_attachments=True, + ) + attachment = MessageAttachment.objects.create( + message=message, + file=SimpleUploadedFile( + "preview.mp4", + b"video-bytes", + content_type="video/mp4", + ), + file_name="preview.mp4", + file_size=11, + mime_type="video/mp4", + file_type="video", + ) + + ticket_response = auth_client.post( + "/api/v1/communications/messages/" + f"{message.id}/attachments/{attachment.id}/playback/" + ) + + assert ticket_response.status_code == status.HTTP_200_OK + assert ticket_response.data["expires_in"] == 900 + playback_url = ticket_response.data["url"] + assert playback_url.startswith( + f"/api/v1/communications/chat-media/{message.id}/{attachment.id}/?ticket=" + ) + + anonymous = APIClient() + first = anonymous.get(playback_url) + second = anonymous.get(playback_url) + assert first.status_code == status.HTTP_200_OK + assert second.status_code == status.HTTP_200_OK + assert first["Content-Type"] == "video/mp4" + assert first["Content-Disposition"].startswith("inline;") + assert first["Referrer-Policy"] == "no-referrer" + assert b"".join(first.streaming_content) == b"video-bytes" + second.close() + + settings.CHAT_ATTACHMENTS_USE_X_ACCEL_REDIRECT = True + accelerated = anonymous.get(playback_url, HTTP_RANGE="bytes=0-3") + assert accelerated.status_code == status.HTTP_200_OK + assert accelerated["X-Accel-Redirect"].startswith( + "/_protected_chat_attachments/" + ) + assert accelerated["Content-Disposition"].startswith("inline;") + + def test_video_playback_rejects_missing_tampered_and_unauthorized_tickets( + self, + auth_client, + private_chat, + user1, + user2, + user3, + ): + from django.core.files.uploadedfile import SimpleUploadedFile + + message = Message.objects.create( + chat=private_chat, + author=user1, + content="Private video", + has_attachments=True, + ) + attachment = MessageAttachment.objects.create( + message=message, + file=SimpleUploadedFile( + "private.mp4", + b"private-video", + content_type="video/mp4", + ), + file_name="private.mp4", + file_size=13, + mime_type="video/mp4", + file_type="video", + ) + playback_path = ( + f"/api/v1/communications/chat-media/{message.id}/{attachment.id}/" + ) + + assert APIClient().get(playback_path).status_code == status.HTTP_404_NOT_FOUND + + ticket_response = auth_client.post( + "/api/v1/communications/messages/" + f"{message.id}/attachments/{attachment.id}/playback/" + ) + playback_url = ticket_response.data["url"] + assert ( + APIClient().get(f"{playback_url}tampered").status_code + == status.HTTP_404_NOT_FOUND + ) + + outsider = APIClient() + outsider.force_authenticate(user=user3) + assert ( + outsider.post( + "/api/v1/communications/messages/" + f"{message.id}/attachments/{attachment.id}/playback/" + ).status_code + == status.HTTP_403_FORBIDDEN + ) + + participant = APIClient() + participant.force_authenticate(user=user2) + participant_ticket = participant.post( + "/api/v1/communications/messages/" + f"{message.id}/attachments/{attachment.id}/playback/" + ) + assert participant_ticket.status_code == status.HTTP_200_OK + private_chat.participants.remove(user2) + assert ( + APIClient().get(participant_ticket.data["url"]).status_code + == status.HTTP_404_NOT_FOUND + ) + # ==================== Chat Creation Tests ==================== @@ -2297,6 +2423,49 @@ class TestMetadata: assert read_state.last_read_message_id == msg2.id assert read_state.last_read_at is not None + def test_partial_mark_read_preserves_later_unread_messages_and_notifications( + self, + auth_client, + private_chat, + user1, + user2, + ): + messages = [ + Message.objects.create( + chat=private_chat, + author=user2, + content=f"Unread {index}", + ) + for index in range(3) + ] + notifications = [ + Notification.objects.create( + recipient=user1, + verb="chat_new_message", + description=f"Unread {index}", + data={"chat_id": private_chat.id, "message_id": message.id}, + ) + for index, message in enumerate(messages) + ] + + response = auth_client.post( + f"/api/v1/communications/chats/{private_chat.pk}/mark-read/", + {"message_id": messages[1].id}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + read_state = ChatReadState.objects.get(chat=private_chat, user=user1) + assert read_state.last_read_message_id == messages[1].id + assert read_state.unread_count == 1 + for notification in notifications: + notification.refresh_from_db() + assert [notification.unread for notification in notifications] == [ + False, + False, + True, + ] + @pytest.mark.parametrize( "verb", [ diff --git a/backend/tests/api/v1/requests_app/test_request_attachments.py b/backend/tests/api/v1/requests_app/test_request_attachments.py index ed1ded31..21f6ae67 100644 --- a/backend/tests/api/v1/requests_app/test_request_attachments.py +++ b/backend/tests/api/v1/requests_app/test_request_attachments.py @@ -22,7 +22,12 @@ def attached_request(make_user, make_request): obj.recipients.add(recipient) obj.cc_users.add(cc) obj.attachment.save("private-note.txt", ContentFile(b"private request contents")) - return obj, {"author": author, "recipient": recipient, "cc": cc, "outsider": outsider} + return obj, { + "author": author, + "recipient": recipient, + "cc": cc, + "outsider": outsider, + } def download_url(obj): @@ -31,7 +36,9 @@ def download_url(obj): @pytest.mark.parametrize("role", ["author", "recipient", "cc"]) @pytest.mark.parametrize("state", [RequestStatus.PENDING, RequestStatus.APPROVED]) -def test_participants_download_with_private_headers(attached_request, auth_client, role, state): +def test_participants_download_with_private_headers( + attached_request, auth_client, role, state +): obj, users = attached_request obj.status = state if state == RequestStatus.APPROVED: @@ -59,7 +66,9 @@ def test_draft_is_author_only(attached_request, auth_client, role): def test_unauthenticated_outsider_and_unrelated_admin_cannot_open_storage( - attached_request, auth_client, make_user, + attached_request, + auth_client, + make_user, ): obj, users = attached_request admin = make_user(is_staff=True, is_superuser=True) diff --git a/backend/tests/api/v1/test_search.py b/backend/tests/api/v1/test_search.py index f79e42e4..35d0ee38 100644 --- a/backend/tests/api/v1/test_search.py +++ b/backend/tests/api/v1/test_search.py @@ -265,15 +265,23 @@ class TestSearchAPI: hidden_response = api_client.get("/api/v1/search/", {"q": "it"}) assert hidden_response.status_code == status.HTTP_200_OK - assert not hidden_response.data["results"] + hidden_documents = [ + result + for result in hidden_response.data["results"] + if result["model_name"] == "document" + ] + assert not hidden_documents assert hidden_response.data["counts"].get("document", 0) == 0 document.recipients.add(regular_user) visible_response = api_client.get("/api/v1/search/", {"q": "it"}) assert visible_response.status_code == status.HTTP_200_OK - assert [result["object_id"] for result in visible_response.data["results"]] == [ - document.pk + visible_documents = [ + result + for result in visible_response.data["results"] + if result["model_name"] == "document" ] + assert [result["object_id"] for result in visible_documents] == [document.pk] assert visible_response.data["counts"]["document"] == 1 def test_search_finds_post(self, api_client, sample_data): diff --git a/backend/tests/realtime/test_user_consumer.py b/backend/tests/realtime/test_user_consumer.py index 497367f7..18cc3bca 100644 --- a/backend/tests/realtime/test_user_consumer.py +++ b/backend/tests/realtime/test_user_consumer.py @@ -407,6 +407,41 @@ class TestUserConsumerTypingIndicator: # Отключаемся (должно сбросить typing) await communicator.disconnect() + async def test_disconnect_notifies_other_viewers_that_typing_stopped( + self, + ws_communicator, + user, + user2, + test_chat_with_users, + ): + sender = await ws_communicator(user=user) + recipient = await ws_communicator(user=user2) + assert (await sender.connect())[0] + assert (await recipient.connect())[0] + + await sender.send_json_to( + {"action": "open_chat", "chat_id": test_chat_with_users.id} + ) + await sender.receive_json_from(timeout=5) + await recipient.send_json_to( + {"action": "open_chat", "chat_id": test_chat_with_users.id} + ) + await recipient.receive_json_from(timeout=5) + + await sender.send_json_to({"action": "typing"}) + typing_started = await recipient.receive_json_from(timeout=5) + assert typing_started["type"] == "typing_start" + assert typing_started["user_id"] == user.id + + await sender.disconnect() + typing_stopped = await recipient.receive_json_from(timeout=5) + assert typing_stopped == { + "type": "typing_stop", + "chat_id": test_chat_with_users.id, + "user_id": user.id, + } + await recipient.disconnect() + @pytest.mark.asyncio class TestUserConsumerMessageActions: diff --git a/backend/tests/test_logging.py b/backend/tests/test_logging.py index 3eda1b9f..d049f0d3 100644 --- a/backend/tests/test_logging.py +++ b/backend/tests/test_logging.py @@ -542,9 +542,36 @@ async def test_websocket_middleware_logs_lifecycle_without_query_string(caplog): "websocket.connection.closed", ] assert caplog.records[-1].close_code == 1000 + assert caplog.records[-1].requested_close_code is None + assert caplog.records[-1].disconnect_code == 1000 assert get_log_context() == {} +@pytest.mark.asyncio +async def test_websocket_middleware_keeps_requested_and_observed_close_codes(caplog): + async def app(scope, receive, send): + await send({"type": "websocket.close", "code": 4401}) + await receive() + + async def receive(): + return {"type": "websocket.disconnect", "code": 1006} + + async def send(_message): + return None + + with caplog.at_level(logging.WARNING, logger="eusrr.websocket"): + await WebSocketLoggingMiddleware(app)( + {"type": "websocket", "path": "/ws/", "headers": []}, + receive, + send, + ) + + closed = caplog.records[-1] + assert closed.close_code == 1006 + assert closed.requested_close_code == 4401 + assert closed.disconnect_code == 1006 + + @pytest.mark.asyncio @override_settings( LOG_TRUST_X_FORWARDED_FOR=True, LOG_TRUSTED_PROXY_CIDRS=("10.0.0.0/24",) @@ -585,6 +612,30 @@ async def test_websocket_session_authentication_binds_user_context(): reset_log_context(token) +@pytest.mark.django_db(transaction=True) +@pytest.mark.asyncio +async def test_websocket_ticket_rejection_logs_safe_reason_code(caplog): + async def app(scope, receive, send): + assert scope["user"].is_authenticated is False + + with caplog.at_level(logging.INFO, logger="eusrr_backend.channels_jwt"): + await JWTAuthMiddleware(app)( + { + "type": "websocket", + "headers": [], + "query_string": b"ticket=invalid-signature", + }, + None, + None, + ) + + rejected = caplog.records[-1] + assert rejected.event == "websocket.authentication.rejected" + assert rejected.credential_type == "ticket" + assert rejected.reason == "invalid_or_expired_ticket" + assert "invalid-signature" not in rejected.getMessage() + + def test_celery_lifecycle_has_task_context_and_duration(caplog): sender = SimpleNamespace(name="documents.tasks.index") diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 00000000..643577df --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 42f7367b..a8a2b26e 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -535,7 +535,7 @@ function MessageDialogPageContent() { /* ────────────────────────────────────────────────────── * WebSocket * ────────────────────────────────────────────────────── */ - const { isConnected, sendTyping, handlers } = useWebSocket({ + const { isConnected, sendTyping, stopTyping, handlers } = useWebSocket({ chatId: chatId || null, autoConnect: true, onReconnectExhausted: handleReconnectExhausted, @@ -2218,6 +2218,7 @@ function MessageDialogPageContent() { onInputClick={() => setShowComposerEmojiPicker(false)} onChangeMessage={setMessageText} onTyping={sendTyping} + onStopTyping={stopTyping} onSend={handleSend} onCancelEdit={handleCancelEdit} onCancelReply={() => setReplyTo(null)} diff --git a/frontend/src/components/messages/ChatMessageAttachment.tsx b/frontend/src/components/messages/ChatMessageAttachment.tsx index 6ea69482..e6643833 100644 --- a/frontend/src/components/messages/ChatMessageAttachment.tsx +++ b/frontend/src/components/messages/ChatMessageAttachment.tsx @@ -6,6 +6,7 @@ import { useState } from "react"; import { AuthenticatedDownloadLink } from "@/components/AuthenticatedDownloadLink"; import MediaPreviewModal from "@/components/shared/MediaPreviewModal"; import { useAuthenticatedFileUrl } from "@/hooks/useAuthenticatedFileUrl"; +import { useAuthenticatedMediaUrl } from "@/hooks/useAuthenticatedMediaUrl"; import { useNearViewport } from "@/hooks/useNearViewport"; import { formatFileSize, @@ -38,6 +39,7 @@ export default function ChatMessageAttachment({ const [useOriginal, setUseOriginal] = useState(false); const [previewOpen, setPreviewOpen] = useState(false); const [playRequested, setPlayRequested] = useState(false); + const [playbackRetryCount, setPlaybackRetryCount] = useState(0); const [mediaFailed, setMediaFailed] = useState(false); const hasThumbnail = Boolean(attachment.thumbnail) && !useOriginal; const thumbnail = useAuthenticatedFileUrl( @@ -53,14 +55,25 @@ export default function ChatMessageAttachment({ // when playback is explicitly requested; images use their thumbnail first. const fullFile = useAuthenticatedFileUrl( endpoint, + !attachment.is_local && image && !needOriginal && previewOpen, + ); + const playback = useAuthenticatedMediaUrl( + `${endpoint}playback/`, !attachment.is_local && - ((image && !needOriginal && previewOpen) || - ((video || audio) && playRequested)), + ((video && isNearViewport) || (audio && playRequested)), ); const fail = () => { setMediaFailed(true); onError(attachment.id); }; + const retryPlaybackOrFail = () => { + if (playbackRetryCount === 0) { + setPlaybackRetryCount(1); + playback.refresh(); + return; + } + fail(); + }; const imageSource = needOriginal ? originalImage : thumbnail; const imageUrl = imageSource.url; const previewUrl = needOriginal ? originalImage.url : fullFile.url; @@ -120,41 +133,45 @@ export default function ChatMessageAttachment({ Загрузка изображения… ) - ) : (video || audio) && !mediaFailed && !fullFile.error ? ( - fullFile.url ? ( + ) : (video || audio) && !mediaFailed && !playback.error ? ( + playback.url ? ( video ? (