package services import ( "context" "crypto/rand" "encoding/hex" "fmt" "path/filepath" "strings" "time" "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", "audio/aiff", "audio/x-aiff", "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) { quota := models.StorageQuota{ UserID: userID, MaxBytes: 5 * 1024 * 1024 * 1024, // 5GB default UsedBytes: 0, } if err := s.db.WithContext(ctx).Where("user_id = ?", userID).FirstOrCreate("a).Error; err != nil { return nil, fmt.Errorf("failed to get or create quota: %w", err) } return "a, 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("a).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 } const maxVersionsPerFile = 10 // CreateVersion saves the current file state as a new version (copies S3 object to preserve it) func (s *CloudService) CreateVersion(ctx context.Context, userID uuid.UUID, fileID uuid.UUID) (*models.CloudFileVersion, error) { file, err := s.GetFileByID(ctx, userID, fileID) if err != nil { return nil, err } var count int64 var nextVersion int if err := s.db.WithContext(ctx).Model(&models.CloudFileVersion{}).Where("file_id = ?", fileID).Count(&count).Error; err != nil { return nil, fmt.Errorf("failed to count versions: %w", err) } if count >= maxVersionsPerFile { var oldest models.CloudFileVersion if err := s.db.WithContext(ctx).Where("file_id = ?", fileID).Order("version ASC").First(&oldest).Error; err == nil { if s.s3Service != nil { _ = s.s3Service.DeleteFile(ctx, oldest.StorageKey) } _ = s.db.WithContext(ctx).Delete(&oldest).Error } } if err := s.db.WithContext(ctx).Model(&models.CloudFileVersion{}).Where("file_id = ?", fileID).Select("COALESCE(MAX(version), 0)").Scan(&nextVersion).Error; err != nil { return nil, fmt.Errorf("failed to get max version: %w", err) } nextVersion++ versionKey := fmt.Sprintf("cloud/%s/versions/%s/v%d", userID, fileID, nextVersion) if s.s3Service != nil { data, err := s.s3Service.DownloadFile(ctx, file.S3Key) if err != nil { return nil, fmt.Errorf("failed to copy file for version: %w", err) } if _, err := s.s3Service.UploadFile(ctx, data, versionKey, file.MimeType); err != nil { return nil, fmt.Errorf("failed to save version copy: %w", err) } } else { versionKey = file.S3Key } version := &models.CloudFileVersion{ FileID: fileID, Version: nextVersion, StorageKey: versionKey, SizeBytes: file.SizeBytes, } if err := s.db.WithContext(ctx).Create(version).Error; err != nil { if s.s3Service != nil { _ = s.s3Service.DeleteFile(ctx, versionKey) } return nil, fmt.Errorf("failed to create version: %w", err) } return version, nil } // ListVersions returns versions for a file, sorted by version DESC func (s *CloudService) ListVersions(ctx context.Context, userID uuid.UUID, fileID uuid.UUID) ([]models.CloudFileVersion, error) { if _, err := s.GetFileByID(ctx, userID, fileID); err != nil { return nil, err } var versions []models.CloudFileVersion if err := s.db.WithContext(ctx).Where("file_id = ?", fileID).Order("version DESC").Limit(maxVersionsPerFile).Find(&versions).Error; err != nil { return nil, fmt.Errorf("failed to list versions: %w", err) } return versions, nil } // RestoreVersion swaps the file's s3_key with the selected version func (s *CloudService) RestoreVersion(ctx context.Context, userID uuid.UUID, fileID uuid.UUID, version int) error { if _, err := s.GetFileByID(ctx, userID, fileID); err != nil { return err } var v models.CloudFileVersion if err := s.db.WithContext(ctx).Where("file_id = ? AND version = ?", fileID, version).First(&v).Error; err != nil { return fmt.Errorf("version not found: %w", err) } if err := s.db.WithContext(ctx).Model(&models.UserFile{}).Where("id = ? AND user_id = ?", fileID, userID). Updates(map[string]interface{}{ "s3_key": v.StorageKey, "size_bytes": v.SizeBytes, }).Error; err != nil { return fmt.Errorf("failed to restore: %w", err) } return nil } // ShareFile creates a share link with token func (s *CloudService) ShareFile(ctx context.Context, userID uuid.UUID, fileID uuid.UUID, permissions string, expiresInHours int) (*models.CloudFileShare, error) { if _, err := s.GetFileByID(ctx, userID, fileID); err != nil { return nil, err } tokenBytes := make([]byte, 32) if _, err := rand.Read(tokenBytes); err != nil { return nil, fmt.Errorf("failed to generate token: %w", err) } token := hex.EncodeToString(tokenBytes) expiresAt := time.Now().Add(time.Duration(expiresInHours) * time.Hour) share := &models.CloudFileShare{ FileID: fileID, Token: token, Permissions: permissions, ExpiresAt: expiresAt, } if err := s.db.WithContext(ctx).Create(share).Error; err != nil { return nil, fmt.Errorf("failed to create share: %w", err) } return share, nil } // GetSharedFile returns file metadata if token is valid and not expired func (s *CloudService) GetSharedFile(ctx context.Context, token string) (*models.UserFile, error) { var share models.CloudFileShare if err := s.db.WithContext(ctx).Where("token = ? AND expires_at > ?", token, time.Now()).First(&share).Error; err != nil { return nil, ErrShareExpired } var file models.UserFile if err := s.db.WithContext(ctx).Where("id = ?", share.FileID).First(&file).Error; err != nil { return nil, ErrFileNotFound } return &file, nil }