add presign endponit #6

Merged
Ghasemi merged 1 commit from feature/minio-endpoint into master 2026-07-27 05:59:18 -04:00
5 changed files with 207 additions and 0 deletions

View file

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

View file

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

View file

@ -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/<uuid:user_uuid>/chats/", UserConversationListView.as_view(), name="user-chat-list"),
path("api/chats/presign/", PresignViewSet.as_view({"post": "create"}), name="chat-presign"),
path("api/chats/<uuid:chat_uuid>/messages/", MessageView.as_view(), name="chat-messages"),
path("api/chats/<uuid:chat_uuid>/read/", ReadStateView.as_view(), name="chat-read"),
path("api/chats/<uuid:chat_uuid>/events/", ChatEventsView.as_view(), name="chat-events"),

View file

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

150
docs/chat-media-handling.md Normal file
View file

@ -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/<chat_uuid>/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: `<type>s/<conversation_uuid>/<random-id>-<filename>` — 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 `"<type>:<object_key>"` 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*