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,135 @@
// monorepo/cloud/backend/internal/maplefile/service/collection/archive.go
package collection
import (
"context"
"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"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type ArchiveCollectionRequestDTO struct {
ID gocql.UUID `json:"id"`
}
type ArchiveCollectionResponseDTO struct {
Success bool `json:"success"`
Message string `json:"message"`
}
type ArchiveCollectionService interface {
Execute(ctx context.Context, req *ArchiveCollectionRequestDTO) (*ArchiveCollectionResponseDTO, error)
}
type archiveCollectionServiceImpl struct {
config *config.Configuration
logger *zap.Logger
repo dom_collection.CollectionRepository
}
func NewArchiveCollectionService(
config *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
) ArchiveCollectionService {
logger = logger.Named("ArchiveCollectionService")
return &archiveCollectionServiceImpl{
config: config,
logger: logger,
repo: repo,
}
}
func (svc *archiveCollectionServiceImpl) Execute(ctx context.Context, req *ArchiveCollectionRequestDTO) (*ArchiveCollectionResponseDTO, error) {
//
// STEP 1: Validation
//
if req == nil {
svc.logger.Warn("Failed validation with nil request")
return nil, httperror.NewForBadRequestWithSingleField("non_field_error", "Collection ID is required")
}
if req.ID.String() == "" {
svc.logger.Warn("Empty collection ID")
return nil, httperror.NewForBadRequestWithSingleField("id", "Collection ID is required")
}
//
// 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")
}
//
// STEP 3: Retrieve existing collection (including non-active states for archiving)
//
collection, err := svc.repo.Get(ctx, req.ID)
if err != nil {
svc.logger.Error("Failed to get collection",
zap.Any("error", err),
zap.Any("collection_id", req.ID))
return nil, err
}
if collection == nil {
svc.logger.Debug("Collection not found",
zap.Any("collection_id", req.ID))
return nil, httperror.NewForNotFoundWithSingleField("message", "Collection not found")
}
//
// STEP 4: Check if user has rights to archive this collection
//
if collection.OwnerID != userID {
svc.logger.Warn("Unauthorized collection archive attempt",
zap.Any("user_id", userID),
zap.Any("collection_id", req.ID))
return nil, httperror.NewForForbiddenWithSingleField("message", "Only the collection owner can archive a collection")
}
//
// STEP 5: Validate state transition
//
err = dom_collection.IsValidStateTransition(collection.State, dom_collection.CollectionStateArchived)
if err != nil {
svc.logger.Warn("Invalid state transition for collection archive",
zap.Any("collection_id", req.ID),
zap.String("current_state", collection.State),
zap.String("target_state", dom_collection.CollectionStateArchived),
zap.Error(err))
return nil, httperror.NewForBadRequestWithSingleField("state", err.Error())
}
//
// STEP 6: Archive the collection
//
collection.State = dom_collection.CollectionStateArchived
collection.Version++ // Update mutation means we increment version.
collection.ModifiedAt = time.Now()
collection.ModifiedByUserID = userID
err = svc.repo.Update(ctx, collection)
if err != nil {
svc.logger.Error("Failed to archive collection",
zap.Any("error", err),
zap.Any("collection_id", req.ID))
return nil, err
}
svc.logger.Info("Collection archived successfully",
zap.Any("collection_id", req.ID),
zap.Any("user_id", userID))
return &ArchiveCollectionResponseDTO{
Success: true,
Message: "Collection archived successfully",
}, nil
}

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
}

View file

@ -0,0 +1,113 @@
// monorepo/cloud/backend/internal/maplefile/service/collection/find_by_parent.go
package collection
import (
"context"
"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"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type FindByParentRequestDTO struct {
ParentID gocql.UUID `json:"parent_id"`
}
type FindCollectionsByParentService interface {
Execute(ctx context.Context, req *FindByParentRequestDTO) (*CollectionsResponseDTO, error)
}
type findCollectionsByParentServiceImpl struct {
config *config.Configuration
logger *zap.Logger
repo dom_collection.CollectionRepository
}
func NewFindCollectionsByParentService(
config *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
) FindCollectionsByParentService {
logger = logger.Named("FindCollectionsByParentService")
return &findCollectionsByParentServiceImpl{
config: config,
logger: logger,
repo: repo,
}
}
func (svc *findCollectionsByParentServiceImpl) Execute(ctx context.Context, req *FindByParentRequestDTO) (*CollectionsResponseDTO, error) {
//
// STEP 1: Validation
//
if req == nil {
svc.logger.Warn("Failed validation with nil request")
return nil, httperror.NewForBadRequestWithSingleField("non_field_error", "Parent ID is required")
}
if req.ParentID.String() == "" {
svc.logger.Warn("Empty parent ID provided")
return nil, httperror.NewForBadRequestWithSingleField("parent_id", "Parent ID is required")
}
//
// 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")
}
//
// STEP 3: Check if user has access to the parent collection
//
hasAccess, err := svc.repo.CheckAccess(ctx, req.ParentID, userID, dom_collection.CollectionPermissionReadOnly)
if err != nil {
svc.logger.Error("Failed to check access",
zap.Any("error", err),
zap.Any("parent_id", req.ParentID),
zap.Any("user_id", userID))
return nil, err
}
if !hasAccess {
svc.logger.Warn("Unauthorized parent collection access attempt",
zap.Any("user_id", userID),
zap.Any("parent_id", req.ParentID))
return nil, httperror.NewForForbiddenWithSingleField("message", "You don't have access to this parent collection")
}
//
// STEP 4: Find collections by parent
//
collections, err := svc.repo.FindByParent(ctx, req.ParentID)
if err != nil {
svc.logger.Error("Failed to find collections by parent",
zap.Any("error", err),
zap.Any("parent_id", req.ParentID))
return nil, err
}
//
// STEP 5: Map domain models to response DTOs
//
response := &CollectionsResponseDTO{
Collections: make([]*CollectionResponseDTO, len(collections)),
}
for i, collection := range collections {
ownerEmail := getOwnerEmailFromMembers(collection)
response.Collections[i] = mapCollectionToDTO(collection, 0, ownerEmail)
}
svc.logger.Debug("Found collections by parent",
zap.Int("count", len(collections)),
zap.Any("parent_id", req.ParentID))
return response, nil
}

View file

@ -0,0 +1,96 @@
// monorepo/cloud/backend/internal/maplefile/service/collection/find_root_collections.go
package collection
import (
"context"
"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"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
)
type FindRootCollectionsService interface {
Execute(ctx context.Context) (*CollectionsResponseDTO, error)
}
type findRootCollectionsServiceImpl struct {
config *config.Configuration
logger *zap.Logger
repo dom_collection.CollectionRepository
}
func NewFindRootCollectionsService(
config *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
) FindRootCollectionsService {
logger = logger.Named("FindRootCollectionsService")
return &findRootCollectionsServiceImpl{
config: config,
logger: logger,
repo: repo,
}
}
func (svc *findRootCollectionsServiceImpl) Execute(ctx context.Context) (*CollectionsResponseDTO, error) {
//
// STEP 1: 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, nil
}
//
// STEP 2: Find root collections for the user
//
collections, err := svc.repo.FindRootCollections(ctx, userID)
if err != nil {
svc.logger.Error("Failed to find root collections",
zap.Any("error", err),
zap.Any("user_id", userID))
return nil, err
}
//
// STEP 3: Filter collections based on permission levels and map to DTOs
//
// Filter out collections where the user doesn't have at least read_only permission
collectionsWithPermission := make([]*CollectionResponseDTO, 0, len(collections))
for _, collection := range collections {
// Check if user has at least read_only permission for this collection
hasAccess, err := svc.repo.CheckAccess(ctx, collection.ID, userID, dom_collection.CollectionPermissionReadOnly)
if err != nil {
svc.logger.Warn("Failed to check collection access for root collection, skipping",
zap.Error(err),
zap.Any("collection_id", collection.ID),
zap.Any("user_id", userID))
continue // Skip collections where we can't verify access
}
if hasAccess {
ownerEmail := getOwnerEmailFromMembers(collection)
collectionsWithPermission = append(collectionsWithPermission,
mapCollectionToDTO(collection, 0, ownerEmail))
} else {
svc.logger.Debug("User lacks permission for root collection, filtering out",
zap.Any("collection_id", collection.ID),
zap.Any("user_id", userID))
}
}
response := &CollectionsResponseDTO{
Collections: collectionsWithPermission,
}
svc.logger.Debug("Found root collections",
zap.Int("count", len(collections)),
zap.Any("user_id", userID))
return response, nil
}

View file

@ -0,0 +1,199 @@
// monorepo/cloud/backend/internal/maplefile/service/collection/get.go
package collection
import (
"context"
"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"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
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/ratelimit"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
type GetCollectionService interface {
Execute(ctx context.Context, collectionID gocql.UUID) (*CollectionResponseDTO, error)
}
type getCollectionServiceImpl struct {
config *config.Configuration
logger *zap.Logger
repo dom_collection.CollectionRepository
userGetByIDUseCase uc_user.UserGetByIDUseCase
authFailureRateLimiter ratelimit.AuthFailureRateLimiter
}
func NewGetCollectionService(
config *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
userGetByIDUseCase uc_user.UserGetByIDUseCase,
authFailureRateLimiter ratelimit.AuthFailureRateLimiter,
) GetCollectionService {
logger = logger.Named("GetCollectionService")
return &getCollectionServiceImpl{
config: config,
logger: logger,
repo: repo,
userGetByIDUseCase: userGetByIDUseCase,
authFailureRateLimiter: authFailureRateLimiter,
}
}
func (svc *getCollectionServiceImpl) Execute(ctx context.Context, collectionID gocql.UUID) (*CollectionResponseDTO, error) {
//
// STEP 1: Validation
//
if collectionID.String() == "" {
svc.logger.Warn("Empty collection ID provided")
return nil, httperror.NewForBadRequestWithSingleField("collection_id", "Collection ID is required")
}
//
// 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")
}
//
// STEP 3: Get collection from repository
//
collection, err := svc.repo.Get(ctx, collectionID)
if err != nil {
svc.logger.Error("Failed to get collection",
zap.Any("error", err),
zap.Any("collection_id", collectionID))
return nil, err
}
if collection == nil {
svc.logger.Debug("Collection not found",
zap.Any("collection_id", collectionID))
return nil, httperror.NewForNotFoundWithSingleField("message", "Collection not found")
}
//
// STEP 4: Check rate limiting for authorization failures
//
// Check if user has exceeded authorization failure limits before checking access
// This helps prevent privilege escalation attempts
if svc.authFailureRateLimiter != nil {
allowed, remainingAttempts, resetTime, err := svc.authFailureRateLimiter.CheckAuthFailure(
ctx,
userID.String(),
collectionID.String(),
"collection:get")
if err != nil {
// Log error but continue - fail open for availability
svc.logger.Error("Failed to check auth failure rate limit",
zap.Error(err),
zap.Any("user_id", userID),
zap.Any("collection_id", collectionID))
} else if !allowed {
svc.logger.Warn("User blocked due to excessive authorization failures",
zap.Any("user_id", userID),
zap.Any("collection_id", collectionID),
zap.Int("remaining_attempts", remainingAttempts),
zap.Time("reset_time", resetTime))
return nil, httperror.NewTooManyRequestsError(
"Too many authorization failures. Please try again later")
}
}
//
// STEP 5: Check if the user has access to this collection
//
// Use CheckAccess to verify both access and permission level
// For GET operations, read_only permission is sufficient
hasAccess, err := svc.repo.CheckAccess(ctx, collectionID, userID, dom_collection.CollectionPermissionReadOnly)
if err != nil {
svc.logger.Error("Failed to check collection access",
zap.Error(err),
zap.Any("user_id", userID),
zap.Any("collection_id", collectionID))
return nil, httperror.NewInternalServerError("Failed to check collection access")
}
if !hasAccess {
// Record authorization failure for rate limiting
if svc.authFailureRateLimiter != nil {
if err := svc.authFailureRateLimiter.RecordAuthFailure(
ctx,
userID.String(),
collectionID.String(),
"collection:get",
"insufficient_permission"); err != nil {
svc.logger.Error("Failed to record auth failure",
zap.Error(err),
zap.Any("user_id", userID),
zap.Any("collection_id", collectionID))
}
}
svc.logger.Warn("Unauthorized collection access attempt",
zap.Any("user_id", userID),
zap.Any("collection_id", collectionID),
zap.String("required_permission", dom_collection.CollectionPermissionReadOnly))
return nil, httperror.NewForForbiddenWithSingleField("message", "You don't have access to this collection")
}
// Record successful authorization
if svc.authFailureRateLimiter != nil {
if err := svc.authFailureRateLimiter.RecordAuthSuccess(
ctx,
userID.String(),
collectionID.String(),
"collection:get"); err != nil {
svc.logger.Debug("Failed to record auth success",
zap.Error(err),
zap.Any("user_id", userID),
zap.Any("collection_id", collectionID))
}
}
//
// STEP 5: Get owner's email
//
var ownerEmail string
svc.logger.Info("🔍 GetCollectionService: Looking up owner email",
zap.String("collection_id", collectionID.String()),
zap.String("owner_id", collection.OwnerID.String()))
owner, err := svc.userGetByIDUseCase.Execute(ctx, collection.OwnerID)
if err != nil {
svc.logger.Warn("Failed to get owner email, continuing without it",
zap.Any("error", err),
zap.Any("owner_id", collection.OwnerID))
// Don't fail the request, just continue without the owner email
} else if owner != nil {
ownerEmail = owner.Email
svc.logger.Info("🔍 GetCollectionService: Found owner email",
zap.String("owner_email", validation.MaskEmail(ownerEmail)))
} else {
svc.logger.Warn("🔍 GetCollectionService: Owner user not found",
zap.String("owner_id", collection.OwnerID.String()))
}
//
// STEP 6: Map domain model to response DTO
//
// Note: We pass collection.FileCount (not 0) to include the actual file count
// in the response. This field is maintained by IncrementFileCount/DecrementFileCount
// calls when files are added/removed from the collection.
//
svc.logger.Info("🔍 GetCollectionService: Mapping to DTO with owner_email",
zap.String("owner_email", validation.MaskEmail(ownerEmail)))
response := mapCollectionToDTO(collection, int(collection.FileCount), ownerEmail)
return response, nil
}

View file

@ -0,0 +1,148 @@
// monorepo/cloud/backend/internal/maplefile/service/collection/get_filtered.go
package collection
import (
"context"
"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"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type GetFilteredCollectionsRequestDTO struct {
IncludeOwned bool `json:"include_owned"`
IncludeShared bool `json:"include_shared"`
}
type FilteredCollectionsResponseDTO struct {
OwnedCollections []*CollectionResponseDTO `json:"owned_collections"`
SharedCollections []*CollectionResponseDTO `json:"shared_collections"`
TotalCount int `json:"total_count"`
}
type GetFilteredCollectionsService interface {
Execute(ctx context.Context, req *GetFilteredCollectionsRequestDTO) (*FilteredCollectionsResponseDTO, error)
}
type getFilteredCollectionsServiceImpl struct {
config *config.Configuration
logger *zap.Logger
repo dom_collection.CollectionRepository
}
func NewGetFilteredCollectionsService(
config *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
) GetFilteredCollectionsService {
logger = logger.Named("GetFilteredCollectionsService")
return &getFilteredCollectionsServiceImpl{
config: config,
logger: logger,
repo: repo,
}
}
func (svc *getFilteredCollectionsServiceImpl) Execute(ctx context.Context, req *GetFilteredCollectionsRequestDTO) (*FilteredCollectionsResponseDTO, error) {
//
// STEP 1: Validation
//
if req == nil {
svc.logger.Warn("Failed validation with nil request")
return nil, httperror.NewForBadRequestWithSingleField("non_field_error", "Request details are required")
}
e := make(map[string]string)
if !req.IncludeOwned && !req.IncludeShared {
e["filter_options"] = "At least one filter option (include_owned or include_shared) must be enabled"
}
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")
}
//
// STEP 3: Create filter options
//
filterOptions := dom_collection.CollectionFilterOptions{
IncludeOwned: req.IncludeOwned,
IncludeShared: req.IncludeShared,
UserID: userID,
}
//
// STEP 4: Get filtered collections from repository
//
result, err := svc.repo.GetCollectionsWithFilter(ctx, filterOptions)
if err != nil {
svc.logger.Error("Failed to get filtered collections",
zap.Any("error", err),
zap.Any("user_id", userID),
zap.Any("filter_options", filterOptions))
return nil, err
}
//
// STEP 5: Filter collections based on permission levels and map to DTOs
//
// For owned collections, the owner always has admin permission
ownedCollectionsWithPermission := make([]*CollectionResponseDTO, 0, len(result.OwnedCollections))
for _, collection := range result.OwnedCollections {
// Owner always has full access, no need to check permission
ownerEmail := getOwnerEmailFromMembers(collection)
ownedCollectionsWithPermission = append(ownedCollectionsWithPermission,
mapCollectionToDTO(collection, int(collection.FileCount), ownerEmail))
}
// For shared collections, verify the user has at least read_only permission
sharedCollectionsWithPermission := make([]*CollectionResponseDTO, 0, len(result.SharedCollections))
for _, collection := range result.SharedCollections {
// Check if user has at least read_only permission for this shared collection
hasAccess, err := svc.repo.CheckAccess(ctx, collection.ID, userID, dom_collection.CollectionPermissionReadOnly)
if err != nil {
svc.logger.Warn("Failed to check collection access, skipping collection",
zap.Error(err),
zap.Any("collection_id", collection.ID),
zap.Any("user_id", userID))
continue // Skip collections where we can't verify access
}
if hasAccess {
ownerEmail := getOwnerEmailFromMembers(collection)
sharedCollectionsWithPermission = append(sharedCollectionsWithPermission,
mapCollectionToDTO(collection, int(collection.FileCount), ownerEmail))
} else {
svc.logger.Debug("User lacks permission for shared collection, filtering out",
zap.Any("collection_id", collection.ID),
zap.Any("user_id", userID))
}
}
response := &FilteredCollectionsResponseDTO{
OwnedCollections: ownedCollectionsWithPermission,
SharedCollections: sharedCollectionsWithPermission,
TotalCount: len(ownedCollectionsWithPermission) + len(sharedCollectionsWithPermission),
}
svc.logger.Debug("Retrieved filtered collections successfully",
zap.Int("owned_count", len(response.OwnedCollections)),
zap.Int("shared_count", len(response.SharedCollections)),
zap.Int("total_count", response.TotalCount),
zap.Any("user_id", userID))
return response, nil
}

View file

@ -0,0 +1,94 @@
// monorepo/cloud/backend/internal/maplefile/service/collection/get.go
package collection
import (
"context"
"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"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
uc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type GetCollectionSyncDataService interface {
Execute(ctx context.Context, userID gocql.UUID, cursor *dom_collection.CollectionSyncCursor, limit int64, accessType string) (*dom_collection.CollectionSyncResponse, error)
}
type getCollectionSyncDataServiceImpl struct {
config *config.Configuration
logger *zap.Logger
getCollectionSyncDataUseCase uc_collection.GetCollectionSyncDataUseCase
}
func NewGetCollectionSyncDataService(
config *config.Configuration,
logger *zap.Logger,
getCollectionSyncDataUseCase uc_collection.GetCollectionSyncDataUseCase,
) GetCollectionSyncDataService {
logger = logger.Named("GetCollectionSyncDataService")
return &getCollectionSyncDataServiceImpl{
config: config,
logger: logger,
getCollectionSyncDataUseCase: getCollectionSyncDataUseCase,
}
}
func (svc *getCollectionSyncDataServiceImpl) Execute(ctx context.Context, userID gocql.UUID, cursor *dom_collection.CollectionSyncCursor, limit int64, accessType string) (*dom_collection.CollectionSyncResponse, error) {
//
// STEP 1: Validation
//
if userID.String() == "" {
svc.logger.Warn("Empty user ID provided")
return nil, httperror.NewForBadRequestWithSingleField("user_id", "User ID is required")
}
//
// STEP 2: Verify user ID from context matches the parameter
//
sessionUserID, 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")
}
// Ensure the user can only get their own sync data
if sessionUserID != userID {
svc.logger.Warn("User trying to access another user's sync data",
zap.Any("session_user_id", sessionUserID),
zap.Any("requested_user_id", userID))
return nil, httperror.NewForForbiddenWithSingleField("message", "Cannot access other user's sync data")
}
//
// STEP 3: Get sync data based on access type
//
// Note: The use case will handle filtering collections based on the user's access
// It returns only collections the user owns or has been granted access to
syncData, err := svc.getCollectionSyncDataUseCase.Execute(ctx, userID, cursor, limit, accessType)
if err != nil {
svc.logger.Error("Failed to get collection sync data",
zap.Any("error", err),
zap.Any("user_id", userID))
return nil, err
}
if syncData == nil {
svc.logger.Debug("Collection sync data not found",
zap.Any("user_id", userID))
return nil, httperror.NewForNotFoundWithSingleField("message", "Collection sync results not found")
}
// Note: Access control is already handled by the use case
// It only returns collections the user has access to
// No need to check individual collection access here
svc.logger.Debug("Collection sync data successfully retrieved",
zap.Any("user_id", userID),
zap.Int("collections_returned", len(syncData.Collections)))
return syncData, nil
}

View file

@ -0,0 +1,106 @@
// monorepo/cloud/backend/internal/maplefile/service/collection/list_by_user.go
package collection
import (
"context"
"errors"
"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"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
)
type CollectionsResponseDTO struct {
Collections []*CollectionResponseDTO `json:"collections"`
}
type ListUserCollectionsService interface {
Execute(ctx context.Context) (*CollectionsResponseDTO, error)
}
type listUserCollectionsServiceImpl struct {
config *config.Configuration
logger *zap.Logger
repo dom_collection.CollectionRepository
fileRepo dom_file.FileMetadataRepository
}
func NewListUserCollectionsService(
config *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
fileRepo dom_file.FileMetadataRepository,
) ListUserCollectionsService {
logger = logger.Named("ListUserCollectionsService")
return &listUserCollectionsServiceImpl{
config: config,
logger: logger,
repo: repo,
fileRepo: fileRepo,
}
}
func (svc *listUserCollectionsServiceImpl) Execute(ctx context.Context) (*CollectionsResponseDTO, error) {
//
// STEP 1: 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, errors.New("user ID not found in context")
}
//
// STEP 2: Get user's owned collections from repository
//
filterResult, err := svc.repo.GetCollectionsWithFilter(ctx, dom_collection.CollectionFilterOptions{
UserID: userID,
IncludeOwned: true,
IncludeShared: false, // Only include owned collections for "My Folders"
})
if err != nil {
svc.logger.Error("Failed to get user collections",
zap.Any("error", err),
zap.Any("user_id", userID))
return nil, err
}
collections := filterResult.GetAllCollections()
//
// STEP 3: Deduplicate collections (user might be both owner and member)
//
seen := make(map[string]bool)
uniqueCollections := make([]*dom_collection.Collection, 0, len(collections))
for _, collection := range collections {
collectionIDStr := collection.ID.String()
if !seen[collectionIDStr] {
seen[collectionIDStr] = true
uniqueCollections = append(uniqueCollections, collection)
}
}
//
// STEP 4: Map domain models to response DTOs with file counts
//
response := &CollectionsResponseDTO{
Collections: make([]*CollectionResponseDTO, len(uniqueCollections)),
}
for i, collection := range uniqueCollections {
// Use the file count stored in the collection itself (no N+1 query)
ownerEmail := getOwnerEmailFromMembers(collection)
response.Collections[i] = mapCollectionToDTO(collection, int(collection.FileCount), ownerEmail)
}
svc.logger.Debug("Retrieved user collections",
zap.Int("total_count", len(collections)),
zap.Int("unique_count", len(uniqueCollections)),
zap.Any("user_id", userID))
return response, nil
}

View file

@ -0,0 +1,111 @@
// monorepo/cloud/backend/internal/maplefile/service/collection/list_shared_with_user.go
package collection
import (
"context"
"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"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
)
type ListSharedCollectionsService interface {
Execute(ctx context.Context) (*CollectionsResponseDTO, error)
}
type listSharedCollectionsServiceImpl struct {
config *config.Configuration
logger *zap.Logger
repo dom_collection.CollectionRepository
fileRepo dom_file.FileMetadataRepository
}
func NewListSharedCollectionsService(
config *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
fileRepo dom_file.FileMetadataRepository,
) ListSharedCollectionsService {
logger = logger.Named("ListSharedCollectionsService")
return &listSharedCollectionsServiceImpl{
config: config,
logger: logger,
repo: repo,
fileRepo: fileRepo,
}
}
func (svc *listSharedCollectionsServiceImpl) Execute(ctx context.Context) (*CollectionsResponseDTO, error) {
//
// STEP 1: 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, nil
}
svc.logger.Info("🔍 LIST SHARED COLLECTIONS: Starting",
zap.String("user_id", userID.String()))
//
// STEP 2: Get collections shared with the user
//
collections, err := svc.repo.GetCollectionsSharedWithUser(ctx, userID)
if err != nil {
svc.logger.Error("🔍 LIST SHARED COLLECTIONS: Failed to get shared collections",
zap.Any("error", err),
zap.Any("user_id", userID))
return nil, err
}
svc.logger.Info("🔍 LIST SHARED COLLECTIONS: Query completed",
zap.String("user_id", userID.String()),
zap.Int("collections_found", len(collections)))
//
// STEP 3: Filter out collections where user is the owner
// (Only show collections shared BY others, not collections user owns and shared with themselves)
//
var sharedByOthers []*dom_collection.Collection
for _, collection := range collections {
if collection.OwnerID != userID {
sharedByOthers = append(sharedByOthers, collection)
svc.logger.Debug("🔍 LIST SHARED COLLECTIONS: Including collection shared by another user",
zap.String("collection_id", collection.ID.String()),
zap.String("owner_id", collection.OwnerID.String()))
} else {
svc.logger.Debug("🔍 LIST SHARED COLLECTIONS: Excluding self-owned collection",
zap.String("collection_id", collection.ID.String()),
zap.String("owner_id", collection.OwnerID.String()))
}
}
svc.logger.Info("🔍 LIST SHARED COLLECTIONS: Filtered collections",
zap.Int("total_collections", len(collections)),
zap.Int("shared_by_others", len(sharedByOthers)),
zap.Int("excluded_self_owned", len(collections)-len(sharedByOthers)))
//
// STEP 4: Map domain models to response DTOs
//
response := &CollectionsResponseDTO{
Collections: make([]*CollectionResponseDTO, len(sharedByOthers)),
}
for i, collection := range sharedByOthers {
// Use the file count stored in the collection itself (no N+1 query)
ownerEmail := getOwnerEmailFromMembers(collection)
response.Collections[i] = mapCollectionToDTO(collection, int(collection.FileCount), ownerEmail)
}
svc.logger.Info("🔍 LIST SHARED COLLECTIONS: Completed successfully",
zap.Int("count", len(sharedByOthers)),
zap.String("user_id", userID.String()))
return response, nil
}

View file

@ -0,0 +1,153 @@
// monorepo/cloud/backend/internal/maplefile/service/collection/move_collection.go
package collection
import (
"context"
"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"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type MoveCollectionRequestDTO struct {
CollectionID gocql.UUID `json:"collection_id"`
NewParentID gocql.UUID `json:"new_parent_id"`
UpdatedAncestors []gocql.UUID `json:"updated_ancestors"`
UpdatedPathSegments []string `json:"updated_path_segments"`
}
type MoveCollectionResponseDTO struct {
Success bool `json:"success"`
Message string `json:"message"`
}
type MoveCollectionService interface {
Execute(ctx context.Context, req *MoveCollectionRequestDTO) (*MoveCollectionResponseDTO, error)
}
type moveCollectionServiceImpl struct {
config *config.Configuration
logger *zap.Logger
repo dom_collection.CollectionRepository
}
func NewMoveCollectionService(
config *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
) MoveCollectionService {
logger = logger.Named("MoveCollectionService")
return &moveCollectionServiceImpl{
config: config,
logger: logger,
repo: repo,
}
}
func (svc *moveCollectionServiceImpl) Execute(ctx context.Context, req *MoveCollectionRequestDTO) (*MoveCollectionResponseDTO, error) {
//
// STEP 1: Validation
//
if req == nil {
svc.logger.Warn("Failed validation with nil request")
return nil, httperror.NewForBadRequestWithSingleField("non_field_error", "Move details are required")
}
e := make(map[string]string)
if req.CollectionID.String() == "" {
e["collection_id"] = "Collection ID is required"
}
if req.NewParentID.String() == "" {
e["new_parent_id"] = "New parent ID is required"
}
if len(req.UpdatedAncestors) == 0 {
e["updated_ancestors"] = "Updated ancestors are required"
}
if len(req.UpdatedPathSegments) == 0 {
e["updated_path_segments"] = "Updated path segments are 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")
}
//
// STEP 3: Check if user has write access to the collection
//
hasAccess, err := svc.repo.CheckAccess(ctx, req.CollectionID, userID, dom_collection.CollectionPermissionReadWrite)
if err != nil {
svc.logger.Error("Failed to check access",
zap.Any("error", err),
zap.Any("collection_id", req.CollectionID),
zap.Any("user_id", userID))
return nil, err
}
if !hasAccess {
svc.logger.Warn("Unauthorized collection move attempt",
zap.Any("user_id", userID),
zap.Any("collection_id", req.CollectionID))
return nil, httperror.NewForForbiddenWithSingleField("message", "You don't have permission to move this collection")
}
//
// STEP 4: Check if user has write access to the new parent
//
hasParentAccess, err := svc.repo.CheckAccess(ctx, req.NewParentID, userID, dom_collection.CollectionPermissionReadWrite)
if err != nil {
svc.logger.Error("Failed to check access to new parent",
zap.Any("error", err),
zap.Any("new_parent_id", req.NewParentID),
zap.Any("user_id", userID))
return nil, err
}
if !hasParentAccess {
svc.logger.Warn("Unauthorized destination parent access",
zap.Any("user_id", userID),
zap.Any("new_parent_id", req.NewParentID))
return nil, httperror.NewForForbiddenWithSingleField("message", "You don't have permission to move to this destination")
}
//
// STEP 5: Move the collection
//
err = svc.repo.MoveCollection(
ctx,
req.CollectionID,
req.NewParentID,
req.UpdatedAncestors,
req.UpdatedPathSegments,
)
if err != nil {
svc.logger.Error("Failed to move collection",
zap.Any("error", err),
zap.Any("collection_id", req.CollectionID),
zap.Any("new_parent_id", req.NewParentID))
return nil, err
}
svc.logger.Info("Collection moved successfully",
zap.Any("collection_id", req.CollectionID),
zap.Any("new_parent_id", req.NewParentID))
return &MoveCollectionResponseDTO{
Success: true,
Message: "Collection moved successfully",
}, nil
}

View file

@ -0,0 +1,170 @@
package collection
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
dom_tag "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/tag"
uc_blockedemail "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/blockedemail"
uc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/collection"
uc_fileobjectstorage "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/fileobjectstorage"
uc_storagedailyusage "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/storagedailyusage"
uc_storageusageevent "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/storageusageevent"
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/ratelimit"
)
// Wire providers for collection services
func ProvideCreateCollectionService(
cfg *config.Configuration,
logger *zap.Logger,
userGetByIDUseCase uc_user.UserGetByIDUseCase,
repo dom_collection.CollectionRepository,
tagRepo dom_tag.Repository,
) CreateCollectionService {
return NewCreateCollectionService(cfg, logger, userGetByIDUseCase, repo, tagRepo)
}
func ProvideGetCollectionService(
cfg *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
userGetByIDUseCase uc_user.UserGetByIDUseCase,
authFailureRateLimiter ratelimit.AuthFailureRateLimiter,
) GetCollectionService {
return NewGetCollectionService(cfg, logger, repo, userGetByIDUseCase, authFailureRateLimiter)
}
func ProvideUpdateCollectionService(
cfg *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
authFailureRateLimiter ratelimit.AuthFailureRateLimiter,
) UpdateCollectionService {
return NewUpdateCollectionService(cfg, logger, repo, authFailureRateLimiter)
}
func ProvideSoftDeleteCollectionService(
cfg *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
fileRepo dom_file.FileMetadataRepository,
getCollectionUseCase uc_collection.GetCollectionUseCase,
updateCollectionUseCase uc_collection.UpdateCollectionUseCase,
hardDeleteCollectionUseCase uc_collection.HardDeleteCollectionUseCase,
deleteMultipleDataUseCase uc_fileobjectstorage.DeleteMultipleEncryptedDataUseCase,
storageQuotaHelperUseCase uc_user.UserStorageQuotaHelperUseCase,
createStorageUsageEventUseCase uc_storageusageevent.CreateStorageUsageEventUseCase,
updateStorageUsageUseCase uc_storagedailyusage.UpdateStorageUsageUseCase,
) SoftDeleteCollectionService {
return NewSoftDeleteCollectionService(
cfg,
logger,
repo,
fileRepo,
getCollectionUseCase,
updateCollectionUseCase,
hardDeleteCollectionUseCase,
deleteMultipleDataUseCase,
storageQuotaHelperUseCase,
createStorageUsageEventUseCase,
updateStorageUsageUseCase,
)
}
func ProvideArchiveCollectionService(
cfg *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
) ArchiveCollectionService {
return NewArchiveCollectionService(cfg, logger, repo)
}
func ProvideRestoreCollectionService(
cfg *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
) RestoreCollectionService {
return NewRestoreCollectionService(cfg, logger, repo)
}
func ProvideListUserCollectionsService(
cfg *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
fileRepo dom_file.FileMetadataRepository,
) ListUserCollectionsService {
return NewListUserCollectionsService(cfg, logger, repo, fileRepo)
}
func ProvideListSharedCollectionsService(
cfg *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
fileRepo dom_file.FileMetadataRepository,
) ListSharedCollectionsService {
return NewListSharedCollectionsService(cfg, logger, repo, fileRepo)
}
func ProvideFindRootCollectionsService(
cfg *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
) FindRootCollectionsService {
return NewFindRootCollectionsService(cfg, logger, repo)
}
func ProvideFindCollectionsByParentService(
cfg *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
) FindCollectionsByParentService {
return NewFindCollectionsByParentService(cfg, logger, repo)
}
func ProvideGetCollectionSyncDataService(
cfg *config.Configuration,
logger *zap.Logger,
getCollectionSyncDataUseCase uc_collection.GetCollectionSyncDataUseCase,
) GetCollectionSyncDataService {
return NewGetCollectionSyncDataService(cfg, logger, getCollectionSyncDataUseCase)
}
func ProvideMoveCollectionService(
cfg *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
) MoveCollectionService {
return NewMoveCollectionService(cfg, logger, repo)
}
func ProvideGetFilteredCollectionsService(
cfg *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
) GetFilteredCollectionsService {
return NewGetFilteredCollectionsService(cfg, logger, repo)
}
func ProvideShareCollectionService(
cfg *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
checkBlockedEmailUC uc_blockedemail.CheckBlockedEmailUseCase,
userGetByIDUC uc_user.UserGetByIDUseCase,
emailer mailgun.Emailer,
) ShareCollectionService {
return NewShareCollectionService(cfg, logger, repo, checkBlockedEmailUC, userGetByIDUC, emailer)
}
func ProvideRemoveMemberService(
cfg *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
) RemoveMemberService {
return NewRemoveMemberService(cfg, logger, repo)
}

View file

@ -0,0 +1,183 @@
// monorepo/cloud/backend/internal/maplefile/service/collection/remove_member.go
package collection
import (
"context"
"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"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/transaction"
"github.com/gocql/gocql"
)
type RemoveMemberRequestDTO struct {
CollectionID gocql.UUID `json:"collection_id"`
RecipientID gocql.UUID `json:"recipient_id"`
RemoveFromDescendants bool `json:"remove_from_descendants"`
}
type RemoveMemberResponseDTO struct {
Success bool `json:"success"`
Message string `json:"message"`
}
type RemoveMemberService interface {
Execute(ctx context.Context, req *RemoveMemberRequestDTO) (*RemoveMemberResponseDTO, error)
}
type removeMemberServiceImpl struct {
config *config.Configuration
logger *zap.Logger
repo dom_collection.CollectionRepository
}
func NewRemoveMemberService(
config *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
) RemoveMemberService {
logger = logger.Named("RemoveMemberService")
return &removeMemberServiceImpl{
config: config,
logger: logger,
repo: repo,
}
}
func (svc *removeMemberServiceImpl) Execute(ctx context.Context, req *RemoveMemberRequestDTO) (*RemoveMemberResponseDTO, error) {
//
// STEP 1: Validation
//
if req == nil {
svc.logger.Warn("Failed validation with nil request")
return nil, httperror.NewForBadRequestWithSingleField("non_field_error", "Remove member details are required")
}
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 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")
}
//
// STEP 3: Check if user has admin access to the collection
//
hasAccess, err := svc.repo.CheckAccess(ctx, req.CollectionID, userID, dom_collection.CollectionPermissionAdmin)
if err != nil {
svc.logger.Error("Failed to check access",
zap.Any("error", err),
zap.Any("collection_id", req.CollectionID),
zap.Any("user_id", userID))
return nil, err
}
// Collection owners and admin members can remove members
if !hasAccess {
isOwner, _ := svc.repo.IsCollectionOwner(ctx, req.CollectionID, userID)
if !isOwner {
svc.logger.Warn("Unauthorized member removal attempt",
zap.Any("user_id", userID),
zap.Any("collection_id", req.CollectionID))
return nil, httperror.NewForForbiddenWithSingleField("message", "You don't have permission to remove members from this collection")
}
}
//
// SAGA: Initialize distributed transaction manager
//
saga := transaction.NewSaga("remove-member", svc.logger)
//
// STEP 4: Retrieve the membership before removing (needed for compensation)
//
existingMembership, err := svc.repo.GetCollectionMembership(ctx, req.CollectionID, req.RecipientID)
if err != nil {
svc.logger.Error("Failed to get collection membership",
zap.Any("error", err),
zap.Any("collection_id", req.CollectionID),
zap.Any("recipient_id", req.RecipientID))
return nil, err
}
if existingMembership == nil {
svc.logger.Debug("Member not found in collection",
zap.Any("collection_id", req.CollectionID),
zap.Any("recipient_id", req.RecipientID))
return nil, httperror.NewForNotFoundWithSingleField("message", "Member not found in this collection")
}
//
// STEP 5: Remove the member
//
var err2 error
if req.RemoveFromDescendants {
err2 = svc.repo.RemoveMemberFromHierarchy(ctx, req.CollectionID, req.RecipientID)
} else {
err2 = svc.repo.RemoveMember(ctx, req.CollectionID, req.RecipientID)
}
if err2 != nil {
svc.logger.Error("Failed to remove member",
zap.Any("error", err2),
zap.Any("collection_id", req.CollectionID),
zap.Any("recipient_id", req.RecipientID),
zap.Bool("remove_from_descendants", req.RemoveFromDescendants))
saga.Rollback(ctx) // Rollback any previous operations
return nil, err2
}
//
// SAGA: Register compensation to re-add the member if needed
// IMPORTANT: Capture by value for closure
//
membershipCaptured := existingMembership
collectionIDCaptured := req.CollectionID
removeFromDescendantsCaptured := req.RemoveFromDescendants
saga.AddCompensation(func(ctx context.Context) error {
svc.logger.Warn("SAGA compensation: re-adding member to collection",
zap.String("collection_id", collectionIDCaptured.String()),
zap.String("recipient_id", membershipCaptured.RecipientID.String()),
zap.Bool("add_to_descendants", removeFromDescendantsCaptured))
if removeFromDescendantsCaptured {
// Re-add to hierarchy if it was removed from hierarchy
return svc.repo.AddMemberToHierarchy(ctx, collectionIDCaptured, membershipCaptured)
}
// Re-add to single collection if it was removed from single collection
return svc.repo.AddMember(ctx, collectionIDCaptured, membershipCaptured)
})
svc.logger.Info("Member removed successfully",
zap.Any("collection_id", req.CollectionID),
zap.Any("recipient_id", req.RecipientID),
zap.Bool("removed_from_descendants", req.RemoveFromDescendants))
return &RemoveMemberResponseDTO{
Success: true,
Message: "Member removed successfully",
}, nil
}

View file

@ -0,0 +1,135 @@
// monorepo/cloud/backend/internal/maplefile/service/collection/restore.go
package collection
import (
"context"
"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"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
type RestoreCollectionRequestDTO struct {
ID gocql.UUID `json:"id"`
}
type RestoreCollectionResponseDTO struct {
Success bool `json:"success"`
Message string `json:"message"`
}
type RestoreCollectionService interface {
Execute(ctx context.Context, req *RestoreCollectionRequestDTO) (*RestoreCollectionResponseDTO, error)
}
type restoreCollectionServiceImpl struct {
config *config.Configuration
logger *zap.Logger
repo dom_collection.CollectionRepository
}
func NewRestoreCollectionService(
config *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
) RestoreCollectionService {
logger = logger.Named("RestoreCollectionService")
return &restoreCollectionServiceImpl{
config: config,
logger: logger,
repo: repo,
}
}
func (svc *restoreCollectionServiceImpl) Execute(ctx context.Context, req *RestoreCollectionRequestDTO) (*RestoreCollectionResponseDTO, error) {
//
// STEP 1: Validation
//
if req == nil {
svc.logger.Warn("Failed validation with nil request")
return nil, httperror.NewForBadRequestWithSingleField("non_field_error", "Collection ID is required")
}
if req.ID.String() == "" {
svc.logger.Warn("Empty collection ID")
return nil, httperror.NewForBadRequestWithSingleField("id", "Collection ID is required")
}
//
// 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")
}
//
// STEP 3: Retrieve existing collection (including non-active states for restoration)
//
collection, err := svc.repo.Get(ctx, req.ID)
if err != nil {
svc.logger.Error("Failed to get collection",
zap.Any("error", err),
zap.Any("collection_id", req.ID))
return nil, err
}
if collection == nil {
svc.logger.Debug("Collection not found",
zap.Any("collection_id", req.ID))
return nil, httperror.NewForNotFoundWithSingleField("message", "Collection not found")
}
//
// STEP 4: Check if user has rights to restore this collection
//
if collection.OwnerID != userID {
svc.logger.Warn("Unauthorized collection restore attempt",
zap.Any("user_id", userID),
zap.Any("collection_id", req.ID))
return nil, httperror.NewForForbiddenWithSingleField("message", "Only the collection owner can restore a collection")
}
//
// STEP 5: Validate state transition
//
err = dom_collection.IsValidStateTransition(collection.State, dom_collection.CollectionStateActive)
if err != nil {
svc.logger.Warn("Invalid state transition for collection restore",
zap.Any("collection_id", req.ID),
zap.String("current_state", collection.State),
zap.String("target_state", dom_collection.CollectionStateActive),
zap.Error(err))
return nil, httperror.NewForBadRequestWithSingleField("state", err.Error())
}
//
// STEP 6: Restore the collection
//
collection.State = dom_collection.CollectionStateActive
collection.Version++ // Update mutation means we increment version.
collection.ModifiedAt = time.Now()
collection.ModifiedByUserID = userID
err = svc.repo.Update(ctx, collection)
if err != nil {
svc.logger.Error("Failed to restore collection",
zap.Any("error", err),
zap.Any("collection_id", req.ID))
return nil, err
}
svc.logger.Info("Collection restored successfully",
zap.Any("collection_id", req.ID),
zap.Any("user_id", userID))
return &RestoreCollectionResponseDTO{
Success: true,
Message: "Collection restored successfully",
}, nil
}

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

View file

@ -0,0 +1,488 @@
// monorepo/cloud/backend/internal/maplefile/service/collection/softdelete.go
package collection
import (
"context"
"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"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
uc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/collection"
uc_fileobjectstorage "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/fileobjectstorage"
uc_storagedailyusage "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/storagedailyusage"
uc_storageusageevent "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/storageusageevent"
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/transaction"
"github.com/gocql/gocql"
)
type SoftDeleteCollectionRequestDTO struct {
ID gocql.UUID `json:"id"`
ForceHardDelete bool `json:"force_hard_delete"` // Skip tombstone for GDPR right-to-be-forgotten
}
type SoftDeleteCollectionResponseDTO struct {
Success bool `json:"success"`
Message string `json:"message"`
}
type SoftDeleteCollectionService interface {
Execute(ctx context.Context, req *SoftDeleteCollectionRequestDTO) (*SoftDeleteCollectionResponseDTO, error)
}
type softDeleteCollectionServiceImpl struct {
config *config.Configuration
logger *zap.Logger
repo dom_collection.CollectionRepository
fileRepo dom_file.FileMetadataRepository
getCollectionUseCase uc_collection.GetCollectionUseCase
updateCollectionUseCase uc_collection.UpdateCollectionUseCase
hardDeleteCollectionUseCase uc_collection.HardDeleteCollectionUseCase
deleteMultipleDataUseCase uc_fileobjectstorage.DeleteMultipleEncryptedDataUseCase
// Storage quota management
storageQuotaHelperUseCase uc_user.UserStorageQuotaHelperUseCase
createStorageUsageEventUseCase uc_storageusageevent.CreateStorageUsageEventUseCase
updateStorageUsageUseCase uc_storagedailyusage.UpdateStorageUsageUseCase
}
func NewSoftDeleteCollectionService(
config *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
fileRepo dom_file.FileMetadataRepository,
getCollectionUseCase uc_collection.GetCollectionUseCase,
updateCollectionUseCase uc_collection.UpdateCollectionUseCase,
hardDeleteCollectionUseCase uc_collection.HardDeleteCollectionUseCase,
deleteMultipleDataUseCase uc_fileobjectstorage.DeleteMultipleEncryptedDataUseCase,
storageQuotaHelperUseCase uc_user.UserStorageQuotaHelperUseCase,
createStorageUsageEventUseCase uc_storageusageevent.CreateStorageUsageEventUseCase,
updateStorageUsageUseCase uc_storagedailyusage.UpdateStorageUsageUseCase,
) SoftDeleteCollectionService {
logger = logger.Named("SoftDeleteCollectionService")
return &softDeleteCollectionServiceImpl{
config: config,
logger: logger,
repo: repo,
fileRepo: fileRepo,
getCollectionUseCase: getCollectionUseCase,
updateCollectionUseCase: updateCollectionUseCase,
hardDeleteCollectionUseCase: hardDeleteCollectionUseCase,
deleteMultipleDataUseCase: deleteMultipleDataUseCase,
storageQuotaHelperUseCase: storageQuotaHelperUseCase,
createStorageUsageEventUseCase: createStorageUsageEventUseCase,
updateStorageUsageUseCase: updateStorageUsageUseCase,
}
}
func (svc *softDeleteCollectionServiceImpl) Execute(ctx context.Context, req *SoftDeleteCollectionRequestDTO) (*SoftDeleteCollectionResponseDTO, error) {
//
// STEP 1: Validation
//
if req == nil {
svc.logger.Warn("Failed validation with nil request")
return nil, httperror.NewForBadRequestWithSingleField("non_field_error", "Collection ID is required")
}
if req.ID.String() == "" {
svc.logger.Warn("Empty collection ID")
return nil, httperror.NewForBadRequestWithSingleField("id", "Collection ID is required")
}
//
// 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")
}
//
// STEP 3: Retrieve related records
//
collection, err := svc.getCollectionUseCase.Execute(ctx, req.ID)
if err != nil {
svc.logger.Error("Failed to get collection",
zap.Any("error", err),
zap.Any("collection_id", req.ID))
return nil, err
}
if collection == nil {
svc.logger.Debug("Collection not found",
zap.Any("collection_id", req.ID))
return nil, httperror.NewForNotFoundWithSingleField("message", "Collection not found")
}
//
// STEP 4: Check if user has rights to delete this collection
//
if collection.OwnerID != userID {
svc.logger.Warn("Unauthorized collection deletion attempt",
zap.Any("user_id", userID),
zap.Any("collection_id", req.ID))
return nil, httperror.NewForForbiddenWithSingleField("message", "Only the collection owner can delete a collection")
}
// Check valid transitions.
if err := dom_collection.IsValidStateTransition(collection.State, dom_collection.CollectionStateDeleted); err != nil {
svc.logger.Warn("Invalid collection state transition",
zap.Any("user_id", userID),
zap.Error(err))
return nil, err
}
svc.logger.Info("Starting soft delete of collection hierarchy",
zap.String("collection_id", collection.ID.String()),
zap.Int("member_count", len(collection.Members)))
//
// SAGA: Initialize distributed transaction manager
//
saga := transaction.NewSaga("soft-delete-collection", svc.logger)
//
// STEP 5: Find all descendant collections
//
descendants, err := svc.repo.FindDescendants(ctx, req.ID)
if err != nil {
svc.logger.Error("Failed to check for descendant collections",
zap.Any("error", err),
zap.Any("collection_id", req.ID))
return nil, err
}
svc.logger.Info("Found descendant collections for deletion",
zap.Any("collection_id", req.ID),
zap.Int("descendants_count", len(descendants)))
//
// STEP 6: Delete all files in the parent collection
//
parentFiles, err := svc.fileRepo.GetByCollection(req.ID)
if err != nil {
svc.logger.Error("Failed to get files for parent collection",
zap.Any("error", err),
zap.Any("collection_id", req.ID))
return nil, err
}
// Collect all S3 storage paths to delete and calculate total storage to release
var allStoragePaths []string
var totalStorageToRelease int64 = 0
if len(parentFiles) > 0 {
parentFileIDs := make([]gocql.UUID, len(parentFiles))
for i, file := range parentFiles {
parentFileIDs[i] = file.ID
// Collect S3 paths for deletion
allStoragePaths = append(allStoragePaths, file.EncryptedFileObjectKey)
if file.EncryptedThumbnailObjectKey != "" {
allStoragePaths = append(allStoragePaths, file.EncryptedThumbnailObjectKey)
}
// Calculate storage to release (only for active files)
if file.State == dom_file.FileStateActive {
totalStorageToRelease += file.EncryptedFileSizeInBytes + file.EncryptedThumbnailSizeInBytes
}
}
// Execute parent file deletion (hard or soft based on flag)
if req.ForceHardDelete {
svc.logger.Info("Hard deleting parent collection files (GDPR mode)",
zap.Int("file_count", len(parentFileIDs)))
if err := svc.fileRepo.HardDeleteMany(parentFileIDs); err != nil {
svc.logger.Error("Failed to hard-delete files in parent collection",
zap.Any("error", err),
zap.Any("collection_id", req.ID),
zap.Int("file_count", len(parentFileIDs)))
saga.Rollback(ctx)
return nil, err
}
// No compensation for hard delete - GDPR requires permanent deletion
} else {
if err := svc.fileRepo.SoftDeleteMany(parentFileIDs); err != nil {
svc.logger.Error("Failed to soft-delete files in parent collection",
zap.Any("error", err),
zap.Any("collection_id", req.ID),
zap.Int("file_count", len(parentFileIDs)))
saga.Rollback(ctx) // Rollback any previous operations
return nil, err
}
// SAGA: Register compensation for parent files deletion
// IMPORTANT: Capture parentFileIDs by value for closure
parentFileIDsCaptured := parentFileIDs
saga.AddCompensation(func(ctx context.Context) error {
svc.logger.Warn("SAGA compensation: restoring parent collection files",
zap.String("collection_id", req.ID.String()),
zap.Int("file_count", len(parentFileIDsCaptured)))
return svc.fileRepo.RestoreMany(parentFileIDsCaptured)
})
}
svc.logger.Info("Deleted files in parent collection",
zap.Any("collection_id", req.ID),
zap.Int("file_count", len(parentFileIDs)))
}
//
// STEP 7: Delete all files in descendant collections
//
totalDescendantFiles := 0
for _, descendant := range descendants {
descendantFiles, err := svc.fileRepo.GetByCollection(descendant.ID)
if err != nil {
svc.logger.Error("Failed to get files for descendant collection",
zap.Any("error", err),
zap.Any("descendant_id", descendant.ID))
saga.Rollback(ctx) // Rollback all previous operations
return nil, err
}
if len(descendantFiles) > 0 {
descendantFileIDs := make([]gocql.UUID, len(descendantFiles))
for i, file := range descendantFiles {
descendantFileIDs[i] = file.ID
// Collect S3 paths for deletion
allStoragePaths = append(allStoragePaths, file.EncryptedFileObjectKey)
if file.EncryptedThumbnailObjectKey != "" {
allStoragePaths = append(allStoragePaths, file.EncryptedThumbnailObjectKey)
}
// Calculate storage to release (only for active files)
if file.State == dom_file.FileStateActive {
totalStorageToRelease += file.EncryptedFileSizeInBytes + file.EncryptedThumbnailSizeInBytes
}
}
// Execute descendant file deletion (hard or soft based on flag)
if req.ForceHardDelete {
if err := svc.fileRepo.HardDeleteMany(descendantFileIDs); err != nil {
svc.logger.Error("Failed to hard-delete files in descendant collection",
zap.Any("error", err),
zap.Any("descendant_id", descendant.ID),
zap.Int("file_count", len(descendantFileIDs)))
saga.Rollback(ctx)
return nil, err
}
// No compensation for hard delete - GDPR requires permanent deletion
} else {
if err := svc.fileRepo.SoftDeleteMany(descendantFileIDs); err != nil {
svc.logger.Error("Failed to soft-delete files in descendant collection",
zap.Any("error", err),
zap.Any("descendant_id", descendant.ID),
zap.Int("file_count", len(descendantFileIDs)))
saga.Rollback(ctx) // Rollback all previous operations
return nil, err
}
// SAGA: Register compensation for this batch of descendant files
// IMPORTANT: Capture by value for closure
descendantFileIDsCaptured := descendantFileIDs
descendantIDCaptured := descendant.ID
saga.AddCompensation(func(ctx context.Context) error {
svc.logger.Warn("SAGA compensation: restoring descendant collection files",
zap.String("descendant_id", descendantIDCaptured.String()),
zap.Int("file_count", len(descendantFileIDsCaptured)))
return svc.fileRepo.RestoreMany(descendantFileIDsCaptured)
})
}
totalDescendantFiles += len(descendantFileIDs)
svc.logger.Debug("Deleted files in descendant collection",
zap.Any("descendant_id", descendant.ID),
zap.Int("file_count", len(descendantFileIDs)))
}
}
svc.logger.Info("Soft-deleted all files in descendant collections",
zap.Int("total_descendant_files", totalDescendantFiles),
zap.Int("descendants_count", len(descendants)))
//
// STEP 8: Delete all descendant collections
//
for _, descendant := range descendants {
// Execute descendant collection deletion (hard or soft based on flag)
if req.ForceHardDelete {
if err := svc.hardDeleteCollectionUseCase.Execute(ctx, descendant.ID); err != nil {
svc.logger.Error("Failed to hard-delete descendant collection",
zap.Any("error", err),
zap.Any("descendant_id", descendant.ID))
saga.Rollback(ctx)
return nil, err
}
// No compensation for hard delete - GDPR requires permanent deletion
} else {
if err := svc.repo.SoftDelete(ctx, descendant.ID); err != nil {
svc.logger.Error("Failed to soft-delete descendant collection",
zap.Any("error", err),
zap.Any("descendant_id", descendant.ID))
saga.Rollback(ctx) // Rollback all previous operations
return nil, err
}
// SAGA: Register compensation for this descendant collection
// IMPORTANT: Capture by value for closure
descendantIDCaptured := descendant.ID
saga.AddCompensation(func(ctx context.Context) error {
svc.logger.Warn("SAGA compensation: restoring descendant collection",
zap.String("descendant_id", descendantIDCaptured.String()))
return svc.repo.Restore(ctx, descendantIDCaptured)
})
}
svc.logger.Debug("Deleted descendant collection",
zap.Any("descendant_id", descendant.ID),
zap.String("descendant_name", descendant.EncryptedName))
}
svc.logger.Info("Deleted all descendant collections",
zap.Int("descendants_count", len(descendants)))
//
// STEP 9: Finally, delete the parent collection
//
if req.ForceHardDelete {
svc.logger.Info("Hard deleting parent collection (GDPR mode)",
zap.String("collection_id", req.ID.String()))
if err := svc.hardDeleteCollectionUseCase.Execute(ctx, req.ID); err != nil {
svc.logger.Error("Failed to hard-delete parent collection",
zap.Any("error", err),
zap.Any("collection_id", req.ID))
saga.Rollback(ctx)
return nil, err
}
// No compensation for hard delete - GDPR requires permanent deletion
} else {
if err := svc.repo.SoftDelete(ctx, req.ID); err != nil {
svc.logger.Error("Failed to soft-delete parent collection",
zap.Any("error", err),
zap.Any("collection_id", req.ID))
saga.Rollback(ctx) // Rollback all previous operations
return nil, err
}
// SAGA: Register compensation for parent collection deletion
// IMPORTANT: Capture by value for closure
parentCollectionIDCaptured := req.ID
saga.AddCompensation(func(ctx context.Context) error {
svc.logger.Warn("SAGA compensation: restoring parent collection",
zap.String("collection_id", parentCollectionIDCaptured.String()))
return svc.repo.Restore(ctx, parentCollectionIDCaptured)
})
}
//
// STEP 10: Update storage tracking (quota, events, daily usage)
//
if totalStorageToRelease > 0 {
svc.logger.Info("Releasing storage quota for collection deletion",
zap.String("collection_id", req.ID.String()),
zap.Int64("total_storage_to_release", totalStorageToRelease))
// Release storage quota
err = svc.storageQuotaHelperUseCase.OnFileDeleted(ctx, userID, totalStorageToRelease)
if err != nil {
svc.logger.Error("Failed to release storage quota after collection deletion",
zap.Error(err))
saga.Rollback(ctx)
return nil, err
}
// Register compensation: re-reserve the released quota
totalStorageCaptured := totalStorageToRelease
userIDCaptured := userID
saga.AddCompensation(func(ctx context.Context) error {
svc.logger.Warn("SAGA compensation: re-reserving released storage quota",
zap.Int64("size", totalStorageCaptured))
return svc.storageQuotaHelperUseCase.CheckAndReserveQuota(ctx, userIDCaptured, totalStorageCaptured)
})
// Create storage usage event
err = svc.createStorageUsageEventUseCase.Execute(ctx, userID, totalStorageToRelease, "remove")
if err != nil {
svc.logger.Error("Failed to create storage usage event for collection deletion",
zap.Error(err))
saga.Rollback(ctx)
return nil, err
}
// Register compensation: create compensating "add" event
saga.AddCompensation(func(ctx context.Context) error {
svc.logger.Warn("SAGA compensation: creating compensating usage event")
return svc.createStorageUsageEventUseCase.Execute(ctx, userIDCaptured, totalStorageCaptured, "add")
})
// Update daily storage usage
today := time.Now().Truncate(24 * time.Hour)
updateReq := &uc_storagedailyusage.UpdateStorageUsageRequest{
UserID: userID,
UsageDay: &today,
TotalBytes: -totalStorageToRelease,
AddBytes: 0,
RemoveBytes: totalStorageToRelease,
IsIncrement: true,
}
err = svc.updateStorageUsageUseCase.Execute(ctx, updateReq)
if err != nil {
svc.logger.Error("Failed to update daily storage usage for collection deletion",
zap.Error(err))
saga.Rollback(ctx)
return nil, err
}
// Register compensation: reverse the usage update
saga.AddCompensation(func(ctx context.Context) error {
svc.logger.Warn("SAGA compensation: reversing daily usage update")
compensateReq := &uc_storagedailyusage.UpdateStorageUsageRequest{
UserID: userIDCaptured,
UsageDay: &today,
TotalBytes: totalStorageCaptured, // Positive to reverse
AddBytes: totalStorageCaptured,
RemoveBytes: 0,
IsIncrement: true,
}
return svc.updateStorageUsageUseCase.Execute(ctx, compensateReq)
})
svc.logger.Info("Storage quota released successfully",
zap.Int64("released_bytes", totalStorageToRelease))
}
//
// STEP 11: Delete all S3 objects
//
if len(allStoragePaths) > 0 {
svc.logger.Info("Deleting S3 objects for collection",
zap.Any("collection_id", req.ID),
zap.Int("s3_objects_count", len(allStoragePaths)))
if err := svc.deleteMultipleDataUseCase.Execute(allStoragePaths); err != nil {
// Log but don't fail - S3 deletion is best effort after metadata is deleted
svc.logger.Error("Failed to delete some S3 objects (continuing anyway)",
zap.Any("error", err),
zap.Int("s3_objects_count", len(allStoragePaths)))
} else {
svc.logger.Info("Successfully deleted all S3 objects",
zap.Int("s3_objects_deleted", len(allStoragePaths)))
}
}
svc.logger.Info("Collection hierarchy deleted successfully",
zap.Any("collection_id", req.ID),
zap.Int("parent_files_deleted", len(parentFiles)),
zap.Int("descendant_files_deleted", totalDescendantFiles),
zap.Int("descendants_deleted", len(descendants)),
zap.Int("total_files_deleted", len(parentFiles)+totalDescendantFiles),
zap.Int("s3_objects_deleted", len(allStoragePaths)))
return &SoftDeleteCollectionResponseDTO{
Success: true,
Message: "Collection, descendants, and all associated files deleted successfully",
}, nil
}

View file

@ -0,0 +1,240 @@
// monorepo/cloud/backend/internal/maplefile/service/collection/update.go
package collection
import (
"context"
"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/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/ratelimit"
)
type UpdateCollectionRequestDTO struct {
ID gocql.UUID `json:"id"`
EncryptedName string `json:"encrypted_name"`
CollectionType string `json:"collection_type,omitempty"`
EncryptedCollectionKey *crypto.EncryptedCollectionKey `json:"encrypted_collection_key,omitempty"`
Version uint64 `json:"version,omitempty"`
}
type UpdateCollectionService interface {
Execute(ctx context.Context, req *UpdateCollectionRequestDTO) (*CollectionResponseDTO, error)
}
type updateCollectionServiceImpl struct {
config *config.Configuration
logger *zap.Logger
repo dom_collection.CollectionRepository
authFailureRateLimiter ratelimit.AuthFailureRateLimiter
}
func NewUpdateCollectionService(
config *config.Configuration,
logger *zap.Logger,
repo dom_collection.CollectionRepository,
authFailureRateLimiter ratelimit.AuthFailureRateLimiter,
) UpdateCollectionService {
logger = logger.Named("UpdateCollectionService")
return &updateCollectionServiceImpl{
config: config,
logger: logger,
repo: repo,
authFailureRateLimiter: authFailureRateLimiter,
}
}
func (svc *updateCollectionServiceImpl) Execute(ctx context.Context, req *UpdateCollectionRequestDTO) (*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["id"] = "Collection ID is required"
}
if req.EncryptedName == "" {
e["encrypted_name"] = "Collection name is required"
}
if req.CollectionType != "" && req.CollectionType != dom_collection.CollectionTypeFolder && req.CollectionType != dom_collection.CollectionTypeAlbum {
e["collection_type"] = "Collection type must be either 'folder' or 'album'"
}
if req.EncryptedCollectionKey == nil {
e["encrypted_collection_key"] = "Encrypted collection key 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")
}
//
// STEP 3: Retrieve existing collection
//
collection, err := svc.repo.Get(ctx, req.ID)
if err != nil {
svc.logger.Error("Failed to get collection",
zap.Any("error", err),
zap.Any("collection_id", req.ID))
return nil, err
}
if collection == nil {
svc.logger.Debug("Collection not found",
zap.Any("collection_id", req.ID))
return nil, httperror.NewForNotFoundWithSingleField("message", "Collection not found")
}
//
// STEP 4: Check rate limiting for authorization failures
//
// Check if user has exceeded authorization failure limits before checking access
if svc.authFailureRateLimiter != nil {
allowed, remainingAttempts, resetTime, err := svc.authFailureRateLimiter.CheckAuthFailure(
ctx,
userID.String(),
req.ID.String(),
"collection:update")
if err != nil {
// Log error but continue - fail open for availability
svc.logger.Error("Failed to check auth failure rate limit",
zap.Error(err),
zap.Any("user_id", userID),
zap.Any("collection_id", req.ID))
} else if !allowed {
svc.logger.Warn("User blocked due to excessive authorization failures",
zap.Any("user_id", userID),
zap.Any("collection_id", req.ID),
zap.Int("remaining_attempts", remainingAttempts),
zap.Time("reset_time", resetTime))
return nil, httperror.NewTooManyRequestsError(
"Too many authorization failures. Please try again later")
}
}
//
// STEP 5: Check if user has rights to update this collection
//
if collection.OwnerID != userID {
// Check if user is a member with admin permissions
isAdmin := false
for _, member := range collection.Members {
if member.RecipientID == userID && member.PermissionLevel == dom_collection.CollectionPermissionAdmin {
isAdmin = true
break
}
}
if !isAdmin {
// Record authorization failure for rate limiting
if svc.authFailureRateLimiter != nil {
if err := svc.authFailureRateLimiter.RecordAuthFailure(
ctx,
userID.String(),
req.ID.String(),
"collection:update",
"insufficient_permission"); err != nil {
svc.logger.Error("Failed to record auth failure",
zap.Error(err),
zap.Any("user_id", userID),
zap.Any("collection_id", req.ID))
}
}
svc.logger.Warn("Unauthorized collection update attempt",
zap.Any("user_id", userID),
zap.Any("collection_id", req.ID))
return nil, httperror.NewForForbiddenWithSingleField("message", "You don't have permission to update this collection")
}
}
// Record successful authorization
if svc.authFailureRateLimiter != nil {
if err := svc.authFailureRateLimiter.RecordAuthSuccess(
ctx,
userID.String(),
req.ID.String(),
"collection:update"); err != nil {
svc.logger.Debug("Failed to record auth success",
zap.Error(err),
zap.Any("user_id", userID),
zap.Any("collection_id", req.ID))
}
}
//
// STEP 6: Check if submitted collection request is in-sync with our backend's collection copy.
//
// Developers note:
// What is the purpose of this check?
// Our server has multiple clients sharing data and hence our backend needs to ensure that the collection being updated is the most recent version.
if collection.Version != req.Version {
svc.logger.Warn("Outdated collection update attempt",
zap.Any("user_id", userID),
zap.Any("collection_id", req.ID),
zap.Any("submitted_version", req.Version),
zap.Any("current_version", collection.Version))
return nil, httperror.NewForBadRequestWithSingleField("message", "Collection has been updated since you last fetched it")
}
//
// STEP 6: Update collection
//
collection.EncryptedName = req.EncryptedName
collection.ModifiedAt = time.Now()
collection.ModifiedByUserID = userID
collection.Version++ // Update mutation means we increment version.
// Only update optional fields if they are provided
if req.CollectionType != "" {
collection.CollectionType = req.CollectionType
}
if req.EncryptedCollectionKey.Ciphertext != nil && len(req.EncryptedCollectionKey.Ciphertext) > 0 &&
req.EncryptedCollectionKey.Nonce != nil && len(req.EncryptedCollectionKey.Nonce) > 0 {
collection.EncryptedCollectionKey = req.EncryptedCollectionKey
}
//
// STEP 7: Save updated collection
//
err = svc.repo.Update(ctx, collection)
if err != nil {
svc.logger.Error("Failed to update collection",
zap.Any("error", err),
zap.Any("collection_id", collection.ID))
return nil, err
}
//
// STEP 8: Map domain model to response DTO
//
ownerEmail := getOwnerEmailFromMembers(collection)
response := mapCollectionToDTO(collection, 0, ownerEmail)
svc.logger.Debug("Collection updated successfully",
zap.Any("collection_id", collection.ID))
return response, nil
}

View file

@ -0,0 +1,158 @@
// monorepo/cloud/backend/internal/maplefile/service/collection/utils.go
package collection
import (
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
// Helper function to get owner email from members list
// The owner is always a member with their email, so we can look them up
func getOwnerEmailFromMembers(collection *dom_collection.Collection) string {
if collection == nil {
return ""
}
for _, member := range collection.Members {
if member.RecipientID == collection.OwnerID {
return member.RecipientEmail
}
}
return ""
}
// Helper function to map a CollectionMembershipDTO to a CollectionMembership domain model
// This assumes a direct field-by-field copy is intended by the DTO structure.
func mapMembershipDTOToDomain(dto *CollectionMembershipDTO) dom_collection.CollectionMembership {
return dom_collection.CollectionMembership{
ID: dto.ID, // Copy DTO ID
CollectionID: dto.CollectionID, // Copy DTO CollectionID
RecipientID: dto.RecipientID, // Copy DTO RecipientID
RecipientEmail: dto.RecipientEmail, // Copy DTO RecipientEmail
GrantedByID: dto.GrantedByID, // Copy DTO GrantedByID
EncryptedCollectionKey: dto.EncryptedCollectionKey, // Copy DTO EncryptedCollectionKey
PermissionLevel: dto.PermissionLevel, // Copy DTO PermissionLevel
CreatedAt: dto.CreatedAt, // Copy DTO CreatedAt
IsInherited: dto.IsInherited, // Copy DTO IsInherited
InheritedFromID: dto.InheritedFromID, // Copy DTO InheritedFromID
// Note: ModifiedAt/By, Version are not in Membership DTO/Domain
}
}
// Helper function to map a CreateCollectionRequestDTO to a Collection domain model.
// This function recursively maps all fields, including nested members and children,
// copying values directly from the DTO. Server-side overrides for fields like
// ID, OwnerID, timestamps, and version are applied *after* this mapping in the Execute method.
// userID and now are passed for potential use in recursive calls if needed for consistency,
// though the primary goal here is to copy DTO values.
func mapCollectionDTOToDomain(dto *CreateCollectionRequestDTO, userID gocql.UUID, now time.Time) *dom_collection.Collection {
if dto == nil {
return nil
}
collection := &dom_collection.Collection{
// Copy all scalar/pointer fields directly from the DTO as requested by the prompt.
// Fields like ID, OwnerID, timestamps, and version from the DTO
// represent the client's proposed state and will be potentially
// overridden by server-managed values later in the Execute method.
ID: dto.ID,
OwnerID: dto.OwnerID,
EncryptedName: dto.EncryptedName,
EncryptedCustomIcon: dto.EncryptedCustomIcon,
CollectionType: dto.CollectionType,
EncryptedCollectionKey: dto.EncryptedCollectionKey,
ParentID: dto.ParentID,
AncestorIDs: dto.AncestorIDs,
CreatedAt: dto.CreatedAt,
CreatedByUserID: dto.CreatedByUserID,
ModifiedAt: dto.ModifiedAt,
ModifiedByUserID: dto.ModifiedByUserID,
}
// Map members slice from DTO to domain model slice
if len(dto.Members) > 0 {
collection.Members = make([]dom_collection.CollectionMembership, len(dto.Members))
for i, memberDTO := range dto.Members {
collection.Members[i] = mapMembershipDTOToDomain(memberDTO)
}
}
return collection
}
// Helper function to map a Collection domain model to a CollectionResponseDTO
// This function should ideally exclude sensitive data (like recipient-specific keys)
// that should not be part of a general response.
// fileCount is the number of active files in this collection (pass 0 if not known)
// ownerEmail is the email address of the collection owner (pass "" if not known)
func mapCollectionToDTO(collection *dom_collection.Collection, fileCount int, ownerEmail string) *CollectionResponseDTO {
if collection == nil {
return nil
}
responseDTO := &CollectionResponseDTO{
ID: collection.ID,
OwnerID: collection.OwnerID,
OwnerEmail: ownerEmail,
EncryptedName: collection.EncryptedName,
EncryptedCustomIcon: collection.EncryptedCustomIcon,
CollectionType: collection.CollectionType,
ParentID: collection.ParentID,
AncestorIDs: collection.AncestorIDs,
Tags: collection.Tags,
// Note: EncryptedCollectionKey from the domain model is the owner's key.
// Including it in the general response DTO might be acceptable if the response
// is only sent to the owner and contains *their* key. Otherwise, this field
// might need conditional inclusion or exclusion. The prompt does not require
// changing this, so we keep the original mapping which copies the owner's key.
EncryptedCollectionKey: collection.EncryptedCollectionKey,
CreatedAt: collection.CreatedAt,
ModifiedAt: collection.ModifiedAt,
FileCount: fileCount,
Version: collection.Version,
// Members slice needs mapping to MembershipResponseDTO
Members: make([]MembershipResponseDTO, len(collection.Members)),
}
// Map members
for i, member := range collection.Members {
responseDTO.Members[i] = MembershipResponseDTO{
ID: member.ID,
RecipientID: member.RecipientID,
RecipientEmail: member.RecipientEmail, // Email for display
PermissionLevel: member.PermissionLevel,
GrantedByID: member.GrantedByID,
CollectionID: member.CollectionID, // Redundant but useful
IsInherited: member.IsInherited,
InheritedFromID: member.InheritedFromID,
CreatedAt: member.CreatedAt,
// Note: EncryptedCollectionKey for this member is recipient-specific
// and should NOT be included in a general response DTO unless
// filtered for the specific recipient receiving the response.
// The MembershipResponseDTO does not have a field for this, which is correct.
EncryptedCollectionKey: member.EncryptedCollectionKey,
}
}
// Debug: Log what we're sending in the DTO
logger, _ := zap.NewDevelopment()
logger.Info("🔍 mapCollectionToDTO: Mapping collection to DTO",
zap.String("collection_id", collection.ID.String()),
zap.Int("domain_members_count", len(collection.Members)),
zap.Int("dto_members_count", len(responseDTO.Members)),
zap.Int("domain_tags_count", len(collection.Tags)),
zap.Int("dto_tags_count", len(responseDTO.Tags)))
for i, member := range responseDTO.Members {
logger.Info("🔍 mapCollectionToDTO: DTO member",
zap.Int("index", i),
zap.String("recipient_email", validation.MaskEmail(member.RecipientEmail)),
zap.String("recipient_id", member.RecipientID.String()),
zap.Int("encrypted_key_length", len(member.EncryptedCollectionKey)))
}
return responseDTO
}