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,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
}