Initial commit: Open sourcing all of the Maple Open Technologies code.

This commit is contained in:
Bartlomiej Mika 2025-12-02 14:33:08 -05:00
commit 755d54a99d
2010 changed files with 448675 additions and 0 deletions

View file

@ -0,0 +1,406 @@
// 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)))
}