517 lines
16 KiB
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("a).Error; err != nil {
|
|
return nil, fmt.Errorf("quota not found: %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
|
|
}
|