package handlers import ( "fmt" "io" "net/http" "path/filepath" apperrors "veza-backend-api/internal/errors" "veza-backend-api/internal/services" chatws "veza-backend-api/internal/websocket/chat" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" ) const chatAttachmentMaxSize = 50 * 1024 * 1024 // 50MB // ChatAttachmentHandler handles chat file uploads (v0.9.7) type ChatAttachmentHandler struct { s3Service *services.S3StorageService permissions *chatws.PermissionService validator UploadValidatorInterface logger *zap.Logger urlExpirySec int64 } // NewChatAttachmentHandler creates a new ChatAttachmentHandler func NewChatAttachmentHandler( s3Service *services.S3StorageService, permissions *chatws.PermissionService, validator UploadValidatorInterface, logger *zap.Logger, ) *ChatAttachmentHandler { if logger == nil { logger = zap.NewNop() } return &ChatAttachmentHandler{ s3Service: s3Service, permissions: permissions, validator: validator, logger: logger, urlExpirySec: 86400, // 24h } } // UploadChatAttachment handles POST /api/v1/chat/rooms/:roomId/attachments // Accepts: audio (mp3, wav, ogg), image (jpg, png), PDF. Max 50MB. func (h *ChatAttachmentHandler) UploadChatAttachment(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } roomID, err := uuid.Parse(c.Param("roomId")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid room ID")) return } if !h.permissions.CanRead(c.Request.Context(), userID, roomID) { RespondWithAppError(c, apperrors.NewForbiddenError("Not allowed to access this conversation")) return } fileHeader, err := c.FormFile("file") if err != nil { RespondWithAppError(c, apperrors.NewValidationError("No file provided")) return } if fileHeader.Size > chatAttachmentMaxSize { RespondWithAppError(c, apperrors.NewValidationError("File too large. Max 50MB")) return } if fileHeader.Size == 0 { RespondWithAppError(c, apperrors.NewValidationError("Empty file")) return } fileType := h.validator.GetFileTypeFromPath(fileHeader.Filename) if fileType == "unknown" { RespondWithAppError(c, apperrors.NewValidationError("Unsupported file type. Allowed: mp3, wav, ogg, jpg, png, pdf")) return } validationResult, err := h.validator.ValidateFile(c.Request.Context(), fileHeader, fileType) if err != nil { h.logger.Warn("Chat attachment validation failed", zap.Error(err), zap.String("filename", fileHeader.Filename)) RespondWithAppError(c, apperrors.NewValidationError("File validation failed: "+err.Error())) return } if !validationResult.Valid { RespondWithAppError(c, apperrors.NewValidationError(validationResult.Error)) return } if validationResult.Quarantined { RespondWithAppError(c, apperrors.NewForbiddenError("File rejected: virus detected")) return } file, err := fileHeader.Open() if err != nil { RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to read file", err)) return } defer file.Close() data, err := io.ReadAll(file) if err != nil { RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to read file", err)) return } ext := filepath.Ext(fileHeader.Filename) if ext == "" { ext = ".bin" } key := fmt.Sprintf("chat/%s/%s/%s%s", roomID, userID, uuid.New().String(), ext) _, err = h.s3Service.UploadFile(c.Request.Context(), data, key, fileHeader.Header.Get("Content-Type")) if err != nil { h.logger.Error("Failed to upload chat attachment", zap.Error(err), zap.String("key", key)) RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to upload file", err)) return } // Return presigned URL for download (24h) signedURL, err := h.s3Service.GetPresignedURL(c.Request.Context(), key) if err != nil { // Fallback to public URL if presigned fails (e.g. public bucket) signedURL = h.s3Service.GetPublicURL(key) } RespondSuccess(c, http.StatusCreated, gin.H{ "url": signedURL, "file_id": uuid.New().String(), "file_name": fileHeader.Filename, "file_size": fileHeader.Size, "file_type": fileType, }) }