diff --git a/internal/biz/biz.go b/internal/biz/biz.go new file mode 100644 index 0000000..f95c803 --- /dev/null +++ b/internal/biz/biz.go @@ -0,0 +1,10 @@ +package biz + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet( + NewFileUsecase, + NewBucketUsecase, + NewFolderUsecase, + NewShareUsecase, +) diff --git a/internal/biz/bucket.go b/internal/biz/bucket.go new file mode 100644 index 0000000..6f9a435 --- /dev/null +++ b/internal/biz/bucket.go @@ -0,0 +1,36 @@ +package biz + +import ( + "context" + + "github.com/go-kratos/kratos/v2/log" +) + +// BucketUsecase wraps FileRepo bucket operations. +type BucketUsecase struct { + repo FileRepo + log *log.Helper +} + +// NewBucketUsecase creates a new BucketUsecase. +func NewBucketUsecase(repo FileRepo, logger log.Logger) *BucketUsecase { + return &BucketUsecase{ + repo: repo, + log: log.NewHelper(logger), + } +} + +// ListBuckets returns all bucket names. +func (uc *BucketUsecase) ListBuckets(ctx context.Context) ([]string, error) { + return uc.repo.ListBuckets(ctx) +} + +// CreateBucket creates a new S3 bucket. +func (uc *BucketUsecase) CreateBucket(ctx context.Context, name string) error { + return uc.repo.CreateBucket(ctx, name) +} + +// DeleteBucket deletes an S3 bucket. +func (uc *BucketUsecase) DeleteBucket(ctx context.Context, name string) error { + return uc.repo.DeleteBucket(ctx, name) +} diff --git a/internal/biz/file.go b/internal/biz/file.go new file mode 100644 index 0000000..d7f1412 --- /dev/null +++ b/internal/biz/file.go @@ -0,0 +1,97 @@ +package biz + +import ( + "context" + "io" + "time" + + "rag/file-system/internal/data" + + "github.com/go-kratos/kratos/v2/log" +) + +// FileRepo defines the interface for S3 storage operations. +type FileRepo interface { + UploadFile(ctx context.Context, bucket, key string, fileData io.Reader) error + DownloadFile(ctx context.Context, bucket, key string) (io.ReadCloser, error) + ListBuckets(ctx context.Context) ([]string, error) + CreateBucket(ctx context.Context, name string) error + DeleteBucket(ctx context.Context, name string) error + ListObjectsV2(ctx context.Context, bucket, prefix string, maxKeys int32, token *string) (*data.ListFilesResult, error) + GeneratePresignedURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error) + GetFileContent(ctx context.Context, bucket, key string) (string, error) + DeleteFile(ctx context.Context, bucket, key string) error + CreateMultipartUpload(ctx context.Context, bucket, key string) (string, error) + UploadPart(ctx context.Context, bucket, key, uploadID string, partNumber int32, fileData io.Reader) (string, error) + CompleteMultipartUpload(ctx context.Context, bucket, key, uploadID string, parts []data.Part) (string, error) + AbortMultipartUpload(ctx context.Context, bucket, key, uploadID string) error +} + +// FileUsecase wraps FileRepo and provides file-level business operations. +type FileUsecase struct { + repo FileRepo + log *log.Helper +} + +// NewFileUsecase creates a new FileUsecase. +func NewFileUsecase(repo FileRepo, logger log.Logger) *FileUsecase { + return &FileUsecase{ + repo: repo, + log: log.NewHelper(logger), + } +} + +// UploadFile uploads data to the specified bucket and key. +func (uc *FileUsecase) UploadFile(ctx context.Context, bucket, key string, fileData io.Reader) error { + return uc.repo.UploadFile(ctx, bucket, key, fileData) +} + +// DownloadFile downloads an object from S3. +func (uc *FileUsecase) DownloadFile(ctx context.Context, bucket, key string) (io.ReadCloser, error) { + return uc.repo.DownloadFile(ctx, bucket, key) +} + +// GetFileContent retrieves text content for preview. +func (uc *FileUsecase) GetFileContent(ctx context.Context, bucket, key string) (string, error) { + return uc.repo.GetFileContent(ctx, bucket, key) +} + +// DeleteFile removes a file from S3. +func (uc *FileUsecase) DeleteFile(ctx context.Context, bucket, key string) error { + return uc.repo.DeleteFile(ctx, bucket, key) +} + +// ListObjectsV2 lists files with pagination support. +func (uc *FileUsecase) ListObjectsV2(ctx context.Context, bucket, prefix string, maxKeys int32, token *string) (*data.ListFilesResult, error) { + return uc.repo.ListObjectsV2(ctx, bucket, prefix, maxKeys, token) +} + +// GeneratePresignedURL generates a presigned URL with custom expiry. +func (uc *FileUsecase) GeneratePresignedURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error) { + return uc.repo.GeneratePresignedURL(ctx, bucket, key, expiry) +} + +// GetPreviewURL generates a presigned URL with 24h expiry for file preview. +func (uc *FileUsecase) GetPreviewURL(ctx context.Context, bucket, key string) (string, error) { + return uc.repo.GeneratePresignedURL(ctx, bucket, key, 24*time.Hour) +} + +// CreateMultipartUpload initializes a multipart upload session. +func (uc *FileUsecase) CreateMultipartUpload(ctx context.Context, bucket, key string) (string, error) { + return uc.repo.CreateMultipartUpload(ctx, bucket, key) +} + +// UploadPart uploads a single part of a multipart upload. +func (uc *FileUsecase) UploadPart(ctx context.Context, bucket, key, uploadID string, partNumber int32, fileData io.Reader) (string, error) { + return uc.repo.UploadPart(ctx, bucket, key, uploadID, partNumber, fileData) +} + +// CompleteMultipartUpload assembles all parts to complete the upload. +func (uc *FileUsecase) CompleteMultipartUpload(ctx context.Context, bucket, key, uploadID string, parts []data.Part) (string, error) { + return uc.repo.CompleteMultipartUpload(ctx, bucket, key, uploadID, parts) +} + +// AbortMultipartUpload cancels an in-progress multipart upload. +func (uc *FileUsecase) AbortMultipartUpload(ctx context.Context, bucket, key, uploadID string) error { + return uc.repo.AbortMultipartUpload(ctx, bucket, key, uploadID) +} diff --git a/internal/biz/folder.go b/internal/biz/folder.go new file mode 100644 index 0000000..1cb05d7 --- /dev/null +++ b/internal/biz/folder.go @@ -0,0 +1,155 @@ +package biz + +import ( + "bytes" + "context" + "fmt" + "time" + + "rag/file-system/internal/data" + "rag/file-system/internal/pkg/sanitize" + + "github.com/go-kratos/kratos/v2/log" + "github.com/google/uuid" +) + +// FolderRepo defines the interface for folder persistence operations. +type FolderRepo interface { + Create(ctx context.Context, folder *data.FolderPO) error + GetByID(ctx context.Context, id string) (*data.FolderPO, error) + GetWithChildren(ctx context.Context, id, ownerID string) (*data.FolderWithChildren, error) + GetTree(ctx context.Context, ownerID string) ([]data.FolderPO, error) + Update(ctx context.Context, folder *data.FolderPO) error + Delete(ctx context.Context, id, ownerID string) error + GetDescendantFileS3Keys(ctx context.Context, id, ownerID string) ([]data.FileMetaPO, error) +} + +// FileMetaRepo defines the interface for file metadata persistence operations. +type FileMetaRepo interface { + Create(ctx context.Context, file *data.FileMetaPO) error + GetByID(ctx context.Context, id string) (*data.FileMetaPO, error) + GetByFolder(ctx context.Context, folderID string) ([]data.FileMetaPO, error) + Move(ctx context.Context, fileID, targetFolderID, ownerID string) error + Delete(ctx context.Context, id, ownerID string) error +} + +// FolderUsecase handles folder and file metadata business logic. +type FolderUsecase struct { + folderRepo FolderRepo + fileRepo FileMetaRepo + s3Repo FileRepo + log *log.Helper +} + +// NewFolderUsecase creates a new FolderUsecase. +func NewFolderUsecase(folderRepo FolderRepo, fileRepo FileMetaRepo, s3Repo FileRepo, logger log.Logger) *FolderUsecase { + return &FolderUsecase{ + folderRepo: folderRepo, + fileRepo: fileRepo, + s3Repo: s3Repo, + log: log.NewHelper(logger), + } +} + +// CreateFolder creates a new folder under the given parent. +func (uc *FolderUsecase) CreateFolder(ctx context.Context, parentID *string, name, ownerID string) (*data.FolderPO, error) { + folder := &data.FolderPO{ + ParentID: parentID, + Name: sanitize.Filename(name), + OwnerID: ownerID, + } + if err := uc.folderRepo.Create(ctx, folder); err != nil { + return nil, fmt.Errorf("failed to create folder: %w", err) + } + return folder, nil +} + +// GetFolder retrieves a folder with its children (sub-folders and files). +func (uc *FolderUsecase) GetFolder(ctx context.Context, id, ownerID string) (*data.FolderPO, []data.FolderPO, []data.FileMetaPO, error) { + result, err := uc.folderRepo.GetWithChildren(ctx, id, ownerID) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to get folder: %w", err) + } + if result == nil { + return nil, nil, nil, nil + } + return &result.Folder, result.SubFolders, result.Files, nil +} + +// GetFolderTree retrieves all folders owned by the given owner. +func (uc *FolderUsecase) GetFolderTree(ctx context.Context, ownerID string) ([]data.FolderPO, error) { + return uc.folderRepo.GetTree(ctx, ownerID) +} + +// RenameFolder updates a folder's name. +func (uc *FolderUsecase) RenameFolder(ctx context.Context, id, name, ownerID string) (*data.FolderPO, error) { + folder, err := uc.folderRepo.GetByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get folder: %w", err) + } + if folder == nil || folder.OwnerID != ownerID { + return nil, fmt.Errorf("folder not found or not owned by user") + } + folder.Name = sanitize.Filename(name) + folder.UpdatedAt = time.Now().UTC() + if err := uc.folderRepo.Update(ctx, folder); err != nil { + return nil, fmt.Errorf("failed to rename folder: %w", err) + } + return folder, nil +} + +// DeleteFolder deletes a folder and all descendant files from both S3 and the database. +func (uc *FolderUsecase) DeleteFolder(ctx context.Context, id, ownerID string) error { + // First, get all descendant file S3 keys so we can delete from S3. + files, err := uc.folderRepo.GetDescendantFileS3Keys(ctx, id, ownerID) + if err != nil { + return fmt.Errorf("failed to get descendant files: %w", err) + } + + // Delete each file from S3. + for _, f := range files { + if delErr := uc.s3Repo.DeleteFile(ctx, f.S3Bucket, f.S3Key); delErr != nil { + uc.log.Errorf("failed to delete S3 object %s/%s: %v", f.S3Bucket, f.S3Key, delErr) + // Continue deleting other files even if one fails. + } + } + + // Delete the folder record (cascade should handle child files in DB). + if err := uc.folderRepo.Delete(ctx, id, ownerID); err != nil { + return fmt.Errorf("failed to delete folder: %w", err) + } + return nil +} + +// UploadToFolder uploads a file to a folder: generates a UUID S3 key, uploads to S3, saves metadata. +func (uc *FolderUsecase) UploadToFolder(ctx context.Context, folderID, fileName string, fileData []byte, contentType, ownerID string) (*data.FileMetaPO, error) { + s3Key := uuid.New().String() + safeName := sanitize.Filename(fileName) + bucket := "files" // Default bucket for folder-based uploads. + + if err := uc.s3Repo.UploadFile(ctx, bucket, s3Key, bytes.NewReader(fileData)); err != nil { + return nil, fmt.Errorf("failed to upload file to S3: %w", err) + } + + meta := &data.FileMetaPO{ + FolderID: folderID, + Name: safeName, + S3Key: s3Key, + S3Bucket: bucket, + Size: int64(len(fileData)), + ContentType: contentType, + OwnerID: ownerID, + } + if err := uc.fileRepo.Create(ctx, meta); err != nil { + // Attempt to clean up the S3 object on metadata save failure. + _ = uc.s3Repo.DeleteFile(ctx, bucket, s3Key) + return nil, fmt.Errorf("failed to save file metadata: %w", err) + } + return meta, nil +} + +// MoveFile moves a file from one folder to another. +func (uc *FolderUsecase) MoveFile(ctx context.Context, fileID, targetFolderID, ownerID string) error { + return uc.fileRepo.Move(ctx, fileID, targetFolderID, ownerID) +} + diff --git a/internal/biz/share.go b/internal/biz/share.go new file mode 100644 index 0000000..9aa80e9 --- /dev/null +++ b/internal/biz/share.go @@ -0,0 +1,146 @@ +package biz + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "rag/file-system/internal/data" + + "github.com/go-kratos/kratos/v2/log" +) + +// ShareRepo defines the interface for share link persistence operations. +type ShareRepo interface { + Create(ctx context.Context, share *data.ShareLinkPO) error + GetByToken(ctx context.Context, token string) (*data.ShareLinkPO, error) + GetByID(ctx context.Context, id string) (*data.ShareLinkPO, error) + Delete(ctx context.Context, id, createdBy string) error + IncrementDownloadCount(ctx context.Context, token string) error + ListByResource(ctx context.Context, resourceType, resourceID string) ([]data.ShareLinkPO, error) +} + +// FileMetaRepoForShare is a minimal interface for share usecase to look up file metadata. +type FileMetaRepoForShare interface { + GetByID(ctx context.Context, id string) (*data.FileMetaPO, error) +} + +// ShareUsecase handles share link business logic. +type ShareUsecase struct { + shareRepo ShareRepo + fileRepo FileMetaRepoForShare + s3Repo FileRepo + log *log.Helper +} + +// NewShareUsecase creates a new ShareUsecase. +func NewShareUsecase(shareRepo ShareRepo, fileRepo FileMetaRepoForShare, s3Repo FileRepo, logger log.Logger) *ShareUsecase { + return &ShareUsecase{ + shareRepo: shareRepo, + fileRepo: fileRepo, + s3Repo: s3Repo, + log: log.NewHelper(logger), + } +} + +// CreateShare creates a new share link with a random token. +func (uc *ShareUsecase) CreateShare(ctx context.Context, resourceType, resourceID, password string, expiresAt *time.Time, maxDownloads *int, createdBy string) (*data.ShareLinkPO, error) { + token, err := generateToken(16) + if err != nil { + return nil, fmt.Errorf("failed to generate share token: %w", err) + } + + share := &data.ShareLinkPO{ + ResourceType: resourceType, + ResourceID: resourceID, + Token: token, + ExpiresAt: expiresAt, + MaxDownloads: maxDownloads, + CreatedBy: createdBy, + } + if password != "" { + pwd := password + share.Password = &pwd + } + + if err := uc.shareRepo.Create(ctx, share); err != nil { + return nil, fmt.Errorf("failed to create share link: %w", err) + } + return share, nil +} + +// DeleteShare removes a share link by ID and creator. +func (uc *ShareUsecase) DeleteShare(ctx context.Context, id, createdBy string) error { + return uc.shareRepo.Delete(ctx, id, createdBy) +} + +// GetShareInfo retrieves share details and the associated file metadata. +func (uc *ShareUsecase) GetShareInfo(ctx context.Context, token string) (*data.ShareLinkPO, *data.FileMetaPO, error) { + share, err := uc.shareRepo.GetByToken(ctx, token) + if err != nil { + return nil, nil, fmt.Errorf("failed to get share link: %w", err) + } + if share == nil { + return nil, nil, nil + } + + fileMeta, err := uc.fileRepo.GetByID(ctx, share.ResourceID) + if err != nil { + return share, nil, fmt.Errorf("failed to get file metadata: %w", err) + } + return share, fileMeta, nil +} + +// DownloadShare validates the share link, increments download count, and returns a presigned URL + filename. +func (uc *ShareUsecase) DownloadShare(ctx context.Context, token string) (string, string, error) { + share, err := uc.shareRepo.GetByToken(ctx, token) + if err != nil { + return "", "", fmt.Errorf("failed to get share link: %w", err) + } + if share == nil { + return "", "", fmt.Errorf("share link not found") + } + + // Check expiry. + if share.ExpiresAt != nil && share.ExpiresAt.Before(time.Now().UTC()) { + return "", "", fmt.Errorf("share link has expired") + } + + // Check max downloads. + if share.MaxDownloads != nil && share.DownloadCount >= *share.MaxDownloads { + return "", "", fmt.Errorf("download limit reached") + } + + // Get file metadata for bucket/key info. + fileMeta, err := uc.fileRepo.GetByID(ctx, share.ResourceID) + if err != nil { + return "", "", fmt.Errorf("failed to get file metadata: %w", err) + } + if fileMeta == nil { + return "", "", fmt.Errorf("file not found") + } + + // Generate presigned URL. + url, err := uc.s3Repo.GeneratePresignedURL(ctx, fileMeta.S3Bucket, fileMeta.S3Key, 15*time.Minute) + if err != nil { + return "", "", fmt.Errorf("failed to generate download URL: %w", err) + } + + // Increment download count. + if err := uc.shareRepo.IncrementDownloadCount(ctx, token); err != nil { + uc.log.Errorf("failed to increment download count for token %s: %v", token, err) + } + + return url, fileMeta.Name, nil +} + +// generateToken creates a cryptographically secure random hex token. +func generateToken(byteLen int) (string, error) { + b := make([]byte, byteLen) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/internal/domain/model/file_meta.go b/internal/domain/model/file_meta.go deleted file mode 100644 index 914fc98..0000000 --- a/internal/domain/model/file_meta.go +++ /dev/null @@ -1,16 +0,0 @@ -package model - -import "time" - -type FileMeta struct { - ID string - FolderID string - Name string - S3Key string - S3Bucket string - Size int64 - ContentType string - OwnerID string - CreatedAt time.Time - UpdatedAt time.Time -} diff --git a/internal/domain/model/folder.go b/internal/domain/model/folder.go deleted file mode 100644 index 864c1f7..0000000 --- a/internal/domain/model/folder.go +++ /dev/null @@ -1,18 +0,0 @@ -package model - -import "time" - -type Folder struct { - ID string - ParentID *string - Name string - OwnerID string - CreatedAt time.Time - UpdatedAt time.Time -} - -type FolderWithChildren struct { - Folder Folder - SubFolders []Folder - Files []FileMeta -} diff --git a/internal/domain/model/share_link.go b/internal/domain/model/share_link.go deleted file mode 100644 index 9e9ae0b..0000000 --- a/internal/domain/model/share_link.go +++ /dev/null @@ -1,25 +0,0 @@ -package model - -import "time" - -type ShareLink struct { - ID string - ResourceType string - ResourceID string - Token string - Password *string - ExpiresAt *time.Time - DownloadCount int - MaxDownloads *int - CreatedBy string - CreatedAt time.Time -} - -type ShareInfo struct { - Token string - ResourceType string - FileName string - FileSize int64 - HasPassword bool - ExpiresAt *time.Time -} diff --git a/internal/domain/repository/file_meta_repository.go b/internal/domain/repository/file_meta_repository.go deleted file mode 100644 index b21898b..0000000 --- a/internal/domain/repository/file_meta_repository.go +++ /dev/null @@ -1,14 +0,0 @@ -package repository - -import ( - "context" - "rag/file-system/internal/domain/model" -) - -type FileMetaRepository interface { - Create(ctx context.Context, file *model.FileMeta) error - GetByID(ctx context.Context, id string) (*model.FileMeta, error) - GetByFolder(ctx context.Context, folderID string) ([]model.FileMeta, error) - Move(ctx context.Context, fileID string, targetFolderID string, ownerID string) error - Delete(ctx context.Context, id string, ownerID string) error -} diff --git a/internal/domain/repository/file_repository.go b/internal/domain/repository/file_repository.go deleted file mode 100644 index 0f865a8..0000000 --- a/internal/domain/repository/file_repository.go +++ /dev/null @@ -1,44 +0,0 @@ -package repository - -import ( - "context" - "rag/file-system/internal/common" - "io" - "time" -) - -type FileInfo struct { - Key string - Size int64 - LastModified time.Time - ETag string -} - -type ListFilesResult struct { - Files []FileInfo - NextContinuationToken *string -} - -type FileRepository interface { - UploadFile(ctx context.Context, bucketName string, objectKey string, data io.Reader) error - DownloadFile(ctx context.Context, bucketName string, objectKey string) (io.ReadCloser, error) - ListBuckets(ctx context.Context) ([]string, error) - CreateBucket(ctx context.Context, bucketName string) error - DeleteBucket(ctx context.Context, bucketName string) error - - // File listing with pagination - ListObjectsV2(ctx context.Context, bucketName string, prefix string, maxKeys int32, continuationToken *string) (*ListFilesResult, error) - GeneratePresignedURL(ctx context.Context, bucketName string, objectKey string, expiry time.Duration) (string, error) - - // File content retrieval for text preview - GetFileContent(ctx context.Context, bucketName string, objectKey string) (string, error) - - // File deletion - DeleteFile(ctx context.Context, bucketName string, objectKey string) error - - // Multipart upload - CreateMultipartUpload(ctx context.Context, bucketName string, objectKey string) (string, error) - UploadPart(ctx context.Context, bucketName string, objectKey string, uploadId string, partNumber int32, data io.Reader) (string, error) - CompleteMultipartUpload(ctx context.Context, bucketName string, objectKey string, uploadId string, parts []common.Part) (string, error) - AbortMultipartUpload(ctx context.Context, bucketName string, objectKey string, uploadId string) error -} diff --git a/internal/domain/repository/folder_repository.go b/internal/domain/repository/folder_repository.go deleted file mode 100644 index 3b83b48..0000000 --- a/internal/domain/repository/folder_repository.go +++ /dev/null @@ -1,16 +0,0 @@ -package repository - -import ( - "context" - "rag/file-system/internal/domain/model" -) - -type FolderRepository interface { - Create(ctx context.Context, folder *model.Folder) error - GetByID(ctx context.Context, id string) (*model.Folder, error) - GetWithChildren(ctx context.Context, id string, ownerID string) (*model.FolderWithChildren, error) - GetTree(ctx context.Context, ownerID string) ([]model.Folder, error) - Update(ctx context.Context, folder *model.Folder) error - Delete(ctx context.Context, id string, ownerID string) error - GetDescendantFileS3Keys(ctx context.Context, id string, ownerID string) ([]model.FileMeta, error) -} diff --git a/internal/domain/repository/share_repository.go b/internal/domain/repository/share_repository.go deleted file mode 100644 index 6e10d96..0000000 --- a/internal/domain/repository/share_repository.go +++ /dev/null @@ -1,15 +0,0 @@ -package repository - -import ( - "context" - "rag/file-system/internal/domain/model" -) - -type ShareRepository interface { - Create(ctx context.Context, share *model.ShareLink) error - GetByToken(ctx context.Context, token string) (*model.ShareLink, error) - GetByID(ctx context.Context, id string) (*model.ShareLink, error) - Delete(ctx context.Context, id string, createdBy string) error - IncrementDownloadCount(ctx context.Context, token string) error - ListByResource(ctx context.Context, resourceType string, resourceID string) ([]model.ShareLink, error) -}