Files
garage-ui/internal/services/s3.go
T

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
}