mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-09-04 11:15:42 +00:00
feat: implement bucket and object management dialogs, enhance caching, and update theme colors
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user