Restore reliable chat realtime and media playback
Some checks failed
Deploy production / deploy (push) Failing after 14m49s

This commit is contained in:
lizerk 2026-09-10 20:23:39 +03:00
parent 1f64bb9dee
commit 2768d2f1b3
32 changed files with 846 additions and 40 deletions

View File

@ -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/<int:message_id>/<int:attachment_id>/",
ChatAttachmentMediaAPIView.as_view(),
name="communications-chat-media",
),
path("auth/", include("api.auth.urls")),
path(
"attendance/logstorm/analyze/",

View File

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

View File

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

View File

@ -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<attachment_id>\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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

9
frontend/AGENTS.md Normal file
View File

@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# 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.
<!-- END:nextjs-agent-rules -->

1
frontend/CLAUDE.md Normal file
View File

@ -0,0 +1 @@
@AGENTS.md

View File

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

View File

@ -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({
Загрузка изображения…
</div>
)
) : (video || audio) && !mediaFailed && !fullFile.error ? (
fullFile.url ? (
) : (video || audio) && !mediaFailed && !playback.error ? (
playback.url ? (
video ? (
<video
controls
playsInline
preload="metadata"
src={fullFile.url}
src={playback.url}
className="max-h-64 w-full rounded-lg bg-black"
onError={fail}
onError={retryPlaybackOrFail}
onLoadedData={() => onLoad(attachment.id)}
/>
) : (
<audio
controls
src={fullFile.url}
src={playback.url}
className="w-full"
onError={fail}
onError={retryPlaybackOrFail}
onCanPlay={() => onLoad(attachment.id)}
/>
)
) : (
<button
type="button"
disabled={playRequested}
onClick={() => setPlayRequested(true)}
disabled={video || playRequested}
onClick={() => {
if (audio) setPlayRequested(true);
}}
className="app-surface-muted flex w-full items-center gap-2 rounded-lg px-3 py-3 text-sm"
>
{playRequested ? (
{video || playRequested ? (
<Loader2 size={20} className="animate-spin" />
) : (
<PlayCircle size={20} />
)}
<span className="truncate">
{playRequested ? "Загрузка…" : attachment.file_name}
{video || playRequested
? "Загрузка превью…"
: attachment.file_name}
</span>
</button>
)
@ -164,10 +181,13 @@ export default function ChatMessageAttachment({
{(image || video || audio) &&
!mediaFailed &&
!imageSource.error &&
(!fullFile.error || image) && <div className="mt-1">{download}</div>}
(!playback.error || image) && <div className="mt-1">{download}</div>}
{fullFile.error && image && (
<p className="text-sm text-amber-700">{fullFile.error}</p>
)}
{playback.error && (video || audio) && (
<p className="text-sm text-amber-700">{playback.error}</p>
)}
{previewOpen && image && previewUrl && (
<MediaPreviewModal
preview={{

View File

@ -61,6 +61,7 @@ type MessageComposerProps = {
onInputClick: () => void;
onChangeMessage: (value: string) => void;
onTyping: () => void;
onStopTyping: () => void;
onSend: (mentionedUserIds: number[]) => Promise<boolean>;
onCancelEdit: () => void;
onCancelReply: () => void;
@ -89,6 +90,7 @@ export default function MessageComposer({
onInputClick,
onChangeMessage,
onTyping,
onStopTyping,
onSend,
onCancelEdit,
onCancelReply,
@ -195,10 +197,13 @@ export default function MessageComposer({
activeMentionUserIds(messageText, selectedMentions),
);
if (!sent) return;
onStopTyping();
setSelectedMentions([]);
setMentionTrigger(null);
setMentionCandidates([]);
}, [messageText, onSend, selectedMentions]);
}, [messageText, onSend, onStopTyping, selectedMentions]);
useEffect(() => () => onStopTyping(), [onStopTyping]);
useEffect(() => {
if (!isAttachmentMenuOpen) return;
@ -478,7 +483,8 @@ export default function MessageComposer({
value={messageText}
onChange={(event) => {
onChangeMessage(event.target.value);
onTyping();
if (event.target.value.trim()) onTyping();
else onStopTyping();
updateMentionTrigger(
event.target.value,
event.target.selectionStart ?? event.target.value.length,
@ -495,6 +501,7 @@ export default function MessageComposer({
);
}}
onPaste={handlePaste}
onBlur={onStopTyping}
onKeyDown={(event) => {
if (isMentionMenuOpen && mentionCandidates.length > 0) {
if (event.key === "ArrowDown") {

View File

@ -16,13 +16,18 @@ export function RequestAttachmentPreviewModal({
onClose,
preview,
}: RequestAttachmentPreviewModalProps) {
const { url: previewUrl, error } = useAuthenticatedFileUrl(preview?.url || "");
const { url: previewUrl, error } = useAuthenticatedFileUrl(
preview?.url || "",
);
if (!preview) return null;
if (!previewUrl) {
return (
<Modal isOpen onClose={onClose} title={preview.name} size="lg">
<p role={error ? "alert" : "status"} className="app-text-muted py-8 text-center">
<p
role={error ? "alert" : "status"}
className="app-text-muted py-8 text-center"
>
{error || "Загрузка вложения…"}
</p>
</Modal>

View File

@ -0,0 +1,84 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { apiClient } from "@/lib/api";
import {
AUTH_SESSION_EVENT,
type AuthSessionEventDetail,
} from "@/lib/api/client";
import { authenticatedMediaPlaybackUrl } from "@/lib/authenticated-files";
type PlaybackResponse = {
url: string;
expires_in: number;
};
type MediaState = {
source: string;
url: string | null;
error: string | null;
generation: number;
};
/** Obtain a short-lived URL so native media elements can use HTTP Range. */
export function useAuthenticatedMediaUrl(endpoint: string, enabled = true) {
const [generation, setGeneration] = useState(0);
const [state, setState] = useState<MediaState | null>(null);
const refresh = useCallback(() => setGeneration((value) => value + 1), []);
useEffect(() => {
if (!enabled || !endpoint) return;
const controller = new AbortController();
const onSessionChange = (event: Event) => {
if (
(event as CustomEvent<AuthSessionEventDetail>).detail.type ===
"session-cleared"
) {
controller.abort();
setState(null);
}
};
window.addEventListener(AUTH_SESSION_EVENT, onSessionChange);
void apiClient
.request<PlaybackResponse>(endpoint, {
method: "POST",
signal: controller.signal,
})
.then((response) => {
if (controller.signal.aborted) return;
setState({
source: endpoint,
url: authenticatedMediaPlaybackUrl(response.url),
error: null,
generation,
});
})
.catch(() => {
if (controller.signal.aborted) return;
setState({
source: endpoint,
url: null,
error: "Не удалось подготовить медиафайл",
generation,
});
});
return () => {
controller.abort();
window.removeEventListener(AUTH_SESSION_EVENT, onSessionChange);
};
}, [enabled, endpoint, generation]);
const current =
state?.source === endpoint && state.generation === generation && enabled
? state
: null;
return {
url: current?.url ?? null,
error: current?.error ?? null,
refresh,
};
}

View File

@ -33,6 +33,7 @@ type RealtimeChatEvent = {
};
const CHAT_LIST_RECONCILE_COOLDOWN_MS = 2_000;
const CHAT_LIST_FALLBACK_POLL_MS = 30_000;
const EMPTY_SNAPSHOT: ChatListSnapshot = {
chats: [],
@ -47,6 +48,7 @@ let loadPromise: Promise<void> | null = null;
let consumerCount = 0;
let realtimeCleanup: (() => void) | null = null;
let cleanupTimer: ReturnType<typeof setTimeout> | null = null;
let fallbackPollTimer: number | null = null;
let wasWsConnected = false;
let hasConnectedOnce = false;
let chatListGeneration = 0;
@ -138,6 +140,26 @@ function reconcileChatList(force = false) {
void loadAllChats(false);
}
function stopFallbackPolling() {
if (!fallbackPollTimer) return;
window.clearInterval(fallbackPollTimer);
fallbackPollTimer = null;
}
function updateFallbackPolling(isConnected: boolean) {
if (isConnected) {
stopFallbackPolling();
return;
}
if (fallbackPollTimer) return;
const poll = () => {
if (document.visibilityState === "visible") reconcileChatList(true);
};
poll();
fallbackPollTimer = window.setInterval(poll, CHAT_LIST_FALLBACK_POLL_MS);
}
function notificationChatId(data: RealtimeChatEvent): number | null {
const value = data.notification?.data?.chat_id;
const chatId = typeof value === "number" ? value : Number(value);
@ -238,6 +260,7 @@ function startRealtime() {
});
const unsubscribeStatus = wsManager.subscribeStatus((status) => {
updateFallbackPolling(status.isConnected);
if (
status.isConnected &&
hasConnectedOnce &&
@ -262,6 +285,7 @@ function startRealtime() {
unsubscribeStatus();
window.removeEventListener("focus", handleFocus);
document.removeEventListener("visibilitychange", handleVisibilityChange);
stopFallbackPolling();
realtimeCleanup = null;
wasWsConnected = false;
hasConnectedOnce = false;

View File

@ -48,6 +48,7 @@ export function useWebSocket({
}: UseWebSocketOptions) {
const [isConnected, setIsConnected] = useState(false);
const lastTypingSentAtRef = useRef(0);
const typingActiveRef = useRef(false);
const wasConnectedRef = useRef(false);
const reconnectExhaustedNotifiedRef = useRef(false);
@ -67,9 +68,18 @@ export function useWebSocket({
}
lastTypingSentAtRef.current = now;
wsManager.send({
const sent = wsManager.send({
action: "typing",
});
if (sent) typingActiveRef.current = true;
}, [chatId]);
const stopTyping = useCallback(() => {
if (!typingActiveRef.current) return;
typingActiveRef.current = false;
lastTypingSentAtRef.current = 0;
if (!chatId || wsManager.getState() !== WebSocket.OPEN) return;
wsManager.send({ action: "stop_typing" });
}, [chatId]);
useEffect(() => {
@ -111,6 +121,7 @@ export function useWebSocket({
wsManager.clearActiveChat(chatId);
wasConnectedRef.current = false;
lastTypingSentAtRef.current = 0;
typingActiveRef.current = false;
};
}, [autoConnect, chatId, onReconnectExhausted]);
@ -118,5 +129,6 @@ export function useWebSocket({
isConnected,
handlers,
sendTyping,
stopTyping,
};
}

View File

@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
authenticatedFileEndpoint,
authenticatedMediaPlaybackUrl,
fetchAuthenticatedBlob,
} from "./authenticated-files.ts";
@ -30,6 +31,19 @@ test("protected attachments use only their local ACL API endpoints", () => {
}
});
test("media playback accepts only scoped same-origin ticket URLs", () => {
const path = "/api/v1/communications/chat-media/9/12/?ticket=signed-value";
assert.equal(authenticatedMediaPlaybackUrl(path), path);
for (const url of [
"https://evil.example/api/v1/communications/chat-media/9/12/?ticket=x",
"/api/v1/communications/chat-media/9/12/",
"/api/v1/communications/chat-media/9/12/?ticket=x&next=evil",
"/api/v1/communications/messages/9/attachments/12/?ticket=x",
]) {
assert.throws(() => authenticatedMediaPlaybackUrl(url));
}
});
test("media requests use the JWT refresh transport and disable caching", async () => {
const path = "/api/v1/communications/messages/9/attachments/12/";
const controller = new AbortController();

View File

@ -14,6 +14,20 @@ export function authenticatedFileEndpoint(fileUrl: string): string {
return `${url.pathname}${url.search}`;
}
/** Accept only the scoped, same-origin playback URL returned by our API. */
export function authenticatedMediaPlaybackUrl(fileUrl: string): string {
const url = new URL(fileUrl, "https://media-api.invalid");
if (
url.origin !== "https://media-api.invalid" ||
!/^\/api\/v1\/communications\/chat-media\/\d+\/\d+\/$/.test(url.pathname) ||
!url.searchParams.get("ticket") ||
[...url.searchParams.keys()].some((key) => key !== "ticket")
) {
throw new Error("Недопустимый адрес воспроизведения");
}
return `${url.pathname}${url.search}`;
}
export async function fetchAuthenticatedBlob(
fileUrl: string,
requestRaw: RawRequestFn,

View File

@ -104,6 +104,7 @@ test("connection recovers immediately when browser comes back online", async ()
const originalClearInterval = globalThis.clearInterval;
const listeners = new Map();
const scheduledTimeouts = [];
const statuses = [];
class FakeWebSocket {
static OPEN = 1;
@ -168,11 +169,16 @@ test("connection recovers immediately when browser comes back online", async ()
const { WebSocketManager } = await import("./websocketManager.ts");
const manager = new WebSocketManager();
const unsubscribe = manager.subscribe(() => {});
const unsubscribeStatus = manager.subscribeStatus((status) =>
statuses.push(status),
);
await new Promise((resolve) => originalSetTimeout(resolve, 0));
assert.equal(FakeWebSocket.instances.length, 1);
FakeWebSocket.instances[0].open();
FakeWebSocket.instances[0].fail();
assert.equal(statuses.at(-1).lastCloseCode, 1006);
assert.equal(statuses.at(-1).lastCloseReason, "network");
assert.equal(scheduledTimeouts.length, 1);
assert.ok(scheduledTimeouts[0].delay >= 1600);
assert.ok(scheduledTimeouts[0].delay <= 2400);
@ -182,6 +188,7 @@ test("connection recovers immediately when browser comes back online", async ()
assert.equal(scheduledTimeouts[0].cleared, true);
assert.equal(FakeWebSocket.instances.length, 2);
unsubscribe();
unsubscribeStatus();
} finally {
globalThis.window = originalWindow;
globalThis.document = originalDocument;

View File

@ -17,6 +17,8 @@ export type ConnectionStatus = {
isConnecting: boolean;
reconnectAttempts: number;
maxReconnectAttempts: number;
lastCloseCode: number | null;
lastCloseReason: string;
};
type StatusHandler = (status: ConnectionStatus) => void;
type WebSocketTicketResponse = { ticket: string; expires_in: number };
@ -39,6 +41,8 @@ export class WebSocketManager {
private shouldReconnect = false;
private connectionGeneration = 0;
private lastServerMessageAt = 0;
private lastCloseCode: number | null = null;
private lastCloseReason = "";
private constructor() {
if (typeof window === "undefined") return;
@ -86,6 +90,8 @@ export class WebSocketManager {
isConnecting: this.isConnecting,
reconnectAttempts: this.reconnectAttempts,
maxReconnectAttempts: RAPID_RECONNECT_LIMIT,
lastCloseCode: this.lastCloseCode,
lastCloseReason: this.lastCloseReason,
};
}
@ -165,6 +171,8 @@ export class WebSocketManager {
if (this.ws !== socket) return;
this.isConnecting = false;
this.reconnectAttempts = 0;
this.lastCloseCode = null;
this.lastCloseReason = "";
this.lastServerMessageAt = Date.now();
this.startHeartbeatWatchdog(socket);
this.syncActiveChatOnSocket();
@ -198,11 +206,18 @@ export class WebSocketManager {
this.notifyStatus();
};
socket.onclose = () => {
socket.onclose = (event) => {
if (this.ws !== socket) return;
this.stopHeartbeatWatchdog();
this.ws = null;
this.isConnecting = false;
this.lastCloseCode = event.code;
this.lastCloseReason = event.reason || "";
console.warn("[WS Manager] Connection closed", {
code: event.code,
reason: event.reason || "",
wasClean: event.wasClean,
});
this.notifyStatus();
this.scheduleReconnect();
};

View File

@ -48,6 +48,9 @@ elif name == 'df':
if os.environ.get('FAIL_STAGE') == 'disk_during_build' and (root/'building').exists():
available = '100000000'
print('1000000' if '--output=iavail' in args else available)
elif name == 'stat':
mode = p(args[-1]).stat().st_mode & 0o777
print(f'0:{mode:o}')
elif name == 'systemctl':
file=root/'services.json'; states=json.loads(file.read_text())
def units(): return [a for a in args[1:] if a.endswith('.service')]
@ -123,7 +126,7 @@ class DeploymentLifecycleTests(unittest.TestCase):
fake = self.bin / "fake"
fake.write_text(FAKE)
fake.chmod(0o755)
for name in ('runuser', 'git', 'df', 'systemctl', 'fake-python', 'npm', 'node',
for name in ('runuser', 'git', 'df', 'stat', 'systemctl', 'fake-python', 'npm', 'node',
'curl', 'sleep', 'findmnt', 'nginx', 'rsync', 'pg_restore'):
(self.bin / name).symlink_to(fake)
text = SOURCE.read_text()
@ -145,6 +148,7 @@ class DeploymentLifecycleTests(unittest.TestCase):
self.root/'srv/eusrr-shared/next-static', self.root/'etc/nginx/conf.d',
self.root/'var/backups/eusrr/deploy'):
directory.mkdir(parents=True, exist_ok=True)
self.slots.chmod(0o755)
for sha in (OLD, PREVIOUS, ORPHAN):
(self.releases/sha/'frontend/.next/static').mkdir(parents=True)
self.slots.joinpath('green').symlink_to(self.releases/OLD)