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