add presign endponit #6
5 changed files with 207 additions and 0 deletions
10
apps/chat/serializers/presign.py
Normal file
10
apps/chat/serializers/presign.py
Normal 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()
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import uuid
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
@ -43,3 +44,17 @@ class StorageService:
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
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
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,14 @@ from apps.chat.views.conversations import (
|
||||||
UserConversationListView,
|
UserConversationListView,
|
||||||
)
|
)
|
||||||
from apps.chat.views.messages import MessageView
|
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.read_state import ChatEventsView, ReadStateView
|
||||||
from apps.chat.views.reports import ConversationReportView
|
from apps.chat.views.reports import ConversationReportView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("api/chats/", ConversationCreateView.as_view(), name="chat-create"),
|
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/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>/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>/read/", ReadStateView.as_view(), name="chat-read"),
|
||||||
path("api/chats/<uuid:chat_uuid>/events/", ChatEventsView.as_view(), name="chat-events"),
|
path("api/chats/<uuid:chat_uuid>/events/", ChatEventsView.as_view(), name="chat-events"),
|
||||||
|
|
|
||||||
30
apps/chat/views/presign.py
Normal file
30
apps/chat/views/presign.py
Normal 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
150
docs/chat-media-handling.md
Normal 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*
|
||||||
Loading…
Add table
Reference in a new issue