package service import ( "context" "errors" "fmt" "io" "mime/multipart" "os" "path/filepath" "strings" "time" "github.com/google/uuid" "github.com/gochat/gochat/internal/config" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" ) // UploadService handles file uploads for both account-level and widget direct uploads. type UploadService struct { directUploadRepo *repository.DirectUploadRepo cfg *config.Config } // NewUploadService creates a new UploadService. func NewUploadService(directUploadRepo *repository.DirectUploadRepo, cfg *config.Config) *UploadService { return &UploadService{ directUploadRepo: directUploadRepo, cfg: cfg, } } // --- DTOs --- // AccountUploadRequest is the DTO for account-level file upload. type AccountUploadRequest struct { FileHeader *multipart.FileHeader `json:"-"` } // WidgetDirectUploadRequest is the DTO for widget direct file upload. type WidgetDirectUploadRequest struct { FileHeader *multipart.FileHeader `json:"-"` } // AccountDirectUploadRequest is the DTO for account-level direct file upload (staged for message attachment). // Reference: Chatwoot POST /api/v1/accounts/:account_id/direct_uploads type AccountDirectUploadRequest struct { FileHeader *multipart.FileHeader `json:"-"` } // UploadResponse is the unified response DTO for upload endpoints. type UploadResponse struct { UploadID uint `json:"upload_id"` UploadUUID string `json:"upload_uuid"` OriginalName string `json:"original_name"` FileType string `json:"file_type"` MimeType string `json:"mime_type"` FileSize int64 `json:"file_size"` FileURL string `json:"file_url"` ThumbURL string `json:"thumb_url,omitempty"` Status string `json:"status"` ExpiresAt time.Time `json:"expires_at"` } // --- Account Upload --- // AccountUpload handles a file upload from the dashboard (account-scoped). // Reference: Chatwoot api/v1/accounts/:account_id/upload func (s *UploadService) AccountUpload(ctx context.Context, accountID uint, req AccountUploadRequest) (*UploadResponse, error) { if accountID == 0 { return nil, errors.New("account_id is required") } if req.FileHeader == nil { return nil, errors.New("file is required") } return s.processUpload(ctx, accountID, req.FileHeader, model.DirectUploadSourceAccount) } // --- Account Direct Upload (staged for message attachment) --- // AccountDirectUpload handles a staged file upload from the dashboard (account-scoped). // Returns a blob/UUID that can be attached to a message later. // Reference: Chatwoot POST /api/v1/accounts/:account_id/direct_uploads func (s *UploadService) AccountDirectUpload(ctx context.Context, accountID uint, req AccountDirectUploadRequest) (*UploadResponse, error) { if accountID == 0 { return nil, errors.New("account_id is required") } if req.FileHeader == nil { return nil, errors.New("file is required") } return s.processUpload(ctx, accountID, req.FileHeader, model.DirectUploadSourceAccount) } // --- Widget Direct Upload --- // WidgetDirectUpload handles a file upload from the widget (visitor direct upload). // Reference: Chatwoot POST /widget/direct_uploads func (s *UploadService) WidgetDirectUpload(ctx context.Context, req WidgetDirectUploadRequest) (*UploadResponse, error) { if req.FileHeader == nil { return nil, errors.New("file is required") } // Widget direct uploads are associated with account 0 initially; // they get linked to a real account when attached to a conversation. return s.processUpload(ctx, 0, req.FileHeader, model.DirectUploadSourceWidget) } // --- Internal helpers --- func (s *UploadService) processUpload(ctx context.Context, accountID uint, fileHeader *multipart.FileHeader, source model.DirectUploadSource) (*UploadResponse, error) { // Step 1: Validate file mimeType := fileHeader.Header.Get("Content-Type") if mimeType == "" || mimeType == "application/octet-stream" { // Fall back to filename-based detection when Content-Type is empty // or the generic default (browsers/multipart forms often send this). detected := detectUploadMIMEFromFilename(fileHeader.Filename) if detected != "" && detected != "application/octet-stream" { mimeType = detected } } fileCategory := categorizeUploadMIME(mimeType) if fileCategory == "" { return nil, fmt.Errorf("unsupported file type: %s", mimeType) } if !isUploadMIMEAllowed(fileCategory, mimeType) { return nil, fmt.Errorf("MIME type %s is not allowed for category %s", mimeType, fileCategory) } maxSize := model.WidgetUploadMaxSizeByType[fileCategory] if maxSize == 0 { maxSize = int64(s.cfg.Storage.MaxFileSize) } if fileHeader.Size > maxSize { return nil, fmt.Errorf("file size %d exceeds maximum %d for type %s", fileHeader.Size, maxSize, fileCategory) } // Step 2: Store file to disk ext := filepath.Ext(fileHeader.Filename) fileURL, thumbURL, err := s.saveFileToDisk(accountID, source, ext, fileHeader) if err != nil { return nil, fmt.Errorf("failed to save file: %w", err) } // Step 3: Create upload record with UUID expiryDuration := 24 * time.Hour uploadUUID := uuid.New().String() upload := &model.DirectUpload{ UploadUUID: uploadUUID, AccountID: accountID, Status: model.DirectUploadStatusPending, Source: source, OriginalName: fileHeader.Filename, FileType: fileCategory, MimeType: mimeType, FileSize: fileHeader.Size, FileURL: fileURL, ThumbURL: thumbURL, ExpiresAt: time.Now().Add(expiryDuration), } if err := s.directUploadRepo.Create(ctx, upload); err != nil { // Clean up file on disk if DB insert fails os.Remove(filepath.Join(s.cfg.Storage.LocalPath, fileURL)) return nil, fmt.Errorf("failed to create upload record: %w", err) } applogger.L().Infof("Direct file upload: account=%d source=%s uuid=%s file=%s size=%d", accountID, source, uploadUUID, fileHeader.Filename, fileHeader.Size) return &UploadResponse{ UploadID: upload.ID, UploadUUID: uploadUUID, OriginalName: upload.OriginalName, FileType: upload.FileType, MimeType: upload.MimeType, FileSize: upload.FileSize, FileURL: upload.FileURL, ThumbURL: upload.ThumbURL, Status: string(upload.Status), ExpiresAt: upload.ExpiresAt, }, nil } func (s *UploadService) saveFileToDisk(accountID uint, source model.DirectUploadSource, ext string, fileHeader *multipart.FileHeader) (string, string, error) { localPath := s.cfg.Storage.LocalPath if localPath == "" { localPath = "./uploads" } // Determine subdirectory based on source subDir := "account" if source == model.DirectUploadSourceWidget { subDir = "widget_direct" } // Build directory path: uploads/// dirPath := filepath.Join(localPath, subDir) if accountID > 0 { dirPath = filepath.Join(dirPath, fmt.Sprintf("%d", accountID)) } // Create directory if it doesn't exist if err := os.MkdirAll(dirPath, 0755); err != nil { return "", "", fmt.Errorf("failed to create upload directory: %w", err) } // Generate unique filename timestamp := time.Now().UnixMilli() baseName := fmt.Sprintf("%d_%s", timestamp, uuid.New().String()[:8]) fileName := baseName + ext fullPath := filepath.Join(dirPath, fileName) // Open uploaded file src, err := fileHeader.Open() if err != nil { return "", "", fmt.Errorf("failed to open uploaded file: %w", err) } defer src.Close() // Create destination file dst, err := os.Create(fullPath) if err != nil { return "", "", fmt.Errorf("failed to create destination file: %w", err) } defer dst.Close() // Copy file content if _, err := io.Copy(dst, src); err != nil { os.Remove(fullPath) // Clean up on failure return "", "", fmt.Errorf("failed to copy file content: %w", err) } // Build relative URL path fileURL := fmt.Sprintf("/uploads/%s/%s/%s", subDir, fmt.Sprintf("%d", accountID), fileName) thumbURL := "" // For images, we reference the same path (thumbnail generation can be added later) mimeType := fileHeader.Header.Get("Content-Type") if strings.HasPrefix(mimeType, "image/") { thumbURL = fileURL // Placeholder: same as fileURL for now } return fileURL, thumbURL, nil } // CleanupExpiredUploads removes expired direct upload records and their files. func (s *UploadService) CleanupExpiredUploads(ctx context.Context) (int64, error) { count, err := s.directUploadRepo.BatchDeleteExpired(ctx, time.Now()) if err != nil { return 0, fmt.Errorf("failed to cleanup expired uploads: %w", err) } applogger.L().Infof("Cleaned up %d expired direct uploads", count) return count, nil } // --- MIME detection helpers (reuse patterns from widget_theme_service.go) --- func detectUploadMIMEFromFilename(filename string) string { ext := strings.ToLower(filepath.Ext(filename)) switch ext { case ".png": return "image/png" case ".jpg", ".jpeg": return "image/jpeg" case ".gif": return "image/gif" case ".webp": return "image/webp" case ".svg": return "image/svg+xml" case ".mp3": return "audio/mpeg" case ".ogg": return "audio/ogg" case ".wav": return "audio/wav" case ".webm": return "audio/webm" case ".mp4": return "video/mp4" case ".pdf": return "application/pdf" case ".csv": return "text/csv" case ".txt": return "text/plain" case ".xls": return "application/vnd.ms-excel" case ".xlsx": return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" case ".doc": return "application/msword" case ".docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" default: return "application/octet-stream" } } func categorizeUploadMIME(mimeType string) string { if strings.HasPrefix(mimeType, "image/") { return "image" } if strings.HasPrefix(mimeType, "audio/") { return "audio" } if strings.HasPrefix(mimeType, "video/") { return "video" } // Check specific file MIME types switch mimeType { case "application/pdf", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "text/plain", "text/csv": return "file" } return "" // unsupported } func isUploadMIMEAllowed(category string, mimeType string) bool { allowed, ok := model.WidgetUploadAllowedTypes[category] if !ok { return false } for _, a := range allowed { if a == mimeType { return true } } // For "file" category, also allow application/octet-stream (unknown file types with correct extension) if category == "file" && mimeType == "application/octet-stream" { return true } return false }