Files
fm_be/internal/service/file_manager_service.go
2026-07-16 22:16:45 +07:00

2975 lines
83 KiB
Go

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
}