add initial project setup

This commit is contained in:
Noooste
2025-11-24 18:16:25 +01:00
parent 998f17c7d9
commit 6b050f1090
57 changed files with 11526 additions and 3 deletions
+302
View File
@@ -0,0 +1,302 @@
package auth
import (
"context"
"crypto/subtle"
"encoding/base64"
"fmt"
"strings"
"Noooste/garage-ui/internal/config"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// AuthService handles authentication operations
type AuthService struct {
config *config.AuthConfig
oidcProvider *oidc.Provider
oidcVerifier *oidc.IDTokenVerifier
oauth2Config *oauth2.Config
}
// UserInfo represents authenticated user information
type UserInfo struct {
Username string
Email string
Name string
Roles []string
}
// NewAuthService creates a new authentication service
func NewAuthService(cfg *config.AuthConfig) (*AuthService, error) {
service := &AuthService{
config: cfg,
}
// Initialize OIDC if enabled
if cfg.Mode == "oidc" && cfg.OIDC.Enabled {
if err := service.initOIDC(); err != nil {
return nil, fmt.Errorf("failed to initialize OIDC: %w", err)
}
}
return service, nil
}
// initOIDC initializes the OIDC provider and configuration
func (a *AuthService) initOIDC() error {
ctx := context.Background()
// Create OIDC provider
provider, err := oidc.NewProvider(ctx, a.config.OIDC.IssuerURL)
if err != nil {
return fmt.Errorf("failed to create OIDC provider: %w", err)
}
a.oidcProvider = provider
// Create ID token verifier
verifierConfig := &oidc.Config{
ClientID: a.config.OIDC.ClientID,
SkipIssuerCheck: a.config.OIDC.SkipIssuerCheck,
SkipExpiryCheck: a.config.OIDC.SkipExpiryCheck,
}
a.oidcVerifier = provider.Verifier(verifierConfig)
// Create OAuth2 config
a.oauth2Config = &oauth2.Config{
ClientID: a.config.OIDC.ClientID,
ClientSecret: a.config.OIDC.ClientSecret,
Endpoint: provider.Endpoint(),
Scopes: a.config.OIDC.Scopes,
}
return nil
}
// ValidateBasicAuth validates basic authentication credentials
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),
) == 1
passwordMatch := subtle.ConstantTimeCompare(
[]byte(password),
[]byte(a.config.Basic.Password),
) == 1
return usernameMatch && passwordMatch
}
// ParseBasicAuth parses the Authorization header for basic auth
func ParseBasicAuth(authHeader string) (username, password string, ok bool) {
if authHeader == "" {
return "", "", false
}
// Check if it's a Basic auth header
const prefix = "Basic "
if !strings.HasPrefix(authHeader, prefix) {
return "", "", false
}
// Decode base64 credentials
encoded := authHeader[len(prefix):]
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", "", false
}
// Split username:password
credentials := string(decoded)
parts := strings.SplitN(credentials, ":", 2)
if len(parts) != 2 {
return "", "", false
}
return parts[0], parts[1], true
}
// GetAuthorizationURL returns the OIDC authorization URL for login
func (a *AuthService) GetAuthorizationURL(state string) (string, error) {
if a.oauth2Config == nil {
return "", fmt.Errorf("OIDC not initialized")
}
return a.oauth2Config.AuthCodeURL(state), nil
}
// ExchangeCode exchanges an authorization code for tokens
func (a *AuthService) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) {
if a.oauth2Config == nil {
return nil, fmt.Errorf("OIDC not initialized")
}
token, err := a.oauth2Config.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("failed to exchange code: %w", err)
}
return token, nil
}
// VerifyIDToken verifies an OIDC ID token and extracts user info
func (a *AuthService) VerifyIDToken(ctx context.Context, rawIDToken string) (*UserInfo, error) {
if a.oidcVerifier == nil {
return nil, fmt.Errorf("OIDC not initialized")
}
// Verify the ID token
idToken, err := a.oidcVerifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("failed to verify ID token: %w", err)
}
// Extract claims
var claims map[string]interface{}
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("failed to parse claims: %w", err)
}
// 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),
}
// Extract roles if configured
if a.config.OIDC.RoleAttributePath != "" {
userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath)
}
return userInfo, nil
}
// GetUserInfo retrieves user information from the OIDC provider
func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserInfo, error) {
if a.oidcProvider == nil {
return nil, fmt.Errorf("OIDC not initialized")
}
// Create OAuth2 token source
tokenSource := a.oauth2Config.TokenSource(ctx, token)
// Get user info from the provider
userInfoEndpoint, err := a.oidcProvider.UserInfo(ctx, tokenSource)
if err != nil {
return nil, fmt.Errorf("failed to get user info: %w", err)
}
// Extract claims
var claims map[string]interface{}
if err := userInfoEndpoint.Claims(&claims); err != nil {
return nil, fmt.Errorf("failed to parse user info claims: %w", err)
}
// 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),
}
// Extract roles if configured
if a.config.OIDC.RoleAttributePath != "" {
userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath)
}
return userInfo, nil
}
// IsAdmin checks if the user has admin role
func (a *AuthService) IsAdmin(userInfo *UserInfo) bool {
if a.config.OIDC.AdminRole == "" {
return false
}
for _, role := range userInfo.Roles {
if role == a.config.OIDC.AdminRole {
return true
}
}
return false
}
// Helper functions
// extractClaim extracts a string claim from the claims map
func extractClaim(claims map[string]interface{}, key string) string {
if key == "" {
return ""
}
value, ok := claims[key]
if !ok {
return ""
}
str, ok := value.(string)
if !ok {
return ""
}
return str
}
// extractRoles extracts roles from nested claim path (e.g., "resource_access.garage-ui.roles")
func extractRoles(claims map[string]interface{}, path string) []string {
if path == "" {
return nil
}
// Split the path by dots to navigate nested claims
parts := strings.Split(path, ".")
current := claims
for i, part := range parts {
value, ok := current[part]
if !ok {
return nil
}
// If this is the last part, it should be the roles array
if i == len(parts)-1 {
return extractStringArray(value)
}
// Navigate to next level
next, ok := value.(map[string]interface{})
if !ok {
return nil
}
current = next
}
return nil
}
// extractStringArray converts an interface{} to []string if possible
func extractStringArray(value interface{}) []string {
// Try direct string array
if strArray, ok := value.([]string); ok {
return strArray
}
// Try interface array and convert to strings
if array, ok := value.([]interface{}); ok {
result := make([]string, 0, len(array))
for _, item := range array {
if str, ok := item.(string); ok {
result = append(result, str)
}
}
return result
}
return nil
}
+188
View File
@@ -0,0 +1,188 @@
package config
import (
"fmt"
"os"
"github.com/spf13/viper"
)
// Config represents the application configuration
type Config struct {
Server ServerConfig `mapstructure:"server"`
Garage GarageConfig `mapstructure:"garage"`
Auth AuthConfig `mapstructure:"auth"`
CORS CORSConfig `mapstructure:"cors"`
Logging LoggingConfig `mapstructure:"logging"`
}
// ServerConfig contains server-related configuration
type ServerConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Environment string `mapstructure:"environment"`
}
// GarageConfig contains Garage S3 connection settings
type GarageConfig struct {
Endpoint string `mapstructure:"endpoint"`
Region string `mapstructure:"region"`
AccessKey string `mapstructure:"access_key"`
SecretKey string `mapstructure:"secret_key"`
UseSSL bool `mapstructure:"use_ssl"`
ForcePathStyle bool `mapstructure:"force_path_style"`
}
// AuthConfig contains authentication configuration
type AuthConfig struct {
Mode string `mapstructure:"mode"` // "none", "basic", or "oidc"
Basic BasicAuthConfig `mapstructure:"basic"`
OIDC OIDCConfig `mapstructure:"oidc"`
}
// BasicAuthConfig contains basic authentication settings
type BasicAuthConfig struct {
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
}
// OIDCConfig contains OIDC authentication settings
type OIDCConfig struct {
Enabled bool `mapstructure:"enabled"`
ProviderName string `mapstructure:"provider_name"`
ClientID string `mapstructure:"client_id"`
ClientSecret string `mapstructure:"client_secret"`
Scopes []string `mapstructure:"scopes"`
IssuerURL string `mapstructure:"issuer_url"`
AuthURL string `mapstructure:"auth_url"`
TokenURL string `mapstructure:"token_url"`
UserinfoURL string `mapstructure:"userinfo_url"`
SkipIssuerCheck bool `mapstructure:"skip_issuer_check"`
SkipExpiryCheck bool `mapstructure:"skip_expiry_check"`
EmailAttribute string `mapstructure:"email_attribute"`
UsernameAttribute string `mapstructure:"username_attribute"`
NameAttribute string `mapstructure:"name_attribute"`
RoleAttributePath string `mapstructure:"role_attribute_path"`
AdminRole string `mapstructure:"admin_role"`
TLSSkipVerify bool `mapstructure:"tls_skip_verify"`
SessionMaxAge int `mapstructure:"session_max_age"`
CookieName string `mapstructure:"cookie_name"`
CookieSecure bool `mapstructure:"cookie_secure"`
CookieHTTPOnly bool `mapstructure:"cookie_http_only"`
CookieSameSite string `mapstructure:"cookie_same_site"`
}
// CORSConfig contains CORS settings for frontend communication
type CORSConfig struct {
Enabled bool `mapstructure:"enabled"`
AllowedOrigins []string `mapstructure:"allowed_origins"`
AllowedMethods []string `mapstructure:"allowed_methods"`
AllowedHeaders []string `mapstructure:"allowed_headers"`
AllowCredentials bool `mapstructure:"allow_credentials"`
MaxAge int `mapstructure:"max_age"`
}
// LoggingConfig contains logging configuration
type LoggingConfig struct {
Level string `mapstructure:"level"`
Format string `mapstructure:"format"`
}
// Load reads the configuration from the specified file
func Load(configPath string) (*Config, error) {
// Set default config file name if not specified
if configPath == "" {
configPath = "config.yaml"
}
// Check if config file exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return nil, fmt.Errorf("config file not found: %s", configPath)
}
// Configure viper to read the config file
viper.SetConfigFile(configPath)
viper.SetConfigType("yaml")
// Allow environment variables to override config values
viper.AutomaticEnv()
// Read the configuration file
if err := viper.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
// Unmarshal the configuration into our Config struct
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
// Validate the configuration
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
return &cfg, nil
}
// Validate checks if the configuration is valid
func (c *Config) Validate() error {
// Validate server config
if c.Server.Port <= 0 || c.Server.Port > 65535 {
return fmt.Errorf("invalid server port: %d", c.Server.Port)
}
// Validate Garage config
if c.Garage.Endpoint == "" {
return fmt.Errorf("garage endpoint is required")
}
if c.Garage.AccessKey == "" {
return fmt.Errorf("garage access_key is required")
}
if c.Garage.SecretKey == "" {
return fmt.Errorf("garage secret_key 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 OIDC config if enabled
if c.Auth.Mode == "oidc" {
if c.Auth.OIDC.ClientID == "" {
return fmt.Errorf("oidc client_id is required when auth mode is 'oidc'")
}
if c.Auth.OIDC.IssuerURL == "" {
return fmt.Errorf("oidc issuer_url is required when auth mode is 'oidc'")
}
if len(c.Auth.OIDC.Scopes) == 0 {
return fmt.Errorf("oidc scopes are required when auth mode is 'oidc'")
}
}
return nil
}
// GetAddress returns the full server address (host:port)
func (c *Config) GetAddress() string {
return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port)
}
// IsDevelopment returns true if running in development mode
func (c *Config) IsDevelopment() bool {
return c.Server.Environment == "development"
}
// IsProduction returns true if running in production mode
func (c *Config) IsProduction() bool {
return c.Server.Environment == "production"
}
+175
View File
@@ -0,0 +1,175 @@
package handlers
import (
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
)
// BucketHandler handles bucket-related operations
type BucketHandler struct {
s3Service *services.S3Service
}
// NewBucketHandler creates a new bucket handler
func NewBucketHandler(s3Service *services.S3Service) *BucketHandler {
return &BucketHandler{
s3Service: s3Service,
}
}
// ListBuckets returns all buckets
// GET /api/v1/buckets
func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
ctx := c.Context()
// List all buckets from Garage
buckets, err := h.s3Service.ListBuckets(ctx)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list buckets: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(buckets))
}
// CreateBucket creates a new bucket
// POST /api/v1/buckets
func (h *BucketHandler) CreateBucket(c fiber.Ctx) error {
ctx := c.Context()
// Parse request body
var req models.CreateBucketRequest
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
)
}
// Validate bucket name
if req.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
// Check if bucket already exists
exists, err := h.s3Service.BucketExists(ctx, req.Name)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
}
if exists {
return c.Status(fiber.StatusConflict).JSON(
models.ErrorResponse(models.ErrCodeBucketExists, "Bucket already exists"),
)
}
// Create the bucket
if err := h.s3Service.CreateBucket(ctx, req.Name); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create bucket: "+err.Error()),
)
}
// Return success response
response := map[string]interface{}{
"bucket": req.Name,
"message": "Bucket created successfully",
}
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(response))
}
// DeleteBucket deletes a bucket
// DELETE /api/v1/buckets/:name
func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameter
bucketName := c.Params("name")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
// Check if bucket exists
exists, err := h.s3Service.BucketExists(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
}
if !exists {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
)
}
// Delete the bucket
if err := h.s3Service.DeleteBucket(ctx, bucketName); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete bucket: "+err.Error()),
)
}
// Return success response
response := map[string]interface{}{
"bucket": bucketName,
"message": "Bucket deleted successfully",
}
return c.JSON(models.SuccessResponse(response))
}
// GetBucketInfo returns information about a specific bucket
// GET /api/v1/buckets/:name
func (h *BucketHandler) GetBucketInfo(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameter
bucketName := c.Params("name")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
// Check if bucket exists
exists, err := h.s3Service.BucketExists(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
}
if !exists {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
)
}
// List all buckets to find this one and get its info
buckets, err := h.s3Service.ListBuckets(ctx)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()),
)
}
// Find the specific bucket
for _, bucket := range buckets.Buckets {
if bucket.Name == bucketName {
return c.JSON(models.SuccessResponse(bucket))
}
}
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
)
}
+31
View File
@@ -0,0 +1,31 @@
package handlers
import (
"time"
"Noooste/garage-ui/internal/models"
"github.com/gofiber/fiber/v3"
)
// HealthHandler handles health check requests
type HealthHandler struct {
version string
}
// NewHealthHandler creates a new health check handler
func NewHealthHandler(version string) *HealthHandler {
return &HealthHandler{
version: version,
}
}
// Check returns the health status of the service
func (h *HealthHandler) Check(c fiber.Ctx) error {
response := models.HealthResponse{
Status: "healthy",
Timestamp: time.Now(),
Version: h.version,
}
return c.JSON(models.SuccessResponse(response))
}
+386
View File
@@ -0,0 +1,386 @@
package handlers
import (
"io"
"time"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
)
// ObjectHandler handles object-related operations
type ObjectHandler struct {
s3Service *services.S3Service
}
// NewObjectHandler creates a new object handler
func NewObjectHandler(s3Service *services.S3Service) *ObjectHandler {
return &ObjectHandler{
s3Service: s3Service,
}
}
// ListObjects returns all objects in a bucket
// GET /api/v1/buckets/:bucket/objects
func (h *ObjectHandler) ListObjects(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameter
bucketName := c.Params("bucket")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
// Get query parameters for filtering
prefix := c.Query("prefix", "")
maxKeys := c.QueryInt("max_keys", 1000)
// Check if bucket exists
exists, err := h.s3Service.BucketExists(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
}
if !exists {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
)
}
// List objects in the bucket
objects, err := h.s3Service.ListObjects(ctx, bucketName, prefix, maxKeys)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list objects: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(objects))
}
// UploadObject uploads a file to a bucket
// POST /api/v1/buckets/:bucket/objects
func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameter
bucketName := c.Params("bucket")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
// Check if bucket exists
exists, err := h.s3Service.BucketExists(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
}
if !exists {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
)
}
// Get file from multipart form
file, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "File is required: "+err.Error()),
)
}
// Get object key (path in bucket)
key := c.FormValue("key")
if key == "" {
// Use filename as key if not provided
key = file.Filename
}
// Open the uploaded file
fileHandle, err := file.Open()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to open uploaded file: "+err.Error()),
)
}
defer fileHandle.Close()
// Get content type
contentType := file.Header.Get("Content-Type")
// Upload to Garage
uploadResult, err := h.s3Service.UploadObject(ctx, bucketName, key, fileHandle, contentType)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to upload object: "+err.Error()),
)
}
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult))
}
// GetObject downloads an object from a bucket
// GET /api/v1/buckets/:bucket/objects/:key
func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name and object key from URL parameters
bucketName := c.Params("bucket")
key := c.Params("key")
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
)
}
// Get object from Garage
body, objectInfo, err := h.s3Service.GetObject(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
}
defer body.Close()
// Set response headers
c.Set("Content-Type", objectInfo.ContentType)
c.Set("Content-Length", string(rune(objectInfo.Size)))
c.Set("ETag", objectInfo.ETag)
c.Set("Last-Modified", objectInfo.LastModified.Format(time.RFC1123))
// Check if client wants to download or view inline
if c.Query("download") == "true" {
c.Set("Content-Disposition", "attachment; filename=\""+key+"\"")
}
// Stream the object body to the client
return c.SendStream(body)
}
// DeleteObject deletes an object from a bucket
// DELETE /api/v1/buckets/:bucket/objects/:key
func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name and object key from URL parameters
bucketName := c.Params("bucket")
key := c.Params("key")
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
)
}
// Check if object exists
exists, err := h.s3Service.ObjectExists(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()),
)
}
if !exists {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found"),
)
}
// Delete the object
if err := h.s3Service.DeleteObject(ctx, bucketName, key); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete object: "+err.Error()),
)
}
// Return success response
response := models.ObjectDeleteResponse{
Bucket: bucketName,
Key: key,
Deleted: true,
}
return c.JSON(models.SuccessResponse(response))
}
// GetObjectMetadata returns metadata for an object without downloading it
// HEAD /api/v1/buckets/:bucket/objects/:key
func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name and object key from URL parameters
bucketName := c.Params("bucket")
key := c.Params("key")
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
)
}
// Get object metadata
metadata, err := h.s3Service.GetObjectMetadata(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(metadata))
}
// GetPresignedURL generates a temporary pre-signed URL for an object
// POST /api/v1/buckets/:bucket/objects/:key/presign
func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name and object key from URL parameters
bucketName := c.Params("bucket")
key := c.Params("key")
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
)
}
// Get expiration time from query parameter (default: 1 hour)
expiresIn := c.QueryInt("expires_in", 3600)
if expiresIn <= 0 || expiresIn > 604800 { // Max 7 days
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration time (must be between 1 and 604800 seconds)"),
)
}
// Check if object exists
exists, err := h.s3Service.ObjectExists(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()),
)
}
if !exists {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found"),
)
}
// Generate pre-signed URL
url, err := h.s3Service.GetPresignedURL(ctx, bucketName, key, time.Duration(expiresIn)*time.Second)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to generate pre-signed URL: "+err.Error()),
)
}
response := map[string]interface{}{
"url": url,
"expires_in": expiresIn,
"bucket": bucketName,
"key": key,
}
return c.JSON(models.SuccessResponse(response))
}
// DeleteMultipleObjects deletes multiple objects from a bucket
// DELETE /api/v1/buckets/:bucket/objects
func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameter
bucketName := c.Params("bucket")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
// Parse request body to get keys
var req struct {
Keys []string `json:"keys"`
}
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
)
}
if len(req.Keys) == 0 {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "At least one key is required"),
)
}
// Delete multiple objects
if err := h.s3Service.DeleteMultipleObjects(ctx, bucketName, req.Keys); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete objects: "+err.Error()),
)
}
response := map[string]interface{}{
"bucket": bucketName,
"deleted": len(req.Keys),
"keys": req.Keys,
}
return c.JSON(models.SuccessResponse(response))
}
// UploadObjectStream uploads an object from request body stream (for large files)
// PUT /api/v1/buckets/:bucket/objects/:key
func (h *ObjectHandler) UploadObjectStream(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name and object key from URL parameters
bucketName := c.Params("bucket")
key := c.Params("key")
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
)
}
// Check if bucket exists
exists, err := h.s3Service.BucketExists(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
}
if !exists {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
)
}
// Get content type from header
contentType := c.Get("Content-Type", "application/octet-stream")
// Get request body as reader
bodyReader := c.Request().BodyStream()
// Upload to Garage
uploadResult, err := h.s3Service.UploadObject(ctx, bucketName, key, io.NopCloser(bodyReader), contentType)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to upload object: "+err.Error()),
)
}
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult))
}
+163
View File
@@ -0,0 +1,163 @@
package handlers
import (
"Noooste/garage-ui/internal/models"
"github.com/gofiber/fiber/v3"
)
// UserHandler handles user/key management operations
// Note: Garage user management typically requires administrative API access
// This is a placeholder implementation that you'll need to extend based on
// your Garage setup and how you manage keys/users
type UserHandler struct {
// In a real implementation, you might have a Garage admin client here
// or interact with Garage's administrative API
}
// NewUserHandler creates a new user handler
func NewUserHandler() *UserHandler {
return &UserHandler{}
}
// ListUsers returns all users/keys
// GET /api/v1/users
func (h *UserHandler) ListUsers(c fiber.Ctx) error {
// NOTE: This is a placeholder implementation
// Garage manages keys/users through its administrative RPC interface
// You'll need to implement this based on your Garage setup
// For now, return a not implemented response
return c.Status(fiber.StatusNotImplemented).JSON(
models.ErrorResponse(models.ErrCodeInternalError,
"User management not yet implemented. Requires Garage Admin API integration."),
)
}
// CreateUser creates a new user/key pair
// POST /api/v1/users
func (h *UserHandler) CreateUser(c fiber.Ctx) error {
// NOTE: This is a placeholder implementation
// To implement this, you need to:
// 1. Connect to Garage's admin RPC interface
// 2. Call the appropriate key creation endpoint
// 3. Return the generated access key and secret key
var req models.CreateUserRequest
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
)
}
return c.Status(fiber.StatusNotImplemented).JSON(
models.ErrorResponse(models.ErrCodeInternalError,
"User creation not yet implemented. Requires Garage Admin API integration."),
)
}
// DeleteUser deletes a user/key
// DELETE /api/v1/users/:access_key
func (h *UserHandler) DeleteUser(c fiber.Ctx) error {
// NOTE: This is a placeholder implementation
accessKey := c.Params("access_key")
if accessKey == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
)
}
return c.Status(fiber.StatusNotImplemented).JSON(
models.ErrorResponse(models.ErrCodeInternalError,
"User deletion not yet implemented. Requires Garage Admin API integration."),
)
}
// GetUser returns information about a specific user/key
// GET /api/v1/users/:access_key
func (h *UserHandler) GetUser(c fiber.Ctx) error {
accessKey := c.Params("access_key")
if accessKey == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
)
}
return c.Status(fiber.StatusNotImplemented).JSON(
models.ErrorResponse(models.ErrCodeInternalError,
"User info not yet implemented. Requires Garage Admin API integration."),
)
}
// UpdateUserPermissions updates user permissions
// PATCH /api/v1/users/:access_key
func (h *UserHandler) UpdateUserPermissions(c fiber.Ctx) error {
accessKey := c.Params("access_key")
if accessKey == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
)
}
var req models.UpdateUserRequest
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
)
}
return c.Status(fiber.StatusNotImplemented).JSON(
models.ErrorResponse(models.ErrCodeInternalError,
"User permission update not yet implemented. Requires Garage Admin API integration."),
)
}
/*
IMPLEMENTATION NOTES FOR USER MANAGEMENT:
Garage uses an administrative RPC interface for managing keys and buckets.
To implement user management, you need to:
1. Install garage-admin client or use HTTP RPC calls
2. Connect to Garage's admin port (typically 3903)
3. Implement the following operations:
- List keys: GET /v1/key
- Create key: POST /v1/key
- Get key info: GET /v1/key?id=<access_key>
- Delete key: DELETE /v1/key?id=<access_key>
- Update key: POST /v1/key?id=<access_key>
Example using HTTP client:
import (
"bytes"
"encoding/json"
"net/http"
)
type GarageAdminClient struct {
baseURL string
token string
}
func (g *GarageAdminClient) ListKeys() ([]KeyInfo, error) {
req, _ := http.NewRequest("GET", g.baseURL+"/v1/key", nil)
req.Header.Set("Authorization", "Bearer "+g.token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var keys []KeyInfo
json.NewDecoder(resp.Body).Decode(&keys)
return keys, nil
}
For more information, see:
https://garagehq.deuxfleurs.fr/documentation/reference-manual/admin-api/
*/
+146
View File
@@ -0,0 +1,146 @@
package middleware
import (
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"github.com/gofiber/fiber/v3"
)
// AuthMiddleware returns a Fiber middleware for authentication
// It handles different auth modes: none, basic, and OIDC
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" {
return c.Next()
}
// Handle basic authentication
if cfg.Mode == "basic" {
return handleBasicAuth(c, authService)
}
// Handle OIDC authentication
if cfg.Mode == "oidc" {
return handleOIDCAuth(c, authService, &cfg.OIDC)
}
// 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 == "" {
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Authentication required"),
)
}
// In a production implementation, you would:
// 1. Validate the session token from the cookie
// 2. Look up the session in a session store (Redis, memory, etc.)
// 3. Verify the session hasn't expired
// 4. Extract user information from the session
//
// For now, we'll implement a basic token verification
// You should extend this based on your session management strategy
// Verify ID token if it's stored in the session
ctx := c.Context()
userInfo, err := authService.VerifyIDToken(ctx, 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()
}
// RequireAuth is a simpler middleware that just checks if auth is enabled
// Use this for routes that should only be accessible when any auth is active
func RequireAuth(cfg *config.AuthConfig) fiber.Handler {
return func(c fiber.Ctx) error {
if cfg.Mode == "none" {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Authentication is required but not configured"),
)
}
return c.Next()
}
}
// RequireAdmin is middleware that checks if the user has admin role (OIDC only)
func RequireAdmin(authService *auth.AuthService) fiber.Handler {
return func(c fiber.Ctx) error {
// Get user info from context (set by AuthMiddleware)
userInfoInterface := c.Locals("userInfo")
if userInfoInterface == nil {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Admin access required"),
)
}
userInfo, ok := userInfoInterface.(*auth.UserInfo)
if !ok {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Admin access required"),
)
}
// Check if user has admin role
if !authService.IsAdmin(userInfo) {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Admin role required"),
)
}
return c.Next()
}
}
+64
View File
@@ -0,0 +1,64 @@
package middleware
import (
"strings"
"Noooste/garage-ui/internal/config"
"github.com/gofiber/fiber/v3"
)
// CORSMiddleware creates a CORS middleware from configuration
func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler {
// If CORS is disabled, return a no-op middleware
if !cfg.Enabled {
return func(c fiber.Ctx) error {
return c.Next()
}
}
return func(c fiber.Ctx) error {
origin := c.Get("Origin")
// Check if origin is allowed
if origin != "" && isAllowedOrigin(origin, cfg.AllowedOrigins) {
// Set CORS headers
c.Set("Access-Control-Allow-Origin", origin)
if cfg.AllowCredentials {
c.Set("Access-Control-Allow-Credentials", "true")
}
// Set allowed methods
if len(cfg.AllowedMethods) > 0 {
c.Set("Access-Control-Allow-Methods", strings.Join(cfg.AllowedMethods, ", "))
}
// Set allowed headers
if len(cfg.AllowedHeaders) > 0 {
c.Set("Access-Control-Allow-Headers", strings.Join(cfg.AllowedHeaders, ", "))
}
// Set max age for preflight cache
if cfg.MaxAge > 0 {
c.Set("Access-Control-Max-Age", string(rune(cfg.MaxAge)))
}
}
// Handle preflight requests
if c.Method() == "OPTIONS" {
return c.SendStatus(fiber.StatusNoContent)
}
return c.Next()
}
}
// isAllowedOrigin checks if an origin is in the allowed list
func isAllowedOrigin(origin string, allowedOrigins []string) bool {
for _, allowed := range allowedOrigins {
if allowed == "*" || allowed == origin {
return true
}
}
return false
}
+56
View File
@@ -0,0 +1,56 @@
package models
// CreateBucketRequest represents a request to create a new bucket
type CreateBucketRequest struct {
Name string `json:"name" validate:"required"`
Region string `json:"region,omitempty"`
}
// DeleteBucketRequest represents a request to delete a bucket
type DeleteBucketRequest struct {
Name string `json:"name" validate:"required"`
}
// ListObjectsRequest represents a request to list objects in a bucket
type ListObjectsRequest struct {
Bucket string `json:"bucket" validate:"required"`
Prefix string `json:"prefix,omitempty"`
MaxKeys int `json:"max_keys,omitempty"`
Marker string `json:"marker,omitempty"`
}
// UploadObjectRequest represents metadata for an object upload
// Note: The actual file data comes from multipart form or request body
type UploadObjectRequest struct {
Bucket string `json:"bucket" validate:"required"`
Key string `json:"key" validate:"required"`
ContentType string `json:"content_type,omitempty"`
}
// DeleteObjectRequest represents a request to delete an object
type DeleteObjectRequest struct {
Bucket string `json:"bucket" validate:"required"`
Key string `json:"key" validate:"required"`
}
// GetObjectRequest represents a request to get/download an object
type GetObjectRequest struct {
Bucket string `json:"bucket" validate:"required"`
Key string `json:"key" validate:"required"`
}
// CreateUserRequest represents a request to create a new user/key
type CreateUserRequest struct {
Name string `json:"name,omitempty"`
}
// DeleteUserRequest represents a request to delete a user/key
type DeleteUserRequest struct {
AccessKey string `json:"access_key" validate:"required"`
}
// UpdateUserRequest represents a request to update user permissions
type UpdateUserRequest struct {
AccessKey string `json:"access_key" validate:"required"`
Permissions []string `json:"permissions,omitempty"`
}
+126
View File
@@ -0,0 +1,126 @@
package models
import "time"
// APIResponse is the standard response structure for all API endpoints
type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error *APIError `json:"error,omitempty"`
}
// APIError represents an error in the API response
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
}
// HealthResponse represents the health check response
type HealthResponse struct {
Status string `json:"status"`
Timestamp time.Time `json:"timestamp"`
Version string `json:"version"`
}
// BucketInfo represents information about a bucket
type BucketInfo struct {
Name string `json:"name"`
CreationDate time.Time `json:"creation_date"`
Region string `json:"region,omitempty"`
}
// BucketListResponse represents a list of buckets
type BucketListResponse struct {
Buckets []BucketInfo `json:"buckets"`
Count int `json:"count"`
}
// 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"`
}
// ObjectListResponse represents a list of objects in a bucket
type ObjectListResponse struct {
Bucket string `json:"bucket"`
Objects []ObjectInfo `json:"objects"`
Count int `json:"count"`
IsTruncated bool `json:"is_truncated"`
NextMarker string `json:"next_marker,omitempty"`
}
// ObjectUploadResponse represents the response after uploading an object
type ObjectUploadResponse struct {
Bucket string `json:"bucket"`
Key string `json:"key"`
ETag string `json:"etag"`
Size int64 `json:"size"`
ContentType string `json:"content_type"`
}
// ObjectDeleteResponse represents the response after deleting an object
type ObjectDeleteResponse struct {
Bucket string `json:"bucket"`
Key string `json:"key"`
Deleted bool `json:"deleted"`
}
// UserInfo represents information about a Garage user (key pair)
type UserInfo struct {
AccessKey string `json:"access_key"`
Name string `json:"name,omitempty"`
Permissions []string `json:"permissions,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
}
// UserListResponse represents a list of users/keys
type UserListResponse struct {
Users []UserInfo `json:"users"`
Count int `json:"count"`
}
// Helper functions to create standard responses
// SuccessResponse creates a successful API response
func SuccessResponse(data interface{}) APIResponse {
return APIResponse{
Success: true,
Data: data,
Error: nil,
}
}
// ErrorResponse creates an error API response
func ErrorResponse(code, message string) APIResponse {
return APIResponse{
Success: false,
Data: nil,
Error: &APIError{
Code: code,
Message: message,
},
}
}
// Common error codes
const (
ErrCodeBadRequest = "BAD_REQUEST"
ErrCodeUnauthorized = "UNAUTHORIZED"
ErrCodeForbidden = "FORBIDDEN"
ErrCodeNotFound = "NOT_FOUND"
ErrCodeConflict = "CONFLICT"
ErrCodeInternalError = "INTERNAL_ERROR"
ErrCodeBucketExists = "BUCKET_ALREADY_EXISTS"
ErrCodeBucketNotFound = "BUCKET_NOT_FOUND"
ErrCodeObjectNotFound = "OBJECT_NOT_FOUND"
ErrCodeInvalidBucketName = "INVALID_BUCKET_NAME"
ErrCodeInvalidObjectKey = "INVALID_OBJECT_KEY"
ErrCodeUploadFailed = "UPLOAD_FAILED"
ErrCodeDeleteFailed = "DELETE_FAILED"
ErrCodeListFailed = "LIST_FAILED"
)
+170
View File
@@ -0,0 +1,170 @@
package routes
import (
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/handlers"
"Noooste/garage-ui/internal/middleware"
"github.com/gofiber/fiber/v3"
)
// SetupRoutes configures all API routes
func SetupRoutes(
app *fiber.App,
cfg *config.Config,
authService *auth.AuthService,
healthHandler *handlers.HealthHandler,
bucketHandler *handlers.BucketHandler,
objectHandler *handlers.ObjectHandler,
userHandler *handlers.UserHandler,
) {
// Apply CORS middleware globally
app.Use(middleware.CORSMiddleware(&cfg.CORS))
// Health check endpoint (no auth required)
app.Get("/health", healthHandler.Check)
app.Get("/api/v1/health", healthHandler.Check)
// API v1 group
api := app.Group("/api/v1")
// Apply authentication middleware to all API routes
api.Use(middleware.AuthMiddleware(&cfg.Auth, authService))
// Bucket routes
buckets := api.Group("/buckets")
{
buckets.Get("/", bucketHandler.ListBuckets) // List all buckets
buckets.Post("/", bucketHandler.CreateBucket) // Create a new bucket
buckets.Get("/:name", bucketHandler.GetBucketInfo) // Get bucket info
buckets.Delete("/:name", bucketHandler.DeleteBucket) // Delete a bucket
}
// Object routes
objects := api.Group("/buckets/:bucket/objects")
{
objects.Get("/", objectHandler.ListObjects) // List objects in bucket
objects.Post("/", objectHandler.UploadObject) // Upload object (multipart)
objects.Delete("/", objectHandler.DeleteMultipleObjects) // Delete multiple objects
objects.Get("/:key", objectHandler.GetObject) // Download object
objects.Put("/:key", objectHandler.UploadObjectStream) // Upload object (stream)
objects.Delete("/:key", objectHandler.DeleteObject) // Delete object
objects.Head("/:key", objectHandler.GetObjectMetadata) // Get object metadata
objects.Post("/:key/presign", objectHandler.GetPresignedURL) // Generate pre-signed URL
}
// User/Key management routes
users := api.Group("/users")
{
users.Get("/", userHandler.ListUsers) // List all users/keys
users.Post("/", userHandler.CreateUser) // Create new user/key
users.Get("/:access_key", userHandler.GetUser) // Get user info
users.Delete("/:access_key", userHandler.DeleteUser) // Delete user/key
users.Patch("/:access_key", userHandler.UpdateUserPermissions) // Update user permissions
}
// OIDC authentication routes (only if OIDC is enabled)
if cfg.Auth.Mode == "oidc" && cfg.Auth.OIDC.Enabled {
authRoutes := app.Group("/auth")
{
// Login endpoint - redirects to OIDC provider
authRoutes.Get("/login", func(c fiber.Ctx) error {
// Generate state token for CSRF protection
state := "random-state-token" // In production, use a secure random token
authURL, err := authService.GetAuthorizationURL(state)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Failed to generate login URL",
})
}
return c.Redirect().To(authURL)
})
// Callback endpoint - handles OIDC redirect after login
authRoutes.Get("/callback", func(c fiber.Ctx) error {
// Get authorization code from query
code := c.Query("code")
if code == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Authorization code is required",
})
}
// Exchange code for tokens
ctx := c.Context()
token, err := authService.ExchangeCode(ctx, code)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "Failed to exchange authorization code",
})
}
// Extract ID token from OAuth2 token
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "No ID token in response",
})
}
// Verify ID token and get user info
userInfo, err := authService.VerifyIDToken(ctx, rawIDToken)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "Invalid ID token",
})
}
// In production, you should:
// 1. Create a session and store it in Redis/memory
// 2. Set a secure session cookie
// 3. Redirect to the frontend with the session
// For now, just set the ID token as a cookie (not recommended for production)
c.Cookie(&fiber.Cookie{
Name: cfg.Auth.OIDC.CookieName,
Value: rawIDToken,
MaxAge: cfg.Auth.OIDC.SessionMaxAge,
Secure: cfg.Auth.OIDC.CookieSecure,
HTTPOnly: cfg.Auth.OIDC.CookieHTTPOnly,
SameSite: cfg.Auth.OIDC.CookieSameSite,
})
return c.JSON(fiber.Map{
"success": true,
"user": userInfo,
})
})
// Logout endpoint
authRoutes.Post("/logout", func(c fiber.Ctx) error {
// Clear session cookie
c.Cookie(&fiber.Cookie{
Name: cfg.Auth.OIDC.CookieName,
Value: "",
MaxAge: -1,
})
return c.JSON(fiber.Map{
"success": true,
"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,
})
})
}
}
}
+312
View File
@@ -0,0 +1,312 @@
package services
import (
"context"
"fmt"
"io"
"time"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
)
// S3Service handles all S3 operations with Garage
type S3Service struct {
client *s3.Client
config *config.GarageConfig
}
// NewS3Service creates a new S3 service instance
func NewS3Service(cfg *config.GarageConfig) *S3Service {
// Create AWS credentials from Garage config
creds := credentials.NewStaticCredentialsProvider(
cfg.AccessKey,
cfg.SecretKey,
"", // session token (not used for Garage)
)
// Configure S3 client for Garage
s3Config := aws.Config{
Region: cfg.Region,
Credentials: creds,
}
// Create S3 client with custom endpoint resolver for Garage
client := s3.NewFromConfig(s3Config, func(o *s3.Options) {
o.BaseEndpoint = aws.String(cfg.Endpoint)
o.UsePathStyle = cfg.ForcePathStyle
})
return &S3Service{
client: client,
config: cfg,
}
}
// ListBuckets retrieves all buckets from Garage
func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse, error) {
// Call S3 ListBuckets API
result, err := s.client.ListBuckets(ctx, &s3.ListBucketsInput{})
if err != nil {
return nil, fmt.Errorf("failed to list buckets: %w", err)
}
// Convert S3 buckets to our model
buckets := make([]models.BucketInfo, 0, len(result.Buckets))
for _, bucket := range result.Buckets {
buckets = append(buckets, models.BucketInfo{
Name: aws.ToString(bucket.Name),
CreationDate: aws.ToTime(bucket.CreationDate),
})
}
return &models.BucketListResponse{
Buckets: buckets,
Count: len(buckets),
}, nil
}
// CreateBucket creates a new bucket in Garage
func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
// Create bucket input
input := &s3.CreateBucketInput{
Bucket: aws.String(bucketName),
}
// Call S3 CreateBucket API
_, err := s.client.CreateBucket(ctx, input)
if err != nil {
return fmt.Errorf("failed to create bucket %s: %w", bucketName, err)
}
return nil
}
// DeleteBucket deletes a bucket from Garage
func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
// Call S3 DeleteBucket API
_, err := s.client.DeleteBucket(ctx, &s3.DeleteBucketInput{
Bucket: aws.String(bucketName),
})
if err != nil {
return fmt.Errorf("failed to delete bucket %s: %w", bucketName, err)
}
return nil
}
// BucketExists checks if a bucket exists
func (s *S3Service) BucketExists(ctx context.Context, bucketName string) (bool, error) {
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{
Bucket: aws.String(bucketName),
})
if err != nil {
// Check if it's a "not found" error
return false, nil
}
return true, nil
}
// ListObjects lists objects in a bucket with optional prefix filter
func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int) (*models.ObjectListResponse, error) {
// Set default max keys if not specified
if maxKeys <= 0 {
maxKeys = 1000
}
// Create list objects input
input := &s3.ListObjectsV2Input{
Bucket: aws.String(bucketName),
MaxKeys: aws.Int32(int32(maxKeys)),
}
if prefix != "" {
input.Prefix = aws.String(prefix)
}
// Call S3 ListObjectsV2 API
result, err := s.client.ListObjectsV2(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err)
}
// Convert S3 objects to our model
objects := make([]models.ObjectInfo, 0, len(result.Contents))
for _, obj := range result.Contents {
objects = append(objects, models.ObjectInfo{
Key: aws.ToString(obj.Key),
Size: aws.ToInt64(obj.Size),
LastModified: aws.ToTime(obj.LastModified),
ETag: aws.ToString(obj.ETag),
StorageClass: string(obj.StorageClass),
})
}
return &models.ObjectListResponse{
Bucket: bucketName,
Objects: objects,
Count: len(objects),
IsTruncated: aws.ToBool(result.IsTruncated),
NextMarker: aws.ToString(result.NextContinuationToken),
}, nil
}
// UploadObject uploads an object to a bucket
func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) {
// Create put object input
input := &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
Body: body,
}
if contentType != "" {
input.ContentType = aws.String(contentType)
}
// Call S3 PutObject API
result, err := s.client.PutObject(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to upload object %s to bucket %s: %w", key, bucketName, err)
}
// Get object metadata to return size
headResult, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
var size int64
if err == nil {
size = aws.ToInt64(headResult.ContentLength)
}
return &models.ObjectUploadResponse{
Bucket: bucketName,
Key: key,
ETag: aws.ToString(result.ETag),
Size: size,
ContentType: contentType,
}, nil
}
// GetObject retrieves an object from a bucket
func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) {
// Call S3 GetObject API
result, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
if err != nil {
return nil, nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err)
}
// Create object info
objectInfo := &models.ObjectInfo{
Key: key,
Size: aws.ToInt64(result.ContentLength),
LastModified: aws.ToTime(result.LastModified),
ETag: aws.ToString(result.ETag),
ContentType: aws.ToString(result.ContentType),
}
return result.Body, objectInfo, nil
}
// DeleteObject deletes an object from a bucket
func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error {
// Call S3 DeleteObject API
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
if err != nil {
return fmt.Errorf("failed to delete object %s from bucket %s: %w", key, bucketName, err)
}
return nil
}
// ObjectExists checks if an object exists in a bucket
func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) {
_, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
if err != nil {
return false, nil
}
return true, nil
}
// GetObjectMetadata retrieves metadata for an object without downloading it
func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) {
result, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
if err != nil {
return nil, fmt.Errorf("failed to get metadata for object %s in bucket %s: %w", key, bucketName, err)
}
return &models.ObjectInfo{
Key: key,
Size: aws.ToInt64(result.ContentLength),
LastModified: aws.ToTime(result.LastModified),
ETag: aws.ToString(result.ETag),
ContentType: aws.ToString(result.ContentType),
}, nil
}
// DeleteMultipleObjects deletes multiple objects from a bucket
func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error {
if len(keys) == 0 {
return nil
}
// Create delete objects for batch deletion
objects := make([]types.ObjectIdentifier, len(keys))
for i, key := range keys {
objects[i] = types.ObjectIdentifier{
Key: aws.String(key),
}
}
// Call S3 DeleteObjects API (batch delete)
_, err := s.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
Bucket: aws.String(bucketName),
Delete: &types.Delete{
Objects: objects,
Quiet: aws.Bool(false),
},
})
if err != nil {
return fmt.Errorf("failed to delete multiple objects from bucket %s: %w", bucketName, err)
}
return nil
}
// GetPresignedURL generates a pre-signed URL for temporary access to an object
// This is useful for sharing files without exposing credentials
func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error) {
// Create presign client
presignClient := s3.NewPresignClient(s.client)
// Generate presigned GET request
presignResult, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
}, func(opts *s3.PresignOptions) {
opts.Expires = expiresIn
})
if err != nil {
return "", fmt.Errorf("failed to generate presigned URL for %s/%s: %w", bucketName, key, err)
}
return presignResult.URL, nil
}