406 lines
16 KiB
Go
406 lines
16 KiB
Go
// 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(`
|
|
<html>
|
|
<body>
|
|
<h2>Hello,</h2>
|
|
<p>Someone has shared a collection with you on MapleFile.</p>
|
|
<p><a href="https://%s" style="color: #4CAF50;">Log in to view it</a></p>
|
|
<br>
|
|
<p style="font-size: 12px; color: #666;">
|
|
You can disable these notifications in your profile settings.
|
|
</p>
|
|
</body>
|
|
</html>
|
|
`, 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)))
|
|
}
|