mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-31 01:09:25 +00:00
feat: implement bucket and object management dialogs, enhance caching, and update theme colors
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
+33
-25
@@ -31,13 +31,15 @@ type GarageConfig struct {
|
||||
SecretKey string `mapstructure:"secret_key"`
|
||||
UseSSL bool `mapstructure:"use_ssl"`
|
||||
ForcePathStyle bool `mapstructure:"force_path_style"`
|
||||
AdminEndpoint string `mapstructure:"admin_endpoint"`
|
||||
AdminToken string `mapstructure:"admin_token"`
|
||||
}
|
||||
|
||||
// AuthConfig contains authentication configuration
|
||||
type AuthConfig struct {
|
||||
Mode string `mapstructure:"mode"` // "none", "basic", or "oidc"
|
||||
Basic BasicAuthConfig `mapstructure:"basic"`
|
||||
OIDC OIDCConfig `mapstructure:"oidc"`
|
||||
Mode string `mapstructure:"mode"` // "none", "basic", or "oidc"
|
||||
Basic BasicAuthConfig `mapstructure:"basic"`
|
||||
OIDC OIDCConfig `mapstructure:"oidc"`
|
||||
}
|
||||
|
||||
// BasicAuthConfig contains basic authentication settings
|
||||
@@ -48,28 +50,28 @@ type BasicAuthConfig struct {
|
||||
|
||||
// OIDCConfig contains OIDC authentication settings
|
||||
type OIDCConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
ProviderName string `mapstructure:"provider_name"`
|
||||
ClientID string `mapstructure:"client_id"`
|
||||
ClientSecret string `mapstructure:"client_secret"`
|
||||
Scopes []string `mapstructure:"scopes"`
|
||||
IssuerURL string `mapstructure:"issuer_url"`
|
||||
AuthURL string `mapstructure:"auth_url"`
|
||||
TokenURL string `mapstructure:"token_url"`
|
||||
UserinfoURL string `mapstructure:"userinfo_url"`
|
||||
SkipIssuerCheck bool `mapstructure:"skip_issuer_check"`
|
||||
SkipExpiryCheck bool `mapstructure:"skip_expiry_check"`
|
||||
EmailAttribute string `mapstructure:"email_attribute"`
|
||||
UsernameAttribute string `mapstructure:"username_attribute"`
|
||||
NameAttribute string `mapstructure:"name_attribute"`
|
||||
RoleAttributePath string `mapstructure:"role_attribute_path"`
|
||||
AdminRole string `mapstructure:"admin_role"`
|
||||
TLSSkipVerify bool `mapstructure:"tls_skip_verify"`
|
||||
SessionMaxAge int `mapstructure:"session_max_age"`
|
||||
CookieName string `mapstructure:"cookie_name"`
|
||||
CookieSecure bool `mapstructure:"cookie_secure"`
|
||||
CookieHTTPOnly bool `mapstructure:"cookie_http_only"`
|
||||
CookieSameSite string `mapstructure:"cookie_same_site"`
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
ProviderName string `mapstructure:"provider_name"`
|
||||
ClientID string `mapstructure:"client_id"`
|
||||
ClientSecret string `mapstructure:"client_secret"`
|
||||
Scopes []string `mapstructure:"scopes"`
|
||||
IssuerURL string `mapstructure:"issuer_url"`
|
||||
AuthURL string `mapstructure:"auth_url"`
|
||||
TokenURL string `mapstructure:"token_url"`
|
||||
UserinfoURL string `mapstructure:"userinfo_url"`
|
||||
SkipIssuerCheck bool `mapstructure:"skip_issuer_check"`
|
||||
SkipExpiryCheck bool `mapstructure:"skip_expiry_check"`
|
||||
EmailAttribute string `mapstructure:"email_attribute"`
|
||||
UsernameAttribute string `mapstructure:"username_attribute"`
|
||||
NameAttribute string `mapstructure:"name_attribute"`
|
||||
RoleAttributePath string `mapstructure:"role_attribute_path"`
|
||||
AdminRole string `mapstructure:"admin_role"`
|
||||
TLSSkipVerify bool `mapstructure:"tls_skip_verify"`
|
||||
SessionMaxAge int `mapstructure:"session_max_age"`
|
||||
CookieName string `mapstructure:"cookie_name"`
|
||||
CookieSecure bool `mapstructure:"cookie_secure"`
|
||||
CookieHTTPOnly bool `mapstructure:"cookie_http_only"`
|
||||
CookieSameSite string `mapstructure:"cookie_same_site"`
|
||||
}
|
||||
|
||||
// CORSConfig contains CORS settings for frontend communication
|
||||
@@ -143,6 +145,12 @@ func (c *Config) Validate() error {
|
||||
if c.Garage.SecretKey == "" {
|
||||
return fmt.Errorf("garage secret_key is required")
|
||||
}
|
||||
if c.Garage.AdminEndpoint == "" {
|
||||
return fmt.Errorf("garage admin_endpoint is required")
|
||||
}
|
||||
if c.Garage.AdminToken == "" {
|
||||
return fmt.Errorf("garage admin_token is required")
|
||||
}
|
||||
|
||||
// Validate auth mode
|
||||
if c.Auth.Mode != "none" && c.Auth.Mode != "basic" && c.Auth.Mode != "oidc" {
|
||||
|
||||
+174
-33
@@ -3,39 +3,96 @@ package handlers
|
||||
import (
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// BucketHandler handles bucket-related operations
|
||||
type BucketHandler struct {
|
||||
s3Service *services.S3Service
|
||||
adminService *services.GarageAdminService
|
||||
s3Service *services.S3Service
|
||||
}
|
||||
|
||||
// NewBucketHandler creates a new bucket handler
|
||||
func NewBucketHandler(s3Service *services.S3Service) *BucketHandler {
|
||||
func NewBucketHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *BucketHandler {
|
||||
return &BucketHandler{
|
||||
s3Service: s3Service,
|
||||
adminService: adminService,
|
||||
s3Service: s3Service,
|
||||
}
|
||||
}
|
||||
|
||||
// ListBuckets returns all buckets
|
||||
// GET /api/v1/buckets
|
||||
// ListBuckets lists all buckets
|
||||
//
|
||||
// @Summary List all buckets
|
||||
// @Description Retrieves a list of all buckets in the Garage storage system with object count and size
|
||||
// @Tags Buckets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=models.BucketListResponse} "Successfully retrieved list of buckets"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list buckets"
|
||||
// @Router /api/v1/buckets [get]
|
||||
func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// List all buckets from Garage
|
||||
buckets, err := h.s3Service.ListBuckets(ctx)
|
||||
// List all buckets from Garage Admin API
|
||||
adminBuckets, err := h.adminService.ListBuckets(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list buckets: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(buckets))
|
||||
// Convert admin bucket response to BucketInfo
|
||||
buckets := make([]models.BucketInfo, 0, len(adminBuckets))
|
||||
for _, adminBucket := range adminBuckets {
|
||||
// Get the bucket name from global aliases
|
||||
var bucketName string
|
||||
if len(adminBucket.GlobalAliases) > 0 {
|
||||
bucketName = adminBucket.GlobalAliases[0]
|
||||
} else {
|
||||
// Skip buckets without global aliases
|
||||
continue
|
||||
}
|
||||
|
||||
bucketInfo := models.BucketInfo{
|
||||
Name: bucketName,
|
||||
CreationDate: adminBucket.Created,
|
||||
Region: "", // Garage doesn't have regions
|
||||
}
|
||||
|
||||
// Try to get bucket statistics (object count and size)
|
||||
// This is done asynchronously to avoid blocking the response
|
||||
// If it fails, we still return the bucket info without stats
|
||||
stats, err := h.s3Service.GetBucketStatistics(ctx, bucketName)
|
||||
if err == nil && stats != nil {
|
||||
bucketInfo.ObjectCount = &stats.ObjectCount
|
||||
bucketInfo.Size = &stats.TotalSize
|
||||
}
|
||||
|
||||
buckets = append(buckets, bucketInfo)
|
||||
}
|
||||
|
||||
response := models.BucketListResponse{
|
||||
Buckets: buckets,
|
||||
Count: len(buckets),
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(response))
|
||||
}
|
||||
|
||||
// CreateBucket creates a new bucket
|
||||
// POST /api/v1/buckets
|
||||
//
|
||||
// @Summary Create a new bucket
|
||||
// @Description Creates a new bucket in the Garage storage system
|
||||
// @Tags Buckets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param payload body models.CreateBucketRequest true "Bucket creation payload"
|
||||
// @Success 201 {object} models.APIResponse{data=object{bucket=string,message=string}} "Bucket created successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request body or bucket name is required"
|
||||
// @Failure 409 {object} models.APIResponse{error=models.APIError} "Bucket already exists"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to create bucket"
|
||||
// @Router /api/v1/buckets [post]
|
||||
func (h *BucketHandler) CreateBucket(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
@@ -55,21 +112,24 @@ func (h *BucketHandler) CreateBucket(c fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// Check if bucket already exists
|
||||
exists, err := h.s3Service.BucketExists(ctx, req.Name)
|
||||
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, req.Name)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if exists {
|
||||
if bucketInfo != nil {
|
||||
return c.Status(fiber.StatusConflict).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBucketExists, "Bucket already exists"),
|
||||
)
|
||||
}
|
||||
|
||||
// Create the bucket
|
||||
if err := h.s3Service.CreateBucket(ctx, req.Name); err != nil {
|
||||
createBucketReq := models.CreateBucketAdminRequest{
|
||||
GlobalAlias: &req.Name,
|
||||
}
|
||||
if bucketInfo, err = h.adminService.CreateBucket(ctx, createBucketReq); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create bucket: "+err.Error()),
|
||||
)
|
||||
@@ -85,7 +145,18 @@ func (h *BucketHandler) CreateBucket(c fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// DeleteBucket deletes a bucket
|
||||
// DELETE /api/v1/buckets/:name
|
||||
//
|
||||
// @Summary Delete a bucket
|
||||
// @Description Deletes an existing bucket from the Garage storage system. The bucket must be empty before deletion.
|
||||
// @Tags Buckets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name path string true "Name of the bucket to delete"
|
||||
// @Success 200 {object} models.APIResponse{data=object{bucket=string,message=string}} "Bucket deleted successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name is required"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket does not exist"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete bucket"
|
||||
// @Router /api/v1/buckets/{name} [delete]
|
||||
func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
@@ -97,22 +168,22 @@ func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error {
|
||||
)
|
||||
}
|
||||
|
||||
// Check if bucket exists
|
||||
exists, err := h.s3Service.BucketExists(ctx, bucketName)
|
||||
// Check if bucket already exists
|
||||
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
if bucketInfo == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
|
||||
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
|
||||
)
|
||||
}
|
||||
|
||||
// Delete the bucket
|
||||
if err := h.s3Service.DeleteBucket(ctx, bucketName); err != nil {
|
||||
if err := h.adminService.DeleteBucket(ctx, bucketInfo.ID); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete bucket: "+err.Error()),
|
||||
)
|
||||
@@ -128,7 +199,18 @@ func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// GetBucketInfo returns information about a specific bucket
|
||||
// GET /api/v1/buckets/:name
|
||||
//
|
||||
// @Summary Get bucket information
|
||||
// @Description Retrieves detailed information about a specific bucket including creation date and region
|
||||
// @Tags Buckets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name path string true "Name of the bucket to retrieve information for"
|
||||
// @Success 200 {object} models.APIResponse{data=models.BucketInfo} "Successfully retrieved bucket information"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name is required"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket does not exist"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to retrieve bucket information"
|
||||
// @Router /api/v1/buckets/{name} [get]
|
||||
func (h *BucketHandler) GetBucketInfo(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
@@ -140,36 +222,95 @@ func (h *BucketHandler) GetBucketInfo(c fiber.Ctx) error {
|
||||
)
|
||||
}
|
||||
|
||||
// Check if bucket exists
|
||||
exists, err := h.s3Service.BucketExists(ctx, bucketName)
|
||||
// Check if bucket already exists
|
||||
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
if bucketInfo == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
|
||||
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
|
||||
)
|
||||
}
|
||||
|
||||
// List all buckets to find this one and get its info
|
||||
buckets, err := h.s3Service.ListBuckets(ctx)
|
||||
return c.JSON(models.SuccessResponse(bucketInfo))
|
||||
}
|
||||
|
||||
// GrantBucketPermission grants permissions for an access key on a bucket
|
||||
//
|
||||
// @Summary Grant bucket permissions
|
||||
// @Description Grants read/write/owner permissions for an access key on a specific bucket
|
||||
// @Tags Buckets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name path string true "Name of the bucket"
|
||||
// @Param request body models.GrantBucketPermissionRequest true "Permission grant request"
|
||||
// @Success 200 {object} models.APIResponse{data=models.GarageBucketInfo} "Permissions granted successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to grant permissions"
|
||||
// @Router /api/v1/buckets/{name}/permissions [post]
|
||||
func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name from URL parameter
|
||||
bucketName := c.Params("name")
|
||||
if bucketName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req models.GrantBucketPermissionRequest
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Validate access key ID
|
||||
if req.AccessKeyID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Access key ID is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Get bucket info to retrieve bucket ID
|
||||
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Find the specific bucket
|
||||
for _, bucket := range buckets.Buckets {
|
||||
if bucket.Name == bucketName {
|
||||
return c.JSON(models.SuccessResponse(bucket))
|
||||
}
|
||||
if bucketInfo == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
|
||||
)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
|
||||
)
|
||||
// Build the permission request for Garage Admin API
|
||||
permRequest := models.BucketKeyPermRequest{
|
||||
BucketID: bucketInfo.ID,
|
||||
AccessKeyID: req.AccessKeyID,
|
||||
Permissions: models.BucketKeyPermission{
|
||||
Read: req.Permissions.Read,
|
||||
Write: req.Permissions.Write,
|
||||
Owner: req.Permissions.Owner,
|
||||
},
|
||||
}
|
||||
|
||||
// Grant permissions using Garage Admin API
|
||||
result, err := h.adminService.AllowBucketKey(ctx, permRequest)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to grant permissions: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(result))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// ClusterHandler handles cluster management operations
|
||||
type ClusterHandler struct {
|
||||
adminService *services.GarageAdminService
|
||||
}
|
||||
|
||||
// NewClusterHandler creates a new cluster handler
|
||||
func NewClusterHandler(adminService *services.GarageAdminService) *ClusterHandler {
|
||||
return &ClusterHandler{
|
||||
adminService: adminService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetHealth returns the health status of the cluster
|
||||
//
|
||||
// @Summary Get cluster health
|
||||
// @Description Retrieves the overall health status of the Garage storage cluster
|
||||
// @Tags Cluster
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved cluster health"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get cluster health"
|
||||
// @Router /api/v1/cluster/health [get]
|
||||
func (h *ClusterHandler) GetHealth(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
health, err := h.adminService.GetClusterHealth(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster health: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(health))
|
||||
}
|
||||
|
||||
// GetStatus returns the status of the cluster
|
||||
//
|
||||
// @Summary Get cluster status
|
||||
// @Description Retrieves the current status of the Garage storage cluster
|
||||
// @Tags Cluster
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved cluster status"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get cluster status"
|
||||
// @Router /api/v1/cluster/status [get]
|
||||
func (h *ClusterHandler) GetStatus(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
status, err := h.adminService.GetClusterStatus(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster status: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(status))
|
||||
}
|
||||
|
||||
// GetStatistics returns global cluster statistics
|
||||
// GET /api/v1/cluster/statistics
|
||||
func (h *ClusterHandler) GetStatistics(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
stats, err := h.adminService.GetClusterStatistics(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster statistics: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(stats))
|
||||
}
|
||||
|
||||
// GetNodeInfo returns information about a specific node
|
||||
//
|
||||
// @Summary Get node information
|
||||
// @Description Retrieves detailed information about a specific node in the Garage storage cluster
|
||||
// @Tags Cluster
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param node_id path string true "ID of the node to retrieve information for"
|
||||
// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved node information"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Node ID is required"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get node information"
|
||||
// @Router /api/v1/cluster/nodes/{node_id} [get]
|
||||
func (h *ClusterHandler) GetNodeInfo(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
nodeID := c.Params("node_id")
|
||||
|
||||
if nodeID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"),
|
||||
)
|
||||
}
|
||||
|
||||
info, err := h.adminService.GetNodeInfo(ctx, nodeID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node info: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(info))
|
||||
}
|
||||
|
||||
// GetNodeStatistics returns statistics for a specific node
|
||||
//
|
||||
// @Summary Get node statistics
|
||||
// @Description Retrieves performance statistics and metrics for a specific node in the Garage storage cluster
|
||||
// @Tags Cluster
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param node_id path string true "ID of the node to retrieve statistics for"
|
||||
// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved node statistics"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Node ID is required"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get node statistics"
|
||||
// @Router /api/v1/cluster/nodes/{node_id}/statistics [get]
|
||||
func (h *ClusterHandler) GetNodeStatistics(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
nodeID := c.Params("node_id")
|
||||
|
||||
if nodeID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"),
|
||||
)
|
||||
}
|
||||
|
||||
stats, err := h.adminService.GetNodeStatistics(ctx, nodeID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node statistics: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(stats))
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
@@ -20,6 +21,14 @@ func NewHealthHandler(version string) *HealthHandler {
|
||||
}
|
||||
|
||||
// Check returns the health status of the service
|
||||
//
|
||||
// @Summary Health check
|
||||
// @Description Returns the health status of the API service along with version information
|
||||
// @Tags Health
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=models.HealthResponse} "Service is healthy"
|
||||
// @Router /api/v1/health [get]
|
||||
func (h *HealthHandler) Check(c fiber.Ctx) error {
|
||||
response := models.HealthResponse{
|
||||
Status: "healthy",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// MonitoringHandler handles monitoring operations
|
||||
type MonitoringHandler struct {
|
||||
adminService *services.GarageAdminService
|
||||
s3Service *services.S3Service
|
||||
}
|
||||
|
||||
// NewMonitoringHandler creates a new monitoring handler
|
||||
func NewMonitoringHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *MonitoringHandler {
|
||||
return &MonitoringHandler{
|
||||
adminService: adminService,
|
||||
s3Service: s3Service,
|
||||
}
|
||||
}
|
||||
|
||||
// GetMetrics retrieves system metrics from the Admin API
|
||||
//
|
||||
// @Summary Get system metrics
|
||||
// @Description Retrieves system metrics from the Garage Admin API for monitoring purposes
|
||||
// @Tags Monitoring
|
||||
// @Accept json
|
||||
// @Produce text/plain
|
||||
// @Success 200 {string} string "System metrics in plain text format"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to retrieve metrics"
|
||||
// @Router /api/v1/monitoring/metrics [get]
|
||||
func (h *MonitoringHandler) GetMetrics(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
metrics, err := h.adminService.GetMetrics(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get metrics: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Return metrics as plain text
|
||||
c.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
return c.SendString(metrics)
|
||||
}
|
||||
|
||||
// CheckAdminHealth checks if the Admin API is reachable
|
||||
//
|
||||
// @Summary Check Admin API health
|
||||
// @Description Performs a health check on the Garage Admin API to verify connectivity and availability
|
||||
// @Tags Monitoring
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=object{status=string,message=string}} "Admin API is healthy"
|
||||
// @Failure 503 {object} models.APIResponse{error=models.APIError} "Admin API health check failed"
|
||||
// @Router /api/v1/monitoring/admin-health [get]
|
||||
func (h *MonitoringHandler) CheckAdminHealth(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
err := h.adminService.HealthCheck(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Admin API health check failed: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(map[string]interface{}{
|
||||
"status": "healthy",
|
||||
"message": "Admin API is reachable",
|
||||
}))
|
||||
}
|
||||
|
||||
// GetDashboardMetrics retrieves aggregated dashboard metrics
|
||||
//
|
||||
// @Summary Get dashboard metrics
|
||||
// @Description Retrieves aggregated metrics for the dashboard including storage, buckets, and request metrics
|
||||
// @Tags Monitoring
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=models.DashboardMetrics} "Successfully retrieved dashboard metrics"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get dashboard metrics"
|
||||
// @Router /api/v1/monitoring/dashboard [get]
|
||||
func (h *MonitoringHandler) GetDashboardMetrics(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket list
|
||||
buckets, err := h.adminService.ListBuckets(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get buckets: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate aggregated metrics
|
||||
var totalSize int64
|
||||
var totalObjects int64
|
||||
usageByBucket := make([]models.BucketUsage, 0)
|
||||
|
||||
for _, bucket := range buckets {
|
||||
// Get bucket info to calculate size and object count
|
||||
bucketInfo, err := h.adminService.GetBucketInfo(ctx, bucket.ID)
|
||||
if err != nil {
|
||||
continue // Skip buckets we can't access
|
||||
}
|
||||
|
||||
// Get size and object count from bucket info
|
||||
bucketSize := bucketInfo.Bytes
|
||||
objectCount := bucketInfo.Objects
|
||||
|
||||
totalSize += bucketSize
|
||||
totalObjects += objectCount
|
||||
|
||||
// Get bucket name from aliases
|
||||
bucketName := bucket.ID
|
||||
if len(bucket.LocalAliases) > 0 {
|
||||
bucketName = bucket.LocalAliases[0].Alias
|
||||
} else if len(bucket.GlobalAliases) > 0 {
|
||||
bucketName = bucket.GlobalAliases[0]
|
||||
}
|
||||
|
||||
usageByBucket = append(usageByBucket, models.BucketUsage{
|
||||
BucketName: bucketName,
|
||||
Size: bucketSize,
|
||||
ObjectCount: objectCount,
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate percentages
|
||||
for i := range usageByBucket {
|
||||
if totalSize > 0 {
|
||||
usageByBucket[i].Percentage = float64(usageByBucket[i].Size) / float64(totalSize) * 100
|
||||
}
|
||||
}
|
||||
|
||||
dashboardMetrics := models.DashboardMetrics{
|
||||
TotalSize: totalSize,
|
||||
ObjectCount: totalObjects,
|
||||
BucketCount: len(buckets),
|
||||
UsageByBucket: usageByBucket,
|
||||
RequestMetrics: models.RequestMetrics{
|
||||
GetRequests: 0,
|
||||
PutRequests: 0,
|
||||
DeleteRequests: 0,
|
||||
ListRequests: 0,
|
||||
Period: "last-24h",
|
||||
},
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(dashboardMetrics))
|
||||
}
|
||||
+268
-66
@@ -2,10 +2,12 @@ package handlers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
@@ -21,8 +23,22 @@ func NewObjectHandler(s3Service *services.S3Service) *ObjectHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// ListObjects returns all objects in a bucket
|
||||
// GET /api/v1/buckets/:bucket/objects
|
||||
// ListObjects lists objects in a bucket with optional filtering and pagination
|
||||
//
|
||||
// @Summary List objects in a bucket
|
||||
// @Description Retrieves a list of objects and prefixes (folders) stored in the specified bucket, with optional filtering by prefix, pagination support, and max keys
|
||||
// @Tags Objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket to list objects from"
|
||||
// @Param prefix query string false "Filter objects by prefix"
|
||||
// @Param max_keys query int false "Maximum number of objects to return (default: 100)"
|
||||
// @Param continuation_token query string false "Token for pagination to retrieve next page of results"
|
||||
// @Success 200 {object} models.APIResponse{data=models.ObjectListResponse} "Successfully retrieved list of objects and prefixes"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list objects"
|
||||
// @Router /api/v1/buckets/{bucket}/objects [get]
|
||||
func (h *ObjectHandler) ListObjects(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
@@ -34,26 +50,20 @@ func (h *ObjectHandler) ListObjects(c fiber.Ctx) error {
|
||||
)
|
||||
}
|
||||
|
||||
// Get query parameters for filtering
|
||||
// Get query parameters for filtering and pagination
|
||||
prefix := c.Query("prefix", "")
|
||||
maxKeys := c.QueryInt("max_keys", 1000)
|
||||
continuationToken := c.Query("continuation_token", "")
|
||||
|
||||
// Check if bucket exists
|
||||
exists, err := h.s3Service.BucketExists(ctx, bucketName)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
|
||||
maxKeysStr := c.Query("max_keys", "100")
|
||||
maxKeys, err := strconv.Atoi(maxKeysStr)
|
||||
if err != nil || maxKeys <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid max_keys parameter"),
|
||||
)
|
||||
}
|
||||
|
||||
// List objects in the bucket
|
||||
objects, err := h.s3Service.ListObjects(ctx, bucketName, prefix, maxKeys)
|
||||
objects, err := h.s3Service.ListObjects(ctx, bucketName, prefix, maxKeys, continuationToken)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list objects: "+err.Error()),
|
||||
@@ -63,8 +73,21 @@ func (h *ObjectHandler) ListObjects(c fiber.Ctx) error {
|
||||
return c.JSON(models.SuccessResponse(objects))
|
||||
}
|
||||
|
||||
// UploadObject uploads a file to a bucket
|
||||
// POST /api/v1/buckets/:bucket/objects
|
||||
// UploadObject uploads an object to a bucket
|
||||
//
|
||||
// @Summary Upload object to bucket
|
||||
// @Description Uploads an object to the specified bucket using multipart/form-data
|
||||
// @Tags Objects
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket to upload the object to"
|
||||
// @Param file formData file true "File to upload"
|
||||
// @Param key formData string false "Object key (path in bucket). If not provided, the filename will be used"
|
||||
// @Success 201 {object} models.APIResponse{data=models.ObjectUploadResponse} "Object uploaded successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload object"
|
||||
// @Router /api/v1/buckets/{bucket}/objects [post]
|
||||
func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
@@ -76,20 +99,6 @@ func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
|
||||
)
|
||||
}
|
||||
|
||||
// Check if bucket exists
|
||||
exists, err := h.s3Service.BucketExists(ctx, bucketName)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
|
||||
)
|
||||
}
|
||||
|
||||
// Get file from multipart form
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -128,8 +137,20 @@ func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult))
|
||||
}
|
||||
|
||||
// GetObject downloads an object from a bucket
|
||||
// GET /api/v1/buckets/:bucket/objects/:key
|
||||
// GetObject retrieves an object from a bucket
|
||||
//
|
||||
// @Summary Get object from bucket
|
||||
// @Description Retrieves an object stored in the specified bucket
|
||||
// @Tags Objects
|
||||
// @Accept json
|
||||
// @Produce application/octet-stream
|
||||
// @Param bucket path string true "Name of the bucket containing the object"
|
||||
// @Param key path string true "Key (path) of the object"
|
||||
// @Param download query bool false "Set to true to download the object as an attachment"
|
||||
// @Success 200 {file} binary "Successfully retrieved the object"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/{key} [get]
|
||||
func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
@@ -168,7 +189,19 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// DeleteObject deletes an object from a bucket
|
||||
// DELETE /api/v1/buckets/:bucket/objects/:key
|
||||
//
|
||||
// @Summary Delete object from bucket
|
||||
// @Description Deletes an object stored in the specified bucket
|
||||
// @Tags Objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket containing the object"
|
||||
// @Param key path string true "Key (path) of the object"
|
||||
// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteResponse} "Successfully deleted the object"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete object"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/{key} [delete]
|
||||
func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
@@ -214,7 +247,18 @@ func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// GetObjectMetadata returns metadata for an object without downloading it
|
||||
// HEAD /api/v1/buckets/:bucket/objects/:key
|
||||
//
|
||||
// @Summary Get object metadata
|
||||
// @Description Retrieves metadata information about an object without downloading the actual content
|
||||
// @Tags Objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket containing the object"
|
||||
// @Param key path string true "Key (path) of the object"
|
||||
// @Success 200 {object} models.APIResponse{data=models.ObjectInfo} "Successfully retrieved object metadata"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/{key}/metadata [get]
|
||||
func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
@@ -239,8 +283,21 @@ func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
|
||||
return c.JSON(models.SuccessResponse(metadata))
|
||||
}
|
||||
|
||||
// GetPresignedURL generates a temporary pre-signed URL for an object
|
||||
// POST /api/v1/buckets/:bucket/objects/:key/presign
|
||||
// GetPresignedURL generates a pre-signed URL for accessing an object
|
||||
//
|
||||
// @Summary Get pre-signed URL for object
|
||||
// @Description Generates a pre-signed URL that allows temporary access to the specified object
|
||||
// @Tags Objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket containing the object"
|
||||
// @Param key path string true "Key (path) of the object"
|
||||
// @Param expires_in query int false "Expiration time in seconds for the pre-signed URL (default: 3600 seconds)"
|
||||
// @Success 200 {object} models.APIResponse{data=models.PresignedURLResponse} "Successfully generated pre-signed URL"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to generate pre-signed URL"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/{key}/presigned-url [get]
|
||||
func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
@@ -255,7 +312,15 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// Get expiration time from query parameter (default: 1 hour)
|
||||
expiresIn := c.QueryInt("expires_in", 3600)
|
||||
expiresInStr := c.Query("expires_in", "3600")
|
||||
expiresIn, err := strconv.ParseInt(expiresInStr, 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration time: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Validate expiration time (1 second to 7 days)
|
||||
if expiresIn <= 0 || expiresIn > 604800 { // Max 7 days
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration time (must be between 1 and 604800 seconds)"),
|
||||
@@ -284,18 +349,30 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
|
||||
)
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"url": url,
|
||||
"expires_in": expiresIn,
|
||||
"bucket": bucketName,
|
||||
"key": key,
|
||||
response := models.PresignedURLResponse{
|
||||
URL: url,
|
||||
ExpiresIn: expiresIn,
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(response))
|
||||
}
|
||||
|
||||
// DeleteMultipleObjects deletes multiple objects from a bucket
|
||||
// DELETE /api/v1/buckets/:bucket/objects
|
||||
//
|
||||
// @Summary Delete multiple objects from bucket
|
||||
// @Description Deletes multiple objects stored in the specified bucket
|
||||
// @Tags Objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket containing the objects"
|
||||
// @Param request body object{keys=[]string,prefix=string} true "List of object keys to delete and optional prefix for path context"
|
||||
// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteMultipleResponse} "Successfully deleted the objects"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete objects"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/delete-multiple [post]
|
||||
func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
@@ -307,9 +384,10 @@ func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
|
||||
)
|
||||
}
|
||||
|
||||
// Parse request body to get keys
|
||||
// Parse request body to get keys and optional prefix
|
||||
var req struct {
|
||||
Keys []string `json:"keys"`
|
||||
Keys []string `json:"keys"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
}
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
@@ -330,17 +408,31 @@ func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
|
||||
)
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"bucket": bucketName,
|
||||
"deleted": len(req.Keys),
|
||||
"keys": req.Keys,
|
||||
response := models.ObjectDeleteMultipleResponse{
|
||||
Bucket: bucketName,
|
||||
Deleted: len(req.Keys),
|
||||
Keys: req.Keys,
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(response))
|
||||
}
|
||||
|
||||
// UploadObjectStream uploads an object from request body stream (for large files)
|
||||
// PUT /api/v1/buckets/:bucket/objects/:key
|
||||
//
|
||||
// @Summary Upload object via stream
|
||||
// @Description Uploads an object directly from the request body stream, suitable for large files
|
||||
// @Tags Objects
|
||||
// @Accept application/octet-stream
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket to upload the object to"
|
||||
// @Param key path string true "Object key (path in bucket)"
|
||||
// @Param Content-Type header string false "Content type of the object being uploaded"
|
||||
// @Param body body string true "Raw binary data of the object"
|
||||
// @Success 201 {object} models.APIResponse{data=models.ObjectUploadResponse} "Object uploaded successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload object"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/{key} [put]
|
||||
func (h *ObjectHandler) UploadObjectStream(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
@@ -354,20 +446,6 @@ func (h *ObjectHandler) UploadObjectStream(c fiber.Ctx) error {
|
||||
)
|
||||
}
|
||||
|
||||
// Check if bucket exists
|
||||
exists, err := h.s3Service.BucketExists(ctx, bucketName)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
|
||||
)
|
||||
}
|
||||
|
||||
// Get content type from header
|
||||
contentType := c.Get("Content-Type", "application/octet-stream")
|
||||
|
||||
@@ -384,3 +462,127 @@ func (h *ObjectHandler) UploadObjectStream(c fiber.Ctx) error {
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult))
|
||||
}
|
||||
|
||||
// UploadMultipleObjects uploads multiple objects to a bucket
|
||||
//
|
||||
// @Summary Upload multiple objects to bucket
|
||||
// @Description Uploads multiple objects to the specified bucket using multipart/form-data. Accepts unlimited number of files and handles them in a loop.
|
||||
// @Tags Objects
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket to upload the objects to"
|
||||
// @Param files formData file true "Files to upload (can be multiple)"
|
||||
// @Success 201 {object} models.APIResponse{data=models.ObjectUploadMultipleResponse} "Objects uploaded successfully (including partial failures)"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload objects"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/upload-multiple [post]
|
||||
func (h *ObjectHandler) UploadMultipleObjects(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name from URL parameter
|
||||
bucketName := c.Params("bucket")
|
||||
if bucketName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Parse multipart form to get all files
|
||||
form, err := c.MultipartForm()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Failed to parse multipart form: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Get all files from the form (they should all be under "files" field)
|
||||
files := form.File["files"]
|
||||
if len(files) == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "At least one file is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Prepare upload data structure
|
||||
uploadFiles := make([]struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
ContentType string
|
||||
}, len(files))
|
||||
|
||||
// Open all files and prepare for upload
|
||||
for i, fileHeader := range files {
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to open file "+fileHeader.Filename+": "+err.Error()),
|
||||
)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Use filename as the key
|
||||
key := fileHeader.Filename
|
||||
contentType := fileHeader.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
uploadFiles[i] = struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
ContentType string
|
||||
}{
|
||||
Key: key,
|
||||
Body: file,
|
||||
ContentType: contentType,
|
||||
}
|
||||
}
|
||||
|
||||
// Upload all files using the service method
|
||||
results := h.s3Service.UploadMultipleObjects(ctx, bucketName, uploadFiles)
|
||||
|
||||
// Process results and categorize successes and failures
|
||||
var successFiles []models.ObjectUploadResult
|
||||
var failedFiles []models.ObjectUploadFailedResult
|
||||
successCount := 0
|
||||
failureCount := 0
|
||||
|
||||
for _, result := range results {
|
||||
if result.Success {
|
||||
successCount++
|
||||
successFiles = append(successFiles, models.ObjectUploadResult{
|
||||
Key: result.Key,
|
||||
ETag: result.ETag,
|
||||
Size: result.Size,
|
||||
ContentType: result.ContentType,
|
||||
})
|
||||
} else {
|
||||
failureCount++
|
||||
failedFiles = append(failedFiles, models.ObjectUploadFailedResult{
|
||||
Key: result.Key,
|
||||
Error: result.Error.Error(),
|
||||
ContentType: result.ContentType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
response := models.ObjectUploadMultipleResponse{
|
||||
Bucket: bucketName,
|
||||
TotalFiles: len(files),
|
||||
SuccessCount: successCount,
|
||||
FailureCount: failureCount,
|
||||
SuccessFiles: successFiles,
|
||||
FailedFiles: failedFiles,
|
||||
}
|
||||
|
||||
// Return 201 if all succeeded, 207 (Multi-Status) if partial success, 500 if all failed
|
||||
statusCode := fiber.StatusCreated
|
||||
if failureCount > 0 && successCount > 0 {
|
||||
statusCode = fiber.StatusMultiStatus // 207
|
||||
} else if failureCount > 0 && successCount == 0 {
|
||||
statusCode = fiber.StatusInternalServerError
|
||||
}
|
||||
|
||||
return c.Status(statusCode).JSON(models.SuccessResponse(response))
|
||||
}
|
||||
|
||||
+270
-92
@@ -1,46 +1,123 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// UserHandler handles user/key management operations
|
||||
// Note: Garage user management typically requires administrative API access
|
||||
// This is a placeholder implementation that you'll need to extend based on
|
||||
// your Garage setup and how you manage keys/users
|
||||
// UserHandler handles user/key management operations using Garage Admin API
|
||||
type UserHandler struct {
|
||||
// In a real implementation, you might have a Garage admin client here
|
||||
// or interact with Garage's administrative API
|
||||
adminService *services.GarageAdminService
|
||||
}
|
||||
|
||||
// NewUserHandler creates a new user handler
|
||||
func NewUserHandler() *UserHandler {
|
||||
return &UserHandler{}
|
||||
func NewUserHandler(adminService *services.GarageAdminService) *UserHandler {
|
||||
return &UserHandler{
|
||||
adminService: adminService,
|
||||
}
|
||||
}
|
||||
|
||||
// ListUsers returns all users/keys
|
||||
// GET /api/v1/users
|
||||
// ListUsers lists all users/access keys
|
||||
//
|
||||
// @Summary List all users
|
||||
// @Description Retrieves a list of all users/access keys
|
||||
// @Tags Users
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=models.UserListResponse} "List of users retrieved successfully"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list users"
|
||||
// @Router /api/v1/users [get]
|
||||
func (h *UserHandler) ListUsers(c fiber.Ctx) error {
|
||||
// NOTE: This is a placeholder implementation
|
||||
// Garage manages keys/users through its administrative RPC interface
|
||||
// You'll need to implement this based on your Garage setup
|
||||
ctx := c.Context()
|
||||
|
||||
// For now, return a not implemented response
|
||||
return c.Status(fiber.StatusNotImplemented).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError,
|
||||
"User management not yet implemented. Requires Garage Admin API integration."),
|
||||
)
|
||||
keys, err := h.adminService.ListKeys(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to list users: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Convert to UserInfo format
|
||||
users := make([]models.UserInfo, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
// Get full key info to retrieve bucket permissions
|
||||
keyInfo, err := h.adminService.GetKeyInfo(ctx, key.ID, false)
|
||||
if err != nil {
|
||||
// If we can't get full info, skip this key or use basic info
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert bucket permissions to frontend format
|
||||
bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets)
|
||||
|
||||
// Determine status based on expiration
|
||||
status := "active"
|
||||
if keyInfo.Expired {
|
||||
status = "inactive"
|
||||
}
|
||||
|
||||
users = append(users, models.UserInfo{
|
||||
AccessKeyID: keyInfo.AccessKeyID,
|
||||
Name: keyInfo.Name,
|
||||
CreatedAt: keyInfo.Created,
|
||||
Status: status,
|
||||
BucketPermissions: bucketPermissions,
|
||||
Expiration: keyInfo.Expiration,
|
||||
Expired: keyInfo.Expired,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(models.UserListResponse{
|
||||
Users: users,
|
||||
Count: len(users),
|
||||
}))
|
||||
}
|
||||
|
||||
// CreateUser creates a new user/key pair
|
||||
// POST /api/v1/users
|
||||
// convertBucketPermissionsToBucketPermissions converts Garage bucket permissions to frontend BucketPermission format
|
||||
func convertBucketPermissionsToBucketPermissions(buckets []models.KeyBucketInfo) []models.BucketPermission {
|
||||
permissions := make([]models.BucketPermission, 0, len(buckets))
|
||||
|
||||
for _, bucket := range buckets {
|
||||
// Get bucket name from aliases
|
||||
var bucketName string
|
||||
if len(bucket.GlobalAliases) > 0 {
|
||||
bucketName = bucket.GlobalAliases[0]
|
||||
} else if len(bucket.LocalAliases) > 0 {
|
||||
bucketName = bucket.LocalAliases[0]
|
||||
} else {
|
||||
bucketName = bucket.ID
|
||||
}
|
||||
|
||||
// Create bucket permission with simple read/write/owner flags
|
||||
permissions = append(permissions, models.BucketPermission{
|
||||
BucketID: bucket.ID,
|
||||
BucketName: bucketName,
|
||||
Read: bucket.Permissions.Read,
|
||||
Write: bucket.Permissions.Write,
|
||||
Owner: bucket.Permissions.Owner,
|
||||
})
|
||||
}
|
||||
|
||||
return permissions
|
||||
}
|
||||
|
||||
// CreateUser creates a new user/access key
|
||||
//
|
||||
// @Summary Create a new user
|
||||
// @Description Creates a new user/access key with optional name
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.CreateUserRequest true "User creation request"
|
||||
// @Success 201 {object} models.APIResponse{data=models.UserInfo} "User created successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request body"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to create user"
|
||||
// @Router /api/v1/users [post]
|
||||
func (h *UserHandler) CreateUser(c fiber.Ctx) error {
|
||||
// NOTE: This is a placeholder implementation
|
||||
// To implement this, you need to:
|
||||
// 1. Connect to Garage's admin RPC interface
|
||||
// 2. Call the appropriate key creation endpoint
|
||||
// 3. Return the generated access key and secret key
|
||||
ctx := c.Context()
|
||||
|
||||
var req models.CreateUserRequest
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
@@ -49,16 +126,57 @@ func (h *UserHandler) CreateUser(c fiber.Ctx) error {
|
||||
)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusNotImplemented).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError,
|
||||
"User creation not yet implemented. Requires Garage Admin API integration."),
|
||||
)
|
||||
// Prepare create key request
|
||||
createReq := models.CreateKeyRequest{}
|
||||
if req.Name != "" {
|
||||
createReq.Name = &req.Name
|
||||
}
|
||||
|
||||
// Create the key
|
||||
keyInfo, err := h.adminService.CreateKey(ctx, createReq)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create user: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Convert bucket permissions to frontend format
|
||||
bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets)
|
||||
|
||||
// Determine status
|
||||
status := "active"
|
||||
if keyInfo.Expired {
|
||||
status = "inactive"
|
||||
}
|
||||
|
||||
// Convert to UserInfo format
|
||||
userInfo := models.UserInfo{
|
||||
AccessKeyID: keyInfo.AccessKeyID,
|
||||
SecretKey: keyInfo.SecretAccessKey,
|
||||
Name: keyInfo.Name,
|
||||
CreatedAt: keyInfo.Created,
|
||||
Status: status,
|
||||
BucketPermissions: bucketPermissions,
|
||||
Expiration: keyInfo.Expiration,
|
||||
Expired: keyInfo.Expired,
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(userInfo))
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user/key
|
||||
// DELETE /api/v1/users/:access_key
|
||||
// DeleteUser deletes a user/access key
|
||||
//
|
||||
// @Summary Delete a user
|
||||
// @Description Deletes a specific user/access key
|
||||
// @Tags Users
|
||||
// @Produce json
|
||||
// @Param access_key path string true "Access key of the user to delete"
|
||||
// @Success 200 {object} models.APIResponse{data=map[string]interface{}} "User deleted successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete user"
|
||||
// @Router /api/v1/users/{access_key} [delete]
|
||||
func (h *UserHandler) DeleteUser(c fiber.Ctx) error {
|
||||
// NOTE: This is a placeholder implementation
|
||||
ctx := c.Context()
|
||||
accessKey := c.Params("access_key")
|
||||
|
||||
if accessKey == "" {
|
||||
@@ -67,15 +185,33 @@ func (h *UserHandler) DeleteUser(c fiber.Ctx) error {
|
||||
)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusNotImplemented).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError,
|
||||
"User deletion not yet implemented. Requires Garage Admin API integration."),
|
||||
)
|
||||
// Delete the key
|
||||
err := h.adminService.DeleteKey(ctx, accessKey)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to delete user: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(map[string]interface{}{
|
||||
"access_key": accessKey,
|
||||
"deleted": true,
|
||||
}))
|
||||
}
|
||||
|
||||
// GetUser returns information about a specific user/key
|
||||
// GET /api/v1/users/:access_key
|
||||
// GetUser retrieves information about a specific user/access key
|
||||
//
|
||||
// @Summary Get user information
|
||||
// @Description Retrieves information about a specific user/access key
|
||||
// @Tags Users
|
||||
// @Produce json
|
||||
// @Param access_key path string true "Access key of the user to retrieve"
|
||||
// @Success 200 {object} models.APIResponse{data=models.UserInfo} "User information retrieved successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get user info"
|
||||
// @Router /api/v1/users/{access_key} [get]
|
||||
func (h *UserHandler) GetUser(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
accessKey := c.Params("access_key")
|
||||
|
||||
if accessKey == "" {
|
||||
@@ -84,15 +220,52 @@ func (h *UserHandler) GetUser(c fiber.Ctx) error {
|
||||
)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusNotImplemented).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError,
|
||||
"User info not yet implemented. Requires Garage Admin API integration."),
|
||||
)
|
||||
// Get key information (without secret key)
|
||||
keyInfo, err := h.adminService.GetKeyInfo(ctx, accessKey, false)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get user info: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Convert bucket permissions to frontend format
|
||||
bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets)
|
||||
|
||||
// Determine status
|
||||
status := "active"
|
||||
if keyInfo.Expired {
|
||||
status = "inactive"
|
||||
}
|
||||
|
||||
// Convert to UserInfo format
|
||||
userInfo := models.UserInfo{
|
||||
AccessKeyID: keyInfo.AccessKeyID,
|
||||
Name: keyInfo.Name,
|
||||
CreatedAt: keyInfo.Created,
|
||||
Status: status,
|
||||
BucketPermissions: bucketPermissions,
|
||||
Expiration: keyInfo.Expiration,
|
||||
Expired: keyInfo.Expired,
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(userInfo))
|
||||
}
|
||||
|
||||
// UpdateUserPermissions updates user permissions
|
||||
// PATCH /api/v1/users/:access_key
|
||||
//
|
||||
// @Summary Update user permissions
|
||||
// @Description Updates the permissions and settings for a specific user/access key
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param access_key path string true "Access key of the user to update"
|
||||
// @Param request body models.UpdateUserRequest true "User update request with new permissions"
|
||||
// @Success 200 {object} models.APIResponse{data=models.UserInfo} "User updated successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required or invalid request body"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to update user"
|
||||
// @Router /api/v1/users/{access_key} [patch]
|
||||
func (h *UserHandler) UpdateUserPermissions(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
accessKey := c.Params("access_key")
|
||||
|
||||
if accessKey == "" {
|
||||
@@ -108,56 +281,61 @@ func (h *UserHandler) UpdateUserPermissions(c fiber.Ctx) error {
|
||||
)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusNotImplemented).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError,
|
||||
"User permission update not yet implemented. Requires Garage Admin API integration."),
|
||||
)
|
||||
}
|
||||
// Prepare update request
|
||||
updateReq := models.UpdateKeyRequest{}
|
||||
|
||||
/*
|
||||
IMPLEMENTATION NOTES FOR USER MANAGEMENT:
|
||||
|
||||
Garage uses an administrative RPC interface for managing keys and buckets.
|
||||
To implement user management, you need to:
|
||||
|
||||
1. Install garage-admin client or use HTTP RPC calls
|
||||
2. Connect to Garage's admin port (typically 3903)
|
||||
3. Implement the following operations:
|
||||
|
||||
- List keys: GET /v1/key
|
||||
- Create key: POST /v1/key
|
||||
- Get key info: GET /v1/key?id=<access_key>
|
||||
- Delete key: DELETE /v1/key?id=<access_key>
|
||||
- Update key: POST /v1/key?id=<access_key>
|
||||
|
||||
Example using HTTP client:
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type GarageAdminClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
}
|
||||
|
||||
func (g *GarageAdminClient) ListKeys() ([]KeyInfo, error) {
|
||||
req, _ := http.NewRequest("GET", g.baseURL+"/v1/key", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+g.token)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Handle status change (activate/deactivate)
|
||||
if req.Status != nil {
|
||||
if *req.Status == "inactive" {
|
||||
// Deactivate by setting expiration to the past
|
||||
pastTime := time.Now().Add(-24 * time.Hour)
|
||||
updateReq.Expiration = &pastTime
|
||||
updateReq.NeverExpires = false
|
||||
} else if *req.Status == "active" {
|
||||
// Activate by removing expiration (set to never expire)
|
||||
updateReq.NeverExpires = true
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var keys []KeyInfo
|
||||
json.NewDecoder(resp.Body).Decode(&keys)
|
||||
return keys, nil
|
||||
// Handle explicit expiration date setting
|
||||
if req.Expiration != nil && *req.Expiration != "" {
|
||||
expirationTime, err := time.Parse(time.RFC3339, *req.Expiration)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration date format: "+err.Error()),
|
||||
)
|
||||
}
|
||||
updateReq.Expiration = &expirationTime
|
||||
updateReq.NeverExpires = false
|
||||
}
|
||||
|
||||
// Update the key
|
||||
keyInfo, err := h.adminService.UpdateKey(ctx, accessKey, updateReq)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to update user: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Convert bucket permissions to frontend format
|
||||
bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets)
|
||||
|
||||
// Determine status
|
||||
status := "active"
|
||||
if keyInfo.Expired {
|
||||
status = "inactive"
|
||||
}
|
||||
|
||||
// Convert to UserInfo format
|
||||
userInfo := models.UserInfo{
|
||||
AccessKeyID: keyInfo.AccessKeyID,
|
||||
Name: keyInfo.Name,
|
||||
CreatedAt: keyInfo.Created,
|
||||
Status: status,
|
||||
BucketPermissions: bucketPermissions,
|
||||
Expiration: keyInfo.Expiration,
|
||||
Expired: keyInfo.Expired,
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(userInfo))
|
||||
}
|
||||
|
||||
For more information, see:
|
||||
https://garagehq.deuxfleurs.fr/documentation/reference-manual/admin-api/
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ====================================
|
||||
// Access Key Models
|
||||
// ====================================
|
||||
|
||||
// GarageKeyInfo represents detailed information about a Garage access key
|
||||
type GarageKeyInfo struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
Name string `json:"name"`
|
||||
Expired bool `json:"expired"`
|
||||
SecretAccessKey *string `json:"secretAccessKey,omitempty"`
|
||||
Permissions KeyPermissions `json:"permissions"`
|
||||
Buckets []KeyBucketInfo `json:"buckets"`
|
||||
Created *time.Time `json:"created,omitempty"`
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
}
|
||||
|
||||
// KeyPermissions represents permissions for an access key
|
||||
type KeyPermissions struct {
|
||||
CreateBucket bool `json:"createBucket"`
|
||||
}
|
||||
|
||||
// KeyBucketInfo represents bucket information associated with a key
|
||||
type KeyBucketInfo struct {
|
||||
ID string `json:"id"`
|
||||
GlobalAliases []string `json:"globalAliases"`
|
||||
LocalAliases []string `json:"localAliases"`
|
||||
Permissions BucketKeyPermission `json:"permissions"`
|
||||
}
|
||||
|
||||
// BucketKeyPermission represents permissions a key has on a specific bucket
|
||||
type BucketKeyPermission struct {
|
||||
Read bool `json:"read"`
|
||||
Write bool `json:"write"`
|
||||
Owner bool `json:"owner"`
|
||||
}
|
||||
|
||||
// CreateKeyRequest represents the request to create a new access key
|
||||
type CreateKeyRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
NeverExpires bool `json:"neverExpires,omitempty"`
|
||||
Allow *KeyPermissions `json:"allow,omitempty"`
|
||||
Deny *KeyPermissions `json:"deny,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateKeyRequest represents the request to update an access key
|
||||
type UpdateKeyRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
NeverExpires bool `json:"neverExpires,omitempty"`
|
||||
Allow *KeyPermissions `json:"allow,omitempty"`
|
||||
Deny *KeyPermissions `json:"deny,omitempty"`
|
||||
}
|
||||
|
||||
// ImportKeyRequest represents the request to import an existing key
|
||||
type ImportKeyRequest struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
SecretAccessKey string `json:"secretAccessKey"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// ListKeysResponseItem represents a single key in the list response
|
||||
type ListKeysResponseItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Expired bool `json:"expired"`
|
||||
Created *time.Time `json:"created,omitempty"`
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Bucket Models (Admin API)
|
||||
// ====================================
|
||||
|
||||
// GarageBucketInfo represents detailed information about a bucket from Admin API
|
||||
type GarageBucketInfo struct {
|
||||
ID string `json:"id"`
|
||||
Created time.Time `json:"created"`
|
||||
GlobalAliases []string `json:"globalAliases"`
|
||||
WebsiteAccess bool `json:"websiteAccess"`
|
||||
WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"`
|
||||
Keys []BucketKeyInfo `json:"keys"`
|
||||
Objects int64 `json:"objects"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
UnfinishedUploads int64 `json:"unfinishedUploads"`
|
||||
UnfinishedMultipartUploads int64 `json:"unfinishedMultipartUploads"`
|
||||
UnfinishedMultipartUploadParts int64 `json:"unfinishedMultipartUploadParts"`
|
||||
UnfinishedMultipartUploadBytes int64 `json:"unfinishedMultipartUploadBytes"`
|
||||
Quotas *BucketQuotas `json:"quotas,omitempty"`
|
||||
}
|
||||
|
||||
// BucketWebsiteConfig represents website configuration for a bucket
|
||||
type BucketWebsiteConfig struct {
|
||||
IndexDocument string `json:"indexDocument"`
|
||||
ErrorDocument *string `json:"errorDocument,omitempty"`
|
||||
}
|
||||
|
||||
// BucketQuotas represents quota settings for a bucket
|
||||
type BucketQuotas struct {
|
||||
MaxSize *int64 `json:"maxSize,omitempty"`
|
||||
MaxObjects *int64 `json:"maxObjects,omitempty"`
|
||||
}
|
||||
|
||||
// BucketKeyInfo represents key information associated with a bucket
|
||||
type BucketKeyInfo struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
Name string `json:"name"`
|
||||
Permissions BucketKeyPermission `json:"permissions"`
|
||||
BucketLocalAliases []string `json:"bucketLocalAliases"`
|
||||
}
|
||||
|
||||
// CreateBucketAdminRequest represents the request to create a bucket via Admin API
|
||||
type CreateBucketAdminRequest struct {
|
||||
GlobalAlias *string `json:"globalAlias,omitempty"`
|
||||
LocalAlias *CreateBucketLocalAlias `json:"localAlias,omitempty"`
|
||||
}
|
||||
|
||||
// CreateBucketLocalAlias represents local alias configuration when creating a bucket
|
||||
type CreateBucketLocalAlias struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
Alias string `json:"alias"`
|
||||
Allow *BucketKeyPermission `json:"allow,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateBucketRequest represents the request to update bucket settings
|
||||
type UpdateBucketRequest struct {
|
||||
WebsiteAccess *UpdateBucketWebsiteAccess `json:"websiteAccess,omitempty"`
|
||||
Quotas *BucketQuotas `json:"quotas,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateBucketWebsiteAccess represents website access settings update
|
||||
type UpdateBucketWebsiteAccess struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
IndexDocument *string `json:"indexDocument,omitempty"`
|
||||
ErrorDocument *string `json:"errorDocument,omitempty"`
|
||||
}
|
||||
|
||||
// ListBucketsResponseItem represents a single bucket in the list response
|
||||
type ListBucketsResponseItem struct {
|
||||
ID string `json:"id"`
|
||||
Created time.Time `json:"created"`
|
||||
GlobalAliases []string `json:"globalAliases"`
|
||||
LocalAliases []BucketLocalAlias `json:"localAliases"`
|
||||
}
|
||||
|
||||
// BucketLocalAlias represents a local alias for a bucket
|
||||
type BucketLocalAlias struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
Alias string `json:"alias"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Bucket Alias Models
|
||||
// ====================================
|
||||
|
||||
// AddBucketAliasRequest represents the request to add a bucket alias
|
||||
type AddBucketAliasRequest struct {
|
||||
BucketID string `json:"bucketId"`
|
||||
GlobalAlias *string `json:"globalAlias,omitempty"`
|
||||
LocalAlias *string `json:"localAlias,omitempty"`
|
||||
AccessKeyID *string `json:"accessKeyId,omitempty"`
|
||||
}
|
||||
|
||||
// RemoveBucketAliasRequest represents the request to remove a bucket alias
|
||||
type RemoveBucketAliasRequest struct {
|
||||
BucketID string `json:"bucketId"`
|
||||
GlobalAlias *string `json:"globalAlias,omitempty"`
|
||||
LocalAlias *string `json:"localAlias,omitempty"`
|
||||
AccessKeyID *string `json:"accessKeyId,omitempty"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Permission Models
|
||||
// ====================================
|
||||
|
||||
// BucketKeyPermRequest represents a request to change bucket-key permissions
|
||||
type BucketKeyPermRequest struct {
|
||||
BucketID string `json:"bucketId"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
Permissions BucketKeyPermission `json:"permissions"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Cluster Models
|
||||
// ====================================
|
||||
|
||||
// ClusterHealth represents the health status of the cluster
|
||||
type ClusterHealth struct {
|
||||
Status string `json:"status"`
|
||||
KnownNodes int `json:"knownNodes"`
|
||||
ConnectedNodes int `json:"connectedNodes"`
|
||||
StorageNodes int `json:"storageNodes"`
|
||||
StorageNodesUp int `json:"storageNodesUp"`
|
||||
Partitions int `json:"partitions"`
|
||||
PartitionsQuorum int `json:"partitionsQuorum"`
|
||||
PartitionsAllOk int `json:"partitionsAllOk"`
|
||||
}
|
||||
|
||||
// ClusterStatus represents the current status of the cluster
|
||||
type ClusterStatus struct {
|
||||
LayoutVersion int `json:"layoutVersion"`
|
||||
Nodes []NodeInfo `json:"nodes"`
|
||||
}
|
||||
|
||||
// ClusterStatistics represents global cluster statistics
|
||||
type ClusterStatistics struct {
|
||||
Freeform string `json:"freeform"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Node Models
|
||||
// ====================================
|
||||
|
||||
// NodeInfo represents information about a cluster node
|
||||
type NodeInfo struct {
|
||||
ID string `json:"id"`
|
||||
IsUp bool `json:"isUp"`
|
||||
LastSeenSecsAgo *int64 `json:"lastSeenSecsAgo,omitempty"`
|
||||
Hostname *string `json:"hostname,omitempty"`
|
||||
Addr *string `json:"addr,omitempty"`
|
||||
GarageVersion *string `json:"garageVersion,omitempty"`
|
||||
Role *NodeRole `json:"role,omitempty"`
|
||||
Draining bool `json:"draining"`
|
||||
DataPartition *FreeSpaceInfo `json:"dataPartition,omitempty"`
|
||||
MetadataPartition *FreeSpaceInfo `json:"metadataPartition,omitempty"`
|
||||
}
|
||||
|
||||
// NodeRole represents the role assigned to a node
|
||||
type NodeRole struct {
|
||||
Zone string `json:"zone"`
|
||||
Capacity *int64 `json:"capacity,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
// FreeSpaceInfo represents disk space information
|
||||
type FreeSpaceInfo struct {
|
||||
Available int64 `json:"available"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// NodeInfoResponse represents the response for GetNodeInfo
|
||||
type NodeInfoResponse struct {
|
||||
NodeID string `json:"nodeId"`
|
||||
GarageVersion string `json:"garageVersion"`
|
||||
RustVersion string `json:"rustVersion"`
|
||||
DBEngine string `json:"dbEngine"`
|
||||
GarageFeatures []string `json:"garageFeatures,omitempty"`
|
||||
}
|
||||
|
||||
// NodeStatisticsResponse represents the response for GetNodeStatistics
|
||||
type NodeStatisticsResponse struct {
|
||||
Freeform string `json:"freeform"`
|
||||
}
|
||||
|
||||
// MultiNodeResponse represents responses from multiple nodes
|
||||
type MultiNodeResponse struct {
|
||||
Success map[string]interface{} `json:"success"`
|
||||
Error map[string]string `json:"error"`
|
||||
}
|
||||
@@ -6,6 +6,12 @@ type CreateBucketRequest struct {
|
||||
Region string `json:"region,omitempty"`
|
||||
}
|
||||
|
||||
// GrantBucketPermissionRequest represents a request to grant permissions on a bucket
|
||||
type GrantBucketPermissionRequest struct {
|
||||
AccessKeyID string `json:"accessKeyId" validate:"required"`
|
||||
Permissions BucketKeyPermission `json:"permissions" validate:"required"`
|
||||
}
|
||||
|
||||
// DeleteBucketRequest represents a request to delete a bucket
|
||||
type DeleteBucketRequest struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
@@ -13,10 +19,10 @@ type DeleteBucketRequest struct {
|
||||
|
||||
// ListObjectsRequest represents a request to list objects in a bucket
|
||||
type ListObjectsRequest struct {
|
||||
Bucket string `json:"bucket" validate:"required"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
MaxKeys int `json:"max_keys,omitempty"`
|
||||
Marker string `json:"marker,omitempty"`
|
||||
Bucket string `json:"bucket" validate:"required"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
MaxKeys int `json:"max_keys,omitempty"`
|
||||
Marker string `json:"marker,omitempty"`
|
||||
}
|
||||
|
||||
// UploadObjectRequest represents metadata for an object upload
|
||||
@@ -51,6 +57,6 @@ type DeleteUserRequest struct {
|
||||
|
||||
// UpdateUserRequest represents a request to update user permissions
|
||||
type UpdateUserRequest struct {
|
||||
AccessKey string `json:"access_key" validate:"required"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
Status *string `json:"status,omitempty"` // "active" or "inactive"
|
||||
Expiration *string `json:"expiration,omitempty"` // ISO 8601 date string
|
||||
}
|
||||
|
||||
+111
-23
@@ -2,6 +2,32 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// DashboardMetrics represents aggregated metrics for the dashboard
|
||||
type DashboardMetrics struct {
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
ObjectCount int64 `json:"objectCount"`
|
||||
BucketCount int `json:"bucketCount"`
|
||||
UsageByBucket []BucketUsage `json:"usageByBucket"`
|
||||
RequestMetrics RequestMetrics `json:"requestMetrics"`
|
||||
}
|
||||
|
||||
// BucketUsage represents storage usage for a single bucket
|
||||
type BucketUsage struct {
|
||||
BucketName string `json:"bucketName"`
|
||||
Size int64 `json:"size"`
|
||||
ObjectCount int64 `json:"objectCount"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
}
|
||||
|
||||
// RequestMetrics represents API request statistics
|
||||
type RequestMetrics struct {
|
||||
GetRequests int64 `json:"getRequests"`
|
||||
PutRequests int64 `json:"putRequests"`
|
||||
DeleteRequests int64 `json:"deleteRequests"`
|
||||
ListRequests int64 `json:"listRequests"`
|
||||
Period string `json:"period"`
|
||||
}
|
||||
|
||||
// APIResponse is the standard response structure for all API endpoints
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
@@ -26,6 +52,8 @@ type HealthResponse struct {
|
||||
type BucketInfo struct {
|
||||
Name string `json:"name"`
|
||||
CreationDate time.Time `json:"creation_date"`
|
||||
ObjectCount *int64 `json:"object_count,omitempty"`
|
||||
Size *int64 `json:"size,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
}
|
||||
|
||||
@@ -47,11 +75,12 @@ type ObjectInfo struct {
|
||||
|
||||
// ObjectListResponse represents a list of objects in a bucket
|
||||
type ObjectListResponse struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Objects []ObjectInfo `json:"objects"`
|
||||
Count int `json:"count"`
|
||||
IsTruncated bool `json:"is_truncated"`
|
||||
NextMarker string `json:"next_marker,omitempty"`
|
||||
Bucket string `json:"bucket"`
|
||||
Objects []ObjectInfo `json:"objects"`
|
||||
Prefixes []string `json:"prefixes"`
|
||||
Count int `json:"count"`
|
||||
IsTruncated bool `json:"is_truncated"`
|
||||
NextContinuationToken string `json:"next_continuation_token,omitempty"`
|
||||
}
|
||||
|
||||
// ObjectUploadResponse represents the response after uploading an object
|
||||
@@ -63,6 +92,31 @@ type ObjectUploadResponse struct {
|
||||
ContentType string `json:"content_type"`
|
||||
}
|
||||
|
||||
// ObjectUploadMultipleResponse represents the response after uploading multiple objects
|
||||
type ObjectUploadMultipleResponse struct {
|
||||
Bucket string `json:"bucket"`
|
||||
TotalFiles int `json:"total_files"`
|
||||
SuccessCount int `json:"success_count"`
|
||||
FailureCount int `json:"failure_count"`
|
||||
SuccessFiles []ObjectUploadResult `json:"success_files"`
|
||||
FailedFiles []ObjectUploadFailedResult `json:"failed_files,omitempty"`
|
||||
}
|
||||
|
||||
// ObjectUploadResult represents a successful upload result
|
||||
type ObjectUploadResult struct {
|
||||
Key string `json:"key"`
|
||||
ETag string `json:"etag"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
}
|
||||
|
||||
// ObjectUploadFailedResult represents a failed upload result
|
||||
type ObjectUploadFailedResult struct {
|
||||
Key string `json:"key"`
|
||||
Error string `json:"error"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
}
|
||||
|
||||
// ObjectDeleteResponse represents the response after deleting an object
|
||||
type ObjectDeleteResponse struct {
|
||||
Bucket string `json:"bucket"`
|
||||
@@ -72,10 +126,44 @@ type ObjectDeleteResponse struct {
|
||||
|
||||
// UserInfo represents information about a Garage user (key pair)
|
||||
type UserInfo struct {
|
||||
AccessKey string `json:"access_key"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
Name string `json:"name"`
|
||||
SecretKey *string `json:"secretKey,omitempty"`
|
||||
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
||||
LastUsed *time.Time `json:"lastUsed,omitempty"`
|
||||
Status string `json:"status"` // "active" or "inactive"
|
||||
BucketPermissions []BucketPermission `json:"permissions"` // Array of bucket permissions
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
Expired bool `json:"expired"`
|
||||
}
|
||||
|
||||
// BucketPermission represents permissions for a specific bucket
|
||||
type BucketPermission struct {
|
||||
BucketID string `json:"bucketId"`
|
||||
BucketName string `json:"bucketName"`
|
||||
Read bool `json:"read"`
|
||||
Write bool `json:"write"`
|
||||
Owner bool `json:"owner"`
|
||||
}
|
||||
|
||||
// Permission represents a permission entry for access control (legacy/deprecated)
|
||||
type Permission struct {
|
||||
Resource string `json:"resource"`
|
||||
Actions []string `json:"actions"`
|
||||
Effect string `json:"effect"` // "Allow" or "Deny"
|
||||
}
|
||||
|
||||
type PresignedURLResponse struct {
|
||||
URL string `json:"url"`
|
||||
ExpiresIn int64 `json:"expires_in"` // in seconds
|
||||
Bucket string `json:"bucket"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
type ObjectDeleteMultipleResponse struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Deleted int `json:"deleted"`
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
|
||||
// UserListResponse represents a list of users/keys
|
||||
@@ -109,18 +197,18 @@ func ErrorResponse(code, message string) APIResponse {
|
||||
|
||||
// Common error codes
|
||||
const (
|
||||
ErrCodeBadRequest = "BAD_REQUEST"
|
||||
ErrCodeUnauthorized = "UNAUTHORIZED"
|
||||
ErrCodeForbidden = "FORBIDDEN"
|
||||
ErrCodeNotFound = "NOT_FOUND"
|
||||
ErrCodeConflict = "CONFLICT"
|
||||
ErrCodeInternalError = "INTERNAL_ERROR"
|
||||
ErrCodeBucketExists = "BUCKET_ALREADY_EXISTS"
|
||||
ErrCodeBucketNotFound = "BUCKET_NOT_FOUND"
|
||||
ErrCodeObjectNotFound = "OBJECT_NOT_FOUND"
|
||||
ErrCodeInvalidBucketName = "INVALID_BUCKET_NAME"
|
||||
ErrCodeInvalidObjectKey = "INVALID_OBJECT_KEY"
|
||||
ErrCodeUploadFailed = "UPLOAD_FAILED"
|
||||
ErrCodeDeleteFailed = "DELETE_FAILED"
|
||||
ErrCodeListFailed = "LIST_FAILED"
|
||||
ErrCodeBadRequest = "BAD_REQUEST"
|
||||
ErrCodeUnauthorized = "UNAUTHORIZED"
|
||||
ErrCodeForbidden = "FORBIDDEN"
|
||||
ErrCodeNotFound = "NOT_FOUND"
|
||||
ErrCodeConflict = "CONFLICT"
|
||||
ErrCodeInternalError = "INTERNAL_ERROR"
|
||||
ErrCodeBucketExists = "BUCKET_ALREADY_EXISTS"
|
||||
ErrCodeBucketNotFound = "BUCKET_NOT_FOUND"
|
||||
ErrCodeObjectNotFound = "OBJECT_NOT_FOUND"
|
||||
ErrCodeInvalidBucketName = "INVALID_BUCKET_NAME"
|
||||
ErrCodeInvalidObjectKey = "INVALID_OBJECT_KEY"
|
||||
ErrCodeUploadFailed = "UPLOAD_FAILED"
|
||||
ErrCodeDeleteFailed = "DELETE_FAILED"
|
||||
ErrCodeListFailed = "LIST_FAILED"
|
||||
)
|
||||
|
||||
+43
-12
@@ -5,7 +5,13 @@ import (
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/handlers"
|
||||
"Noooste/garage-ui/internal/middleware"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
|
||||
// Swagger imports
|
||||
_ "Noooste/garage-ui/docs"
|
||||
|
||||
"github.com/Noooste/swagger"
|
||||
)
|
||||
|
||||
// SetupRoutes configures all API routes
|
||||
@@ -17,6 +23,8 @@ func SetupRoutes(
|
||||
bucketHandler *handlers.BucketHandler,
|
||||
objectHandler *handlers.ObjectHandler,
|
||||
userHandler *handlers.UserHandler,
|
||||
clusterHandler *handlers.ClusterHandler,
|
||||
monitoringHandler *handlers.MonitoringHandler,
|
||||
) {
|
||||
// Apply CORS middleware globally
|
||||
app.Use(middleware.CORSMiddleware(&cfg.CORS))
|
||||
@@ -25,6 +33,9 @@ func SetupRoutes(
|
||||
app.Get("/health", healthHandler.Check)
|
||||
app.Get("/api/v1/health", healthHandler.Check)
|
||||
|
||||
// Swagger documentation endpoint (no auth required)
|
||||
app.Get("/docs/*", swagger.HandlerDefault)
|
||||
|
||||
// API v1 group
|
||||
api := app.Group("/api/v1")
|
||||
|
||||
@@ -34,23 +45,25 @@ func SetupRoutes(
|
||||
// Bucket routes
|
||||
buckets := api.Group("/buckets")
|
||||
{
|
||||
buckets.Get("/", bucketHandler.ListBuckets) // List all buckets
|
||||
buckets.Post("/", bucketHandler.CreateBucket) // Create a new bucket
|
||||
buckets.Get("/:name", bucketHandler.GetBucketInfo) // Get bucket info
|
||||
buckets.Delete("/:name", bucketHandler.DeleteBucket) // Delete a bucket
|
||||
buckets.Get("/", bucketHandler.ListBuckets) // List all buckets
|
||||
buckets.Post("/", bucketHandler.CreateBucket) // Create a new bucket
|
||||
buckets.Get("/:name", bucketHandler.GetBucketInfo) // Get bucket info
|
||||
buckets.Delete("/:name", bucketHandler.DeleteBucket) // Delete a bucket
|
||||
buckets.Post("/:name/permissions", bucketHandler.GrantBucketPermission) // Grant bucket permissions
|
||||
}
|
||||
|
||||
// Object routes
|
||||
objects := api.Group("/buckets/:bucket/objects")
|
||||
{
|
||||
objects.Get("/", objectHandler.ListObjects) // List objects in bucket
|
||||
objects.Post("/", objectHandler.UploadObject) // Upload object (multipart)
|
||||
objects.Delete("/", objectHandler.DeleteMultipleObjects) // Delete multiple objects
|
||||
objects.Get("/:key", objectHandler.GetObject) // Download object
|
||||
objects.Put("/:key", objectHandler.UploadObjectStream) // Upload object (stream)
|
||||
objects.Delete("/:key", objectHandler.DeleteObject) // Delete object
|
||||
objects.Head("/:key", objectHandler.GetObjectMetadata) // Get object metadata
|
||||
objects.Post("/:key/presign", objectHandler.GetPresignedURL) // Generate pre-signed URL
|
||||
objects.Get("/", objectHandler.ListObjects) // List objects in bucket
|
||||
objects.Post("/", objectHandler.UploadObject) // Upload object (multipart)
|
||||
objects.Post("/upload-multiple", objectHandler.UploadMultipleObjects) // Upload multiple objects
|
||||
objects.Post("/delete-multiple", objectHandler.DeleteMultipleObjects) // Delete multiple objects
|
||||
objects.Get("/:key", objectHandler.GetObject) // Download object
|
||||
objects.Put("/:key", objectHandler.UploadObjectStream) // Upload object (stream)
|
||||
objects.Delete("/:key", objectHandler.DeleteObject) // Delete object
|
||||
objects.Head("/:key", objectHandler.GetObjectMetadata) // Get object metadata
|
||||
objects.Post("/:key/presign", objectHandler.GetPresignedURL) // Generate pre-signed URL
|
||||
}
|
||||
|
||||
// User/Key management routes
|
||||
@@ -63,6 +76,24 @@ func SetupRoutes(
|
||||
users.Patch("/:access_key", userHandler.UpdateUserPermissions) // Update user permissions
|
||||
}
|
||||
|
||||
// Cluster management routes
|
||||
cluster := api.Group("/cluster")
|
||||
{
|
||||
cluster.Get("/health", clusterHandler.GetHealth) // Get cluster health
|
||||
cluster.Get("/status", clusterHandler.GetStatus) // Get cluster status
|
||||
cluster.Get("/statistics", clusterHandler.GetStatistics) // Get cluster statistics
|
||||
cluster.Get("/nodes/:node_id", clusterHandler.GetNodeInfo) // Get node info
|
||||
cluster.Get("/nodes/:node_id/statistics", clusterHandler.GetNodeStatistics) // Get node statistics
|
||||
}
|
||||
|
||||
// Monitoring routes
|
||||
monitoring := api.Group("/monitoring")
|
||||
{
|
||||
monitoring.Get("/metrics", monitoringHandler.GetMetrics) // Get Prometheus metrics
|
||||
monitoring.Get("/admin-health", monitoringHandler.CheckAdminHealth) // Check Admin API health
|
||||
monitoring.Get("/dashboard", monitoringHandler.GetDashboardMetrics) // Get dashboard metrics
|
||||
}
|
||||
|
||||
// OIDC authentication routes (only if OIDC is enabled)
|
||||
if cfg.Auth.Mode == "oidc" && cfg.Auth.OIDC.Enabled {
|
||||
authRoutes := app.Group("/auth")
|
||||
|
||||
@@ -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