Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
336
cloud/maplefile-backend/internal/service/collection/create.go
Normal file
336
cloud/maplefile-backend/internal/service/collection/create.go
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue