From 8eb0a07d98d93b54ae99ce248aab81afe8ee01a6 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 21:09:04 +0300 Subject: [PATCH] Require chat membership for attachment downloads --- chats/admin.py | 36 ++- chats/attachments.py | 46 ++++ chats/models.py | 9 + chats/permissions.py | 10 + chats/search.py | 10 +- chats/serializers.py | 51 ++++- chats/tests/test_attachment_download.py | 266 ++++++++++++++++++++++ chats/urls.py | 12 + chats/utils.py | 13 -- chats/views.py | 61 +++-- files/downloads.py | 131 +++++++++++ files/service.py | 7 +- templates/admin/chats/message_search.html | 7 +- 13 files changed, 599 insertions(+), 60 deletions(-) create mode 100644 chats/attachments.py create mode 100644 chats/tests/test_attachment_download.py create mode 100644 files/downloads.py diff --git a/chats/admin.py b/chats/admin.py index a8eb6712..122160a1 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -5,11 +5,12 @@ from django.core.exceptions import PermissionDenied from django.core.paginator import Paginator from django.db import transaction -from django.shortcuts import get_object_or_404 from django.template.response import TemplateResponse from django.urls import path, reverse from django.utils.html import format_html +from django.views.decorators.http import require_safe +from chats.attachments import download_chat_attachment, get_accessible_chat from chats.exceptions import MessageValidationException from chats.models import ( ProjectChat, @@ -19,7 +20,7 @@ FileToMessage, ) from chats.project_reads import mark_project_message_read, publish_project_message_read -from chats.search import ChatMessageSearchForm, accessible_chats, search_chat_messages +from chats.search import ChatMessageSearchForm, search_chat_messages @admin.display(description="Пользователи чата") @@ -40,13 +41,21 @@ def message_search_url_name(self): ) def get_urls(self): - converter = "int" if self.model is ProjectChat else "slug" + object_path = ( + "" if self.model is ProjectChat else "" + ) return [ path( - "<" + converter + ":object_id>/message-search/", + object_path + "/message-search/", self.admin_site.admin_view(self.message_search), name=self.message_search_url_name, ), + path( + object_path + + "/messages//files//download/", + self.admin_site.admin_view(require_safe(self.attachment_download)), + name=self.message_search_url_name + "_download", + ), ] + super().get_urls() @admin.display(description="Поиск по переписке") @@ -59,10 +68,7 @@ def message_search_link(self, chat): return format_html('Найти в чате', url) def message_search(self, request, object_id): - visible = accessible_chats(request.user, self.model) - chat = get_object_or_404(visible, pk=object_id) - if not self.has_view_or_change_permission(request, chat): - raise PermissionDenied + chat = self.get_chat_for_member(request, object_id) form = ChatMessageSearchForm(request.GET) page = None if form.is_valid(): @@ -82,9 +88,23 @@ def message_search(self, request, object_id): "title": "Поиск по переписке", "form": form, "page": page, + "chat": chat, + "attachment_download_route": "admin:" + + self.message_search_url_name + + "_download", }, ) + def get_chat_for_member(self, request, object_id): + chat = get_accessible_chat(request.user, self.model, object_id) + if not self.has_view_or_change_permission(request, chat): + raise PermissionDenied + return chat + + def attachment_download(self, request, object_id, message_id, attachment_id): + chat = self.get_chat_for_member(request, object_id) + return download_chat_attachment(request, chat, message_id, attachment_id) + @admin.register(ProjectChat) class ChatAdmin(ChatSearchAdmin): diff --git a/chats/attachments.py b/chats/attachments.py new file mode 100644 index 00000000..a20a1838 --- /dev/null +++ b/chats/attachments.py @@ -0,0 +1,46 @@ +from django.http import Http404 +from django.urls import reverse + +from chats.models import FileToMessage, ProjectChat +from chats.permissions import accessible_chats +from files.downloads import download_stored_file + + +def get_accessible_chat(user, model, chat_id): + chat = accessible_chats(user, model).filter(pk=chat_id).first() + if chat is None: + raise Http404("Чат недоступен.") + return chat + + +def chat_attachments(chat): + if isinstance(chat, ProjectChat): + return FileToMessage.objects.filter( + project_message__chat=chat, + project_message__is_deleted=False, + direct_message__isnull=True, + ) + return FileToMessage.objects.filter( + direct_message__chat=chat, + direct_message__is_deleted=False, + project_message__isnull=True, + ) + + +def attachment_download_url(attachment, message): + kind = "project" if attachment.project_message_id is not None else "direct" + return reverse( + "chats:" + kind + "-chat-attachment-download", + args=[message.chat_id, message.pk, attachment.download_id], + ) + + +def download_chat_attachment(request, chat, message_id, attachment_id): + message_field = ( + "project_message_id" if isinstance(chat, ProjectChat) else "direct_message_id" + ) + attachments = chat_attachments(chat).filter(**{message_field: message_id}) + for attachment in attachments.select_related("file"): + if attachment.download_id == attachment_id: + return download_stored_file(request, attachment.file) + raise Http404("Вложение недоступно.") diff --git a/chats/models.py b/chats/models.py index 0b620ceb..2262fc88 100644 --- a/chats/models.py +++ b/chats/models.py @@ -1,4 +1,5 @@ from abc import abstractmethod +from hashlib import sha256 from typing import List from django.contrib.auth import get_user_model @@ -331,6 +332,14 @@ class FileToMessage(models.Model): null=True, ) + @property + def download_id(self): + return sha256(self.file_id.encode()).hexdigest() + + @property + def is_linked_to_one_message(self): + return (self.direct_message_id is None) != (self.project_message_id is None) + def __str__(self): return f"FileToMessage<{self.file}>" diff --git a/chats/permissions.py b/chats/permissions.py index d004cd47..83cc789a 100644 --- a/chats/permissions.py +++ b/chats/permissions.py @@ -6,6 +6,16 @@ User = get_user_model() +def accessible_chats(user, chat_model): + from chats.models import DirectChat + + if not user.is_authenticated: + return chat_model.objects.none() + if chat_model is DirectChat: + return user.direct_chats.all() + return user.get_project_chats() + + def get_lookup_kwarg(request, view, *names): view_kwargs = getattr(view, "kwargs", {}) or {} parser_kwargs = (getattr(request, "parser_context", None) or {}).get("kwargs", {}) diff --git a/chats/search.py b/chats/search.py index a13ea65e..67bee0f8 100644 --- a/chats/search.py +++ b/chats/search.py @@ -2,7 +2,7 @@ from django.db.models import Case, Exists, F, OuterRef, Q, TextField, Value, When from django.db.models.functions import Concat -from chats.models import DirectChat, ProjectChat +from chats.models import ProjectChat from core.search import unicode_literal_pattern from files.models import UserFile @@ -44,14 +44,6 @@ def __init__(self, data): self.order_fields(["q", "scope", "author_id"]) -def accessible_chats(user, chat_model): - if not user.is_authenticated: - return chat_model.objects.none() - if chat_model is DirectChat: - return user.direct_chats.all() - return user.get_project_chats() - - def search_chat_messages(chat, filters): query = filters["q"] scope = filters["scope"] diff --git a/chats/serializers.py b/chats/serializers.py index 910b0353..79396048 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -1,7 +1,9 @@ from rest_framework import serializers +from chats.attachments import attachment_download_url from chats.models import ( DirectChat, + FileToMessage, ProjectChat, DirectChatMessage, ProjectChatMessage, @@ -11,6 +13,43 @@ from users.serializers import UserListSerializer, UserDetailSerializer, UserChatSerializer +class ChatAttachmentSerializer(serializers.ModelSerializer): + name = serializers.CharField(source="file.name", read_only=True) + extension = serializers.CharField(source="file.extension", read_only=True) + mime_type = serializers.CharField(source="file.mime_type", read_only=True) + size = serializers.IntegerField(source="file.size", read_only=True) + user = serializers.IntegerField( + source="file.user_id", read_only=True, allow_null=True + ) + datetime_uploaded = serializers.DateTimeField( + source="file.datetime_uploaded", read_only=True + ) + link = serializers.SerializerMethodField() + + def get_link(self, attachment) -> str: + message = self.context.get("message") or ( + attachment.project_message + if attachment.project_message_id is not None + else attachment.direct_message + ) + return attachment_download_url(attachment, message) + + class Meta: + model = FileToMessage + fields = UserFileSerializer.Meta.fields + + +def serialize_message_attachments(message): + if message.is_deleted: + return [] + attachments = [ + item for item in message.file_to_message.all() if item.is_linked_to_one_message + ] + return ChatAttachmentSerializer( + attachments, many=True, context={"message": message} + ).data + + class DirectChatListSerializer(serializers.ModelSerializer): last_message = serializers.SerializerMethodField() opponent = serializers.SerializerMethodField() @@ -162,11 +201,7 @@ class DirectChatMessageListSerializer(serializers.ModelSerializer): @classmethod def get_files(cls, message: DirectChatMessage): - data = [] - for file_to_message in message.file_to_message.all(): - file_data = UserFileSerializer(file_to_message.file).data - data.append(file_data) - return data + return serialize_message_attachments(message) class Meta: model = DirectChatMessage @@ -227,11 +262,7 @@ class ProjectChatMessageListSerializer(ProjectChatMessageSerializer): @classmethod def get_files(cls, message: ProjectChatMessage): - data = [] - for file_to_message in message.file_to_message.all(): - file_data = UserFileSerializer(file_to_message.file).data - data.append(file_data) - return data + return serialize_message_attachments(message) class Meta: model = ProjectChatMessage diff --git a/chats/tests/test_attachment_download.py b/chats/tests/test_attachment_download.py new file mode 100644 index 00000000..9f672c72 --- /dev/null +++ b/chats/tests/test_attachment_download.py @@ -0,0 +1,266 @@ +from unittest.mock import Mock, patch + +import requests +from django.contrib.auth import get_user_model +from django.test import AsyncRequestFactory, SimpleTestCase, TestCase, override_settings +from django.urls import reverse +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import AccessToken + +from chats.models import DirectChat, DirectChatMessage, FileToMessage, ProjectChatMessage +from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from chats.tests.test_project_reads import create_project_chat +from files.downloads import download_stored_file +from files.models import UserFile +from projects.models import Collaborator + +STORAGE = "https://storage.example.test/v1/account/media/" +TOKEN = "test-storage-token" + + +def upstream_file(status=200): + upstream = Mock(status_code=status) + upstream.headers = {"Content-Length": "6"} + upstream.iter_content.return_value = iter([b"abc", b"def"]) + return upstream + + +@override_settings(ALLOWED_HOSTS=["testserver"], SELECTEL_SWIFT_URL=STORAGE) +class ChatAttachmentDownloadTests(TestCase): + @classmethod + def setUpTestData(cls): + users = get_user_model().objects + cls.author = users.create(**TEST_USER1, is_active=True) + cls.member = users.create( + **TEST_USER2, is_active=True, is_staff=True, is_superuser=True + ) + cls.outsider = users.create( + **TEST_USER3, is_active=True, is_staff=True, is_superuser=True + ) + direct = DirectChat.get_chat(cls.author, cls.member) + project = create_project_chat(cls.author, cls.member) + cls.project = project + cls.direct_message = DirectChatMessage.objects.create( + chat=direct, author=cls.author, text="Личное сообщение" + ) + cls.project_message = ProjectChatMessage.objects.create( + chat=project, author=cls.author, text="Сообщение проекта" + ) + cls.direct_attachment = cls.attach(cls.direct_message, "direct") + cls.project_attachment = cls.attach(cls.project_message, "project") + cls.other_chat = create_project_chat(cls.author, cls.member) + + @classmethod + def attach(cls, message, label): + file = UserFile.objects.create( + link=STORAGE + label + ".pdf", + user=cls.author, + name="План проекта", + extension="pdf", + mime_type="application/pdf", + ) + key = ( + "direct_message" + if isinstance(message, DirectChatMessage) + else "project_message" + ) + return FileToMessage.objects.create(file=file, **{key: message}) + + def setUp(self): + self.api = APIClient() + + def authenticate_member(self): + self.api.credentials( + HTTP_AUTHORIZATION=f"Bearer {AccessToken.for_user(self.member)}" + ) + + @staticmethod + def download_url(kind, message, attachment, chat_id=None): + return reverse( + "chats:" + kind + "-chat-attachment-download", + args=[chat_id or message.chat_id, message.pk, attachment.download_id], + ) + + def test_history_and_file_list_return_the_same_protected_link(self): + self.authenticate_member() + for kind, message, attachment in ( + ("direct", self.direct_message, self.direct_attachment), + ("project", self.project_message, self.project_attachment), + ): + with self.subTest(kind=kind): + args = [message.chat_id] + history = self.api.get( + reverse("chats:" + kind + "-chat-messages", args=args), secure=True + ).json() + files = self.api.get( + reverse("chats:" + kind + "-chat-files", args=args), secure=True + ).json() + link = self.download_url(kind, message, attachment) + self.assertEqual(history["results"][0]["files"][0]["link"], link) + self.assertEqual(files[0]["link"], link) + self.assertEqual(files[0]["name"], "План проекта") + self.assertNotIn(STORAGE, str(history) + str(files)) + + @patch("files.downloads.SelectelSwiftStorage._get_auth_token", return_value=TOKEN) + @patch("files.downloads.requests.get") + def test_members_can_download_through_api_and_admin(self, storage_get, _token): + self.authenticate_member() + self.client.force_login(self.member) + admin_url = reverse( + "admin:chats_projectchat_message_search_download", + args=[ + self.project.pk, + self.project_message.pk, + self.project_attachment.download_id, + ], + ) + search = self.client.get( + reverse("admin:chats_projectchat_message_search", args=[self.project.pk]), + secure=True, + ) + self.assertContains(search, admin_url) + for client, url, expected_file in ( + ( + self.api, + self.download_url("direct", self.direct_message, self.direct_attachment), + self.direct_attachment.file_id, + ), + ( + self.api, + self.download_url( + "project", self.project_message, self.project_attachment + ), + self.project_attachment.file_id, + ), + (self.client, admin_url, self.project_attachment.file_id), + ): + with self.subTest(url=url): + upstream = upstream_file() + storage_get.return_value = upstream + response = client.get(url, secure=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/octet-stream") + self.assertIn("filename*=utf-8''", response["Content-Disposition"]) + self.assertIn("no-store", response["Cache-Control"]) + self.assertEqual(response["Content-Length"], "6") + self.assertEqual(b"".join(response.streaming_content), b"abcdef") + upstream.close.assert_called_once() + storage_get.assert_called_with( + expected_file, + headers={"X-Auth-Token": TOKEN, "Accept-Encoding": "identity"}, + stream=True, + timeout=(5, 15), + allow_redirects=False, + ) + + upstream = upstream_file() + storage_get.return_value = upstream + response = self.api.head( + self.download_url("direct", self.direct_message, self.direct_attachment), + secure=True, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Length"], "6") + self.assertEqual(response.content, b"") + upstream.iter_content.assert_not_called() + upstream.close.assert_called_once() + + @patch("files.downloads.SelectelSwiftStorage._get_auth_token") + @patch("files.downloads.requests.get") + def test_access_and_message_checks_run_before_storage(self, storage_get, token): + url = self.download_url("project", self.project_message, self.project_attachment) + self.assertEqual(self.api.get(url, secure=True).status_code, 401) + self.api.force_authenticate(self.outsider) + self.assertEqual(self.api.get(url, secure=True).status_code, 404) + direct_url = self.download_url( + "direct", self.direct_message, self.direct_attachment + ) + self.assertEqual(self.api.get(direct_url, secure=True).status_code, 404) + self.client.force_login(self.outsider) + admin_url = reverse( + "admin:chats_projectchat_message_search_download", + args=[ + self.project.pk, + self.project_message.pk, + self.project_attachment.download_id, + ], + ) + self.assertEqual(self.client.get(admin_url, secure=True).status_code, 404) + + self.api.force_authenticate(self.member) + wrong_chat = self.download_url( + "project", + self.project_message, + self.project_attachment, + chat_id=self.other_chat.pk, + ) + wrong_file = self.download_url( + "project", self.project_message, self.direct_attachment + ) + for unavailable in (wrong_chat, wrong_file): + self.assertEqual(self.api.get(unavailable, secure=True).status_code, 404) + Collaborator.objects.filter( + project=self.project.project, user=self.member + ).delete() + self.assertEqual(self.api.get(url, secure=True).status_code, 404) + self.api.force_authenticate(self.author) + self.project_message.is_deleted = True + self.project_message.save(update_fields=["is_deleted"]) + self.assertEqual(self.api.get(url, secure=True).status_code, 404) + files_url = reverse("chats:project-chat-files", args=[self.project.pk]) + self.assertEqual(self.api.get(files_url, secure=True).json(), []) + token.assert_not_called() + storage_get.assert_not_called() + + @patch("files.downloads.SelectelSwiftStorage._get_auth_token", return_value=TOKEN) + @patch("files.downloads.requests.get") + def test_storage_errors_do_not_redirect_the_client(self, storage_get, _token): + self.authenticate_member() + url = self.download_url("direct", self.direct_message, self.direct_attachment) + for upstream_status, expected in ((404, 404), (302, 502), (503, 502)): + with self.subTest(upstream_status=upstream_status): + upstream = upstream_file(upstream_status) + upstream.headers["Location"] = "https://storage.example.test/redirect" + storage_get.return_value = upstream + response = self.api.get(url, secure=True) + self.assertEqual(response.status_code, expected) + self.assertNotIn("Location", response) + self.assertIn("no-store", response["Cache-Control"]) + upstream.close.assert_called_once() + storage_get.side_effect = requests.Timeout + self.assertEqual(self.api.get(url, secure=True).status_code, 502) + + @patch("files.downloads.SelectelSwiftStorage._get_auth_token") + @patch("files.downloads.requests.get") + def test_download_uses_only_configured_storage(self, storage_get, token): + self.authenticate_member() + file = UserFile.objects.create( + link="https://other.example.test/files/file.pdf", + user=self.author, + ) + attachment = FileToMessage.objects.create( + file=file, direct_message=self.direct_message + ) + url = self.download_url("direct", self.direct_message, attachment) + self.assertEqual(self.api.get(url, secure=True).status_code, 404) + token.assert_not_called() + storage_get.assert_not_called() + + +@override_settings(SELECTEL_SWIFT_URL=STORAGE) +class AsyncAttachmentDownloadTests(SimpleTestCase): + @patch("files.downloads.SelectelSwiftStorage._get_auth_token", return_value=TOKEN) + @patch("files.downloads.requests.get") + async def test_asgi_consumes_the_upstream_one_chunk_at_a_time( + self, storage_get, _token + ): + upstream = upstream_file() + storage_get.return_value = upstream + file = UserFile(link=STORAGE + "object", name="План", extension="pdf") + response = download_stored_file(AsyncRequestFactory().get("/"), file) + self.assertTrue(response.is_async) + self.assertEqual( + [chunk async for chunk in response.streaming_content], [b"abc", b"def"] + ) + response.close() + upstream.close.assert_called_once() diff --git a/chats/urls.py b/chats/urls.py index f670d400..ee4157d8 100644 --- a/chats/urls.py +++ b/chats/urls.py @@ -1,6 +1,8 @@ from django.urls import path +from chats.models import DirectChat, ProjectChat from chats.views import ( + ChatAttachmentDownload, DirectChatList, DirectChatMessageList, HasChatUnreadsView, @@ -27,6 +29,11 @@ DirectChatFileList.as_view(), name="direct-chat-files", ), + path( + "directs//messages//files//download/", + ChatAttachmentDownload.as_view(chat_model=DirectChat), + name="direct-chat-attachment-download", + ), path("projects/", ProjectChatList.as_view(), name="project-chat-list"), path("projects//", ProjectChatDetail.as_view(), name="project-chat-detail"), path( @@ -39,6 +46,11 @@ ProjectChatFileList.as_view(), name="project-chat-files", ), + path( + "projects//messages//files//download/", + ChatAttachmentDownload.as_view(chat_model=ProjectChat), + name="project-chat-attachment-download", + ), path( "has-unreads/", HasChatUnreadsView.as_view(), diff --git a/chats/utils.py b/chats/utils.py index 8d85a7a0..bda114ed 100644 --- a/chats/utils.py +++ b/chats/utils.py @@ -57,16 +57,3 @@ async def get_chat_and_user_ids_from_content(content, current_user) -> tuple[str f"User {current_user.id} is not a member of chat {chat_id}" ) return chat_id, other_user - - -def get_all_files(messages): - # looks like something bad - - files = [] - for message in messages: - if hasattr(message, "file_to_message"): - files_in_message = [ - file_to_message.file for file_to_message in message.file_to_message.all() - ] - files.extend(files_in_message) - - return files diff --git a/chats/views.py b/chats/views.py index 5acfae3a..f0cfcf90 100644 --- a/chats/views.py +++ b/chats/views.py @@ -1,5 +1,7 @@ from django.contrib.auth import get_user_model from django.shortcuts import get_object_or_404 +from django.utils.decorators import method_decorator +from django.views.decorators.cache import never_cache from rest_framework import status from rest_framework.generics import ( @@ -13,16 +15,22 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.exceptions import ValidationError from rest_framework.response import Response +from rest_framework.views import APIView +from chats.attachments import ( + chat_attachments, + download_chat_attachment, + get_accessible_chat, +) from chats.models import ProjectChat, DirectChat from chats.pagination import MessageListPagination -from chats.permissions import IsProjectChatMember, IsChatMember +from chats.permissions import IsProjectChatMember, IsChatMember, accessible_chats from chats.project_reads import ( unread_project_messages, with_project_chat_unreads, with_project_message_reads, ) -from chats.search import ChatMessageSearchForm, accessible_chats, search_chat_messages +from chats.search import ChatMessageSearchForm, search_chat_messages from chats.serializers import ( DirectChatListSerializer, DirectChatMessageListSerializer, @@ -30,10 +38,8 @@ ProjectChatMessageListSerializer, ProjectChatDetailSerializer, DirectChatDetailSerializer, + ChatAttachmentSerializer, ) -from chats.utils import get_all_files -from files.models import UserFile -from files.serializers import UserFileSerializer User = get_user_model() @@ -230,26 +236,45 @@ def post(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) -class DirectChatFileList(ListCreateAPIView): - serializer_class = UserFileSerializer +class ChatFileList(ListAPIView): + serializer_class = ChatAttachmentSerializer permission_classes = [IsAuthenticated] def get_queryset(self): - messages = self.request.user.direct_chats.get(id=self.kwargs["id"]).messages.all() + if getattr(self, "swagger_fake_view", False): + return self.serializer_class.Meta.model.objects.none() + chat = get_accessible_chat(self.request.user, self.chat_model, self.kwargs["id"]) + return ( + chat_attachments(chat) + .select_related("file", "direct_message", "project_message") + .order_by("file__datetime_uploaded", "pk") + ) - return get_all_files(messages) +class DirectChatFileList(ChatFileList): + chat_model = DirectChat -class ProjectChatFileList(ListCreateAPIView): - serializer_class = UserFileSerializer - permission_classes = [IsProjectChatMember] - def get_queryset(self): - try: - messages = ProjectChat.objects.get(id=self.kwargs["id"]).messages.all() - return get_all_files(messages) - except ProjectChat.DoesNotExist: - return UserFile.objects.none() +class ProjectChatFileList(ChatFileList): + chat_model = ProjectChat + + +@method_decorator(never_cache, name="dispatch") +class ChatAttachmentDownload(APIView): + permission_classes = [IsAuthenticated] + chat_model = None + + @swagger_auto_schema( + produces=["application/octet-stream"], + responses={ + 200: openapi.Response("Файл", schema=openapi.Schema(type=openapi.TYPE_FILE)), + 404: "Вложение недоступно", + 502: "Хранилище недоступно", + }, + ) + def get(self, request, id, message_id, attachment_id): + chat = get_accessible_chat(request.user, self.chat_model, id) + return download_chat_attachment(request, chat, message_id, attachment_id) class HasChatUnreadsView(GenericAPIView): diff --git a/files/downloads.py b/files/downloads.py new file mode 100644 index 00000000..8c526990 --- /dev/null +++ b/files/downloads.py @@ -0,0 +1,131 @@ +from urllib.parse import unquote, urlsplit + +import requests +from asgiref.sync import sync_to_async +from django.conf import settings +from django.core.handlers.asgi import ASGIRequest +from django.http import Http404, HttpResponse, StreamingHttpResponse +from django.utils.http import content_disposition_header + +from files.exceptions import SelectelUploadError +from files.service import SelectelSwiftStorage + + +def is_storage_url(link): + try: + base = urlsplit(settings.SELECTEL_SWIFT_URL) + target = urlsplit(link) + path = unquote(target.path) + prefix = unquote(base.path).rstrip("/") + "/" + return ( + target.scheme == base.scheme == "https" + and target.netloc.lower() == base.netloc.lower() + and not target.username + and not target.password + and not target.query + and not target.fragment + and path.startswith(prefix) + and len(path) > len(prefix) + and "\\" not in path + and "%" not in path + and not any(ord(char) < 32 or ord(char) == 127 for char in path) + and all(segment not in ("", ".", "..") for segment in path[1:].split("/")) + ) + except ValueError: + return False + + +def download_filename(file): + name = file.name + (f".{file.extension}" if file.extension else "") + name = "".join( + char if char not in "/\\" and ord(char) >= 32 and ord(char) != 127 else "_" + for char in name + ).strip(" .") + return name or "Вложение" + + +class DownloadBody: + def __init__(self, upstream): + self.upstream = upstream + self.chunks = upstream.iter_content(chunk_size=64 * 1024) + + def __iter__(self): + return iter(self.chunks) + + async def iter_async(self): + # Django buffers synchronous iterators under ASGI. Pull one chunk at a time. + next_chunk = sync_to_async(next, thread_sensitive=False) + while (chunk := await next_chunk(self.chunks, None)) is not None: + yield chunk + + def close(self): + self.upstream.close() + + +def download_stored_file(request, file): + if not is_storage_url(file.link): + raise Http404("Вложение недоступно.") + try: + upstream = requests.get( + file.link, + headers={ + "X-Auth-Token": SelectelSwiftStorage._get_auth_token(), + "Accept-Encoding": "identity", + }, + stream=True, + timeout=(5, 15), + allow_redirects=False, + ) + except (requests.RequestException, SelectelUploadError, KeyError): + return storage_unavailable() + + if upstream.status_code != 200: + upstream.close() + if upstream.status_code == 404: + raise Http404("Файл не найден в хранилище.") + return storage_unavailable() + + raw_request = getattr(request, "_request", request) + content_type = "application/octet-stream" + if raw_request.method == "HEAD": + upstream.close() + response = HttpResponse(content_type=content_type) + else: + body = DownloadBody(upstream) + if isinstance(raw_request, ASGIRequest): + body = AsyncDownloadBody(body) + response = StreamingHttpResponse(body, content_type=content_type) + response["Content-Disposition"] = content_disposition_header( + as_attachment=True, filename=download_filename(file) + ) + response["Cache-Control"] = "private, no-store" + response["X-Content-Type-Options"] = "nosniff" + length = upstream.headers.get("Content-Length", "") + if ( + length.isascii() + and length.isdecimal() + and not upstream.headers.get("Content-Encoding") + ): + response["Content-Length"] = length + return response + + +class AsyncDownloadBody: + def __init__(self, body): + self.body = body + + def __aiter__(self): + return self.body.iter_async() + + def close(self): + self.body.close() + + +def storage_unavailable(): + response = HttpResponse( + "Не удалось скачать файл. Повторите попытку.", + status=502, + content_type="text/plain; charset=utf-8", + ) + response["Cache-Control"] = "private, no-store" + return response diff --git a/files/service.py b/files/service.py index 1304af6c..7a045105 100644 --- a/files/service.py +++ b/files/service.py @@ -130,7 +130,12 @@ def _get_auth_token(): } } } - response = requests.post(settings.SELECTEL_AUTH_TOKEN_URL, json=data) + response = requests.post( + settings.SELECTEL_AUTH_TOKEN_URL, + json=data, + timeout=(5, 15), + allow_redirects=False, + ) if response.status_code not in [200, 201]: raise SelectelUploadError( "Couldn't generate a token for Selectel Swift API (selcdn)" diff --git a/templates/admin/chats/message_search.html b/templates/admin/chats/message_search.html index c4334ff9..91299c7f 100644 --- a/templates/admin/chats/message_search.html +++ b/templates/admin/chats/message_search.html @@ -16,7 +16,12 @@

Сообщение №{{ message.pk }} · {{ message.author.get_full_name }}

Вложения:

    {% for attachment in message.file_to_message.all %} -
  • {{ attachment.file.name }}{% if attachment.file.extension %}.{{ attachment.file.extension }}{% endif %}
  • +
  • + {{ attachment.file.name }}{% if attachment.file.extension %}.{{ attachment.file.extension }}{% endif %} + {% if attachment.is_linked_to_one_message %} + Скачать + {% endif %} +
  • {% endfor %}
{% endif %}