veza/veza-backend-api/internal/services/cloud_service.go

385 lines
11 KiB
Go
Raw Normal View History

package services
import (
"context"
"fmt"
"path/filepath"
"strings"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
"veza-backend-api/internal/models"
)
var (
ErrFolderNotEmpty = fmt.Errorf("folder is not empty")
)
const MaxCloudFileSize = 500 * 1024 * 1024 // 500MB
var AllowedMimeTypes = []string{
"audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav",
"audio/flac", "audio/x-flac", "audio/ogg", "audio/aac",
"audio/mp4", "audio/x-m4a", "audio/midi", "audio/x-midi",
"application/zip", "application/x-zip-compressed",
"application/octet-stream",
}
type CloudService struct {
db *gorm.DB
logger *zap.Logger
s3Service *S3StorageService
}
func NewCloudService(db *gorm.DB, logger *zap.Logger, s3Service *S3StorageService) *CloudService {
return &CloudService{
db: db,
logger: logger,
s3Service: s3Service,
}
}
func (s *CloudService) ListFolders(ctx context.Context, userID uuid.UUID, parentID *uuid.UUID) ([]models.UserFolder, error) {
var folders []models.UserFolder
query := s.db.WithContext(ctx).Where("user_id = ?", userID)
if parentID != nil {
query = query.Where("parent_id = ?", *parentID)
} else {
query = query.Where("parent_id IS NULL")
}
if err := query.Order("name ASC").Find(&folders).Error; err != nil {
return nil, fmt.Errorf("failed to list folders: %w", err)
}
return folders, nil
}
func (s *CloudService) CreateFolder(ctx context.Context, userID uuid.UUID, name string, parentID *uuid.UUID) (*models.UserFolder, error) {
if parentID != nil {
if err := s.verifyFolderOwnership(ctx, userID, *parentID); err != nil {
return nil, err
}
}
folder := &models.UserFolder{
ID: uuid.New(),
UserID: userID,
Name: name,
ParentID: parentID,
}
if err := s.db.WithContext(ctx).Create(folder).Error; err != nil {
return nil, fmt.Errorf("failed to create folder: %w", err)
}
return folder, nil
}
func (s *CloudService) RenameFolder(ctx context.Context, userID uuid.UUID, folderID uuid.UUID, newName string) error {
if err := s.verifyFolderOwnership(ctx, userID, folderID); err != nil {
return err
}
result := s.db.WithContext(ctx).Model(&models.UserFolder{}).
Where("id = ? AND user_id = ?", folderID, userID).
Update("name", newName)
if result.Error != nil {
return fmt.Errorf("failed to rename folder: %w", result.Error)
}
return nil
}
func (s *CloudService) DeleteFolder(ctx context.Context, userID uuid.UUID, folderID uuid.UUID) error {
if err := s.verifyFolderOwnership(ctx, userID, folderID); err != nil {
return err
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var files []models.UserFile
if err := tx.Where("folder_id = ? AND user_id = ?", folderID, userID).Find(&files).Error; err != nil {
return err
}
var totalSize int64
for _, f := range files {
if s.s3Service != nil {
_ = s.s3Service.DeleteFile(ctx, f.S3Key)
}
totalSize += f.SizeBytes
}
if err := tx.Where("folder_id = ? AND user_id = ?", folderID, userID).Delete(&models.UserFile{}).Error; err != nil {
return err
}
var childFolders []models.UserFolder
if err := tx.Where("parent_id = ? AND user_id = ?", folderID, userID).Find(&childFolders).Error; err != nil {
return err
}
for _, child := range childFolders {
if err := s.deleteFolderRecursive(ctx, tx, userID, child.ID, &totalSize); err != nil {
return err
}
}
if err := tx.Where("id = ? AND user_id = ?", folderID, userID).Delete(&models.UserFolder{}).Error; err != nil {
return err
}
if totalSize > 0 {
if err := tx.Model(&models.StorageQuota{}).
Where("user_id = ?", userID).
Update("used_bytes", gorm.Expr("CASE WHEN used_bytes - ? > 0 THEN used_bytes - ? ELSE 0 END", totalSize, totalSize)).Error; err != nil {
return err
}
}
return nil
})
}
func (s *CloudService) deleteFolderRecursive(ctx context.Context, tx *gorm.DB, userID uuid.UUID, folderID uuid.UUID, totalSize *int64) error {
var files []models.UserFile
if err := tx.Where("folder_id = ? AND user_id = ?", folderID, userID).Find(&files).Error; err != nil {
return err
}
for _, f := range files {
if s.s3Service != nil {
_ = s.s3Service.DeleteFile(ctx, f.S3Key)
}
*totalSize += f.SizeBytes
}
if err := tx.Where("folder_id = ? AND user_id = ?", folderID, userID).Delete(&models.UserFile{}).Error; err != nil {
return err
}
var children []models.UserFolder
if err := tx.Where("parent_id = ? AND user_id = ?", folderID, userID).Find(&children).Error; err != nil {
return err
}
for _, child := range children {
if err := s.deleteFolderRecursive(ctx, tx, userID, child.ID, totalSize); err != nil {
return err
}
}
return tx.Where("id = ? AND user_id = ?", folderID, userID).Delete(&models.UserFolder{}).Error
}
func (s *CloudService) ListFiles(ctx context.Context, userID uuid.UUID, folderID *uuid.UUID) ([]models.UserFile, error) {
var files []models.UserFile
query := s.db.WithContext(ctx).Where("user_id = ?", userID)
if folderID != nil {
query = query.Where("folder_id = ?", *folderID)
} else {
query = query.Where("folder_id IS NULL")
}
if err := query.Order("created_at DESC").Find(&files).Error; err != nil {
return nil, fmt.Errorf("failed to list files: %w", err)
}
return files, nil
}
func (s *CloudService) UploadFile(ctx context.Context, userID uuid.UUID, folderID *uuid.UUID, filename string, data []byte, mimeType string) (*models.UserFile, error) {
if !s.isAllowedMimeType(mimeType) {
return nil, ErrInvalidMimeType
}
fileSize := int64(len(data))
if fileSize > MaxCloudFileSize {
return nil, ErrFileTooLarge
}
if folderID != nil {
if err := s.verifyFolderOwnership(ctx, userID, *folderID); err != nil {
return nil, err
}
}
if err := s.checkQuota(ctx, userID, fileSize); err != nil {
return nil, err
}
fileID := uuid.New()
s3Key := fmt.Sprintf("cloud/%s/%s/%s%s", userID, fileID, sanitizeFilename(filename), filepath.Ext(filename))
var file *models.UserFile
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&models.StorageQuota{}).
Where("user_id = ? AND used_bytes + ? <= max_bytes", userID, fileSize).
Update("used_bytes", gorm.Expr("used_bytes + ?", fileSize))
if result.RowsAffected == 0 {
return ErrQuotaExceeded
}
if result.Error != nil {
return result.Error
}
if s.s3Service != nil {
if _, err := s.s3Service.UploadFile(ctx, data, s3Key, mimeType); err != nil {
tx.Model(&models.StorageQuota{}).
Where("user_id = ?", userID).
Update("used_bytes", gorm.Expr("CASE WHEN used_bytes - ? > 0 THEN used_bytes - ? ELSE 0 END", fileSize, fileSize))
return fmt.Errorf("failed to upload to S3: %w", err)
}
}
file = &models.UserFile{
ID: fileID,
UserID: userID,
FolderID: folderID,
Filename: filename,
S3Key: s3Key,
SizeBytes: fileSize,
MimeType: mimeType,
}
if err := tx.Create(file).Error; err != nil {
if s.s3Service != nil {
_ = s.s3Service.DeleteFile(ctx, s3Key)
}
return fmt.Errorf("failed to create file record: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return file, nil
}
func (s *CloudService) DeleteFile(ctx context.Context, userID uuid.UUID, fileID uuid.UUID) error {
var file models.UserFile
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", fileID, userID).First(&file).Error; err != nil {
return ErrFileNotFound
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("id = ? AND user_id = ?", fileID, userID).Delete(&models.UserFile{}).Error; err != nil {
return err
}
if err := tx.Model(&models.StorageQuota{}).
Where("user_id = ?", userID).
Update("used_bytes", gorm.Expr("CASE WHEN used_bytes - ? > 0 THEN used_bytes - ? ELSE 0 END", file.SizeBytes, file.SizeBytes)).Error; err != nil {
return err
}
if s.s3Service != nil {
_ = s.s3Service.DeleteFile(ctx, file.S3Key)
}
return nil
})
}
func (s *CloudService) GetFileByID(ctx context.Context, userID uuid.UUID, fileID uuid.UUID) (*models.UserFile, error) {
var file models.UserFile
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", fileID, userID).First(&file).Error; err != nil {
return nil, ErrFileNotFound
}
return &file, nil
}
func (s *CloudService) GetQuota(ctx context.Context, userID uuid.UUID) (*models.StorageQuota, error) {
var quota models.StorageQuota
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).First(&quota).Error; err != nil {
return nil, fmt.Errorf("quota not found: %w", err)
}
return &quota, nil
}
func (s *CloudService) UpdateQuotaUsed(ctx context.Context, userID uuid.UUID, delta int64) error {
result := s.db.WithContext(ctx).Model(&models.StorageQuota{}).
Where("user_id = ?", userID).
Update("used_bytes", gorm.Expr("CASE WHEN used_bytes + ? > 0 THEN used_bytes + ? ELSE 0 END", delta, delta))
if result.Error != nil {
return fmt.Errorf("failed to update quota: %w", result.Error)
}
return nil
}
func (s *CloudService) InitQuota(ctx context.Context, userID uuid.UUID) error {
quota := &models.StorageQuota{
UserID: userID,
MaxBytes: 5 * 1024 * 1024 * 1024, // 5GB
UsedBytes: 0,
}
result := s.db.WithContext(ctx).
Where("user_id = ?", userID).
FirstOrCreate(quota)
return result.Error
}
func (s *CloudService) StreamFile(ctx context.Context, userID uuid.UUID, fileID uuid.UUID) ([]byte, *models.UserFile, error) {
file, err := s.GetFileByID(ctx, userID, fileID)
if err != nil {
return nil, nil, err
}
if s.s3Service == nil {
return nil, nil, fmt.Errorf("S3 service not configured")
}
data, err := s.s3Service.DownloadFile(ctx, file.S3Key)
if err != nil {
return nil, nil, fmt.Errorf("failed to download file: %w", err)
}
return data, file, nil
}
func (s *CloudService) verifyFolderOwnership(ctx context.Context, userID uuid.UUID, folderID uuid.UUID) error {
var count int64
s.db.WithContext(ctx).Model(&models.UserFolder{}).
Where("id = ? AND user_id = ?", folderID, userID).
Count(&count)
if count == 0 {
return ErrFolderNotFound
}
return nil
}
func (s *CloudService) checkQuota(ctx context.Context, userID uuid.UUID, additionalBytes int64) error {
var quota models.StorageQuota
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).First(&quota).Error; err != nil {
return fmt.Errorf("quota not found: %w", err)
}
if quota.UsedBytes+additionalBytes > quota.MaxBytes {
return ErrQuotaExceeded
}
return nil
}
func (s *CloudService) isAllowedMimeType(mimeType string) bool {
if strings.HasPrefix(mimeType, "audio/") {
return true
}
for _, allowed := range AllowedMimeTypes {
if mimeType == allowed {
return true
}
}
return false
}
func sanitizeFilename(name string) string {
base := filepath.Base(name)
ext := filepath.Ext(base)
nameWithoutExt := strings.TrimSuffix(base, ext)
safe := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
return r
}
return '_'
}, nameWithoutExt)
if safe == "" {
safe = "file"
}
return safe
}