monorepo/cloud/maplefile-backend/internal/service/file/create_pending_file.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)
}