diff --git a/Integreat - Deutsch - Augsburg.pdf b/Integreat - Deutsch - Augsburg.pdf deleted file mode 100644 index 396b086094..0000000000 Binary files a/Integreat - Deutsch - Augsburg.pdf and /dev/null differ diff --git a/integreat_cms/api/urls.py b/integreat_cms/api/urls.py index 0ab522923d..c73747bc63 100644 --- a/integreat_cms/api/urls.py +++ b/integreat_cms/api/urls.py @@ -343,6 +343,11 @@ user_chat.is_chat_enabled_for_user, name="is_chat_enabled_for_user", ), + path( + "chat//attachment///", + user_chat.chat_attachment, + name="chat_attachment", + ), path("/", include(content_api_urlpatterns)), ], ), diff --git a/integreat_cms/api/v3/chat/user_chat.py b/integreat_cms/api/v3/chat/user_chat.py index 7f3520f23a..3d4c535fdd 100644 --- a/integreat_cms/api/v3/chat/user_chat.py +++ b/integreat_cms/api/v3/chat/user_chat.py @@ -7,12 +7,13 @@ import json import logging import random +from io import BytesIO from typing import TYPE_CHECKING import requests from django.conf import settings from django.core.cache import cache -from django.http import HttpResponse, JsonResponse +from django.http import FileResponse, HttpResponse, JsonResponse from django.shortcuts import get_object_or_404 from django.utils.dateparse import parse_datetime from django.views.decorators.csrf import csrf_exempt @@ -26,6 +27,7 @@ ) if TYPE_CHECKING: + from django.core.files.uploadedfile import UploadedFile from django.http import HttpRequest logger = logging.getLogger(__name__) @@ -89,6 +91,30 @@ def get_or_create_user_chat( return None +def validate_attachments(attachments: list[UploadedFile]) -> str | None: + """ + Validate a list of uploaded chat attachments against the configured + size, MIME type and count restrictions. + + :param attachments: uploaded files to validate + :return: an error message if validation fails, otherwise ``None`` + """ + for attachment in attachments: + if ( + attachment.size is None + or attachment.size > settings.INTEGREAT_CHAT_ATTACHMENT_MAX_SIZE + ): + return f"Attachment '{attachment.name}' exceeds the maximum allowed size." + if ( + attachment.content_type + not in settings.INTEGREAT_CHAT_ATTACHMENT_ALLOWED_MIME_TYPES + ): + return f"Attachment '{attachment.name}' has an unsupported file type." + if len(attachments) > settings.INTEGREAT_CHAT_ATTACHMENT_MAX_COUNT: + return f"At most {settings.INTEGREAT_CHAT_ATTACHMENT_MAX_COUNT} attachments can be sent per message." + return None + + def process_chat_payload( request: HttpRequest, device_id: str, language_slug: str ) -> JsonResponse: @@ -103,13 +129,20 @@ def process_chat_payload( language = Language.objects.get(slug=language_slug) if (user_chat := get_or_create_user_chat(request, device_id, language)) is None: return JsonResponse({"error": "Chat not found."}, status=404) - if request.POST.get("message"): + attachments = request.FILES.getlist("attachment") + if attachments and (error := validate_attachments(attachments)) is not None: + return JsonResponse({"error": error}, status=400) + message_text = request.POST.get("message", "") + if message_text or attachments: response = user_chat.save_message( - message=request.POST.get("message"), internal=False, automatic_message=False + message=message_text, + internal=False, + automatic_message=False, + attachments=attachments or None, ) user_chat.language = language user_chat.save() - if response is not None: + if message_text and response is not None: if user_chat.automatic_answers: user_chat.processing_answer = True # type: ignore[assignment] celery_translate_and_answer_question.apply_async( @@ -122,7 +155,7 @@ def process_chat_payload( else: celery_translate_question.apply_async( args=[ - request.POST.get("message"), + message_text, request.region.slug, response["ticket_id"], ] @@ -176,6 +209,66 @@ def chat( ) +@csrf_exempt +@json_response +@rate_limit +def chat_attachment( + request: HttpRequest, + region_slug: str, + device_id: str, + article_id: int, + attachment_id: int, +) -> FileResponse | JsonResponse: + """ + Download an attachment from the current chat ticket of the given device. + + The attachment must belong to a non-internal article of the device's current + Zammad ticket; otherwise a 404 is returned. + + :param request: Django request + :param region_slug: slug of the region + :param device_id: ID of the device requesting the attachment + :param article_id: ID of the Zammad article the attachment belongs to + :param attachment_id: ID of the attachment within the article + :return: file response or JSON error + """ + if ( + not request.region.integreat_chat_enabled + or not request.region.zammad_url + or not request.region.zammad_access_token + ): + return JsonResponse( + {"error": "No chat server is configured for your region."}, + status=503, + ) + user_chat = UserChat.objects.current_chat(device_id, region=request.region) + if user_chat is None or user_chat.is_expired: + return JsonResponse({"error": "Chat not found."}, status=404) + try: + result = user_chat.get_attachment(article_id, attachment_id) + except ( + requests.exceptions.HTTPError, + requests.exceptions.ConnectionError, + ValueError, + ): + logger.exception("Could not connect to Zammad") + return JsonResponse( + { + "error": "An error occurred while attempting to connect to the chat server." + }, + status=500, + ) + if result is None: + return JsonResponse({"error": "Attachment not found."}, status=404) + content, content_type, filename = result + return FileResponse( + BytesIO(content), + content_type=content_type, + as_attachment=True, + filename=filename, + ) + + def is_app_user_message(webhook_message: dict) -> bool: """ Check if message originates from app user diff --git a/integreat_cms/cms/utils/zammad.py b/integreat_cms/cms/utils/zammad.py index 8e92f45373..ebfedf68f7 100644 --- a/integreat_cms/cms/utils/zammad.py +++ b/integreat_cms/cms/utils/zammad.py @@ -2,6 +2,7 @@ Zammad API helper functions """ +import base64 from abc import abstractmethod from datetime import datetime from typing import TYPE_CHECKING @@ -11,6 +12,8 @@ from django.core.cache import cache if TYPE_CHECKING: + from django.core.files.uploadedfile import UploadedFile + from ..models import Region @@ -137,6 +140,15 @@ def clean_message(self, message: dict) -> dict: ) message["user_is_author"] = message["role"] == "user" message["content"] = message["body"] + message["attachments"] = [ + { + "id": attachment["id"], + "filename": attachment["filename"], + "size": int(attachment.get("size", 0) or 0), + "content_type": self._attachment_content_type(attachment), + } + for attachment in (message.get("attachments") or []) + ] keys_to_keep = [ "status", "error", @@ -146,6 +158,7 @@ def clean_message(self, message: dict) -> dict: "role", "content", "created_at", + "attachments", ] return {key: message[key] for key in message if key in keys_to_keep} @@ -182,6 +195,7 @@ def save_message( internal: bool, automatic_message: bool, words_generated: int = 0, + attachments: "list[UploadedFile] | None" = None, ) -> dict | None: """ Save a new message (article) to a Zammad ticket. @@ -190,29 +204,40 @@ def save_message( :param internal: true if message should not be visible to app user :param automatic_message: true if message does not originate from a human :param num_words: number of words in the generated message + :param attachments: optional list of uploaded files to attach to the article :return: Zammad ticket information """ self.update_mt_budget(words_generated) cache.delete(f"{self.region.slug}_{self.device_id}") + payload: dict = { + "ticket_id": self.zammad_id, + "body": message, + "internal": internal, + "automatic_message": automatic_message, + "subject": ( + "app user message" + if not automatic_message + else "automatically generated message" + ), + "content_type": "text/html", + "type": "web", + "sender": "Customer" if not automatic_message else "Agent", + } + if attachments: + payload["attachments"] = [ + { + "filename": attachment.name, + "data": base64.b64encode(attachment.read()).decode("ascii"), + "mime-type": attachment.content_type or "application/octet-stream", + } + for attachment in attachments + ] try: response = self.zammad_request( "POST", "/api/v1/ticket_articles", - { - "ticket_id": self.zammad_id, - "body": message, - "internal": internal, - "automatic_message": automatic_message, - "subject": ( - "app user message" - if not automatic_message - else "automatically generated message" - ), - "content_type": "text/html", - "type": "web", - "sender": "Customer" if not automatic_message else "Agent", - }, + payload, ) except ValueError: return None @@ -220,6 +245,64 @@ def save_message( return None return response.json() + @staticmethod + def _attachment_content_type(attachment: dict) -> str: + """ + Determine the MIME type of a Zammad attachment. + + Zammad reports the MIME type under the ``Mime-Type`` preference key for + every attachment, while ``Content-Type`` is only present for inline + images. ``Mime-Type`` is therefore used first, falling back to + ``Content-Type`` and finally to ``application/octet-stream``. + + :param attachment: attachment dict as returned by the Zammad API + :return: the MIME type of the attachment + """ + preferences = attachment.get("preferences") or {} + for key in ("Mime-Type", "Content-Type"): + content_type = preferences.get(key) + if content_type: + return content_type + return "application/octet-stream" + + def get_attachment( + self, article_id: int, attachment_id: int + ) -> tuple[bytes, str, str] | None: + """ + Fetch an attachment from a Zammad article that belongs to this ticket. + + The article is first inspected to ensure it belongs to this ticket and is + not internal. Internal article attachments must never be exposed to app users. + + :param article_id: ID of the Zammad article (message) + :param attachment_id: ID of the attachment within the article + :return: tuple of ``(content, content_type, filename)`` or ``None`` if + the attachment cannot be served + """ + try: + article = self.zammad_request( + "GET", f"/api/v1/ticket_articles/{article_id}" + ).json() + except ValueError: + return None + if article.get("ticket_id") != self.zammad_id or article.get("internal"): + return None + attachment_meta = next( + (a for a in (article.get("attachments") or []) if a["id"] == attachment_id), + None, + ) + if attachment_meta is None: + return None + try: + response = self.zammad_request( + "GET", + f"/api/v1/ticket_attachment/{self.zammad_id}/{article_id}/{attachment_id}", + ) + except ValueError: + return None + content_type = self._attachment_content_type(attachment_meta) + return response.content, content_type, attachment_meta["filename"] + @property def evaluation_consent(self) -> bool: """ diff --git a/integreat_cms/core/settings.py b/integreat_cms/core/settings.py index ba657660a7..ba102077ea 100644 --- a/integreat_cms/core/settings.py +++ b/integreat_cms/core/settings.py @@ -1410,7 +1410,28 @@ #: the ``device_id`` attribute is set on Zammad tickets so that incoming webhooks #: can be mapped to the correct chat regardless of the region. MULTI_REGION_ZAMMAD: Final[bool] = bool( - strtobool(os.environ.get("INTEGREAT_CMS_MULTI_REGION_ZAMMAD", "False")), + strtobool(os.environ.get("INTEGREAT_CMS_MULTI_REGION_ZAMMAD", "False")) +) + +#: Maximum size (bytes) of a single chat attachment uploaded by an Integreat app user +INTEGREAT_CHAT_ATTACHMENT_MAX_SIZE: Final[int] = env_int( + "INTEGREAT_CMS_INTEGREAT_CHAT_ATTACHMENT_MAX_SIZE", + 10 * 1024 * 1024, +) + +#: Maximum number of chat attachments per message from an Integreat app user +INTEGREAT_CHAT_ATTACHMENT_MAX_COUNT: Final[int] = env_int( + "INTEGREAT_CMS_INTEGREAT_CHAT_ATTACHMENT_MAX_COUNT", 5 +) + +#: Allowed MIME types for chat attachments uploaded by Integreat app users +INTEGREAT_CHAT_ATTACHMENT_ALLOWED_MIME_TYPES: Final[tuple[str, ...]] = ( + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "application/pdf", + "text/plain", ) ##########