346 lines
10 KiB
Go
346 lines
10 KiB
Go
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/<sub_dir>/<account_id>/
|
|
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
|
|
} |