Compare commits

...

12 Commits

Author SHA1 Message Date
Noste 80553c6450 feat: enhance authentication and upload features; add JWT support and upload progress component
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 12:21:49 +01:00
Noste 7b31490488 feat: bump version to 0.1.7 and appVersion to 0.0.11; update version badges in README
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-22 12:15:45 +01:00
Noste 7419d5a14b feat: update garage image version to 2.1.0 and fix JSON field name for storage nodes
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-22 12:11:38 +01:00
Noste 4864185183 feat: update README version badge to 0.1.6
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-22 00:34:53 +01:00
Noste 7aa5aeb4f8 feat: bump Helm chart version to 0.1.6 and update endpoint pattern in values schema
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-22 00:15:34 +01:00
Noste b4bdd13743 feat: update Helm chart version to 0.1.5 and add values schema for configuration
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-22 00:10:27 +01:00
Noste 62b76769d0 feat: bump version to 0.1.4 and appVersion to 0.0.10; update authentication secrets in deployment and values
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 23:53:19 +01:00
Noste 31b3aca8f9 feat: enable multi-platform builds in Dockerfile
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 23:48:03 +01:00
Noste 61dae6c605 feat: implement admin and OIDC authentication methods; add login and user info endpoints
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 23:42:02 +01:00
Noste b49c634f17 bump version to 0.1.3 and update appVersion to 0.0.7; refine region comment in config example
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 15:54:06 +01:00
Noste 5910961825 docs: update README
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 15:42:18 +01:00
Noste 7a4e187745 feat: trim protocol from MinIO endpoint and set SSL flag
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 15:29:19 +01:00
57 changed files with 3528 additions and 1143 deletions
+6 -3
View File
@@ -1,4 +1,4 @@
FROM node:25-alpine3.22 AS frontend-builder
FROM --platform=$BUILDPLATFORM node:25-alpine3.22 AS frontend-builder
WORKDIR /app/frontend
@@ -11,7 +11,10 @@ COPY frontend/ .
RUN npm run build
FROM golang:1.25.4-alpine3.22 AS backend-builder
FROM --platform=$BUILDPLATFORM golang:1.25.4-alpine3.22 AS backend-builder
ARG TARGETOS
ARG TARGETARCH
WORKDIR /app
@@ -31,7 +34,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o garage-ui .
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -installsuffix cgo -o garage-ui .
FROM alpine:3.22
+137
View File
@@ -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
+63 -37
View File
@@ -85,11 +85,6 @@ docker run -d \
docker pull noooste/garage-ui:latest
```
**Available tags:**
- `latest` - Latest stable release
- `v0.0.4` - Specific version
- `0.0` - Minor version track
The Docker image is multi-platform, supporting `linux/amd64` and `linux/arm64`.
### Kubernetes Deployment with Helm
@@ -156,23 +151,23 @@ server:
# Garage S3 Configuration
garage:
endpoint: "http://garage:3900"
region: "eu-west-1"
endpoint: "garage:3900"
region: "eu-west-1" # Ensure to match Garage region from garage.toml
admin_endpoint: "http://garage:3903"
admin_token: "your-admin-token-here"
# Authentication (none, basic, oidc)
# Authentication Configuration
# You can enable one or both authentication methods
auth:
mode: "none"
# Basic auth example
basic:
# Admin authentication (username/password)
admin:
enabled: false # Set to true to enable admin login
username: "admin"
password: "secure-password"
# OIDC example (Keycloak, Auth0, etc.)
# OIDC authentication (Keycloak, Auth0, etc.)
oidc:
enabled: true
enabled: false # Set to true to enable OIDC login
provider_name: "Keycloak"
client_id: "garage-ui"
client_secret: "your-client-secret"
@@ -192,9 +187,10 @@ GARAGE_UI_SERVER_PORT=8080
GARAGE_UI_GARAGE_ENDPOINT=http://garage:3900
GARAGE_UI_GARAGE_ADMIN_ENDPOINT=http://garage:3903
GARAGE_UI_GARAGE_ADMIN_TOKEN=your-token
GARAGE_UI_AUTH_MODE=basic
GARAGE_UI_AUTH_BASIC_USERNAME=admin
GARAGE_UI_AUTH_BASIC_PASSWORD=password
GARAGE_UI_AUTH_ADMIN_ENABLED=true
GARAGE_UI_AUTH_ADMIN_USERNAME=admin
GARAGE_UI_AUTH_ADMIN_PASSWORD=password
GARAGE_UI_AUTH_OIDC_ENABLED=false
GARAGE_UI_LOGGING_LEVEL=info
```
@@ -361,38 +357,44 @@ Once the backend is running, access the Swagger UI documentation at:
---
## Authentication Modes
## Authentication
Garage UI supports three authentication modes:
Garage UI supports flexible authentication with the ability to enable one or both methods simultaneously:
### 1. None (Default)
### No Authentication
No authentication required - suitable for private networks or development.
If neither admin nor OIDC authentication is enabled, the application runs without authentication - suitable for private networks or development.
```yaml
auth:
mode: "none"
admin:
enabled: false
oidc:
enabled: false
```
### 2. Basic Authentication
### Admin Authentication
Simple username/password authentication.
Simple username/password authentication using JWT tokens.
```yaml
auth:
mode: "basic"
basic:
admin:
enabled: true
username: "admin"
password: "your-secure-password"
oidc:
enabled: false
```
### 3. OIDC (OpenID Connect)
### OIDC (OpenID Connect) Authentication
Enterprise-grade authentication with providers like Keycloak, Auth0, Okta, etc.
```yaml
auth:
mode: "oidc"
admin:
enabled: false
oidc:
enabled: true
provider_name: "Keycloak"
@@ -406,6 +408,29 @@ auth:
cookie_secure: true # Enable in production with HTTPS
```
### Both Authentication Methods
You can enable both admin and OIDC authentication simultaneously. Users will be presented with both options on the login page:
```yaml
auth:
admin:
enabled: true
username: "admin"
password: "your-secure-password"
oidc:
enabled: true
provider_name: "Keycloak"
client_id: "garage-ui"
client_secret: "your-client-secret"
issuer_url: "https://auth.example.com/realms/master"
auth_url: "https://auth.example.com/realms/master/protocol/openid-connect/auth"
token_url: "https://auth.example.com/realms/master/protocol/openid-connect/token"
userinfo_url: "https://auth.example.com/realms/master/protocol/openid-connect/userinfo"
session_max_age: 86400
cookie_secure: true
```
**Role-Based Access (Optional):**
```yaml
@@ -477,7 +502,7 @@ replicaCount: 2
image:
repository: noooste/garage-ui
tag: v0.0.4
tag: latest
garage:
endpoint: "http://garage.storage.svc.cluster.local:3900"
@@ -497,12 +522,15 @@ ingress:
hosts:
- garage-ui.example.com
auth:
mode: oidc
oidc:
clientId: garage-ui
clientSecret: secret
issuerUrl: https://auth.example.com/realms/master
config:
auth:
admin:
enabled: false
oidc:
enabled: true
client_id: garage-ui
client_secret: secret
issuer_url: https://auth.example.com/realms/master
```
Install with:
@@ -623,8 +651,6 @@ GARAGE_UI_LOGGING_LEVEL=debug
This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details.
Copyright (c) 2025 Noste
---
## Acknowledgments
+35 -28
View File
@@ -15,7 +15,8 @@ import (
// AuthService handles authentication operations
type AuthService struct {
config *config.AuthConfig
authConfig *config.AuthConfig
serverConfig *config.ServerConfig
oidcProvider *oidc.Provider
oidcVerifier *oidc.IDTokenVerifier
oauth2Config *oauth2.Config
@@ -31,19 +32,20 @@ type UserInfo struct {
}
// NewAuthService creates a new authentication service
func NewAuthService(cfg *config.AuthConfig) (*AuthService, error) {
jwtService, err := NewJWTService()
func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig) (*AuthService, error) {
jwtService, err := NewJWTServiceWithKey(authCfg.JWTPrivKey)
if err != nil {
return nil, fmt.Errorf("failed to initialize JWT service: %w", err)
}
service := &AuthService{
config: cfg,
jwtService: jwtService,
authConfig: authCfg,
serverConfig: serverCfg,
jwtService: jwtService,
}
// Initialize OIDC if enabled
if cfg.Mode == "oidc" && cfg.OIDC.Enabled {
if authCfg.OIDC.Enabled {
if err := service.initOIDC(); err != nil {
return nil, fmt.Errorf("failed to initialize OIDC: %w", err)
}
@@ -57,7 +59,7 @@ func (a *AuthService) initOIDC() error {
ctx := context.Background()
// Create OIDC provider
provider, err := oidc.NewProvider(ctx, a.config.OIDC.IssuerURL)
provider, err := oidc.NewProvider(ctx, a.authConfig.OIDC.IssuerURL)
if err != nil {
return fmt.Errorf("failed to create OIDC provider: %w", err)
}
@@ -66,18 +68,23 @@ func (a *AuthService) initOIDC() error {
// Create ID token verifier
verifierConfig := &oidc.Config{
ClientID: a.config.OIDC.ClientID,
SkipIssuerCheck: a.config.OIDC.SkipIssuerCheck,
SkipExpiryCheck: a.config.OIDC.SkipExpiryCheck,
ClientID: a.authConfig.OIDC.ClientID,
SkipIssuerCheck: a.authConfig.OIDC.SkipIssuerCheck,
SkipExpiryCheck: a.authConfig.OIDC.SkipExpiryCheck,
}
a.oidcVerifier = provider.Verifier(verifierConfig)
// Construct redirect URL from server config
// Use root_url if set, otherwise construct from protocol/domain
redirectURL := a.serverConfig.RootURL + "/auth/oidc/callback"
// Create OAuth2 config
a.oauth2Config = &oauth2.Config{
ClientID: a.config.OIDC.ClientID,
ClientSecret: a.config.OIDC.ClientSecret,
ClientID: a.authConfig.OIDC.ClientID,
ClientSecret: a.authConfig.OIDC.ClientSecret,
RedirectURL: redirectURL,
Endpoint: provider.Endpoint(),
Scopes: a.config.OIDC.Scopes,
Scopes: a.authConfig.OIDC.Scopes,
}
return nil
@@ -88,12 +95,12 @@ func (a *AuthService) ValidateBasicAuth(username, password string) bool {
// Use constant-time comparison to prevent timing attacks
usernameMatch := subtle.ConstantTimeCompare(
[]byte(username),
[]byte(a.config.Basic.Username),
[]byte(a.authConfig.Admin.Username),
) == 1
passwordMatch := subtle.ConstantTimeCompare(
[]byte(password),
[]byte(a.config.Basic.Password),
[]byte(a.authConfig.Admin.Password),
) == 1
return usernameMatch && passwordMatch
@@ -171,14 +178,14 @@ func (a *AuthService) VerifyIDToken(ctx context.Context, rawIDToken string) (*Us
// Extract user information using configured attributes
userInfo := &UserInfo{
Username: extractClaim(claims, a.config.OIDC.UsernameAttribute),
Email: extractClaim(claims, a.config.OIDC.EmailAttribute),
Name: extractClaim(claims, a.config.OIDC.NameAttribute),
Username: extractClaim(claims, a.authConfig.OIDC.UsernameAttribute),
Email: extractClaim(claims, a.authConfig.OIDC.EmailAttribute),
Name: extractClaim(claims, a.authConfig.OIDC.NameAttribute),
}
// Extract roles if configured
if a.config.OIDC.RoleAttributePath != "" {
userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath)
if a.authConfig.OIDC.RoleAttributePath != "" {
userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
}
return userInfo, nil
@@ -207,14 +214,14 @@ func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*Us
// Build user info
userInfo := &UserInfo{
Username: extractClaim(claims, a.config.OIDC.UsernameAttribute),
Email: extractClaim(claims, a.config.OIDC.EmailAttribute),
Name: extractClaim(claims, a.config.OIDC.NameAttribute),
Username: extractClaim(claims, a.authConfig.OIDC.UsernameAttribute),
Email: extractClaim(claims, a.authConfig.OIDC.EmailAttribute),
Name: extractClaim(claims, a.authConfig.OIDC.NameAttribute),
}
// Extract roles if configured
if a.config.OIDC.RoleAttributePath != "" {
userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath)
if a.authConfig.OIDC.RoleAttributePath != "" {
userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
}
return userInfo, nil
@@ -222,12 +229,12 @@ func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*Us
// IsAdmin checks if the user has admin role
func (a *AuthService) IsAdmin(userInfo *UserInfo) bool {
if a.config.OIDC.AdminRole == "" {
if a.authConfig.OIDC.AdminRole == "" {
return false
}
for _, role := range userInfo.Roles {
if role == a.config.OIDC.AdminRole {
if role == a.authConfig.OIDC.AdminRole {
return true
}
}
@@ -320,7 +327,7 @@ func (a *AuthService) ValidateAndConsumeState(token string) bool {
// GenerateSessionToken generates a JWT session token for the user
func (a *AuthService) GenerateSessionToken(userInfo *UserInfo) (string, error) {
return a.jwtService.GenerateToken(userInfo, a.config.OIDC.SessionMaxAge)
return a.jwtService.GenerateToken(userInfo, a.authConfig.OIDC.SessionMaxAge)
}
// ValidateSessionToken validates a JWT session token and returns user info
+76 -15
View File
@@ -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
}
+40 -27
View File
@@ -19,10 +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
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
@@ -37,13 +44,14 @@ type GarageConfig struct {
// AuthConfig contains authentication configuration
type AuthConfig struct {
Mode string `mapstructure:"mode"` // "none", "basic", or "oidc"
Basic BasicAuthConfig `mapstructure:"basic"`
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)
}
// BasicAuthConfig contains basic authentication settings
type BasicAuthConfig struct {
// AdminAuthConfig contains admin authentication settings
type AdminAuthConfig struct {
Enabled bool `mapstructure:"enabled"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
}
@@ -107,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)
@@ -139,6 +146,13 @@ func bindEnvVars() {
viper.BindEnv("server.port", "GARAGE_UI_SERVER_PORT")
viper.BindEnv("server.environment", "GARAGE_UI_SERVER_ENVIRONMENT")
viper.BindEnv("server.frontend_path", "GARAGE_UI_SERVER_FRONTEND_PATH")
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")
@@ -149,9 +163,10 @@ func bindEnvVars() {
viper.BindEnv("garage.admin_token", "GARAGE_UI_GARAGE_ADMIN_TOKEN")
// Auth config
viper.BindEnv("auth.mode", "GARAGE_UI_AUTH_MODE")
viper.BindEnv("auth.basic.username", "GARAGE_UI_AUTH_BASIC_USERNAME")
viper.BindEnv("auth.basic.password", "GARAGE_UI_AUTH_BASIC_PASSWORD")
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")
@@ -208,28 +223,26 @@ func (c *Config) Validate() error {
return fmt.Errorf("garage admin_token is required")
}
// Validate auth mode
if c.Auth.Mode != "none" && c.Auth.Mode != "basic" && c.Auth.Mode != "oidc" {
return fmt.Errorf("auth mode must be 'none', 'basic', or 'oidc', got: %s", c.Auth.Mode)
}
// Validate basic auth if enabled
if c.Auth.Mode == "basic" {
if c.Auth.Basic.Username == "" || c.Auth.Basic.Password == "" {
return fmt.Errorf("basic auth username and password are required when auth mode is 'basic'")
// Validate admin auth if enabled
if c.Auth.Admin.Enabled {
if c.Auth.Admin.Username == "" || c.Auth.Admin.Password == "" {
return fmt.Errorf("admin auth username and password are required when admin auth is enabled")
}
}
// Validate OIDC config if enabled
if c.Auth.Mode == "oidc" {
if c.Auth.OIDC.Enabled {
if c.Auth.OIDC.ClientID == "" {
return fmt.Errorf("oidc client_id is required when auth mode is 'oidc'")
return fmt.Errorf("oidc client_id is required when oidc is enabled")
}
if c.Auth.OIDC.IssuerURL == "" {
return fmt.Errorf("oidc issuer_url is required when auth mode is 'oidc'")
return fmt.Errorf("oidc issuer_url is required when oidc is enabled")
}
if c.Server.RootURL == "" {
return fmt.Errorf("server.root_url is required when oidc is enabled")
}
if len(c.Auth.OIDC.Scopes) == 0 {
return fmt.Errorf("oidc scopes are required when auth mode is 'oidc'")
return fmt.Errorf("oidc scopes are required when oidc is enabled")
}
}
+152
View File
@@ -0,0 +1,152 @@
package handlers
import (
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"github.com/gofiber/fiber/v3"
)
// AuthHandler handles authentication-related requests
type AuthHandler struct {
cfg *config.Config
authService *auth.AuthService
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(cfg *config.Config, authService *auth.AuthService) *AuthHandler {
return &AuthHandler{
cfg: cfg,
authService: authService,
}
}
// GetAuthConfig returns the current authentication configuration
// @Summary Get authentication configuration
// @Description Returns the current auth configuration (admin and/or OIDC)
// @Tags auth
// @Produce json
// @Success 200 {object} object{admin=object,oidc=object} "Auth config"
// @Router /auth/config [get]
func (h *AuthHandler) GetAuthConfig(c fiber.Ctx) error {
response := fiber.Map{
"admin": fiber.Map{
"enabled": h.cfg.Auth.Admin.Enabled,
},
"oidc": fiber.Map{
"enabled": h.cfg.Auth.OIDC.Enabled,
},
}
// Add provider name if OIDC is enabled
if h.cfg.Auth.OIDC.Enabled {
provider := h.cfg.Auth.OIDC.ProviderName
if provider == "" {
provider = "OIDC Provider"
}
response["oidc"].(fiber.Map)["provider"] = provider
}
return c.JSON(response)
}
// LoginBasicRequest represents the basic auth login request
type LoginBasicRequest struct {
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
}
// LoginAdmin handles admin authentication login
// @Summary Admin auth login
// @Description Authenticate with admin username and password, returns JWT token
// @Tags auth
// @Accept json
// @Produce json
// @Param credentials body LoginBasicRequest true "Login credentials"
// @Success 200 {object} object{success=bool,token=string,user=object} "Login successful"
// @Failure 400 {object} models.APIResponse "Invalid request"
// @Failure 401 {object} models.APIResponse "Invalid credentials"
// @Router /auth/login [post]
func (h *AuthHandler) LoginAdmin(c fiber.Ctx) error {
// Parse request body
var req LoginBasicRequest
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body"),
)
}
// Validate credentials against admin config
if req.Username != h.cfg.Auth.Admin.Username || req.Password != h.cfg.Auth.Admin.Password {
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid credentials"),
)
}
// Create user info object
userInfo := &auth.UserInfo{
Username: req.Username,
}
// Generate JWT session token
sessionToken, err := h.authService.GenerateSessionToken(userInfo)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create session"),
)
}
return c.JSON(fiber.Map{
"success": true,
"token": sessionToken,
"user": fiber.Map{
"username": userInfo.Username,
},
})
}
// GetMe returns the current authenticated user's information
// @Summary Get current user
// @Description Returns information about the currently authenticated user
// @Tags auth
// @Produce json
// @Security ApiKeyAuth
// @Success 200 {object} object{success=bool,user=object} "User information"
// @Failure 401 {object} models.APIResponse "Not authenticated"
// @Router /auth/me [get]
func (h *AuthHandler) GetMe(c fiber.Ctx) error {
// Try to get user info from OIDC context
userInfoInterface := c.Locals("userInfo")
if userInfoInterface != nil {
userInfo, ok := userInfoInterface.(*auth.UserInfo)
if ok {
return c.JSON(fiber.Map{
"success": true,
"user": fiber.Map{
"username": userInfo.Username,
"email": userInfo.Email,
"name": userInfo.Name,
},
})
}
}
// Try to get username from basic auth context
usernameInterface := c.Locals("username")
if usernameInterface != nil {
username, ok := usernameInterface.(string)
if ok {
return c.JSON(fiber.Map{
"success": true,
"user": fiber.Map{
"username": username,
},
})
}
}
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Not authenticated"),
)
}
+14 -9
View File
@@ -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)
+28 -8
View File
@@ -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(
+40 -72
View File
@@ -8,95 +8,63 @@ import (
"github.com/gofiber/fiber/v3"
)
// AuthMiddleware returns a Fiber middleware for authentication
// It handles different auth modes: none, basic, 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 auth mode is "none", allow all requests
if cfg.Mode == "none" {
// If no auth is enabled, allow all requests
if !cfg.Admin.Enabled && !cfg.OIDC.Enabled {
return c.Next()
}
// Handle basic authentication
if cfg.Mode == "basic" {
return handleBasicAuth(c, authService)
// Get Authorization header
authHeader := c.Get("Authorization")
// Try admin auth if enabled and header is present
if cfg.Admin.Enabled && authHeader != "" {
// Check if it's a Bearer token (JWT from admin login)
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
token := authHeader[7:]
// Validate JWT session token
userInfo, err := authService.ValidateSessionToken(token)
if err == nil {
// Valid admin token
c.Locals("userInfo", userInfo)
c.Locals("username", userInfo.Username)
if userInfo.Email != "" {
c.Locals("email", userInfo.Email)
}
return c.Next()
}
}
}
// Handle OIDC authentication
if cfg.Mode == "oidc" {
return handleOIDCAuth(c, authService, &cfg.OIDC)
// Try OIDC auth if enabled
if cfg.OIDC.Enabled {
sessionCookie := c.Cookies(cfg.OIDC.CookieName)
if sessionCookie != "" {
// Validate JWT session token from cookie
userInfo, err := authService.ValidateSessionToken(sessionCookie)
if err == nil {
// Valid OIDC token
c.Locals("userInfo", userInfo)
c.Locals("username", userInfo.Username)
c.Locals("email", userInfo.Email)
return c.Next()
}
}
}
// Unknown auth mode - deny access
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid authentication mode"),
)
}
}
// handleBasicAuth validates basic authentication credentials
func handleBasicAuth(c fiber.Ctx, authService *auth.AuthService) error {
// Get Authorization header
authHeader := c.Get("Authorization")
if authHeader == "" {
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Authorization header required"),
)
}
// Parse basic auth credentials
username, password, ok := auth.ParseBasicAuth(authHeader)
if !ok {
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid Authorization header format"),
)
}
// Validate credentials
if !authService.ValidateBasicAuth(username, password) {
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid credentials"),
)
}
// Store username in context for later use
c.Locals("username", username)
return c.Next()
}
// handleOIDCAuth validates OIDC session/token
func handleOIDCAuth(c fiber.Ctx, authService *auth.AuthService, oidcCfg *config.OIDCConfig) error {
// Get session cookie
sessionCookie := c.Cookies(oidcCfg.CookieName)
if sessionCookie == "" {
// No valid authentication found
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Authentication required"),
)
}
// Validate JWT session token
userInfo, err := authService.ValidateSessionToken(sessionCookie)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid or expired session"),
)
}
// Store user info in context for handlers to use
c.Locals("userInfo", userInfo)
c.Locals("username", userInfo.Username)
c.Locals("email", userInfo.Email)
return c.Next()
}
func RequireAuth(cfg *config.AuthConfig) fiber.Handler {
return func(c fiber.Ctx) error {
if cfg.Mode == "none" {
if !cfg.Admin.Enabled && !cfg.OIDC.Enabled {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Authentication is required but not configured"),
)
+1 -1
View File
@@ -194,7 +194,7 @@ type ClusterHealth struct {
KnownNodes int `json:"knownNodes"`
ConnectedNodes int `json:"connectedNodes"`
StorageNodes int `json:"storageNodes"`
StorageNodesUp int `json:"storageNodesOk"`
StorageNodesUp int `json:"storageNodesUp"`
Partitions int `json:"partitions"`
PartitionsQuorum int `json:"partitionsQuorum"`
PartitionsAllOk int `json:"partitionsAllOk"`
+9 -9
View File
@@ -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"`
+91 -28
View File
@@ -6,6 +6,7 @@ import (
"Noooste/garage-ui/internal/handlers"
"Noooste/garage-ui/internal/middleware"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
@@ -39,6 +40,12 @@ func SetupRoutes(
// Swagger documentation endpoint (no auth required)
app.Get("/docs/*", swagger.HandlerDefault)
// Create auth handler
authHandler := handlers.NewAuthHandler(cfg, authService)
// Auth configuration endpoint (always accessible, no auth required)
app.Get("/auth/config", authHandler.GetAuthConfig)
// API v1 group
api := app.Group("/api/v1")
@@ -62,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")
{
@@ -97,12 +166,22 @@ func SetupRoutes(
monitoring.Get("/dashboard", monitoringHandler.GetDashboardMetrics) // Get dashboard metrics
}
// Admin auth login endpoint (only if admin is enabled)
if cfg.Auth.Admin.Enabled {
app.Post("/auth/login", authHandler.LoginAdmin)
}
// Auth "me" endpoint (if any auth is enabled)
if cfg.Auth.Admin.Enabled || cfg.Auth.OIDC.Enabled {
app.Get("/auth/me", middleware.AuthMiddleware(&cfg.Auth, authService), authHandler.GetMe)
}
// OIDC authentication routes (only if OIDC is enabled)
if cfg.Auth.Mode == "oidc" && cfg.Auth.OIDC.Enabled {
authRoutes := app.Group("/auth")
if cfg.Auth.OIDC.Enabled {
oidcRoutes := app.Group("/auth/oidc")
{
// Login endpoint - redirects to OIDC provider
authRoutes.Get("/login", func(c fiber.Ctx) error {
oidcRoutes.Get("/login", func(c fiber.Ctx) error {
state, err := authService.GenerateStateToken()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
@@ -120,7 +199,7 @@ func SetupRoutes(
})
// Callback endpoint - handles OIDC redirect after login
authRoutes.Get("/callback", func(c fiber.Ctx) error {
oidcRoutes.Get("/callback", func(c fiber.Ctx) error {
// Get and validate state token
state := c.Query("state")
if !authService.ValidateAndConsumeState(state) {
@@ -180,14 +259,12 @@ func SetupRoutes(
SameSite: cfg.Auth.OIDC.CookieSameSite,
})
return c.JSON(fiber.Map{
"success": true,
"user": userInfo,
})
// Redirect to frontend with success indicator
return c.Redirect().To("/login?login=success")
})
// Logout endpoint
authRoutes.Post("/logout", func(c fiber.Ctx) error {
oidcRoutes.Post("/logout", func(c fiber.Ctx) error {
// Clear session cookie
c.Cookie(&fiber.Cookie{
Name: cfg.Auth.OIDC.CookieName,
@@ -200,21 +277,6 @@ func SetupRoutes(
"message": "Logged out successfully",
})
})
// User info endpoint
authRoutes.Get("/me", middleware.AuthMiddleware(&cfg.Auth, authService), func(c fiber.Ctx) error {
userInfo := c.Locals("userInfo")
if userInfo == nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "Not authenticated",
})
}
return c.JSON(fiber.Map{
"success": true,
"user": userInfo,
})
})
}
}
@@ -227,6 +289,7 @@ func SetupRoutes(
path := c.Path()
if strings.HasPrefix(path, "/api/") ||
strings.HasPrefix(path, "/auth") ||
strings.HasPrefix(path, "/health") ||
strings.HasPrefix(path, "/docs") {
fmt.Println("API or health check route, skipping SPA fallback:", path)
+24 -10
View File
@@ -3,6 +3,7 @@ package services
import (
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/pkg/utils"
"context"
"encoding/json"
"fmt"
@@ -31,17 +32,30 @@ func NewGarageAdminService(cfg *config.GarageConfig) *GarageAdminService {
}
}
// doRequest performs an HTTP request to the Admin API
// doRequest performs an HTTP request to the Admin API with retry logic for connection refused errors
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)
var resp *azuretls.Response
retryConfig := utils.DefaultRetryConfig()
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
var reqErr error
resp, reqErr = 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)
return reqErr
})
if err != nil {
return nil, err
}
return resp, nil
}
// decodeResponse decodes a JSON response into the target structure
+141 -71
View File
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"io"
"net/url"
"strings"
"time"
"Noooste/garage-ui/internal/config"
@@ -24,6 +26,16 @@ type S3Service struct {
// NewS3Service creates a new S3 service instance using MinIO SDK
func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S3Service {
// Create MinIO client for Garage
// trim http or https from endpoint
if strings.HasPrefix(cfg.Endpoint, "http://") {
cfg.Endpoint = strings.TrimPrefix(cfg.Endpoint, "http://")
}
if strings.HasPrefix(cfg.Endpoint, "https://") {
cfg.Endpoint = strings.TrimPrefix(cfg.Endpoint, "https://")
cfg.UseSSL = true
}
client, err := minio.New(cfg.Endpoint, &minio.Options{
//Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: cfg.UseSSL,
@@ -40,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)
@@ -111,8 +121,15 @@ func (s *S3Service) getMinioClient(ctx context.Context, bucketName string) (*min
// ListBuckets retrieves all buckets from Garage
func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse, error) {
// Call MinIO ListBuckets API
bucketInfos, err := s.client.ListBuckets(ctx)
var bucketInfos []minio.BucketInfo
// Call MinIO ListBuckets API with retry logic
retryConfig := utils.DefaultRetryConfig()
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
var listErr error
bucketInfos, listErr = s.client.ListBuckets(ctx)
return listErr
})
if err != nil {
return nil, fmt.Errorf("failed to list buckets: %w", err)
}
@@ -139,9 +156,12 @@ func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// Call MinIO MakeBucket API
err = client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{
Region: s.config.Region,
// Call MinIO MakeBucket API with retry logic
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
return client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{
Region: s.config.Region,
})
})
if err != nil {
return fmt.Errorf("failed to create bucket %s: %w", bucketName, err)
@@ -157,8 +177,11 @@ func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// Call MinIO RemoveBucket API
err = client.RemoveBucket(ctx, bucketName)
// Call MinIO RemoveBucket API with retry logic
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
return client.RemoveBucket(ctx, bucketName)
})
if err != nil {
return fmt.Errorf("failed to delete bucket %s: %w", bucketName, err)
}
@@ -179,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{
@@ -244,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
}
@@ -262,8 +293,15 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
ContentType: contentType,
}
// Call MinIO PutObject API
info, err := client.PutObject(ctx, bucketName, key, body, -1, opts)
var info minio.UploadInfo
// Call MinIO PutObject API with retry logic
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
var uploadErr error
info, uploadErr = client.PutObject(ctx, bucketName, key, body, -1, opts)
return uploadErr
})
if err != nil {
return nil, fmt.Errorf("failed to upload object %s to bucket %s: %w", key, bucketName, err)
}
@@ -279,8 +317,15 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
// GetObject retrieves an object from a bucket
func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) {
// Call MinIO GetObject API
object, err := s.client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{})
var object *minio.Object
// Call MinIO GetObject API with retry logic
retryConfig := utils.DefaultRetryConfig()
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
var getErr error
object, getErr = s.client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{})
return getErr
})
if err != nil {
return nil, nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err)
}
@@ -306,8 +351,11 @@ func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.R
// DeleteObject deletes an object from a bucket
func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error {
// Call MinIO RemoveObject API
err := s.client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{})
// Call MinIO RemoveObject API with retry logic
retryConfig := utils.DefaultRetryConfig()
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
return s.client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{})
})
if err != nil {
return fmt.Errorf("failed to delete object %s from bucket %s: %w", key, bucketName, err)
}
@@ -323,7 +371,15 @@ func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (b
return false, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
_, err = client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
var statErr error
// Call MinIO StatObject API with retry logic
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
_, statErr = client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
return statErr
})
if err != nil {
// Check if error is "object not found"
errResponse := minio.ToErrorResponse(err)
@@ -343,7 +399,15 @@ func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key strin
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
stat, err := client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
var stat minio.ObjectInfo
// Call MinIO StatObject API with retry logic
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
var statErr error
stat, statErr = client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
return statErr
})
if err != nil {
return nil, fmt.Errorf("failed to get metadata for object %s in bucket %s: %w", key, bucketName, err)
}
@@ -354,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
}
@@ -404,8 +470,15 @@ func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string,
return "", fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// Generate presigned GET URL
presignedURL, err := client.PresignedGetObject(ctx, bucketName, key, expiresIn, nil)
var presignedURL *url.URL
// Generate presigned GET URL with retry logic
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
var presignErr error
presignedURL, presignErr = client.PresignedGetObject(ctx, bucketName, key, expiresIn, nil)
return presignErr
})
if err != nil {
return "", fmt.Errorf("failed to generate presigned URL for %s/%s: %w", bucketName, key, err)
}
@@ -423,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
+40 -9
View File
@@ -25,9 +25,6 @@ import (
// @description This API provides endpoints for managing buckets, objects, users, and cluster operations.
// @termsOfService http://swagger.io/terms/
// @contact.name API Support
// @contact.email support@garage-ui.io
// @license.name MIT
// @license.url https://opensource.org/licenses/MIT
@@ -81,8 +78,19 @@ func main() {
log.Println("Initializing S3 service...")
s3Service := services.NewS3Service(&cfg.Garage, adminService)
log.Printf("Initializing authentication service (mode: %s)...", cfg.Auth.Mode)
authService, err := auth.NewAuthService(&cfg.Auth)
// Determine enabled auth methods for logging
authMethods := []string{}
if cfg.Auth.Admin.Enabled {
authMethods = append(authMethods, "admin")
}
if cfg.Auth.OIDC.Enabled {
authMethods = append(authMethods, "oidc")
}
if len(authMethods) == 0 {
authMethods = append(authMethods, "none")
}
log.Printf("Initializing authentication service (enabled: %v)...", authMethods)
authService, err := auth.NewAuthService(&cfg.Auth, &cfg.Server)
if err != nil {
log.Fatalf("Failed to initialize auth service: %v", err)
}
@@ -95,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
+104
View File
@@ -0,0 +1,104 @@
package utils
import (
"context"
"errors"
"fmt"
"net"
"syscall"
"time"
)
// RetryConfig configures retry behavior
type RetryConfig struct {
MaxRetries int
InitialBackoff time.Duration
MaxBackoff time.Duration
BackoffFactor float64
}
// DefaultRetryConfig returns default retry configuration
func DefaultRetryConfig() RetryConfig {
return RetryConfig{
MaxRetries: 3,
InitialBackoff: 100 * time.Millisecond,
MaxBackoff: 5 * time.Second,
BackoffFactor: 2.0,
}
}
// IsConnectionRefused checks if the error is a connection refused error
func IsConnectionRefused(err error) bool {
if err == nil {
return false
}
// Check for connection refused error
var opErr *net.OpError
if errors.As(err, &opErr) {
// Check if it's a connection refused error
if opErr.Op == "dial" || opErr.Op == "read" {
var syscallErr *syscall.Errno
if errors.As(opErr.Err, &syscallErr) {
return *syscallErr == syscall.ECONNREFUSED
}
}
}
// Also check for "connection refused" in error message as fallback
if errors.Is(err, syscall.ECONNREFUSED) {
return true
}
return false
}
// RetryWithBackoff executes a function with exponential backoff on connection refused errors
func RetryWithBackoff(ctx context.Context, config RetryConfig, fn func() error) error {
var lastErr error
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
// Execute the function
err := fn()
if err == nil {
return nil
}
lastErr = err
// If it's not a connection refused error, don't retry
if !IsConnectionRefused(err) {
return err
}
// If this was the last attempt, return the error
if attempt == config.MaxRetries {
return fmt.Errorf("max retries (%d) exceeded: %w", config.MaxRetries, err)
}
// Calculate backoff duration with exponential increase
backoff := time.Duration(float64(config.InitialBackoff) * pow(config.BackoffFactor, float64(attempt)))
if backoff > config.MaxBackoff {
backoff = config.MaxBackoff
}
// Wait with context cancellation support
select {
case <-ctx.Done():
return fmt.Errorf("context cancelled during retry: %w", ctx.Err())
case <-time.After(backoff):
// Continue to next attempt
}
}
return lastErr
}
// pow is a simple integer exponentiation helper
func pow(base float64, exp float64) float64 {
result := 1.0
for i := 0; i < int(exp); i++ {
result *= base
}
return result
}
+24 -7
View File
@@ -5,29 +5,46 @@ server:
host: "0.0.0.0"
port: 8080
environment: "development" # development, production
domain: "localhost" # Domain name for the application
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
region: "eu-west-1" # S3 region (can be any value for Garage)
region: "eu-west-1" # S3 region (ensure it matches Garage S3 configuration)
# Garage Admin API configuration
admin_endpoint: "http://localhost:3903" # Garage Admin API endpoint
admin_token: "changeme" # Admin API bearer token
# Authentication Configuration
# You can enable one or both authentication methods
auth:
# Auth mode: "none", "basic", or "oidc"
mode: "none"
# 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
# Basic Authentication (only used when mode = "basic")
basic:
# Admin Authentication (username/password)
admin:
enabled: false # Set to true to enable admin login
username: "admin"
password: "changeme"
# OIDC Configuration (only used when mode = "oidc")
# OIDC Configuration
# NOTE: When OIDC is enabled, server.root_url is required for OAuth2 redirects
# The redirect URL will be automatically constructed as: {root_url}/auth/oidc/callback
oidc:
enabled: true
enabled: false # Set to true to enable OIDC login
provider_name: "Keycloak"
client_id: "garage-ui"
client_secret: "your-client-secret"
+9 -1
View File
@@ -1,7 +1,7 @@
---
services:
garage:
image: dxflrs/garage:v2.0.0
image: dxflrs/garage:v2.1.0
container_name: garage
volumes:
- ./garage.toml:/etc/garage.toml
@@ -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"
-42
View File
@@ -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;
}
+27 -1
View File
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import {BrowserRouter, Route, Routes} from 'react-router-dom';
import {QueryClientProvider} from '@tanstack/react-query';
import {ThemeProvider, useTheme} from '@/components/theme-provider';
@@ -6,8 +7,13 @@ import {Dashboard} from '@/pages/Dashboard';
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';
import {ProtectedRoute} from '@/components/auth/ProtectedRoute';
import {LoadingSpinner} from '@/components/auth/LoadingSpinner';
function ThemedToaster() {
const { theme } = useTheme();
@@ -16,14 +22,34 @@ function ThemedToaster() {
}
function App() {
const { initialize, isLoading } = useAuthStore();
useEffect(() => {
initialize();
}, [initialize]);
if (isLoading) {
return <LoadingSpinner />;
}
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="system" storageKey="Noooste/garage-ui-theme">
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<Dashboard />} />
<Route path="buckets" element={<Buckets />} />
<Route path="buckets/:bucketName/objects/*" element={<ObjectDetailsView />} />
<Route path="cluster" element={<Cluster />} />
<Route path="access" element={<AccessControl />} />
</Route>
@@ -0,0 +1,114 @@
import { useState } from 'react';
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, CardHeader, CardTitle } from '@/components/ui/card';
import { LogIn } from 'lucide-react';
import type { AuthConfig } from '@/types/auth';
interface BasicLoginFormProps {
showOIDC?: boolean;
config?: AuthConfig | null;
}
export function BasicLoginForm({ showOIDC = false, config }: BasicLoginFormProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { loginAdmin, loginOIDC } = useAuthStore();
const returnUrl = searchParams.get('returnUrl') || '/';
const providerName = config?.oidc?.provider || 'OIDC Provider';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
await loginAdmin(username, password);
// Navigate to return URL on success
navigate(decodeURIComponent(returnUrl));
} catch (error) {
// Error is already handled by the store and toast
console.error('Login failed:', error);
} finally {
setIsLoading(false);
}
};
return (
<Card className="w-full">
<CardHeader className="space-y-1">
<div className="flex items-center justify-center mb-4">
<img
src="/garage.png"
alt="Garage Logo"
className="h-16 w-16 object-contain"
/>
</div>
<CardTitle className="text-2xl text-center">
Welcome to Garage UI
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="username" className="text-sm font-medium">Username</label>
<Input
id="username"
type="text"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
disabled={isLoading}
autoComplete="username"
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">Password</label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
autoComplete="current-password"
/>
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading || !username || !password}
>
{isLoading ? 'Signing in...' : 'Sign in'}
</Button>
</form>
{showOIDC && (
<div className="mt-4">
<div className="relative mb-4">
<div className="relative flex justify-center text-xs">
<span className="bg-card px-2 text-muted-foreground">or</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full"
onClick={loginOIDC}
>
<LogIn className="mr-2 h-4 w-4" />
Sign in with {providerName}
</Button>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,12 @@
import { Loader2 } from 'lucide-react';
export function LoadingSpinner() {
return (
<div className="flex h-screen w-full items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
import { useAuthStore } from '@/store/auth-store';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { LogIn } from 'lucide-react';
export function OIDCLoginView() {
const { config, loginOIDC } = useAuthStore();
const providerName = config?.oidc.provider || 'OIDC Provider';
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<div className="flex items-center justify-center mb-4">
<img
src="/garage.png"
alt="Garage Logo"
className="h-16 w-16 object-contain"
/>
</div>
<CardTitle className="text-2xl text-center">Sign in to Garage UI</CardTitle>
<CardDescription className="text-center">
Authenticate using your {providerName} account
</CardDescription>
</CardHeader>
<CardContent>
<Button
onClick={loginOIDC}
className="w-full"
size="lg"
>
<LogIn className="mr-2 h-5 w-5" />
Continue with {providerName}
</Button>
<p className="mt-4 text-center text-xs text-muted-foreground">
You will be redirected to {providerName} to complete the sign-in process
</p>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,29 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '@/store/auth-store';
import { LoadingSpinner } from './LoadingSpinner';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading, config } = useAuthStore();
const location = useLocation();
if (isLoading) {
return <LoadingSpinner />;
}
// If no auth is enabled, always allow access
if (config && !config.admin.enabled && !config.oidc.enabled) {
return <>{children}</>;
}
// If not authenticated, redirect to login with return URL
if (!isAuthenticated) {
const returnUrl = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?returnUrl=${returnUrl}`} replace />;
}
return <>{children}</>;
}
@@ -10,7 +10,8 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { FolderIcon, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react';
import { formatBytes, formatDate } from '@/lib/utils';
import { formatBytes } from '@/lib/file-utils';
import { formatDate } from '@/lib/utils';
import type { Bucket } from '@/types';
interface BucketListViewProps {
@@ -6,9 +6,10 @@ import {Header} from '@/components/layout/header';
import {ObjectsTable} from './ObjectsTable';
import {CreateDirectoryDialog} from './CreateDirectoryDialog';
import {DeleteObjectDialog} from './DeleteObjectDialog';
import {UploadProgress} from './UploadProgress';
import {ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, Search, Trash, Upload} from 'lucide-react';
import {getBreadcrumbs} from '@/lib/file-utils';
import type {S3Object} from '@/types';
import type {S3Object, UploadTask} from '@/types';
interface ObjectBrowserViewProps {
bucketName: string;
@@ -23,6 +24,7 @@ interface ObjectBrowserViewProps {
onNavigateToFolder: (path: string) => void;
onBackToBuckets: () => void;
onUploadFiles: (files: File[]) => Promise<boolean>;
uploadTasks: UploadTask[];
onDeleteObject: (key: string) => Promise<boolean>;
onDeleteMultipleObjects: (keys: string[]) => Promise<boolean>;
onCreateDirectory: (name: string) => Promise<boolean>;
@@ -48,6 +50,7 @@ export function ObjectBrowserView({
onNavigateToFolder,
onBackToBuckets,
onUploadFiles,
uploadTasks,
onDeleteObject,
onDeleteMultipleObjects,
onCreateDirectory,
@@ -233,7 +236,7 @@ export function ObjectBrowserView({
</div>
{/* Upload Zone */}
{showUploadZone && (
{showUploadZone && uploadTasks.length === 0 && (
<div className="border rounded-lg p-6 bg-muted/30 space-y-4">
<div className="flex gap-6">
<div className="flex-shrink-0 flex items-center justify-center">
@@ -312,6 +315,9 @@ export function ObjectBrowserView({
</div>
)}
{/* Upload Progress */}
{uploadTasks.length > 0 && <UploadProgress tasks={uploadTasks} />}
{/* Objects Table with Drag & Drop */}
<div
{...getRootProps()}
@@ -344,6 +350,7 @@ export function ObjectBrowserView({
)}
<ObjectsTable
bucketName={bucketName}
objects={objects}
currentPath={currentPath}
searchQuery={searchQuery}
@@ -0,0 +1,271 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { objectsApi } from '@/lib/api';
import type { ObjectMetadata } from '@/types';
import { Header } from '@/components/layout/header';
import { Button } from '@/components/ui/button';
import { ArrowLeft, Download, Trash, Copy, File } from 'lucide-react';
import { toast } from 'sonner';
import { formatBytes } from '@/lib/file-utils';
export function ObjectDetailsView() {
const navigate = useNavigate();
const { bucketName, '*': encodedObjectKey } = useParams();
// Decode the object key from the URL
const objectKey = encodedObjectKey ? decodeURIComponent(encodedObjectKey) : undefined;
const [metadata, setMetadata] = useState<ObjectMetadata | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!bucketName || !objectKey) {
setError('Bucket name and object key are required');
setIsLoading(false);
return;
}
const fetchMetadata = async () => {
try {
setIsLoading(true);
setError(null);
const data = await objectsApi.getMetadata(bucketName, objectKey);
setMetadata(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load object metadata');
console.error('Failed to fetch object metadata:', err);
} finally {
setIsLoading(false);
}
};
fetchMetadata();
}, [bucketName, objectKey]);
const handleDownload = async () => {
if (!bucketName || !objectKey) return;
try {
const blob = await objectsApi.get(bucketName, objectKey);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = objectKey.split('/').pop() || 'download';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Download started');
} catch (err) {
console.error('Download failed:', err);
}
};
const handleDelete = async () => {
if (!bucketName || !objectKey) return;
if (!confirm(`Are you sure you want to delete "${objectKey}"?`)) {
return;
}
try {
await objectsApi.delete(bucketName, objectKey);
toast.success('Object deleted successfully');
handleBackNavigation();
} catch (err) {
console.error('Delete failed:', err);
}
};
const handleBackNavigation = () => {
if (!bucketName) return;
// Navigate back to the bucket explorer with the appropriate prefix
// Extract the folder path from the object key (everything before the last /)
const folderPath = objectKey?.split('/').slice(0, -1).join('/') || '';
const prefix = folderPath ? `${folderPath}/` : '';
// Navigate to the bucket view with the correct prefix
navigate(`/buckets?bucket=${encodeURIComponent(bucketName)}${prefix ? `&prefix=${encodeURIComponent(prefix)}` : ''}`);
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short',
});
};
if (isLoading) {
return (
<div>
<Header title="Object Details" />
<div className="p-4 sm:p-6">
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Loading object details...</div>
</div>
</div>
</div>
);
}
if (error || !metadata) {
return (
<div>
<Header title="Object Details" />
<div className="p-4 sm:p-6">
<Button variant="outline" onClick={handleBackNavigation} className="mb-4">
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<div className="flex items-center justify-center h-64">
<div className="text-red-500">{error || 'Object not found'}</div>
</div>
</div>
</div>
);
}
const fileName = objectKey?.split('/').pop() || objectKey || '';
const pathParts = objectKey?.split('/').filter(part => part) || [];
const parentPath = pathParts.slice(0, -1).join('/');
return (
<div>
<Header title={fileName} />
<div className="p-4 sm:p-6 space-y-6">
{/* Back Button and Actions */}
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleBackNavigation}>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleDownload}>
<Download className="h-4 w-4" />
Download
</Button>
<Button
variant="outline"
className="border-red-500 text-red-500 hover:bg-red-500/5"
onClick={handleDelete}
>
<Trash className="h-4 w-4" />
Delete
</Button>
</div>
</div>
{/* File Name Header */}
<div className="flex items-start gap-3 p-4 border-b border-border bg-card rounded-t-lg">
<div className="mt-1">
<File className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-4 flex-wrap">
<h2 className="text-lg font-medium text-foreground break-all">
{parentPath && (
<span className="text-muted-foreground font-mono">/{parentPath}/</span>
)}
{fileName}
</h2>
<button
onClick={() => copyToClipboard(metadata.key)}
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 shrink-0"
>
<Copy className="h-3 w-3" />
Copy
</button>
</div>
</div>
</div>
{/* Object Details Section */}
<div className="border border-border rounded-lg bg-card">
<div className="p-6 border-b border-border">
<h3 className="text-base font-semibold text-foreground">Object Details</h3>
</div>
<div className="divide-y divide-border">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
<div className="text-sm font-medium text-muted-foreground">Date Created</div>
<div className="sm:col-span-2 text-sm text-foreground">
{formatDate(metadata.lastModified)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
<div className="text-sm font-medium text-muted-foreground">Type</div>
<div className="sm:col-span-2 text-sm text-foreground">
{metadata.contentType || 'application/octet-stream'}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
<div className="text-sm font-medium text-muted-foreground">Storage Class</div>
<div className="sm:col-span-2 text-sm text-foreground">
{metadata.storageClass || 'Standard'}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
<div className="text-sm font-medium text-muted-foreground">Size</div>
<div className="sm:col-span-2 text-sm text-foreground">
{formatBytes(metadata.size)}
</div>
</div>
</div>
</div>
{/* Custom Metadata Section */}
{metadata.metadata && Object.keys(metadata.metadata).length > 0 && (
<div className="border border-border rounded-lg bg-card">
<div className="p-6 border-b border-border">
<h3 className="text-base font-semibold text-foreground">Custom Metadata</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/30">
<tr className="border-b border-border">
<th className="px-6 py-3 text-left text-sm font-medium text-muted-foreground">
Key
</th>
<th className="px-6 py-3 text-left text-sm font-medium text-muted-foreground">
Value
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{Object.entries(metadata.metadata).map(([key, value]) => (
<tr key={key} className="hover:bg-muted/30">
<td className="px-6 py-4 text-sm font-medium text-foreground break-all">
{key}
</td>
<td className="px-6 py-4 text-sm text-foreground break-all">{value}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Object Preview Section */}
<div className="border border-border rounded-lg bg-card">
<div className="p-6 border-b border-border">
<h3 className="text-base font-semibold text-foreground">Object Preview</h3>
</div>
<div className="p-6">
<p className="text-sm text-muted-foreground">No preview available</p>
</div>
</div>
</div>
</div>
);
}
@@ -1,4 +1,5 @@
import {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Checkbox} from '@/components/ui/checkbox';
@@ -11,13 +12,13 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {ChevronLeft, ChevronRight, Download, FileIcon, FolderIcon, Loader2, MoreVertical, Trash2} from 'lucide-react';
import {ChevronLeft, ChevronRight, Download, Eye, FileIcon, FolderIcon, Loader2, MoreVertical, Trash2} from 'lucide-react';
import {Select, SelectOption} from '@/components/ui/select';
import {formatBytes} from '@/lib/utils';
import {formatRelativeTime, getFileType} from '@/lib/file-utils';
import {formatBytes, formatRelativeTime} from '@/lib/file-utils';
import type {S3Object} from '@/types';
interface ObjectsTableProps {
bucketName: string;
objects: S3Object[];
currentPath: string;
searchQuery: string;
@@ -41,6 +42,7 @@ type SortColumn = 'name' | 'size' | 'modified';
type SortDirection = 'asc' | 'desc';
export function ObjectsTable({
bucketName,
objects,
currentPath,
searchQuery,
@@ -59,6 +61,7 @@ export function ObjectsTable({
initialPageToken,
initialItemsPerPage,
}: ObjectsTableProps) {
const navigate = useNavigate();
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [filteredObjects, setFilteredObjects] = useState<S3Object[]>([]);
@@ -117,16 +120,22 @@ export function ObjectsTable({
return sorted;
};
// Effect 1: Apply client-side filtering and sorting (NO pagination reset)
useEffect(() => {
const filtered = objects.filter((obj) =>
obj.key.toLowerCase().includes(searchQuery.toLowerCase())
);
const sorted = sortObjects(filtered);
setFilteredObjects(sorted);
// Reset pagination when path/search changes
// Do NOT reset pagination - search/sort are client-side operations
}, [searchQuery, objects, sortColumn, sortDirection]);
// Effect 2: Reset pagination ONLY on path navigation
useEffect(() => {
setPageTokens([undefined]);
setCurrentPageIndex(0);
}, [searchQuery, objects, sortColumn, sortDirection, currentPath]);
}, [currentPath]);
// Update page tokens when we get a new next token
useEffect(() => {
@@ -273,14 +282,17 @@ export function ObjectsTable({
{obj.key.replace(currentPath, '').replace('/', '')}
</button>
) : (
<span className="font-medium">
<button
onClick={() => navigate(`/buckets/${bucketName}/objects/${encodeURIComponent(obj.key)}`)}
className="font-medium cursor-pointer hover:underline hover:text-primary"
>
{obj.key.replace(currentPath, '')}
</span>
</button>
)}
</div>
</TableCell>
<TableCell className="hidden sm:table-cell">
{obj.isFolder ? 'Directory' : getFileType(obj.key.replace(currentPath, ''))}
{obj.isFolder ? 'Directory' : (obj.contentType || 'application/octet-stream')}
</TableCell>
<TableCell className="hidden md:table-cell">
{obj.storageClass && (
@@ -349,6 +361,10 @@ export function ObjectsTable({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/buckets/${bucketName}/objects/${encodeURIComponent(obj.key)}`)}>
<Eye className="h-4 w-4" />
View Details
</DropdownMenuItem>
<DropdownMenuItem>
<Download className="h-4 w-4" />
Download
@@ -0,0 +1,115 @@
import { CheckCircle, Upload, AlertCircle, Loader2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import type { UploadTask } from '@/types';
interface UploadProgressProps {
tasks: UploadTask[];
}
export function UploadProgress({ tasks }: UploadProgressProps) {
if (tasks.length === 0) return null;
const completedCount = tasks.filter(t => t.status === 'completed').length;
const errorCount = tasks.filter(t => t.status === 'error').length;
const totalCount = tasks.length;
const processedCount = completedCount + errorCount;
const allDone = processedCount === totalCount;
// Find currently uploading file
const currentFile = tasks.find(t => t.status === 'uploading');
const currentFileName = currentFile?.key.split('/').pop() || currentFile?.key || 'Processing...';
// File-based progress plus contribution from current upload
const baseProgress = (processedCount / totalCount) * 100;
const currentFileContribution = currentFile
? (currentFile.progress / 100) * (1 / totalCount) * 100
: 0;
const overallProgress = Math.min(baseProgress + currentFileContribution, 100);
return (
<Card className="border-primary/20 shadow-md">
<CardContent className="pt-6">
<div className="space-y-4">
{/* Header with icon and status */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg flex-shrink-0 ${
allDone
? 'bg-green-500/10 dark:bg-green-500/20'
: errorCount > 0
? 'bg-yellow-500/10 dark:bg-yellow-500/20'
: 'bg-primary/10 dark:bg-primary/20'
}`}>
{allDone ? (
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-500" />
) : errorCount > 0 ? (
<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-500" />
) : (
<Loader2 className="h-5 w-5 text-primary animate-spin" />
)}
</div>
<div className="min-w-0">
<div className="font-semibold text-sm">
{allDone ? 'Upload Complete' : 'Uploading Files'}
</div>
<div className="text-xs text-muted-foreground">
{processedCount} of {totalCount} files
</div>
</div>
</div>
<div className="text-right flex-shrink-0">
<div className={`text-2xl font-bold tabular-nums ${
allDone ? 'text-green-600 dark:text-green-500' : 'text-primary'
}`}>
{Math.round(overallProgress)}%
</div>
</div>
</div>
{/* Progress bar with gradient */}
<div className="space-y-2">
{!allDone && currentFile && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Upload className="h-3.5 w-3.5 flex-shrink-0" />
<span className="truncate flex-1" title={currentFileName}>
{currentFileName}
</span>
</div>
)}
<div className="relative w-full bg-secondary rounded-full h-3 overflow-hidden">
<div
className={`h-full transition-all duration-300 ease-out relative bg-green-500 dark:bg-green-600`}
style={{ width: `${overallProgress}%` }}
>
{/* Animated shimmer effect */}
{!allDone && overallProgress > 0 && (
<div
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 dark:via-white/25 to-transparent animate-shimmer"
/>
)}
</div>
</div>
</div>
{/* Error indicator with icon */}
{errorCount > 0 && (
<div className="flex items-center gap-2 text-xs bg-red-500/10 dark:bg-red-500/20 text-red-700 dark:text-red-400 rounded-md px-3 py-2 border border-red-200 dark:border-red-900/50">
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0" />
<span>
{errorCount} file{errorCount > 1 ? 's' : ''} failed to upload
</span>
</div>
)}
{/* Success message */}
{allDone && errorCount === 0 && (
<div className="flex items-center gap-2 text-xs bg-green-500/10 dark:bg-green-500/20 text-green-700 dark:text-green-400 rounded-md px-3 py-2 border border-green-200 dark:border-green-900/50">
<CheckCircle className="h-3.5 w-3.5 flex-shrink-0" />
<span>All files uploaded successfully</span>
</div>
)}
</div>
</CardContent>
</Card>
);
}
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react';
import {Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip} from 'recharts';
import type {BucketUsage} from '@/types';
import {formatBytes} from '@/lib/utils';
import {formatBytes} from '@/lib/file-utils';
import {chartColorPalette, getTextColor, getTooltipStyle} from '@/lib/chart-colors';
interface BucketUsageChartProps {
@@ -1,66 +0,0 @@
import {useEffect, useState} from 'react';
import {Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import type {ClusterHealth} from '@/types';
import {getGridColor, getTextColor, getTooltipStyle, grafanaColors} from '@/lib/chart-colors';
interface ClusterHealthChartProps {
data: ClusterHealth;
}
export function ClusterHealthChart({ data }: ClusterHealthChartProps) {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const checkDarkMode = () => {
setIsDark(document.documentElement.classList.contains('dark'));
};
checkDarkMode();
const observer = new MutationObserver(checkDarkMode);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
const colors = isDark ? grafanaColors.dark : grafanaColors.light;
const textColor = getTextColor(isDark);
const gridColor = getGridColor(isDark);
const tooltipStyle = getTooltipStyle(isDark);
const unhealthyColor = isDark ? '#e8e8e8' : '#d1d5db';
const chartData = [
{
metric: 'Nodes',
healthy: data.storageNodesUp,
unhealthy: data.storageNodes - data.storageNodesUp,
},
{
metric: 'Partitions',
healthy: data.partitionsAllOk,
unhealthy: data.partitions - data.partitionsAllOk,
},
{
metric: 'Connected',
healthy: data.connectedNodes,
unhealthy: data.knownNodes - data.connectedNodes,
},
];
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} />
<XAxis dataKey="metric" stroke={textColor} />
<YAxis stroke={textColor} />
<Tooltip
contentStyle={tooltipStyle as React.CSSProperties}
labelStyle={{ color: textColor }}
/>
<Legend wrapperStyle={{ color: textColor }} />
<Bar dataKey="healthy" stackId="a" fill={colors.green} name="Healthy" />
<Bar dataKey="unhealthy" stackId="a" fill={unhealthyColor} name="Unhealthy" />
</BarChart>
</ResponsiveContainer>
);
}
@@ -1,60 +0,0 @@
import { useEffect, useState } from 'react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import type { RequestMetrics } from '@/types';
import { grafanaColors, getTextColor, getGridColor, getTooltipStyle } from '@/lib/chart-colors';
interface RequestMetricsChartProps {
data: RequestMetrics;
}
export function RequestMetricsChart({ data }: RequestMetricsChartProps) {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const checkDarkMode = () => {
setIsDark(document.documentElement.classList.contains('dark'));
};
checkDarkMode();
const observer = new MutationObserver(checkDarkMode);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
const colors = isDark ? grafanaColors.dark : grafanaColors.light;
const textColor = getTextColor(isDark);
const gridColor = getGridColor(isDark);
const tooltipStyle = getTooltipStyle(isDark);
const chartData = [
{
name: 'Requests (24h)',
GET: data.getRequests,
PUT: data.putRequests,
DELETE: data.deleteRequests,
LIST: data.listRequests,
},
];
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} />
<XAxis dataKey="name" stroke={textColor} />
<YAxis stroke={textColor} />
<Tooltip
formatter={(value) => (value as number).toLocaleString()}
contentStyle={tooltipStyle as React.CSSProperties}
labelStyle={{ color: textColor }}
/>
<Legend wrapperStyle={{ color: textColor }} />
<Bar dataKey="GET" stackId="a" fill={colors.blue} />
<Bar dataKey="PUT" stackId="a" fill={colors.green} />
<Bar dataKey="DELETE" stackId="a" fill={colors.red} />
<Bar dataKey="LIST" stackId="a" fill={colors.orange} />
</BarChart>
</ResponsiveContainer>
);
}
+32 -12
View File
@@ -1,6 +1,8 @@
import {Link, useLocation} from 'react-router-dom';
import {cn} from '@/lib/utils';
import {Database, Key, LayoutDashboard, Server} from 'lucide-react';
import {Database, Key, LayoutDashboard, LogOut, Server, User} from 'lucide-react';
import {useAuthStore} from '@/store/auth-store';
import {Button} from '@/components/ui/button';
interface NavItem {
title: string;
@@ -38,6 +40,11 @@ interface SidebarProps {
export function Sidebar({ isOpen, onClose }: SidebarProps) {
const location = useLocation();
const { user, config, logout } = useAuthStore();
const handleLogout = () => {
logout();
};
return (
<div
@@ -76,17 +83,30 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
);
})}
</nav>
{/*<div className="border-t p-4">*/}
{/* <div className="flex items-center gap-3 rounded-lg bg-muted px-3 py-2">*/}
{/* <div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">*/}
{/* AD*/}
{/* </div>*/}
{/* <div className="flex-1 overflow-hidden">*/}
{/* <p className="text-sm font-medium truncate">Admin User</p>*/}
{/* <p className="text-xs text-muted-foreground truncate">admin@garage.local</p>*/}
{/* </div>*/}
{/* </div>*/}
{/*</div>*/}
{config && (config.admin.enabled || config.oidc.enabled) && user && (
<div className="border-t p-4 space-y-2">
<div className="flex items-center gap-3 rounded-lg bg-muted px-3 py-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">
<User className="h-4 w-4" />
</div>
<div className="flex-1 overflow-hidden">
<p className="text-sm font-medium truncate">{user.name || user.username}</p>
{user.email && (
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
)}
</div>
</div>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={handleLogout}
>
<LogOut className="mr-2 h-4 w-4" />
Logout
</Button>
</div>
)}
</div>
);
}
+1 -2
View File
@@ -1,3 +1,2 @@
export { useDashboardData } from './useApi';
export { useBuckets } from './useBuckets';
export { useDashboardData, useBuckets } from './useApi';
export { useBucketObjects } from './useBucketObjects';
+90 -26
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { objectsApi } from '@/lib/api';
import type { S3Object } from '@/types';
import type { S3Object, UploadTask } from '@/types';
import { toast } from 'sonner';
export function useBucketObjects(bucketName: string | null, currentPath: string = '') {
@@ -14,6 +14,7 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
const [itemsPerPage, setItemsPerPage] = useState(25);
const [currentContinuationToken, setCurrentContinuationToken] = useState<string | undefined>(undefined);
const [previousPath, setPreviousPath] = useState<string>(currentPath);
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
const fetchObjects = useCallback(async (continuationToken?: string, isRefresh = false, isNav = false) => {
if (!bucketName) return;
@@ -57,41 +58,103 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
const uploadFiles = useCallback(async (files: File[]) => {
if (!bucketName) return false;
try {
// Check if files are from a folder upload
const hasRelativePaths = files.some((file: any) => file.webkitRelativePath);
// Check if files are from a folder upload
const hasRelativePaths = files.some((file: any) => file.webkitRelativePath);
// Get unique folders from the files
const folders = new Set<string>();
files.forEach((file: any) => {
if (file.webkitRelativePath) {
const parts = file.webkitRelativePath.split('/');
if (parts.length > 1) {
folders.add(parts[0]);
}
// Get unique folders from the files
const folders = new Set<string>();
files.forEach((file: any) => {
if (file.webkitRelativePath) {
const parts = file.webkitRelativePath.split('/');
if (parts.length > 1) {
folders.add(parts[0]);
}
}
});
// Initialize upload tasks
const tasks: UploadTask[] = files.map((file, index) => {
const relativePath = (file as any).webkitRelativePath || file.name;
const key = currentPath ? `${currentPath}${relativePath}` : relativePath;
return {
id: `${Date.now()}-${index}`,
file,
key,
bucket: bucketName,
progress: 0,
status: 'pending' as const,
};
});
setUploadTasks(tasks);
// Upload files with progress tracking and error handling
let successCount = 0;
let errorCount = 0;
// Upload files one by one
const concurrency = 1;
const uploadPromises: Promise<void>[] = [];
for (let i = 0; i < tasks.length; i += concurrency) {
const batch = tasks.slice(i, Math.min(i + concurrency, tasks.length));
const batchPromises = batch.map(async (task) => {
try {
// Update task status to uploading
setUploadTasks(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'uploading' as const } : t
));
await objectsApi.upload(bucketName, task.key, task.file, (progress) => {
setUploadTasks(prev => prev.map(t =>
t.id === task.id ? { ...t, progress } : t
));
});
// Update task status to completed
setUploadTasks(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'completed' as const, progress: 100 } : t
));
successCount++;
} catch (error) {
// Update task status to error but continue with other uploads
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
setUploadTasks(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'error' as const, error: errorMessage } : t
));
errorCount++;
console.error(`Failed to upload ${task.key}:`, error);
}
});
for (const file of files) {
// Use webkitRelativePath if available (for folder uploads), otherwise use file.name
const relativePath = (file as any).webkitRelativePath || file.name;
const key = currentPath ? `${currentPath}${relativePath}` : relativePath;
await objectsApi.upload(bucketName, key, file);
}
uploadPromises.push(...batchPromises);
await Promise.all(batchPromises);
}
await Promise.all(uploadPromises);
// Show summary toast
if (errorCount === 0) {
if (hasRelativePaths && folders.size > 0) {
const folderNames = Array.from(folders).join(', ');
toast.success(`Successfully uploaded ${files.length} file${files.length > 1 ? 's' : ''} from ${folders.size} folder${folders.size > 1 ? 's' : ''} (${folderNames})`);
toast.success(`Successfully uploaded ${successCount} file${successCount > 1 ? 's' : ''} from ${folders.size} folder${folders.size > 1 ? 's' : ''} (${folderNames})`);
} else {
toast.success(`Successfully uploaded ${files.length} file${files.length > 1 ? 's' : ''}`);
toast.success(`Successfully uploaded ${successCount} file${successCount > 1 ? 's' : ''}`);
}
await fetchObjects(currentContinuationToken, true);
return true;
} catch (error) {
console.error('Upload error:', error);
return false;
} else if (successCount > 0) {
toast.warning(`Uploaded ${successCount} file${successCount > 1 ? 's' : ''}, ${errorCount} failed`);
} else {
toast.error(`Failed to upload ${errorCount} file${errorCount > 1 ? 's' : ''}`);
}
// Clear upload tasks after a delay
setTimeout(() => {
setUploadTasks([]);
}, 3000);
await fetchObjects(currentContinuationToken, true);
return successCount > 0;
}, [bucketName, currentPath, currentContinuationToken, fetchObjects]);
const deleteObject = useCallback(async (key: string) => {
@@ -160,6 +223,7 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
setItemsPerPage,
fetchObjects,
uploadFiles,
uploadTasks,
deleteObject,
deleteMultipleObjects,
createDirectory,
-77
View File
@@ -1,77 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { bucketsApi } from '@/lib/api';
import type { Bucket } from '@/types';
import { toast } from 'sonner';
export function useBuckets() {
const [buckets, setBuckets] = useState<Bucket[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchBuckets = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const data = await bucketsApi.list();
setBuckets(data);
} catch (err) {
setError(err as Error);
console.error('Failed to fetch buckets:', err);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchBuckets();
}, [fetchBuckets]);
const createBucket = useCallback(async (name: string, region?: string) => {
try {
await bucketsApi.create(name, region);
toast.success(`Bucket "${name}" created successfully`);
await fetchBuckets();
return true;
} catch (error) {
console.error('Create bucket error:', error);
return false;
}
}, [fetchBuckets]);
const deleteBucket = useCallback(async (name: string) => {
try {
await bucketsApi.delete(name);
toast.success(`Bucket "${name}" deleted successfully`);
await fetchBuckets();
return true;
} catch (error) {
console.error('Delete bucket error:', error);
return false;
}
}, [fetchBuckets]);
const grantPermission = useCallback(async (
bucketName: string,
accessKeyId: string,
permissions: { read: boolean; write: boolean; owner: boolean }
) => {
try {
await bucketsApi.grantPermission(bucketName, accessKeyId, permissions);
toast.success('Permissions granted successfully');
return true;
} catch (error) {
console.error('Grant permission error:', error);
return false;
}
}, []);
return {
buckets,
isLoading,
error,
fetchBuckets,
createBucket,
deleteBucket,
grantPermission,
};
}
+96 -6
View File
@@ -15,6 +15,13 @@ import type {
S3Object,
StorageMetrics,
} from '@/types';
import type { AuthUser } from '@/types/auth';
// Helper function to encode object keys for URLs
// Encodes the entire key including slashes to ensure proper handling of special characters
const encodeObjectKey = (key: string): string => {
return encodeURIComponent(key);
};
const api = axios.create({
baseURL: '/api',
@@ -23,6 +30,14 @@ const api = axios.create({
},
});
// Separate axios instance for auth endpoints (which are not under /api)
const authApiClient = axios.create({
baseURL: '/auth',
headers: {
'Content-Type': 'application/json',
},
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('auth-token');
if (token) {
@@ -31,6 +46,14 @@ api.interceptors.request.use((config) => {
return config;
});
authApiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('auth-token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => {
// If response has success=false in data, treat it as an error
@@ -50,6 +73,19 @@ api.interceptors.response.use(
return response;
},
(error) => {
// Handle 401 Unauthorized - redirect to login
if (error.response?.status === 401) {
// Clear auth token
localStorage.removeItem('auth-token');
// Only redirect if not already on login page
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
return Promise.reject(error);
}
// Handle axios errors
if (error.response) {
// Server responded with error status
@@ -84,6 +120,44 @@ api.interceptors.response.use(
}
);
// Auth API
export const authApi = {
getConfig: async () => {
const response = await authApiClient.get<{
admin: { enabled: boolean };
oidc: { enabled: boolean; provider?: string };
}>('/config');
return response;
},
loginAdmin: async (username: string, password: string) => {
const response = await authApiClient.post<{ success: boolean; token: string; user: AuthUser }>('/login', {
username,
password,
});
return response;
},
me: async () => {
const response = await authApiClient.get<{ success: boolean; user: AuthUser }>('/me');
return response;
},
logoutAdmin: async () => {
// For admin, just clear local storage (no server logout needed)
return Promise.resolve();
},
logoutOIDC: async () => {
const response = await authApiClient.post('/oidc/logout');
return response;
},
loginOIDC: () => {
window.location.href = '/auth/oidc/login';
},
};
// Bucket API
export const bucketsApi = {
list: async (): Promise<Bucket[]> => {
@@ -139,6 +213,7 @@ export const objectsApi = {
size: obj.size,
lastModified: obj.last_modified,
etag: obj.etag,
contentType: obj.content_type,
storageClass: obj.storage_class,
isFolder: false,
})) || [];
@@ -161,23 +236,38 @@ export const objectsApi = {
},
get: async (bucket: string, key: string): Promise<Blob> => {
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`, {
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}`, {
responseType: 'blob'
});
return response.data;
},
getMetadata: async (bucket: string, key: string): Promise<ObjectMetadata> => {
const response = await api.head(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`);
return response.data.data;
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}/metadata`);
const data = response.data.data;
return {
key: data.key,
size: data.size,
lastModified: data.last_modified,
contentType: data.content_type,
etag: data.etag,
storageClass: data.storage_class,
metadata: data.metadata,
};
},
upload: async (bucket: string, key: string, file: File): Promise<void> => {
upload: async (bucket: string, key: string, file: File, onProgress?: (progress: number) => void): Promise<void> => {
const formData = new FormData();
formData.append('file', file);
formData.append('key', key);
await api.post(`/v1/buckets/${bucket}/objects`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
onProgress(progress);
}
},
});
},
@@ -194,7 +284,7 @@ export const objectsApi = {
},
delete: async (bucket: string, key: string): Promise<void> => {
await api.delete(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`);
await api.delete(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}`);
},
deleteMultiple: async (bucket: string, keys: string[], prefix?: string): Promise<void> => {
@@ -203,7 +293,7 @@ export const objectsApi = {
},
getPresignedUrl: async (bucket: string, key: string, expiresIn: number = 3600): Promise<string> => {
const response = await api.post(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}/presign`, {}, {
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}/presign`, {
params: { expires_in: expiresIn }
});
return response.data.data.url;
+15
View File
@@ -98,3 +98,18 @@ export function formatRelativeTime(date: Date): string {
if (diffDays < 30) return `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) !== 1 ? 's' : ''} ago`;
return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) !== 1 ? 's' : ''} ago`;
}
/**
* Format bytes to human-readable size
*/
export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
-12
View File
@@ -5,18 +5,6 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat('en-US', {
+267 -57
View File
@@ -58,12 +58,6 @@ export function AccessControl() {
const [permissionOwner, setPermissionOwner] = useState(false);
// Key settings state (activation/expiration)
// const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
// const [settingsKey, setSettingsKey] = useState<AccessKey | null>(null);
// const [keyStatus, setKeyStatus] = useState<'active' | 'inactive'>('active');
// const [expirationDate, setExpirationDate] = useState<string>('');
// const [neverExpires, setNeverExpires] = useState(true);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [settingsKey, setSettingsKey] = useState<AccessKey | null>(null);
const [keyStatus, setKeyStatus] = useState<'active' | 'inactive'>('active');
@@ -75,6 +69,14 @@ export function AccessControl() {
const [revealedSecretKey, setRevealedSecretKey] = useState<string>('');
const [isLoadingSecretKey, setIsLoadingSecretKey] = useState(false);
// Key details dialog state
const [keyDetailsDialogOpen, setKeyDetailsDialogOpen] = useState(false);
const [viewingKey, setViewingKey] = useState<AccessKey | null>(null);
const [detailsSecretKey, setDetailsSecretKey] = useState<string>('');
const [isLoadingDetailsSecretKey, setIsLoadingDetailsSecretKey] = useState(false);
const [copiedAccessKeyId, setCopiedAccessKeyId] = useState(false);
const [copiedSecretKey, setCopiedSecretKey] = useState(false);
useEffect(() => {
const fetchKeys = async () => {
try {
@@ -341,6 +343,25 @@ export function AccessControl() {
return perms.join(', ') || 'None';
};
const handleRowClick = async (key: AccessKey) => {
setViewingKey(key);
setKeyDetailsDialogOpen(true);
setDetailsSecretKey('');
setIsLoadingDetailsSecretKey(true);
setCopiedAccessKeyId(false);
setCopiedSecretKey(false);
// Fetch the secret key immediately
try {
const secretKey = await accessApi.getSecretKey(key.accessKeyId);
setDetailsSecretKey(secretKey);
} catch (error) {
console.error('Failed to fetch secret key:', error);
} finally {
setIsLoadingDetailsSecretKey(false);
}
};
return (
<div>
<Header
@@ -418,7 +439,6 @@ export function AccessControl() {
<TableHead className="hidden sm:table-cell">Access Key ID</TableHead>
<TableHead>Status</TableHead>
<TableHead className="hidden md:table-cell">Created</TableHead>
<TableHead className="hidden lg:table-cell">Last Used</TableHead>
<TableHead className="hidden md:table-cell">Permissions</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
@@ -426,7 +446,7 @@ export function AccessControl() {
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12">
<TableCell colSpan={6} className="text-center py-12">
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Loading API keys...</span>
@@ -435,24 +455,36 @@ export function AccessControl() {
</TableRow>
) : filteredKeys.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-muted-foreground">
<TableCell colSpan={6} className="text-center py-12 text-muted-foreground">
{searchQuery ? 'No keys found matching your search' : 'No API keys yet'}
</TableCell>
</TableRow>
) : (
filteredKeys.map((key) => (
<TableRow key={key.accessKeyId}>
<TableRow
key={key.accessKeyId}
onClick={() => handleRowClick(key)}
className="cursor-pointer hover:bg-muted/50"
>
<TableCell className="font-medium truncate max-w-[150px]">{key.name}</TableCell>
<TableCell className="hidden sm:table-cell">
<div className="flex items-center gap-2">
<code className="text-xs bg-muted px-2 py-1 rounded truncate max-w-[150px] block">
<code
className="text-xs bg-muted px-2 py-1 rounded truncate max-w-[150px] block cursor-pointer hover:bg-muted/80 transition-colors"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(key.accessKeyId);
toast.success('Access Key ID copied to clipboard');
}}
>
{key.accessKeyId}
</code>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0"
onClick={() => {
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(key.accessKeyId);
toast.success('Access Key ID copied to clipboard');
}}
@@ -467,9 +499,6 @@ export function AccessControl() {
</Badge>
</TableCell>
<TableCell className="hidden md:table-cell">{formatDate(key.createdAt)}</TableCell>
<TableCell className="hidden lg:table-cell">
{key.lastUsed ? formatDate(key.lastUsed) : 'Never'}
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex flex-wrap gap-1">
{key.permissions.slice(0, 2).map((perm, idx) => (
@@ -487,7 +516,7 @@ export function AccessControl() {
)}
</div>
</TableCell>
<TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" size="icon">
@@ -578,7 +607,13 @@ export function AccessControl() {
<div className="space-y-2">
<label className="text-sm font-medium">Access Key ID</label>
<div className="flex items-center gap-2">
<code className="text-sm bg-muted px-3 py-2 rounded flex-1">
<code
className="text-sm bg-muted px-3 py-2 rounded flex-1 cursor-pointer hover:bg-muted/80 transition-colors"
onClick={() => {
navigator.clipboard.writeText(newlyCreatedKey.accessKeyId);
toast.success('Access Key ID copied to clipboard');
}}
>
{newlyCreatedKey.accessKeyId}
</code>
<Button
@@ -596,7 +631,15 @@ export function AccessControl() {
<div className="space-y-2">
<label className="text-sm font-medium">Secret Access Key</label>
<div className="flex items-center gap-2">
<code className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all">
<code
className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all cursor-pointer hover:bg-muted/80 transition-colors"
onClick={() => {
if (newlyCreatedKey.secretKey) {
navigator.clipboard.writeText(newlyCreatedKey.secretKey);
toast.success('Secret Access Key copied to clipboard');
}
}}
>
{newlyCreatedKey.secretKey}
</code>
<Button
@@ -613,14 +656,14 @@ export function AccessControl() {
</Button>
</div>
</div>
<div className="border rounded-lg p-4 bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-900">
<div className="border rounded-lg p-4 bg-orange-100 border-orange-300 dark:bg-orange-950/20 dark:border-orange-900">
<div className="flex gap-2">
<ShieldX className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0" />
<ShieldX className="h-5 w-5 text-orange-700 dark:text-orange-500 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
<p className="text-sm font-medium text-orange-950 dark:text-orange-200">
Important: Save This Key Now
</p>
<p className="text-xs text-yellow-700 dark:text-yellow-300">
<p className="text-xs text-orange-900 dark:text-orange-300">
This is the only time you'll see the secret access key. Make sure to copy and save it securely.
If you lose it, you'll need to create a new key.
</p>
@@ -794,7 +837,15 @@ export function AccessControl() {
<div className="space-y-2">
<label className="text-sm font-medium">Access Key ID</label>
<div className="flex items-center gap-2">
<code className="text-sm bg-muted px-3 py-2 rounded flex-1">
<code
className="text-sm bg-muted px-3 py-2 rounded flex-1 cursor-pointer hover:bg-muted/80 transition-colors"
onClick={() => {
if (selectedKey?.accessKeyId) {
navigator.clipboard.writeText(selectedKey.accessKeyId);
toast.success('Access Key ID copied to clipboard');
}
}}
>
{selectedKey?.accessKeyId}
</code>
<Button
@@ -821,7 +872,15 @@ export function AccessControl() {
</div>
) : (
<>
<code className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all">
<code
className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all cursor-pointer hover:bg-muted/80 transition-colors"
onClick={() => {
if (revealedSecretKey) {
navigator.clipboard.writeText(revealedSecretKey);
toast.success('Secret Access Key copied to clipboard');
}
}}
>
{revealedSecretKey}
</code>
<Button
@@ -841,19 +900,6 @@ export function AccessControl() {
)}
</div>
</div>
<div className="border rounded-lg p-4 bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-900">
<div className="flex gap-2">
<ShieldX className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Security Warning
</p>
<p className="text-xs text-yellow-700 dark:text-yellow-300">
Keep this secret key secure. Anyone with access to it can perform operations on your behalf.
</p>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button onClick={() => setSecretKeyDialogOpen(false)}>
@@ -934,26 +980,6 @@ export function AccessControl() {
)}
</div>
</div>
{/* Current Status Display */}
<div className="border rounded-lg p-4 bg-muted/50">
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Current Status:</span>
<Badge variant={settingsKey?.status === 'active' ? 'default' : 'secondary'}>
{settingsKey?.status}
</Badge>
</div>
{settingsKey?.expiration && (
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Current Expiration:</span>
<span className="text-sm text-muted-foreground">
{formatDate(settingsKey.expiration)}
</span>
</div>
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSettingsDialogOpen(false)}>
@@ -966,6 +992,190 @@ export function AccessControl() {
</DialogContent>
</Dialog>
{/* Key Details Dialog */}
<Dialog open={keyDetailsDialogOpen} onOpenChange={setKeyDetailsDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>API Key Details</DialogTitle>
<DialogDescription>
View and manage your API key credentials and permissions
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Key Name and Status */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Key Name</label>
<div className="text-sm text-muted-foreground">{viewingKey?.name}</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Status</label>
<div>
<Badge variant={viewingKey?.status === 'active' ? 'default' : 'secondary'}>
{viewingKey?.status}
</Badge>
</div>
</div>
</div>
{/* Access Key ID */}
<div className="space-y-2">
<label className="text-sm font-medium">Access Key ID</label>
<div className="flex items-center gap-2">
<code
className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all cursor-pointer hover:bg-muted/80 transition-colors"
onClick={() => {
if (viewingKey?.accessKeyId) {
navigator.clipboard.writeText(viewingKey.accessKeyId);
setCopiedAccessKeyId(true);
setTimeout(() => setCopiedAccessKeyId(false), 2000);
toast.success('Access Key ID copied to clipboard');
}
}}
>
{viewingKey?.accessKeyId}
</code>
<Button
variant="outline"
size="sm"
onClick={() => {
if (viewingKey?.accessKeyId) {
navigator.clipboard.writeText(viewingKey.accessKeyId);
setCopiedAccessKeyId(true);
setTimeout(() => setCopiedAccessKeyId(false), 2000);
toast.success('Access Key ID copied to clipboard');
}
}}
>
{copiedAccessKeyId ? 'Copied' : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
{/* Secret Access Key */}
<div className="space-y-2">
<label className="text-sm font-medium">Secret Access Key</label>
<div className="flex items-center gap-2">
{isLoadingDetailsSecretKey ? (
<div className="flex items-center gap-2 text-muted-foreground flex-1 bg-muted px-3 py-2 rounded">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Loading secret key...</span>
</div>
) : (
<>
<code
className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all cursor-pointer hover:bg-muted/80 transition-colors"
onClick={() => {
if (detailsSecretKey) {
navigator.clipboard.writeText(detailsSecretKey);
setCopiedSecretKey(true);
setTimeout(() => setCopiedSecretKey(false), 2000);
toast.success('Secret Access Key copied to clipboard');
}
}}
>
{''.repeat(40)}
</code>
<Button
variant="outline"
size="sm"
onClick={() => {
if (detailsSecretKey) {
navigator.clipboard.writeText(detailsSecretKey);
setCopiedSecretKey(true);
setTimeout(() => setCopiedSecretKey(false), 2000);
toast.success('Secret Access Key copied to clipboard');
}
}}
disabled={!detailsSecretKey}
>
{copiedSecretKey ? 'Copied' : <Copy className="h-4 w-4" />}
</Button>
</>
)}
</div>
</div>
{/* Metadata */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Created</label>
<div className="text-sm text-muted-foreground">{viewingKey && formatDate(viewingKey.createdAt)}</div>
</div>
{viewingKey?.expiration && (
<div className="space-y-2">
<label className="text-sm font-medium">Expiration</label>
<div className="text-sm text-muted-foreground">{formatDate(viewingKey.expiration)}</div>
</div>
)}
</div>
{/* Bucket Permissions */}
<div className="space-y-3">
<label className="text-sm font-medium">Bucket Permissions</label>
{viewingKey && viewingKey.permissions.length > 0 ? (
<div className="border rounded-lg divide-y">
{viewingKey.permissions.map((perm, idx) => (
<div key={idx} className="p-3 flex items-center justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">{perm.bucketName}</div>
<div className="text-xs text-muted-foreground">
{formatPermissions(perm)}
</div>
</div>
<div className="flex gap-1">
{perm.read && (
<Badge variant="outline" className="text-xs">
Read
</Badge>
)}
{perm.write && (
<Badge variant="outline" className="text-xs">
Write
</Badge>
)}
{perm.owner && (
<Badge variant="outline" className="text-xs">
Owner
</Badge>
)}
</div>
</div>
))}
</div>
) : (
<div className="border rounded-lg p-6 text-center">
<p className="text-sm text-muted-foreground">
This key has no bucket permissions yet
</p>
</div>
)}
</div>
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button
variant="outline"
onClick={() => {
setKeyDetailsDialogOpen(false);
if (viewingKey) {
handleOpenEditPermissions(viewingKey);
}
}}
className="w-full sm:w-auto"
>
<Edit className="h-4 w-4" />
Edit Permissions
</Button>
<Button
onClick={() => setKeyDetailsDialogOpen(false)}
className="w-full sm:w-auto"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Permissions Dialog */}
<Dialog open={editPermissionsDialogOpen} onOpenChange={setEditPermissionsDialogOpen}>
<DialogContent className="max-w-2xl">
+39 -2
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Header } from '@/components/layout/header';
import { useBuckets } from '@/hooks/useBuckets';
import { useBuckets, useCreateBucket, useDeleteBucket, useGrantBucketPermission } from '@/hooks/useApi';
import { useBucketObjects } from '@/hooks/useBucketObjects';
import { BucketListView } from '@/components/buckets/BucketListView';
import { ObjectBrowserView } from '@/components/buckets/ObjectBrowserView';
@@ -51,7 +51,10 @@ export function Buckets() {
}, [searchParams]);
// Custom hooks
const { buckets, isLoading: bucketsLoading, createBucket, deleteBucket, grantPermission } = useBuckets();
const { data: buckets = [], isLoading: bucketsLoading } = useBuckets();
const createBucketMutation = useCreateBucket();
const deleteBucketMutation = useDeleteBucket();
const grantPermissionMutation = useGrantBucketPermission();
const {
objects,
isLoading: objectsLoading,
@@ -62,6 +65,7 @@ export function Buckets() {
itemsPerPage,
setItemsPerPage,
uploadFiles,
uploadTasks,
deleteObject,
deleteMultipleObjects,
createDirectory,
@@ -145,6 +149,38 @@ export function Buckets() {
}
};
// Wrapper functions for mutations to match dialog APIs
const createBucket = async (name: string, region?: string) => {
try {
await createBucketMutation.mutateAsync({ name, region });
return true;
} catch (error) {
return false;
}
};
const deleteBucket = async (name: string) => {
try {
await deleteBucketMutation.mutateAsync(name);
return true;
} catch (error) {
return false;
}
};
const grantPermission = async (
bucketName: string,
accessKeyId: string,
permissions: { read: boolean; write: boolean; owner: boolean }
) => {
try {
await grantPermissionMutation.mutateAsync({ bucketName, accessKeyId, permissions });
return true;
} catch (error) {
return false;
}
};
// If viewing a bucket's objects, show the object browser view
if (viewingBucket) {
return (
@@ -161,6 +197,7 @@ export function Buckets() {
onNavigateToFolder={handleNavigateToFolder}
onBackToBuckets={handleBackToBuckets}
onUploadFiles={uploadFiles}
uploadTasks={uploadTasks}
onDeleteObject={deleteObject}
onDeleteMultipleObjects={deleteMultipleObjects}
onCreateDirectory={createDirectory}
+1 -1
View File
@@ -1,6 +1,6 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {Header} from '@/components/layout/header';
import {formatBytes} from '@/lib/utils';
import {formatBytes} from '@/lib/file-utils';
import {Activity, AlertCircle, CheckCircle2, Clock, Cpu, Database, Info, Network, Server, XCircle,} from 'lucide-react';
import {useQuery} from '@tanstack/react-query';
import {garageApi} from '@/lib/api';
+1 -1
View File
@@ -1,6 +1,6 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {Header} from '@/components/layout/header';
import {formatBytes} from '@/lib/utils';
import {formatBytes} from '@/lib/file-utils';
import {AlertCircle, Database, FolderOpen, HardDrive, Server, Zap} from 'lucide-react';
import {BucketUsageChart} from '@/components/charts/BucketUsageChart';
import {useDashboardData} from '@/hooks/useApi';
+76
View File
@@ -0,0 +1,76 @@
import { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuthStore } from '@/store/auth-store';
import { BasicLoginForm } from '@/components/auth/BasicLoginForm';
import { OIDCLoginView } from '@/components/auth/OIDCLoginView';
import { LoadingSpinner } from '@/components/auth/LoadingSpinner';
export function Login() {
const { config, isLoading, initialize, isAuthenticated } = useAuthStore();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const loginSuccess = searchParams.get('login');
const returnUrl = searchParams.get('returnUrl') || '/';
useEffect(() => {
// Handle OIDC callback
if (loginSuccess === 'success') {
// OIDC login successful, re-initialize auth to fetch user
initialize().then(() => {
navigate(decodeURIComponent(returnUrl));
});
}
}, [loginSuccess, initialize, navigate, returnUrl]);
useEffect(() => {
// If already authenticated, redirect to return URL
if (isAuthenticated && !loginSuccess) {
navigate(decodeURIComponent(returnUrl));
}
}, [isAuthenticated, navigate, returnUrl, loginSuccess]);
if (isLoading || loginSuccess === 'success') {
return <LoadingSpinner />;
}
// No auth enabled, redirect to dashboard immediately
if (config && !config.admin.enabled && !config.oidc.enabled) {
navigate('/');
return null;
}
// Show login options based on what's enabled
const showAdmin = config?.admin.enabled || false;
const showOIDC = config?.oidc.enabled || false;
// If both are enabled, show both options in single modal
if (showAdmin && showOIDC) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<BasicLoginForm showOIDC={true} config={config} />
</div>
</div>
);
}
// Show only OIDC if enabled
if (showOIDC) {
return <OIDCLoginView />;
}
// Show only admin if enabled
if (showAdmin) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<BasicLoginForm />
</div>
</div>
);
}
// Still loading config
return <LoadingSpinner />;
}
+154
View File
@@ -0,0 +1,154 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { AuthConfig, AuthUser, AuthState } from '@/types/auth';
import { authApi } from '@/lib/api';
interface AuthStore extends AuthState {
config: AuthConfig | null;
// Actions
setUser: (user: AuthUser | null) => void;
setConfig: (config: AuthConfig) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
setAuthenticated: (authenticated: boolean) => void;
// Async actions
initialize: () => Promise<void>;
loginAdmin: (username: string, password: string) => Promise<void>;
loginOIDC: () => void;
logout: () => Promise<void>;
}
export const useAuthStore = create<AuthStore>()(
persist(
(set, get) => ({
user: null,
config: null,
isAuthenticated: false,
isLoading: true,
error: null,
setUser: (user) => set({ user, isAuthenticated: !!user }),
setConfig: (config) => set({ config }),
setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error }),
setAuthenticated: (isAuthenticated) => set({ isAuthenticated }),
initialize: async () => {
try {
set({ isLoading: true, error: null });
// Fetch auth configuration
const configResponse = await authApi.getConfig();
const config = configResponse.data as AuthConfig;
set({ config });
// If no auth is enabled, mark as authenticated immediately
if (!config.admin.enabled && !config.oidc.enabled) {
set({
isAuthenticated: true,
isLoading: false,
user: { username: 'guest' }
});
return;
}
// Try to get current user (check if already authenticated)
try {
const userResponse = await authApi.me();
const user = userResponse.data.user;
set({
user,
isAuthenticated: true,
isLoading: false
});
} catch (error) {
// Not authenticated - this is okay
set({
user: null,
isAuthenticated: false,
isLoading: false
});
}
} catch (error) {
console.error('Failed to initialize auth:', error);
set({
error: 'Failed to initialize authentication',
isLoading: false,
isAuthenticated: false
});
}
},
loginAdmin: async (username, password) => {
try {
set({ isLoading: true, error: null });
const response = await authApi.loginAdmin(username, password);
const { token, user } = response.data;
// Store token in localStorage
localStorage.setItem('auth-token', token);
// Update state
set({
user,
isAuthenticated: true,
isLoading: false,
error: null
});
} catch (error: any) {
const errorMessage = error.response?.data?.error?.message || 'Login failed';
set({
error: errorMessage,
isLoading: false,
isAuthenticated: false,
user: null
});
throw error; // Re-throw for form handling
}
},
loginOIDC: () => {
// Redirect to OIDC login endpoint
window.location.href = '/auth/oidc/login';
},
logout: async () => {
const { config } = get();
try {
// Call logout endpoint for OIDC mode
if (config?.oidc.enabled) {
await authApi.logoutOIDC();
} else if (config?.admin.enabled) {
await authApi.logoutAdmin();
}
} catch (error) {
console.error('Logout API call failed:', error);
}
// Clear local storage
localStorage.removeItem('auth-token');
// Clear state
set({
user: null,
isAuthenticated: false,
error: null
});
// Redirect to login page
window.location.href = '/login';
},
}),
{
name: 'auth-storage',
partialize: (state) => ({
user: state.user,
// Don't persist config, isLoading, or error
}),
}
)
);
+22
View File
@@ -0,0 +1,22 @@
export interface AuthConfig {
admin: {
enabled: boolean;
};
oidc: {
enabled: boolean;
provider?: string;
};
}
export interface AuthUser {
username: string;
email?: string;
name?: string;
}
export interface AuthState {
user: AuthUser | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
}
+2 -1
View File
@@ -33,6 +33,7 @@ export interface S3Object {
size: number;
lastModified: string;
etag?: string;
contentType?: string;
storageClass?: string;
isFolder?: boolean;
}
@@ -52,6 +53,7 @@ export interface ObjectMetadata {
lastModified: string;
contentType: string;
etag: string;
storageClass?: string;
metadata?: Record<string, string>;
versionId?: string;
}
@@ -62,7 +64,6 @@ export interface AccessKey {
name: string;
secretKey?: string;
createdAt: string;
lastUsed?: string;
status: 'active' | 'inactive';
permissions: BucketPermission[];
expiration?: string;
+9
View File
@@ -47,6 +47,15 @@ export default {
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
shimmer: {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(100%)' },
},
},
animation: {
shimmer: 'shimmer 2s infinite',
},
},
},
plugins: [],
+4
View File
@@ -18,6 +18,10 @@ export default defineConfig({
target: 'http://localhost:8080',
changeOrigin: true,
},
'/auth': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
+3 -2
View File
@@ -1,9 +1,10 @@
apiVersion: v2
name: garage-ui
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
icon: https://helm.noste.dev/garage.png
type: application
version: 0.1.2
appVersion: "v0.0.6"
version: 0.1.7
appVersion: "v0.0.11"
keywords:
- garage
- s3
+47 -18
View File
@@ -2,8 +2,8 @@
A Helm chart for deploying [Garage UI](https://github.com/Noooste/garage-ui), a modern web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) distributed object storage systems.
[![Version](https://img.shields.io/badge/version-0.1.1-blue.svg)](Chart.yaml)
[![App Version](https://img.shields.io/badge/app%20version-v0.0.6-green.svg)](Chart.yaml)
[![Version](https://img.shields.io/badge/version-0.1.7-blue.svg)](Chart.yaml)
[![App Version](https://img.shields.io/badge/app%20version-v0.0.11-green.svg)](Chart.yaml)
## Table of Contents
@@ -147,30 +147,53 @@ config:
environment: production # Environment (production/development/staging)
```
#### Authentication Modes
#### Authentication Configuration
**No Authentication** (default, suitable for private networks):
```yaml
config:
auth:
mode: "none"
admin:
enabled: false
oidc:
enabled: false
```
**Basic Authentication**:
**Admin Authentication** (username/password with JWT):
```yaml
config:
auth:
mode: "basic"
basic:
admin:
enabled: true
username: "admin"
password: "your-secure-password"
oidc:
enabled: false
```
**OIDC/SSO** (recommended for production):
```yaml
config:
auth:
mode: "oidc"
admin:
enabled: false
oidc:
enabled: true
provider_name: "Keycloak"
client_id: "garage-ui"
client_secret: "your-oidc-secret"
issuer_url: "https://auth.example.com/realms/master"
# ... additional OIDC settings
```
**Both Authentication Methods** (admin and OIDC simultaneously):
```yaml
config:
auth:
admin:
enabled: true
username: "admin"
password: "your-secure-password"
oidc:
enabled: true
provider_name: "Keycloak"
@@ -324,10 +347,10 @@ Install:
helm install garage-ui ./helm/garage-ui -f values-ingress.yaml
```
### Example 3: With Basic Authentication
### Example 3: With Admin Authentication
```yaml
# values-basic-auth.yaml
# values-admin-auth.yaml
config:
garage:
endpoint: "http://garage:3900"
@@ -335,10 +358,12 @@ config:
admin_token: "your-admin-token"
auth:
mode: "basic"
basic:
admin:
enabled: true
username: "admin"
password: "super-secret-password-change-me"
oidc:
enabled: false
ingress:
enabled: true
@@ -352,7 +377,7 @@ ingress:
Install:
```bash
helm install garage-ui ./helm/garage-ui -f values-basic-auth.yaml
helm install garage-ui ./helm/garage-ui -f values-admin-auth.yaml
```
### Example 4: Production Setup with OIDC (Keycloak)
@@ -372,7 +397,8 @@ config:
region: "us-east-1"
auth:
mode: "oidc"
admin:
enabled: false
oidc:
enabled: true
provider_name: "Keycloak"
@@ -640,7 +666,7 @@ Common issues:
- **Missing admin token**: Ensure `config.garage.admin_token` is set or `config.garage.existingSecret.name` points to a valid secret
- **Secret not found**: If using `existingSecret`, verify the secret exists: `kubectl get secret <secret-name>`
- **Unreachable Garage**: Verify endpoints are accessible from within the cluster
- **Invalid OIDC config**: Check all OIDC URLs and credentials when using `auth.mode: oidc`
- **Invalid OIDC config**: Check all OIDC URLs and credentials when using `auth.oidc.enabled: true`
### Cannot Access the UI
@@ -684,14 +710,17 @@ curl http://garage:3903/health
### Authentication Issues
**Basic Auth:**
- Verify username/password in `config.auth.basic`
**Admin Auth:**
- Verify username/password in `config.auth.admin`
- Ensure `config.auth.admin.enabled` is set to `true`
- Check browser developer tools for 401 errors
- Verify JWT token is being sent in Authorization header
**OIDC:**
- Ensure `config.auth.oidc.enabled` is set to `true`
- Verify all OIDC URLs are accessible
- Check OIDC provider logs
- Ensure redirect URI is registered: `https://your-domain/auth/callback`
- Ensure redirect URI is registered: `https://your-domain/auth/oidc/callback`
- Verify client ID and secret
### View Application Logs
+4
View File
@@ -8,4 +8,8 @@ data:
config.yaml: |
{{- $config := deepCopy .Values.config }}
{{- $_ := unset $config.garage "admin_token" }}
{{- $_2 := unset $config.auth.admin "password" }}
{{- $_3 := unset $config.auth.oidc "client_secret" }}
{{- $_4 := unset $config.auth "jwt_private_key" }}
{{- $_5 := unset $config.auth "jwt_private_key_secret" }}
{{- $config | toYaml | nindent 4 }}
+37 -1
View File
@@ -33,7 +33,7 @@ spec:
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 8080
containerPort: {{ .Values.config.server.port }}
protocol: TCP
env:
- name: GARAGE_UI_GARAGE_ADMIN_TOKEN
@@ -46,6 +46,42 @@ spec:
name: {{ include "garage-ui.fullname" . }}-admin-token
key: admin-token
{{- end }}
{{- if or .Values.config.auth.jwt_private_key .Values.config.auth.jwt_private_key_secret.name }}
- name: GARAGE_UI_AUTH_JWT_PRIVATE_KEY
valueFrom:
secretKeyRef:
{{- if .Values.config.auth.jwt_private_key_secret.name }}
name: {{ .Values.config.auth.jwt_private_key_secret.name }}
key: {{ .Values.config.auth.jwt_private_key_secret.key }}
{{- else }}
name: {{ include "garage-ui.fullname" . }}-jwt-key
key: jwt-key
{{- end }}
{{- end }}
{{- if .Values.config.auth.oidc.enabled }}
- name: GARAGE_UI_AUTH_OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
{{- if .Values.config.auth.oidc.existingSecret.name }}
name: {{ .Values.config.auth.oidc.existingSecret.name }}
key: {{ .Values.config.auth.oidc.existingSecret.key }}
{{- else }}
name: {{ include "garage-ui.fullname" . }}-oidc-client-secret
key: client-secret
{{- end }}
{{- end }}
{{- if .Values.config.auth.admin.enabled }}
- name: GARAGE_UI_AUTH_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
{{- if .Values.config.auth.admin.existingSecret.name }}
name: {{ .Values.config.auth.admin.existingSecret.name }}
key: {{ .Values.config.auth.admin.existingSecret.key }}
{{- else }}
name: {{ include "garage-ui.fullname" . }}-admin-password
key: admin-password
{{- end }}
{{- end }}
{{- if .Values.livenessProbe.enabled }}
livenessProbe:
httpGet:
+40
View File
@@ -9,3 +9,43 @@ type: Opaque
data:
admin-token: {{ .Values.config.garage.admin_token | b64enc | quote }}
{{- end }}
---
{{- if and .Values.config.auth.admin.enabled (not .Values.config.auth.admin.existingSecret.name) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "garage-ui.fullname" . }}-admin-password
labels:
{{- include "garage-ui.labels" . | nindent 4 }}
type: Opaque
data:
{{- if .Values.config.auth.admin.password }}
admin-password: {{ .Values.config.auth.admin.password | b64enc | quote }}
{{- else }}
admin-password: {{ randAlphaNum 32 | b64enc | quote }}
{{- end }}
{{- end }}
---
{{- if and .Values.config.auth.oidc.enabled (not .Values.config.auth.oidc.existingSecret.name) .Values.config.auth.oidc.client_secret }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "garage-ui.fullname" . }}-oidc-client-secret
labels:
{{- include "garage-ui.labels" . | nindent 4 }}
type: Opaque
data:
client-secret: {{ .Values.config.auth.oidc.client_secret | b64enc | quote }}
{{- end }}
---
{{- if and (not .Values.config.auth.jwt_private_key_secret.name) .Values.config.auth.jwt_private_key }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "garage-ui.fullname" . }}-jwt-key
labels:
{{- include "garage-ui.labels" . | nindent 4 }}
type: Opaque
data:
jwt-key: {{ .Values.config.auth.jwt_private_key | b64enc | quote }}
{{- end }}
+854
View File
@@ -0,0 +1,854 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Garage UI Helm Chart Values",
"description": "Configuration values for the Garage UI Helm chart deployment",
"type": "object",
"required": ["replicaCount", "image", "config"],
"properties": {
"replicaCount": {
"type": "integer",
"description": "Number of replica pods to run. Increase for high availability (recommended: 2-3 for production)",
"minimum": 1,
"default": 1
},
"image": {
"type": "object",
"description": "Docker image configuration",
"required": ["repository", "pullPolicy"],
"properties": {
"repository": {
"type": "string",
"description": "Container registry and image name",
"default": "noooste/garage-ui"
},
"pullPolicy": {
"type": "string",
"description": "Image pull policy",
"enum": ["Always", "IfNotPresent", "Never"],
"default": "IfNotPresent"
},
"tag": {
"type": "string",
"description": "Image tag to use (defaults to chart appVersion if empty)",
"default": ""
}
}
},
"imagePullSecrets": {
"type": "array",
"description": "Credentials for accessing private container registries",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
}
}
},
"default": []
},
"nameOverride": {
"type": "string",
"description": "Override the default chart name in resource names",
"default": ""
},
"fullnameOverride": {
"type": "string",
"description": "Override the full resource name (includes release name)",
"default": ""
},
"config": {
"type": "object",
"description": "Main application configuration",
"required": ["server", "garage", "auth", "cors", "logging"],
"properties": {
"server": {
"type": "object",
"description": "Server configuration",
"required": ["host", "port", "environment", "protocol"],
"properties": {
"host": {
"type": "string",
"description": "Network interface to bind to (0.0.0.0 for all interfaces)",
"default": "0.0.0.0"
},
"port": {
"type": "integer",
"description": "Port the application listens on",
"minimum": 1,
"maximum": 65535,
"default": 8080
},
"environment": {
"type": "string",
"description": "Deployment environment",
"enum": ["production", "development", "staging"],
"default": "production"
},
"domain": {
"type": "string",
"description": "Domain name for the application",
"default": "garage-ui.example.com"
},
"protocol": {
"type": "string",
"description": "Protocol for internal communication",
"enum": ["http", "https"],
"default": "http"
},
"root_url": {
"type": "string",
"description": "Full external URL for OAuth2 redirects (REQUIRED when OIDC is enabled)",
"pattern": "^https?://",
"default": "https://garage-ui.example.com"
},
"max_body_size": {
"type": "integer",
"description": "Maximum request body size in bytes (for file uploads)",
"minimum": 1,
"default": 314572800
},
"max_header_size": {
"type": "integer",
"description": "Maximum request header size in bytes",
"minimum": 1,
"default": 1048576
},
"read_buffer_size": {
"type": "integer",
"description": "Read buffer size for request data in bytes",
"minimum": 1,
"default": 4096
},
"write_buffer_size": {
"type": "integer",
"description": "Write buffer size for response data in bytes",
"minimum": 1,
"default": 4096
}
}
},
"garage": {
"type": "object",
"description": "Garage S3 storage configuration",
"required": ["endpoint", "region", "admin_endpoint"],
"properties": {
"endpoint": {
"type": "string",
"description": "Garage S3 API endpoint",
"pattern": "^(https?://)?[a-zA-Z0-9.-]+(:[0-9]+)?$",
"default": "http://garage:3900"
},
"region": {
"type": "string",
"description": "S3 region name (can be any value for Garage)",
"default": "garage"
},
"admin_endpoint": {
"type": "string",
"description": "Garage Admin API endpoint",
"pattern": "^https?://",
"default": "http://garage:3903"
},
"admin_token": {
"type": "string",
"description": "Admin API bearer token (ignored if existingSecret is configured)",
"default": ""
},
"existingSecret": {
"type": "object",
"description": "Use an existing Kubernetes secret for the admin token",
"properties": {
"name": {
"type": "string",
"description": "Name of the existing secret containing the admin token",
"default": ""
},
"key": {
"type": "string",
"description": "Key within the secret that contains the admin token value",
"default": "admin-token"
}
}
}
}
},
"auth": {
"type": "object",
"description": "Authentication configuration (one or both methods can be enabled)",
"required": ["admin", "oidc"],
"properties": {
"jwt_private_key": {
"type": "string",
"description": "Ed25519 private key for JWT signing in PEM format (EdDSA algorithm). Generate with: openssl genpkey -algorithm ED25519. If not provided, auto-generated on each restart (not recommended for production)",
"default": ""
},
"jwt_private_key_secret": {
"type": "object",
"description": "Use an existing Kubernetes secret for the JWT private key (recommended)",
"properties": {
"name": {
"type": "string",
"description": "Name of the existing secret containing the JWT private key",
"default": ""
},
"key": {
"type": "string",
"description": "Key within the secret that contains the JWT private key value",
"default": "jwt-key.pem"
}
}
},
"admin": {
"type": "object",
"description": "Admin authentication settings (username/password)",
"required": ["enabled"],
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable or disable admin authentication",
"default": false
},
"username": {
"type": "string",
"description": "Username for admin login",
"default": "admin"
},
"password": {
"type": "string",
"description": "Password for admin login (ignored if existingSecret is configured)",
"default": "changeme"
},
"existingSecret": {
"type": "object",
"description": "Use an existing Kubernetes secret for the admin password",
"properties": {
"name": {
"type": "string",
"description": "Name of the existing secret containing the admin password",
"default": ""
},
"key": {
"type": "string",
"description": "Key within the secret that contains the admin password value",
"default": "admin-password"
}
}
}
}
},
"oidc": {
"type": "object",
"description": "OpenID Connect (OIDC) configuration",
"required": ["enabled"],
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable or disable OIDC authentication",
"default": false
},
"provider_name": {
"type": "string",
"description": "Display name of your OIDC provider",
"default": "Keycloak"
},
"client_id": {
"type": "string",
"description": "OAuth2 client ID registered with your OIDC provider",
"default": "garage-ui"
},
"client_secret": {
"type": "string",
"description": "OAuth2 client secret (ignored if existingSecret is configured)",
"default": "your-client-secret"
},
"existingSecret": {
"type": "object",
"description": "Use an existing Kubernetes secret for the client secret",
"properties": {
"name": {
"type": "string",
"description": "Name of the existing secret containing the client secret",
"default": ""
},
"key": {
"type": "string",
"description": "Key within the secret that contains the client secret value",
"default": "client-secret"
}
}
},
"scopes": {
"type": "array",
"description": "OAuth2/OIDC scopes to request during authentication",
"items": {
"type": "string"
},
"default": ["openid", "email", "profile"]
},
"issuer_url": {
"type": "string",
"description": "OIDC issuer URL (base URL for OIDC discovery)",
"pattern": "^https?://",
"default": "https://keycloak.example.com/realms/master"
},
"auth_url": {
"type": "string",
"description": "Authorization endpoint URL",
"pattern": "^https?://",
"default": "https://keycloak.example.com/realms/master/protocol/openid-connect/auth"
},
"token_url": {
"type": "string",
"description": "Token endpoint URL",
"pattern": "^https?://",
"default": "https://keycloak.example.com/realms/master/protocol/openid-connect/token"
},
"userinfo_url": {
"type": "string",
"description": "User info endpoint URL",
"pattern": "^https?://",
"default": "https://keycloak.example.com/realms/master/protocol/openid-connect/userinfo"
},
"skip_issuer_check": {
"type": "boolean",
"description": "Skip issuer validation (not recommended for production)",
"default": false
},
"skip_expiry_check": {
"type": "boolean",
"description": "Skip token expiry validation (not recommended for production)",
"default": false
},
"email_attribute": {
"type": "string",
"description": "Claim containing user's email address",
"default": "email"
},
"username_attribute": {
"type": "string",
"description": "Claim containing user's username/login ID",
"default": "preferred_username"
},
"name_attribute": {
"type": "string",
"description": "Claim containing user's display name",
"default": "name"
},
"role_attribute_path": {
"type": "string",
"description": "Path to roles in the OIDC token claims",
"default": "resource_access.garage-ui.roles"
},
"admin_role": {
"type": "string",
"description": "Role name that grants admin privileges",
"default": "admin"
},
"tls_skip_verify": {
"type": "boolean",
"description": "Skip TLS certificate verification (only for testing)",
"default": false
},
"session_max_age": {
"type": "integer",
"description": "Session validity duration in seconds",
"minimum": 60,
"default": 86400
},
"cookie_name": {
"type": "string",
"description": "Name of the session cookie",
"default": "garage_session"
},
"cookie_secure": {
"type": "boolean",
"description": "Only send cookie over HTTPS connections",
"default": true
},
"cookie_http_only": {
"type": "boolean",
"description": "Prevent JavaScript access to the cookie",
"default": true
},
"cookie_same_site": {
"type": "string",
"description": "SameSite cookie attribute for CSRF protection",
"enum": ["lax", "strict", "none"],
"default": "lax"
}
}
}
}
},
"cors": {
"type": "object",
"description": "CORS (Cross-Origin Resource Sharing) configuration",
"required": ["enabled"],
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable or disable CORS",
"default": true
},
"allowed_origins": {
"type": "array",
"description": "List of allowed origins",
"items": {
"type": "string"
},
"default": ["*"]
},
"allowed_methods": {
"type": "array",
"description": "HTTP methods allowed in CORS requests",
"items": {
"type": "string",
"enum": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
},
"default": ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
},
"allowed_headers": {
"type": "array",
"description": "HTTP headers allowed in CORS requests",
"items": {
"type": "string"
},
"default": ["Origin", "Content-Type", "Accept", "Authorization"]
},
"allow_credentials": {
"type": "boolean",
"description": "Allow credentials in CORS requests (cannot be true when allowed_origins contains '*')",
"default": false
},
"max_age": {
"type": "integer",
"description": "Cache duration for CORS preflight responses in seconds",
"minimum": 0,
"default": 3600
}
}
},
"logging": {
"type": "object",
"description": "Logging configuration",
"required": ["level", "format"],
"properties": {
"level": {
"type": "string",
"description": "Log verbosity level",
"enum": ["debug", "info", "warn", "error"],
"default": "info"
},
"format": {
"type": "string",
"description": "Log output format",
"enum": ["json", "text"],
"default": "json"
}
}
}
}
},
"podAnnotations": {
"type": "object",
"description": "Annotations to add to the pod",
"additionalProperties": {
"type": "string"
},
"default": {}
},
"podSecurityContext": {
"type": "object",
"description": "Pod-level security context",
"properties": {
"runAsNonRoot": {
"type": "boolean",
"description": "Run containers as non-root user",
"default": true
},
"runAsUser": {
"type": "integer",
"description": "User ID to run containers as",
"minimum": 0,
"default": 1000
},
"fsGroup": {
"type": "integer",
"description": "Group ID for filesystem access",
"minimum": 0,
"default": 1000
}
}
},
"securityContext": {
"type": "object",
"description": "Container-level security context",
"properties": {
"allowPrivilegeEscalation": {
"type": "boolean",
"description": "Allow privilege escalation",
"default": false
},
"capabilities": {
"type": "object",
"properties": {
"drop": {
"type": "array",
"items": {
"type": "string"
},
"default": ["ALL"]
}
}
},
"readOnlyRootFilesystem": {
"type": "boolean",
"description": "Mount root filesystem as read-only",
"default": false
}
}
},
"service": {
"type": "object",
"description": "Kubernetes Service configuration",
"required": ["type", "port"],
"properties": {
"type": {
"type": "string",
"description": "Service type",
"enum": ["ClusterIP", "NodePort", "LoadBalancer"],
"default": "ClusterIP"
},
"port": {
"type": "integer",
"description": "Port the service listens on",
"minimum": 1,
"maximum": 65535,
"default": 80
}
}
},
"ingress": {
"type": "object",
"description": "Ingress configuration",
"required": ["enabled"],
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable or disable ingress",
"default": false
},
"className": {
"type": "string",
"description": "Ingress class name",
"default": "nginx"
},
"annotations": {
"type": "object",
"description": "Additional annotations for the ingress",
"additionalProperties": {
"type": "string"
},
"default": {}
},
"hosts": {
"type": "array",
"description": "Hostname and path configuration",
"items": {
"type": "object",
"required": ["host"],
"properties": {
"host": {
"type": "string",
"description": "Hostname for the ingress"
},
"paths": {
"type": "array",
"items": {
"type": "object",
"required": ["path", "pathType"],
"properties": {
"path": {
"type": "string",
"description": "URL path"
},
"pathType": {
"type": "string",
"description": "Path type",
"enum": ["Prefix", "Exact", "ImplementationSpecific"]
}
}
}
}
}
},
"default": [
{
"host": "garage-ui.local",
"paths": [
{
"path": "/",
"pathType": "Prefix"
}
]
}
]
},
"tls": {
"type": "array",
"description": "TLS/SSL configuration",
"items": {
"type": "object",
"properties": {
"secretName": {
"type": "string",
"description": "Name of the TLS secret"
},
"hosts": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"default": []
}
}
},
"resources": {
"type": "object",
"description": "CPU and memory resource limits and requests",
"properties": {
"limits": {
"type": "object",
"description": "Maximum resources the container can use",
"properties": {
"cpu": {
"type": "string",
"description": "Maximum CPU cores",
"pattern": "^[0-9]+(m|[0-9]*\\.?[0-9]+)?$",
"default": "500m"
},
"memory": {
"type": "string",
"description": "Maximum memory",
"pattern": "^[0-9]+(Ki|Mi|Gi|Ti|Pi|Ei)?$",
"default": "512Mi"
}
}
},
"requests": {
"type": "object",
"description": "Guaranteed resources allocated to the container",
"properties": {
"cpu": {
"type": "string",
"description": "Guaranteed CPU",
"pattern": "^[0-9]+(m|[0-9]*\\.?[0-9]+)?$",
"default": "100m"
},
"memory": {
"type": "string",
"description": "Guaranteed memory",
"pattern": "^[0-9]+(Ki|Mi|Gi|Ti|Pi|Ei)?$",
"default": "128Mi"
}
}
}
}
},
"livenessProbe": {
"type": "object",
"description": "Liveness probe configuration",
"required": ["enabled"],
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable or disable liveness probe",
"default": true
},
"httpGet": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Health check endpoint path",
"default": "/health"
},
"port": {
"type": "string",
"description": "Port name",
"default": "http"
}
}
},
"initialDelaySeconds": {
"type": "integer",
"description": "Wait time before starting liveness checks",
"minimum": 0,
"default": 30
},
"periodSeconds": {
"type": "integer",
"description": "How often to perform the probe",
"minimum": 1,
"default": 10
},
"timeoutSeconds": {
"type": "integer",
"description": "Maximum time to wait for probe completion",
"minimum": 1,
"default": 3
},
"failureThreshold": {
"type": "integer",
"description": "Consecutive failures before restarting",
"minimum": 1,
"default": 3
}
}
},
"readinessProbe": {
"type": "object",
"description": "Readiness probe configuration",
"required": ["enabled"],
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable or disable readiness probe",
"default": true
},
"httpGet": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Readiness check endpoint path",
"default": "/health"
},
"port": {
"type": "string",
"description": "Port name",
"default": "http"
}
}
},
"initialDelaySeconds": {
"type": "integer",
"description": "Wait time before starting readiness checks",
"minimum": 0,
"default": 10
},
"periodSeconds": {
"type": "integer",
"description": "How often to perform the probe",
"minimum": 1,
"default": 5
},
"timeoutSeconds": {
"type": "integer",
"description": "Maximum time to wait for probe completion",
"minimum": 1,
"default": 3
},
"failureThreshold": {
"type": "integer",
"description": "Consecutive failures before marking as not ready",
"minimum": 1,
"default": 3
}
}
},
"serviceMonitor": {
"type": "object",
"description": "ServiceMonitor for Prometheus Operator",
"required": ["enabled"],
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable or disable ServiceMonitor creation",
"default": false
},
"interval": {
"type": "string",
"description": "How often Prometheus should scrape metrics",
"pattern": "^[0-9]+(s|m|h)$",
"default": "30s"
},
"path": {
"type": "string",
"description": "Metrics endpoint path",
"default": "/api/v1/monitoring/metrics"
},
"labels": {
"type": "object",
"description": "Additional labels for the ServiceMonitor",
"additionalProperties": {
"type": "string"
},
"default": {}
}
}
},
"networkPolicy": {
"type": "object",
"description": "NetworkPolicy configuration",
"required": ["enabled"],
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable or disable NetworkPolicy creation",
"default": false
},
"policyTypes": {
"type": "array",
"description": "Types of policies to enforce",
"items": {
"type": "string",
"enum": ["Ingress", "Egress"]
},
"default": ["Ingress", "Egress"]
}
}
},
"nodeSelector": {
"type": "object",
"description": "Node selector labels",
"additionalProperties": {
"type": "string"
},
"default": {}
},
"tolerations": {
"type": "array",
"description": "Tolerations for pod scheduling",
"items": {
"type": "object",
"properties": {
"key": {
"type": "string"
},
"operator": {
"type": "string",
"enum": ["Exists", "Equal"]
},
"value": {
"type": "string"
},
"effect": {
"type": "string",
"enum": ["NoSchedule", "PreferNoSchedule", "NoExecute"]
}
}
},
"default": []
},
"affinity": {
"type": "object",
"description": "Affinity rules for pod scheduling",
"default": {}
}
}
}
+53 -409
View File
@@ -1,567 +1,211 @@
# Default values for garage-ui Helm chart
# This file contains configuration values for deploying Garage UI
# Customize the values below according to your deployment environment
# Default values for garage-ui
# Number of replica pods to run
# Increase for high availability (recommended: 2-3 for production)
replicaCount: 1
# Docker image configuration
image:
# Container registry and image name
# Default uses the official Garage UI image from Docker Hub
repository: noooste/garage-ui
# Image pull policy
# Options: Always, IfNotPresent, Never
# IfNotPresent: Only pull if image is not already present locally
pullPolicy: IfNotPresent
# Image tag to use (defaults to chart appVersion if empty)
# Example: "1.0.0" or "latest"
# Overrides the image tag whose default is the chart appVersion
tag: ""
# Credentials for accessing private container registries
# Example:
# imagePullSecrets:
# - name: regcred
imagePullSecrets: []
# Override the default chart name in resource names
# Leave empty to use the chart name
nameOverride: ""
# Override the full resource name (includes release name)
# Leave empty to use the default naming convention
fullnameOverride: ""
# ============================================================================
# APPLICATION CONFIGURATION
# ============================================================================
# This section contains the main application configuration
# Customize according to your Garage deployment and security requirements
#
# TIP: You can now easily override specific values using --set:
# --set config.server.port=9090
# --set config.garage.admin_token=your-token
# --set config.auth.mode=basic
config:
# ========================================
# Server Configuration
# ========================================
server:
# Network interface to bind to
# "0.0.0.0" listens on all interfaces (recommended for containers)
host: "0.0.0.0"
# Port the application listens on
# Default: 8080
port: 8080
# Deployment environment
# Options: "production", "development", "staging"
environment: "production"
domain: "garage-ui.example.com"
protocol: "http"
# Full external URL (required for OIDC)
root_url: "https://garage-ui.example.com"
# Request size limits (in bytes)
max_body_size: 314572800 # 300MB
max_header_size: 1048576 # 1MB
read_buffer_size: 4096 # 4KB
write_buffer_size: 4096 # 4KB
# ========================================
# Garage S3 Storage Configuration
# ========================================
garage:
# Garage S3 API endpoint
# Format: http(s)://hostname:port
# Default port: 3900
# Example: "http://garage.example.com:3900" or "http://garage:3900" for in-cluster
endpoint: "http://garage:3900"
# S3 region name
# For Garage, this can be any arbitrary value (Garage ignores regions)
# Default: "garage"
region: "garage"
# Garage Admin API endpoint
# This is used for administrative operations like bucket and key management
# Default port: 3903
# Example: "http://garage.example.com:3903" or "http://garage:3903" for in-cluster
admin_endpoint: "http://garage:3903"
# Admin API bearer token
# REQUIRED: Obtain this from your Garage server configuration
# This token grants administrative access - keep it secure!
# To generate: See Garage documentation for admin token setup
# NOTE: If existingSecret is configured, this value will be ignored
admin_token: ""
# Use an existing Kubernetes secret for the admin token (recommended for production)
# When configured, this takes precedence over the admin_token value above
# The secret should contain a key with the admin token value
# Use existing secret for admin token (recommended)
existingSecret:
# Name of the existing secret containing the admin token
# Leave empty to use the admin_token value above instead
# Example: "garage-admin-token"
name: ""
# Key within the secret that contains the admin token value
# Default: "admin-token"
key: "admin-token"
# ========================================
# Authentication Configuration
# ========================================
auth:
# Authentication mode
# Options:
# "none" - No authentication (open access - not recommended for production)
# "basic" - Simple username/password authentication
# "oidc" - OpenID Connect integration (recommended for production)
mode: "none"
# Ed25519 private key for JWT signing (PEM format)
# Generate with: openssl genpkey -algorithm ED25519
# If not provided, auto-generated on each restart (not recommended for production)
jwt_private_key: ""
# Use existing secret for JWT private key (recommended)
jwt_private_key_secret:
name: ""
key: "jwt-key.pem"
# Basic Authentication Settings
# Only used when mode = "basic"
# Provides simple username/password protection
basic:
# Username for basic auth login
username: "admin"
# Password for basic auth login
# IMPORTANT: Change this default password immediately!
password: "changeme"
# OpenID Connect (OIDC) Configuration
# Only used when mode = "oidc"
# Integrates with identity providers like Keycloak, Auth0, Okta, etc.
oidc:
# Enable/disable OIDC (must be true when mode = "oidc")
# Admin authentication (username/password)
admin:
enabled: false
username: "admin"
password: "changeme"
existingSecret:
name: ""
key: "admin-password"
# Display name of your OIDC provider
# Examples: "Keycloak", "Auth0", "Okta", "Azure AD"
# OIDC authentication (Keycloak, Auth0, Okta, etc.)
# NOTE: Requires server.root_url to be set
oidc:
enabled: false
provider_name: "Keycloak"
# OAuth2 client ID registered with your OIDC provider
# Obtain this from your OIDC provider's application settings
client_id: "garage-ui"
# OAuth2 client secret registered with your OIDC provider
# IMPORTANT: Keep this secret secure! Consider using Kubernetes secrets
client_secret: "your-client-secret"
# OAuth2/OIDC scopes to request during authentication
# Standard scopes: openid (required), email, profile
# Add custom scopes as needed by your provider
existingSecret:
name: ""
key: "client-secret"
scopes:
- openid
- email
- profile
# OIDC Provider Endpoints
# Replace "keycloak.example.com" and realm with your actual values
# For Keycloak: https://your-keycloak/realms/your-realm
# For Auth0: https://your-tenant.auth0.com
# For Okta: https://your-domain.okta.com
# OIDC issuer URL (base URL for OIDC discovery)
# OIDC provider endpoints
issuer_url: "https://keycloak.example.com/realms/master"
# Authorization endpoint URL
auth_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/auth"
# Token endpoint URL
token_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/token"
# User info endpoint URL
userinfo_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/userinfo"
# Token Validation Settings
# Set to true only if you have issuer mismatch issues (not recommended)
# Validation settings
skip_issuer_check: false
# Set to true to skip token expiry validation (not recommended for production)
skip_expiry_check: false
# User Attribute Mappings
# Map OIDC claims to application user attributes
# Adjust these based on your OIDC provider's claim structure
# Claim containing user's email address
# User attribute mappings
email_attribute: "email"
# Claim containing user's username/login ID
username_attribute: "preferred_username"
# Claim containing user's display name
name_attribute: "name"
# Role-Based Access Control (Optional)
# Path to roles in the OIDC token claims
# Example for Keycloak: "resource_access.garage-ui.roles"
# Example for Auth0: "https://your-app/roles"
# Role-based access control
role_attribute_path: "resource_access.garage-ui.roles"
# Role name that grants admin privileges
# Users with this role will have full administrative access
admin_role: "admin"
# TLS/SSL Configuration
# Skip TLS certificate verification (only for testing - never in production!)
# TLS settings
tls_skip_verify: false
# Session Management
# How long user sessions remain valid without activity
# Value in seconds (86400 = 24 hours)
session_max_age: 86400
# Name of the session cookie
# Session settings
session_max_age: 86400 # 24 hours
cookie_name: "garage_session"
# Only send cookie over HTTPS connections
# Set to false only for local development without HTTPS
cookie_secure: true
# Prevent JavaScript access to the cookie (security feature)
# Should remain true for security
cookie_http_only: true
# SameSite cookie attribute for CSRF protection
# Options: "lax" (recommended), "strict", "none"
# Use "lax" for most cases, "strict" for maximum security
cookie_same_site: "lax"
# ========================================
# CORS (Cross-Origin Resource Sharing)
# ========================================
# Configure which origins can access the API from browsers
# CORS configuration
cors:
# Enable or disable CORS
# Disable if the frontend and backend are served from the same origin
enabled: true
# List of allowed origins
# "*" allows all origins (convenient but less secure)
# For production, specify exact origins:
# Example:
# - "https://garage-ui.example.com"
# - "https://app.example.com"
allowed_origins:
- "*"
# HTTP methods that are allowed in CORS requests
# Include all methods your API uses
allowed_methods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
# HTTP headers that are allowed in CORS requests
# Add any custom headers your application requires
allowed_headers:
- Origin
- Content-Type
- Accept
- Authorization
# Allow credentials (cookies, authorization headers) in CORS requests
# Set to true if your frontend needs to send authentication cookies
# NOTE: When true, allowed_origins cannot be "*"
allow_credentials: false
# How long browsers can cache CORS preflight responses (in seconds)
# 3600 = 1 hour
max_age: 3600
# ========================================
# Logging Configuration
# ========================================
logging:
# Log verbosity level
# Options:
# "debug" - Verbose logging, useful for troubleshooting
# "info" - Standard logging (recommended for production)
# "warn" - Only warnings and errors
# "error" - Only errors
# Options: debug, info, warn, error
level: "info"
# Log output format
# Options:
# "json" - Structured JSON format (recommended for production/log aggregation)
# "text" - Human-readable text format (useful for development)
# Options: json, text
format: "json"
# ============================================================================
# KUBERNETES POD CONFIGURATION
# ============================================================================
# Annotations to add to the pod
# Use for integrations with service meshes, monitoring, etc.
# Example:
# podAnnotations:
# prometheus.io/scrape: "true"
# prometheus.io/port: "8080"
# Pod annotations
podAnnotations: {}
# Pod-level security context
# Defines security settings for all containers in the pod
# Pod security context
podSecurityContext:
# Run containers as non-root user (security best practice)
runAsNonRoot: true
# User ID to run containers as
# Default: 1000 (non-privileged user)
runAsUser: 1000
# Group ID for filesystem access
# Files created by the pod will have this group ownership
fsGroup: 1000
# Container-level security context
# Security settings specific to the application container
# Container security context
securityContext:
# Prevent privilege escalation (security best practice)
# Ensures the container cannot gain more privileges than its parent
allowPrivilegeEscalation: false
# Drop all Linux capabilities and only add what's needed
# This follows the principle of least privilege
capabilities:
drop:
- ALL
# Allow write access to the root filesystem
# Set to true for read-only root filesystem (more secure but may require adjustments)
readOnlyRootFilesystem: false
# ============================================================================
# KUBERNETES SERVICE
# ============================================================================
service:
# Service type determines how the service is exposed
# Options:
# ClusterIP - Internal only (default, recommended when using Ingress)
# NodePort - Expose on each node's IP at a static port
# LoadBalancer - Expose using a cloud provider's load balancer
type: ClusterIP
# Port the service listens on
# This is the port other services/ingress will connect to
# Default: 80
port: 80
# ============================================================================
# INGRESS CONFIGURATION
# ============================================================================
# Ingress exposes HTTP/HTTPS routes from outside the cluster to the service
ingress:
# Enable or disable ingress
# Set to true to make the application accessible from outside the cluster
enabled: false
# Ingress class name
# Specifies which ingress controller to use
# Common values: "nginx", "traefik", "alb" (AWS)
# Ensure the specified controller is installed in your cluster
className: "nginx"
# Additional annotations for the ingress
# Use for configuring ingress controller behavior, SSL, authentication, etc.
# Examples:
# cert-manager.io/cluster-issuer: "letsencrypt-prod" # For automatic SSL certificates
# nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
# nginx.ingress.kubernetes.io/proxy-body-size: "100m"
annotations: {}
# Hostname and path configuration
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
# nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
hosts:
# Replace "garage-ui.local" with your actual domain
- host: garage-ui.local
paths:
# Path where the application will be accessible
- path: /
# pathType options: Prefix, Exact, ImplementationSpecific
pathType: Prefix
# TLS/SSL configuration
# Uncomment and configure to enable HTTPS
# Requires a TLS certificate stored in a Kubernetes secret
tls: []
# - secretName: garage-ui-tls
# hosts:
# - garage-ui.local
#
# To use cert-manager for automatic certificates:
# 1. Install cert-manager in your cluster
# 2. Create a ClusterIssuer (e.g., letsencrypt-prod)
# 3. Add annotation: cert-manager.io/cluster-issuer: "letsencrypt-prod"
# 4. Uncomment the tls section above
# ============================================================================
# RESOURCE LIMITS AND REQUESTS
# ============================================================================
# Configure CPU and memory allocation for the pods
# Requests: Guaranteed resources (used for scheduling)
# Limits: Maximum resources the container can use
resources:
limits:
# Maximum CPU cores the container can use
# 500m = 0.5 CPU cores
# Increase for high-traffic deployments
cpu: 500m
# Maximum memory the container can use
# Container will be killed if it exceeds this limit
memory: 512Mi
requests:
# Guaranteed CPU allocated to the container
# 100m = 0.1 CPU cores
# Cluster scheduler ensures this amount is available
cpu: 100m
# Guaranteed memory allocated to the container
# Cluster scheduler ensures this amount is available
memory: 128Mi
# ============================================================================
# HEALTH CHECKS
# ============================================================================
# Liveness Probe
# Kubernetes restarts the container if this probe fails
# Detects if the application is running but in a broken state
livenessProbe:
# Enable or disable the liveness probe
enabled: true
# HTTP endpoint to check
httpGet:
# Health check endpoint path
path: /health
# Port name (defined in the container spec)
port: http
# Wait time before starting liveness checks after container starts
# Give the application time to initialize
initialDelaySeconds: 30
# How often to perform the probe (in seconds)
periodSeconds: 10
# Maximum time to wait for the probe to complete
timeoutSeconds: 3
# Number of consecutive failures before restarting the container
failureThreshold: 3
# Readiness Probe
# Kubernetes stops sending traffic if this probe fails
# Determines when the container is ready to accept traffic
readinessProbe:
# Enable or disable the readiness probe
enabled: true
# HTTP endpoint to check
httpGet:
# Readiness check endpoint path
path: /health
# Port name (defined in the container spec)
port: http
# Wait time before starting readiness checks after container starts
initialDelaySeconds: 10
# How often to perform the probe (in seconds)
periodSeconds: 5
# Maximum time to wait for the probe to complete
timeoutSeconds: 3
# Number of consecutive failures before marking as not ready
failureThreshold: 3
# ============================================================================
# MONITORING AND OBSERVABILITY
# ============================================================================
# ServiceMonitor for Prometheus Operator
# Automatically configure Prometheus to scrape metrics from the application
# Requires Prometheus Operator to be installed in the cluster
serviceMonitor:
# Enable or disable ServiceMonitor creation
# Set to true if using Prometheus Operator for monitoring
enabled: false
# How often Prometheus should scrape metrics
# Format: duration string (e.g., "30s", "1m", "5m")
interval: 30s
# Metrics endpoint path
# The application exposes metrics at this path
path: /api/v1/monitoring/metrics
# Additional labels for the ServiceMonitor
# Use to match Prometheus scrape configurations
# Example:
# labels:
# prometheus: kube-prometheus
labels: {}
# ============================================================================
# NETWORK POLICY
# ============================================================================
# Controls network traffic to/from the pods
# Requires a network plugin that supports NetworkPolicy (e.g., Calico, Cilium)
# NetworkPolicy
networkPolicy:
# Enable or disable NetworkPolicy creation
# Provides network-level security by restricting pod communication
enabled: false
# Types of policies to enforce
# Ingress: Controls incoming traffic to the pod
# Egress: Controls outgoing traffic from the pod
policyTypes:
- Ingress
- Egress
# Note: When enabled, by default only allows necessary traffic
# Customize the NetworkPolicy template if you need specific rules
# ============================================================================
# POD SCHEDULING
# ============================================================================
# Node Selector
# Schedule pods only on nodes with specific labels
# Useful for deploying to specific node pools or hardware types
# Example:
# nodeSelector:
# disktype: ssd
# environment: production
# Node labels for pod assignment
nodeSelector: {}
# Tolerations
# Allow pods to be scheduled on nodes with matching taints
# Useful for dedicated node pools or special hardware
# Example:
# tolerations:
# - key: "dedicated"
# operator: "Equal"
# value: "garage-ui"
# effect: "NoSchedule"
# Tolerations for pod assignment
tolerations: []
# Affinity Rules
# Advanced pod scheduling constraints
# Controls which nodes pods can be scheduled on and pod co-location
# Example for pod anti-affinity (spread pods across nodes):
# affinity:
# podAntiAffinity:
# preferredDuringSchedulingIgnoredDuringExecution:
# - weight: 100
# podAffinityTerm:
# labelSelector:
# matchExpressions:
# - key: app.kubernetes.io/name
# operator: In
# values:
# - garage-ui
# topologyKey: kubernetes.io/hostname
# Affinity for pod assignment
affinity: {}