2975 lines
83 KiB
Go
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
|
|
}
|