From 392aa8e9c1f992b5e413a98061ac146ddd12b90e Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Sun, 26 Jul 2026 11:21:53 +0330 Subject: [PATCH] add presign endponit --- apps/chat/serializers/presign.py | 10 +++ apps/chat/services/storage.py | 15 ++++ apps/chat/urls.py | 2 + apps/chat/views/presign.py | 30 +++++++ docs/chat-media-handling.md | 150 +++++++++++++++++++++++++++++++ 5 files changed, 207 insertions(+) create mode 100644 apps/chat/serializers/presign.py create mode 100644 apps/chat/views/presign.py create mode 100644 docs/chat-media-handling.md diff --git a/apps/chat/serializers/presign.py b/apps/chat/serializers/presign.py new file mode 100644 index 0000000..ac25f71 --- /dev/null +++ b/apps/chat/serializers/presign.py @@ -0,0 +1,10 @@ +from rest_framework import serializers + + +class SignContentSerializer(serializers.Serializer): + filename = serializers.CharField() + + +class PresignResultSerializer(serializers.Serializer): + upload_url = serializers.CharField() + object_key = serializers.CharField() diff --git a/apps/chat/services/storage.py b/apps/chat/services/storage.py index 65a1b03..cdd06dd 100644 --- a/apps/chat/services/storage.py +++ b/apps/chat/services/storage.py @@ -1,3 +1,4 @@ +import uuid from datetime import timedelta from django.conf import settings @@ -43,3 +44,17 @@ class StorageService: ) except Exception: return None + + def get_upload_url(self, filename: str) -> tuple[str, str]: + """ + Sign a time-limited upload URL for a new object under a fresh + object_key, in the same bucket get_download_url reads from, so the + key handed back can be sent straight to POST /messages/ once the + upload completes. + """ + extension = filename.split(".")[-1] + object_key = f"temp/{uuid.uuid4()}.{extension}" + upload_url = self._client.presigned_put_object( + self._bucket, object_key, expires=timedelta(minutes=30) + ) + return upload_url, object_key diff --git a/apps/chat/urls.py b/apps/chat/urls.py index 44d9bf4..40dd50f 100644 --- a/apps/chat/urls.py +++ b/apps/chat/urls.py @@ -7,12 +7,14 @@ from apps.chat.views.conversations import ( UserConversationListView, ) from apps.chat.views.messages import MessageView +from apps.chat.views.presign import PresignViewSet from apps.chat.views.read_state import ChatEventsView, ReadStateView from apps.chat.views.reports import ConversationReportView urlpatterns = [ path("api/chats/", ConversationCreateView.as_view(), name="chat-create"), path("api/users//chats/", UserConversationListView.as_view(), name="user-chat-list"), + path("api/chats/presign/", PresignViewSet.as_view({"post": "create"}), name="chat-presign"), path("api/chats//messages/", MessageView.as_view(), name="chat-messages"), path("api/chats//read/", ReadStateView.as_view(), name="chat-read"), path("api/chats//events/", ChatEventsView.as_view(), name="chat-events"), diff --git a/apps/chat/views/presign.py b/apps/chat/views/presign.py new file mode 100644 index 0000000..e6614af --- /dev/null +++ b/apps/chat/views/presign.py @@ -0,0 +1,30 @@ +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + +from apps.chat.serializers.presign import PresignResultSerializer, SignContentSerializer +from apps.chat.services.storage import StorageService + + +class PresignViewSet(GenericViewSet): + authentication_classes = [] + permission_classes = [] + serializer_class = SignContentSerializer + + @extend_schema( + request=SignContentSerializer, + responses={201: PresignResultSerializer}, + ) + def create(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + upload_url, object_key = StorageService().get_upload_url( + serializer.validated_data["filename"] + ) + + return Response( + {"upload_url": upload_url, "object_key": object_key}, + status=status.HTTP_201_CREATED, + ) diff --git a/docs/chat-media-handling.md b/docs/chat-media-handling.md new file mode 100644 index 0000000..8891325 --- /dev/null +++ b/docs/chat-media-handling.md @@ -0,0 +1,150 @@ +# Sending & receiving media in chat + +**Backend → frontend handoff.** How image, video, and voice messages move through MinIO and the `/messages/` endpoint. One decision is still open and blocks upload — see [§5](#5-open-question--how-does-the-client-get-write-access-to-minio). + +| | | +|---|---| +| **Service** | gooyal_chat backend | +| **Endpoint base** | `/api/chats//messages/` | +| **Status** | text messages ready · media upload path blocked | + +--- + +## 1. How it fits together + +The backend never touches file bytes. Media is written straight from the client to MinIO; the chat backend only stores a short text reference to it (relayed through Mattermost) and, on read, signs a temporary download link. Two separate hops, two separate systems: + +```mermaid +sequenceDiagram + participant C as Client + participant M as MinIO + participant B as Chat backend + participant MM as Mattermost + + Note over C,M: Upload phase + C->>M: PUT file bytes to object_key + M-->>C: 200 OK + + Note over C,B: Send phase + C->>B: POST /messages/ { message_type, object_key } + B->>MM: post "image:object_key" as text + MM-->>B: post_id + B-->>C: 201 { post_id, url } + + Note over C,B: Read phase + C->>B: GET /messages/ + B->>MM: fetch posts + B->>M: presign GET for each object_key + M-->>B: signed URL (60 min TTL) + B-->>C: 200 [{ post_id, url, ... }] +``` + +The consequence: the backend cannot validate a file it never receives, and it never caches a download URL — every read re-signs one fresh. + +--- + +## 2. Sending a media message + +1. **Client** — pick a unique object key and `PUT` the file to MinIO directly (bucket `chat`). Suggested key shape: `s//-` — the backend imposes no format, but a conversation-scoped, collision-proof key keeps the bucket sane. +2. **Client** — enforce type and size limits *before* upload. The backend does not check either — see [§6](#6-what-the-backend-wont-check-for-you). +3. **Client** — call `POST /messages/` with the `object_key` that was just uploaded, and the matching `message_type`. +4. **Backend** — stores the message in Mattermost as text `":"` and returns it with a freshly-signed `url` for immediate display. + +### Request + +`POST /api/chats/{chat_uuid}/messages/` + +```json +{ + "sender_uuid": "9f2c1e2a-...", + "message_type": "image", + "object_key": "images/4b1c.../8a21-sunset.jpg" +} +``` + +| Field | Type | Rule | +|---|---|---| +| `sender_uuid` | UUID | required | +| `message_type` | enum (`text` \| `image` \| `video` \| `voice`) | optional — defaults to `text` | +| `text` | string | required only when `message_type` is `text` | +| `object_key` | string | required when `message_type` is `image`/`video`/`voice` — just checked for non-empty, nothing else | + +### Response — `201` + +```json +{ + "post_id": "prt_9f81...", + "sender_uuid": "9f2c1e2a-...", + "message_type": "image", + "mattermost_message_type": "", + "text": null, + "url": "https://minio.internal/chat/images/...?X-Amz-Signature=...", + "created_at": "2026-07-26T09:14:02Z" +} +``` + +A `403 { "detail": "..." }` comes back instead if the conversation has been closed. + +--- + +## 3. Reading messages + +`GET /api/chats/{chat_uuid}/messages/?per_page=20&page=0` + +```json +[ + { + "post_id": "prt_9f81...", + "message_type": "voice", + "url": "https://minio.internal/chat/voice/...?X-Amz-Signature=...", + "created_at": "2026-07-26T09:12:40Z" + } +] +``` + +| Param | Default | Notes | +|---|---|---| +| `page` | `0` | ignored if `since` is present | +| `per_page` | `20` | — | +| `since` | — | Unix ms timestamp; use for polling new messages instead of paging | + +> **Don't cache the `url` field.** It's signed for 60 minutes (`MINIO_PRESIGN_EXPIRY_SECONDS`) and re-generated on every request — it is never stored. If a link goes stale (e.g. a long-lived chat view), re-fetch the message rather than reusing the old URL. + +--- + +## 4. Object key convention + +The backend treats `object_key` as an opaque string — it validates nothing beyond "non-empty" and constructs no path itself. That freedom is also the risk: two clients that pick colliding keys silently overwrite each other's file in MinIO with no error from this API. Recommended shape: + +``` +images/{conversation_uuid}/{uuid4}-{original_filename} +video/{conversation_uuid}/{uuid4}-{original_filename} +voice/{conversation_uuid}/{uuid4}-{original_filename} +``` + +Bucket is `chat` (`MINIO_BUCKET_CHAT`) in every environment unless ops overrides it. + +--- + +## 5. Open question — how does the client get write access to MinIO? + +> **⚠ Blocks implementation.** This backend exposes **no upload endpoint** — no presigned-PUT route, no STS token issuance, nothing. `StorageService` only signs *download* URLs. There is currently no documented way for a client to legitimately write to the `chat` MinIO bucket. + +Before building the upload step, confirm with backend/infra which of these it will be: + +- A new `POST /messages/upload-url/`-style endpoint that returns a short-lived presigned **PUT** URL for a given key (mirrors the existing download flow) — most likely path, but doesn't exist yet. +- Direct client-held MinIO credentials scoped to the bucket (no backend round-trip). +- An anonymous/public-write bucket policy scoped by key prefix. + +Don't build against an assumption here — the upload half of this feature can't ship until backend confirms one of the above (or another option) and, if it's the first, implements the endpoint. + +--- + +## 6. What the backend won't check for you + +- **File type / size / content.** The only server-side check is "`object_key` is non-empty." Enforce accepted mime types and max size client-side before upload. +- **That the object actually exists.** Nothing verifies the key was really written to MinIO before the message is sent. A typo'd key sends successfully and just renders as a broken link later. +- **Auth on the messages endpoint.** `MessageView` currently has no authentication or permission check at all — anyone who can reach the route can post as any `sender_uuid`. Don't build client trust assumptions (e.g. "the server verified this sender") on top of it; treat it as likely to change. + +--- +*gooyal_chat · backend↔frontend handoff · generated 2026-07-26*