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

This commit is contained in:
Noooste
2025-11-25 18:12:48 +01:00
parent f33452dcb1
commit c6248adde9
51 changed files with 5953 additions and 2107 deletions
+1
View File
@@ -8,6 +8,7 @@ import (
"strings"
"Noooste/garage-ui/internal/config"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
+33 -25
View File
@@ -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
View File
@@ -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))
}
+145
View File
@@ -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))
}
+9
View File
@@ -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",
+152
View File
@@ -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
View File
@@ -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
View File
@@ -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/
*/
+1
View File
@@ -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"
)
+1
View File
@@ -4,6 +4,7 @@ import (
"strings"
"Noooste/garage-ui/internal/config"
"github.com/gofiber/fiber/v3"
)
+263
View File
@@ -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"`
}
+12 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+460
View File
@@ -0,0 +1,460 @@
package services
import (
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/Noooste/azuretls-client"
)
// GarageAdminService handles interactions with the Garage Admin API
type GarageAdminService struct {
baseURL string
token string
httpClient *azuretls.Session
}
// NewGarageAdminService creates a new Garage Admin API service
func NewGarageAdminService(cfg *config.GarageConfig) *GarageAdminService {
session := azuretls.NewSession()
session.Log()
return &GarageAdminService{
baseURL: cfg.AdminEndpoint,
token: cfg.AdminToken,
httpClient: session,
}
}
// doRequest performs an HTTP request to the Admin API
func (s *GarageAdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) {
return s.httpClient.Do(&azuretls.Request{
Method: method,
Url: s.baseURL + path,
Body: body,
IgnoreBody: true, // decodeResponse will handle body reading
OrderedHeaders: azuretls.OrderedHeaders{
{"Authorization", fmt.Sprintf("Bearer %s", s.token)},
},
}, ctx)
}
// decodeResponse decodes a JSON response into the target structure
func decodeResponse(resp *azuretls.Response, target interface{}) error {
defer resp.RawBody.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(resp.RawBody)
return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
if target != nil {
if err := json.NewDecoder(resp.RawBody).Decode(target); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
}
return nil
}
// ====================================
// Access Key Operations
// ====================================
// ListKeys returns all access keys in the cluster
func (s *GarageAdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListKeys", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result []models.ListKeysResponseItem
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return result, nil
}
// CreateKey creates a new API access key
func (s *GarageAdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateKey", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// GetKeyInfo returns information about a specific access key
func (s *GarageAdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) {
path := fmt.Sprintf("/v2/GetKeyInfo?id=%s", keyID)
if showSecret {
path += "&showSecretKey=true"
}
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// UpdateKey updates information about an access key
func (s *GarageAdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
path := fmt.Sprintf("/v2/UpdateKey?id=%s", keyID)
resp, err := s.doRequest(ctx, http.MethodPost, path, req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// DeleteKey deletes an access key from the cluster
func (s *GarageAdminService) DeleteKey(ctx context.Context, keyID string) error {
path := fmt.Sprintf("/v2/DeleteKey?id=%s", keyID)
resp, err := s.doRequest(ctx, http.MethodPost, path, nil)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
if err := decodeResponse(resp, nil); err != nil {
return fmt.Errorf("failed to process response: %w", err)
}
return nil
}
// ImportKey imports an existing API access key
func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/ImportKey", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// ====================================
// Bucket Operations (Admin API)
// ====================================
// ListBuckets returns all buckets in the cluster
func (s *GarageAdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListBuckets", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result []models.ListBucketsResponseItem
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return result, nil
}
// GetBucketInfo returns detailed information about a bucket by ID
func (s *GarageAdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) {
path := fmt.Sprintf("/v2/GetBucketInfo?id=%s", bucketID)
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// GetBucketInfoByAlias returns detailed information about a bucket by its global alias
func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) {
path := fmt.Sprintf("/v2/GetBucketInfo?globalAlias=%s", globalAlias)
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// CreateBucket creates a new bucket via the Admin API
func (s *GarageAdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateBucket", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// UpdateBucket updates bucket settings
func (s *GarageAdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
path := fmt.Sprintf("/v2/UpdateBucket?id=%s", bucketID)
resp, err := s.doRequest(ctx, http.MethodPost, path, req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// DeleteBucket deletes a bucket
func (s *GarageAdminService) DeleteBucket(ctx context.Context, bucketID string) error {
path := fmt.Sprintf("/v2/DeleteBucket?id=%s", bucketID)
resp, err := s.doRequest(ctx, http.MethodPost, path, nil)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
if err := decodeResponse(resp, nil); err != nil {
return fmt.Errorf("failed to process response: %w", err)
}
return nil
}
// ====================================
// Bucket Alias Operations
// ====================================
// AddBucketAlias adds an alias to a bucket
func (s *GarageAdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AddBucketAlias", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// RemoveBucketAlias removes an alias from a bucket
func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/RemoveBucketAlias", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// ====================================
// Permission Operations
// ====================================
// AllowBucketKey grants permissions for a key on a bucket
func (s *GarageAdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AllowBucketKey", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// DenyBucketKey revokes permissions for a key on a bucket
func (s *GarageAdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/DenyBucketKey", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// ====================================
// Cluster Operations
// ====================================
// GetClusterHealth returns the health status of the cluster
func (s *GarageAdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterHealth", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.ClusterHealth
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// GetClusterStatus returns the current status of the cluster
func (s *GarageAdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatus", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.ClusterStatus
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// GetClusterStatistics returns global cluster statistics
func (s *GarageAdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatistics", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.ClusterStatistics
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// ====================================
// Node Operations
// ====================================
// GetNodeInfo returns information about a specific node
func (s *GarageAdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
path := fmt.Sprintf("/v2/GetNodeInfo?node=%s", nodeID)
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.MultiNodeResponse
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// GetNodeStatistics returns statistics for a specific node
func (s *GarageAdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
path := fmt.Sprintf("/v2/GetNodeStatistics?node=%s", nodeID)
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.MultiNodeResponse
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// ====================================
// Monitoring Operations
// ====================================
// HealthCheck checks if the Admin API is reachable
func (s *GarageAdminService) HealthCheck(ctx context.Context) error {
resp, err := s.doRequest(ctx, http.MethodGet, "/health", nil)
if err != nil {
return fmt.Errorf("health check failed: %w", err)
}
if err := decodeResponse(resp, nil); err != nil {
return fmt.Errorf("health check returned error: %w", err)
}
return nil
}
// GetMetrics returns Prometheus metrics from the Admin API
func (s *GarageAdminService) GetMetrics(ctx context.Context) (string, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/metrics", nil)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.RawBody.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(resp.RawBody)
return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
bodyBytes, err := io.ReadAll(resp.RawBody)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
return string(bodyBytes), nil
}
+266 -35
View File
@@ -8,6 +8,8 @@ import (
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/pkg/utils"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
@@ -16,13 +18,14 @@ import (
// S3Service handles all S3 operations with Garage
type S3Service struct {
client *s3.Client
config *config.GarageConfig
client *s3.Client
config *config.GarageConfig
adminService *GarageAdminService
}
// NewS3Service creates a new S3 service instance
func NewS3Service(cfg *config.GarageConfig) *S3Service {
// Create AWS credentials from Garage config
func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S3Service {
// Create AWS credentials from Garage config (default/fallback credentials)
creds := credentials.NewStaticCredentialsProvider(
cfg.AccessKey,
cfg.SecretKey,
@@ -42,11 +45,89 @@ func NewS3Service(cfg *config.GarageConfig) *S3Service {
})
return &S3Service{
client: client,
config: cfg,
client: client,
config: cfg,
adminService: adminService,
}
}
// getBucketCredentials retrieves credentials for a specific bucket
// It checks the cache first, then queries the Garage Admin API
func (s *S3Service) getBucketCredentials(ctx context.Context, bucketName string) (aws.CredentialsProvider, error) {
cacheKey := fmt.Sprintf("key:%s", bucketName)
cacheData := utils.GlobalCache.Get(cacheKey)
if cacheData != nil {
return cacheData.(aws.CredentialsProvider), nil
}
// Get bucket info from Garage Admin API
bucketInfo, err := s.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("failed to get bucket info: %w", err)
}
// Find a key with read and write permissions
var accessKeyID, secretAccessKey string
for _, keyInfo := range bucketInfo.Keys {
if !keyInfo.Permissions.Read || !keyInfo.Permissions.Write {
continue
}
// Get key details with secret
keyDetails, err := s.adminService.GetKeyInfo(ctx, keyInfo.AccessKeyID, true)
if err != nil {
return nil, fmt.Errorf("failed to get key info: %w", err)
}
if keyDetails.SecretAccessKey != nil {
accessKeyID = keyDetails.AccessKeyID
secretAccessKey = *keyDetails.SecretAccessKey
break
}
}
if accessKeyID == "" || secretAccessKey == "" {
return nil, fmt.Errorf("no valid credentials found for bucket %s", bucketName)
}
// Create credentials provider
credential := credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, "")
// Cache credentials for 1 hour
utils.GlobalCache.Set(cacheKey, credential, time.Hour)
return credential, nil
}
// getS3Client creates an S3 client for a specific bucket with dynamic credentials
func (s *S3Service) getS3Client(ctx context.Context, bucketName string) (*s3.Client, error) {
creds, err := s.getBucketCredentials(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("cannot get credentials for bucket %s: %w", bucketName, err)
}
// AWS config
awsConfig := aws.Config{
Credentials: creds,
Region: s.config.Region,
}
// Build S3 client with BaseEndpoint for Garage
client := s3.NewFromConfig(awsConfig, func(o *s3.Options) {
o.BaseEndpoint = aws.String(s.config.Endpoint)
o.UsePathStyle = s.config.ForcePathStyle
o.EndpointResolver = s3.EndpointResolverFunc(func(region string, opts s3.EndpointResolverOptions) (aws.Endpoint, error) {
return aws.Endpoint{
URL: s.config.Endpoint,
SigningRegion: s.config.Region,
}, nil
})
})
return client, nil
}
// ListBuckets retrieves all buckets from Garage
func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse, error) {
// Call S3 ListBuckets API
@@ -72,13 +153,17 @@ func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse
// CreateBucket creates a new bucket in Garage
func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
// Create bucket input
client, err := s.getS3Client(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
}
input := &s3.CreateBucketInput{
Bucket: aws.String(bucketName),
}
// Call S3 CreateBucket API
_, err := s.client.CreateBucket(ctx, input)
_, err = client.CreateBucket(ctx, input)
if err != nil {
return fmt.Errorf("failed to create bucket %s: %w", bucketName, err)
}
@@ -88,8 +173,13 @@ func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
// DeleteBucket deletes a bucket from Garage
func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
client, err := s.getS3Client(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
}
// Call S3 DeleteBucket API
_, err := s.client.DeleteBucket(ctx, &s3.DeleteBucketInput{
_, err = client.DeleteBucket(ctx, &s3.DeleteBucketInput{
Bucket: aws.String(bucketName),
})
if err != nil {
@@ -99,37 +189,36 @@ func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
return nil
}
// BucketExists checks if a bucket exists
func (s *S3Service) BucketExists(ctx context.Context, bucketName string) (bool, error) {
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{
Bucket: aws.String(bucketName),
})
// ListObjects lists objects in a bucket with optional prefix filter and pagination
func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error) {
// Get bucket-specific S3 client
client, err := s.getS3Client(ctx, bucketName)
if err != nil {
// Check if it's a "not found" error
return false, nil
return nil, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
}
return true, nil
}
// ListObjects lists objects in a bucket with optional prefix filter
func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int) (*models.ObjectListResponse, error) {
// Set default max keys if not specified
if maxKeys <= 0 {
maxKeys = 1000
maxKeys = 100
}
// Create list objects input
input := &s3.ListObjectsV2Input{
Bucket: aws.String(bucketName),
MaxKeys: aws.Int32(int32(maxKeys)),
Bucket: aws.String(bucketName),
Delimiter: aws.String("/"),
MaxKeys: aws.Int32(int32(maxKeys)),
}
if prefix != "" {
input.Prefix = aws.String(prefix)
}
if continuationToken != "" {
input.ContinuationToken = aws.String(continuationToken)
}
// Call S3 ListObjectsV2 API
result, err := s.client.ListObjectsV2(ctx, input)
result, err := client.ListObjectsV2(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err)
}
@@ -146,17 +235,30 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
})
}
// Extract common prefixes (folders/directories)
prefixes := make([]string, 0, len(result.CommonPrefixes))
for _, p := range result.CommonPrefixes {
prefixes = append(prefixes, aws.ToString(p.Prefix))
}
return &models.ObjectListResponse{
Bucket: bucketName,
Objects: objects,
Count: len(objects),
IsTruncated: aws.ToBool(result.IsTruncated),
NextMarker: aws.ToString(result.NextContinuationToken),
Bucket: bucketName,
Objects: objects,
Prefixes: prefixes,
Count: len(objects),
IsTruncated: aws.ToBool(result.IsTruncated),
NextContinuationToken: aws.ToString(result.NextContinuationToken),
}, nil
}
// UploadObject uploads an object to a bucket
func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) {
// Get bucket-specific S3 client
client, err := s.getS3Client(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
}
// Create put object input
input := &s3.PutObjectInput{
Bucket: aws.String(bucketName),
@@ -169,13 +271,13 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
}
// Call S3 PutObject API
result, err := s.client.PutObject(ctx, input)
result, err := client.PutObject(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to upload object %s to bucket %s: %w", key, bucketName, err)
}
// Get object metadata to return size
headResult, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
headResult, err := client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
@@ -233,7 +335,13 @@ func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) er
// ObjectExists checks if an object exists in a bucket
func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) {
_, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
// Get bucket-specific S3 client
client, err := s.getS3Client(ctx, bucketName)
if err != nil {
return false, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
}
_, err = client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
@@ -245,7 +353,13 @@ func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (b
// GetObjectMetadata retrieves metadata for an object without downloading it
func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) {
result, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
// Get bucket-specific S3 client
client, err := s.getS3Client(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
}
result, err := client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
@@ -268,6 +382,12 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
return nil
}
// Get bucket-specific S3 client
client, err := s.getS3Client(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
}
// Create delete objects for batch deletion
objects := make([]types.ObjectIdentifier, len(keys))
for i, key := range keys {
@@ -277,7 +397,7 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
}
// Call S3 DeleteObjects API (batch delete)
_, err := s.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
_, err = client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
Bucket: aws.String(bucketName),
Delete: &types.Delete{
Objects: objects,
@@ -294,8 +414,14 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
// GetPresignedURL generates a pre-signed URL for temporary access to an object
// This is useful for sharing files without exposing credentials
func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error) {
// Get bucket-specific S3 client
client, err := s.getS3Client(ctx, bucketName)
if err != nil {
return "", fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
}
// Create presign client
presignClient := s3.NewPresignClient(s.client)
presignClient := s3.NewPresignClient(client)
// Generate presigned GET request
presignResult, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
@@ -310,3 +436,108 @@ func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string,
return presignResult.URL, nil
}
// UploadResult represents the result of a single file upload
type UploadResult struct {
Key string
Success bool
Error error
ETag string
Size int64
ContentType string
}
// UploadMultipleObjects uploads multiple objects to a bucket
// It handles uploads in batches to respect any S3/Garage limits
// Returns results for each file, including both successes and failures
func (s *S3Service) UploadMultipleObjects(ctx context.Context, bucketName string, files []struct {
Key string
Body io.Reader
ContentType string
}) []UploadResult {
results := make([]UploadResult, len(files))
// Get bucket-specific S3 client once for all uploads
client, err := s.getS3Client(ctx, bucketName)
if err != nil {
// If we can't get the client, all uploads fail
for i := range files {
results[i] = UploadResult{
Key: files[i].Key,
Success: false,
Error: fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err),
}
}
return results
}
// Upload each file
for i, file := range files {
// Create put object input
input := &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(file.Key),
Body: file.Body,
}
if file.ContentType != "" {
input.ContentType = aws.String(file.ContentType)
}
// Attempt upload
result, err := client.PutObject(ctx, input)
if err != nil {
results[i] = UploadResult{
Key: file.Key,
Success: false,
Error: fmt.Errorf("failed to upload object %s: %w", file.Key, err),
ContentType: file.ContentType,
}
continue
}
// Get object metadata to return size
headResult, err := client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(file.Key),
})
var size int64
if err == nil {
size = aws.ToInt64(headResult.ContentLength)
}
results[i] = UploadResult{
Key: file.Key,
Success: true,
Error: nil,
ETag: aws.ToString(result.ETag),
Size: size,
ContentType: file.ContentType,
}
}
return results
}
// BucketStatistics holds statistical information about a bucket
type BucketStatistics struct {
ObjectCount int64
TotalSize int64
}
// GetBucketStatistics retrieves bucket statistics from Garage Admin API
// This is much more efficient than iterating through all objects
func (s *S3Service) GetBucketStatistics(ctx context.Context, bucketName string) (*BucketStatistics, error) {
// Get bucket info from Garage Admin API which includes object count and size
bucketInfo, err := s.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("failed to get bucket info for %s: %w", bucketName, err)
}
// Return statistics from Admin API
return &BucketStatistics{
ObjectCount: bucketInfo.Objects,
TotalSize: bucketInfo.Bytes,
}, nil
}