Restore reliable chat realtime and media playback
Some checks failed
Deploy production / deploy (push) Failing after 14m49s
Some checks failed
Deploy production / deploy (push) Failing after 14m49s
This commit is contained in:
parent
1f64bb9dee
commit
2768d2f1b3
@ -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/",
|
||||
|
||||
77
backend/communications/api/media.py
Normal file
77
backend/communications/api/media.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
77
backend/communications/media_tickets.py
Normal file
77
backend/communications/media_tickets.py
Normal 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
|
||||
@ -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)),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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",
|
||||
[
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
9
frontend/AGENTS.md
Normal 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
1
frontend/CLAUDE.md
Normal file
@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@ -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)}
|
||||
|
||||
@ -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={{
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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>
|
||||
|
||||
84
frontend/src/hooks/useAuthenticatedMediaUrl.ts
Normal file
84
frontend/src/hooks/useAuthenticatedMediaUrl.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user