Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
429
cloud/maplefile-backend/internal/service/file/softdelete.go
Normal file
429
cloud/maplefile-backend/internal/service/file/softdelete.go
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
// monorepo/cloud/backend/internal/maplefile/service/file/softdelete.go
|
||||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants"
|
||||
uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user"
|
||||
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
|
||||
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
|
||||
uc_filemetadata "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/filemetadata"
|
||||
uc_fileobjectstorage "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/fileobjectstorage"
|
||||
uc_storagedailyusage "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/storagedailyusage"
|
||||
uc_storageusageevent "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/storageusageevent"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/transaction"
|
||||
)
|
||||
|
||||
type SoftDeleteFileRequestDTO struct {
|
||||
FileID gocql.UUID `json:"file_id"`
|
||||
ForceHardDelete bool `json:"force_hard_delete"` // Skip tombstone for GDPR right-to-be-forgotten
|
||||
}
|
||||
|
||||
type SoftDeleteFileResponseDTO struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
ReleasedBytes int64 `json:"released_bytes"` // Amount of storage quota released
|
||||
}
|
||||
|
||||
type SoftDeleteFileService interface {
|
||||
Execute(ctx context.Context, req *SoftDeleteFileRequestDTO) (*SoftDeleteFileResponseDTO, error)
|
||||
}
|
||||
|
||||
type softDeleteFileServiceImpl struct {
|
||||
config *config.Configuration
|
||||
logger *zap.Logger
|
||||
collectionRepo dom_collection.CollectionRepository
|
||||
getMetadataUseCase uc_filemetadata.GetFileMetadataUseCase
|
||||
updateFileMetadataUseCase uc_filemetadata.UpdateFileMetadataUseCase
|
||||
softDeleteMetadataUseCase uc_filemetadata.SoftDeleteFileMetadataUseCase
|
||||
hardDeleteMetadataUseCase uc_filemetadata.HardDeleteFileMetadataUseCase
|
||||
deleteDataUseCase uc_fileobjectstorage.DeleteEncryptedDataUseCase
|
||||
listFilesByOwnerIDService ListFilesByOwnerIDService
|
||||
// Storage quota management
|
||||
storageQuotaHelperUseCase uc_user.UserStorageQuotaHelperUseCase
|
||||
// Add storage usage tracking use cases
|
||||
createStorageUsageEventUseCase uc_storageusageevent.CreateStorageUsageEventUseCase
|
||||
updateStorageUsageUseCase uc_storagedailyusage.UpdateStorageUsageUseCase
|
||||
}
|
||||
|
||||
func NewSoftDeleteFileService(
|
||||
config *config.Configuration,
|
||||
logger *zap.Logger,
|
||||
collectionRepo dom_collection.CollectionRepository,
|
||||
getMetadataUseCase uc_filemetadata.GetFileMetadataUseCase,
|
||||
updateFileMetadataUseCase uc_filemetadata.UpdateFileMetadataUseCase,
|
||||
softDeleteMetadataUseCase uc_filemetadata.SoftDeleteFileMetadataUseCase,
|
||||
hardDeleteMetadataUseCase uc_filemetadata.HardDeleteFileMetadataUseCase,
|
||||
deleteDataUseCase uc_fileobjectstorage.DeleteEncryptedDataUseCase,
|
||||
listFilesByOwnerIDService ListFilesByOwnerIDService,
|
||||
storageQuotaHelperUseCase uc_user.UserStorageQuotaHelperUseCase,
|
||||
createStorageUsageEventUseCase uc_storageusageevent.CreateStorageUsageEventUseCase,
|
||||
updateStorageUsageUseCase uc_storagedailyusage.UpdateStorageUsageUseCase,
|
||||
) SoftDeleteFileService {
|
||||
logger = logger.Named("SoftDeleteFileService")
|
||||
return &softDeleteFileServiceImpl{
|
||||
config: config,
|
||||
logger: logger,
|
||||
collectionRepo: collectionRepo,
|
||||
getMetadataUseCase: getMetadataUseCase,
|
||||
updateFileMetadataUseCase: updateFileMetadataUseCase,
|
||||
softDeleteMetadataUseCase: softDeleteMetadataUseCase,
|
||||
hardDeleteMetadataUseCase: hardDeleteMetadataUseCase,
|
||||
deleteDataUseCase: deleteDataUseCase,
|
||||
listFilesByOwnerIDService: listFilesByOwnerIDService,
|
||||
storageQuotaHelperUseCase: storageQuotaHelperUseCase,
|
||||
createStorageUsageEventUseCase: createStorageUsageEventUseCase,
|
||||
updateStorageUsageUseCase: updateStorageUsageUseCase,
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *softDeleteFileServiceImpl) Execute(ctx context.Context, req *SoftDeleteFileRequestDTO) (*SoftDeleteFileResponseDTO, error) {
|
||||
//
|
||||
// STEP 1: Validation
|
||||
//
|
||||
if req == nil {
|
||||
svc.logger.Warn("Failed validation with nil request")
|
||||
return nil, httperror.NewForBadRequestWithSingleField("non_field_error", "File ID is required")
|
||||
}
|
||||
|
||||
if req.FileID.String() == "" {
|
||||
svc.logger.Warn("Empty file ID provided")
|
||||
return nil, httperror.NewForBadRequestWithSingleField("file_id", "File ID is required")
|
||||
}
|
||||
|
||||
//
|
||||
// STEP 2: Get user ID from context
|
||||
//
|
||||
userID, ok := ctx.Value(constants.SessionUserID).(gocql.UUID)
|
||||
if !ok {
|
||||
svc.logger.Error("Failed getting user ID from context")
|
||||
return nil, httperror.NewForInternalServerErrorWithSingleField("message", "Authentication context error")
|
||||
}
|
||||
|
||||
//
|
||||
// STEP 3: Get file metadata
|
||||
//
|
||||
file, err := svc.getMetadataUseCase.Execute(req.FileID)
|
||||
if err != nil {
|
||||
svc.logger.Error("Failed to get file metadata",
|
||||
zap.Any("error", err),
|
||||
zap.Any("file_id", req.FileID))
|
||||
|
||||
svc.logger.Debug("Debugging started, will list all files that belong to the authenticated user")
|
||||
currentFiles, err := svc.listFilesByOwnerIDService.Execute(ctx, &ListFilesByOwnerIDRequestDTO{OwnerID: userID})
|
||||
if err != nil {
|
||||
svc.logger.Error("Failed to list files by owner ID",
|
||||
zap.Any("error", err),
|
||||
zap.Any("user_id", userID))
|
||||
return nil, err
|
||||
}
|
||||
for _, file := range currentFiles.Files {
|
||||
svc.logger.Debug("File",
|
||||
zap.Any("id", file.ID))
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//
|
||||
// STEP 4: Check if user has write access to the file's collection
|
||||
//
|
||||
hasAccess, err := svc.collectionRepo.CheckAccess(ctx, file.CollectionID, userID, dom_collection.CollectionPermissionReadWrite)
|
||||
if err != nil {
|
||||
svc.logger.Error("Failed to check collection access",
|
||||
zap.Any("error", err),
|
||||
zap.Any("collection_id", file.CollectionID),
|
||||
zap.Any("user_id", userID))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !hasAccess {
|
||||
svc.logger.Warn("Unauthorized file deletion attempt",
|
||||
zap.Any("user_id", userID),
|
||||
zap.Any("file_id", req.FileID),
|
||||
zap.Any("collection_id", file.CollectionID))
|
||||
return nil, httperror.NewForForbiddenWithSingleField("message", "You don't have permission to delete this file")
|
||||
}
|
||||
|
||||
// Check valid transitions.
|
||||
if err := dom_file.IsValidStateTransition(file.State, dom_file.FileStateDeleted); err != nil {
|
||||
svc.logger.Warn("Invalid file state transition",
|
||||
zap.Any("user_id", userID),
|
||||
zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//
|
||||
// SAGA: Initialize distributed transaction manager
|
||||
//
|
||||
saga := transaction.NewSaga("soft-delete-file", svc.logger)
|
||||
|
||||
//
|
||||
// STEP 5: Calculate storage space to be released
|
||||
//
|
||||
totalFileSize := file.EncryptedFileSizeInBytes + file.EncryptedThumbnailSizeInBytes
|
||||
|
||||
svc.logger.Info("Starting file soft-delete with SAGA protection",
|
||||
zap.String("file_id", req.FileID.String()),
|
||||
zap.Int64("file_size", file.EncryptedFileSizeInBytes),
|
||||
zap.Int64("thumbnail_size", file.EncryptedThumbnailSizeInBytes),
|
||||
zap.Int64("total_size_to_release", totalFileSize))
|
||||
|
||||
//
|
||||
// STEP 6: Update file metadata with tombstone (SAGA protected)
|
||||
//
|
||||
originalState := file.State
|
||||
originalTombstoneVersion := file.TombstoneVersion
|
||||
originalTombstoneExpiry := file.TombstoneExpiry
|
||||
|
||||
file.State = dom_file.FileStateDeleted
|
||||
file.Version++
|
||||
file.ModifiedAt = time.Now()
|
||||
file.ModifiedByUserID = userID
|
||||
file.TombstoneVersion = file.Version
|
||||
file.TombstoneExpiry = time.Now().Add(30 * 24 * time.Hour)
|
||||
|
||||
if err := svc.updateFileMetadataUseCase.Execute(ctx, file); err != nil {
|
||||
svc.logger.Error("Failed to update file metadata with tombstone",
|
||||
zap.Error(err))
|
||||
saga.Rollback(ctx)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Register compensation: restore original metadata
|
||||
fileIDCaptured := file.ID
|
||||
originalStateCaptured := originalState
|
||||
originalTombstoneVersionCaptured := originalTombstoneVersion
|
||||
originalTombstoneExpiryCaptured := originalTombstoneExpiry
|
||||
saga.AddCompensation(func(ctx context.Context) error {
|
||||
svc.logger.Warn("SAGA compensation: restoring file metadata",
|
||||
zap.String("file_id", fileIDCaptured.String()))
|
||||
|
||||
restoredFile, err := svc.getMetadataUseCase.Execute(fileIDCaptured)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
restoredFile.State = originalStateCaptured
|
||||
restoredFile.TombstoneVersion = originalTombstoneVersionCaptured
|
||||
restoredFile.TombstoneExpiry = originalTombstoneExpiryCaptured
|
||||
restoredFile.ModifiedAt = time.Now()
|
||||
|
||||
return svc.updateFileMetadataUseCase.Execute(ctx, restoredFile)
|
||||
})
|
||||
|
||||
//
|
||||
// STEP 7: Delete file metadata record (SAGA protected)
|
||||
//
|
||||
if req.ForceHardDelete {
|
||||
// Hard delete - permanent removal for GDPR right-to-be-forgotten
|
||||
svc.logger.Info("Performing hard delete (GDPR mode) - no tombstone",
|
||||
zap.String("file_id", req.FileID.String()))
|
||||
|
||||
err = svc.hardDeleteMetadataUseCase.Execute(req.FileID)
|
||||
if err != nil {
|
||||
svc.logger.Error("Failed to hard-delete file metadata",
|
||||
zap.Error(err))
|
||||
saga.Rollback(ctx) // Restores tombstone metadata
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// No compensation for hard delete - GDPR compliance requires permanent deletion
|
||||
} else {
|
||||
// Soft delete - 30-day tombstone (standard deletion)
|
||||
err = svc.softDeleteMetadataUseCase.Execute(req.FileID)
|
||||
if err != nil {
|
||||
svc.logger.Error("Failed to soft-delete file metadata",
|
||||
zap.Error(err))
|
||||
saga.Rollback(ctx) // Restores tombstone metadata
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Register compensation: restore metadata record to active state
|
||||
saga.AddCompensation(func(ctx context.Context) error {
|
||||
svc.logger.Warn("SAGA compensation: restoring file metadata record to active state",
|
||||
zap.String("file_id", fileIDCaptured.String()))
|
||||
|
||||
// Get the soft-deleted file
|
||||
deletedFile, err := svc.getMetadataUseCase.Execute(fileIDCaptured)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Restore to active state
|
||||
deletedFile.State = dom_file.FileStateActive
|
||||
deletedFile.ModifiedAt = time.Now()
|
||||
deletedFile.Version++
|
||||
deletedFile.TombstoneVersion = 0
|
||||
deletedFile.TombstoneExpiry = time.Time{}
|
||||
|
||||
return svc.updateFileMetadataUseCase.Execute(ctx, deletedFile)
|
||||
})
|
||||
}
|
||||
|
||||
//
|
||||
// STEP 8: Update collection file count (SAGA protected)
|
||||
//
|
||||
if originalState == dom_file.FileStateActive {
|
||||
err = svc.collectionRepo.DecrementFileCount(ctx, file.CollectionID)
|
||||
if err != nil {
|
||||
svc.logger.Error("Failed to decrement file count for collection",
|
||||
zap.String("collection_id", file.CollectionID.String()),
|
||||
zap.Error(err))
|
||||
saga.Rollback(ctx)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Register compensation: increment the count back
|
||||
collectionIDCaptured := file.CollectionID
|
||||
saga.AddCompensation(func(ctx context.Context) error {
|
||||
svc.logger.Warn("SAGA compensation: restoring file count",
|
||||
zap.String("collection_id", collectionIDCaptured.String()))
|
||||
return svc.collectionRepo.IncrementFileCount(ctx, collectionIDCaptured)
|
||||
})
|
||||
}
|
||||
|
||||
//
|
||||
// STEP 9: Release storage quota (SAGA protected)
|
||||
//
|
||||
var releasedBytes int64 = 0
|
||||
if originalState == dom_file.FileStateActive && totalFileSize > 0 {
|
||||
err = svc.storageQuotaHelperUseCase.OnFileDeleted(ctx, userID, totalFileSize)
|
||||
if err != nil {
|
||||
svc.logger.Error("Failed to release storage quota after file deletion",
|
||||
zap.Error(err))
|
||||
saga.Rollback(ctx) // Restores metadata + tombstone
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Register compensation: re-reserve the released quota
|
||||
totalFileSizeCaptured := totalFileSize
|
||||
userIDCaptured := userID
|
||||
saga.AddCompensation(func(ctx context.Context) error {
|
||||
svc.logger.Warn("SAGA compensation: re-reserving released storage quota",
|
||||
zap.Int64("size", totalFileSizeCaptured))
|
||||
return svc.storageQuotaHelperUseCase.CheckAndReserveQuota(ctx, userIDCaptured, totalFileSizeCaptured)
|
||||
})
|
||||
|
||||
releasedBytes = totalFileSize
|
||||
svc.logger.Info("Storage quota released successfully",
|
||||
zap.Int64("released_bytes", releasedBytes))
|
||||
|
||||
//
|
||||
// STEP 10: Create storage usage event (SAGA protected)
|
||||
//
|
||||
err = svc.createStorageUsageEventUseCase.Execute(ctx, file.OwnerID, totalFileSize, "remove")
|
||||
if err != nil {
|
||||
svc.logger.Error("Failed to create storage usage event for deletion",
|
||||
zap.Error(err))
|
||||
saga.Rollback(ctx) // Restores quota + metadata
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Register compensation: create compensating "add" event
|
||||
ownerIDCaptured := file.OwnerID
|
||||
saga.AddCompensation(func(ctx context.Context) error {
|
||||
svc.logger.Warn("SAGA compensation: creating compensating usage event")
|
||||
return svc.createStorageUsageEventUseCase.Execute(ctx, ownerIDCaptured, totalFileSizeCaptured, "add")
|
||||
})
|
||||
|
||||
//
|
||||
// STEP 11: Update daily storage usage (SAGA protected)
|
||||
//
|
||||
today := time.Now().Truncate(24 * time.Hour)
|
||||
updateReq := &uc_storagedailyusage.UpdateStorageUsageRequest{
|
||||
UserID: file.OwnerID,
|
||||
UsageDay: &today,
|
||||
TotalBytes: -totalFileSize,
|
||||
AddBytes: 0,
|
||||
RemoveBytes: totalFileSize,
|
||||
IsIncrement: true,
|
||||
}
|
||||
err = svc.updateStorageUsageUseCase.Execute(ctx, updateReq)
|
||||
if err != nil {
|
||||
svc.logger.Error("Failed to update daily storage usage for deletion",
|
||||
zap.Error(err))
|
||||
saga.Rollback(ctx) // Restores everything
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Register compensation: reverse the usage update
|
||||
saga.AddCompensation(func(ctx context.Context) error {
|
||||
svc.logger.Warn("SAGA compensation: reversing daily usage update")
|
||||
compensateReq := &uc_storagedailyusage.UpdateStorageUsageRequest{
|
||||
UserID: ownerIDCaptured,
|
||||
UsageDay: &today,
|
||||
TotalBytes: totalFileSizeCaptured, // Positive to reverse
|
||||
AddBytes: totalFileSizeCaptured,
|
||||
RemoveBytes: 0,
|
||||
IsIncrement: true,
|
||||
}
|
||||
return svc.updateStorageUsageUseCase.Execute(ctx, compensateReq)
|
||||
})
|
||||
} else if originalState == dom_file.FileStatePending {
|
||||
// For pending files, release the reserved quota (SAGA protected)
|
||||
err = svc.storageQuotaHelperUseCase.ReleaseQuota(ctx, userID, totalFileSize)
|
||||
if err != nil {
|
||||
svc.logger.Error("Failed to release reserved storage quota for pending file",
|
||||
zap.Error(err))
|
||||
saga.Rollback(ctx) // Restores metadata + tombstone
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Register compensation: re-reserve the released quota
|
||||
totalFileSizeCaptured := totalFileSize
|
||||
userIDCaptured := userID
|
||||
saga.AddCompensation(func(ctx context.Context) error {
|
||||
svc.logger.Warn("SAGA compensation: re-reserving pending file quota")
|
||||
return svc.storageQuotaHelperUseCase.CheckAndReserveQuota(ctx, userIDCaptured, totalFileSizeCaptured)
|
||||
})
|
||||
|
||||
releasedBytes = totalFileSize
|
||||
svc.logger.Info("Reserved storage quota released for pending file",
|
||||
zap.Int64("released_bytes", releasedBytes))
|
||||
}
|
||||
|
||||
//
|
||||
// STEP 12: Delete S3 objects
|
||||
//
|
||||
var storagePaths []string
|
||||
storagePaths = append(storagePaths, file.EncryptedFileObjectKey)
|
||||
if file.EncryptedThumbnailObjectKey != "" {
|
||||
storagePaths = append(storagePaths, file.EncryptedThumbnailObjectKey)
|
||||
}
|
||||
|
||||
svc.logger.Info("Deleting S3 objects for file",
|
||||
zap.String("file_id", req.FileID.String()),
|
||||
zap.Int("s3_objects_count", len(storagePaths)))
|
||||
|
||||
for _, storagePath := range storagePaths {
|
||||
if err := svc.deleteDataUseCase.Execute(storagePath); err != nil {
|
||||
// Log but don't fail - S3 deletion is best effort after metadata is deleted
|
||||
svc.logger.Error("Failed to delete S3 object (continuing anyway)",
|
||||
zap.String("storage_path", storagePath),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// SUCCESS: All operations completed with SAGA protection
|
||||
//
|
||||
svc.logger.Info("File deleted successfully with SAGA protection",
|
||||
zap.String("file_id", req.FileID.String()),
|
||||
zap.String("collection_id", file.CollectionID.String()),
|
||||
zap.Int64("released_bytes", releasedBytes),
|
||||
zap.Int("s3_objects_deleted", len(storagePaths)))
|
||||
|
||||
return &SoftDeleteFileResponseDTO{
|
||||
Success: true,
|
||||
Message: "File deleted successfully",
|
||||
ReleasedBytes: releasedBytes,
|
||||
}, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue