package services import ( "fmt" "io" "mime/multipart" "os" "path/filepath" "strings" "time" "github.com/google/uuid" "github.com/rs/zerolog/log" "github.com/treytartt/mycrib-api/internal/config" ) // StorageService handles file uploads to local filesystem type StorageService struct { cfg *config.StorageConfig } // UploadResult contains information about an uploaded file type UploadResult struct { URL string `json:"url"` FileName string `json:"file_name"` FileSize int64 `json:"file_size"` MimeType string `json:"mime_type"` } // NewStorageService creates a new storage service func NewStorageService(cfg *config.StorageConfig) (*StorageService, error) { // Ensure upload directory exists if err := os.MkdirAll(cfg.UploadDir, 0755); err != nil { return nil, fmt.Errorf("failed to create upload directory: %w", err) } // Create subdirectories for organization subdirs := []string{"images", "documents", "completions"} for _, subdir := range subdirs { path := filepath.Join(cfg.UploadDir, subdir) if err := os.MkdirAll(path, 0755); err != nil { return nil, fmt.Errorf("failed to create subdirectory %s: %w", subdir, err) } } log.Info().Str("upload_dir", cfg.UploadDir).Msg("Storage service initialized") return &StorageService{cfg: cfg}, nil } // Upload saves a file to the local filesystem func (s *StorageService) Upload(file *multipart.FileHeader, category string) (*UploadResult, error) { // Validate file size if file.Size > s.cfg.MaxFileSize { return nil, fmt.Errorf("file size %d exceeds maximum allowed %d bytes", file.Size, s.cfg.MaxFileSize) } // Get MIME type mimeType := file.Header.Get("Content-Type") if mimeType == "" { mimeType = "application/octet-stream" } // Validate MIME type if !s.isAllowedType(mimeType) { return nil, fmt.Errorf("file type %s is not allowed", mimeType) } // Generate unique filename ext := filepath.Ext(file.Filename) if ext == "" { ext = s.getExtensionFromMimeType(mimeType) } newFilename := fmt.Sprintf("%s_%s%s", time.Now().Format("20060102"), uuid.New().String()[:8], ext) // Determine subdirectory based on category subdir := "images" switch category { case "document", "documents": subdir = "documents" case "completion", "completions": subdir = "completions" } // Full path destPath := filepath.Join(s.cfg.UploadDir, subdir, newFilename) // Open source file src, err := file.Open() if err != nil { return nil, fmt.Errorf("failed to open uploaded file: %w", err) } defer src.Close() // Create destination file dst, err := os.Create(destPath) if err != nil { return nil, fmt.Errorf("failed to create destination file: %w", err) } defer dst.Close() // Copy file content written, err := io.Copy(dst, src) if err != nil { // Clean up on error os.Remove(destPath) return nil, fmt.Errorf("failed to save file: %w", err) } // Generate URL url := fmt.Sprintf("%s/%s/%s", s.cfg.BaseURL, subdir, newFilename) log.Info(). Str("filename", newFilename). Str("category", category). Int64("size", written). Str("mime_type", mimeType). Msg("File uploaded successfully") return &UploadResult{ URL: url, FileName: file.Filename, FileSize: written, MimeType: mimeType, }, nil } // Delete removes a file from storage func (s *StorageService) Delete(fileURL string) error { // Convert URL to file path relativePath := strings.TrimPrefix(fileURL, s.cfg.BaseURL) relativePath = strings.TrimPrefix(relativePath, "/") fullPath := filepath.Join(s.cfg.UploadDir, relativePath) // Security check: ensure path is within upload directory absUploadDir, _ := filepath.Abs(s.cfg.UploadDir) absFilePath, _ := filepath.Abs(fullPath) if !strings.HasPrefix(absFilePath, absUploadDir) { return fmt.Errorf("invalid file path") } if err := os.Remove(fullPath); err != nil { if os.IsNotExist(err) { return nil // File already doesn't exist } return fmt.Errorf("failed to delete file: %w", err) } log.Info().Str("path", fullPath).Msg("File deleted") return nil } // isAllowedType checks if the MIME type is in the allowed list func (s *StorageService) isAllowedType(mimeType string) bool { allowed := strings.Split(s.cfg.AllowedTypes, ",") for _, t := range allowed { if strings.TrimSpace(t) == mimeType { return true } } return false } // getExtensionFromMimeType returns a file extension for common MIME types func (s *StorageService) getExtensionFromMimeType(mimeType string) string { extensions := map[string]string{ "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", "application/pdf": ".pdf", } if ext, ok := extensions[mimeType]; ok { return ext } return "" } // GetUploadDir returns the upload directory path func (s *StorageService) GetUploadDir() string { return s.cfg.UploadDir }