mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 07:48:13 +00:00
544 lines
16 KiB
Go
544 lines
16 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"time"
|
|
|
|
"Noooste/garage-ui/internal/config"
|
|
"Noooste/garage-ui/internal/models"
|
|
"Noooste/garage-ui/pkg/utils"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/aws"
|
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
|
)
|
|
|
|
// S3Service handles all S3 operations with Garage
|
|
type S3Service struct {
|
|
client *s3.Client
|
|
config *config.GarageConfig
|
|
adminService *GarageAdminService
|
|
}
|
|
|
|
// NewS3Service creates a new S3 service instance
|
|
func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S3Service {
|
|
// Create AWS credentials from Garage config (default/fallback credentials)
|
|
creds := credentials.NewStaticCredentialsProvider(
|
|
cfg.AccessKey,
|
|
cfg.SecretKey,
|
|
"", // session token (not used for Garage)
|
|
)
|
|
|
|
// Configure S3 client for Garage
|
|
s3Config := aws.Config{
|
|
Region: cfg.Region,
|
|
Credentials: creds,
|
|
}
|
|
|
|
// Create S3 client with custom endpoint resolver for Garage
|
|
client := s3.NewFromConfig(s3Config, func(o *s3.Options) {
|
|
o.BaseEndpoint = aws.String(cfg.Endpoint)
|
|
o.UsePathStyle = cfg.ForcePathStyle
|
|
})
|
|
|
|
return &S3Service{
|
|
client: client,
|
|
config: cfg,
|
|
adminService: adminService,
|
|
}
|
|
}
|
|
|
|
// getBucketCredentials retrieves credentials for a specific bucket
|
|
// It checks the cache first, then queries the Garage Admin API
|
|
func (s *S3Service) getBucketCredentials(ctx context.Context, bucketName string) (aws.CredentialsProvider, error) {
|
|
cacheKey := fmt.Sprintf("key:%s", bucketName)
|
|
cacheData := utils.GlobalCache.Get(cacheKey)
|
|
|
|
if cacheData != nil {
|
|
return cacheData.(aws.CredentialsProvider), nil
|
|
}
|
|
|
|
// Get bucket info from Garage Admin API
|
|
bucketInfo, err := s.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get bucket info: %w", err)
|
|
}
|
|
|
|
// Find a key with read and write permissions
|
|
var accessKeyID, secretAccessKey string
|
|
for _, keyInfo := range bucketInfo.Keys {
|
|
if !keyInfo.Permissions.Read || !keyInfo.Permissions.Write {
|
|
continue
|
|
}
|
|
|
|
// Get key details with secret
|
|
keyDetails, err := s.adminService.GetKeyInfo(ctx, keyInfo.AccessKeyID, true)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get key info: %w", err)
|
|
}
|
|
|
|
if keyDetails.SecretAccessKey != nil {
|
|
accessKeyID = keyDetails.AccessKeyID
|
|
secretAccessKey = *keyDetails.SecretAccessKey
|
|
break
|
|
}
|
|
}
|
|
|
|
if accessKeyID == "" || secretAccessKey == "" {
|
|
return nil, fmt.Errorf("no valid credentials found for bucket %s", bucketName)
|
|
}
|
|
|
|
// Create credentials provider
|
|
credential := credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, "")
|
|
|
|
// Cache credentials for 1 hour
|
|
utils.GlobalCache.Set(cacheKey, credential, time.Hour)
|
|
|
|
return credential, nil
|
|
}
|
|
|
|
// getS3Client creates an S3 client for a specific bucket with dynamic credentials
|
|
func (s *S3Service) getS3Client(ctx context.Context, bucketName string) (*s3.Client, error) {
|
|
creds, err := s.getBucketCredentials(ctx, bucketName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot get credentials for bucket %s: %w", bucketName, err)
|
|
}
|
|
|
|
// AWS config
|
|
awsConfig := aws.Config{
|
|
Credentials: creds,
|
|
Region: s.config.Region,
|
|
}
|
|
|
|
// Build S3 client with BaseEndpoint for Garage
|
|
client := s3.NewFromConfig(awsConfig, func(o *s3.Options) {
|
|
o.BaseEndpoint = aws.String(s.config.Endpoint)
|
|
o.UsePathStyle = s.config.ForcePathStyle
|
|
o.EndpointResolver = s3.EndpointResolverFunc(func(region string, opts s3.EndpointResolverOptions) (aws.Endpoint, error) {
|
|
return aws.Endpoint{
|
|
URL: s.config.Endpoint,
|
|
SigningRegion: s.config.Region,
|
|
}, nil
|
|
})
|
|
})
|
|
|
|
return client, nil
|
|
}
|
|
|
|
// ListBuckets retrieves all buckets from Garage
|
|
func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse, error) {
|
|
// Call S3 ListBuckets API
|
|
result, err := s.client.ListBuckets(ctx, &s3.ListBucketsInput{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list buckets: %w", err)
|
|
}
|
|
|
|
// Convert S3 buckets to our model
|
|
buckets := make([]models.BucketInfo, 0, len(result.Buckets))
|
|
for _, bucket := range result.Buckets {
|
|
buckets = append(buckets, models.BucketInfo{
|
|
Name: aws.ToString(bucket.Name),
|
|
CreationDate: aws.ToTime(bucket.CreationDate),
|
|
})
|
|
}
|
|
|
|
return &models.BucketListResponse{
|
|
Buckets: buckets,
|
|
Count: len(buckets),
|
|
}, nil
|
|
}
|
|
|
|
// CreateBucket creates a new bucket in Garage
|
|
func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
|
|
client, err := s.getS3Client(ctx, bucketName)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
|
|
}
|
|
|
|
input := &s3.CreateBucketInput{
|
|
Bucket: aws.String(bucketName),
|
|
}
|
|
|
|
// Call S3 CreateBucket API
|
|
_, err = client.CreateBucket(ctx, input)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create bucket %s: %w", bucketName, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteBucket deletes a bucket from Garage
|
|
func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
|
|
client, err := s.getS3Client(ctx, bucketName)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
|
|
}
|
|
|
|
// Call S3 DeleteBucket API
|
|
_, err = client.DeleteBucket(ctx, &s3.DeleteBucketInput{
|
|
Bucket: aws.String(bucketName),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete bucket %s: %w", bucketName, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListObjects lists objects in a bucket with optional prefix filter and pagination
|
|
func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error) {
|
|
// Get bucket-specific S3 client
|
|
client, err := s.getS3Client(ctx, bucketName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
|
|
}
|
|
|
|
// Set default max keys if not specified
|
|
if maxKeys <= 0 {
|
|
maxKeys = 100
|
|
}
|
|
|
|
// Create list objects input
|
|
input := &s3.ListObjectsV2Input{
|
|
Bucket: aws.String(bucketName),
|
|
Delimiter: aws.String("/"),
|
|
MaxKeys: aws.Int32(int32(maxKeys)),
|
|
}
|
|
|
|
if prefix != "" {
|
|
input.Prefix = aws.String(prefix)
|
|
}
|
|
|
|
if continuationToken != "" {
|
|
input.ContinuationToken = aws.String(continuationToken)
|
|
}
|
|
|
|
// Call S3 ListObjectsV2 API
|
|
result, err := client.ListObjectsV2(ctx, input)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err)
|
|
}
|
|
|
|
// Convert S3 objects to our model
|
|
objects := make([]models.ObjectInfo, 0, len(result.Contents))
|
|
for _, obj := range result.Contents {
|
|
objects = append(objects, models.ObjectInfo{
|
|
Key: aws.ToString(obj.Key),
|
|
Size: aws.ToInt64(obj.Size),
|
|
LastModified: aws.ToTime(obj.LastModified),
|
|
ETag: aws.ToString(obj.ETag),
|
|
StorageClass: string(obj.StorageClass),
|
|
})
|
|
}
|
|
|
|
// Extract common prefixes (folders/directories)
|
|
prefixes := make([]string, 0, len(result.CommonPrefixes))
|
|
for _, p := range result.CommonPrefixes {
|
|
prefixes = append(prefixes, aws.ToString(p.Prefix))
|
|
}
|
|
|
|
return &models.ObjectListResponse{
|
|
Bucket: bucketName,
|
|
Objects: objects,
|
|
Prefixes: prefixes,
|
|
Count: len(objects),
|
|
IsTruncated: aws.ToBool(result.IsTruncated),
|
|
NextContinuationToken: aws.ToString(result.NextContinuationToken),
|
|
}, nil
|
|
}
|
|
|
|
// UploadObject uploads an object to a bucket
|
|
func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) {
|
|
// Get bucket-specific S3 client
|
|
client, err := s.getS3Client(ctx, bucketName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
|
|
}
|
|
|
|
// Create put object input
|
|
input := &s3.PutObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(key),
|
|
Body: body,
|
|
}
|
|
|
|
if contentType != "" {
|
|
input.ContentType = aws.String(contentType)
|
|
}
|
|
|
|
// Call S3 PutObject API
|
|
result, err := client.PutObject(ctx, input)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to upload object %s to bucket %s: %w", key, bucketName, err)
|
|
}
|
|
|
|
// Get object metadata to return size
|
|
headResult, err := client.HeadObject(ctx, &s3.HeadObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(key),
|
|
})
|
|
|
|
var size int64
|
|
if err == nil {
|
|
size = aws.ToInt64(headResult.ContentLength)
|
|
}
|
|
|
|
return &models.ObjectUploadResponse{
|
|
Bucket: bucketName,
|
|
Key: key,
|
|
ETag: aws.ToString(result.ETag),
|
|
Size: size,
|
|
ContentType: contentType,
|
|
}, nil
|
|
}
|
|
|
|
// GetObject retrieves an object from a bucket
|
|
func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) {
|
|
// Call S3 GetObject API
|
|
result, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(key),
|
|
})
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err)
|
|
}
|
|
|
|
// Create object info
|
|
objectInfo := &models.ObjectInfo{
|
|
Key: key,
|
|
Size: aws.ToInt64(result.ContentLength),
|
|
LastModified: aws.ToTime(result.LastModified),
|
|
ETag: aws.ToString(result.ETag),
|
|
ContentType: aws.ToString(result.ContentType),
|
|
}
|
|
|
|
return result.Body, objectInfo, nil
|
|
}
|
|
|
|
// DeleteObject deletes an object from a bucket
|
|
func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error {
|
|
// Call S3 DeleteObject API
|
|
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(key),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete object %s from bucket %s: %w", key, bucketName, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ObjectExists checks if an object exists in a bucket
|
|
func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) {
|
|
// Get bucket-specific S3 client
|
|
client, err := s.getS3Client(ctx, bucketName)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
|
|
}
|
|
|
|
_, err = client.HeadObject(ctx, &s3.HeadObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(key),
|
|
})
|
|
if err != nil {
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// GetObjectMetadata retrieves metadata for an object without downloading it
|
|
func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) {
|
|
// Get bucket-specific S3 client
|
|
client, err := s.getS3Client(ctx, bucketName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
|
|
}
|
|
|
|
result, err := client.HeadObject(ctx, &s3.HeadObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(key),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get metadata for object %s in bucket %s: %w", key, bucketName, err)
|
|
}
|
|
|
|
return &models.ObjectInfo{
|
|
Key: key,
|
|
Size: aws.ToInt64(result.ContentLength),
|
|
LastModified: aws.ToTime(result.LastModified),
|
|
ETag: aws.ToString(result.ETag),
|
|
ContentType: aws.ToString(result.ContentType),
|
|
}, nil
|
|
}
|
|
|
|
// DeleteMultipleObjects deletes multiple objects from a bucket
|
|
func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error {
|
|
if len(keys) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Get bucket-specific S3 client
|
|
client, err := s.getS3Client(ctx, bucketName)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
|
|
}
|
|
|
|
// Create delete objects for batch deletion
|
|
objects := make([]types.ObjectIdentifier, len(keys))
|
|
for i, key := range keys {
|
|
objects[i] = types.ObjectIdentifier{
|
|
Key: aws.String(key),
|
|
}
|
|
}
|
|
|
|
// Call S3 DeleteObjects API (batch delete)
|
|
_, err = client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
|
|
Bucket: aws.String(bucketName),
|
|
Delete: &types.Delete{
|
|
Objects: objects,
|
|
Quiet: aws.Bool(false),
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete multiple objects from bucket %s: %w", bucketName, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetPresignedURL generates a pre-signed URL for temporary access to an object
|
|
// This is useful for sharing files without exposing credentials
|
|
func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error) {
|
|
// Get bucket-specific S3 client
|
|
client, err := s.getS3Client(ctx, bucketName)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
|
|
}
|
|
|
|
// Create presign client
|
|
presignClient := s3.NewPresignClient(client)
|
|
|
|
// Generate presigned GET request
|
|
presignResult, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(key),
|
|
}, func(opts *s3.PresignOptions) {
|
|
opts.Expires = expiresIn
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate presigned URL for %s/%s: %w", bucketName, key, err)
|
|
}
|
|
|
|
return presignResult.URL, nil
|
|
}
|
|
|
|
// UploadResult represents the result of a single file upload
|
|
type UploadResult struct {
|
|
Key string
|
|
Success bool
|
|
Error error
|
|
ETag string
|
|
Size int64
|
|
ContentType string
|
|
}
|
|
|
|
// UploadMultipleObjects uploads multiple objects to a bucket
|
|
// It handles uploads in batches to respect any S3/Garage limits
|
|
// Returns results for each file, including both successes and failures
|
|
func (s *S3Service) UploadMultipleObjects(ctx context.Context, bucketName string, files []struct {
|
|
Key string
|
|
Body io.Reader
|
|
ContentType string
|
|
}) []UploadResult {
|
|
results := make([]UploadResult, len(files))
|
|
|
|
// Get bucket-specific S3 client once for all uploads
|
|
client, err := s.getS3Client(ctx, bucketName)
|
|
if err != nil {
|
|
// If we can't get the client, all uploads fail
|
|
for i := range files {
|
|
results[i] = UploadResult{
|
|
Key: files[i].Key,
|
|
Success: false,
|
|
Error: fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err),
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
// Upload each file
|
|
for i, file := range files {
|
|
// Create put object input
|
|
input := &s3.PutObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(file.Key),
|
|
Body: file.Body,
|
|
}
|
|
|
|
if file.ContentType != "" {
|
|
input.ContentType = aws.String(file.ContentType)
|
|
}
|
|
|
|
// Attempt upload
|
|
result, err := client.PutObject(ctx, input)
|
|
if err != nil {
|
|
results[i] = UploadResult{
|
|
Key: file.Key,
|
|
Success: false,
|
|
Error: fmt.Errorf("failed to upload object %s: %w", file.Key, err),
|
|
ContentType: file.ContentType,
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Get object metadata to return size
|
|
headResult, err := client.HeadObject(ctx, &s3.HeadObjectInput{
|
|
Bucket: aws.String(bucketName),
|
|
Key: aws.String(file.Key),
|
|
})
|
|
|
|
var size int64
|
|
if err == nil {
|
|
size = aws.ToInt64(headResult.ContentLength)
|
|
}
|
|
|
|
results[i] = UploadResult{
|
|
Key: file.Key,
|
|
Success: true,
|
|
Error: nil,
|
|
ETag: aws.ToString(result.ETag),
|
|
Size: size,
|
|
ContentType: file.ContentType,
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// BucketStatistics holds statistical information about a bucket
|
|
type BucketStatistics struct {
|
|
ObjectCount int64
|
|
TotalSize int64
|
|
}
|
|
|
|
// GetBucketStatistics retrieves bucket statistics from Garage Admin API
|
|
// This is much more efficient than iterating through all objects
|
|
func (s *S3Service) GetBucketStatistics(ctx context.Context, bucketName string) (*BucketStatistics, error) {
|
|
// Get bucket info from Garage Admin API which includes object count and size
|
|
bucketInfo, err := s.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get bucket info for %s: %w", bucketName, err)
|
|
}
|
|
|
|
// Return statistics from Admin API
|
|
return &BucketStatistics{
|
|
ObjectCount: bucketInfo.Objects,
|
|
TotalSize: bucketInfo.Bytes,
|
|
}, nil
|
|
}
|