395 lines
16 KiB
Go
395 lines
16 KiB
Go
// monorepo/cloud/backend/internal/maplefile/service/file/create_pending_file.go
|
|
package file
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"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"
|
|
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/crypto"
|
|
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
|
|
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
|
|
dom_tag "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/tag"
|
|
uc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/collection"
|
|
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_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user"
|
|
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
|
|
)
|
|
|
|
type CreatePendingFileRequestDTO struct {
|
|
ID gocql.UUID `json:"id"`
|
|
CollectionID gocql.UUID `json:"collection_id"`
|
|
EncryptedMetadata string `json:"encrypted_metadata"`
|
|
EncryptedFileKey crypto.EncryptedFileKey `json:"encrypted_file_key"`
|
|
EncryptionVersion string `json:"encryption_version"`
|
|
EncryptedHash string `json:"encrypted_hash"`
|
|
// Optional: expected file size for validation (in bytes)
|
|
ExpectedFileSizeInBytes int64 `json:"expected_file_size_in_bytes,omitempty"`
|
|
// Optional: expected thumbnail size for validation (in bytes)
|
|
ExpectedThumbnailSizeInBytes int64 `json:"expected_thumbnail_size_in_bytes,omitempty"`
|
|
// Optional: content type for file upload validation (e.g., "image/jpeg", "video/mp4")
|
|
// Required for album uploads to enforce photo/video restrictions
|
|
ContentType string `json:"content_type,omitempty"`
|
|
// Optional: tag IDs to embed in file at creation time
|
|
TagIDs []gocql.UUID `json:"tag_ids,omitempty"`
|
|
}
|
|
|
|
type FileResponseDTO struct {
|
|
ID gocql.UUID `json:"id"`
|
|
CollectionID gocql.UUID `json:"collection_id"`
|
|
OwnerID gocql.UUID `json:"owner_id"`
|
|
EncryptedMetadata string `json:"encrypted_metadata"`
|
|
EncryptedFileKey crypto.EncryptedFileKey `json:"encrypted_file_key"`
|
|
EncryptionVersion string `json:"encryption_version"`
|
|
EncryptedHash string `json:"encrypted_hash"`
|
|
EncryptedFileSizeInBytes int64 `json:"encrypted_file_size_in_bytes"`
|
|
EncryptedThumbnailSizeInBytes int64 `json:"encrypted_thumbnail_size_in_bytes"`
|
|
Tags []dom_tag.EmbeddedTag `json:"tags"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
ModifiedAt time.Time `json:"modified_at"`
|
|
Version uint64 `json:"version"`
|
|
State string `json:"state"`
|
|
TombstoneVersion uint64 `json:"tombstone_version"`
|
|
TombstoneExpiry time.Time `json:"tombstone_expiry"`
|
|
}
|
|
|
|
type CreatePendingFileResponseDTO struct {
|
|
File *FileResponseDTO `json:"file"`
|
|
PresignedUploadURL string `json:"presigned_upload_url"`
|
|
PresignedThumbnailURL string `json:"presigned_thumbnail_url,omitempty"`
|
|
UploadURLExpirationTime time.Time `json:"upload_url_expiration_time"`
|
|
Success bool `json:"success"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type CreatePendingFileService interface {
|
|
Execute(ctx context.Context, req *CreatePendingFileRequestDTO) (*CreatePendingFileResponseDTO, error)
|
|
}
|
|
|
|
type createPendingFileServiceImpl struct {
|
|
config *config.Configuration
|
|
logger *zap.Logger
|
|
getCollectionUseCase uc_collection.GetCollectionUseCase
|
|
checkCollectionAccessUseCase uc_collection.CheckCollectionAccessUseCase
|
|
checkFileExistsUseCase uc_filemetadata.CheckFileExistsUseCase
|
|
createMetadataUseCase uc_filemetadata.CreateFileMetadataUseCase
|
|
generatePresignedUploadURLUseCase uc_fileobjectstorage.GeneratePresignedUploadURLUseCase
|
|
storageQuotaHelperUseCase uc_user.UserStorageQuotaHelperUseCase
|
|
tagRepo dom_tag.Repository
|
|
fileValidator *FileValidator
|
|
}
|
|
|
|
func NewCreatePendingFileService(
|
|
config *config.Configuration,
|
|
logger *zap.Logger,
|
|
getCollectionUseCase uc_collection.GetCollectionUseCase,
|
|
checkCollectionAccessUseCase uc_collection.CheckCollectionAccessUseCase,
|
|
checkFileExistsUseCase uc_filemetadata.CheckFileExistsUseCase,
|
|
createMetadataUseCase uc_filemetadata.CreateFileMetadataUseCase,
|
|
generatePresignedUploadURLUseCase uc_fileobjectstorage.GeneratePresignedUploadURLUseCase,
|
|
storageQuotaHelperUseCase uc_user.UserStorageQuotaHelperUseCase,
|
|
tagRepo dom_tag.Repository,
|
|
) CreatePendingFileService {
|
|
logger = logger.Named("CreatePendingFileService")
|
|
return &createPendingFileServiceImpl{
|
|
config: config,
|
|
logger: logger,
|
|
getCollectionUseCase: getCollectionUseCase,
|
|
checkCollectionAccessUseCase: checkCollectionAccessUseCase,
|
|
checkFileExistsUseCase: checkFileExistsUseCase,
|
|
createMetadataUseCase: createMetadataUseCase,
|
|
generatePresignedUploadURLUseCase: generatePresignedUploadURLUseCase,
|
|
storageQuotaHelperUseCase: storageQuotaHelperUseCase,
|
|
tagRepo: tagRepo,
|
|
fileValidator: NewFileValidator(),
|
|
}
|
|
}
|
|
|
|
func (svc *createPendingFileServiceImpl) Execute(ctx context.Context, req *CreatePendingFileRequestDTO) (*CreatePendingFileResponseDTO, error) {
|
|
//
|
|
// STEP 1: Validation
|
|
//
|
|
if req == nil {
|
|
svc.logger.Warn("⚠️ Failed validation with nil request")
|
|
return nil, httperror.NewForBadRequestWithSingleField("non_field_error", "File creation details are required")
|
|
}
|
|
|
|
e := make(map[string]string)
|
|
if req.ID.String() == "" {
|
|
e["id"] = "Client-side generated ID is required"
|
|
}
|
|
doesExist, err := svc.checkFileExistsUseCase.Execute(req.ID)
|
|
if err != nil {
|
|
e["id"] = fmt.Sprintf("Client-side generated ID causes error: %v", req.ID)
|
|
}
|
|
if doesExist {
|
|
e["id"] = "Client-side generated ID already exists"
|
|
}
|
|
if req.CollectionID.String() == "" {
|
|
e["collection_id"] = "Collection ID is required"
|
|
}
|
|
if req.EncryptedMetadata == "" {
|
|
e["encrypted_metadata"] = "Encrypted metadata is required"
|
|
}
|
|
if req.EncryptedFileKey.Ciphertext == nil || len(req.EncryptedFileKey.Ciphertext) == 0 {
|
|
e["encrypted_file_key"] = "Encrypted file key is required"
|
|
}
|
|
if req.EncryptionVersion == "" {
|
|
e["encryption_version"] = "Encryption version is required"
|
|
}
|
|
if req.EncryptedHash == "" {
|
|
e["encrypted_hash"] = "Encrypted hash is required"
|
|
}
|
|
if req.ExpectedFileSizeInBytes <= 0 {
|
|
e["expected_file_size_in_bytes"] = "Expected file size must be greater than 0"
|
|
}
|
|
|
|
if len(e) != 0 {
|
|
svc.logger.Warn("⚠️ Failed validation",
|
|
zap.Any("error", e))
|
|
return nil, httperror.NewForBadRequest(&e)
|
|
}
|
|
|
|
//
|
|
// 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: Check storage quota BEFORE creating file
|
|
//
|
|
totalExpectedSize := req.ExpectedFileSizeInBytes + req.ExpectedThumbnailSizeInBytes
|
|
err = svc.storageQuotaHelperUseCase.CheckAndReserveQuota(ctx, userID, totalExpectedSize)
|
|
if err != nil {
|
|
svc.logger.Warn("⚠️ Storage quota check failed",
|
|
zap.String("user_id", userID.String()),
|
|
zap.Int64("requested_size", totalExpectedSize),
|
|
zap.Error(err))
|
|
return nil, err // This will be a proper HTTP error from the quota helper
|
|
}
|
|
|
|
svc.logger.Info("✅ Storage quota reserved successfully",
|
|
zap.String("user_id", userID.String()),
|
|
zap.Int64("reserved_size", totalExpectedSize))
|
|
|
|
//
|
|
// STEP 4: Check if user has write access to the collection
|
|
//
|
|
hasAccess, err := svc.checkCollectionAccessUseCase.Execute(ctx, req.CollectionID, userID, dom_collection.CollectionPermissionReadWrite)
|
|
if err != nil {
|
|
// Release reserved quota on error
|
|
if releaseErr := svc.storageQuotaHelperUseCase.ReleaseQuota(ctx, userID, totalExpectedSize); releaseErr != nil {
|
|
svc.logger.Error("❌ Failed to release quota after collection access check error", zap.Error(releaseErr))
|
|
}
|
|
|
|
svc.logger.Error("❌ Failed to check collection access",
|
|
zap.Any("error", err),
|
|
zap.Any("collection_id", req.CollectionID),
|
|
zap.Any("user_id", userID))
|
|
return nil, err
|
|
}
|
|
|
|
if !hasAccess {
|
|
// Release reserved quota on access denied
|
|
if releaseErr := svc.storageQuotaHelperUseCase.ReleaseQuota(ctx, userID, totalExpectedSize); releaseErr != nil {
|
|
svc.logger.Error("❌ Failed to release quota after access denied", zap.Error(releaseErr))
|
|
}
|
|
|
|
svc.logger.Warn("⚠️ Unauthorized file creation attempt",
|
|
zap.Any("user_id", userID),
|
|
zap.Any("collection_id", req.CollectionID))
|
|
return nil, httperror.NewForForbiddenWithSingleField("message", "You don't have permission to create files in this collection")
|
|
}
|
|
|
|
//
|
|
// STEP 5: Get collection details and validate file upload
|
|
//
|
|
// CWE-434: Unrestricted Upload of File with Dangerous Type
|
|
// OWASP A04:2021: Insecure Design - File upload validation
|
|
collection, err := svc.getCollectionUseCase.Execute(ctx, req.CollectionID)
|
|
if err != nil {
|
|
// Release reserved quota on error
|
|
if releaseErr := svc.storageQuotaHelperUseCase.ReleaseQuota(ctx, userID, totalExpectedSize); releaseErr != nil {
|
|
svc.logger.Error("❌ Failed to release quota after collection retrieval error", zap.Error(releaseErr))
|
|
}
|
|
|
|
svc.logger.Error("❌ Failed to get collection details",
|
|
zap.Error(err),
|
|
zap.Any("collection_id", req.CollectionID))
|
|
return nil, err
|
|
}
|
|
|
|
// Validate file upload based on collection type
|
|
if err := svc.fileValidator.ValidateFileUpload(
|
|
collection.CollectionType,
|
|
req.ExpectedFileSizeInBytes,
|
|
req.ExpectedThumbnailSizeInBytes,
|
|
req.ContentType,
|
|
); err != nil {
|
|
// Release reserved quota on validation error
|
|
if releaseErr := svc.storageQuotaHelperUseCase.ReleaseQuota(ctx, userID, totalExpectedSize); releaseErr != nil {
|
|
svc.logger.Error("❌ Failed to release quota after validation error", zap.Error(releaseErr))
|
|
}
|
|
|
|
svc.logger.Warn("⚠️ File upload validation failed",
|
|
zap.Error(err),
|
|
zap.String("collection_type", collection.CollectionType),
|
|
zap.Int64("file_size", req.ExpectedFileSizeInBytes),
|
|
zap.String("content_type", req.ContentType))
|
|
return nil, httperror.NewForBadRequestWithSingleField("file", err.Error())
|
|
}
|
|
|
|
svc.logger.Info("✅ File upload validated successfully",
|
|
zap.String("collection_type", collection.CollectionType),
|
|
zap.Int64("file_size", req.ExpectedFileSizeInBytes),
|
|
zap.String("content_type", req.ContentType))
|
|
|
|
//
|
|
// STEP 6: Generate storage paths.
|
|
//
|
|
storagePath := generateStoragePath(userID.String(), req.ID.String())
|
|
thumbnailStoragePath := generateThumbnailStoragePath(userID.String(), req.ID.String())
|
|
|
|
//
|
|
// STEP 6: Generate presigned upload URLs
|
|
//
|
|
uploadURLDuration := 1 * time.Hour // URLs valid for 1 hour
|
|
expirationTime := time.Now().Add(uploadURLDuration)
|
|
|
|
presignedUploadURL, err := svc.generatePresignedUploadURLUseCase.Execute(ctx, storagePath, uploadURLDuration)
|
|
if err != nil {
|
|
// Release reserved quota on error
|
|
if releaseErr := svc.storageQuotaHelperUseCase.ReleaseQuota(ctx, userID, totalExpectedSize); releaseErr != nil {
|
|
svc.logger.Error("❌ Failed to release quota after presigned URL generation error", zap.Error(releaseErr))
|
|
}
|
|
|
|
svc.logger.Error("❌ Failed to generate presigned upload URL",
|
|
zap.Any("error", err),
|
|
zap.Any("file_id", req.ID),
|
|
zap.String("storage_path", storagePath))
|
|
return nil, err
|
|
}
|
|
|
|
// Generate thumbnail upload URL (optional)
|
|
var presignedThumbnailURL string
|
|
if req.ExpectedThumbnailSizeInBytes > 0 {
|
|
presignedThumbnailURL, err = svc.generatePresignedUploadURLUseCase.Execute(ctx, thumbnailStoragePath, uploadURLDuration)
|
|
if err != nil {
|
|
svc.logger.Warn("⚠️ Failed to generate thumbnail presigned upload URL, continuing without it",
|
|
zap.Any("error", err),
|
|
zap.Any("file_id", req.ID),
|
|
zap.String("thumbnail_storage_path", thumbnailStoragePath))
|
|
}
|
|
}
|
|
|
|
//
|
|
// STEP 7: Look up and embed tags if TagIDs were provided
|
|
//
|
|
var embeddedTags []dom_tag.EmbeddedTag
|
|
if len(req.TagIDs) > 0 {
|
|
svc.logger.Debug("🏷️ Looking up tags to embed in file",
|
|
zap.Int("tagCount", len(req.TagIDs)))
|
|
|
|
for _, tagID := range req.TagIDs {
|
|
tagObj, err := svc.tagRepo.GetByID(ctx, tagID)
|
|
if err != nil {
|
|
svc.logger.Warn("Failed to get tag for embedding, skipping",
|
|
zap.String("tagID", tagID.String()),
|
|
zap.Error(err))
|
|
continue
|
|
}
|
|
// Verify tag belongs to the user
|
|
if tagObj.UserID != userID {
|
|
svc.logger.Warn("Tag does not belong to user, skipping",
|
|
zap.String("tagID", tagID.String()),
|
|
zap.String("userID", userID.String()))
|
|
continue
|
|
}
|
|
embeddedTags = append(embeddedTags, *tagObj.ToEmbeddedTag())
|
|
}
|
|
|
|
svc.logger.Info("✅ Tags embedded in file",
|
|
zap.Int("embeddedCount", len(embeddedTags)),
|
|
zap.Int("requestedCount", len(req.TagIDs)))
|
|
}
|
|
|
|
//
|
|
// STEP 8: Create pending file metadata record
|
|
//
|
|
now := time.Now()
|
|
file := &dom_file.File{
|
|
ID: req.ID,
|
|
CollectionID: req.CollectionID,
|
|
OwnerID: userID,
|
|
EncryptedMetadata: req.EncryptedMetadata,
|
|
EncryptedFileKey: req.EncryptedFileKey,
|
|
EncryptionVersion: req.EncryptionVersion,
|
|
EncryptedHash: req.EncryptedHash,
|
|
EncryptedFileObjectKey: storagePath,
|
|
EncryptedFileSizeInBytes: req.ExpectedFileSizeInBytes, // Will be updated when upload completes
|
|
EncryptedThumbnailObjectKey: thumbnailStoragePath,
|
|
EncryptedThumbnailSizeInBytes: req.ExpectedThumbnailSizeInBytes, // Will be updated when upload completes
|
|
Tags: embeddedTags,
|
|
CreatedAt: now,
|
|
CreatedByUserID: userID,
|
|
ModifiedAt: now,
|
|
ModifiedByUserID: userID,
|
|
Version: 1, // File creation always starts mutation version at 1.
|
|
State: dom_file.FileStatePending, // File creation always starts state in a pending upload.
|
|
}
|
|
|
|
err = svc.createMetadataUseCase.Execute(file)
|
|
if err != nil {
|
|
// Release reserved quota on error
|
|
if releaseErr := svc.storageQuotaHelperUseCase.ReleaseQuota(ctx, userID, totalExpectedSize); releaseErr != nil {
|
|
svc.logger.Error("❌ Failed to release quota after metadata creation error", zap.Error(releaseErr))
|
|
}
|
|
|
|
svc.logger.Error("❌ Failed to create pending file metadata",
|
|
zap.Any("error", err),
|
|
zap.Any("file_id", req.ID))
|
|
return nil, err
|
|
}
|
|
|
|
//
|
|
// STEP 9: Prepare response
|
|
//
|
|
response := &CreatePendingFileResponseDTO{
|
|
File: mapFileToDTO(file),
|
|
PresignedUploadURL: presignedUploadURL,
|
|
PresignedThumbnailURL: presignedThumbnailURL,
|
|
UploadURLExpirationTime: expirationTime,
|
|
Success: true,
|
|
Message: "Pending file created successfully. Storage quota reserved. Use the presigned URL to upload your file.",
|
|
}
|
|
|
|
svc.logger.Info("✅ Pending file created successfully with quota reservation",
|
|
zap.Any("file_id", req.ID),
|
|
zap.Any("collection_id", req.CollectionID),
|
|
zap.Any("owner_id", userID),
|
|
zap.String("storage_path", storagePath),
|
|
zap.Int64("reserved_size", totalExpectedSize),
|
|
zap.Time("url_expiration", expirationTime))
|
|
|
|
return response, nil
|
|
}
|
|
|
|
// Helper function to generate consistent storage path
|
|
func generateStoragePath(ownerID, fileID string) string {
|
|
return fmt.Sprintf("users/%s/files/%s", ownerID, fileID)
|
|
}
|
|
|
|
// Helper function to generate consistent thumbnail storage path
|
|
func generateThumbnailStoragePath(ownerID, fileID string) string {
|
|
return fmt.Sprintf("users/%s/files/%s_thumb", ownerID, fileID)
|
|
}
|