package service import ( "context" "errors" "fmt" "log/slog" "path/filepath" "sort" "strings" "time" "unicode/utf8" "gorm.io/gorm" filemanager "wucher/internal/domain/file_manager" "wucher/internal/queue" "wucher/internal/shared/pkg/uuidv7" ) type FileManagerService struct { folderRepo filemanager.FolderRepository fileRepo filemanager.FileRepository attachmentRepo filemanager.AttachmentRepository uploadIntentRepo filemanager.FileUploadIntentRepository storage filemanager.ObjectStorage uploadPolicy filemanager.FileUploadPolicy fileQueue filemanager.FileProcessingQueue fileOutbox filemanager.FileProcessingOutboxWriter fileOutboxSerializer queue.FileProcessingMessageSerializer fileStatusEvents filemanager.FileStatusRealtimeEventWriter trashRetention time.Duration logger *slog.Logger enableS3Tagging bool } type FileManagerServiceDependencies struct { UploadIntentRepo filemanager.FileUploadIntentRepository Storage filemanager.ObjectStorage UploadPolicy filemanager.FileUploadPolicy FileQueue filemanager.FileProcessingQueue FileOutbox filemanager.FileProcessingOutboxWriter FileOutboxSerializer queue.FileProcessingMessageSerializer FileStatusEvents filemanager.FileStatusRealtimeEventWriter TrashRetention time.Duration Logger *slog.Logger EnableS3Tagging bool } func NewFileManagerService( folderRepo filemanager.FolderRepository, fileRepo filemanager.FileRepository, attachmentRepo filemanager.AttachmentRepository, deps FileManagerServiceDependencies, ) *FileManagerService { svc := &FileManagerService{ folderRepo: folderRepo, fileRepo: fileRepo, attachmentRepo: attachmentRepo, uploadIntentRepo: deps.UploadIntentRepo, storage: deps.Storage, fileQueue: deps.FileQueue, fileOutbox: deps.FileOutbox, fileOutboxSerializer: deps.FileOutboxSerializer, fileStatusEvents: deps.FileStatusEvents, trashRetention: 30 * 24 * time.Hour, logger: deps.Logger, enableS3Tagging: deps.EnableS3Tagging, } if svc.logger == nil { svc.logger = slog.Default() } if deps.TrashRetention >= 0 { svc.trashRetention = deps.TrashRetention } if svc.uploadIntentRepo == nil { uploadIntentRepo, ok := fileRepo.(filemanager.FileUploadIntentRepository) if ok { svc.uploadIntentRepo = uploadIntentRepo } } svc.SetUploadPolicy(deps.UploadPolicy) return svc } func (s *FileManagerService) SetTrashRetention(retention time.Duration) { if retention < 0 { retention = 0 } s.trashRetention = retention } func (s *FileManagerService) defaultTrashPurgeAt(now time.Time) *time.Time { if s.trashRetention <= 0 { return nil } v := now.UTC().Add(s.trashRetention) return &v } func (s *FileManagerService) SetObjectStorage(storage filemanager.ObjectStorage) { s.storage = storage } func (s *FileManagerService) SetUploadPolicy(policy filemanager.FileUploadPolicy) { s.uploadPolicy = normalizeUploadPolicy(policy) } func (s *FileManagerService) SetFileProcessingQueue(fileQueue filemanager.FileProcessingQueue) { s.fileQueue = fileQueue } func (s *FileManagerService) SetFileUploadIntentRepository(repo filemanager.FileUploadIntentRepository) { s.uploadIntentRepo = repo } func (s *FileManagerService) SetFileProcessingOutbox( outbox filemanager.FileProcessingOutboxWriter, serializer queue.FileProcessingMessageSerializer, ) { s.fileOutbox = outbox s.fileOutboxSerializer = serializer } func (s *FileManagerService) SetFileStatusRealtimeEventWriter(writer filemanager.FileStatusRealtimeEventWriter) { s.fileStatusEvents = writer } func (s *FileManagerService) GetOrCreateSystemRootFolder(ctx context.Context) (*filemanager.Folder, error) { if s.folderRepo == nil { return nil, filemanager.ErrFolderNotFound } root, err := s.folderRepo.GetByParentAndName(ctx, nil, filemanager.SystemRootFolderName) if err != nil { return nil, err } if root != nil { return root, nil } row := &filemanager.Folder{ ParentID: nil, Name: filemanager.SystemRootFolderName, NameNormalized: filemanager.SystemRootFolderName, Depth: 0, PathCache: "/" + filemanager.SystemRootFolderName, NameSlot: "live", } if err := s.folderRepo.Create(ctx, row); err != nil { // If another request created it first, re-read. root, getErr := s.folderRepo.GetByParentAndName(ctx, nil, filemanager.SystemRootFolderName) if getErr == nil && root != nil { return root, nil } return nil, err } return row, nil } func (s *FileManagerService) CreateFolderNode(ctx context.Context, params filemanager.CreateFolderParams) (*filemanager.Folder, error) { name, nameNormalized, err := normalizeFolderName(params.Name) if err != nil { return nil, err } var parentID []byte depth := 0 pathCache := buildFolderPathCache("", name) if len(params.ParentID) != 0 { parent, err := s.folderRepo.GetByID(ctx, params.ParentID) if err != nil { return nil, err } if parent == nil { return nil, filemanager.ErrFolderNotFound } parentID = append([]byte(nil), params.ParentID...) depth = parent.Depth + 1 pathCache = buildFolderPathCache(parent.PathCache, name) } dup, err := s.folderRepo.GetByParentAndName(ctx, parentID, nameNormalized) if err != nil { return nil, err } if dup != nil { return nil, filemanager.ErrDuplicateName } row := &filemanager.Folder{ ParentID: parentID, Name: name, NameNormalized: nameNormalized, Depth: depth, PathCache: pathCache, NameSlot: "live", CreatedBy: params.ActorID, UpdatedBy: params.ActorID, } if err := s.folderRepo.Create(ctx, row); err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return nil, filemanager.ErrDuplicateName } return nil, err } created, err := s.folderRepo.GetByID(ctx, row.ID) if err == nil && created != nil { return created, nil } return row, nil } func (s *FileManagerService) MoveFolderNode(ctx context.Context, id []byte, params filemanager.MoveFolderParams) (*filemanager.Folder, error) { current, err := s.folderRepo.GetByIDAny(ctx, id) if err != nil { return nil, err } if current == nil { return nil, filemanager.ErrFolderNotFound } if current.DeletedAt != nil { return nil, fmt.Errorf("%w: folder in trash cannot be moved", filemanager.ErrInvalidMove) } targetName := current.Name targetNameNormalized := current.NameNormalized if params.Name != nil { name, nameNormalized, err := normalizeFolderName(*params.Name) if err != nil { return nil, err } targetName = name targetNameNormalized = nameNormalized } targetParentID := current.ParentID if params.ParentProvided { targetParentID = append([]byte(nil), params.ParentID...) if len(targetParentID) != 0 { parent, err := s.folderRepo.GetByIDAny(ctx, targetParentID) if err != nil { return nil, err } if parent == nil { return nil, filemanager.ErrFolderNotFound } if parent.DeletedAt != nil { return nil, fmt.Errorf("%w: destination folder in trash cannot be used", filemanager.ErrInvalidMove) } } } moved, err := s.folderRepo.MoveSubtree(ctx, id, targetParentID, targetName, targetNameNormalized, params.ActorID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, filemanager.ErrFolderNotFound } if errors.Is(err, gorm.ErrDuplicatedKey) { return nil, filemanager.ErrDuplicateName } if errors.Is(err, filemanager.ErrInvalidMove) { return nil, filemanager.ErrInvalidMove } return nil, err } if moved == nil { return nil, filemanager.ErrFolderNotFound } return moved, nil } func (s *FileManagerService) RequestFileUploadIntent(ctx context.Context, params filemanager.RequestFileUploadIntentParams) (*filemanager.FileUploadIntentGrant, error) { if s.storage == nil { return nil, filemanager.ErrStorageUnavailable } if s.uploadIntentRepo == nil { return nil, filemanager.ErrUploadIntentRepositoryDisabled } name, nameNormalized, err := normalizeFileName(params.Name) if err != nil { return nil, err } contentType := normalizeUploadedMimeType(params.ContentType) if err := s.validateUploadAgainstPolicy(name, contentType, params.SizeBytes); err != nil { return nil, err } intentID := uuidv7.MustBytes() objectKey := buildImmutableFileObjectKey(name) grant, err := s.storage.PresignPutObject(ctx, filemanager.PresignPutObjectInput{ ObjectKey: objectKey, ContentType: contentType, SizeBytes: params.SizeBytes, }) if err != nil { return nil, err } expiresAt := grant.ExpiresAt.UTC() if expiresAt.IsZero() { expiresAt = time.Now().UTC().Add(15 * time.Minute) } bucket := strings.TrimSpace(grant.Bucket) key := strings.TrimSpace(grant.ObjectKey) if key == "" { key = objectKey } method := strings.ToUpper(strings.TrimSpace(grant.Method)) if method == "" { method = "PUT" } intent := &filemanager.FileUploadIntent{ ID: intentID, Name: name, NameNormalized: nameNormalized, Extension: extensionFromName(name), SizeBytes: params.SizeBytes, MimeType: contentType, Bucket: bucket, ObjectKey: key, Status: filemanager.FileUploadIntentStatusPending, ExpiresAt: expiresAt, CreatedBy: params.ActorID, UpdatedBy: params.ActorID, } if err := s.uploadIntentRepo.CreateUploadIntent(ctx, intent); err != nil { return nil, err } return &filemanager.FileUploadIntentGrant{ UploadIntentID: append([]byte(nil), intent.ID...), Bucket: intent.Bucket, ObjectKey: intent.ObjectKey, Name: intent.Name, MimeType: intent.MimeType, SizeBytes: intent.SizeBytes, Method: method, UploadURL: strings.TrimSpace(grant.URL), Headers: cloneTrimmedStringMap(grant.Headers), ExpiresAt: intent.ExpiresAt.UTC(), }, nil } func (s *FileManagerService) UpdateUploadIntentSize(ctx context.Context, params filemanager.FileUploadIntentSizeUpdateParams) error { if s.uploadIntentRepo == nil { return filemanager.ErrUploadIntentRepositoryDisabled } updater, ok := s.uploadIntentRepo.(interface { UpdateUploadIntentSize(context.Context, filemanager.FileUploadIntentSizeUpdateParams) error }) if !ok { return filemanager.ErrUploadIntentRepositoryDisabled } return updater.UpdateUploadIntentSize(ctx, params) } func (s *FileManagerService) CompleteFileUploadIntent(ctx context.Context, params filemanager.CompleteFileUploadIntentParams) (*filemanager.File, error) { if s.storage == nil { return nil, filemanager.ErrStorageUnavailable } if s.uploadIntentRepo == nil { return nil, filemanager.ErrUploadIntentRepositoryDisabled } intent, err := s.uploadIntentRepo.GetUploadIntentByID(ctx, params.UploadIntentID) if err != nil { return nil, err } if intent == nil { return nil, filemanager.ErrUploadIntentNotFound } status := filemanager.NormalizeFileUploadIntentStatus(intent.Status) if status == filemanager.FileUploadIntentStatusCompleted { row, err := s.fileRepo.GetByObjectKey(ctx, intent.ObjectKey) if err != nil { return nil, err } if row != nil { if touchErr := s.touchFolders(ctx, params.ActorID, row.FolderID); touchErr != nil { return nil, touchErr } return row, nil } return nil, filemanager.ErrUploadIntentAlreadyComplete } if status == filemanager.FileUploadIntentStatusExpired { return nil, filemanager.ErrUploadIntentExpired } if status != filemanager.FileUploadIntentStatusPending { return nil, filemanager.ErrUploadIntentStatusConflict } now := time.Now().UTC() if !intent.ExpiresAt.IsZero() && now.After(intent.ExpiresAt.UTC()) { _ = s.uploadIntentRepo.MarkUploadIntentExpired(ctx, filemanager.FileUploadIntentExpireParams{ ID: intent.ID, UpdatedBy: params.ActorID, ExpiredAt: now, }) return nil, filemanager.ErrUploadIntentExpired } headMeta, err := s.verifyUploadedObjectForIntent(ctx, intent) if err != nil { return nil, err } if txRepo, ok := s.fileRepo.(filemanager.FileTransactionalRepository); ok && s.fileOutboxReady() { row, err := s.completeFileUploadIntentDurable(ctx, txRepo, intent, headMeta, params.ActorID) if err != nil { return nil, err } if touchErr := s.touchFolders(ctx, params.ActorID, row.FolderID); touchErr != nil { return nil, touchErr } s.ensureLifecycleTempMarker(ctx, row) s.emitFileStatusRealtimeEvent(ctx, row, filemanager.FileStatusUploaded, params.ActorID, "") return row, nil } row, err := s.createUploadedFileFromIntent(ctx, intent, headMeta, params.ActorID) if err != nil { return nil, err } if err := s.uploadIntentRepo.MarkUploadIntentCompleted(ctx, filemanager.FileUploadIntentCompleteParams{ ID: intent.ID, UpdatedBy: params.ActorID, CompletedAt: time.Now().UTC(), }); err != nil { return nil, err } if touchErr := s.touchFolders(ctx, params.ActorID, row.FolderID); touchErr != nil { return nil, touchErr } s.ensureLifecycleTempMarker(ctx, row) s.emitFileStatusRealtimeEvent(ctx, row, filemanager.FileStatusUploaded, params.ActorID, "") if err := s.enqueueUploadedFileForProcessing(ctx, row, params.ActorID); err != nil { failedAt := time.Now().UTC() reason := truncateFailureReason(err.Error()) _ = s.fileRepo.TransitionStatus(ctx, filemanager.FileStatusTransitionParams{ ID: row.ID, ExpectedFrom: filemanager.FileStatusUploaded, To: filemanager.FileStatusFailed, UpdatedBy: params.ActorID, ProcessedAt: &failedAt, FailedAt: &failedAt, FailureReason: &reason, }) row.Status = filemanager.FileStatusFailed row.FailureReason = reason row.UpdatedAt = failedAt row.UpdatedBy = append([]byte(nil), params.ActorID...) s.emitFileStatusRealtimeEvent(ctx, row, filemanager.FileStatusFailed, params.ActorID, reason) return nil, err } return row, nil } func (s *FileManagerService) CancelFileUploadIntent(ctx context.Context, params filemanager.CancelFileUploadIntentParams) error { if s.uploadIntentRepo == nil { return filemanager.ErrUploadIntentRepositoryDisabled } intent, err := s.uploadIntentRepo.GetUploadIntentByID(ctx, params.UploadIntentID) if err != nil { return err } if intent == nil { return filemanager.ErrUploadIntentNotFound } status := filemanager.NormalizeFileUploadIntentStatus(intent.Status) switch status { case filemanager.FileUploadIntentStatusExpired: // Idempotent: already canceled/expired. return nil case filemanager.FileUploadIntentStatusCompleted: return filemanager.ErrUploadIntentAlreadyComplete case filemanager.FileUploadIntentStatusPending: // Continue to cancellation flow. default: return filemanager.ErrUploadIntentStatusConflict } if s.storage == nil { return filemanager.ErrStorageUnavailable } if strings.TrimSpace(intent.ObjectKey) != "" { if err := s.storage.DeleteObject(ctx, intent.ObjectKey); err != nil { return err } } now := time.Now().UTC() if err := s.uploadIntentRepo.MarkUploadIntentExpired(ctx, filemanager.FileUploadIntentExpireParams{ ID: intent.ID, UpdatedBy: params.ActorID, ExpiredAt: now, }); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return filemanager.ErrUploadIntentStatusConflict } return err } return nil } func (s *FileManagerService) UploadFileNode(ctx context.Context, params filemanager.UploadFileParams) (*filemanager.File, error) { if s.storage == nil { return nil, filemanager.ErrStorageUnavailable } if params.Body == nil { return nil, filemanager.ErrInvalidName } name, nameNormalized, err := normalizeFileName(params.Name) if err != nil { return nil, err } contentType := normalizeUploadedMimeType(params.ContentType) if err := s.validateUploadAgainstPolicy(name, contentType, params.SizeBytes); err != nil { return nil, err } // Upload stage always parks metadata under hidden system root. // The final logical folder/name is set by CreateFileNodeFromUpload. root, err := s.GetOrCreateSystemRootFolder(ctx) if err != nil { return nil, err } targetFolderID := root.ID objectKey := buildImmutableFileObjectKey(name) validatedAt := time.Now().UTC() metadata := map[string]string{ "uploaded": validatedAt.Format(time.RFC3339), } if folderIDStr, err := uuidv7.BytesToString(targetFolderID); err == nil { metadata["folder_id"] = folderIDStr } meta, err := s.storage.PutObject(ctx, filemanager.PutObjectInput{ ObjectKey: objectKey, ContentType: contentType, SizeBytes: params.SizeBytes, Body: params.Body, Metadata: metadata, }) if err != nil { return nil, err } row := &filemanager.File{ ID: uuidv7.MustBytes(), FolderID: targetFolderID, Name: name, NameNormalized: nameNormalized, Extension: extensionFromName(name), SizeBytes: params.SizeBytes, MimeType: contentType, Bucket: meta.Bucket, ObjectKey: meta.ObjectKey, ObjectVersion: meta.VersionID, ETag: meta.ETag, IsTemplate: params.IsTemplate, Status: filemanager.FileStatusUploaded, LifecycleStatus: filemanager.FileLifecycleStatusTemp, ProcessingStartedAt: nil, ProcessedAt: nil, ValidatedAt: nil, NameSlot: tempUploadNameSlot(), CreatedBy: params.ActorID, UpdatedBy: params.ActorID, } if txRepo, ok := s.fileRepo.(filemanager.FileTransactionalRepository); ok && s.fileOutboxReady() { if err := txRepo.WithTransaction(ctx, func(repo filemanager.FileRepository, outbox filemanager.FileProcessingOutboxWriter) error { if err := repo.Create(ctx, row); err != nil { return err } return s.persistFileProcessingOutbox(ctx, outbox, row, params.ActorID) }); err != nil { _ = s.storage.DeleteObject(ctx, meta.ObjectKey) return nil, err } createdRow := row created, err := s.fileRepo.GetByID(ctx, row.ID) if err == nil && created != nil { createdRow = created } s.ensureLifecycleTempMarker(ctx, createdRow) s.emitFileStatusRealtimeEvent(ctx, createdRow, filemanager.FileStatusUploaded, params.ActorID, "") return createdRow, nil } if err := s.fileRepo.Create(ctx, row); err != nil { _ = s.storage.DeleteObject(ctx, meta.ObjectKey) return nil, err } createdRow := row created, err := s.fileRepo.GetByID(ctx, row.ID) if err == nil && created != nil { createdRow = created } if touchErr := s.touchFolders(ctx, params.ActorID, createdRow.FolderID); touchErr != nil { return nil, touchErr } s.ensureLifecycleTempMarker(ctx, createdRow) s.emitFileStatusRealtimeEvent(ctx, createdRow, filemanager.FileStatusUploaded, params.ActorID, "") if err := s.enqueueUploadedFileForProcessing(ctx, createdRow, params.ActorID); err != nil { now := time.Now().UTC() reason := truncateFailureReason(err.Error()) _ = s.fileRepo.TransitionStatus(ctx, filemanager.FileStatusTransitionParams{ ID: createdRow.ID, ExpectedFrom: filemanager.FileStatusUploaded, To: filemanager.FileStatusFailed, UpdatedBy: params.ActorID, ProcessedAt: &now, FailedAt: &now, FailureReason: &reason, }) createdRow.Status = filemanager.FileStatusFailed createdRow.FailureReason = reason createdRow.UpdatedBy = append([]byte(nil), params.ActorID...) createdRow.UpdatedAt = now s.emitFileStatusRealtimeEvent(ctx, createdRow, filemanager.FileStatusFailed, params.ActorID, reason) return nil, err } return createdRow, nil } func (s *FileManagerService) CreateFileNodeFromUploadIntent(ctx context.Context, params filemanager.CreateFileFromUploadIntentParams) (*filemanager.File, error) { name, _, err := normalizeFileName(params.Name) if err != nil { return nil, err } targetFolderID, err := s.resolveTargetFolderID(ctx, params.FolderID, params.FolderProvided, true) if err != nil { return nil, err } uploaded, err := s.CompleteFileUploadIntent(ctx, filemanager.CompleteFileUploadIntentParams{ UploadIntentID: params.UploadIntentID, ActorID: params.ActorID, }) if err != nil { return nil, err } if uploaded == nil { return nil, filemanager.ErrFileNotFound } currentStatus := filemanager.NormalizeFileStatus(uploaded.Status) if strings.EqualFold(strings.TrimSpace(uploaded.NameSlot), "live") && currentStatus == filemanager.FileStatusReady { return nil, filemanager.ErrFileAlreadyFinalized } if uploaded.IsTemplate != params.IsTemplate { uploaded.IsTemplate = params.IsTemplate if err := s.fileRepo.Update(ctx, uploaded); err != nil { return nil, err } } targetName, _, err := s.resolveAvailableFileName(ctx, targetFolderID, name, uploaded.ID) if err != nil { return nil, err } moved, err := s.MoveFileNode(ctx, uploaded.ID, filemanager.MoveFileParams{ Name: &targetName, FolderID: targetFolderID, FolderProvided: true, ActorID: params.ActorID, }) if err != nil { return nil, err } return moved, nil } func (s *FileManagerService) CreateFileNodeFromUpload(ctx context.Context, params filemanager.CreateFileFromUploadParams) (*filemanager.File, error) { current, err := s.fileRepo.GetByID(ctx, params.UploadFileID) if err != nil { return nil, err } if current == nil { return nil, filemanager.ErrFileNotFound } currentStatus := filemanager.NormalizeFileStatus(current.Status) if strings.EqualFold(strings.TrimSpace(current.NameSlot), "live") && currentStatus == filemanager.FileStatusReady { return nil, filemanager.ErrFileAlreadyFinalized } if err := ensureFileStatusTransition(currentStatus, filemanager.FileStatusReady); err != nil { return nil, fmt.Errorf("%w: current status is %q", filemanager.ErrFileNotEligibleForFinalize, currentStatus) } targetFolderID := current.FolderID if params.FolderProvided { folderID, err := s.resolveTargetFolderID(ctx, params.FolderID, true, true) if err != nil { return nil, err } targetFolderID = folderID } targetName := current.Name if params.Name != nil { name, _, err := normalizeFileName(*params.Name) if err != nil { return nil, err } targetName = name } targetName, targetNameNormalized, err := s.resolveAvailableFileName(ctx, targetFolderID, targetName, current.ID) if err != nil { return nil, err } if err := s.fileRepo.Finalize(ctx, filemanager.FileFinalizeParams{ ID: current.ID, ExpectedFrom: currentStatus, FolderID: targetFolderID, Name: targetName, NameNormalized: targetNameNormalized, Extension: extensionFromName(targetName), UpdatedBy: params.ActorID, }); err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return nil, filemanager.ErrDuplicateName } if errors.Is(err, gorm.ErrRecordNotFound) { return nil, filemanager.ErrFileStatusConflict } return nil, err } updated, err := s.fileRepo.GetByID(ctx, params.UploadFileID) if err == nil && updated != nil { if touchErr := s.touchFolders(ctx, params.ActorID, updated.FolderID); touchErr != nil { return nil, touchErr } s.emitFileStatusRealtimeEvent(ctx, updated, filemanager.FileStatusReady, params.ActorID, "") return updated, nil } if err != nil { return nil, err } return nil, filemanager.ErrFileNotFound } func (s *FileManagerService) UploadFileNodesBulk(ctx context.Context, params []filemanager.UploadFileParams) []filemanager.BulkFileNodeResult { results := make([]filemanager.BulkFileNodeResult, 0, len(params)) for i := range params { row, err := s.UploadFileNode(ctx, params[i]) results = append(results, filemanager.BulkFileNodeResult{ Index: i, File: row, Err: err, }) } return results } func (s *FileManagerService) CreateFileNodesFromUploadBulk(ctx context.Context, params []filemanager.CreateFileFromUploadParams) []filemanager.BulkFileNodeResult { results := make([]filemanager.BulkFileNodeResult, 0, len(params)) for i := range params { row, err := s.CreateFileNodeFromUpload(ctx, params[i]) results = append(results, filemanager.BulkFileNodeResult{ Index: i, File: row, Err: err, }) } return results } func (s *FileManagerService) MoveFileNode(ctx context.Context, id []byte, params filemanager.MoveFileParams) (*filemanager.File, error) { current, err := s.fileRepo.GetByIDAny(ctx, id) if err != nil { return nil, err } if current == nil { return nil, filemanager.ErrFileNotFound } if current.DeletedAt != nil { return nil, fmt.Errorf("%w: file in trash cannot be moved", filemanager.ErrInvalidMove) } targetFolderID := current.FolderID sourceFolderID := append([]byte(nil), current.FolderID...) if params.FolderProvided { targetFolderID = append([]byte(nil), params.FolderID...) if len(targetFolderID) != 0 { destination, err := s.folderRepo.GetByIDAny(ctx, targetFolderID) if err != nil { return nil, err } if destination == nil { return nil, filemanager.ErrFolderNotFound } if destination.DeletedAt != nil { return nil, fmt.Errorf("%w: destination folder in trash cannot be used", filemanager.ErrInvalidMove) } } } targetName := current.Name targetNameNormalized := current.NameNormalized if params.Name != nil { name, nameNormalized, err := normalizeFileName(*params.Name) if err != nil { return nil, err } targetName = name targetNameNormalized = nameNormalized } dup, err := s.fileRepo.GetByFolderAndName(ctx, targetFolderID, targetNameNormalized) if err != nil { return nil, err } if dup != nil && !bytesEqual16(dup.ID, current.ID) { return nil, filemanager.ErrDuplicateName } current.FolderID = targetFolderID current.Name = targetName current.NameNormalized = targetNameNormalized current.Extension = extensionFromName(targetName) current.NameSlot = "live" current.UpdatedBy = params.ActorID if err := s.fileRepo.Update(ctx, current); err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return nil, filemanager.ErrDuplicateName } return nil, err } if err := s.touchFolders(ctx, params.ActorID, sourceFolderID, targetFolderID); err != nil { return nil, err } updated, err := s.fileRepo.GetByID(ctx, current.ID) if err == nil && updated != nil { return updated, nil } return current, nil } func (s *FileManagerService) PurgeFolderNode(ctx context.Context, id []byte) error { row, err := s.folderRepo.GetByIDAny(ctx, id) if err != nil { return err } if row == nil || isSystemRootFolder(row) { return filemanager.ErrFolderNotFound } if row.DeletedAt == nil { return filemanager.ErrFolderNotInTrash } subtreeFolders, err := s.collectFolderSubtreeAny(ctx, id) if err != nil { return err } if len(subtreeFolders) == 0 { return filemanager.ErrFolderNotFound } folderIDs := make([][]byte, 0, len(subtreeFolders)) for i := range subtreeFolders { folderIDs = append(folderIDs, subtreeFolders[i].ID) } allFiles, err := s.listAllFilesByFolders(ctx, folderIDs) if err != nil { return err } objectKeys := make([]string, 0, len(allFiles)) for i := range allFiles { fileRow := allFiles[i] if fileRow.DeletedAt == nil { if err := s.fileRepo.Delete(ctx, fileRow.ID, nil, nil); err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } } if err := s.fileRepo.Purge(ctx, fileRow.ID); err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } if key := strings.TrimSpace(fileRow.ObjectKey); key != "" { objectKeys = append(objectKeys, key) } if fileRow.ThumbnailObjectKey != nil { if key := strings.TrimSpace(*fileRow.ThumbnailObjectKey); key != "" { objectKeys = append(objectKeys, key) } } } sort.SliceStable(subtreeFolders, func(i, j int) bool { return subtreeFolders[i].Depth > subtreeFolders[j].Depth }) for i := range subtreeFolders { folderID := subtreeFolders[i].ID if subtreeFolders[i].DeletedAt == nil { if err := s.folderRepo.Delete(ctx, folderID, nil, nil); err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } } if err := s.folderRepo.Purge(ctx, folderID); err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } } if s.storage != nil && len(objectKeys) > 0 { uniqueKeys := uniqueNonEmptyStrings(objectKeys) go func(storage filemanager.ObjectStorage, keys []string) { bgCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() for i := range keys { _ = storage.DeleteObject(bgCtx, keys[i]) } }(s.storage, uniqueKeys) } return nil } func (s *FileManagerService) PurgeFileNode(ctx context.Context, id []byte) error { row, err := s.fileRepo.GetByIDAny(ctx, id) if err != nil { return err } if row == nil { return filemanager.ErrFileNotFound } if row.DeletedAt == nil { return filemanager.ErrFileNotInTrash } objectKeys := make([]string, 0, 2) if key := strings.TrimSpace(row.ObjectKey); key != "" { objectKeys = append(objectKeys, key) } if row.ThumbnailObjectKey != nil { if key := strings.TrimSpace(*row.ThumbnailObjectKey); key != "" { objectKeys = append(objectKeys, key) } } if err := s.fileRepo.Purge(ctx, id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return filemanager.ErrFileNotFound } return err } if s.storage != nil && len(objectKeys) > 0 { uniqueKeys := uniqueNonEmptyStrings(objectKeys) go func(storage filemanager.ObjectStorage, keys []string) { bgCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() for i := range keys { _ = storage.DeleteObject(bgCtx, keys[i]) } }(s.storage, uniqueKeys) } return nil } func (s *FileManagerService) ListNodesByParent(ctx context.Context, parentID []byte) ([]filemanager.Folder, []filemanager.File, error) { if len(parentID) != 0 { parent, err := s.folderRepo.GetByID(ctx, parentID) if err != nil { return nil, nil, err } if parent == nil { return nil, nil, filemanager.ErrFolderNotFound } folders, _, err := s.folderRepo.ListByParent(ctx, parentID, "name ASC", 0, 0) if err != nil { return nil, nil, err } files, _, err := s.fileRepo.ListByFolder(ctx, parentID, "name ASC", 0, 0) if err != nil { return nil, nil, err } return folders, files, nil } root, err := s.GetOrCreateSystemRootFolder(ctx) if err != nil { return nil, nil, err } rootFolders, _, err := s.folderRepo.ListByParent(ctx, nil, "name ASC", 0, 0) if err != nil { return nil, nil, err } systemRootChildren, _, err := s.folderRepo.ListByParent(ctx, root.ID, "name ASC", 0, 0) if err != nil { return nil, nil, err } out := make([]filemanager.Folder, 0, len(rootFolders)+len(systemRootChildren)) seen := make(map[string]struct{}, len(rootFolders)+len(systemRootChildren)) appendVisibleFolder := func(row filemanager.Folder) { if isSystemRootFolder(&row) { return } idKey := string(row.ID) if _, ok := seen[idKey]; ok { return } seen[idKey] = struct{}{} out = append(out, row) } for i := range rootFolders { appendVisibleFolder(rootFolders[i]) } for i := range systemRootChildren { appendVisibleFolder(systemRootChildren[i]) } sort.SliceStable(out, func(i, j int) bool { return strings.ToLower(strings.TrimSpace(out[i].Name)) < strings.ToLower(strings.TrimSpace(out[j].Name)) }) files, _, err := s.fileRepo.ListByFolder(ctx, root.ID, "name ASC", 0, 0) if err != nil { return nil, nil, err } return out, files, nil } func (s *FileManagerService) SearchNodes(ctx context.Context, query string) ([]filemanager.Folder, []filemanager.File, error) { folders, _, err := s.folderRepo.SearchByName(ctx, query, "name ASC", 0, 0) if err != nil { return nil, nil, err } files, _, err := s.fileRepo.SearchByName(ctx, query, "name ASC", 0, 0) if err != nil { return nil, nil, err } return folders, files, nil } func (s *FileManagerService) ListTrashNodes(ctx context.Context) ([]filemanager.Folder, []filemanager.File, error) { folders, _, err := s.folderRepo.ListTrash(ctx, "deleted_at DESC", 0, 0) if err != nil { return nil, nil, err } files, _, err := s.fileRepo.ListTrashByFolder(ctx, nil, "deleted_at DESC", 0, 0) if err != nil { return nil, nil, err } return folders, files, nil } func (s *FileManagerService) resolveTargetFolderID(ctx context.Context, folderID []byte, folderProvided bool, allowRoot bool) ([]byte, error) { if folderProvided && len(folderID) != 0 { folder, err := s.folderRepo.GetByID(ctx, folderID) if err != nil { return nil, err } if folder == nil { return nil, filemanager.ErrFolderNotFound } return folder.ID, nil } if allowRoot { root, err := s.GetOrCreateSystemRootFolder(ctx) if err != nil { return nil, err } return root.ID, nil } return nil, filemanager.ErrFolderNotFound } func (s *FileManagerService) CreateFolder(ctx context.Context, row *filemanager.Folder) error { return s.folderRepo.Create(ctx, row) } func (s *FileManagerService) UpdateFolder(ctx context.Context, row *filemanager.Folder) error { return s.folderRepo.Update(ctx, row) } func (s *FileManagerService) DeleteFolder(ctx context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error { resolvedPurgeAt := purgeAt if resolvedPurgeAt == nil { resolvedPurgeAt = s.defaultTrashPurgeAt(time.Now().UTC()) } row, err := s.folderRepo.GetByID(ctx, id) if err != nil { return err } if row == nil || isSystemRootFolder(row) { return filemanager.ErrFolderNotFound } subtreeFolders, err := s.collectFolderSubtreeAny(ctx, id) if err != nil { return err } if len(subtreeFolders) == 0 { return filemanager.ErrFolderNotFound } folderIDs := make([][]byte, 0, len(subtreeFolders)) for i := range subtreeFolders { folderIDs = append(folderIDs, subtreeFolders[i].ID) } activeFiles, err := s.listActiveFilesByFolders(ctx, folderIDs) if err != nil { return err } for i := range activeFiles { fileID := append([]byte(nil), activeFiles[i].ID...) if err := s.fileRepo.Delete(ctx, fileID, deletedBy, resolvedPurgeAt); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { continue } return err } } sort.SliceStable(subtreeFolders, func(i, j int) bool { return subtreeFolders[i].Depth > subtreeFolders[j].Depth }) for i := range subtreeFolders { if subtreeFolders[i].DeletedAt != nil { continue } folderID := append([]byte(nil), subtreeFolders[i].ID...) if err := s.folderRepo.Delete(ctx, folderID, deletedBy, resolvedPurgeAt); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { continue } return err } } return nil } func (s *FileManagerService) RestoreFolder(ctx context.Context, id []byte, targetParentID []byte, targetProvided bool, restoredBy []byte) error { row, err := s.folderRepo.GetByIDAny(ctx, id) if err != nil { return err } if row == nil { return filemanager.ErrFolderNotFound } if row.DeletedAt == nil { return filemanager.ErrFolderNotInTrash } if isSystemRootFolder(row) { return filemanager.ErrFolderNotFound } _ = targetParentID _ = targetProvided var ( resolvedParentID []byte newRootDepth int newRootParentPath string ) if len(row.ParentID) != 0 { parent, err := s.folderRepo.GetByID(ctx, row.ParentID) if err != nil { return err } if parent == nil { return filemanager.ErrFolderNotFound } if bytesEqual16(parent.ID, row.ID) { return filemanager.ErrInvalidMove } if !isSystemRootFolder(parent) { resolvedParentID = append([]byte(nil), parent.ID...) newRootDepth = parent.Depth + 1 newRootParentPath = parent.PathCache } } dup, err := s.folderRepo.GetByParentAndName(ctx, resolvedParentID, row.NameNormalized) if err != nil { return err } if dup != nil && !bytesEqual16(dup.ID, row.ID) { return filemanager.ErrDuplicateName } subtreeFolders, err := s.collectFolderSubtreeAny(ctx, id) if err != nil { return err } if len(subtreeFolders) == 0 { return filemanager.ErrFolderNotFound } oldRootPath := strings.TrimSpace(row.PathCache) if oldRootPath == "" { oldRootPath = buildFolderPathCache("", row.Name) } newRootPath := buildFolderPathCache(newRootParentPath, row.Name) depthDelta := newRootDepth - row.Depth sort.SliceStable(subtreeFolders, func(i, j int) bool { return subtreeFolders[i].Depth < subtreeFolders[j].Depth }) for i := range subtreeFolders { current := subtreeFolders[i] updated := current if bytesEqual16(current.ID, row.ID) { updated.ParentID = append([]byte(nil), resolvedParentID...) updated.Depth = newRootDepth updated.PathCache = newRootPath } else { updated.Depth = current.Depth + depthDelta if updated.Depth < 0 { updated.Depth = 0 } updated.PathCache = replaceFolderPathPrefix(current.PathCache, oldRootPath, newRootPath) } if current.DeletedAt != nil { updated.DeletedAt = nil updated.DeletedBy = nil updated.PurgeAt = nil updated.NameSlot = "live" } updated.UpdatedBy = restoredBy if err := s.folderRepo.Update(ctx, &updated); err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return filemanager.ErrDuplicateName } return err } } folderIDs := make([][]byte, 0, len(subtreeFolders)) for i := range subtreeFolders { folderIDs = append(folderIDs, subtreeFolders[i].ID) } trashedFiles, err := s.listTrashedFilesByFolders(ctx, folderIDs) if err != nil { return err } for i := range trashedFiles { rowFile := trashedFiles[i] rowFile.Status = filemanager.FileStatusReady rowFile.TrashedAt = nil rowFile.DeletedAt = nil rowFile.DeletedBy = nil rowFile.PurgeAt = nil rowFile.NameSlot = "live" rowFile.UpdatedBy = restoredBy if err := s.fileRepo.Update(ctx, &rowFile); err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return filemanager.ErrDuplicateName } return err } } return nil } func (s *FileManagerService) PurgeFolder(ctx context.Context, id []byte) error { return s.folderRepo.Purge(ctx, id) } func (s *FileManagerService) GetFolderByID(ctx context.Context, id []byte) (*filemanager.Folder, error) { row, err := s.folderRepo.GetByID(ctx, id) if err != nil { return nil, err } if row != nil && isSystemRootFolder(row) { return nil, nil } return row, nil } func (s *FileManagerService) GetFolderByIDAny(ctx context.Context, id []byte) (*filemanager.Folder, error) { row, err := s.folderRepo.GetByIDAny(ctx, id) if err != nil { return nil, err } if row != nil && isSystemRootFolder(row) { return nil, nil } return row, nil } func (s *FileManagerService) GetFolderByParentAndName(ctx context.Context, parentID []byte, nameNormalized string) (*filemanager.Folder, error) { row, err := s.folderRepo.GetByParentAndName(ctx, parentID, nameNormalized) if err != nil { return nil, err } if row != nil && isSystemRootFolder(row) { return nil, nil } return row, nil } func (s *FileManagerService) ListFoldersByParent(ctx context.Context, parentID []byte, sort string, limit, offset int) ([]filemanager.Folder, int64, error) { rows, total, err := s.folderRepo.ListByParent(ctx, parentID, normalizeFolderSort(sort), limit, offset) if err != nil { return nil, 0, err } if len(parentID) != 0 { return rows, total, nil } filtered := make([]filemanager.Folder, 0, len(rows)) for i := range rows { if isSystemRootFolder(&rows[i]) { continue } filtered = append(filtered, rows[i]) } if total > int64(len(rows)-len(filtered)) { total -= int64(len(rows) - len(filtered)) } return filtered, total, nil } func (s *FileManagerService) ListTrashFoldersByParent(ctx context.Context, parentID []byte, sort string, limit, offset int) ([]filemanager.Folder, int64, error) { return s.folderRepo.ListTrashByParent(ctx, parentID, normalizeFolderSort(sort), limit, offset) } func (s *FileManagerService) CreateFile(ctx context.Context, row *filemanager.File) error { if err := s.fileRepo.Create(ctx, row); err != nil { return err } actorID := row.UpdatedBy if len(actorID) == 0 { actorID = row.CreatedBy } return s.touchFolders(ctx, actorID, row.FolderID) } func (s *FileManagerService) UpdateFile(ctx context.Context, row *filemanager.File) error { return s.fileRepo.Update(ctx, row) } func (s *FileManagerService) DeleteFile(ctx context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error { resolvedPurgeAt := purgeAt if resolvedPurgeAt == nil { resolvedPurgeAt = s.defaultTrashPurgeAt(time.Now().UTC()) } row, err := s.fileRepo.GetByIDAny(ctx, id) if err != nil { return err } if row == nil || row.DeletedAt != nil { return filemanager.ErrFileNotFound } if err := s.fileRepo.Delete(ctx, id, deletedBy, resolvedPurgeAt); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return filemanager.ErrFileNotFound } return err } if err := s.touchFolders(ctx, deletedBy, row.FolderID); err != nil { return err } deletedAt := time.Now().UTC() row.Status = filemanager.FileStatusTrashed row.TrashedAt = &deletedAt row.DeletedAt = &deletedAt row.DeletedBy = append([]byte(nil), deletedBy...) row.UpdatedBy = append([]byte(nil), deletedBy...) row.UpdatedAt = deletedAt if resolvedPurgeAt != nil { purgeAtCopy := resolvedPurgeAt.UTC() row.PurgeAt = &purgeAtCopy } s.emitFileStatusRealtimeEvent(ctx, row, filemanager.FileStatusTrashed, deletedBy, "") return nil } func (s *FileManagerService) PurgeExpiredTrash(ctx context.Context, now time.Time, limit int) (int, int, error) { if limit == 0 { limit = 200 } if limit < 0 { limit = 0 } shouldPurge := func(deletedAt, purgeAt *time.Time) bool { if deletedAt == nil { return false } if purgeAt != nil { return !purgeAt.UTC().After(now.UTC()) } if s.trashRetention <= 0 { return false } dueAt := deletedAt.UTC().Add(s.trashRetention) return !dueAt.After(now.UTC()) } files, _, err := s.fileRepo.ListTrashByFolder(ctx, nil, "deleted_at ASC", 0, 0) if err != nil { return 0, 0, err } dueFileIDs := make([][]byte, 0, len(files)) for i := range files { if shouldPurge(files[i].DeletedAt, files[i].PurgeAt) { dueFileIDs = append(dueFileIDs, append([]byte(nil), files[i].ID...)) } } folders, _, err := s.folderRepo.ListTrash(ctx, "deleted_at ASC", 0, 0) if err != nil { return 0, 0, err } dueFolders := make([]filemanager.Folder, 0, len(folders)) for i := range folders { if shouldPurge(folders[i].DeletedAt, folders[i].PurgeAt) { dueFolders = append(dueFolders, folders[i]) } } sort.SliceStable(dueFolders, func(i, j int) bool { return dueFolders[i].Depth < dueFolders[j].Depth }) purgedFiles := 0 purgedFolders := 0 maxOps := limit ops := 0 for i := range dueFileIDs { if maxOps > 0 && ops >= maxOps { return purgedFiles, purgedFolders, nil } if err := s.PurgeFileNode(ctx, dueFileIDs[i]); err != nil { if errors.Is(err, filemanager.ErrFileNotFound) || errors.Is(err, filemanager.ErrFileNotInTrash) { continue } return purgedFiles, purgedFolders, err } purgedFiles++ ops++ } for i := range dueFolders { if maxOps > 0 && ops >= maxOps { return purgedFiles, purgedFolders, nil } if err := s.PurgeFolderNode(ctx, dueFolders[i].ID); err != nil { if errors.Is(err, filemanager.ErrFolderNotFound) || errors.Is(err, filemanager.ErrFolderNotInTrash) { continue } return purgedFiles, purgedFolders, err } purgedFolders++ ops++ } return purgedFiles, purgedFolders, nil } func (s *FileManagerService) RestoreFile(ctx context.Context, id []byte, targetFolderID []byte, targetProvided bool, restoredBy []byte) error { row, err := s.fileRepo.GetByIDAny(ctx, id) if err != nil { return err } if row == nil { return filemanager.ErrFileNotFound } if row.DeletedAt == nil { return filemanager.ErrFileNotInTrash } _ = targetFolderID _ = targetProvided folder, err := s.folderRepo.GetByID(ctx, row.FolderID) if err != nil { return err } if folder == nil { return filemanager.ErrFolderNotFound } resolvedFolderID := append([]byte(nil), row.FolderID...) dup, err := s.fileRepo.GetByFolderAndName(ctx, resolvedFolderID, row.NameNormalized) if err != nil { return err } if dup != nil && !bytesEqual16(dup.ID, row.ID) { return filemanager.ErrDuplicateName } row.FolderID = append([]byte(nil), resolvedFolderID...) row.Status = filemanager.FileStatusReady row.TrashedAt = nil row.DeletedAt = nil row.DeletedBy = nil row.PurgeAt = nil row.NameSlot = "live" row.UpdatedBy = restoredBy if err := s.fileRepo.Update(ctx, row); err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return filemanager.ErrDuplicateName } return err } if err := s.touchFolders(ctx, restoredBy, row.FolderID); err != nil { return err } row.UpdatedAt = time.Now().UTC() s.emitFileStatusRealtimeEvent(ctx, row, filemanager.FileStatusReady, restoredBy, "") return nil } func (s *FileManagerService) PurgeFile(ctx context.Context, id []byte) error { return s.fileRepo.Purge(ctx, id) } func (s *FileManagerService) GetFileByID(ctx context.Context, id []byte) (*filemanager.File, error) { return s.fileRepo.GetByID(ctx, id) } func (s *FileManagerService) GetFileByIDAny(ctx context.Context, id []byte) (*filemanager.File, error) { return s.fileRepo.GetByIDAny(ctx, id) } func (s *FileManagerService) GetFileByObjectKey(ctx context.Context, objectKey string) (*filemanager.File, error) { return s.fileRepo.GetByObjectKey(ctx, objectKey) } func (s *FileManagerService) GetFileByFolderAndName(ctx context.Context, folderID []byte, nameNormalized string) (*filemanager.File, error) { return s.fileRepo.GetByFolderAndName(ctx, folderID, nameNormalized) } func (s *FileManagerService) GetFileByTemplateAndTakeover(ctx context.Context, templateUUID, takeoverID []byte) (*filemanager.File, error) { return s.fileRepo.GetByTemplateAndTakeover(ctx, templateUUID, takeoverID) } func (s *FileManagerService) ListFilesByFolder(ctx context.Context, folderID []byte, sort string, limit, offset int) ([]filemanager.File, int64, error) { return s.fileRepo.ListByFolder(ctx, folderID, normalizeFileSort(sort), limit, offset) } func (s *FileManagerService) ListTrashFilesByFolder(ctx context.Context, folderID []byte, sort string, limit, offset int) ([]filemanager.File, int64, error) { return s.fileRepo.ListTrashByFolder(ctx, folderID, normalizeFileSort(sort), limit, offset) } func (s *FileManagerService) CreateAttachment(ctx context.Context, params filemanager.CreateAttachmentParams) (*filemanager.Attachment, error) { if s.attachmentRepo == nil { return nil, filemanager.ErrAttachmentRepositoryDisabled } refType := strings.ToLower(strings.TrimSpace(params.RefType)) refID := strings.TrimSpace(params.RefID) if refType == "" || refID == "" { return nil, filemanager.ErrAttachmentRefRequired } row, err := s.fileRepo.GetByID(ctx, params.FileID) if err != nil { return nil, err } if row == nil { return nil, filemanager.ErrFileNotFound } if filemanager.NormalizeFileStatus(row.Status) != filemanager.FileStatusReady { return nil, filemanager.ErrFileNotReadyForAttachment } category := normalizeOptionalString(params.Category) dup, err := s.attachmentRepo.GetByRefAndFile(ctx, refType, refID, row.ID) if err != nil { return nil, err } if dup != nil { return nil, filemanager.ErrAttachmentAlreadyExists } attachment := &filemanager.Attachment{ FileID: append([]byte(nil), row.ID...), RefType: refType, RefID: refID, Category: category, SortOrder: params.SortOrder, IsPrimary: params.IsPrimary, AttachedBy: append([]byte(nil), params.ActorID...), } if err := s.attachmentRepo.Create(ctx, attachment); err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return nil, filemanager.ErrAttachmentAlreadyExists } return nil, err } if err := s.MarkFileActive(ctx, row.ID, params.ActorID); err != nil { return nil, err } created, err := s.attachmentRepo.GetByID(ctx, attachment.ID) if err != nil { return nil, err } if created != nil { return created, nil } return attachment, nil } func (s *FileManagerService) GetAttachmentByID(ctx context.Context, id []byte) (*filemanager.Attachment, error) { if s.attachmentRepo == nil { return nil, filemanager.ErrAttachmentRepositoryDisabled } return s.attachmentRepo.GetByID(ctx, id) } func (s *FileManagerService) ListAttachmentsByReference(ctx context.Context, params filemanager.ListAttachmentsParams) ([]filemanager.Attachment, int64, error) { if s.attachmentRepo == nil { return nil, 0, filemanager.ErrAttachmentRepositoryDisabled } refType := strings.ToLower(strings.TrimSpace(params.RefType)) refID := strings.TrimSpace(params.RefID) if refType == "" || refID == "" { return nil, 0, filemanager.ErrAttachmentRefRequired } filter := filemanager.AttachmentListFilter{ RefType: refType, RefID: refID, Category: normalizeOptionalString(params.Category), Sort: normalizeAttachmentSort(params.Sort), Limit: params.Limit, Offset: params.Offset, } return s.attachmentRepo.ListByReference(ctx, filter) } func (s *FileManagerService) DeleteAttachment(ctx context.Context, id []byte) error { if s.attachmentRepo == nil { return filemanager.ErrAttachmentRepositoryDisabled } existing, err := s.attachmentRepo.GetByID(ctx, id) if err != nil { return err } if existing == nil { return filemanager.ErrAttachmentNotFound } if err := s.attachmentRepo.Delete(ctx, id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return filemanager.ErrAttachmentNotFound } return err } if err := s.ReconcileFileLifecycle(ctx, existing.FileID, nil); err != nil { return err } return nil } func (s *FileManagerService) ListEditedWBFilesByTakeoverID(ctx context.Context, takeoverID []byte) ([]filemanager.File, error) { return s.fileRepo.ListEditedTemplatesByTakeoverID(ctx, takeoverID) } func (s *FileManagerService) MarkFileActive(ctx context.Context, fileID []byte, actorID []byte) error { if len(fileID) == 0 { return nil } lifecycleRepo, ok := s.fileRepo.(filemanager.FileLifecycleRepository) if !ok { return nil } row, err := s.fileRepo.GetByIDAny(ctx, fileID) if err != nil { return err } if row == nil || row.DeletedAt != nil { return nil } now := time.Now().UTC() if err := lifecycleRepo.UpdateLifecycle(ctx, filemanager.FileLifecycleTransitionParams{ ID: fileID, Status: filemanager.FileLifecycleStatusActive, UpdatedBy: actorID, AttachedAt: &now, ClearOrphanedAt: true, }); err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return err } return nil } s.applyLifecycleTag(ctx, row.ObjectKey, lifecycleTagKeep) return nil } func (s *FileManagerService) ReconcileFileLifecycle(ctx context.Context, fileID []byte, actorID []byte) error { if len(fileID) == 0 { return nil } lifecycleRepo, ok := s.fileRepo.(filemanager.FileLifecycleRepository) if !ok { return nil } row, err := s.fileRepo.GetByIDAny(ctx, fileID) if err != nil { return err } if row == nil || row.DeletedAt != nil { return nil } if filemanager.NormalizeFileLifecycleStatus(row.LifecycleStatus) == filemanager.FileLifecycleStatusDeleted { return nil } referenced, err := lifecycleRepo.IsFileReferenced(ctx, fileID) if err != nil { return err } if referenced { return s.MarkFileActive(ctx, fileID, actorID) } now := time.Now().UTC() if err := lifecycleRepo.UpdateLifecycle(ctx, filemanager.FileLifecycleTransitionParams{ ID: fileID, Status: filemanager.FileLifecycleStatusOrphanPending, UpdatedBy: actorID, OrphanedAt: &now, }); err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return err } return nil } s.applyLifecycleTag(ctx, row.ObjectKey, lifecycleTagKeep) return nil } func (s *FileManagerService) CleanupOrphanPendingFiles( ctx context.Context, now time.Time, gracePeriod time.Duration, limit int, dryRun bool, ) (filemanager.FileCleanupStats, error) { stats := filemanager.FileCleanupStats{DryRun: dryRun} lifecycleRepo, ok := s.fileRepo.(filemanager.FileLifecycleRepository) if !ok { return stats, nil } if limit <= 0 { limit = 100 } cutoff := now.UTC() if gracePeriod > 0 { cutoff = cutoff.Add(-gracePeriod) } rows, err := lifecycleRepo.ListFilesByLifecycle(ctx, filemanager.FileLifecycleListFilter{ Status: filemanager.FileLifecycleStatusOrphanPending, OrphanedBeforeEq: &cutoff, ExcludeInTrash: true, ExcludeLifecycleDeleted: true, Limit: limit, }) if err != nil { return stats, err } stats = s.reapUnreferencedFiles(ctx, lifecycleRepo, rows, dryRun) s.logger.Info("file cleanup completed", "scanned", stats.Scanned, "deleted", stats.Deleted, "skipped", stats.Skipped, "failed", stats.Failed, "dry_run", dryRun) return stats, nil } func (s *FileManagerService) CleanupStaleTempUploads( ctx context.Context, now time.Time, gracePeriod time.Duration, limit int, dryRun bool, ) (filemanager.FileCleanupStats, error) { stats := filemanager.FileCleanupStats{DryRun: dryRun} lifecycleRepo, ok := s.fileRepo.(filemanager.FileLifecycleRepository) if !ok { return stats, nil } if limit <= 0 { limit = 100 } cutoff := now.UTC() if gracePeriod > 0 { cutoff = cutoff.Add(-gracePeriod) } rows, err := lifecycleRepo.ListFilesByLifecycle(ctx, filemanager.FileLifecycleListFilter{ Status: filemanager.FileLifecycleStatusTemp, CreatedBeforeEq: &cutoff, ExcludeInTrash: true, ExcludeLifecycleDeleted: true, Limit: limit, }) if err != nil { return stats, err } stats = s.reapUnreferencedFiles(ctx, lifecycleRepo, rows, dryRun) s.logger.Info("temp upload cleanup completed", "scanned", stats.Scanned, "deleted", stats.Deleted, "skipped", stats.Skipped, "failed", stats.Failed, "dry_run", dryRun) return stats, nil } func (s *FileManagerService) CleanupStaleDraftFiles( ctx context.Context, now time.Time, gracePeriod time.Duration, limit int, dryRun bool, ) (filemanager.FileCleanupStats, error) { stats := filemanager.FileCleanupStats{DryRun: dryRun} lifecycleRepo, ok := s.fileRepo.(filemanager.FileLifecycleRepository) if !ok { return stats, nil } if limit <= 0 { limit = 100 } cutoff := now.UTC() if gracePeriod > 0 { cutoff = cutoff.Add(-gracePeriod) } rows, err := lifecycleRepo.ListFilesByLifecycle(ctx, filemanager.FileLifecycleListFilter{ Status: filemanager.FileLifecycleStatusDraft, CreatedBeforeEq: &cutoff, ExcludeInTrash: true, ExcludeLifecycleDeleted: true, Limit: limit, }) if err != nil { return stats, err } stats = s.reapUnreferencedFiles(ctx, lifecycleRepo, rows, dryRun) s.logger.Info("draft cleanup completed", "scanned", stats.Scanned, "deleted", stats.Deleted, "skipped", stats.Skipped, "failed", stats.Failed, "dry_run", dryRun) return stats, nil } func (s *FileManagerService) reapUnreferencedFiles( ctx context.Context, lifecycleRepo filemanager.FileLifecycleRepository, rows []filemanager.File, dryRun bool, ) filemanager.FileCleanupStats { stats := filemanager.FileCleanupStats{DryRun: dryRun} for i := range rows { row := rows[i] stats.Scanned++ referenced, refErr := lifecycleRepo.IsFileReferenced(ctx, row.ID) if refErr != nil { stats.Failed++ s.logger.Error("file cleanup: reference check failed", "file_id", fileIDString(row.ID), "error", refErr) continue } if referenced { if err := s.MarkFileActive(ctx, row.ID, nil); err != nil { stats.Failed++ s.logger.Error("file cleanup: re-activate failed", "file_id", fileIDString(row.ID), "error", err) continue } stats.Skipped++ continue } if dryRun { stats.Skipped++ s.logger.Info("file cleanup dry-run: candidate", "file_id", fileIDString(row.ID), "object_key", row.ObjectKey) continue } s.applyLifecycleTag(ctx, row.ObjectKey, lifecycleTagDelete) if err := s.deleteFileObjects(ctx, &row); err != nil { stats.Failed++ s.logger.Error("file cleanup: object delete failed", "file_id", fileIDString(row.ID), "error", err) continue } deletedAt := time.Now().UTC() if err := lifecycleRepo.UpdateLifecycle(ctx, filemanager.FileLifecycleTransitionParams{ ID: row.ID, Status: filemanager.FileLifecycleStatusDeleted, UpdatedBy: nil, LifecycleDelete: &deletedAt, ClearOrphanedAt: true, }); err != nil { stats.Failed++ s.logger.Error("file cleanup: mark deleted failed", "file_id", fileIDString(row.ID), "error", err) continue } stats.Deleted++ } return stats } const ( lifecycleTagKey = "gc" lifecycleTagTemp = "temp" lifecycleTagKeep = "keep" lifecycleTagDelete = "delete" ) func (s *FileManagerService) ensureLifecycleTempMarker(ctx context.Context, row *filemanager.File) { if row == nil { return } if row.DeletedAt != nil { return } status := filemanager.NormalizeFileLifecycleStatus(row.LifecycleStatus) if status != "" && status != filemanager.FileLifecycleStatusTemp { return } if lifecycleRepo, ok := s.fileRepo.(filemanager.FileLifecycleRepository); ok { if err := lifecycleRepo.UpdateLifecycle(ctx, filemanager.FileLifecycleTransitionParams{ ID: row.ID, Status: filemanager.FileLifecycleStatusTemp, UpdatedBy: row.UpdatedBy, ClearOrphanedAt: true, }); err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { s.logger.Error("failed to set temp lifecycle status", "file_id", fileIDString(row.ID), "error", err) } } s.applyLifecycleTag(ctx, row.ObjectKey, lifecycleTagTemp) } func (s *FileManagerService) applyLifecycleTag(ctx context.Context, objectKey, value string) { if !s.enableS3Tagging { return } tagger, ok := s.storage.(filemanager.ObjectTaggingStorage) if !ok { return } key := strings.TrimSpace(objectKey) val := strings.TrimSpace(value) if key == "" || val == "" { return } go func(k, v string) { bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := tagger.PutObjectTagging(bgCtx, k, map[string]string{lifecycleTagKey: v}); err != nil { s.logger.Error("failed to apply lifecycle object tag", "object_key", k, "tag", lifecycleTagKey+"="+v, "error", err) } }(key, val) } func (s *FileManagerService) deleteFileObjects(ctx context.Context, row *filemanager.File) error { if row == nil || s.storage == nil { return nil } keys := make([]string, 0, 2) if key := strings.TrimSpace(row.ObjectKey); key != "" { keys = append(keys, key) } if row.ThumbnailObjectKey != nil { if key := strings.TrimSpace(*row.ThumbnailObjectKey); key != "" { keys = append(keys, key) } } for i := range keys { if err := s.storage.DeleteObject(ctx, keys[i]); err != nil { return err } } return nil } func fileIDString(id []byte) string { v, err := uuidv7.BytesToString(id) if err != nil { return "" } return v } func normalizeFolderName(raw string) (string, string, error) { name := strings.TrimSpace(raw) if name == "" { return "", "", filemanager.ErrInvalidName } if name == "." || name == ".." || strings.ContainsAny(name, "/\\") { return "", "", filemanager.ErrInvalidName } nameNormalized := strings.ToLower(name) if nameNormalized == filemanager.SystemRootFolderName { return "", "", filemanager.ErrReservedName } return name, nameNormalized, nil } func normalizeFileName(raw string) (string, string, error) { name := strings.TrimSpace(raw) if name == "" { return "", "", filemanager.ErrInvalidName } name = strings.ReplaceAll(name, "\\", "/") name = filepath.Base(name) if name == "" || name == "." || name == "/" || strings.Contains(name, "\x00") { return "", "", filemanager.ErrInvalidName } return name, strings.ToLower(name), nil } func buildFolderPathCache(parentPath, name string) string { parentPath = strings.TrimSpace(parentPath) if parentPath == "" { return "/" + name } parentPath = strings.TrimRight(parentPath, "/") return parentPath + "/" + name } func extensionFromName(name string) string { ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(name)), ".") if len(ext) > 32 { ext = ext[:32] } return ext } func (s *FileManagerService) resolveAvailableFileName( ctx context.Context, folderID []byte, requestedName string, excludeID []byte, ) (string, string, error) { name, nameNormalized, err := normalizeFileName(requestedName) if err != nil { return "", "", err } available, err := s.isFileNameAvailable(ctx, folderID, nameNormalized, excludeID) if err != nil { return "", "", err } if available { return name, nameNormalized, nil } base, ext := splitNameAndExt(name) for i := 1; i <= 10000; i++ { candidateRaw := indexedFileName(base, ext, i) candidate, candidateNormalized, err := normalizeFileName(candidateRaw) if err != nil { return "", "", err } available, err := s.isFileNameAvailable(ctx, folderID, candidateNormalized, excludeID) if err != nil { return "", "", err } if available { return candidate, candidateNormalized, nil } } return "", "", filemanager.ErrDuplicateName } func (s *FileManagerService) isFileNameAvailable( ctx context.Context, folderID []byte, nameNormalized string, excludeID []byte, ) (bool, error) { dup, err := s.fileRepo.GetByFolderAndName(ctx, folderID, nameNormalized) if err != nil { return false, err } if dup == nil { return true, nil } if len(excludeID) == 16 && bytesEqual16(dup.ID, excludeID) { return true, nil } return false, nil } func splitNameAndExt(name string) (string, string) { ext := filepath.Ext(name) base := strings.TrimSuffix(name, ext) if base == "" { base = name ext = "" } return base, ext } func indexedFileName(base, ext string, idx int) string { suffix := fmt.Sprintf(" (%d)", idx) maxNameRunes := 255 allowedBaseRunes := maxNameRunes - utf8.RuneCountInString(ext) - utf8.RuneCountInString(suffix) if allowedBaseRunes < 1 { allowedBaseRunes = 1 } if utf8.RuneCountInString(base) > allowedBaseRunes { base = truncateRunes(base, allowedBaseRunes) base = strings.TrimSpace(base) if base == "" { base = "file" } } return base + suffix + ext } func truncateRunes(value string, maxRunes int) string { if maxRunes <= 0 { return "" } if utf8.RuneCountInString(value) <= maxRunes { return value } var b strings.Builder count := 0 for _, r := range value { if count >= maxRunes { break } b.WriteRune(r) count++ } return b.String() } func buildImmutableFileObjectKey(name string) string { uuidBytes := uuidv7.MustBytes() uuidStr, err := uuidv7.BytesToString(uuidBytes) if err != nil { uuidStr = strings.ReplaceAll(fmt.Sprintf("%x", uuidBytes), "-", "") } uuidStr = strings.ReplaceAll(uuidStr, "-", "") ext := filepath.Ext(name) ext = strings.ToLower(strings.TrimSpace(ext)) if len(ext) > 16 { ext = ext[:16] } datePrefix := time.Now().UTC().Format("2006/01/02") if ext == "" { return "files/" + datePrefix + "/" + uuidStr } return "files/" + datePrefix + "/" + uuidStr + ext } func tempUploadNameSlot() string { uuidBytes := uuidv7.MustBytes() slot, err := uuidv7.BytesToString(uuidBytes) if err == nil && strings.TrimSpace(slot) != "" { return slot } return strings.ReplaceAll(fmt.Sprintf("%x", uuidBytes), "-", "") } func ensureFileStatusTransition(fromStatus, toStatus string) error { from := filemanager.NormalizeFileStatus(fromStatus) to := filemanager.NormalizeFileStatus(toStatus) if !filemanager.CanTransitionFileStatus(from, to) { return fmt.Errorf("%w: %s -> %s", filemanager.ErrInvalidFileStatusTransition, from, to) } return nil } func normalizeUploadPolicy(policy filemanager.FileUploadPolicy) filemanager.FileUploadPolicy { normalized := filemanager.FileUploadPolicy{ MaxFileSizeBytes: policy.MaxFileSizeBytes, AllowEmptyFile: policy.AllowEmptyFile, MaxFilenameLength: policy.MaxFilenameLength, } if normalized.MaxFilenameLength <= 0 { normalized.MaxFilenameLength = 255 } if len(policy.AllowedMimeTypes) > 0 { seen := make(map[string]struct{}, len(policy.AllowedMimeTypes)) for i := range policy.AllowedMimeTypes { v := normalizeMimeTypeToken(policy.AllowedMimeTypes[i]) if v == "" { continue } if _, ok := seen[v]; ok { continue } seen[v] = struct{}{} normalized.AllowedMimeTypes = append(normalized.AllowedMimeTypes, v) } } if len(policy.AllowedExtensions) > 0 { seen := make(map[string]struct{}, len(policy.AllowedExtensions)) for i := range policy.AllowedExtensions { v := normalizeExtensionToken(policy.AllowedExtensions[i]) if v == "" { continue } if _, ok := seen[v]; ok { continue } seen[v] = struct{}{} normalized.AllowedExtensions = append(normalized.AllowedExtensions, v) } } return normalized } func (s *FileManagerService) validateUploadAgainstPolicy(fileName, contentType string, sizeBytes int64) error { policy := s.uploadPolicy if sizeBytes == 0 && !policy.AllowEmptyFile { return filemanager.ErrEmptyFileNotAllowed } if policy.MaxFileSizeBytes > 0 && sizeBytes > policy.MaxFileSizeBytes { return fmt.Errorf("%w: max %d bytes", filemanager.ErrFileTooLarge, policy.MaxFileSizeBytes) } if policy.MaxFilenameLength > 0 { nameLength := utf8.RuneCountInString(fileName) if nameLength > policy.MaxFilenameLength { return fmt.Errorf("%w: max %d characters", filemanager.ErrFileNameTooLong, policy.MaxFilenameLength) } } if len(policy.AllowedMimeTypes) > 0 { allowed := false normalizedMime := normalizeMimeTypeToken(contentType) for i := range policy.AllowedMimeTypes { if policy.AllowedMimeTypes[i] == normalizedMime { allowed = true break } } if !allowed { return fmt.Errorf("%w: %s", filemanager.ErrFileMimeTypeNotAllowed, normalizedMime) } } if len(policy.AllowedExtensions) > 0 { allowed := false ext := normalizeExtensionToken(extensionFromName(fileName)) for i := range policy.AllowedExtensions { if policy.AllowedExtensions[i] == ext { allowed = true break } } if !allowed { if ext == "" { return fmt.Errorf("%w: file has no extension", filemanager.ErrFileExtensionNotAllowed) } return fmt.Errorf("%w: .%s", filemanager.ErrFileExtensionNotAllowed, ext) } } return nil } func normalizeUploadedMimeType(raw string) string { normalized := normalizeMimeTypeToken(raw) if normalized == "" { return "application/octet-stream" } return normalized } func normalizeMimeTypeToken(raw string) string { v := strings.ToLower(strings.TrimSpace(raw)) if idx := strings.Index(v, ";"); idx >= 0 { v = strings.TrimSpace(v[:idx]) } return v } func normalizeExtensionToken(raw string) string { v := strings.ToLower(strings.TrimSpace(raw)) v = strings.TrimPrefix(v, ".") return v } func (s *FileManagerService) fileOutboxReady() bool { return s.fileOutbox != nil && s.fileOutboxSerializer != nil } func (s *FileManagerService) persistFileProcessingOutbox( ctx context.Context, outbox filemanager.FileProcessingOutboxWriter, row *filemanager.File, actorID []byte, ) error { if outbox == nil || !s.fileOutboxReady() { return filemanager.ErrFileProcessingQueueDisabled } job, err := buildFileProcessingJob(row, actorID) if err != nil { return err } message, err := s.fileOutboxSerializer.Serialize(ctx, queue.FileProcessingJob{ JobID: job.JobID, FileUUID: job.FileUUID, Bucket: job.Bucket, ObjectKey: job.ObjectKey, UploadedBy: job.UploadedBy, MimeType: job.MimeType, SizeBytes: job.SizeBytes, TraceID: job.TraceID, RequestID: job.RequestID, }) if err != nil { return err } record, err := filemanager.NewFileProcessingOutboxMessage(time.Now().UTC(), &filemanager.FileProcessingOutboundMessage{ ID: message.ID, Body: append([]byte(nil), message.Body...), Attributes: message.Attributes, MessageGroupID: message.MessageGroupID, DeduplicationID: message.DeduplicationID, }) if err != nil { return err } return outbox.CreateFileProcessingOutboxMessage(ctx, record) } func (s *FileManagerService) enqueueUploadedFileForProcessing(ctx context.Context, row *filemanager.File, actorID []byte) error { if row == nil { return filemanager.ErrFileNotFound } if s.fileQueue == nil { return filemanager.ErrFileProcessingQueueDisabled } job, err := buildFileProcessingJob(row, actorID) if err != nil { return err } return s.fileQueue.Enqueue(ctx, job) } func (s *FileManagerService) verifyUploadedObjectForIntent(ctx context.Context, intent *filemanager.FileUploadIntent) (filemanager.ObjectMetadata, error) { if intent == nil { return filemanager.ObjectMetadata{}, filemanager.ErrUploadIntentNotFound } meta, err := s.storage.HeadObject(ctx, intent.ObjectKey) if err != nil { if errors.Is(err, filemanager.ErrUploadObjectNotFound) { return filemanager.ObjectMetadata{}, filemanager.ErrUploadObjectNotFound } return filemanager.ObjectMetadata{}, err } if strings.TrimSpace(meta.ObjectKey) == "" { meta.ObjectKey = strings.TrimSpace(intent.ObjectKey) } if strings.TrimSpace(meta.Bucket) == "" { meta.Bucket = strings.TrimSpace(intent.Bucket) } if strings.TrimSpace(meta.ObjectKey) != strings.TrimSpace(intent.ObjectKey) { return filemanager.ObjectMetadata{}, fmt.Errorf("%w: object key mismatch", filemanager.ErrUploadObjectMismatch) } if expectedBucket := strings.TrimSpace(intent.Bucket); expectedBucket != "" && !strings.EqualFold(strings.TrimSpace(meta.Bucket), expectedBucket) { return filemanager.ObjectMetadata{}, fmt.Errorf("%w: bucket mismatch", filemanager.ErrUploadObjectMismatch) } if intent.SizeBytes >= 0 && meta.SizeBytes != intent.SizeBytes { return filemanager.ObjectMetadata{}, fmt.Errorf("%w: expected size %d bytes, got %d bytes", filemanager.ErrUploadObjectMismatch, intent.SizeBytes, meta.SizeBytes) } expectedMime := normalizeMimeTypeToken(intent.MimeType) actualMime := normalizeMimeTypeToken(meta.ContentType) if expectedMime != "" && actualMime != "" && expectedMime != actualMime { return filemanager.ObjectMetadata{}, fmt.Errorf("%w: expected mime %s, got %s", filemanager.ErrUploadObjectMismatch, expectedMime, actualMime) } return meta, nil } func (s *FileManagerService) createUploadedFileFromIntent( ctx context.Context, intent *filemanager.FileUploadIntent, meta filemanager.ObjectMetadata, actorID []byte, ) (*filemanager.File, error) { if intent == nil { return nil, filemanager.ErrUploadIntentNotFound } root, err := s.GetOrCreateSystemRootFolder(ctx) if err != nil { return nil, err } sizeBytes := intent.SizeBytes if meta.SizeBytes >= 0 { sizeBytes = meta.SizeBytes } mimeType := normalizeUploadedMimeType(firstNonEmptyString(meta.ContentType, intent.MimeType)) row := &filemanager.File{ ID: uuidv7.MustBytes(), FolderID: root.ID, Name: intent.Name, NameNormalized: intent.NameNormalized, Extension: firstNonEmptyString(intent.Extension, extensionFromName(intent.Name)), SizeBytes: sizeBytes, MimeType: mimeType, Bucket: firstNonEmptyString(meta.Bucket, intent.Bucket), ObjectKey: firstNonEmptyString(meta.ObjectKey, intent.ObjectKey), ObjectVersion: strings.TrimSpace(meta.VersionID), ETag: strings.TrimSpace(meta.ETag), IsTemplate: false, Status: filemanager.FileStatusUploaded, LifecycleStatus: filemanager.FileLifecycleStatusTemp, ProcessingStartedAt: nil, ProcessedAt: nil, ValidatedAt: nil, NameSlot: tempUploadNameSlot(), CreatedBy: actorID, UpdatedBy: actorID, } if err := s.fileRepo.Create(ctx, row); err != nil { return nil, err } created, err := s.fileRepo.GetByID(ctx, row.ID) if err == nil && created != nil { return created, nil } return row, nil } func (s *FileManagerService) completeFileUploadIntentDurable( ctx context.Context, txRepo filemanager.FileTransactionalRepository, intent *filemanager.FileUploadIntent, meta filemanager.ObjectMetadata, actorID []byte, ) (*filemanager.File, error) { var completedRow *filemanager.File root, err := s.GetOrCreateSystemRootFolder(ctx) if err != nil { return nil, err } if err := txRepo.WithTransaction(ctx, func(repo filemanager.FileRepository, outbox filemanager.FileProcessingOutboxWriter) error { intentRepo, ok := repo.(filemanager.FileUploadIntentRepository) if !ok { return filemanager.ErrUploadIntentRepositoryDisabled } lockedIntent, err := intentRepo.GetUploadIntentByIDForUpdate(ctx, intent.ID) if err != nil { return err } if lockedIntent == nil { return filemanager.ErrUploadIntentNotFound } status := filemanager.NormalizeFileUploadIntentStatus(lockedIntent.Status) switch status { case filemanager.FileUploadIntentStatusCompleted: existing, getErr := repo.GetByObjectKey(ctx, lockedIntent.ObjectKey) if getErr != nil { return getErr } if existing != nil { completedRow = existing return nil } return filemanager.ErrUploadIntentAlreadyComplete case filemanager.FileUploadIntentStatusExpired: return filemanager.ErrUploadIntentExpired case filemanager.FileUploadIntentStatusPending: default: return filemanager.ErrUploadIntentStatusConflict } now := time.Now().UTC() if !lockedIntent.ExpiresAt.IsZero() && now.After(lockedIntent.ExpiresAt.UTC()) { if err := intentRepo.MarkUploadIntentExpired(ctx, filemanager.FileUploadIntentExpireParams{ ID: lockedIntent.ID, UpdatedBy: actorID, ExpiredAt: now, }); err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } return filemanager.ErrUploadIntentExpired } existing, err := repo.GetByObjectKey(ctx, lockedIntent.ObjectKey) if err != nil { return err } if existing != nil { completedRow = existing if err := intentRepo.MarkUploadIntentCompleted(ctx, filemanager.FileUploadIntentCompleteParams{ ID: lockedIntent.ID, UpdatedBy: actorID, CompletedAt: now, }); err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } return nil } sizeBytes := lockedIntent.SizeBytes if meta.SizeBytes >= 0 { sizeBytes = meta.SizeBytes } row := &filemanager.File{ ID: uuidv7.MustBytes(), FolderID: root.ID, Name: lockedIntent.Name, NameNormalized: lockedIntent.NameNormalized, Extension: firstNonEmptyString(lockedIntent.Extension, extensionFromName(lockedIntent.Name)), SizeBytes: sizeBytes, MimeType: normalizeUploadedMimeType(firstNonEmptyString(meta.ContentType, lockedIntent.MimeType)), Bucket: firstNonEmptyString(meta.Bucket, lockedIntent.Bucket), ObjectKey: firstNonEmptyString(meta.ObjectKey, lockedIntent.ObjectKey), ObjectVersion: strings.TrimSpace(meta.VersionID), ETag: strings.TrimSpace(meta.ETag), Status: filemanager.FileStatusUploaded, LifecycleStatus: filemanager.FileLifecycleStatusTemp, ProcessingStartedAt: nil, ProcessedAt: nil, ValidatedAt: nil, NameSlot: tempUploadNameSlot(), CreatedBy: actorID, UpdatedBy: actorID, } if err := repo.Create(ctx, row); err != nil { return err } if err := s.persistFileProcessingOutbox(ctx, outbox, row, actorID); err != nil { return err } if err := intentRepo.MarkUploadIntentCompleted(ctx, filemanager.FileUploadIntentCompleteParams{ ID: lockedIntent.ID, UpdatedBy: actorID, CompletedAt: now, }); err != nil { return err } completedRow = row return nil }); err != nil { return nil, err } if completedRow == nil { return nil, filemanager.ErrFileNotFound } if len(completedRow.ID) == 16 { created, err := s.fileRepo.GetByID(ctx, completedRow.ID) if err == nil && created != nil { return created, nil } } return completedRow, nil } func (s *FileManagerService) emitFileStatusRealtimeEvent( ctx context.Context, row *filemanager.File, status string, actorID []byte, failureReason string, ) { if s.fileStatusEvents == nil || row == nil { return } if len(row.ID) == 0 { return } status = filemanager.NormalizeFileStatus(status) if !filemanager.IsKnownFileStatus(status) { return } userID := pickRealtimeEventUserID(actorID, row.CreatedBy, row.UpdatedBy) if len(userID) == 0 { return } name := strings.TrimSpace(row.Name) reason := strings.TrimSpace(failureReason) if reason == "" { reason = strings.TrimSpace(row.FailureReason) } occurredAt := row.UpdatedAt if occurredAt.IsZero() { occurredAt = time.Now().UTC() } _ = s.fileStatusEvents.CreateFileStatusRealtimeEvent(ctx, filemanager.FileStatusRealtimeEventInput{ FileID: append([]byte(nil), row.ID...), UserID: userID, Status: status, Name: name, FailureReason: reason, OccurredAt: occurredAt, }) } func pickRealtimeEventUserID(candidates ...[]byte) []byte { for i := range candidates { if len(candidates[i]) == 16 { return append([]byte(nil), candidates[i]...) } } return nil } func buildFileProcessingJob(row *filemanager.File, actorID []byte) (filemanager.FileProcessingJob, error) { if row == nil { return filemanager.FileProcessingJob{}, filemanager.ErrFileNotFound } job := filemanager.FileProcessingJob{ Bucket: strings.TrimSpace(row.Bucket), ObjectKey: strings.TrimSpace(row.ObjectKey), MimeType: strings.TrimSpace(row.MimeType), SizeBytes: row.SizeBytes, UploadedBy: bytesToUUIDString(actorID), } fileUUID, err := uuidv7.BytesToString(row.ID) if err != nil { return filemanager.FileProcessingJob{}, fmt.Errorf("convert file id to uuid: %w", err) } job.FileUUID = fileUUID return job, nil } func truncateFailureReason(raw string) string { const maxReasonLength = 1024 raw = strings.TrimSpace(raw) if len(raw) <= maxReasonLength { return raw } return raw[:maxReasonLength] } func bytesToUUIDString(raw []byte) string { if len(raw) == 0 { return "" } value, err := uuidv7.BytesToString(raw) if err != nil { return "" } return value } func firstNonEmptyString(values ...string) string { for i := range values { value := strings.TrimSpace(values[i]) if value != "" { return value } } return "" } func cloneTrimmedStringMap(input map[string]string) map[string]string { if len(input) == 0 { return nil } out := make(map[string]string, len(input)) for key, value := range input { k := strings.TrimSpace(key) if k == "" { continue } out[k] = strings.TrimSpace(value) } if len(out) == 0 { return nil } return out } func (s *FileManagerService) collectFolderSubtreeAny(ctx context.Context, rootID []byte) ([]filemanager.Folder, error) { root, err := s.folderRepo.GetByIDAny(ctx, rootID) if err != nil { return nil, err } if root == nil { return nil, nil } out := make([]filemanager.Folder, 0, 16) queue := make([]filemanager.Folder, 0, 16) seen := make(map[string]struct{}, 16) rootUUID := bytesToUUIDString(root.ID) seen[rootUUID] = struct{}{} out = append(out, *root) queue = append(queue, *root) for len(queue) > 0 { parent := queue[0] queue = queue[1:] activeChildren, _, err := s.folderRepo.ListByParent(ctx, parent.ID, "name ASC", 0, 0) if err != nil { return nil, err } trashedChildren, _, err := s.folderRepo.ListTrashByParent(ctx, parent.ID, "name ASC", 0, 0) if err != nil { return nil, err } for i := range activeChildren { child := activeChildren[i] key := bytesToUUIDString(child.ID) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, child) queue = append(queue, child) } for i := range trashedChildren { child := trashedChildren[i] key := bytesToUUIDString(child.ID) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, child) queue = append(queue, child) } } return out, nil } func (s *FileManagerService) listActiveFilesByFolders(ctx context.Context, folderIDs [][]byte) ([]filemanager.File, error) { out := make([]filemanager.File, 0, len(folderIDs)) for i := range folderIDs { rows, _, err := s.fileRepo.ListByFolder(ctx, folderIDs[i], "name ASC", 0, 0) if err != nil { return nil, err } out = append(out, rows...) } return out, nil } func (s *FileManagerService) listTrashedFilesByFolders(ctx context.Context, folderIDs [][]byte) ([]filemanager.File, error) { out := make([]filemanager.File, 0, len(folderIDs)) for i := range folderIDs { rows, _, err := s.fileRepo.ListTrashByFolder(ctx, folderIDs[i], "name ASC", 0, 0) if err != nil { return nil, err } out = append(out, rows...) } return out, nil } func (s *FileManagerService) listAllFilesByFolders(ctx context.Context, folderIDs [][]byte) ([]filemanager.File, error) { activeRows, err := s.listActiveFilesByFolders(ctx, folderIDs) if err != nil { return nil, err } trashedRows, err := s.listTrashedFilesByFolders(ctx, folderIDs) if err != nil { return nil, err } out := make([]filemanager.File, 0, len(activeRows)+len(trashedRows)) seen := make(map[string]struct{}, len(activeRows)+len(trashedRows)) appendUnique := func(rows []filemanager.File) { for i := range rows { key := bytesToUUIDString(rows[i].ID) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, rows[i]) } } appendUnique(activeRows) appendUnique(trashedRows) return out, nil } func uniqueNonEmptyStrings(values []string) []string { if len(values) == 0 { return nil } out := make([]string, 0, len(values)) seen := make(map[string]struct{}, len(values)) for i := range values { value := strings.TrimSpace(values[i]) if value == "" { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} out = append(out, value) } return out } func replaceFolderPathPrefix(pathCache string, oldPrefix string, newPrefix string) string { if oldPrefix == "" { return pathCache } if pathCache == oldPrefix { return newPrefix } prefixWithSlash := oldPrefix + "/" if strings.HasPrefix(pathCache, prefixWithSlash) { return newPrefix + strings.TrimPrefix(pathCache, oldPrefix) } return pathCache } func bytesEqual16(a, b []byte) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } func isSystemRootFolder(row *filemanager.Folder) bool { if row == nil { return false } return len(row.ParentID) == 0 && strings.EqualFold(strings.TrimSpace(row.NameNormalized), filemanager.SystemRootFolderName) } func normalizeFolderSort(sort string) string { if normalized := normalizeSortByRules(sort, sortField("name"), sortField("depth"), sortField("created_at"), sortField("updated_at", "updated_at", "updated_by"), ); normalized != "" { return normalized } return "name ASC" } func normalizeFileSort(sort string) string { if normalized := normalizeSortByRules(sort, sortField("name"), sortField("size_bytes"), sortField("mime_type"), sortField("created_at"), sortField("updated_at", "updated_at", "updated_by"), ); normalized != "" { return normalized } return "name ASC" } func normalizeAttachmentSort(sort string) string { if normalized := normalizeSortByRules(sort, sortExpr("sort_order ASC, created_at DESC", "sort_order DESC, created_at DESC", "sort_order"), sortField("created_at"), sortField("updated_at", "updated_at", "updated_by"), ); normalized != "" { return normalized } return "sort_order ASC, created_at DESC" } func normalizeOptionalString(value *string) *string { if value == nil { return nil } cleaned := strings.TrimSpace(*value) if cleaned == "" { return nil } return &cleaned } func (s *FileManagerService) touchFolders(ctx context.Context, updatedBy []byte, folderIDs ...[]byte) error { seen := make(map[string]struct{}, len(folderIDs)) now := time.Now().UTC() queue := make([][]byte, 0, len(folderIDs)) for i := range folderIDs { if len(folderIDs[i]) != 0 { queue = append(queue, append([]byte(nil), folderIDs[i]...)) } } for len(queue) > 0 { id := queue[0] queue = queue[1:] if len(id) == 0 { continue } key := string(id) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} folder, err := s.folderRepo.GetByIDAny(ctx, id) if err != nil { return err } if folder == nil { continue } if len(folder.ParentID) != 0 { queue = append(queue, append([]byte(nil), folder.ParentID...)) } if folder.DeletedAt != nil { continue } folder.UpdatedBy = append([]byte(nil), updatedBy...) folder.UpdatedAt = now if err := s.folderRepo.Update(ctx, folder); err != nil { return err } } return nil }