diff --git a/.env.example b/.env.example index 18b410d..10cfe8d 100644 --- a/.env.example +++ b/.env.example @@ -27,10 +27,15 @@ MATTERMOST_SERVICE_USERNAME=chat-service MATTERMOST_SERVICE_EMAIL=chat-service@local.invalid # MinIO object storage +# The frontend uploads media directly to MinIO and sends us only the +# resulting object key — these credentials are used solely to sign +# short-lived download URLs when messages are read back. MINIO_ENDPOINT=localhost:9000 MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_BUCKET_CHAT=chat +MINIO_SECURE=false +MINIO_PRESIGN_EXPIRY_SECONDS=3600 # Chat long-poll tuning CHAT_LONG_POLL_TIMEOUT_SECONDS=25 diff --git a/apps/chat/admin.py b/apps/chat/admin.py index 8c38f3f..a389924 100644 --- a/apps/chat/admin.py +++ b/apps/chat/admin.py @@ -1,3 +1,23 @@ from django.contrib import admin -# Register your models here. +from apps.chat.models import ConversationReport + + +@admin.register(ConversationReport) +class ConversationReportAdmin(admin.ModelAdmin): + list_display = ("uuid", "conversation", "reporter_uuid", "reason", "created_at") + list_filter = ("reason", "created_at") + search_fields = ("uuid", "conversation__uuid", "reporter_uuid") + readonly_fields = ( + "uuid", + "conversation", + "reporter_uuid", + "reason", + "other_reason_text", + "created_at", + ) + ordering = ("-created_at",) + + def has_add_permission(self, request): + # Reports are only ever created by users through the API. + return False diff --git a/apps/chat/migrations/0004_conversationreport.py b/apps/chat/migrations/0004_conversationreport.py new file mode 100644 index 0000000..8b9352c --- /dev/null +++ b/apps/chat/migrations/0004_conversationreport.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.13 on 2026-07-20 10:17 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat', '0003_conversation_closed_by_uuid_conversation_status_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ConversationReport', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('reporter_uuid', models.UUIDField()), + ('reason', models.CharField(choices=[('harassing_message', 'پیام آزار دهنده'), ('fraud', 'کلاه\u200cبرداری'), ('inappropriate_content', 'محتوای نامناسب'), ('other', 'سایر')], max_length=32)), + ('other_reason_text', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='chat.conversation')), + ], + options={ + 'indexes': [models.Index(fields=['conversation'], name='chat_conver_convers_d48bec_idx'), models.Index(fields=['reporter_uuid'], name='chat_conver_reporte_5dc041_idx'), models.Index(fields=['reason'], name='chat_conver_reason_62cd7f_idx'), models.Index(fields=['created_at'], name='chat_conver_created_6ce88a_idx')], + }, + ), + ] diff --git a/apps/chat/models/__init__.py b/apps/chat/models/__init__.py index 873fa0d..5eb06c5 100644 --- a/apps/chat/models/__init__.py +++ b/apps/chat/models/__init__.py @@ -2,6 +2,7 @@ from apps.chat.models.conversation import Conversation, ConversationStatus, Conv from apps.chat.models.mapping import MattermostAccountMapping from apps.chat.models.participant import ConversationParticipant from apps.chat.models.read_state import ConversationReadState +from apps.chat.models.report import ConversationReport, ReportReason __all__ = [ "Conversation", @@ -10,4 +11,6 @@ __all__ = [ "ConversationParticipant", "MattermostAccountMapping", "ConversationReadState", + "ConversationReport", + "ReportReason", ] diff --git a/apps/chat/models/report.py b/apps/chat/models/report.py new file mode 100644 index 0000000..e6e2460 --- /dev/null +++ b/apps/chat/models/report.py @@ -0,0 +1,37 @@ +import uuid + +from django.db import models + +from apps.chat.models.conversation import Conversation + + +class ReportReason(models.TextChoices): + HARASSING_MESSAGE = "harassing_message", "پیام آزار دهنده" + FRAUD = "fraud", "کلاه‌برداری" + INAPPROPRIATE_CONTENT = "inappropriate_content", "محتوای نامناسب" + OTHER = "other", "سایر" + + +class ConversationReport(models.Model): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + conversation = models.ForeignKey( + Conversation, + related_name="reports", + on_delete=models.CASCADE, + ) + reporter_uuid = models.UUIDField() + reason = models.CharField(max_length=32, choices=ReportReason.choices) + # Only populated when reason == OTHER. + other_reason_text = models.TextField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + indexes = [ + models.Index(fields=["conversation"]), + models.Index(fields=["reporter_uuid"]), + models.Index(fields=["reason"]), + models.Index(fields=["created_at"]), + ] + + def __str__(self): + return f"{self.get_reason_display()} — {self.conversation_id}" diff --git a/apps/chat/serializers/messages.py b/apps/chat/serializers/messages.py index 67bc47b..86c0fa4 100644 --- a/apps/chat/serializers/messages.py +++ b/apps/chat/serializers/messages.py @@ -1,11 +1,28 @@ from rest_framework import serializers +MEDIA_MESSAGE_TYPES = {"image", "video", "voice"} + class SendMessageSerializer(serializers.Serializer): sender_uuid = serializers.UUIDField() - message_type = serializers.ChoiceField(choices=["text", "image", "file"], default="text") + message_type = serializers.ChoiceField( + choices=["text", "image", "video", "voice"], default="text" + ) text = serializers.CharField(required=False, allow_null=True, allow_blank=True) - file = serializers.FileField(required=False, allow_null=True) + object_key = serializers.CharField(required=False, allow_null=True, allow_blank=True) + + def validate(self, attrs): + message_type = attrs.get("message_type", "text") + if message_type in MEDIA_MESSAGE_TYPES: + if not attrs.get("object_key"): + raise serializers.ValidationError( + {"object_key": "object_key is required for image, video, and voice messages."} + ) + elif not attrs.get("text"): + raise serializers.ValidationError( + {"text": "text is required for text messages."} + ) + return attrs class MessageSerializer(serializers.Serializer): diff --git a/apps/chat/serializers/reports.py b/apps/chat/serializers/reports.py new file mode 100644 index 0000000..09ca0ce --- /dev/null +++ b/apps/chat/serializers/reports.py @@ -0,0 +1,31 @@ +from rest_framework import serializers + +from apps.chat.models import ConversationReport, ReportReason + + +class CreateReportSerializer(serializers.Serializer): + reporter_uuid = serializers.UUIDField() + reason = serializers.ChoiceField(choices=ReportReason.choices) + other_reason_text = serializers.CharField(required=False, allow_null=True, allow_blank=True) + + def validate(self, attrs): + if attrs["reason"] == ReportReason.OTHER and not attrs.get("other_reason_text"): + raise serializers.ValidationError( + {"other_reason_text": "other_reason_text is required when reason is 'other'."} + ) + return attrs + + +class ReportSerializer(serializers.ModelSerializer): + conversation_uuid = serializers.UUIDField(source="conversation_id") + + class Meta: + model = ConversationReport + fields = [ + "uuid", + "conversation_uuid", + "reporter_uuid", + "reason", + "other_reason_text", + "created_at", + ] diff --git a/apps/chat/services/message.py b/apps/chat/services/message.py index e2c7236..d206cbe 100644 --- a/apps/chat/services/message.py +++ b/apps/chat/services/message.py @@ -11,6 +11,8 @@ from apps.chat.models import Conversation, ConversationStatus, MattermostAccount from apps.chat.services.account import AccountService from apps.chat.services.storage import StorageService +MEDIA_MESSAGE_TYPES = {"image", "video", "voice"} + class MessageService: def __init__( @@ -41,7 +43,7 @@ class MessageService: sender_uuid: UUID, message_type: str, text: str | None = None, - file=None, + object_key: str | None = None, ) -> dict: conversation = Conversation.objects.get(uuid=conversation_uuid) if conversation.status == ConversationStatus.CLOSED: @@ -50,15 +52,15 @@ class MessageService: if not self._account.validate_user(sender_uuid): raise ValueError(f"Sender {sender_uuid} is not valid") - file_url: str | None = None - if file is not None: - filename = getattr(file, "name", f"{sender_uuid}") - file_url = self._storage.upload_file(file, filename) - - if message_type == "text": - mm_message = text or "" + # Media was already uploaded straight to MinIO by the client; the + # Mattermost post body only ever carries ":", never + # a URL, so a stored link can't outlive its presigned expiry. + download_url: str | None = None + if message_type in MEDIA_MESSAGE_TYPES: + mm_message = f"{message_type}:{object_key}" + download_url = self._storage.get_download_url(object_key) else: - mm_message = file_url or text or "" + mm_message = text or "" post_id = self._mm.post_message(conversation.mattermost_channel_id, mm_message) @@ -67,7 +69,7 @@ class MessageService: post_id=post_id, sender_uuid=sender_uuid, message_type=message_type, - payload={"text": text, "file_url": file_url}, + payload={"text": text, "object_key": object_key, "url": download_url}, ) for publisher in self._publishers: publisher.publish(event) @@ -77,7 +79,7 @@ class MessageService: "sender_uuid": sender_uuid, "message_type": message_type, "text": text, - "url": file_url, + "url": download_url, "created_at": None, } @@ -106,23 +108,33 @@ class MessageService: return [self._normalize_post(p, mappings) for p in raw_posts] - @staticmethod - def _normalize_post(post: dict, mappings: dict) -> dict: + def _normalize_post(self, post: dict, mappings: dict) -> dict: mm_uid = post.get("user_id") sender_uuid = mappings.get(mm_uid) msg = post.get("message", "") - is_url = msg.startswith("http") created_ms = post.get("create_at") created_at = ( datetime.fromtimestamp(created_ms / 1000, tz=timezone.utc) if created_ms else None ) + + message_type, sep, object_key = msg.partition(":") + if sep and message_type in MEDIA_MESSAGE_TYPES and object_key: + return { + "post_id": post.get("id"), + "sender_uuid": sender_uuid, + "message_type": message_type, + "text": None, + "url": self._storage.get_download_url(object_key), + "created_at": created_at, + } + return { "post_id": post.get("id"), "sender_uuid": sender_uuid, - "message_type": "file" if is_url else "text", - "text": None if is_url else msg, - "url": msg if is_url else None, + "message_type": "text", + "text": msg, + "url": None, "created_at": created_at, } diff --git a/apps/chat/services/report.py b/apps/chat/services/report.py new file mode 100644 index 0000000..30ead42 --- /dev/null +++ b/apps/chat/services/report.py @@ -0,0 +1,20 @@ +from uuid import UUID + +from apps.chat.models import Conversation, ConversationReport, ReportReason + + +class ReportService: + def create( + self, + conversation_uuid: UUID, + reporter_uuid: UUID, + reason: str, + other_reason_text: str | None = None, + ) -> ConversationReport: + conversation = Conversation.objects.get(uuid=conversation_uuid) + return ConversationReport.objects.create( + conversation=conversation, + reporter_uuid=reporter_uuid, + reason=reason, + other_reason_text=other_reason_text if reason == ReportReason.OTHER else None, + ) diff --git a/apps/chat/services/storage.py b/apps/chat/services/storage.py index eff1738..65a1b03 100644 --- a/apps/chat/services/storage.py +++ b/apps/chat/services/storage.py @@ -1,12 +1,17 @@ -import io -import mimetypes -from uuid import uuid4 +from datetime import timedelta from django.conf import settings from minio import Minio class StorageService: + """ + Thin adapter around MinIO for the object-key-in-message-body flow: the + frontend uploads media directly to MinIO and only ever sends us the + resulting object key, so this service's job is limited to turning that + key back into a short-lived download URL on read. + """ + def __init__(self): endpoint = getattr(settings, "MINIO_ENDPOINT", None) if not endpoint: @@ -20,23 +25,21 @@ class StorageService: secure=secure, ) self._bucket = getattr(settings, "MINIO_BUCKET_CHAT", "chat") - scheme = "https" if secure else "http" - self._base_url = f"{scheme}://{endpoint}" - - def upload_file(self, file, filename: str) -> str: - data = file.read() if hasattr(file, "read") else file - size = len(data) - - content_type, _ = mimetypes.guess_type(filename) - if not content_type: - content_type = "application/octet-stream" - - object_name = f"{uuid4().hex}/{filename}" - self._client.put_object( - self._bucket, - object_name, - io.BytesIO(data), - size, - content_type=content_type, + self._expiry = timedelta( + seconds=getattr(settings, "MINIO_PRESIGN_EXPIRY_SECONDS", 3600) ) - return f"{self._base_url}/{self._bucket}/{object_name}" + + def get_download_url(self, object_key: str) -> str | None: + """ + Sign a time-limited download URL for an object the client already + uploaded. Generated fresh on every call rather than cached/stored, + since a stored link would eventually expire while still displayed. + Returns None on failure so one bad key can't fail an entire message + list — callers should treat that as "link unavailable", not an error. + """ + try: + return self._client.presigned_get_object( + self._bucket, object_key, expires=self._expiry + ) + except Exception: + return None diff --git a/apps/chat/tests/test_messages.py b/apps/chat/tests/test_messages.py index 294d606..5f61b9b 100644 --- a/apps/chat/tests/test_messages.py +++ b/apps/chat/tests/test_messages.py @@ -7,14 +7,13 @@ from apps.chat.models import Conversation, MattermostAccountMapping from apps.chat.services.message import MessageService -def _make_service(*, mm_post_id="post-1", storage_url=None, publishers=None): +def _make_service(*, mm_post_id="post-1", download_url=None, publishers=None): """Return a MessageService with all external deps mocked.""" mm_client = Mock() mm_client.post_message.return_value = mm_post_id storage = Mock() - if storage_url: - storage.upload_file.return_value = storage_url + storage.get_download_url.return_value = download_url return ( MessageService( @@ -33,11 +32,12 @@ def test_send_text_message(): sender_uuid = uuid.uuid4() ws_publisher = Mock() - svc, mm_client, _ = _make_service(mm_post_id="post-abc", publishers=[ws_publisher]) + svc, mm_client, storage = _make_service(mm_post_id="post-abc", publishers=[ws_publisher]) result = svc.send(conv.uuid, sender_uuid, "text", text="Hello world") mm_client.post_message.assert_called_once_with("ch-send-text", "Hello world") + storage.get_download_url.assert_not_called() ws_publisher.publish.assert_called_once() event = ws_publisher.publish.call_args[0][0] @@ -47,6 +47,7 @@ def test_send_text_message(): assert result["post_id"] == "post-abc" assert result["text"] == "Hello world" + assert result["url"] is None @pytest.mark.django_db @@ -54,17 +55,16 @@ def test_send_image_message(): conv = Conversation.objects.create(mattermost_channel_id="ch-send-image") sender_uuid = uuid.uuid4() - file_url = "http://minio.local/chat/abc123/photo.jpg" - svc, mm_client, storage = _make_service(storage_url=file_url, publishers=[]) + object_key = "images/abc123/photo.jpg" + download_url = "http://minio.local/chat/images/abc123/photo.jpg?X-Amz-Signature=..." + svc, mm_client, storage = _make_service(download_url=download_url, publishers=[]) - fake_file = Mock() - fake_file.name = "photo.jpg" + result = svc.send(conv.uuid, sender_uuid, "image", object_key=object_key) - result = svc.send(conv.uuid, sender_uuid, "image", file=fake_file) - - storage.upload_file.assert_called_once_with(fake_file, "photo.jpg") - mm_client.post_message.assert_called_once_with("ch-send-image", file_url) - assert result["url"] == file_url + storage.get_download_url.assert_called_once_with(object_key) + mm_client.post_message.assert_called_once_with("ch-send-image", f"image:{object_key}") + assert result["url"] == download_url + assert result["message_type"] == "image" @pytest.mark.django_db @@ -77,6 +77,7 @@ def test_list_messages_normalized(): user_uuid=sender_uuid, mattermost_user_id=mm_user_id ) + object_key = "voice/def456/clip.ogg" raw_posts = [ { "id": "p1", @@ -87,14 +88,15 @@ def test_list_messages_normalized(): { "id": "p2", "user_id": mm_user_id, - "message": "http://minio.local/chat/file.pdf", + "message": f"voice:{object_key}", "create_at": 1700000001000, }, ] + download_url = "http://minio.local/chat/voice/def456/clip.ogg?X-Amz-Signature=..." mm_client = Mock() mm_client.get_posts.return_value = raw_posts - svc, _, _ = _make_service(publishers=[]) + svc, _, storage = _make_service(download_url=download_url, publishers=[]) svc._mm = mm_client # inject after construction to keep _make_service simple messages = svc.list_messages(conv.uuid, page=0, per_page=20) @@ -108,9 +110,39 @@ def test_list_messages_normalized(): assert text_msg["text"] == "First message" assert text_msg["url"] is None - file_msg = messages[1] - assert file_msg["post_id"] == "p2" - assert file_msg["sender_uuid"] == sender_uuid - assert file_msg["message_type"] == "file" - assert file_msg["url"] == "http://minio.local/chat/file.pdf" - assert file_msg["text"] is None + voice_msg = messages[1] + assert voice_msg["post_id"] == "p2" + assert voice_msg["sender_uuid"] == sender_uuid + assert voice_msg["message_type"] == "voice" + assert voice_msg["url"] == download_url + assert voice_msg["text"] is None + + storage.get_download_url.assert_called_once_with(object_key) + + +@pytest.mark.django_db +def test_list_messages_treats_colon_in_plain_text_as_text(): + """A plain-text message that happens to contain a colon but doesn't use + a recognized media-type prefix must not be mistaken for a media message. + """ + conv = Conversation.objects.create(mattermost_channel_id="ch-list-colon") + + raw_posts = [ + { + "id": "p1", + "user_id": None, + "message": "Meeting at 10:30", + "create_at": 1700000000000, + }, + ] + + mm_client = Mock() + mm_client.get_posts.return_value = raw_posts + svc, _, storage = _make_service(publishers=[]) + svc._mm = mm_client + + messages = svc.list_messages(conv.uuid, page=0, per_page=20) + + assert messages[0]["message_type"] == "text" + assert messages[0]["text"] == "Meeting at 10:30" + storage.get_download_url.assert_not_called() diff --git a/apps/chat/tests/test_reports.py b/apps/chat/tests/test_reports.py new file mode 100644 index 0000000..f468f26 --- /dev/null +++ b/apps/chat/tests/test_reports.py @@ -0,0 +1,85 @@ +import uuid + +import pytest + +from apps.chat.models import Conversation, ConversationReport, ReportReason +from apps.chat.serializers.reports import CreateReportSerializer +from apps.chat.services.report import ReportService + + +def _make_conv(channel_id="ch-report"): + return Conversation.objects.create(mattermost_channel_id=channel_id) + + +@pytest.mark.django_db +def test_create_report_with_predefined_reason(): + conv = _make_conv() + reporter_uuid = uuid.uuid4() + + report = ReportService().create( + conv.uuid, reporter_uuid, ReportReason.FRAUD + ) + + assert report.conversation_id == conv.uuid + assert report.reporter_uuid == reporter_uuid + assert report.reason == ReportReason.FRAUD + assert report.other_reason_text is None + assert ConversationReport.objects.filter(uuid=report.uuid).exists() + + +@pytest.mark.django_db +def test_create_report_other_reason_stores_text(): + conv = _make_conv() + reporter_uuid = uuid.uuid4() + + report = ReportService().create( + conv.uuid, reporter_uuid, ReportReason.OTHER, other_reason_text="spamming me constantly" + ) + + assert report.reason == ReportReason.OTHER + assert report.other_reason_text == "spamming me constantly" + + +@pytest.mark.django_db +def test_create_report_ignores_other_reason_text_for_non_other_reason(): + conv = _make_conv() + reporter_uuid = uuid.uuid4() + + report = ReportService().create( + conv.uuid, + reporter_uuid, + ReportReason.INAPPROPRIATE_CONTENT, + other_reason_text="this should be dropped", + ) + + assert report.reason == ReportReason.INAPPROPRIATE_CONTENT + assert report.other_reason_text is None + + +def test_serializer_requires_other_reason_text_when_reason_is_other(): + serializer = CreateReportSerializer( + data={"reporter_uuid": str(uuid.uuid4()), "reason": ReportReason.OTHER} + ) + + assert not serializer.is_valid() + assert "other_reason_text" in serializer.errors + + +def test_serializer_valid_without_other_reason_text_for_predefined_reason(): + serializer = CreateReportSerializer( + data={"reporter_uuid": str(uuid.uuid4()), "reason": ReportReason.HARASSING_MESSAGE} + ) + + assert serializer.is_valid(), serializer.errors + + +def test_serializer_valid_with_other_reason_text_for_other_reason(): + serializer = CreateReportSerializer( + data={ + "reporter_uuid": str(uuid.uuid4()), + "reason": ReportReason.OTHER, + "other_reason_text": "something specific", + } + ) + + assert serializer.is_valid(), serializer.errors diff --git a/apps/chat/urls.py b/apps/chat/urls.py index 1571a28..44d9bf4 100644 --- a/apps/chat/urls.py +++ b/apps/chat/urls.py @@ -8,6 +8,7 @@ from apps.chat.views.conversations import ( ) from apps.chat.views.messages import MessageView 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"), @@ -17,4 +18,5 @@ urlpatterns = [ path("api/chats//events/", ChatEventsView.as_view(), name="chat-events"), path("api/chats//close/", ConversationCloseView.as_view(), name="chat-close"), path("api/chats//reopen/", ConversationReopenView.as_view(), name="chat-reopen"), + path("api/chats//report/", ConversationReportView.as_view(), name="chat-report"), ] diff --git a/apps/chat/views/messages.py b/apps/chat/views/messages.py index 4ea3da3..5bc1aee 100644 --- a/apps/chat/views/messages.py +++ b/apps/chat/views/messages.py @@ -29,7 +29,7 @@ class MessageView(APIView): sender_uuid=data["sender_uuid"], message_type=data["message_type"], text=data.get("text"), - file=data.get("file"), + object_key=data.get("object_key"), ) except ConversationClosedError as exc: return Response({"detail": str(exc)}, status=status.HTTP_403_FORBIDDEN) diff --git a/apps/chat/views/reports.py b/apps/chat/views/reports.py new file mode 100644 index 0000000..519bba7 --- /dev/null +++ b/apps/chat/views/reports.py @@ -0,0 +1,32 @@ +from uuid import UUID + +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.chat.serializers.reports import CreateReportSerializer, ReportSerializer +from apps.chat.services.report import ReportService + + +class ConversationReportView(APIView): + authentication_classes = [] + permission_classes = [] + + @extend_schema( + request=CreateReportSerializer, + responses={201: ReportSerializer}, + ) + def post(self, request, chat_uuid): + serializer = CreateReportSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + report = ReportService().create( + conversation_uuid=UUID(str(chat_uuid)), + reporter_uuid=data["reporter_uuid"], + reason=data["reason"], + other_reason_text=data.get("other_reason_text"), + ) + + return Response(ReportSerializer(report).data, status=status.HTTP_201_CREATED) diff --git a/env.sample b/env.sample index 1dd3033..4177b22 100644 --- a/env.sample +++ b/env.sample @@ -24,3 +24,5 @@ MINIO_ENDPOINT=localhost:9000 MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_BUCKET_CHAT=chat +MINIO_SECURE=false +MINIO_PRESIGN_EXPIRY_SECONDS=3600 diff --git a/main/settings.py b/main/settings.py index 47446b4..be4b5d8 100644 --- a/main/settings.py +++ b/main/settings.py @@ -288,5 +288,7 @@ MINIO_ENDPOINT = config('MINIO_ENDPOINT', default=None) MINIO_ACCESS_KEY = config('MINIO_ACCESS_KEY', default=None) MINIO_SECRET_KEY = config('MINIO_SECRET_KEY', default=None) MINIO_BUCKET_CHAT = config('MINIO_BUCKET_CHAT', default='chat') +MINIO_SECURE = config('MINIO_SECURE', default=False, cast=bool) +MINIO_PRESIGN_EXPIRY_SECONDS = config('MINIO_PRESIGN_EXPIRY_SECONDS', default=3600, cast=int) GDAL_LIBRARY_PATH = config('GDAL_LIBRARY_PATH', default=None) GEOS_LIBRARY_PATH = config('GEOS_LIBRARY_PATH', default=None)