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