# app/services/message_attachment_service.py """ Attachment handling service for messaging system. Handles file upload, validation, storage, and retrieval. """ import logging import os import uuid from datetime import datetime from pathlib import Path from fastapi import UploadFile from sqlalchemy.orm import Session from app.services.admin_settings_service import admin_settings_service logger = logging.getLogger(__name__) # Allowed MIME types for attachments ALLOWED_MIME_TYPES = { # Images "image/jpeg", "image/png", "image/gif", "image/webp", # Documents "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", # Archives "application/zip", # Text "text/plain", "text/csv", } IMAGE_MIME_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"} # Default max file size in MB DEFAULT_MAX_FILE_SIZE_MB = 10 class MessageAttachmentService: """Service for handling message attachments.""" def __init__(self, storage_base: str = "uploads/messages"): self.storage_base = storage_base def get_max_file_size_bytes(self, db: Session) -> int: """Get maximum file size from platform settings.""" max_mb = admin_settings_service.get_setting_value( db, "message_attachment_max_size_mb", default=DEFAULT_MAX_FILE_SIZE_MB, ) try: max_mb = int(max_mb) except (TypeError, ValueError): max_mb = DEFAULT_MAX_FILE_SIZE_MB return max_mb * 1024 * 1024 # Convert to bytes def validate_file_type(self, mime_type: str) -> bool: """Check if file type is allowed.""" return mime_type in ALLOWED_MIME_TYPES def is_image(self, mime_type: str) -> bool: """Check if file is an image.""" return mime_type in IMAGE_MIME_TYPES async def validate_and_store( self, db: Session, file: UploadFile, conversation_id: int, ) -> dict: """ Validate and store an uploaded file. Returns dict with file metadata for MessageAttachment creation. Raises: ValueError: If file type or size is invalid """ # Validate MIME type content_type = file.content_type or "application/octet-stream" if not self.validate_file_type(content_type): raise ValueError( f"File type '{content_type}' not allowed. " "Allowed types: images (JPEG, PNG, GIF, WebP), " "PDF, Office documents, ZIP, text files." ) # Read file content content = await file.read() file_size = len(content) # Validate file size max_size = self.get_max_file_size_bytes(db) if file_size > max_size: raise ValueError( f"File size {file_size / 1024 / 1024:.1f}MB exceeds " f"maximum allowed size of {max_size / 1024 / 1024:.1f}MB" ) # Generate unique filename original_filename = file.filename or "attachment" ext = Path(original_filename).suffix.lower() unique_filename = f"{uuid.uuid4().hex}{ext}" # Create storage path: uploads/messages/YYYY/MM/conversation_id/filename now = datetime.utcnow() relative_path = os.path.join( self.storage_base, str(now.year), f"{now.month:02d}", str(conversation_id), ) # Ensure directory exists os.makedirs(relative_path, exist_ok=True) # Full file path file_path = os.path.join(relative_path, unique_filename) # Write file with open(file_path, "wb") as f: f.write(content) # Prepare metadata is_image = self.is_image(content_type) metadata = { "filename": unique_filename, "original_filename": original_filename, "file_path": file_path, "file_size": file_size, "mime_type": content_type, "is_image": is_image, } # Generate thumbnail for images if is_image: thumbnail_data = self._create_thumbnail(content, file_path) metadata.update(thumbnail_data) logger.info( f"Stored attachment {unique_filename} for conversation {conversation_id} " f"({file_size} bytes, type: {content_type})" ) return metadata def _create_thumbnail(self, content: bytes, original_path: str) -> dict: """Create thumbnail for image attachments.""" try: from PIL import Image import io img = Image.open(io.BytesIO(content)) width, height = img.size # Create thumbnail img.thumbnail((200, 200)) thumb_path = original_path.replace(".", "_thumb.") img.save(thumb_path) return { "image_width": width, "image_height": height, "thumbnail_path": thumb_path, } except ImportError: logger.warning("PIL not installed, skipping thumbnail generation") return {} except Exception as e: logger.error(f"Failed to create thumbnail: {e}") return {} def delete_attachment( self, file_path: str, thumbnail_path: str | None = None ) -> bool: """Delete attachment files from storage.""" try: if os.path.exists(file_path): os.remove(file_path) logger.info(f"Deleted attachment file: {file_path}") if thumbnail_path and os.path.exists(thumbnail_path): os.remove(thumbnail_path) logger.info(f"Deleted thumbnail: {thumbnail_path}") return True except Exception as e: logger.error(f"Failed to delete attachment {file_path}: {e}") return False def get_download_url(self, file_path: str) -> str: """ Get download URL for an attachment. For local storage, returns a relative path that can be served by the static file handler or a dedicated download endpoint. """ # Convert local path to URL path # Assumes files are served from /static/uploads or similar return f"/static/{file_path}" def get_file_content(self, file_path: str) -> bytes | None: """Read file content from storage.""" try: if os.path.exists(file_path): with open(file_path, "rb") as f: return f.read() return None except Exception as e: logger.error(f"Failed to read file {file_path}: {e}") return None # Singleton instance message_attachment_service = MessageAttachmentService()