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

517 lines
16 KiB
Go

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) {
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
}
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
}