package service import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "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 inboxRepo *repository.InboxRepo contactInboxRepo *repository.ContactInboxRepo cfg *config.Config } // NewUploadService creates a new UploadService. func NewUploadService(directUploadRepo *repository.DirectUploadRepo, cfg *config.Config) *UploadService { return &UploadService{ directUploadRepo: directUploadRepo, cfg: cfg, } } // WithWidgetAuth wires the widget session repositories used by Chatwoot's // website_token + X-Auth-Token direct upload guard. func (s *UploadService) WithWidgetAuth(inboxRepo *repository.InboxRepo, contactInboxRepo *repository.ContactInboxRepo) *UploadService { s.inboxRepo = inboxRepo s.contactInboxRepo = contactInboxRepo return s } // --- 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:"-"` } type ActiveStorageDirectUploadRequest struct { WebsiteToken string `json:"-"` AuthToken string `json:"-"` Blob ActiveStorageBlobParams `json:"blob"` } type ActiveStorageBlobParams struct { Filename string `json:"filename"` ByteSize int64 `json:"byte_size"` Checksum string `json:"checksum"` ContentType string `json:"content_type"` Metadata map[string]any `json:"metadata"` } type ActiveStorageDirectUploadResponse struct { ID uint `json:"id"` Key string `json:"key"` Filename string `json:"filename"` ContentType string `json:"content_type"` Metadata map[string]any `json:"metadata"` ServiceName string `json:"service_name"` ByteSize int64 `json:"byte_size"` Checksum string `json:"checksum"` CreatedAt time.Time `json:"created_at"` SignedID string `json:"signed_id"` DirectUpload ActiveStorageUploadURL `json:"direct_upload"` } type ActiveStorageUploadURL struct { URL string `json:"url"` Headers map[string]string `json:"headers"` } // 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) } func (s *UploadService) CreateWidgetDirectUpload(ctx context.Context, req ActiveStorageDirectUploadRequest) (*ActiveStorageDirectUploadResponse, error) { accountID, err := s.validateWidgetUploadSession(ctx, req.WebsiteToken, req.AuthToken) if err != nil { return nil, err } if req.Blob.Filename == "" { return nil, errors.New("filename is required") } if req.Blob.ByteSize <= 0 { return nil, errors.New("byte_size is required") } mimeType := req.Blob.ContentType if mimeType == "" || mimeType == "application/octet-stream" { mimeType = detectUploadMIMEFromFilename(req.Blob.Filename) } 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 req.Blob.ByteSize > maxSize { return nil, fmt.Errorf("file size %d exceeds maximum %d for type %s", req.Blob.ByteSize, maxSize, fileCategory) } uploadUUID := uuid.New().String() fileURL, thumbURL := s.directUploadURL(model.DirectUploadSourceWidget, 0, uploadUUID, req.Blob.Filename) metadata := req.Blob.Metadata if metadata == nil { metadata = map[string]any{} } metadata["checksum"] = req.Blob.Checksum metadata["active_storage_key"] = randomStorageKey() metadataJSON, _ := json.Marshal(metadata) upload := &model.DirectUpload{ UploadUUID: uploadUUID, AccountID: accountID, Status: model.DirectUploadStatusPending, Source: model.DirectUploadSourceWidget, OriginalName: req.Blob.Filename, FileType: fileCategory, MimeType: mimeType, FileSize: req.Blob.ByteSize, FileURL: fileURL, ThumbURL: thumbURL, Metadata: metadataJSON, ExpiresAt: time.Now().Add(24 * time.Hour), } if err := s.directUploadRepo.Create(ctx, upload); err != nil { return nil, fmt.Errorf("failed to create direct upload: %w", err) } return &ActiveStorageDirectUploadResponse{ ID: upload.ID, Key: fmt.Sprint(metadata["active_storage_key"]), Filename: upload.OriginalName, ContentType: upload.MimeType, Metadata: metadata, ServiceName: "gochat_local", ByteSize: upload.FileSize, Checksum: req.Blob.Checksum, CreatedAt: upload.CreatedAt, SignedID: upload.UploadUUID, DirectUpload: ActiveStorageUploadURL{ URL: "/api/v1/widget/direct_uploads/" + upload.UploadUUID, Headers: map[string]string{ "Content-Type": upload.MimeType, }, }, }, nil } func (s *UploadService) validateWidgetUploadSession(ctx context.Context, websiteToken, authToken string) (uint, error) { if websiteToken == "" { return 0, errors.New("website_token is required") } if authToken == "" { return 0, errors.New("widget auth token is required") } if s.inboxRepo == nil || s.contactInboxRepo == nil { return 0, nil } inbox, err := s.inboxRepo.FindByWebsiteToken(ctx, websiteToken) if err != nil { return 0, fmt.Errorf("invalid website_token: %w", err) } if !inbox.Enabled { return 0, errors.New("inbox is disabled") } contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, authToken) if err != nil { return 0, fmt.Errorf("invalid widget auth token: %w", err) } if contactInbox.InboxID != inbox.ID { return 0, errors.New("widget auth token does not belong to this inbox") } return inbox.AccountID, nil } func (s *UploadService) CompleteWidgetDirectUpload(ctx context.Context, uploadUUID string, body io.Reader) (*UploadResponse, error) { if uploadUUID == "" { return nil, errors.New("upload_uuid is required") } upload, err := s.directUploadRepo.FindByUUID(ctx, uploadUUID) if err != nil { return nil, fmt.Errorf("direct upload not found: %w", err) } if upload.Source != model.DirectUploadSourceWidget { return nil, errors.New("direct upload source mismatch") } if time.Now().After(upload.ExpiresAt) { upload.Status = model.DirectUploadStatusExpired _ = s.directUploadRepo.Update(ctx, upload) return nil, errors.New("direct upload has expired") } if err := s.saveReaderToDisk(upload.FileURL, body); err != nil { return nil, fmt.Errorf("failed to save direct upload: %w", err) } return &UploadResponse{ UploadID: upload.ID, UploadUUID: upload.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 } // --- 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" } dirPath := s.uploadDir(source, accountID) 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) } fileURL := s.uploadURL(source, 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 } func (s *UploadService) saveReaderToDisk(fileURL string, body io.Reader) error { localPath := s.cfg.Storage.LocalPath if localPath == "" { localPath = "./uploads" } relative := strings.TrimPrefix(fileURL, "/uploads/") fullPath := filepath.Join(localPath, relative) if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { return err } dst, err := os.Create(fullPath) if err != nil { return err } defer dst.Close() _, err = io.Copy(dst, body) return err } func (s *UploadService) directUploadURL(source model.DirectUploadSource, accountID uint, uploadUUID, filename string) (string, string) { ext := filepath.Ext(filename) fileName := uploadUUID + ext fileURL := s.uploadURL(source, accountID, fileName) thumbURL := "" if strings.HasPrefix(detectUploadMIMEFromFilename(filename), "image/") { thumbURL = fileURL } return fileURL, thumbURL } func (s *UploadService) uploadDir(source model.DirectUploadSource, accountID uint) string { localPath := s.cfg.Storage.LocalPath if localPath == "" { localPath = "./uploads" } subDir := uploadSubDir(source) parts := []string{localPath, subDir} if accountID > 0 { parts = append(parts, fmt.Sprintf("%d", accountID)) } return filepath.Join(parts...) } func (s *UploadService) uploadURL(source model.DirectUploadSource, accountID uint, fileName string) string { subDir := uploadSubDir(source) if accountID > 0 { return fmt.Sprintf("/uploads/%s/%d/%s", subDir, accountID, fileName) } return fmt.Sprintf("/uploads/%s/%s", subDir, fileName) } func uploadSubDir(source model.DirectUploadSource) string { if source == model.DirectUploadSourceWidget { return "widget_direct" } return "account" } func randomStorageKey() string { buf := make([]byte, 16) if _, err := rand.Read(buf); err != nil { return uuid.New().String() } return hex.EncodeToString(buf) } // 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 }