feat: implement bucket and object management dialogs, enhance caching, and update theme colors

This commit is contained in:
Noooste
2025-11-25 18:12:48 +01:00
parent f33452dcb1
commit c6248adde9
51 changed files with 5953 additions and 2107 deletions
+460
View File
@@ -0,0 +1,460 @@
package services
import (
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/Noooste/azuretls-client"
)
// GarageAdminService handles interactions with the Garage Admin API
type GarageAdminService struct {
baseURL string
token string
httpClient *azuretls.Session
}
// NewGarageAdminService creates a new Garage Admin API service
func NewGarageAdminService(cfg *config.GarageConfig) *GarageAdminService {
session := azuretls.NewSession()
session.Log()
return &GarageAdminService{
baseURL: cfg.AdminEndpoint,
token: cfg.AdminToken,
httpClient: session,
}
}
// doRequest performs an HTTP request to the Admin API
func (s *GarageAdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) {
return s.httpClient.Do(&azuretls.Request{
Method: method,
Url: s.baseURL + path,
Body: body,
IgnoreBody: true, // decodeResponse will handle body reading
OrderedHeaders: azuretls.OrderedHeaders{
{"Authorization", fmt.Sprintf("Bearer %s", s.token)},
},
}, ctx)
}
// decodeResponse decodes a JSON response into the target structure
func decodeResponse(resp *azuretls.Response, target interface{}) error {
defer resp.RawBody.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(resp.RawBody)
return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
if target != nil {
if err := json.NewDecoder(resp.RawBody).Decode(target); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
}
return nil
}
// ====================================
// Access Key Operations
// ====================================
// ListKeys returns all access keys in the cluster
func (s *GarageAdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListKeys", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result []models.ListKeysResponseItem
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return result, nil
}
// CreateKey creates a new API access key
func (s *GarageAdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateKey", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// GetKeyInfo returns information about a specific access key
func (s *GarageAdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) {
path := fmt.Sprintf("/v2/GetKeyInfo?id=%s", keyID)
if showSecret {
path += "&showSecretKey=true"
}
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// UpdateKey updates information about an access key
func (s *GarageAdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
path := fmt.Sprintf("/v2/UpdateKey?id=%s", keyID)
resp, err := s.doRequest(ctx, http.MethodPost, path, req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// DeleteKey deletes an access key from the cluster
func (s *GarageAdminService) DeleteKey(ctx context.Context, keyID string) error {
path := fmt.Sprintf("/v2/DeleteKey?id=%s", keyID)
resp, err := s.doRequest(ctx, http.MethodPost, path, nil)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
if err := decodeResponse(resp, nil); err != nil {
return fmt.Errorf("failed to process response: %w", err)
}
return nil
}
// ImportKey imports an existing API access key
func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/ImportKey", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// ====================================
// Bucket Operations (Admin API)
// ====================================
// ListBuckets returns all buckets in the cluster
func (s *GarageAdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListBuckets", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result []models.ListBucketsResponseItem
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return result, nil
}
// GetBucketInfo returns detailed information about a bucket by ID
func (s *GarageAdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) {
path := fmt.Sprintf("/v2/GetBucketInfo?id=%s", bucketID)
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// GetBucketInfoByAlias returns detailed information about a bucket by its global alias
func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) {
path := fmt.Sprintf("/v2/GetBucketInfo?globalAlias=%s", globalAlias)
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// CreateBucket creates a new bucket via the Admin API
func (s *GarageAdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateBucket", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// UpdateBucket updates bucket settings
func (s *GarageAdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
path := fmt.Sprintf("/v2/UpdateBucket?id=%s", bucketID)
resp, err := s.doRequest(ctx, http.MethodPost, path, req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// DeleteBucket deletes a bucket
func (s *GarageAdminService) DeleteBucket(ctx context.Context, bucketID string) error {
path := fmt.Sprintf("/v2/DeleteBucket?id=%s", bucketID)
resp, err := s.doRequest(ctx, http.MethodPost, path, nil)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
if err := decodeResponse(resp, nil); err != nil {
return fmt.Errorf("failed to process response: %w", err)
}
return nil
}
// ====================================
// Bucket Alias Operations
// ====================================
// AddBucketAlias adds an alias to a bucket
func (s *GarageAdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AddBucketAlias", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// RemoveBucketAlias removes an alias from a bucket
func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/RemoveBucketAlias", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// ====================================
// Permission Operations
// ====================================
// AllowBucketKey grants permissions for a key on a bucket
func (s *GarageAdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AllowBucketKey", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// DenyBucketKey revokes permissions for a key on a bucket
func (s *GarageAdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/DenyBucketKey", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// ====================================
// Cluster Operations
// ====================================
// GetClusterHealth returns the health status of the cluster
func (s *GarageAdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterHealth", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.ClusterHealth
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// GetClusterStatus returns the current status of the cluster
func (s *GarageAdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatus", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.ClusterStatus
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// GetClusterStatistics returns global cluster statistics
func (s *GarageAdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatistics", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.ClusterStatistics
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// ====================================
// Node Operations
// ====================================
// GetNodeInfo returns information about a specific node
func (s *GarageAdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
path := fmt.Sprintf("/v2/GetNodeInfo?node=%s", nodeID)
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.MultiNodeResponse
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// GetNodeStatistics returns statistics for a specific node
func (s *GarageAdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
path := fmt.Sprintf("/v2/GetNodeStatistics?node=%s", nodeID)
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.MultiNodeResponse
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// ====================================
// Monitoring Operations
// ====================================
// HealthCheck checks if the Admin API is reachable
func (s *GarageAdminService) HealthCheck(ctx context.Context) error {
resp, err := s.doRequest(ctx, http.MethodGet, "/health", nil)
if err != nil {
return fmt.Errorf("health check failed: %w", err)
}
if err := decodeResponse(resp, nil); err != nil {
return fmt.Errorf("health check returned error: %w", err)
}
return nil
}
// GetMetrics returns Prometheus metrics from the Admin API
func (s *GarageAdminService) GetMetrics(ctx context.Context) (string, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/metrics", nil)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.RawBody.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(resp.RawBody)
return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
bodyBytes, err := io.ReadAll(resp.RawBody)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
return string(bodyBytes), nil
}
+266 -35
View File
@@ -8,6 +8,8 @@ import (
"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"
@@ -16,13 +18,14 @@ import (
// S3Service handles all S3 operations with Garage
type S3Service struct {
client *s3.Client
config *config.GarageConfig
client *s3.Client
config *config.GarageConfig
adminService *GarageAdminService
}
// NewS3Service creates a new S3 service instance
func NewS3Service(cfg *config.GarageConfig) *S3Service {
// Create AWS credentials from Garage config
func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S3Service {
// Create AWS credentials from Garage config (default/fallback credentials)
creds := credentials.NewStaticCredentialsProvider(
cfg.AccessKey,
cfg.SecretKey,
@@ -42,11 +45,89 @@ func NewS3Service(cfg *config.GarageConfig) *S3Service {
})
return &S3Service{
client: client,
config: cfg,
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
@@ -72,13 +153,17 @@ func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse
// CreateBucket creates a new bucket in Garage
func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
// Create bucket input
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 := s.client.CreateBucket(ctx, input)
_, err = client.CreateBucket(ctx, input)
if err != nil {
return fmt.Errorf("failed to create bucket %s: %w", bucketName, err)
}
@@ -88,8 +173,13 @@ func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
// 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 := s.client.DeleteBucket(ctx, &s3.DeleteBucketInput{
_, err = client.DeleteBucket(ctx, &s3.DeleteBucketInput{
Bucket: aws.String(bucketName),
})
if err != nil {
@@ -99,37 +189,36 @@ func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
return nil
}
// BucketExists checks if a bucket exists
func (s *S3Service) BucketExists(ctx context.Context, bucketName string) (bool, error) {
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{
Bucket: aws.String(bucketName),
})
// 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 {
// Check if it's a "not found" error
return false, nil
return nil, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
}
return true, nil
}
// ListObjects lists objects in a bucket with optional prefix filter
func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int) (*models.ObjectListResponse, error) {
// Set default max keys if not specified
if maxKeys <= 0 {
maxKeys = 1000
maxKeys = 100
}
// Create list objects input
input := &s3.ListObjectsV2Input{
Bucket: aws.String(bucketName),
MaxKeys: aws.Int32(int32(maxKeys)),
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 := s.client.ListObjectsV2(ctx, input)
result, err := client.ListObjectsV2(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err)
}
@@ -146,17 +235,30 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
})
}
// 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,
Count: len(objects),
IsTruncated: aws.ToBool(result.IsTruncated),
NextMarker: aws.ToString(result.NextContinuationToken),
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),
@@ -169,13 +271,13 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
}
// Call S3 PutObject API
result, err := s.client.PutObject(ctx, input)
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 := s.client.HeadObject(ctx, &s3.HeadObjectInput{
headResult, err := client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
@@ -233,7 +335,13 @@ func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) er
// ObjectExists checks if an object exists in a bucket
func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) {
_, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
// 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),
})
@@ -245,7 +353,13 @@ func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (b
// GetObjectMetadata retrieves metadata for an object without downloading it
func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) {
result, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
// 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),
})
@@ -268,6 +382,12 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
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 {
@@ -277,7 +397,7 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
}
// Call S3 DeleteObjects API (batch delete)
_, err := s.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
_, err = client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
Bucket: aws.String(bucketName),
Delete: &types.Delete{
Objects: objects,
@@ -294,8 +414,14 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
// 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(s.client)
presignClient := s3.NewPresignClient(client)
// Generate presigned GET request
presignResult, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
@@ -310,3 +436,108 @@ func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string,
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
}