336 lines
17 KiB
Go
336 lines
17 KiB
Go
// monorepo/cloud/backend/internal/maplefile/service/collection/create.go
|
|
package collection
|
|
|
|
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"
|
|
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/tag"
|
|
uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user"
|
|
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
|
|
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
|
|
)
|
|
|
|
// CreateCollectionRequestDTO represents a Data Transfer Object (DTO)
|
|
// used for transferring collection (folder or album) data between the local device and the cloud server.
|
|
// This data is end-to-end encrypted (E2EE) on the local device before transmission.
|
|
// The cloud server stores this encrypted data but cannot decrypt it.
|
|
// On the local device, this data is decrypted for use and storage (not stored in this encrypted DTO format locally).
|
|
// It can represent both root collections and embedded subcollections.
|
|
type CreateCollectionRequestDTO struct {
|
|
ID gocql.UUID `bson:"_id" json:"id"`
|
|
OwnerID gocql.UUID `bson:"owner_id" json:"owner_id"`
|
|
EncryptedName string `bson:"encrypted_name" json:"encrypted_name"`
|
|
EncryptedCustomIcon string `bson:"encrypted_custom_icon" json:"encrypted_custom_icon"`
|
|
CollectionType string `bson:"collection_type" json:"collection_type"`
|
|
EncryptedCollectionKey *crypto.EncryptedCollectionKey `bson:"encrypted_collection_key" json:"encrypted_collection_key"`
|
|
Members []*CollectionMembershipDTO `bson:"members" json:"members"`
|
|
ParentID gocql.UUID `bson:"parent_id,omitempty" json:"parent_id,omitempty"`
|
|
AncestorIDs []gocql.UUID `bson:"ancestor_ids,omitempty" json:"ancestor_ids,omitempty"`
|
|
TagIDs []gocql.UUID `bson:"tag_ids,omitempty" json:"tag_ids,omitempty"` // Tag IDs to embed in collection
|
|
Children []*CreateCollectionRequestDTO `bson:"children,omitempty" json:"children,omitempty"`
|
|
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
|
CreatedByUserID gocql.UUID `json:"created_by_user_id"`
|
|
ModifiedAt time.Time `bson:"modified_at" json:"modified_at"`
|
|
ModifiedByUserID gocql.UUID `json:"modified_by_user_id"`
|
|
}
|
|
|
|
type CollectionMembershipDTO struct {
|
|
ID gocql.UUID `bson:"_id" json:"id"`
|
|
CollectionID gocql.UUID `bson:"collection_id" json:"collection_id"`
|
|
RecipientID gocql.UUID `bson:"recipient_id" json:"recipient_id"`
|
|
RecipientEmail string `bson:"recipient_email" json:"recipient_email"`
|
|
GrantedByID gocql.UUID `bson:"granted_by_id" json:"granted_by_id"`
|
|
EncryptedCollectionKey []byte `bson:"encrypted_collection_key" json:"encrypted_collection_key"`
|
|
PermissionLevel string `bson:"permission_level" json:"permission_level"`
|
|
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
|
IsInherited bool `bson:"is_inherited" json:"is_inherited"`
|
|
InheritedFromID gocql.UUID `bson:"inherited_from_id,omitempty" json:"inherited_from_id,omitempty"`
|
|
}
|
|
|
|
type CollectionResponseDTO struct {
|
|
ID gocql.UUID `json:"id"`
|
|
OwnerID gocql.UUID `json:"owner_id"`
|
|
OwnerEmail string `json:"owner_email"`
|
|
EncryptedName string `json:"encrypted_name"`
|
|
EncryptedCustomIcon string `json:"encrypted_custom_icon,omitempty"`
|
|
CollectionType string `json:"collection_type"`
|
|
ParentID gocql.UUID `json:"parent_id,omitempty"`
|
|
AncestorIDs []gocql.UUID `json:"ancestor_ids,omitempty"`
|
|
Tags []tag.EmbeddedTag `json:"tags,omitempty"`
|
|
EncryptedCollectionKey *crypto.EncryptedCollectionKey `json:"encrypted_collection_key,omitempty"`
|
|
Children []*CollectionResponseDTO `json:"children,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
ModifiedAt time.Time `json:"modified_at"`
|
|
Members []MembershipResponseDTO `json:"members"`
|
|
FileCount int `json:"file_count"`
|
|
Version uint64 `json:"version"`
|
|
}
|
|
|
|
type MembershipResponseDTO struct {
|
|
ID gocql.UUID `bson:"_id" json:"id"`
|
|
CollectionID gocql.UUID `bson:"collection_id" json:"collection_id"` // ID of the collection (redundant but helpful for queries)
|
|
RecipientID gocql.UUID `bson:"recipient_id" json:"recipient_id"` // User receiving access
|
|
RecipientEmail string `bson:"recipient_email" json:"recipient_email"` // Email for display purposes
|
|
GrantedByID gocql.UUID `bson:"granted_by_id" json:"granted_by_id"` // User who shared the collection
|
|
|
|
// Collection key encrypted with recipient's public key using box_seal. This matches the box_seal format which doesn't need a separate nonce.
|
|
EncryptedCollectionKey []byte `bson:"encrypted_collection_key" json:"encrypted_collection_key"`
|
|
|
|
// Access details
|
|
PermissionLevel string `bson:"permission_level" json:"permission_level"`
|
|
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
|
|
|
// Sharing origin tracking
|
|
IsInherited bool `bson:"is_inherited" json:"is_inherited"` // Tracks whether access was granted directly or inherited from a parent
|
|
InheritedFromID gocql.UUID `bson:"inherited_from_id,omitempty" json:"inherited_from_id,omitempty"` // InheritedFromID identifies which parent collection granted this access
|
|
}
|
|
|
|
type CreateCollectionService interface {
|
|
Execute(ctx context.Context, req *CreateCollectionRequestDTO) (*CollectionResponseDTO, error)
|
|
}
|
|
|
|
type createCollectionServiceImpl struct {
|
|
config *config.Configuration
|
|
logger *zap.Logger
|
|
userGetByIDUseCase uc_user.UserGetByIDUseCase
|
|
repo dom_collection.CollectionRepository
|
|
tagRepo tag.Repository
|
|
}
|
|
|
|
func NewCreateCollectionService(
|
|
config *config.Configuration,
|
|
logger *zap.Logger,
|
|
userGetByIDUseCase uc_user.UserGetByIDUseCase,
|
|
repo dom_collection.CollectionRepository,
|
|
tagRepo tag.Repository,
|
|
) CreateCollectionService {
|
|
logger = logger.Named("CreateCollectionService")
|
|
return &createCollectionServiceImpl{
|
|
config: config,
|
|
logger: logger,
|
|
userGetByIDUseCase: userGetByIDUseCase,
|
|
repo: repo,
|
|
tagRepo: tagRepo,
|
|
}
|
|
}
|
|
|
|
func (svc *createCollectionServiceImpl) Execute(ctx context.Context, req *CreateCollectionRequestDTO) (*CollectionResponseDTO, error) {
|
|
//
|
|
// STEP 1: Validation
|
|
//
|
|
if req == nil {
|
|
svc.logger.Warn("Failed validation with nil request")
|
|
return nil, httperror.NewForBadRequestWithSingleField("non_field_error", "Collection details are required")
|
|
}
|
|
|
|
e := make(map[string]string)
|
|
if req.ID.String() == "" {
|
|
e["encrypted_name"] = "Client-side generated ID is required"
|
|
}
|
|
if req.EncryptedName == "" {
|
|
e["encrypted_name"] = "Collection name is required"
|
|
}
|
|
if req.CollectionType == "" {
|
|
e["collection_type"] = "Collection type is required"
|
|
} else if req.CollectionType != dom_collection.CollectionTypeFolder && req.CollectionType != dom_collection.CollectionTypeAlbum {
|
|
e["collection_type"] = "Collection type must be either 'folder' or 'album'"
|
|
}
|
|
// Check pointer and then content
|
|
if req.EncryptedCollectionKey == nil || req.EncryptedCollectionKey.Ciphertext == nil || len(req.EncryptedCollectionKey.Ciphertext) == 0 {
|
|
e["encrypted_collection_key"] = "Encrypted collection key ciphertext is required"
|
|
}
|
|
if req.EncryptedCollectionKey == nil || req.EncryptedCollectionKey.Nonce == nil || len(req.EncryptedCollectionKey.Nonce) == 0 {
|
|
e["encrypted_collection_key"] = "Encrypted collection key nonce is required"
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
federateduser, err := svc.userGetByIDUseCase.Execute(ctx, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed getting user from database: %v", err)
|
|
}
|
|
if federateduser == nil {
|
|
return nil, fmt.Errorf("User does not exist for user id: %v", userID.String())
|
|
}
|
|
|
|
//
|
|
// STEP 3: Create collection object by mapping DTO and applying server-side logic
|
|
//
|
|
now := time.Now()
|
|
|
|
// Map all fields from the request DTO to the domain object.
|
|
// This copies client-provided values including potential ID, OwnerID, timestamps, etc.
|
|
collection := mapCollectionDTOToDomain(req, userID, now)
|
|
|
|
// Apply server-side mandatory fields/overrides for the top-level collection.
|
|
// These values are managed by the backend regardless of what the client provides in the DTO.
|
|
// This ensures data integrity and reflects the server's perspective of the creation event.
|
|
collection.ID = gocql.TimeUUID() // Always generate a new ID on the server for a new creation
|
|
collection.OwnerID = userID // The authenticated user is the authoritative owner
|
|
collection.CreatedAt = now // Server timestamp for creation
|
|
collection.ModifiedAt = now // Server timestamp for modification
|
|
collection.CreatedByUserID = userID // The authenticated user is the creator
|
|
collection.ModifiedByUserID = userID // The authenticated user is the initial modifier
|
|
collection.Version = 1 // Collection creation **always** starts mutation version at 1.
|
|
collection.State = dom_collection.CollectionStateActive // Collection creation **always** starts in active state.
|
|
|
|
// Ensure owner membership exists with Admin permissions.
|
|
// Check if the owner is already present in the members list copied from the DTO.
|
|
ownerAlreadyMember := false
|
|
for i := range collection.Members { // Iterate by index to allow modification if needed
|
|
if collection.Members[i].RecipientID == userID {
|
|
// Owner is found. Ensure they have Admin permission and correct granted_by/is_inherited status.
|
|
collection.Members[i].RecipientEmail = federateduser.Email
|
|
collection.Members[i].PermissionLevel = dom_collection.CollectionPermissionAdmin
|
|
collection.Members[i].GrantedByID = userID
|
|
collection.Members[i].IsInherited = false
|
|
// NOTE: We intentionally do NOT set EncryptedCollectionKey here for the owner
|
|
// The owner accesses the collection key through their master key, not through
|
|
// the encrypted member key. This is validated in the repository layer.
|
|
collection.Members[i].EncryptedCollectionKey = nil
|
|
// Optionally update membership CreatedAt here if server should control it, otherwise keep DTO value.
|
|
// collection.Members[i].CreatedAt = now
|
|
ownerAlreadyMember = true
|
|
svc.logger.Debug("✅ Owner membership updated with Admin permissions (no encrypted key needed)")
|
|
break
|
|
}
|
|
}
|
|
|
|
// If owner is not in the members list, add their mandatory membership.
|
|
if !ownerAlreadyMember {
|
|
svc.logger.Debug("☑️ Owner is not in the members list, add their mandatory membership now")
|
|
ownerMembership := dom_collection.CollectionMembership{
|
|
ID: gocql.TimeUUID(), // Unique ID for this specific membership record
|
|
RecipientID: userID,
|
|
RecipientEmail: federateduser.Email,
|
|
CollectionID: collection.ID, // Link to the newly created collection ID
|
|
PermissionLevel: dom_collection.CollectionPermissionAdmin, // Owner must have Admin
|
|
GrantedByID: userID, // Owner implicitly grants themselves permission
|
|
IsInherited: false, // Owner membership is never inherited
|
|
CreatedAt: now, // Server timestamp for membership creation
|
|
// NOTE: EncryptedCollectionKey is intentionally nil for owner memberships
|
|
// The owner has access to the collection key through their master key
|
|
// This is validated in the repository layer which allows nil encrypted keys for owners
|
|
EncryptedCollectionKey: nil,
|
|
// InheritedFromID is nil for direct membership.
|
|
}
|
|
// Append the mandatory owner membership. If req.Members was empty, this initializes the slice.
|
|
collection.Members = append(collection.Members, ownerMembership)
|
|
|
|
svc.logger.Debug("✅ Owner membership added with Admin permissions (no encrypted key needed)")
|
|
}
|
|
|
|
svc.logger.Debug("🔍 Collection debugging info",
|
|
zap.String("collectionID", collection.ID.String()),
|
|
zap.String("collectionOwnerID", collection.OwnerID.String()),
|
|
zap.String("currentUserID", userID.String()),
|
|
zap.Int("totalMembers", len(collection.Members)),
|
|
zap.String("encryptedName", collection.EncryptedName))
|
|
|
|
for i, memberDTO := range collection.Members {
|
|
isOwner := memberDTO.RecipientID == collection.OwnerID
|
|
svc.logger.Debug("🔍 Cloud collection member DTO",
|
|
zap.Int("memberIndex", i),
|
|
zap.String("memberID", memberDTO.ID.String()),
|
|
zap.String("recipientID", memberDTO.RecipientID.String()),
|
|
zap.String("recipientEmail", validation.MaskEmail(memberDTO.RecipientEmail)),
|
|
zap.String("permissionLevel", memberDTO.PermissionLevel),
|
|
zap.Bool("isInherited", memberDTO.IsInherited),
|
|
zap.Bool("isOwner", isOwner),
|
|
zap.Int("encryptedKeyLength", len(memberDTO.EncryptedCollectionKey)))
|
|
}
|
|
|
|
// ENHANCED DEBUGGING: Log current user info for comparison
|
|
svc.logger.Debug("🔍 Current user info for comparison",
|
|
zap.String("currentUserID", federateduser.ID.String()),
|
|
zap.String("currentUserEmail", validation.MaskEmail(federateduser.Email)),
|
|
zap.String("currentUserName", federateduser.Name))
|
|
|
|
// Note: Fields like ParentID, AncestorIDs, EncryptedCollectionKey,
|
|
// EncryptedName, CollectionType, and recursively mapped Children are copied directly from the DTO
|
|
// by the mapCollectionDTOToDomain function before server overrides. This fulfills the
|
|
// prompt's requirement to copy these fields from the DTO.
|
|
|
|
//
|
|
// STEP 3.5: Look up and embed tags if TagIDs were provided
|
|
//
|
|
if len(req.TagIDs) > 0 {
|
|
svc.logger.Debug("🏷️ Looking up tags to embed in collection",
|
|
zap.Int("tagCount", len(req.TagIDs)))
|
|
|
|
var embeddedTags []tag.EmbeddedTag
|
|
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
|
|
}
|
|
if tagObj == nil {
|
|
svc.logger.Warn("Tag not found for embedding, skipping",
|
|
zap.String("tagID", tagID.String()))
|
|
continue
|
|
}
|
|
// Convert Tag to EmbeddedTag
|
|
embedded := tagObj.ToEmbeddedTag()
|
|
if embedded != nil {
|
|
embeddedTags = append(embeddedTags, *embedded)
|
|
svc.logger.Debug("🏷️ Tag embedded successfully",
|
|
zap.String("tagID", tagID.String()))
|
|
}
|
|
}
|
|
collection.Tags = embeddedTags
|
|
svc.logger.Debug("🏷️ Tags embedded in collection",
|
|
zap.Int("embeddedCount", len(embeddedTags)))
|
|
}
|
|
|
|
//
|
|
// STEP 4: Create collection in repository
|
|
//
|
|
|
|
if err := svc.repo.Create(ctx, collection); err != nil {
|
|
svc.logger.Error("Failed to create collection",
|
|
zap.Any("error", err),
|
|
zap.Any("owner_id", collection.OwnerID),
|
|
zap.String("name", collection.EncryptedName))
|
|
return nil, err
|
|
}
|
|
|
|
//
|
|
// STEP 5: Map domain model to response DTO
|
|
//
|
|
// The mapCollectionToDTO helper is used here to convert the created domain object back
|
|
// into the response DTO format, potentially excluding sensitive fields like keys
|
|
// or specific membership details not meant for the general response.
|
|
response := mapCollectionToDTO(collection, 0, federateduser.Email)
|
|
|
|
svc.logger.Debug("Collection created successfully",
|
|
zap.Any("collection_id", collection.ID),
|
|
zap.Any("owner_id", collection.OwnerID))
|
|
|
|
return response, nil
|
|
}
|