// monorepo/cloud/backend/internal/maplefile/service/collection/share_collection.go package collection import ( "context" "fmt" "time" "go.uber.org/zap" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants" dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection" uc_blockedemail "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/blockedemail" uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/emailer/mailgun" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/transaction" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation" "github.com/gocql/gocql" ) type ShareCollectionRequestDTO struct { CollectionID gocql.UUID `json:"collection_id"` RecipientID gocql.UUID `json:"recipient_id"` RecipientEmail string `json:"recipient_email"` PermissionLevel string `json:"permission_level"` EncryptedCollectionKey []byte `json:"encrypted_collection_key"` ShareWithDescendants bool `json:"share_with_descendants"` } type ShareCollectionResponseDTO struct { Success bool `json:"success"` Message string `json:"message"` MembershipsCreated int `json:"memberships_created,omitempty"` } type ShareCollectionService interface { Execute(ctx context.Context, req *ShareCollectionRequestDTO) (*ShareCollectionResponseDTO, error) } type shareCollectionServiceImpl struct { config *config.Configuration logger *zap.Logger repo dom_collection.CollectionRepository checkBlockedEmailUC uc_blockedemail.CheckBlockedEmailUseCase userGetByIDUC uc_user.UserGetByIDUseCase emailer mailgun.Emailer } func NewShareCollectionService( config *config.Configuration, logger *zap.Logger, repo dom_collection.CollectionRepository, checkBlockedEmailUC uc_blockedemail.CheckBlockedEmailUseCase, userGetByIDUC uc_user.UserGetByIDUseCase, emailer mailgun.Emailer, ) ShareCollectionService { logger = logger.Named("ShareCollectionService") return &shareCollectionServiceImpl{ config: config, logger: logger, repo: repo, checkBlockedEmailUC: checkBlockedEmailUC, userGetByIDUC: userGetByIDUC, emailer: emailer, } } func (svc *shareCollectionServiceImpl) Execute(ctx context.Context, req *ShareCollectionRequestDTO) (*ShareCollectionResponseDTO, error) { // // STEP 1: Enhanced Validation with Detailed Logging // if req == nil { svc.logger.Warn("Failed validation with nil request") return nil, httperror.NewBadRequestError("Share details are required") } // Log the incoming request for debugging (PII masked for security) svc.logger.Debug("received share collection request", zap.String("collection_id", req.CollectionID.String()), zap.String("recipient_id", req.RecipientID.String()), zap.String("recipient_email", validation.MaskEmail(req.RecipientEmail)), zap.String("permission_level", req.PermissionLevel), zap.Int("encrypted_key_length", len(req.EncryptedCollectionKey)), zap.Bool("share_with_descendants", req.ShareWithDescendants)) e := make(map[string]string) if req.CollectionID.String() == "" { e["collection_id"] = "Collection ID is required" } if req.RecipientID.String() == "" { e["recipient_id"] = "Recipient ID is required" } if req.RecipientEmail == "" { e["recipient_email"] = "Recipient email is required" } if req.PermissionLevel == "" { // Will default to read-only in repository } else if req.PermissionLevel != dom_collection.CollectionPermissionReadOnly && req.PermissionLevel != dom_collection.CollectionPermissionReadWrite && req.PermissionLevel != dom_collection.CollectionPermissionAdmin { e["permission_level"] = "Invalid permission level" } // CRITICAL: Validate encrypted collection key is present and has valid format // Note: We use generic error messages to avoid revealing cryptographic implementation details const ( minEncryptedKeySize = 32 // Minimum expected size for encrypted key maxEncryptedKeySize = 1024 // Maximum reasonable size to prevent abuse ) if len(req.EncryptedCollectionKey) == 0 { svc.logger.Error("encrypted collection key validation failed", zap.String("collection_id", req.CollectionID.String()), zap.String("recipient_id", req.RecipientID.String()), zap.Int("encrypted_key_length", len(req.EncryptedCollectionKey))) e["encrypted_collection_key"] = "Encrypted collection key is required" } else if len(req.EncryptedCollectionKey) < minEncryptedKeySize || len(req.EncryptedCollectionKey) > maxEncryptedKeySize { // Generic error message - don't reveal size expectations svc.logger.Error("encrypted collection key has invalid size", zap.String("collection_id", req.CollectionID.String()), zap.String("recipient_id", req.RecipientID.String()), zap.Int("encrypted_key_length", len(req.EncryptedCollectionKey))) e["encrypted_collection_key"] = "Encrypted collection key is invalid" } if len(e) != 0 { svc.logger.Warn("Failed validation", zap.Any("error", e)) return nil, httperror.NewValidationError(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.NewInternalServerError("Authentication context error") } // // STEP 3: Retrieve existing collection // collection, err := svc.repo.Get(ctx, req.CollectionID) if err != nil { svc.logger.Error("Failed to get collection", zap.Any("error", err), zap.Any("collection_id", req.CollectionID)) return nil, err } if collection == nil { svc.logger.Debug("Collection not found", zap.Any("collection_id", req.CollectionID)) return nil, httperror.NewNotFoundError("Collection") } // // STEP 4: Check if user has rights to share this collection // hasSharePermission := false // Owner always has share permission if collection.OwnerID == userID { hasSharePermission = true } else { // Check if user is an admin member for _, member := range collection.Members { if member.RecipientID == userID && member.PermissionLevel == dom_collection.CollectionPermissionAdmin { hasSharePermission = true break } } } if !hasSharePermission { svc.logger.Warn("Unauthorized collection sharing attempt", zap.Any("user_id", userID), zap.Any("collection_id", req.CollectionID)) return nil, httperror.NewForbiddenError("You don't have permission to share this collection") } // // STEP 5: Validate that we're not sharing with the owner (redundant) // if req.RecipientID == collection.OwnerID { svc.logger.Warn("Attempt to share collection with its owner", zap.String("collection_id", req.CollectionID.String()), zap.String("owner_id", collection.OwnerID.String()), zap.String("recipient_id", req.RecipientID.String())) return nil, httperror.NewValidationError(map[string]string{"recipient_id": "Cannot share collection with its owner"}) } // // STEP 5.5: Check if the recipient has blocked the sender // // Get the sender's email by looking up the user sender, err := svc.userGetByIDUC.Execute(ctx, userID) if err != nil { svc.logger.Error("Failed to get sender user info", zap.Any("error", err), zap.String("user_id", userID.String())) // Don't block the sharing if we can't get user info - continue without check } else if sender != nil && sender.Email != "" { isBlocked, err := svc.checkBlockedEmailUC.Execute(ctx, req.RecipientID, sender.Email) if err != nil { svc.logger.Error("Failed to check blocked email status", zap.Any("error", err), zap.String("recipient_id", req.RecipientID.String()), zap.String("sender_email", validation.MaskEmail(sender.Email))) // Don't block the sharing if we can't check - log and continue } else if isBlocked { svc.logger.Info("Sharing blocked by recipient", zap.String("collection_id", req.CollectionID.String()), zap.String("recipient_id", req.RecipientID.String()), zap.String("sender_email", validation.MaskEmail(sender.Email))) return nil, httperror.NewForbiddenError("Unable to share with this user. You may have been blocked.") } } // // STEP 6: Create membership with EXPLICIT validation // svc.logger.Info("creating membership with validated encrypted key", zap.String("collection_id", req.CollectionID.String()), zap.String("recipient_id", req.RecipientID.String()), zap.Int("encrypted_key_length", len(req.EncryptedCollectionKey)), zap.String("permission_level", req.PermissionLevel)) membership := &dom_collection.CollectionMembership{ ID: gocql.TimeUUID(), CollectionID: req.CollectionID, RecipientID: req.RecipientID, RecipientEmail: req.RecipientEmail, GrantedByID: userID, EncryptedCollectionKey: req.EncryptedCollectionKey, // This should NEVER be nil for shared members PermissionLevel: req.PermissionLevel, CreatedAt: time.Now(), IsInherited: false, } // DOUBLE-CHECK: Verify the membership has the encrypted key before proceeding if len(membership.EncryptedCollectionKey) == 0 { svc.logger.Error("CRITICAL: Membership created without encrypted collection key", zap.String("collection_id", req.CollectionID.String()), zap.String("recipient_id", req.RecipientID.String()), zap.String("membership_id", membership.ID.String())) return nil, httperror.NewInternalServerError("Failed to create membership with encrypted key") } svc.logger.Info("membership created successfully with encrypted key", zap.String("collection_id", req.CollectionID.String()), zap.String("recipient_id", req.RecipientID.String()), zap.String("membership_id", membership.ID.String()), zap.Int("encrypted_key_length", len(membership.EncryptedCollectionKey))) // // SAGA: Initialize distributed transaction manager // saga := transaction.NewSaga("share-collection", svc.logger) // // STEP 7: Add membership to collection // var membershipsCreated int = 1 if req.ShareWithDescendants { // Add member to collection and all descendants err = svc.repo.AddMemberToHierarchy(ctx, req.CollectionID, membership) if err != nil { svc.logger.Error("Failed to add member to collection hierarchy", zap.Any("error", err), zap.Any("collection_id", req.CollectionID), zap.Any("recipient_id", req.RecipientID)) saga.Rollback(ctx) // Rollback any previous operations return nil, err } // SAGA: Register compensation for hierarchical membership addition // IMPORTANT: Capture by value for closure collectionIDCaptured := req.CollectionID recipientIDCaptured := req.RecipientID saga.AddCompensation(func(ctx context.Context) error { svc.logger.Warn("SAGA compensation: removing member from collection hierarchy", zap.String("collection_id", collectionIDCaptured.String()), zap.String("recipient_id", recipientIDCaptured.String())) return svc.repo.RemoveMemberFromHierarchy(ctx, collectionIDCaptured, recipientIDCaptured) }) // Get the number of descendants to report how many memberships were created descendants, err := svc.repo.FindDescendants(ctx, req.CollectionID) if err == nil { membershipsCreated += len(descendants) } } else { // Add member just to this collection err = svc.repo.AddMember(ctx, req.CollectionID, membership) if err != nil { svc.logger.Error("Failed to add member to collection", zap.Any("error", err), zap.Any("collection_id", req.CollectionID), zap.Any("recipient_id", req.RecipientID)) saga.Rollback(ctx) // Rollback any previous operations return nil, err } // SAGA: Register compensation for single membership addition // IMPORTANT: Capture by value for closure collectionIDCaptured := req.CollectionID recipientIDCaptured := req.RecipientID saga.AddCompensation(func(ctx context.Context) error { svc.logger.Warn("SAGA compensation: removing member from collection", zap.String("collection_id", collectionIDCaptured.String()), zap.String("recipient_id", recipientIDCaptured.String())) return svc.repo.RemoveMember(ctx, collectionIDCaptured, recipientIDCaptured) }) } svc.logger.Info("Collection shared successfully", zap.Any("collection_id", req.CollectionID), zap.Any("recipient_id", req.RecipientID), zap.Any("granted_by", userID), zap.String("permission_level", req.PermissionLevel), zap.Bool("shared_with_descendants", req.ShareWithDescendants), zap.Int("memberships_created", membershipsCreated)) // // STEP 8: Send email notification to recipient (best effort) // go svc.sendShareNotificationEmail(ctx, req.RecipientID, req.RecipientEmail) return &ShareCollectionResponseDTO{ Success: true, Message: "Collection shared successfully", MembershipsCreated: membershipsCreated, }, nil } // sendShareNotificationEmail sends a notification email to the recipient about a shared collection. // This is a best-effort operation - failures are logged but don't affect the share operation. // Note: This function creates its own background context since it runs in a goroutine after the // HTTP request context may be canceled. func (svc *shareCollectionServiceImpl) sendShareNotificationEmail(_ context.Context, recipientID gocql.UUID, recipientEmail string) { // Create a new background context with timeout for the async email operation // We don't use the request context because it gets canceled when the response is sent ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // Get recipient user to check notification preferences recipient, err := svc.userGetByIDUC.Execute(ctx, recipientID) if err != nil { svc.logger.Warn("Failed to get recipient for email notification", zap.Error(err), zap.String("recipient_id", recipientID.String())) return } if recipient == nil { svc.logger.Warn("Recipient not found for email notification", zap.String("recipient_id", recipientID.String())) return } // Check if recipient has disabled share notifications // Default to true (enabled) if not set if recipient.ProfileData != nil && recipient.ProfileData.ShareNotificationsEnabled != nil && !*recipient.ProfileData.ShareNotificationsEnabled { svc.logger.Debug("Recipient has disabled share notifications", zap.String("recipient_id", recipientID.String()), zap.String("recipient_email", validation.MaskEmail(recipientEmail))) return } // Build email content subject := "You have a new shared collection on MapleFile" sender := svc.emailer.GetSenderEmail() frontendURL := svc.emailer.GetFrontendDomainName() htmlContent := fmt.Sprintf(`

Hello,

Someone has shared a collection with you on MapleFile.

Log in to view it


You can disable these notifications in your profile settings.

`, frontendURL) // Send the email if err := svc.emailer.Send(ctx, sender, subject, recipientEmail, htmlContent); err != nil { svc.logger.Warn("Failed to send share notification email", zap.Error(err), zap.String("recipient_email", validation.MaskEmail(recipientEmail))) return } svc.logger.Debug("Share notification email sent", zap.String("recipient_email", validation.MaskEmail(recipientEmail))) }