Files
gochat/internal/service/upload_service.go
T

713 lines
24 KiB
Go

package service
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"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
conversationRepo *repository.ConversationRepo
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
}
// WithConversationRepo wires the account-scoped conversation lookup needed by
// Chatwoot's nested conversation direct upload endpoint.
func (s *UploadService) WithConversationRepo(conversationRepo *repository.ConversationRepo) *UploadService {
s.conversationRepo = conversationRepo
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)
}
func (s *UploadService) AccountUploadFromURL(ctx context.Context, accountID uint, externalURL string) (*UploadResponse, error) {
if accountID == 0 {
return nil, errors.New("account_id is required")
}
parsed, err := url.ParseRequestURI(externalURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return nil, errors.New("invalid url")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, externalURL, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch external url: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("failed to fetch external url: status %d", resp.StatusCode)
}
maxSize := int64(s.cfg.Storage.MaxFileSize)
if maxSize <= 0 {
maxSize = 50 << 20
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxSize {
return nil, errors.New("file too large")
}
filename := filepath.Base(parsed.Path)
if filename == "." || filename == "/" || filename == "" {
filename = "upload"
}
contentType := resp.Header.Get("Content-Type")
if idx := strings.Index(contentType, ";"); idx >= 0 {
contentType = strings.TrimSpace(contentType[:idx])
}
return s.processUploadContent(ctx, accountID, model.DirectUploadSourceAccount, filename, contentType, int64(len(data)), bytes.NewReader(data))
}
// --- 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
}
return s.createActiveStorageDirectUpload(ctx, accountID, 0, model.DirectUploadSourceWidget, req, "/api/v1/widget/direct_uploads/")
}
func (s *UploadService) CreateConversationDirectUpload(ctx context.Context, accountID, conversationID uint, req ActiveStorageDirectUploadRequest) (*ActiveStorageDirectUploadResponse, error) {
if accountID == 0 {
return nil, errors.New("account_id is required")
}
if conversationID == 0 {
return nil, errors.New("conversation_id is required")
}
if s.conversationRepo == nil {
return nil, errors.New("conversation repository is not configured")
}
if _, err := s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, conversationID); err != nil {
return nil, fmt.Errorf("conversation not found: %w", err)
}
urlPrefix := fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/direct_uploads/", accountID, conversationID)
return s.createActiveStorageDirectUpload(ctx, accountID, accountID, model.DirectUploadSourceAccount, req, urlPrefix)
}
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
}
func (s *UploadService) CompleteConversationDirectUpload(ctx context.Context, accountID, conversationID uint, uploadUUID string, body io.Reader) (*UploadResponse, error) {
if accountID == 0 {
return nil, errors.New("account_id is required")
}
if conversationID == 0 {
return nil, errors.New("conversation_id is required")
}
if s.conversationRepo == nil {
return nil, errors.New("conversation repository is not configured")
}
if _, err := s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, conversationID); err != nil {
return nil, fmt.Errorf("conversation not found: %w", err)
}
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.DirectUploadSourceAccount || upload.AccountID != accountID {
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) createActiveStorageDirectUpload(ctx context.Context, accountID, storageAccountID uint, source model.DirectUploadSource, req ActiveStorageDirectUploadRequest, directUploadURLPrefix string) (*ActiveStorageDirectUploadResponse, error) {
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(source, storageAccountID, 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: source,
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: directUploadURLPrefix + upload.UploadUUID,
Headers: map[string]string{
"Content-Type": upload.MimeType,
},
},
}, nil
}
func (s *UploadService) processUpload(ctx context.Context, accountID uint, fileHeader *multipart.FileHeader, source model.DirectUploadSource) (*UploadResponse, error) {
mimeType := fileHeader.Header.Get("Content-Type")
src, err := fileHeader.Open()
if err != nil {
return nil, fmt.Errorf("failed to open uploaded file: %w", err)
}
defer src.Close()
return s.processUploadContent(ctx, accountID, source, fileHeader.Filename, mimeType, fileHeader.Size, src)
}
func (s *UploadService) processUploadContent(ctx context.Context, accountID uint, source model.DirectUploadSource, filename, mimeType string, size int64, reader io.Reader) (*UploadResponse, error) {
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(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 size > maxSize {
return nil, fmt.Errorf("file size %d exceeds maximum %d for type %s", size, maxSize, fileCategory)
}
// Step 2: Store file to disk
ext := filepath.Ext(filename)
fileURL, thumbURL, err := s.saveUploadReader(accountID, source, ext, mimeType, reader)
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: filename,
FileType: fileCategory,
MimeType: mimeType,
FileSize: 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, filename, 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) {
src, err := fileHeader.Open()
if err != nil {
return "", "", fmt.Errorf("failed to open uploaded file: %w", err)
}
defer src.Close()
return s.saveUploadReader(accountID, source, ext, fileHeader.Header.Get("Content-Type"), src)
}
func (s *UploadService) saveUploadReader(accountID uint, source model.DirectUploadSource, ext, mimeType string, reader io.Reader) (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)
// 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, reader); 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)
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
}