From 80553c64500df66a13f6d8c9b9835fdfce679c84 Mon Sep 17 00:00:00 2001
From: Noste <83548733+Noooste@users.noreply.github.com>
Date: Fri, 26 Dec 2025 12:21:49 +0100
Subject: [PATCH] feat: enhance authentication and upload features; add JWT
support and upload progress component
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
---
Makefile | 137 +++++
backend/internal/auth/auth.go | 2 +-
backend/internal/auth/jwt.go | 91 +++-
backend/internal/config/config.go | 31 +-
backend/internal/handlers/buckets.go | 23 +-
backend/internal/handlers/objects.go | 36 +-
backend/internal/middleware/auth.go | 3 +-
backend/internal/models/responses.go | 18 +-
backend/internal/routes/routes.go | 73 ++-
backend/internal/services/s3.go | 113 +++--
backend/main.go | 31 +-
config.yaml.example | 13 +
docker-compose.yml | 8 +
frontend/src/App.css | 42 --
frontend/src/App.tsx | 2 +
.../src/components/auth/BasicLoginForm.tsx | 24 +-
.../src/components/auth/OIDCLoginView.tsx | 8 +-
.../src/components/buckets/BucketListView.tsx | 3 +-
.../components/buckets/ObjectBrowserView.tsx | 11 +-
.../components/buckets/ObjectDetailsView.tsx | 271 ++++++++++
.../src/components/buckets/ObjectsTable.tsx | 32 +-
.../src/components/buckets/UploadProgress.tsx | 115 +++++
.../components/charts/BucketUsageChart.tsx | 2 +-
.../components/charts/ClusterHealthChart.tsx | 66 ---
.../components/charts/RequestMetricsChart.tsx | 60 ---
frontend/src/hooks/index.ts | 3 +-
frontend/src/hooks/useBucketObjects.ts | 116 ++++-
frontend/src/hooks/useBuckets.ts | 77 ---
frontend/src/lib/api.ts | 34 +-
frontend/src/lib/file-utils.ts | 15 +
frontend/src/lib/utils.ts | 12 -
frontend/src/pages/AccessControl.tsx | 324 +++++++++---
frontend/src/pages/Buckets.tsx | 41 +-
frontend/src/pages/Cluster.tsx | 2 +-
frontend/src/pages/Dashboard.tsx | 2 +-
frontend/src/pages/Login.tsx | 4 -
frontend/src/types/index.ts | 3 +-
frontend/tailwind.config.js | 9 +
helm/garage-ui/templates/configmap.yaml | 2 +
helm/garage-ui/templates/deployment.yaml | 12 +
helm/garage-ui/templates/secret.yaml | 12 +
helm/garage-ui/values.schema.json | 45 ++
helm/garage-ui/values.yaml | 479 ++----------------
43 files changed, 1464 insertions(+), 943 deletions(-)
create mode 100644 Makefile
delete mode 100644 frontend/src/App.css
create mode 100644 frontend/src/components/buckets/ObjectDetailsView.tsx
create mode 100644 frontend/src/components/buckets/UploadProgress.tsx
delete mode 100644 frontend/src/components/charts/ClusterHealthChart.tsx
delete mode 100644 frontend/src/components/charts/RequestMetricsChart.tsx
delete mode 100644 frontend/src/hooks/useBuckets.ts
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..283c7d1
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,137 @@
+.PHONY: help build up down restart logs clean ps health dev-build dev-up dev-down dev-restart dev-logs prod-up prod-down prod-restart prod-logs push stop
+
+# Variables
+DOCKER_COMPOSE = docker-compose
+DOCKER_COMPOSE_DEV = docker-compose -f docker-compose.dev.yml
+DOCKER_COMPOSE_PROD = docker-compose -f docker-compose.yml
+IMAGE_NAME = noooste/garage-ui
+IMAGE_TAG = latest
+
+## help: Show this help message
+help:
+ @echo 'Usage:'
+ @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /'
+
+## build: Build the Docker image locally
+build:
+ docker build -t $(IMAGE_NAME):$(IMAGE_TAG) .
+
+## build-no-cache: Build the Docker image without cache
+build-no-cache:
+ docker build --no-cache -t $(IMAGE_NAME):$(IMAGE_TAG) .
+
+## push: Push the Docker image to registry
+push: build
+ docker push $(IMAGE_NAME):$(IMAGE_TAG)
+
+## dev-build: Build and start development environment
+dev-build:
+ $(DOCKER_COMPOSE_DEV) build
+
+## dev-up: Start development environment
+dev-up:
+ $(DOCKER_COMPOSE_DEV) up -d
+
+## dev-down: Stop development environment
+dev-down:
+ $(DOCKER_COMPOSE_DEV) down
+
+## dev-restart: Restart development environment
+dev-restart: dev-down dev-up
+
+## dev-logs: Show logs for development environment
+dev-logs:
+ $(DOCKER_COMPOSE_DEV) logs -f
+
+## dev-logs-ui: Show logs for garage-ui in development
+dev-logs-ui:
+ $(DOCKER_COMPOSE_DEV) logs -f garage-ui
+
+## dev-logs-garage: Show logs for garage in development
+dev-logs-garage:
+ $(DOCKER_COMPOSE_DEV) logs -f garage
+
+## dev-shell: Open shell in garage-ui container (development)
+dev-shell:
+ $(DOCKER_COMPOSE_DEV) exec garage-ui sh
+
+## prod-up: Start production environment
+prod-up:
+ $(DOCKER_COMPOSE_PROD) up -d
+
+## prod-down: Stop production environment
+prod-down:
+ $(DOCKER_COMPOSE_PROD) down
+
+## prod-restart: Restart production environment
+prod-restart: prod-down prod-up
+
+## prod-logs: Show logs for production environment
+prod-logs:
+ $(DOCKER_COMPOSE_PROD) logs -f
+
+## prod-logs-ui: Show logs for garage-ui in production
+prod-logs-ui:
+ $(DOCKER_COMPOSE_PROD) logs -f garage-ui
+
+## prod-logs-garage: Show logs for garage in production
+prod-logs-garage:
+ $(DOCKER_COMPOSE_PROD) logs -f garage
+
+## prod-pull: Pull latest images for production
+prod-pull:
+ $(DOCKER_COMPOSE_PROD) pull
+
+## up: Start default (production) environment
+up: prod-up
+
+## down: Stop all environments
+down:
+ $(DOCKER_COMPOSE_PROD) down
+ $(DOCKER_COMPOSE_DEV) down
+
+## stop: Stop all containers
+stop:
+ $(DOCKER_COMPOSE_PROD) stop
+ $(DOCKER_COMPOSE_DEV) stop
+
+## restart: Restart default (production) environment
+restart: prod-restart
+
+## logs: Show logs for default (production) environment
+logs: prod-logs
+
+## ps: Show running containers
+ps:
+ docker ps -a --filter "name=garage"
+
+## health: Check health status of containers
+health:
+ @echo "=== Container Status ==="
+ @docker ps --filter "name=garage" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
+
+## clean: Remove all containers, volumes, and images
+clean:
+ $(DOCKER_COMPOSE_PROD) down -v --remove-orphans
+ $(DOCKER_COMPOSE_DEV) down -v --remove-orphans
+ docker system prune -f
+
+## clean-volumes: Remove all volumes (WARNING: deletes data)
+clean-volumes:
+ $(DOCKER_COMPOSE_PROD) down -v
+ $(DOCKER_COMPOSE_DEV) down -v
+ rm -rf ./meta ./data
+
+## rebuild: Clean rebuild of development environment
+rebuild: dev-down dev-build dev-up
+
+## install: Initial setup - create necessary directories
+install:
+ @echo "Creating necessary directories..."
+ @mkdir -p meta data
+ @echo "Setup complete. Edit garage.toml and config.yaml before starting."
+
+## update: Pull latest changes and rebuild
+update: prod-pull prod-restart
+
+.DEFAULT_GOAL := help
diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go
index 1a3fce6..5642417 100644
--- a/backend/internal/auth/auth.go
+++ b/backend/internal/auth/auth.go
@@ -33,7 +33,7 @@ type UserInfo struct {
// NewAuthService creates a new authentication service
func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig) (*AuthService, error) {
- jwtService, err := NewJWTService()
+ jwtService, err := NewJWTServiceWithKey(authCfg.JWTPrivKey)
if err != nil {
return nil, fmt.Errorf("failed to initialize JWT service: %w", err)
}
diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go
index 63c84b5..79cd982 100644
--- a/backend/internal/auth/jwt.go
+++ b/backend/internal/auth/jwt.go
@@ -1,9 +1,8 @@
package auth
import (
+ "crypto/ed25519"
"crypto/rand"
- "crypto/rsa"
- "crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
@@ -14,9 +13,10 @@ import (
)
type JWTService struct {
- privateKey *rsa.PrivateKey
- publicKey *rsa.PublicKey
+ privateKey ed25519.PrivateKey
+ publicKey ed25519.PublicKey
stateStore *StateStore
+ mu sync.RWMutex
}
type StateStore struct {
@@ -38,20 +38,53 @@ type SessionClaims struct {
}
func NewJWTService() (*JWTService, error) {
- privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
- if err != nil {
- return nil, fmt.Errorf("failed to generate RSA key: %w", err)
+ return NewJWTServiceWithKey("")
+}
+
+func NewJWTServiceWithKey(privateKeyPEM string) (*JWTService, error) {
+ var privateKey ed25519.PrivateKey
+ var publicKey ed25519.PublicKey
+ var err error
+
+ if privateKeyPEM != "" {
+ // Parse the provided PEM-encoded private key
+ privateKey, err = parseEd25519PrivateKeyFromPEM(privateKeyPEM)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse Ed25519 private key: %w", err)
+ }
+ publicKey = privateKey.Public().(ed25519.PublicKey)
+ } else {
+ // Generate a new Ed25519 key pair if no key is provided
+ publicKey, privateKey, err = ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate Ed25519 key: %w", err)
+ }
}
return &JWTService{
privateKey: privateKey,
- publicKey: &privateKey.PublicKey,
+ publicKey: publicKey,
stateStore: &StateStore{
states: make(map[string]StateData),
},
}, nil
}
+func parseEd25519PrivateKeyFromPEM(privateKeyPEM string) (ed25519.PrivateKey, error) {
+ block, _ := pem.Decode([]byte(privateKeyPEM))
+ if block == nil {
+ return nil, fmt.Errorf("failed to decode PEM block")
+ }
+
+ // Check if it's raw Ed25519 private key bytes (64 bytes)
+ if len(block.Bytes) == ed25519.PrivateKeySize {
+ return ed25519.PrivateKey(block.Bytes), nil
+ }
+
+ return nil, fmt.Errorf("invalid Ed25519 private key format: expected %d bytes, got %d",
+ ed25519.PrivateKeySize, len(block.Bytes))
+}
+
func (j *JWTService) GenerateStateToken() (string, error) {
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
@@ -105,6 +138,13 @@ func (j *JWTService) cleanupExpiredStates() {
}
func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (string, error) {
+ j.mu.RLock()
+ defer j.mu.RUnlock()
+
+ if j.privateKey == nil {
+ return "", fmt.Errorf("private key not initialized")
+ }
+
now := time.Now()
expiresAt := now.Add(time.Duration(sessionMaxAge) * time.Second)
@@ -120,7 +160,7 @@ func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (strin
},
}
- token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
+ token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
tokenString, err := token.SignedString(j.privateKey)
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
@@ -130,8 +170,15 @@ func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (strin
}
func (j *JWTService) ValidateToken(tokenString string) (*SessionClaims, error) {
+ j.mu.RLock()
+ defer j.mu.RUnlock()
+
+ if j.publicKey == nil {
+ return nil, fmt.Errorf("public key not initialized")
+ }
+
token, err := jwt.ParseWithClaims(tokenString, &SessionClaims{}, func(token *jwt.Token) (interface{}, error) {
- if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
+ if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return j.publicKey, nil
@@ -149,15 +196,29 @@ func (j *JWTService) ValidateToken(tokenString string) (*SessionClaims, error) {
}
func (j *JWTService) GetPublicKeyPEM() (string, error) {
- pubKeyBytes, err := x509.MarshalPKIXPublicKey(j.publicKey)
- if err != nil {
- return "", fmt.Errorf("failed to marshal public key: %w", err)
+ j.mu.RLock()
+ defer j.mu.RUnlock()
+
+ if j.publicKey == nil {
+ return "", fmt.Errorf("public key not initialized")
}
pubKeyPEM := pem.EncodeToMemory(&pem.Block{
- Type: "RSA PUBLIC KEY",
- Bytes: pubKeyBytes,
+ Type: "PUBLIC KEY",
+ Bytes: []byte(j.publicKey),
})
return string(pubKeyPEM), nil
}
+
+// GetPublicKeyBase64 returns the base64url-encoded public key for JWKS
+func (j *JWTService) GetPublicKeyBase64() (string, error) {
+ j.mu.RLock()
+ defer j.mu.RUnlock()
+
+ if j.publicKey == nil {
+ return "", fmt.Errorf("public key not initialized")
+ }
+
+ return base64.RawURLEncoding.EncodeToString(j.publicKey), nil
+}
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index ab7deaf..0ee4df1 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -19,13 +19,17 @@ type Config struct {
// ServerConfig contains server-related configuration
type ServerConfig struct {
- Host string `mapstructure:"host"`
- Port int `mapstructure:"port"`
- Environment string `mapstructure:"environment"`
- FrontendPath string `mapstructure:"frontend_path"` // Path to frontend dist directory
- Domain string `mapstructure:"domain"` // Domain name (e.g., garage-ui.example.com)
- Protocol string `mapstructure:"protocol"` // Protocol for internal communication (http/https)
- RootURL string `mapstructure:"root_url"` // Full external URL for redirects (e.g., https://garage-ui.example.com)
+ Host string `mapstructure:"host"`
+ Port int `mapstructure:"port"`
+ Environment string `mapstructure:"environment"`
+ FrontendPath string `mapstructure:"frontend_path"` // Path to frontend dist directory
+ Domain string `mapstructure:"domain"` // Domain name (e.g., garage-ui.example.com)
+ Protocol string `mapstructure:"protocol"` // Protocol for internal communication (http/https)
+ RootURL string `mapstructure:"root_url"` // Full external URL for redirects (e.g., https://garage-ui.example.com)
+ MaxBodySize int64 `mapstructure:"max_body_size"` // Maximum request body size in bytes (default: 300MB)
+ MaxHeaderSize int `mapstructure:"max_header_size"` // Maximum request header size in bytes (default: 1MB)
+ ReadBufferSize int `mapstructure:"read_buffer_size"` // Read buffer size in bytes (default: 4KB)
+ WriteBufferSize int `mapstructure:"write_buffer_size"` // Write buffer size in bytes (default: 4KB)
}
// GarageConfig contains Garage S3 connection settings
@@ -40,8 +44,9 @@ type GarageConfig struct {
// AuthConfig contains authentication configuration
type AuthConfig struct {
- Admin AdminAuthConfig `mapstructure:"admin"`
- OIDC OIDCConfig `mapstructure:"oidc"`
+ Admin AdminAuthConfig `mapstructure:"admin"`
+ OIDC OIDCConfig `mapstructure:"oidc"`
+ JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes)
}
// AdminAuthConfig contains admin authentication settings
@@ -110,8 +115,7 @@ func Load(configPath string) (*Config, error) {
viper.SetEnvPrefix("GARAGE_UI")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
- // Bind environment variables to config keys
- // This ensures env vars override config file values
+ // Env vars override config file values
bindEnvVars()
// Read the config file (optional - will use defaults and env vars if not found)
@@ -145,6 +149,10 @@ func bindEnvVars() {
viper.BindEnv("server.domain", "GARAGE_UI_SERVER_DOMAIN")
viper.BindEnv("server.protocol", "GARAGE_UI_SERVER_PROTOCOL")
viper.BindEnv("server.root_url", "GARAGE_UI_SERVER_ROOT_URL")
+ viper.BindEnv("server.max_body_size", "GARAGE_UI_SERVER_MAX_BODY_SIZE")
+ viper.BindEnv("server.max_header_size", "GARAGE_UI_SERVER_MAX_HEADER_SIZE")
+ viper.BindEnv("server.read_buffer_size", "GARAGE_UI_SERVER_READ_BUFFER_SIZE")
+ viper.BindEnv("server.write_buffer_size", "GARAGE_UI_SERVER_WRITE_BUFFER_SIZE")
// Garage config
viper.BindEnv("garage.endpoint", "GARAGE_UI_GARAGE_ENDPOINT")
@@ -158,6 +166,7 @@ func bindEnvVars() {
viper.BindEnv("auth.admin.enabled", "GARAGE_UI_AUTH_ADMIN_ENABLED")
viper.BindEnv("auth.admin.username", "GARAGE_UI_AUTH_ADMIN_USERNAME")
viper.BindEnv("auth.admin.password", "GARAGE_UI_AUTH_ADMIN_PASSWORD")
+ viper.BindEnv("auth.jwt_private_key", "GARAGE_UI_AUTH_JWT_PRIVATE_KEY")
// OIDC config
viper.BindEnv("auth.oidc.enabled", "GARAGE_UI_AUTH_OIDC_ENABLED")
diff --git a/backend/internal/handlers/buckets.go b/backend/internal/handlers/buckets.go
index f44288b..b9f42e5 100644
--- a/backend/internal/handlers/buckets.go
+++ b/backend/internal/handlers/buckets.go
@@ -54,19 +54,24 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
continue
}
+ // Get detailed bucket info from Admin API to retrieve object count and size
+ detailedInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
+ if err != nil {
+ // If we can't get detailed info, return basic info without stats
+ buckets = append(buckets, models.BucketInfo{
+ Name: bucketName,
+ CreationDate: adminBucket.Created,
+ Region: "",
+ })
+ 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
+ ObjectCount: &detailedInfo.Objects,
+ Size: &detailedInfo.Bytes,
}
buckets = append(buckets, bucketInfo)
diff --git a/backend/internal/handlers/objects.go b/backend/internal/handlers/objects.go
index b2414da..ccf8e8b 100644
--- a/backend/internal/handlers/objects.go
+++ b/backend/internal/handlers/objects.go
@@ -154,9 +154,14 @@ func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
ctx := c.Context()
- // Get bucket name and object key from URL parameters
+ // Get bucket name from URL parameters
bucketName := c.Params("bucket")
- key := c.Params("key")
+
+ // Get object key from locals (set by route handler) or from params
+ key, ok := c.Locals("objectKey").(string)
+ if !ok || key == "" {
+ key = c.Params("key")
+ }
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
@@ -205,9 +210,14 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
ctx := c.Context()
- // Get bucket name and object key from URL parameters
+ // Get bucket name from URL parameters
bucketName := c.Params("bucket")
- key := c.Params("key")
+
+ // Get object key from locals (set by route handler) or from params
+ key, ok := c.Locals("objectKey").(string)
+ if !ok || key == "" {
+ key = c.Params("key")
+ }
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
@@ -262,9 +272,14 @@ func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
ctx := c.Context()
- // Get bucket name and object key from URL parameters
+ // Get bucket name from URL parameters
bucketName := c.Params("bucket")
- key := c.Params("key")
+
+ // Get object key from locals (set by route handler) or from params
+ key, ok := c.Locals("objectKey").(string)
+ if !ok || key == "" {
+ key = c.Params("key")
+ }
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
@@ -301,9 +316,14 @@ func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
ctx := c.Context()
- // Get bucket name and object key from URL parameters
+ // Get bucket name from URL parameters
bucketName := c.Params("bucket")
- key := c.Params("key")
+
+ // Get object key from locals (set by route handler) or from params
+ key, ok := c.Locals("objectKey").(string)
+ if !ok || key == "" {
+ key = c.Params("key")
+ }
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go
index cde9aec..3256074 100644
--- a/backend/internal/middleware/auth.go
+++ b/backend/internal/middleware/auth.go
@@ -8,8 +8,7 @@ import (
"github.com/gofiber/fiber/v3"
)
-// AuthMiddleware returns a Fiber middleware for authentication
-// It handles multiple auth methods: admin and OIDC
+// AuthMiddleware supports admin and OIDC authentication
func AuthMiddleware(cfg *config.AuthConfig, authService *auth.AuthService) fiber.Handler {
return func(c fiber.Ctx) error {
// If no auth is enabled, allow all requests
diff --git a/backend/internal/models/responses.go b/backend/internal/models/responses.go
index bf9f513..8ded350 100644
--- a/backend/internal/models/responses.go
+++ b/backend/internal/models/responses.go
@@ -41,8 +41,8 @@ type HealthResponse struct {
// BucketInfo represents information about a bucket
type BucketInfo struct {
Name string `json:"name"`
- CreationDate time.Time `json:"creation_date"`
- ObjectCount *int64 `json:"object_count,omitempty"`
+ CreationDate time.Time `json:"creationDate"`
+ ObjectCount *int64 `json:"objectCount,omitempty"`
Size *int64 `json:"size,omitempty"`
Region string `json:"region,omitempty"`
}
@@ -55,12 +55,13 @@ type BucketListResponse struct {
// ObjectInfo represents information about an object
type ObjectInfo struct {
- Key string `json:"key"`
- Size int64 `json:"size"`
- LastModified time.Time `json:"last_modified"`
- ETag string `json:"etag"`
- ContentType string `json:"content_type,omitempty"`
- StorageClass string `json:"storage_class,omitempty"`
+ Key string `json:"key"`
+ Size int64 `json:"size"`
+ LastModified time.Time `json:"last_modified"`
+ ETag string `json:"etag"`
+ ContentType string `json:"content_type,omitempty"`
+ StorageClass string `json:"storage_class,omitempty"`
+ Metadata map[string]string `json:"metadata,omitempty"`
}
// ObjectListResponse represents a list of objects in a bucket
@@ -120,7 +121,6 @@ type UserInfo struct {
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"`
diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go
index 22dbd59..946858b 100644
--- a/backend/internal/routes/routes.go
+++ b/backend/internal/routes/routes.go
@@ -6,6 +6,7 @@ import (
"Noooste/garage-ui/internal/handlers"
"Noooste/garage-ui/internal/middleware"
"fmt"
+ "net/url"
"os"
"path/filepath"
"strings"
@@ -68,12 +69,74 @@ func SetupRoutes(
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.Delete("/:key", objectHandler.DeleteObject) // Delete object
- objects.Head("/:key", objectHandler.GetObjectMetadata) // Get object metadata
- objects.Post("/:key/presign", objectHandler.GetPresignedURL) // Generate pre-signed URL
}
+ // Object-specific routes with wildcard key parameter (supports paths with slashes)
+ // These need to be registered on the main app with auth middleware applied
+ objectWildcardHandler := func(c fiber.Ctx) error {
+ // Get the full path from wildcard parameter
+ // Note: Fiber v3 does NOT automatically decode params, we need to do it manually
+ path := c.Params("*")
+
+ // Decode the full path using QueryUnescape (handles %20, %2F, etc.)
+ decodedPath, err := url.QueryUnescape(path)
+ if err != nil {
+ // If decoding fails, use the original path
+ decodedPath = path
+ }
+
+ // Check if it's a metadata request
+ if strings.HasSuffix(decodedPath, "/metadata") {
+ // Remove /metadata suffix to get the actual key
+ key := strings.TrimSuffix(decodedPath, "/metadata")
+ c.Locals("objectKey", key)
+ return objectHandler.GetObjectMetadata(c)
+ }
+ // Check if it's a presign request
+ if strings.HasSuffix(decodedPath, "/presign") {
+ // Remove /presign suffix to get the actual key
+ key := strings.TrimSuffix(decodedPath, "/presign")
+ c.Locals("objectKey", key)
+ return objectHandler.GetPresignedURL(c)
+ }
+ // Otherwise, it's a regular object download
+ c.Locals("objectKey", decodedPath)
+ return objectHandler.GetObject(c)
+ }
+
+ objectDeleteHandler := func(c fiber.Ctx) error {
+ path := c.Params("*")
+
+ // Decode the full path using QueryUnescape
+ key, err := url.QueryUnescape(path)
+ if err != nil {
+ // If decoding fails, use the original path
+ key = path
+ }
+
+ c.Locals("objectKey", key)
+ return objectHandler.DeleteObject(c)
+ }
+
+ objectHeadHandler := func(c fiber.Ctx) error {
+ path := c.Params("*")
+
+ // Decode the full path using QueryUnescape
+ key, err := url.QueryUnescape(path)
+ if err != nil {
+ // If decoding fails, use the original path
+ key = path
+ }
+
+ c.Locals("objectKey", key)
+ return objectHandler.GetObjectMetadata(c)
+ }
+
+ // Register with auth middleware
+ app.Get("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectWildcardHandler)
+ app.Delete("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectDeleteHandler)
+ app.Head("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectHeadHandler)
+
// User/Key management routes
users := api.Group("/users")
{
@@ -197,7 +260,7 @@ func SetupRoutes(
})
// Redirect to frontend with success indicator
- return c.Redirect().To("/?login=success")
+ return c.Redirect().To("/login?login=success")
})
// Logout endpoint
diff --git a/backend/internal/services/s3.go b/backend/internal/services/s3.go
index ced1ebc..269243a 100644
--- a/backend/internal/services/s3.go
+++ b/backend/internal/services/s3.go
@@ -52,8 +52,6 @@ func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S
}
}
-// 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) (*credentials.Credentials, error) {
cacheKey := fmt.Sprintf("key:%s", bucketName)
cacheData := utils.GlobalCache.Get(cacheKey)
@@ -204,64 +202,72 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
maxKeys = 1000
}
- // Use ListObjectsV2 for proper pagination support
- opts := minio.ListObjectsOptions{
- Prefix: prefix,
- Recursive: false,
- MaxKeys: maxKeys,
- StartAfter: continuationToken,
- UseV1: false,
+ // Create Core client for low-level API access
+ core := &minio.Core{Client: client}
+
+ // Use Core.ListObjectsV2 for proper pagination with continuation tokens
+ result, err := core.ListObjectsV2(
+ bucketName,
+ prefix, // objectPrefix
+ "", // startAfter (empty when using continuationToken)
+ continuationToken, // continuationToken (proper S3 token)
+ "/", // delimiter (for folder listing)
+ maxKeys, // maxkeys
+ )
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err)
}
- objects := make([]models.ObjectInfo, 0)
- prefixesMap := make(map[string]bool)
+ // Process objects from result.Contents
+ // Note: ListObjectsV2 doesn't return ContentType, so we need to fetch it separately
+ objects := make([]models.ObjectInfo, len(result.Contents))
- var lastKey string
- isTruncated := false
- itemCount := 0
+ // Use goroutines to fetch ContentType concurrently for better performance
+ type statResult struct {
+ index int
+ contentType string
+ err error
+ }
- // List objects using the channel-based API
- for object := range client.ListObjects(ctx, bucketName, opts) {
- if object.Err != nil {
- return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, object.Err)
- }
+ statChan := make(chan statResult, len(result.Contents))
- // Check if this is a prefix (directory)
- if len(object.Key) > 0 && object.Key[len(object.Key)-1:] == "/" && object.Size == 0 {
- prefixesMap[object.Key] = true
- continue
- }
+ for i, obj := range result.Contents {
+ go func(idx int, objKey string) {
+ // Fetch object metadata to get ContentType
+ stat, err := client.StatObject(ctx, bucketName, objKey, minio.StatObjectOptions{})
+ if err != nil {
+ // If StatObject fails, we still include the object but without ContentType
+ statChan <- statResult{index: idx, contentType: "", err: err}
+ return
+ }
+ statChan <- statResult{index: idx, contentType: stat.ContentType, err: nil}
+ }(i, obj.Key)
- // Track the last key for pagination
- lastKey = object.Key
-
- // Add to objects list
- objects = append(objects, models.ObjectInfo{
- Key: object.Key,
- Size: object.Size,
- LastModified: object.LastModified,
- ETag: object.ETag,
- ContentType: object.ContentType,
- StorageClass: object.StorageClass,
- })
-
- itemCount++
- if itemCount >= maxKeys {
- isTruncated = true
- break
+ // Initialize the object with basic info from ListObjectsV2
+ objects[i] = models.ObjectInfo{
+ Key: obj.Key,
+ Size: obj.Size,
+ LastModified: obj.LastModified,
+ ETag: obj.ETag,
+ StorageClass: obj.StorageClass,
}
}
- // Convert prefixes map to slice
- prefixList := make([]string, 0, len(prefixesMap))
- for p := range prefixesMap {
- prefixList = append(prefixList, p)
+ // Collect results from goroutines
+ for range result.Contents {
+ res := <-statChan
+ if res.err == nil {
+ objects[res.index].ContentType = res.contentType
+ }
+ // If there was an error, ContentType remains empty, which is acceptable
}
+ close(statChan)
- // Prepare next continuation token
- var nextToken string
- if isTruncated && lastKey != "" {
- nextToken = lastKey
+ // Process folders from result.CommonPrefixes
+ prefixList := make([]string, 0, len(result.CommonPrefixes))
+ for _, p := range result.CommonPrefixes {
+ prefixList = append(prefixList, p.Prefix)
}
return &models.ObjectListResponse{
@@ -269,8 +275,8 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
Objects: objects,
Prefixes: prefixList,
Count: len(objects),
- IsTruncated: isTruncated,
- NextContinuationToken: nextToken,
+ IsTruncated: result.IsTruncated,
+ NextContinuationToken: result.NextContinuationToken,
}, nil
}
@@ -412,6 +418,8 @@ func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key strin
LastModified: stat.LastModified,
ETag: stat.ETag,
ContentType: stat.ContentType,
+ StorageClass: stat.StorageClass,
+ Metadata: stat.UserMetadata,
}, nil
}
@@ -488,9 +496,6 @@ type UploadResult struct {
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
diff --git a/backend/main.go b/backend/main.go
index 49c8029..01a1c8f 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -103,12 +103,35 @@ func main() {
clusterHandler := handlers.NewClusterHandler(adminService)
monitoringHandler := handlers.NewMonitoringHandler(adminService, s3Service)
+ // Set default values for buffer sizes if not configured
+ maxBodySize := cfg.Server.MaxBodySize
+ if maxBodySize == 0 {
+ maxBodySize = 300 * 1024 * 1024 // 300MB default
+ }
+ maxHeaderSize := cfg.Server.MaxHeaderSize
+ if maxHeaderSize == 0 {
+ maxHeaderSize = 1 * 1024 * 1024 // 1MB default
+ }
+ readBufferSize := cfg.Server.ReadBufferSize
+ if readBufferSize == 0 {
+ readBufferSize = 4096 // 4KB default
+ }
+ writeBufferSize := cfg.Server.WriteBufferSize
+ if writeBufferSize == 0 {
+ writeBufferSize = 4096 // 4KB default
+ }
+
+ log.Printf("Server limits - Max body: %d bytes (%.2fMB), Max header: %d bytes (%.2fKB)",
+ maxBodySize, float64(maxBodySize)/(1024*1024),
+ maxHeaderSize, float64(maxHeaderSize)/1024)
+
// Create Fiber app with configuration
app := fiber.New(fiber.Config{
- AppName: "Garage UI Backend v" + version,
- //DisableStartupMessage: false,
- //EnablePrintRoutes: cfg.IsDevelopment(),
- ErrorHandler: customErrorHandler,
+ AppName: "Garage UI Backend v" + version,
+ BodyLimit: int(maxBodySize),
+ ReadBufferSize: readBufferSize,
+ WriteBufferSize: writeBufferSize,
+ ErrorHandler: customErrorHandler,
})
// Apply global middleware
diff --git a/config.yaml.example b/config.yaml.example
index 891a987..b8bb940 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -9,6 +9,12 @@ server:
protocol: "http" # Protocol for internal communication (http/https)
root_url: "http://localhost:8080" # Full external URL for OAuth2 redirects (adjust for production)
+ # Request size limits (in bytes)
+ max_body_size: 314572800 # 300MB - Maximum request body size (increase for large file uploads)
+ max_header_size: 1048576 # 1MB - Maximum request header size
+ read_buffer_size: 4096 # 4KB - Read buffer size
+ write_buffer_size: 4096 # 4KB - Write buffer size
+
# Garage S3 Configuration
garage:
endpoint: "http://localhost:3900" # Garage S3 API endpoint
@@ -21,6 +27,13 @@ garage:
# Authentication Configuration
# You can enable one or both authentication methods
auth:
+ # JWT Configuration
+ # Ed25519 private key in PEM format for JWT token signing
+ # If not specified, a new key will be generated on each startup (tokens won't persist across restarts)
+ # Generate with: openssl genpkey -algorithm ED25519 -out jwt-key.pem
+ # The key is a 64-byte Ed25519 private key
+ jwt_private_key: "" # Leave empty to auto-generate, or provide PEM-encoded Ed25519 private key
+
# Admin Authentication (username/password)
admin:
enabled: false # Set to true to enable admin login
diff --git a/docker-compose.yml b/docker-compose.yml
index f802e94..4079fbc 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -34,6 +34,14 @@ services:
GARAGE_UI_SERVER_PORT: "8080"
GARAGE_UI_SERVER_ENVIRONMENT: "production"
+ # JWT Configuration
+ # IMPORTANT: Replace this with your own Ed25519 private key in production!
+ # Generate with: openssl genpkey -algorithm ED25519
+ GARAGE_UI_AUTH_JWT_PRIVATE_KEY: |
+ -----BEGIN PRIVATE KEY-----
+ MC4CAQAwBQYDK2VwBCIEIH0ZHqIV7MEyVsxNYc5TA/a0qaBxgq3ntlFS3w1F03MS
+ -----END PRIVATE KEY-----
+
# Logging
GARAGE_UI_LOGGING_LEVEL: "info"
GARAGE_UI_LOGGING_FORMAT: "json"
diff --git a/frontend/src/App.css b/frontend/src/App.css
deleted file mode 100644
index b9d355d..0000000
--- a/frontend/src/App.css
+++ /dev/null
@@ -1,42 +0,0 @@
-#root {
- max-width: 1280px;
- margin: 0 auto;
- padding: 2rem;
- text-align: center;
-}
-
-.logo {
- height: 6em;
- padding: 1.5em;
- will-change: filter;
- transition: filter 300ms;
-}
-.logo:hover {
- filter: drop-shadow(0 0 2em #646cffaa);
-}
-.logo.react:hover {
- filter: drop-shadow(0 0 2em #61dafbaa);
-}
-
-@keyframes logo-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
-
-@media (prefers-reduced-motion: no-preference) {
- a:nth-of-type(2) .logo {
- animation: logo-spin infinite 20s linear;
- }
-}
-
-.card {
- padding: 2em;
-}
-
-.read-the-docs {
- color: #888;
-}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 770b2f3..aeb7d5b 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -8,6 +8,7 @@ import {Buckets} from '@/pages/Buckets';
import {Cluster} from '@/pages/Cluster';
import {AccessControl} from '@/pages/AccessControl';
import {Login} from '@/pages/Login';
+import {ObjectDetailsView} from '@/components/buckets/ObjectDetailsView';
import {Toaster} from 'sonner';
import {queryClient} from '@/lib/query-client';
import {useAuthStore} from '@/store/auth-store';
@@ -48,6 +49,7 @@ function App() {
>
} />
} />
+ } />
} />
} />
diff --git a/frontend/src/components/auth/BasicLoginForm.tsx b/frontend/src/components/auth/BasicLoginForm.tsx
index 7c4dcfa..4d337f2 100644
--- a/frontend/src/components/auth/BasicLoginForm.tsx
+++ b/frontend/src/components/auth/BasicLoginForm.tsx
@@ -3,8 +3,8 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuthStore } from '@/store/auth-store';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
-import { Lock, LogIn } from 'lucide-react';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { LogIn } from 'lucide-react';
import type { AuthConfig } from '@/types/auth';
interface BasicLoginFormProps {
@@ -43,16 +43,15 @@ export function BasicLoginForm({ showOIDC = false, config }: BasicLoginFormProps
-
-
-
+
- {showOIDC ? 'Sign in to Garage UI' : 'Admin Login'}
+ Welcome to Garage UI
-
- {showOIDC ? 'Enter your credentials or use SSO' : 'Enter your credentials to access the dashboard'}
-