add presign endponit
This commit is contained in:
parent
f0f7454797
commit
392aa8e9c1
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 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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
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