diff --git a/go.mod b/go.mod index 30241d87f..96e7cdf3f 100644 --- a/go.mod +++ b/go.mod @@ -131,8 +131,6 @@ require ( github.com/cognusion/imaging v1.0.2 github.com/fluent/fluent-logger-golang v1.10.1 github.com/getsentry/sentry-go v0.42.0 - github.com/gin-contrib/sessions v1.0.4 - github.com/gin-gonic/gin v1.11.0 github.com/go-ldap/ldap/v3 v3.4.12 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/flatbuffers/go v0.0.0-20230108230133-3b8644d32c50 @@ -214,6 +212,7 @@ require ( github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dromara/dongle v1.0.1 // indirect + github.com/gin-gonic/gin v1.11.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect @@ -260,7 +259,6 @@ require ( github.com/pierrre/geohash v1.0.0 // indirect github.com/pquerna/otp v1.5.0 // indirect github.com/pterm/pterm v0.12.81 // indirect - github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.0 // indirect github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 // indirect github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 // indirect @@ -285,6 +283,7 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect go.opentelemetry.io/otel/exporters/zipkin v1.36.0 // indirect go.opentelemetry.io/proto/otlp v1.7.0 // indirect + go.uber.org/mock v0.5.2 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.32.0 // indirect @@ -347,15 +346,12 @@ require ( github.com/bradenaw/juniper v0.15.3 // indirect github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect github.com/buengese/sgzip v0.1.1 // indirect - github.com/bytedance/sonic v1.14.0 // indirect - github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/calebcase/tmpfile v1.0.3 // indirect github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cloudinary/cloudinary-go/v2 v2.13.0 // indirect github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc // indirect github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc // indirect - github.com/cloudwego/base64x v0.1.6 // indirect github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect github.com/colinmarc/hdfs/v2 v2.4.0 // indirect github.com/creasty/defaults v1.8.0 // indirect @@ -375,7 +371,6 @@ require ( github.com/flynn/noise v1.1.0 // indirect github.com/gabriel-vasile/mimetype v1.4.11 // indirect github.com/geoffgarside/ber v1.2.0 // indirect - github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect @@ -395,10 +390,9 @@ require ( github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect - github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/schema v1.4.1 // indirect github.com/gorilla/securecookie v1.1.2 // indirect - github.com/gorilla/sessions v1.4.0 // indirect + github.com/gorilla/sessions v1.4.0 github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect @@ -480,9 +474,7 @@ require ( github.com/tinylib/msgp v1.5.0 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect - github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twmb/murmur3 v1.1.8 // indirect - github.com/ugorji/go/codec v1.3.0 // indirect github.com/unknwon/goconfig v1.0.0 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect @@ -508,7 +500,6 @@ require ( go.opentelemetry.io/otel/trace v1.38.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect - golang.org/x/arch v0.20.0 // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 // indirect diff --git a/go.sum b/go.sum index 87ba3c09b..f129e4cd4 100644 --- a/go.sum +++ b/go.sum @@ -1061,8 +1061,6 @@ github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNe github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8= github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U= -github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= @@ -1314,8 +1312,6 @@ github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= -github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= -github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= diff --git a/weed/admin/dash/admin_data.go b/weed/admin/dash/admin_data.go index 118665d7d..3fce7bd55 100644 --- a/weed/admin/dash/admin_data.go +++ b/weed/admin/dash/admin_data.go @@ -6,7 +6,6 @@ import ( "sort" "time" - "github.com/gin-gonic/gin" "github.com/seaweedfs/seaweedfs/weed/cluster" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/iam" @@ -185,28 +184,28 @@ func (s *AdminServer) GetAdminData(username string) (AdminData, error) { } // ShowAdmin displays the main admin page (now uses GetAdminData) -func (s *AdminServer) ShowAdmin(c *gin.Context) { - username := c.GetString("username") +func (s *AdminServer) ShowAdmin(w http.ResponseWriter, r *http.Request) { + username := UsernameFromContext(r.Context()) adminData, err := s.GetAdminData(username) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get admin data: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get admin data: "+err.Error()) return } // Return JSON for API calls - c.JSON(http.StatusOK, adminData) + writeJSON(w, http.StatusOK, adminData) } // ShowOverview displays cluster overview -func (s *AdminServer) ShowOverview(c *gin.Context) { +func (s *AdminServer) ShowOverview(w http.ResponseWriter, r *http.Request) { topology, err := s.GetClusterTopology() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusInternalServerError, err.Error()) return } - c.JSON(http.StatusOK, topology) + writeJSON(w, http.StatusOK, topology) } // getMasterNodesStatus checks status of all master nodes diff --git a/weed/admin/dash/admin_server.go b/weed/admin/dash/admin_server.go index 0a2eaee2f..d0f982189 100644 --- a/weed/admin/dash/admin_server.go +++ b/weed/admin/dash/admin_server.go @@ -7,8 +7,6 @@ import ( "sort" "strings" "time" - - "github.com/gin-gonic/gin" "github.com/seaweedfs/seaweedfs/weed/admin/maintenance" adminplugin "github.com/seaweedfs/seaweedfs/weed/admin/plugin" "github.com/seaweedfs/seaweedfs/weed/cluster" @@ -817,18 +815,18 @@ func (s *AdminServer) GetClusterBrokers() (*ClusterBrokersData, error) { // VacuumVolume method moved to volume_management.go // TriggerTopicRetentionPurgeAPI triggers topic retention purge via HTTP API -func (as *AdminServer) TriggerTopicRetentionPurgeAPI(c *gin.Context) { +func (as *AdminServer) TriggerTopicRetentionPurgeAPI(w http.ResponseWriter, r *http.Request) { err := as.TriggerTopicRetentionPurge() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusInternalServerError, err.Error()) return } - c.JSON(http.StatusOK, gin.H{"message": "Topic retention purge triggered successfully"}) + writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Topic retention purge triggered successfully"}) } // GetConfigInfo returns information about the admin configuration -func (as *AdminServer) GetConfigInfo(c *gin.Context) { +func (as *AdminServer) GetConfigInfo(w http.ResponseWriter, r *http.Request) { configInfo := as.configPersistence.GetConfigInfo() // Add additional admin server info @@ -846,7 +844,7 @@ func (as *AdminServer) GetConfigInfo(c *gin.Context) { configInfo["maintenance_running"] = false } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "config_info": configInfo, "title": "Configuration Information", }) diff --git a/weed/admin/dash/auth_middleware.go b/weed/admin/dash/auth_middleware.go index 49adb8470..dd2f9abed 100644 --- a/weed/admin/dash/auth_middleware.go +++ b/weed/admin/dash/auth_middleware.go @@ -4,81 +4,91 @@ import ( "crypto/subtle" "net/http" - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" + "github.com/gorilla/sessions" "github.com/seaweedfs/seaweedfs/weed/glog" ) -// ShowLogin displays the login page -func (s *AdminServer) ShowLogin(c *gin.Context) { - // If authentication is not required, redirect to admin - session := sessions.Default(c) - if session.Get("authenticated") == true { - c.Redirect(http.StatusSeeOther, "/admin") - return - } - - // For now, return a simple login form as JSON - c.HTML(http.StatusOK, "login.html", gin.H{ - "title": "SeaweedFS Admin Login", - "error": c.Query("error"), - }) +// ShowLogin displays the login page. +func (s *AdminServer) ShowLogin(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/login", http.StatusSeeOther) } -// HandleLogin handles login form submission -func (s *AdminServer) HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword string) gin.HandlerFunc { - return func(c *gin.Context) { - loginUsername := c.PostForm("username") - loginPassword := c.PostForm("password") +// HandleLogin handles login form submission. +func (s *AdminServer) HandleLogin(store sessions.Store, adminUser, adminPassword, readOnlyUser, readOnlyPassword string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/login?error=Invalid form submission", http.StatusSeeOther) + return + } + session, err := store.Get(r, sessionName) + if err != nil { + http.Redirect(w, r, "/login?error=Unable to create session. Please try again or contact administrator.", http.StatusSeeOther) + return + } + + if err := ValidateSessionCSRFToken(session, r); err != nil { + http.Redirect(w, r, "/login?error=Invalid CSRF token", http.StatusSeeOther) + return + } + + loginUsername := r.FormValue("username") + loginPassword := r.FormValue("password") var role string var authenticated bool - // Check admin credentials + // Check admin credentials. if adminPassword != "" && loginUsername == adminUser && subtle.ConstantTimeCompare([]byte(loginPassword), []byte(adminPassword)) == 1 { role = "admin" authenticated = true } else if readOnlyPassword != "" && loginUsername == readOnlyUser && subtle.ConstantTimeCompare([]byte(loginPassword), []byte(readOnlyPassword)) == 1 { - // Check read-only credentials + // Check read-only credentials. role = "readonly" authenticated = true } if authenticated { - session := sessions.Default(c) - // Clear any existing invalid session data before setting new values - session.Clear() - session.Set("authenticated", true) - session.Set("username", loginUsername) - session.Set("role", role) + for key := range session.Values { + delete(session.Values, key) + } + session.Values["authenticated"] = true + session.Values["username"] = loginUsername + session.Values["role"] = role csrfToken, err := generateCSRFToken() if err != nil { - c.Redirect(http.StatusSeeOther, "/login?error=Unable to create session. Please try again or contact administrator.") + http.Redirect(w, r, "/login?error=Unable to create session. Please try again or contact administrator.", http.StatusSeeOther) return } - session.Set(sessionCSRFTokenKey, csrfToken) - if err := session.Save(); err != nil { - // Log the detailed error server-side for diagnostics + session.Values[sessionCSRFTokenKey] = csrfToken + if err := session.Save(r, w); err != nil { + // Log the detailed error server-side for diagnostics. glog.Errorf("Failed to save session for user %s: %v", loginUsername, err) - c.Redirect(http.StatusSeeOther, "/login?error=Unable to create session. Please try again or contact administrator.") + http.Redirect(w, r, "/login?error=Unable to create session. Please try again or contact administrator.", http.StatusSeeOther) return } - c.Redirect(http.StatusSeeOther, "/admin") + http.Redirect(w, r, "/admin", http.StatusSeeOther) return } - // Authentication failed - c.Redirect(http.StatusSeeOther, "/login?error=Invalid credentials") + // Authentication failed. + http.Redirect(w, r, "/login?error=Invalid credentials", http.StatusSeeOther) } } -// HandleLogout handles user logout -func (s *AdminServer) HandleLogout(c *gin.Context) { - session := sessions.Default(c) - session.Clear() - if err := session.Save(); err != nil { +// HandleLogout handles user logout. +func (s *AdminServer) HandleLogout(store sessions.Store, w http.ResponseWriter, r *http.Request) { + session, err := store.Get(r, sessionName) + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + for key := range session.Values { + delete(session.Values, key) + } + session.Options.MaxAge = -1 + if err := session.Save(r, w); err != nil { glog.Warningf("Failed to save session during logout: %v", err) } - c.Redirect(http.StatusSeeOther, "/login") + http.Redirect(w, r, "/login", http.StatusSeeOther) } diff --git a/weed/admin/dash/bucket_management.go b/weed/admin/dash/bucket_management.go index 6c8fc9205..4b7d34361 100644 --- a/weed/admin/dash/bucket_management.go +++ b/weed/admin/dash/bucket_management.go @@ -8,7 +8,7 @@ import ( "strings" "time" - "github.com/gin-gonic/gin" + "github.com/gorilla/mux" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/s3api" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" @@ -29,7 +29,7 @@ type S3BucketsData struct { } type CreateBucketRequest struct { - Name string `json:"name" binding:"required"` + Name string `json:"name"` // validated manually in CreateBucket Region string `json:"region"` QuotaSize int64 `json:"quota_size"` // Quota size in bytes QuotaUnit string `json:"quota_unit"` // Unit: MB, GB, TB @@ -45,47 +45,51 @@ type CreateBucketRequest struct { // S3 Bucket Management Handlers // ShowS3Buckets displays the Object Store buckets management page -func (s *AdminServer) ShowS3Buckets(c *gin.Context) { - username := c.GetString("username") +func (s *AdminServer) ShowS3Buckets(w http.ResponseWriter, r *http.Request) { + username := UsernameFromContext(r.Context()) data, err := s.GetS3BucketsData() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get Object Store buckets: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get Object Store buckets: "+err.Error()) return } data.Username = username - c.JSON(http.StatusOK, data) + writeJSON(w, http.StatusOK, data) } // ShowBucketDetails displays detailed information about a specific bucket -func (s *AdminServer) ShowBucketDetails(c *gin.Context) { - bucketName := c.Param("bucket") +func (s *AdminServer) ShowBucketDetails(w http.ResponseWriter, r *http.Request) { + bucketName := mux.Vars(r)["bucket"] if bucketName == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"}) + writeJSONError(w, http.StatusBadRequest, "Bucket name is required") return } details, err := s.GetBucketDetails(bucketName) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get bucket details: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get bucket details: "+err.Error()) return } - c.JSON(http.StatusOK, details) + writeJSON(w, http.StatusOK, details) } // CreateBucket creates a new S3 bucket -func (s *AdminServer) CreateBucket(c *gin.Context) { +func (s *AdminServer) CreateBucket(w http.ResponseWriter, r *http.Request) { var req CreateBucketRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + if strings.TrimSpace(req.Name) == "" { + writeJSONError(w, http.StatusBadRequest, "Bucket name is required") return } // Validate bucket name (basic validation) if len(req.Name) < 3 || len(req.Name) > 63 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name must be between 3 and 63 characters"}) + writeJSONError(w, http.StatusBadRequest, "Bucket name must be between 3 and 63 characters") return } @@ -96,42 +100,47 @@ func (s *AdminServer) CreateBucket(c *gin.Context) { // Validate object lock mode if req.ObjectLockMode != "GOVERNANCE" && req.ObjectLockMode != "COMPLIANCE" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Object lock mode must be either GOVERNANCE or COMPLIANCE"}) + writeJSONError(w, http.StatusBadRequest, "Object lock mode must be either GOVERNANCE or COMPLIANCE") return } // Validate retention duration if default retention is enabled if req.SetDefaultRetention { if req.ObjectLockDuration <= 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Object lock duration must be greater than 0 days when default retention is enabled"}) + writeJSONError(w, http.StatusBadRequest, "Object lock duration must be greater than 0 days when default retention is enabled") return } } } - // Convert quota to bytes - quotaBytes := convertQuotaToBytes(req.QuotaSize, req.QuotaUnit) + normalizedUnit, err := normalizeQuotaUnit(req.QuotaUnit) + if err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) + return + } + req.QuotaUnit = normalizedUnit + quotaBytes := convertQuotaToBytes(req.QuotaSize, normalizedUnit) // Validate quota: if enabled, size must be greater than 0 if req.QuotaEnabled && quotaBytes <= 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Quota size must be greater than 0 when quota is enabled"}) + writeJSONError(w, http.StatusBadRequest, "Quota size must be greater than 0 when quota is enabled") return } // Sanitize owner: trim whitespace and enforce max length owner := strings.TrimSpace(req.Owner) if len(owner) > MaxOwnerNameLength { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength)}) + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength)) return } - err := s.CreateS3BucketWithObjectLock(req.Name, quotaBytes, req.QuotaEnabled, req.VersioningEnabled, req.ObjectLockEnabled, req.ObjectLockMode, req.SetDefaultRetention, req.ObjectLockDuration, owner) + err = s.CreateS3BucketWithObjectLock(req.Name, quotaBytes, req.QuotaEnabled, req.VersioningEnabled, req.ObjectLockEnabled, req.ObjectLockMode, req.SetDefaultRetention, req.ObjectLockDuration, owner) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create bucket: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to create bucket: "+err.Error()) return } - c.JSON(http.StatusCreated, gin.H{ + writeJSON(w, http.StatusCreated, map[string]interface{}{ "message": "Bucket created successfully", "bucket": req.Name, "quota_size": req.QuotaSize, @@ -146,10 +155,10 @@ func (s *AdminServer) CreateBucket(c *gin.Context) { } // UpdateBucketQuota updates the quota settings for a bucket -func (s *AdminServer) UpdateBucketQuota(c *gin.Context) { - bucketName := c.Param("bucket") +func (s *AdminServer) UpdateBucketQuota(w http.ResponseWriter, r *http.Request) { + bucketName := mux.Vars(r)["bucket"] if bucketName == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"}) + writeJSONError(w, http.StatusBadRequest, "Bucket name is required") return } @@ -158,21 +167,32 @@ func (s *AdminServer) UpdateBucketQuota(c *gin.Context) { QuotaUnit string `json:"quota_unit"` QuotaEnabled bool `json:"quota_enabled"` } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } + if req.QuotaEnabled && req.QuotaSize <= 0 { + writeJSONError(w, http.StatusBadRequest, "quota_size must be > 0 when quota_enabled is true") + return + } + + normalizedUnit, err := normalizeQuotaUnit(req.QuotaUnit) + if err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) + return + } + req.QuotaUnit = normalizedUnit // Convert quota to bytes - quotaBytes := convertQuotaToBytes(req.QuotaSize, req.QuotaUnit) + quotaBytes := convertQuotaToBytes(req.QuotaSize, normalizedUnit) - err := s.SetBucketQuota(bucketName, quotaBytes, req.QuotaEnabled) + err = s.SetBucketQuota(bucketName, quotaBytes, req.QuotaEnabled) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bucket quota: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to update bucket quota: "+err.Error()) return } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "message": "Bucket quota updated successfully", "bucket": bucketName, "quota_size": req.QuotaSize, @@ -182,30 +202,30 @@ func (s *AdminServer) UpdateBucketQuota(c *gin.Context) { } // DeleteBucket deletes an S3 bucket -func (s *AdminServer) DeleteBucket(c *gin.Context) { - bucketName := c.Param("bucket") +func (s *AdminServer) DeleteBucket(w http.ResponseWriter, r *http.Request) { + bucketName := mux.Vars(r)["bucket"] if bucketName == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"}) + writeJSONError(w, http.StatusBadRequest, "Bucket name is required") return } err := s.DeleteS3Bucket(bucketName) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete bucket: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to delete bucket: "+err.Error()) return } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "message": "Bucket deleted successfully", "bucket": bucketName, }) } // UpdateBucketOwner updates the owner of an S3 bucket -func (s *AdminServer) UpdateBucketOwner(c *gin.Context) { - bucketName := c.Param("bucket") +func (s *AdminServer) UpdateBucketOwner(w http.ResponseWriter, r *http.Request) { + bucketName := mux.Vars(r)["bucket"] if bucketName == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"}) + writeJSONError(w, http.StatusBadRequest, "Bucket name is required") return } @@ -213,31 +233,31 @@ func (s *AdminServer) UpdateBucketOwner(c *gin.Context) { var req struct { Owner *string `json:"owner"` } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } // Require owner field to be explicitly provided if req.Owner == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Owner field is required (use empty string to clear owner)"}) + writeJSONError(w, http.StatusBadRequest, "Owner field is required (use empty string to clear owner)") return } // Trim and validate owner owner := strings.TrimSpace(*req.Owner) if len(owner) > MaxOwnerNameLength { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength)}) + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength)) return } err := s.SetBucketOwner(bucketName, owner) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bucket owner: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to update bucket owner: "+err.Error()) return } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "message": "Bucket owner updated successfully", "bucket": bucketName, "owner": owner, @@ -284,14 +304,14 @@ func (s *AdminServer) SetBucketOwner(bucketName string, owner string) error { } // ListBucketsAPI returns the list of buckets as JSON -func (s *AdminServer) ListBucketsAPI(c *gin.Context) { +func (s *AdminServer) ListBucketsAPI(w http.ResponseWriter, r *http.Request) { buckets, err := s.GetS3Buckets() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get buckets: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get buckets: "+err.Error()) return } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "buckets": buckets, "total": len(buckets), }) @@ -303,16 +323,32 @@ func convertQuotaToBytes(size int64, unit string) int64 { return 0 } - switch strings.ToUpper(unit) { + switch unit { case "TB": return size * 1024 * 1024 * 1024 * 1024 case "GB": return size * 1024 * 1024 * 1024 case "MB": return size * 1024 * 1024 + case "KB": + return size * 1024 + case "B": + return size default: - // Default to MB if unit is not recognized - return size * 1024 * 1024 + return 0 + } +} + +func normalizeQuotaUnit(unit string) (string, error) { + normalized := strings.ToUpper(strings.TrimSpace(unit)) + if normalized == "" { + return "MB", nil + } + switch normalized { + case "B", "KB", "MB", "GB", "TB": + return normalized, nil + default: + return "", fmt.Errorf("unsupported quota unit: %s", unit) } } diff --git a/weed/admin/dash/context.go b/weed/admin/dash/context.go new file mode 100644 index 000000000..80aca78ab --- /dev/null +++ b/weed/admin/dash/context.go @@ -0,0 +1,58 @@ +package dash + +import "context" + +type contextKey string + +const ( + contextUsernameKey contextKey = "admin.username" + contextRoleKey contextKey = "admin.role" + contextCSRFKey contextKey = "admin.csrf" +) + +// WithAuthContext stores auth metadata on the request context. +func WithAuthContext(ctx context.Context, username, role, csrfToken string) context.Context { + if username != "" { + ctx = context.WithValue(ctx, contextUsernameKey, username) + } + if role != "" { + ctx = context.WithValue(ctx, contextRoleKey, role) + } + if csrfToken != "" { + ctx = context.WithValue(ctx, contextCSRFKey, csrfToken) + } + return ctx +} + +// UsernameFromContext retrieves the username from context. +func UsernameFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + if value, ok := ctx.Value(contextUsernameKey).(string); ok { + return value + } + return "" +} + +// RoleFromContext retrieves the role from context. +func RoleFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + if value, ok := ctx.Value(contextRoleKey).(string); ok { + return value + } + return "" +} + +// CSRFTokenFromContext retrieves the CSRF token from context. +func CSRFTokenFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + if value, ok := ctx.Value(contextCSRFKey).(string); ok { + return value + } + return "" +} diff --git a/weed/admin/dash/csrf.go b/weed/admin/dash/csrf.go index 81944c4f9..bd36be70d 100644 --- a/weed/admin/dash/csrf.go +++ b/weed/admin/dash/csrf.go @@ -4,10 +4,10 @@ import ( "crypto/rand" "crypto/subtle" "encoding/hex" + "fmt" "net/http" - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" + "github.com/gorilla/sessions" ) const sessionCSRFTokenKey = "csrf_token" @@ -20,41 +20,77 @@ func generateCSRFToken() (string, error) { return hex.EncodeToString(tokenBytes), nil } -func getOrCreateSessionCSRFToken(session sessions.Session) (string, error) { - if existing, ok := session.Get(sessionCSRFTokenKey).(string); ok && existing != "" { +func getOrCreateSessionCSRFToken(session *sessions.Session, r *http.Request, w http.ResponseWriter) (string, error) { + if existing, ok := session.Values[sessionCSRFTokenKey].(string); ok && existing != "" { return existing, nil } token, err := generateCSRFToken() if err != nil { return "", err } - session.Set(sessionCSRFTokenKey, token) - if err := session.Save(); err != nil { + session.Values[sessionCSRFTokenKey] = token + if err := session.Save(r, w); err != nil { return "", err } return token, nil } -func requireSessionCSRFToken(c *gin.Context) bool { - session := sessions.Default(c) - if session.Get("authenticated") != true { +func requireSessionCSRFToken(w http.ResponseWriter, r *http.Request) bool { + expectedToken := CSRFTokenFromContext(r.Context()) + username := UsernameFromContext(r.Context()) + if expectedToken == "" { // Admin UI can run without auth; in that mode CSRF token checks are not applicable. - return true - } - - expectedToken, ok := session.Get(sessionCSRFTokenKey).(string) - if !ok || expectedToken == "" { - c.JSON(http.StatusForbidden, gin.H{"error": "missing CSRF session token"}) + if username == "" { + return true + } + writeJSONError(w, http.StatusForbidden, "missing CSRF session token") return false } - providedToken := c.GetHeader("X-CSRF-Token") - if providedToken == "" { - providedToken = c.PostForm("csrf_token") + providedToken, err := getProvidedCSRFToken(r) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "Failed to parse form: "+err.Error()) + return false } if providedToken == "" || subtle.ConstantTimeCompare([]byte(expectedToken), []byte(providedToken)) != 1 { - c.JSON(http.StatusForbidden, gin.H{"error": "invalid CSRF token"}) + writeJSONError(w, http.StatusForbidden, "invalid CSRF token") return false } return true } + +func getProvidedCSRFToken(r *http.Request) (string, error) { + providedToken := r.Header.Get("X-CSRF-Token") + if providedToken != "" { + return providedToken, nil + } + if err := r.ParseForm(); err != nil { + return "", err + } + return r.FormValue("csrf_token"), nil +} + +func EnsureSessionCSRFToken(session *sessions.Session, r *http.Request, w http.ResponseWriter) (string, error) { + if session == nil { + return "", fmt.Errorf("session is nil") + } + return getOrCreateSessionCSRFToken(session, r, w) +} + +func ValidateSessionCSRFToken(session *sessions.Session, r *http.Request) error { + if session == nil { + return fmt.Errorf("session is nil") + } + expectedToken, _ := session.Values[sessionCSRFTokenKey].(string) + providedToken, err := getProvidedCSRFToken(r) + if err != nil { + return fmt.Errorf("failed to read CSRF token: %w", err) + } + if expectedToken == "" { + return fmt.Errorf("missing session CSRF token") + } + if providedToken == "" || subtle.ConstantTimeCompare([]byte(expectedToken), []byte(providedToken)) != 1 { + return fmt.Errorf("invalid CSRF token") + } + return nil +} diff --git a/weed/admin/dash/http_helpers.go b/weed/admin/dash/http_helpers.go new file mode 100644 index 000000000..c6445972b --- /dev/null +++ b/weed/admin/dash/http_helpers.go @@ -0,0 +1,24 @@ +package dash + +import ( + "io" + "net/http" + + "github.com/seaweedfs/seaweedfs/weed/admin/internal/httputil" +) + +func writeJSON(w http.ResponseWriter, status int, payload interface{}) { + httputil.WriteJSON(w, status, payload) +} + +func writeJSONError(w http.ResponseWriter, status int, message string) { + httputil.WriteJSONError(w, status, message) +} + +func decodeJSONBody(r io.Reader, v interface{}) error { + return httputil.DecodeJSONBody(r, v) +} + +func newJSONMaxReader(w http.ResponseWriter, r *http.Request) io.Reader { + return httputil.NewJSONMaxReader(w, r) +} diff --git a/weed/admin/dash/middleware.go b/weed/admin/dash/middleware.go index 631dd7869..292ecdef2 100644 --- a/weed/admin/dash/middleware.go +++ b/weed/admin/dash/middleware.go @@ -4,109 +4,129 @@ import ( "net/http" "strings" - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" + "github.com/gorilla/mux" + "github.com/gorilla/sessions" ) -// setAuthContext sets username and role in context for use in handlers -func setAuthContext(c *gin.Context, username, role interface{}) { - c.Set("username", username) - if role != nil { - c.Set("role", role) - } else { - // Default to admin for backward compatibility - c.Set("role", "admin") +const sessionName = "admin-session" + +// SessionName returns the cookie session name used by the admin UI. +func SessionName() string { + return sessionName +} + +type sessionValidationErrorKind int + +const ( + sessionValidationErrorKindUnauthenticated sessionValidationErrorKind = iota + sessionValidationErrorKindSessionInit +) + +type sessionValidationError struct { + kind sessionValidationErrorKind + err error +} + +func (e *sessionValidationError) Error() string { + if e.err != nil { + return e.err.Error() } + return "session validation failed" +} + +func (e *sessionValidationError) Unwrap() error { + return e.err } -// RequireAuth checks if user is authenticated -func RequireAuth() gin.HandlerFunc { - return func(c *gin.Context) { - session := sessions.Default(c) - authenticated := session.Get("authenticated") - username := session.Get("username") - role := session.Get("role") - - if authenticated != true || username == nil { - c.Redirect(http.StatusTemporaryRedirect, "/login") - c.Abort() - return - } - - csrfToken, err := getOrCreateSessionCSRFToken(session) - if err != nil { - c.Redirect(http.StatusTemporaryRedirect, "/login?error=Unable to initialize session") - c.Abort() - return - } - - // Set username and role in context for use in handlers - setAuthContext(c, username, role) - c.Set("csrf_token", csrfToken) - c.Next() +func validateSession(store sessions.Store, w http.ResponseWriter, r *http.Request) (string, string, string, error) { + session, err := store.Get(r, sessionName) + if err != nil { + return "", "", "", &sessionValidationError{kind: sessionValidationErrorKindSessionInit, err: err} + } + + authenticated, _ := session.Values["authenticated"].(bool) + username, _ := session.Values["username"].(string) + role, _ := session.Values["role"].(string) + if !authenticated || username == "" { + return "", "", "", &sessionValidationError{kind: sessionValidationErrorKindUnauthenticated} } + + csrfToken, err := getOrCreateSessionCSRFToken(session, r, w) + if err != nil { + return "", "", "", &sessionValidationError{kind: sessionValidationErrorKindSessionInit, err: err} + } + + return username, role, csrfToken, nil } -// RequireAuthAPI checks if user is authenticated for API endpoints -// Returns JSON error instead of redirecting to login page -func RequireAuthAPI() gin.HandlerFunc { - return func(c *gin.Context) { - session := sessions.Default(c) - authenticated := session.Get("authenticated") - username := session.Get("username") - role := session.Get("role") - - if authenticated != true || username == nil { - c.JSON(http.StatusUnauthorized, gin.H{ - "error": "Authentication required", - "message": "Please log in to access this endpoint", - }) - c.Abort() - return - } - - csrfToken, err := getOrCreateSessionCSRFToken(session) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to initialize session", - "message": "Unable to initialize CSRF token", - }) - c.Abort() - return - } - - // Set username and role in context for use in handlers - setAuthContext(c, username, role) - c.Set("csrf_token", csrfToken) - c.Next() +// RequireAuth checks if user is authenticated. +func RequireAuth(store sessions.Store) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, role, csrfToken, err := validateSession(store, w, r) + if err != nil { + if verr, ok := err.(*sessionValidationError); ok && verr.kind == sessionValidationErrorKindUnauthenticated { + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) + } else { + http.Redirect(w, r, "/login?error=Unable to initialize session", http.StatusTemporaryRedirect) + } + return + } + + ctx := WithAuthContext(r.Context(), username, role, csrfToken) + next.ServeHTTP(w, r.WithContext(ctx)) + }) } } -// RequireWriteAccess checks if user has admin role (write access) -// Returns JSON error for API endpoints, redirects for HTML endpoints -func RequireWriteAccess() gin.HandlerFunc { - return func(c *gin.Context) { - role, exists := c.Get("role") - if !exists { - role = "admin" // Default for backward compatibility - } - - roleStr, ok := role.(string) - if !ok || roleStr != "admin" { - // Check if this is an API request (path starts with /api) or HTML request - path := c.Request.URL.Path - if strings.HasPrefix(path, "/api") { - c.JSON(http.StatusForbidden, gin.H{ - "error": "Insufficient permissions", - "message": "This operation requires admin access. Read-only users can only view data.", - }) - } else { - c.Redirect(http.StatusSeeOther, "/admin?error=Insufficient permissions") +// RequireAuthAPI checks if user is authenticated for API endpoints. +// Returns JSON error instead of redirecting to login page. +func RequireAuthAPI(store sessions.Store) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, role, csrfToken, err := validateSession(store, w, r) + if err != nil { + if verr, ok := err.(*sessionValidationError); ok && verr.kind == sessionValidationErrorKindUnauthenticated { + writeJSON(w, http.StatusUnauthorized, map[string]string{ + "error": "Authentication required", + "message": "Please log in to access this endpoint", + }) + } else { + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "Failed to initialize session", + "message": "Unable to initialize session", + }) + } + return + } + + ctx := WithAuthContext(r.Context(), username, role, csrfToken) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// RequireWriteAccess checks if user has admin role (write access). +// Returns JSON error for API endpoints, redirects for HTML endpoints. +func RequireWriteAccess() mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + role := RoleFromContext(r.Context()) + + if role != "admin" { + // Check if this is an API request (path starts with /api) or HTML request. + if strings.HasPrefix(r.URL.Path, "/api") { + writeJSON(w, http.StatusForbidden, map[string]string{ + "error": "Insufficient permissions", + "message": "This operation requires admin access. Read-only users can only view data.", + }) + } else { + http.Redirect(w, r, "/admin?error=Insufficient permissions", http.StatusSeeOther) + } + return } - c.Abort() - return - } - c.Next() + next.ServeHTTP(w, r) + }) } } diff --git a/weed/admin/dash/plugin_api.go b/weed/admin/dash/plugin_api.go index c4e9aa789..65197a3fd 100644 --- a/weed/admin/dash/plugin_api.go +++ b/weed/admin/dash/plugin_api.go @@ -13,7 +13,7 @@ import ( "strings" "time" - "github.com/gin-gonic/gin" + "github.com/gorilla/mux" "github.com/seaweedfs/seaweedfs/weed/admin/plugin" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb" @@ -32,17 +32,17 @@ const ( ) // GetPluginStatusAPI returns plugin status. -func (s *AdminServer) GetPluginStatusAPI(c *gin.Context) { +func (s *AdminServer) GetPluginStatusAPI(w http.ResponseWriter, r *http.Request) { plugin := s.GetPlugin() if plugin == nil { - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "enabled": false, "worker_grpc_port": s.GetWorkerGrpcPort(), }) return } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "enabled": true, "configured": plugin.IsConfigured(), "base_dir": plugin.BaseDir(), @@ -52,101 +52,104 @@ func (s *AdminServer) GetPluginStatusAPI(c *gin.Context) { } // GetPluginWorkersAPI returns currently connected plugin workers. -func (s *AdminServer) GetPluginWorkersAPI(c *gin.Context) { +func (s *AdminServer) GetPluginWorkersAPI(w http.ResponseWriter, r *http.Request) { workers := s.GetPluginWorkers() if workers == nil { - c.JSON(http.StatusOK, []interface{}{}) + writeJSON(w, http.StatusOK, []interface{}{}) return } - c.JSON(http.StatusOK, workers) + writeJSON(w, http.StatusOK, workers) } // GetPluginJobTypesAPI returns known plugin job types from workers and persisted data. -func (s *AdminServer) GetPluginJobTypesAPI(c *gin.Context) { +func (s *AdminServer) GetPluginJobTypesAPI(w http.ResponseWriter, r *http.Request) { jobTypes, err := s.ListPluginJobTypes() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusInternalServerError, err.Error()) return } if jobTypes == nil { - c.JSON(http.StatusOK, []interface{}{}) + writeJSON(w, http.StatusOK, []interface{}{}) return } - c.JSON(http.StatusOK, jobTypes) + writeJSON(w, http.StatusOK, jobTypes) } // GetPluginJobsAPI returns tracked jobs for monitoring. -func (s *AdminServer) GetPluginJobsAPI(c *gin.Context) { - jobType := strings.TrimSpace(c.Query("job_type")) - state := strings.TrimSpace(c.Query("state")) - limit := parsePositiveInt(c.Query("limit"), 200) +func (s *AdminServer) GetPluginJobsAPI(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + jobType := strings.TrimSpace(query.Get("job_type")) + state := strings.TrimSpace(query.Get("state")) + limit := parsePositiveInt(query.Get("limit"), 200) jobs := s.ListPluginJobs(jobType, state, limit) if jobs == nil { - c.JSON(http.StatusOK, []interface{}{}) + writeJSON(w, http.StatusOK, []interface{}{}) return } - c.JSON(http.StatusOK, jobs) + writeJSON(w, http.StatusOK, jobs) } // GetPluginJobAPI returns one tracked job. -func (s *AdminServer) GetPluginJobAPI(c *gin.Context) { - jobID := strings.TrimSpace(c.Param("jobId")) +func (s *AdminServer) GetPluginJobAPI(w http.ResponseWriter, r *http.Request) { + jobID := strings.TrimSpace(mux.Vars(r)["jobId"]) if jobID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "jobId is required"}) + writeJSONError(w, http.StatusBadRequest, "jobId is required") return } job, found := s.GetPluginJob(jobID) if !found { - c.JSON(http.StatusNotFound, gin.H{"error": "job not found"}) + writeJSONError(w, http.StatusNotFound, "job not found") return } - c.JSON(http.StatusOK, job) + writeJSON(w, http.StatusOK, job) } // GetPluginJobDetailAPI returns detailed information for one tracked plugin job. -func (s *AdminServer) GetPluginJobDetailAPI(c *gin.Context) { - jobID := strings.TrimSpace(c.Param("jobId")) +func (s *AdminServer) GetPluginJobDetailAPI(w http.ResponseWriter, r *http.Request) { + jobID := strings.TrimSpace(mux.Vars(r)["jobId"]) if jobID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "jobId is required"}) + writeJSONError(w, http.StatusBadRequest, "jobId is required") return } - activityLimit := parsePositiveInt(c.Query("activity_limit"), 500) - relatedLimit := parsePositiveInt(c.Query("related_limit"), 20) + query := r.URL.Query() + activityLimit := parsePositiveInt(query.Get("activity_limit"), 500) + relatedLimit := parsePositiveInt(query.Get("related_limit"), 20) detail, found, err := s.GetPluginJobDetail(jobID, activityLimit, relatedLimit) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusInternalServerError, err.Error()) return } if !found || detail == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "job detail not found"}) + writeJSONError(w, http.StatusNotFound, "job detail not found") return } - c.JSON(http.StatusOK, detail) + writeJSON(w, http.StatusOK, detail) } // GetPluginActivitiesAPI returns recent plugin activities. -func (s *AdminServer) GetPluginActivitiesAPI(c *gin.Context) { - jobType := strings.TrimSpace(c.Query("job_type")) - limit := parsePositiveInt(c.Query("limit"), 500) +func (s *AdminServer) GetPluginActivitiesAPI(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + jobType := strings.TrimSpace(query.Get("job_type")) + limit := parsePositiveInt(query.Get("limit"), 500) activities := s.ListPluginActivities(jobType, limit) if activities == nil { - c.JSON(http.StatusOK, []interface{}{}) + writeJSON(w, http.StatusOK, []interface{}{}) return } - c.JSON(http.StatusOK, activities) + writeJSON(w, http.StatusOK, activities) } // GetPluginSchedulerStatesAPI returns per-job-type scheduler status for monitoring. -func (s *AdminServer) GetPluginSchedulerStatesAPI(c *gin.Context) { - jobTypeFilter := strings.TrimSpace(c.Query("job_type")) +func (s *AdminServer) GetPluginSchedulerStatesAPI(w http.ResponseWriter, r *http.Request) { + jobTypeFilter := strings.TrimSpace(r.URL.Query().Get("job_type")) states, err := s.ListPluginSchedulerStates() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusInternalServerError, err.Error()) return } @@ -157,71 +160,71 @@ func (s *AdminServer) GetPluginSchedulerStatesAPI(c *gin.Context) { filtered = append(filtered, state) } } - c.JSON(http.StatusOK, filtered) + writeJSON(w, http.StatusOK, filtered) return } if states == nil { - c.JSON(http.StatusOK, []interface{}{}) + writeJSON(w, http.StatusOK, []interface{}{}) return } - c.JSON(http.StatusOK, states) + writeJSON(w, http.StatusOK, states) } // RequestPluginJobTypeSchemaAPI asks a worker for one job type schema. -func (s *AdminServer) RequestPluginJobTypeSchemaAPI(c *gin.Context) { - jobType := strings.TrimSpace(c.Param("jobType")) +func (s *AdminServer) RequestPluginJobTypeSchemaAPI(w http.ResponseWriter, r *http.Request) { + jobType := strings.TrimSpace(mux.Vars(r)["jobType"]) if jobType == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"}) + writeJSONError(w, http.StatusBadRequest, "jobType is required") return } - forceRefresh := c.DefaultQuery("force_refresh", "false") == "true" + forceRefresh := strings.EqualFold(r.URL.Query().Get("force_refresh"), "true") - ctx, cancel := context.WithTimeout(c.Request.Context(), defaultPluginDetectionTimeout) + ctx, cancel := context.WithTimeout(r.Context(), defaultPluginDetectionTimeout) defer cancel() descriptor, err := s.RequestPluginJobTypeDescriptor(ctx, jobType, forceRefresh) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusInternalServerError, err.Error()) return } - renderProtoJSON(c, http.StatusOK, descriptor) + renderProtoJSON(w, http.StatusOK, descriptor) } // GetPluginJobTypeDescriptorAPI returns persisted descriptor for a job type. -func (s *AdminServer) GetPluginJobTypeDescriptorAPI(c *gin.Context) { - jobType := strings.TrimSpace(c.Param("jobType")) +func (s *AdminServer) GetPluginJobTypeDescriptorAPI(w http.ResponseWriter, r *http.Request) { + jobType := strings.TrimSpace(mux.Vars(r)["jobType"]) if jobType == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"}) + writeJSONError(w, http.StatusBadRequest, "jobType is required") return } descriptor, err := s.LoadPluginJobTypeDescriptor(jobType) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusInternalServerError, err.Error()) return } if descriptor == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "descriptor not found"}) + writeJSONError(w, http.StatusNotFound, "descriptor not found") return } - renderProtoJSON(c, http.StatusOK, descriptor) + renderProtoJSON(w, http.StatusOK, descriptor) } // GetPluginJobTypeConfigAPI loads persisted config for a job type. -func (s *AdminServer) GetPluginJobTypeConfigAPI(c *gin.Context) { - jobType := strings.TrimSpace(c.Param("jobType")) +func (s *AdminServer) GetPluginJobTypeConfigAPI(w http.ResponseWriter, r *http.Request) { + jobType := strings.TrimSpace(mux.Vars(r)["jobType"]) if jobType == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"}) + writeJSONError(w, http.StatusBadRequest, "jobType is required") return } config, err := s.LoadPluginJobTypeConfig(jobType) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusInternalServerError, err.Error()) return } if config == nil { @@ -233,20 +236,20 @@ func (s *AdminServer) GetPluginJobTypeConfigAPI(c *gin.Context) { } } - renderProtoJSON(c, http.StatusOK, config) + renderProtoJSON(w, http.StatusOK, config) } // UpdatePluginJobTypeConfigAPI stores persisted config for a job type. -func (s *AdminServer) UpdatePluginJobTypeConfigAPI(c *gin.Context) { - jobType := strings.TrimSpace(c.Param("jobType")) +func (s *AdminServer) UpdatePluginJobTypeConfigAPI(w http.ResponseWriter, r *http.Request) { + jobType := strings.TrimSpace(mux.Vars(r)["jobType"]) if jobType == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"}) + writeJSONError(w, http.StatusBadRequest, "jobType is required") return } config := &plugin_pb.PersistedJobTypeConfig{} - if err := parseProtoJSONBody(c, config); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + if err := parseProtoJSONBody(w, r, config); err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) return } @@ -264,35 +267,35 @@ func (s *AdminServer) UpdatePluginJobTypeConfigAPI(c *gin.Context) { config.WorkerConfigValues = map[string]*plugin_pb.ConfigValue{} } - username := c.GetString("username") + username := UsernameFromContext(r.Context()) if username == "" { username = "admin" } config.UpdatedBy = username if err := s.SavePluginJobTypeConfig(config); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusInternalServerError, err.Error()) return } - renderProtoJSON(c, http.StatusOK, config) + renderProtoJSON(w, http.StatusOK, config) } // GetPluginRunHistoryAPI returns bounded run history for a job type. -func (s *AdminServer) GetPluginRunHistoryAPI(c *gin.Context) { - jobType := strings.TrimSpace(c.Param("jobType")) +func (s *AdminServer) GetPluginRunHistoryAPI(w http.ResponseWriter, r *http.Request) { + jobType := strings.TrimSpace(mux.Vars(r)["jobType"]) if jobType == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"}) + writeJSONError(w, http.StatusBadRequest, "jobType is required") return } history, err := s.GetPluginRunHistory(jobType) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusInternalServerError, err.Error()) return } if history == nil { - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "job_type": jobType, "successful_runs": []interface{}{}, "error_runs": []interface{}{}, @@ -301,14 +304,14 @@ func (s *AdminServer) GetPluginRunHistoryAPI(c *gin.Context) { return } - c.JSON(http.StatusOK, history) + writeJSON(w, http.StatusOK, history) } // TriggerPluginDetectionAPI runs one detector for this job type and returns proposals. -func (s *AdminServer) TriggerPluginDetectionAPI(c *gin.Context) { - jobType := strings.TrimSpace(c.Param("jobType")) +func (s *AdminServer) TriggerPluginDetectionAPI(w http.ResponseWriter, r *http.Request) { + jobType := strings.TrimSpace(mux.Vars(r)["jobType"]) if jobType == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"}) + writeJSONError(w, http.StatusBadRequest, "jobType is required") return } @@ -318,19 +321,19 @@ func (s *AdminServer) TriggerPluginDetectionAPI(c *gin.Context) { TimeoutSeconds int `json:"timeout_seconds"` } - if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil && err != io.EOF { + writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } clusterContext, err := s.parseOrBuildClusterContext(req.ClusterContext) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusBadRequest, err.Error()) return } timeout := normalizeTimeout(req.TimeoutSeconds, defaultPluginDetectionTimeout, maxPluginDetectionTimeout) - ctx, cancel := context.WithTimeout(c.Request.Context(), timeout) + ctx, cancel := context.WithTimeout(r.Context(), timeout) defer cancel() report, err := s.RunPluginDetectionWithReport(ctx, jobType, clusterContext, req.MaxResults) @@ -384,7 +387,7 @@ func (s *AdminServer) TriggerPluginDetectionAPI(c *gin.Context) { } } - response := gin.H{ + response := map[string]interface{}{ "job_type": jobType, "request_id": requestID, "detector_worker_id": detectorWorkerID, @@ -396,18 +399,18 @@ func (s *AdminServer) TriggerPluginDetectionAPI(c *gin.Context) { if err != nil { response["error"] = err.Error() - c.JSON(http.StatusInternalServerError, response) + writeJSON(w, http.StatusInternalServerError, response) return } - c.JSON(http.StatusOK, response) + writeJSON(w, http.StatusOK, response) } // RunPluginJobTypeAPI runs full workflow for one job type: detect then dispatch detected jobs. -func (s *AdminServer) RunPluginJobTypeAPI(c *gin.Context) { - jobType := strings.TrimSpace(c.Param("jobType")) +func (s *AdminServer) RunPluginJobTypeAPI(w http.ResponseWriter, r *http.Request) { + jobType := strings.TrimSpace(mux.Vars(r)["jobType"]) if jobType == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"}) + writeJSONError(w, http.StatusBadRequest, "jobType is required") return } @@ -418,8 +421,8 @@ func (s *AdminServer) RunPluginJobTypeAPI(c *gin.Context) { Attempt int32 `json:"attempt"` } - if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil && err != io.EOF { + writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } if req.Attempt < 1 { @@ -428,24 +431,24 @@ func (s *AdminServer) RunPluginJobTypeAPI(c *gin.Context) { clusterContext, err := s.parseOrBuildClusterContext(req.ClusterContext) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusBadRequest, err.Error()) return } timeout := normalizeTimeout(req.TimeoutSeconds, defaultPluginRunTimeout, maxPluginRunTimeout) - ctx, cancel := context.WithTimeout(c.Request.Context(), timeout) + ctx, cancel := context.WithTimeout(r.Context(), timeout) defer cancel() proposals, err := s.RunPluginDetection(ctx, jobType, clusterContext, req.MaxResults) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusInternalServerError, err.Error()) return } detectedCount := len(proposals) filteredProposals, skippedActiveCount, err := s.FilterPluginProposalsWithActiveJobs(jobType, proposals) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusInternalServerError, err.Error()) return } @@ -485,7 +488,7 @@ func (s *AdminServer) RunPluginJobTypeAPI(c *gin.Context) { results = append(results, result) } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "job_type": jobType, "detected_count": detectedCount, "ready_to_execute_count": len(filteredProposals), @@ -498,7 +501,7 @@ func (s *AdminServer) RunPluginJobTypeAPI(c *gin.Context) { } // ExecutePluginJobAPI executes one job on a capable worker and waits for completion. -func (s *AdminServer) ExecutePluginJobAPI(c *gin.Context) { +func (s *AdminServer) ExecutePluginJobAPI(w http.ResponseWriter, r *http.Request) { var req struct { Job json.RawMessage `json:"job"` ClusterContext json.RawMessage `json:"cluster_context"` @@ -506,24 +509,24 @@ func (s *AdminServer) ExecutePluginJobAPI(c *gin.Context) { TimeoutSeconds int `json:"timeout_seconds"` } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } if len(req.Job) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "job is required"}) + writeJSONError(w, http.StatusBadRequest, "job is required") return } job := &plugin_pb.JobSpec{} if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(req.Job, job); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid job payload: " + err.Error()}) + writeJSONError(w, http.StatusBadRequest, "invalid job payload: "+err.Error()) return } clusterContext, err := s.parseOrBuildClusterContext(req.ClusterContext) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusBadRequest, err.Error()) return } @@ -532,7 +535,7 @@ func (s *AdminServer) ExecutePluginJobAPI(c *gin.Context) { } timeout := normalizeTimeout(req.TimeoutSeconds, defaultPluginExecutionTimeout, maxPluginExecutionTimeout) - ctx, cancel := context.WithTimeout(c.Request.Context(), timeout) + ctx, cancel := context.WithTimeout(r.Context(), timeout) defer cancel() completed, err := s.ExecutePluginJob(ctx, job, clusterContext, req.Attempt) @@ -540,15 +543,15 @@ func (s *AdminServer) ExecutePluginJobAPI(c *gin.Context) { if completed != nil { payload, marshalErr := protoMessageToMap(completed) if marshalErr == nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error(), "completion": payload}) + writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error(), "completion": payload}) return } } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusInternalServerError, err.Error()) return } - renderProtoJSON(c, http.StatusOK, completed) + renderProtoJSON(w, http.StatusOK, completed) } func (s *AdminServer) parseOrBuildClusterContext(raw json.RawMessage) (*plugin_pb.ClusterContext, error) { @@ -636,8 +639,8 @@ func (s *AdminServer) buildDefaultPluginClusterContext() *plugin_pb.ClusterConte const parseProtoJSONBodyMaxBytes = 1 << 20 // 1 MB -func parseProtoJSONBody(c *gin.Context, message proto.Message) error { - limitedBody := http.MaxBytesReader(c.Writer, c.Request.Body, parseProtoJSONBodyMaxBytes) +func parseProtoJSONBody(w http.ResponseWriter, r *http.Request, message proto.Message) error { + limitedBody := http.MaxBytesReader(w, r.Body, parseProtoJSONBodyMaxBytes) data, err := io.ReadAll(limitedBody) if err != nil { return fmt.Errorf("failed to read request body: %w", err) @@ -651,17 +654,19 @@ func parseProtoJSONBody(c *gin.Context, message proto.Message) error { return nil } -func renderProtoJSON(c *gin.Context, statusCode int, message proto.Message) { +func renderProtoJSON(w http.ResponseWriter, statusCode int, message proto.Message) { payload, err := protojson.MarshalOptions{ UseProtoNames: true, EmitUnpopulated: true, }.Marshal(message) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode response: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "failed to encode response: "+err.Error()) return } - c.Data(statusCode, "application/json", payload) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _, _ = w.Write(payload) } func protoMessageToMap(message proto.Message) (map[string]interface{}, error) { diff --git a/weed/admin/dash/s3tables_management.go b/weed/admin/dash/s3tables_management.go index 0c38c58a0..f9492d7da 100644 --- a/weed/admin/dash/s3tables_management.go +++ b/weed/admin/dash/s3tables_management.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "github.com/gin-gonic/gin" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" @@ -67,10 +66,10 @@ func parseNamespaceInput(namespace string) ([]string, error) { return s3tables.ParseNamespace(namespace) } -func (s *AdminServer) parseNamespaceFromGin(c *gin.Context, namespace string) ([]string, bool) { +func (s *AdminServer) parseNamespaceFromRequest(w http.ResponseWriter, namespace string) ([]string, bool) { parts, err := parseNamespaceInput(namespace) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid namespace: " + err.Error()}) + writeJSONError(w, http.StatusBadRequest, "Invalid namespace: "+err.Error()) return nil, false } return parts, true @@ -569,58 +568,61 @@ func parseSummaryInt(summary map[string]string, keys ...string) (int64, bool) { // API handlers -func (s *AdminServer) ListS3TablesBucketsAPI(c *gin.Context) { - data, err := s.GetS3TablesBucketsData(c.Request.Context()) +func (s *AdminServer) ListS3TablesBucketsAPI(w http.ResponseWriter, r *http.Request) { + data, err := s.GetS3TablesBucketsData(r.Context()) if err != nil { - writeS3TablesError(c, err) + writeS3TablesError(w, err) return } - c.JSON(200, data) + writeJSON(w, http.StatusOK, data) } -func (s *AdminServer) CreateS3TablesBucket(c *gin.Context) { +func (s *AdminServer) CreateS3TablesBucket(w http.ResponseWriter, r *http.Request) { + if !requireSessionCSRFToken(w, r) { + return + } var req struct { Name string `json:"name"` Tags map[string]string `json:"tags"` Owner string `json:"owner"` } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } if req.Name == "" { - c.JSON(400, gin.H{"error": "Bucket name is required"}) + writeJSONError(w, http.StatusBadRequest, "Bucket name is required") return } owner := strings.TrimSpace(req.Owner) if len(owner) > MaxOwnerNameLength { - c.JSON(400, gin.H{"error": fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength)}) + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength)) return } if len(req.Tags) > 0 { if err := s3tables.ValidateTags(req.Tags); err != nil { - c.JSON(400, gin.H{"error": "Invalid tags: " + err.Error()}) + writeJSONError(w, http.StatusBadRequest, "Invalid tags: "+err.Error()) return } } createReq := &s3tables.CreateTableBucketRequest{Name: req.Name, Tags: req.Tags} var resp s3tables.CreateTableBucketResponse - if err := s.executeS3TablesOperation(c.Request.Context(), "CreateTableBucket", createReq, &resp); err != nil { - writeS3TablesError(c, err) + if err := s.executeS3TablesOperation(r.Context(), "CreateTableBucket", createReq, &resp); err != nil { + writeS3TablesError(w, err) return } if owner != "" { - if err := s.SetTableBucketOwner(c.Request.Context(), req.Name, owner); err != nil { + if err := s.SetTableBucketOwner(r.Context(), req.Name, owner); err != nil { deleteReq := &s3tables.DeleteTableBucketRequest{TableBucketARN: resp.ARN} - if deleteErr := s.executeS3TablesOperation(c.Request.Context(), "DeleteTableBucket", deleteReq, nil); deleteErr != nil { - c.JSON(500, gin.H{"error": fmt.Sprintf("Failed to set table bucket owner: %v; rollback delete failed: %v", err, deleteErr)}) + if deleteErr := s.executeS3TablesOperation(r.Context(), "DeleteTableBucket", deleteReq, nil); deleteErr != nil { + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to set table bucket owner: %v; rollback delete failed: %v", err, deleteErr)) return } - writeS3TablesError(c, err) + writeS3TablesError(w, err) return } } - c.JSON(201, gin.H{"arn": resp.ARN}) + writeJSON(w, http.StatusCreated, map[string]interface{}{"arn": resp.ARN}) } func (s *AdminServer) SetTableBucketOwner(ctx context.Context, bucketName, owner string) error { @@ -663,101 +665,107 @@ func (s *AdminServer) SetTableBucketOwner(ctx context.Context, bucketName, owner }) } -func (s *AdminServer) DeleteS3TablesBucket(c *gin.Context) { - bucketArn := c.Query("bucket") +func (s *AdminServer) DeleteS3TablesBucket(w http.ResponseWriter, r *http.Request) { + if !requireSessionCSRFToken(w, r) { + return + } + bucketArn := r.URL.Query().Get("bucket") if bucketArn == "" { - c.JSON(400, gin.H{"error": "Bucket ARN is required"}) + writeJSONError(w, http.StatusBadRequest, "Bucket ARN is required") return } req := &s3tables.DeleteTableBucketRequest{TableBucketARN: bucketArn} - if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTableBucket", req, nil); err != nil { - writeS3TablesError(c, err) + if err := s.executeS3TablesOperation(r.Context(), "DeleteTableBucket", req, nil); err != nil { + writeS3TablesError(w, err) return } - c.JSON(200, gin.H{"message": "Bucket deleted"}) + writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Bucket deleted"}) } -func (s *AdminServer) ListS3TablesNamespacesAPI(c *gin.Context) { - bucketArn := c.Query("bucket") +func (s *AdminServer) ListS3TablesNamespacesAPI(w http.ResponseWriter, r *http.Request) { + bucketArn := r.URL.Query().Get("bucket") if bucketArn == "" { - c.JSON(400, gin.H{"error": "bucket query parameter is required"}) + writeJSONError(w, http.StatusBadRequest, "bucket query parameter is required") return } - data, err := s.GetS3TablesNamespacesData(c.Request.Context(), bucketArn) + data, err := s.GetS3TablesNamespacesData(r.Context(), bucketArn) if err != nil { - writeS3TablesError(c, err) + writeS3TablesError(w, err) return } - c.JSON(200, data) + writeJSON(w, http.StatusOK, data) } -func (s *AdminServer) CreateS3TablesNamespace(c *gin.Context) { - if !requireSessionCSRFToken(c) { +func (s *AdminServer) CreateS3TablesNamespace(w http.ResponseWriter, r *http.Request) { + if !requireSessionCSRFToken(w, r) { return } var req struct { BucketARN string `json:"bucket_arn"` Name string `json:"name"` } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } if req.BucketARN == "" || req.Name == "" { - c.JSON(400, gin.H{"error": "bucket_arn and name are required"}) + writeJSONError(w, http.StatusBadRequest, "bucket_arn and name are required") return } - namespaceParts, ok := s.parseNamespaceFromGin(c, req.Name) + namespaceParts, ok := s.parseNamespaceFromRequest(w, req.Name) if !ok { return } createReq := &s3tables.CreateNamespaceRequest{TableBucketARN: req.BucketARN, Namespace: namespaceParts} var resp s3tables.CreateNamespaceResponse - if err := s.executeS3TablesOperation(c.Request.Context(), "CreateNamespace", createReq, &resp); err != nil { - writeS3TablesError(c, err) + if err := s.executeS3TablesOperation(r.Context(), "CreateNamespace", createReq, &resp); err != nil { + writeS3TablesError(w, err) return } - c.JSON(201, gin.H{"namespace": resp.Namespace}) + writeJSON(w, http.StatusCreated, map[string]interface{}{"namespace": resp.Namespace}) } -func (s *AdminServer) DeleteS3TablesNamespace(c *gin.Context) { - if !requireSessionCSRFToken(c) { +func (s *AdminServer) DeleteS3TablesNamespace(w http.ResponseWriter, r *http.Request) { + if !requireSessionCSRFToken(w, r) { return } - bucketArn := c.Query("bucket") - namespace := c.Query("name") + bucketArn := r.URL.Query().Get("bucket") + namespace := r.URL.Query().Get("name") if bucketArn == "" || namespace == "" { - c.JSON(400, gin.H{"error": "bucket and name query parameters are required"}) + writeJSONError(w, http.StatusBadRequest, "bucket and name query parameters are required") return } - namespaceParts, ok := s.parseNamespaceFromGin(c, namespace) + namespaceParts, ok := s.parseNamespaceFromRequest(w, namespace) if !ok { return } req := &s3tables.DeleteNamespaceRequest{TableBucketARN: bucketArn, Namespace: namespaceParts} - if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteNamespace", req, nil); err != nil { - writeS3TablesError(c, err) + if err := s.executeS3TablesOperation(r.Context(), "DeleteNamespace", req, nil); err != nil { + writeS3TablesError(w, err) return } - c.JSON(200, gin.H{"message": "Namespace deleted"}) + writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Namespace deleted"}) } -func (s *AdminServer) ListS3TablesTablesAPI(c *gin.Context) { - bucketArn := c.Query("bucket") +func (s *AdminServer) ListS3TablesTablesAPI(w http.ResponseWriter, r *http.Request) { + bucketArn := r.URL.Query().Get("bucket") if bucketArn == "" { - c.JSON(400, gin.H{"error": "bucket query parameter is required"}) + writeJSONError(w, http.StatusBadRequest, "bucket query parameter is required") return } - namespace := c.Query("namespace") - data, err := s.GetS3TablesTablesData(c.Request.Context(), bucketArn, namespace) + namespace := r.URL.Query().Get("namespace") + data, err := s.GetS3TablesTablesData(r.Context(), bucketArn, namespace) if err != nil { - writeS3TablesError(c, err) + writeS3TablesError(w, err) return } - c.JSON(200, data) + writeJSON(w, http.StatusOK, data) } -func (s *AdminServer) CreateS3TablesTable(c *gin.Context) { +func (s *AdminServer) CreateS3TablesTable(w http.ResponseWriter, r *http.Request) { + if !requireSessionCSRFToken(w, r) { + return + } var req struct { BucketARN string `json:"bucket_arn"` Namespace string `json:"namespace"` @@ -766,15 +774,15 @@ func (s *AdminServer) CreateS3TablesTable(c *gin.Context) { Tags map[string]string `json:"tags"` Metadata *s3tables.TableMetadata `json:"metadata"` } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } if req.BucketARN == "" || req.Namespace == "" || req.Name == "" { - c.JSON(400, gin.H{"error": "bucket_arn, namespace, and name are required"}) + writeJSONError(w, http.StatusBadRequest, "bucket_arn, namespace, and name are required") return } - namespaceParts, ok := s.parseNamespaceFromGin(c, req.Namespace) + namespaceParts, ok := s.parseNamespaceFromRequest(w, req.Namespace) if !ok { return } @@ -784,7 +792,7 @@ func (s *AdminServer) CreateS3TablesTable(c *gin.Context) { } if len(req.Tags) > 0 { if err := s3tables.ValidateTags(req.Tags); err != nil { - c.JSON(400, gin.H{"error": "Invalid tags: " + err.Error()}) + writeJSONError(w, http.StatusBadRequest, "Invalid tags: "+err.Error()) return } } @@ -797,211 +805,232 @@ func (s *AdminServer) CreateS3TablesTable(c *gin.Context) { Metadata: req.Metadata, } var resp s3tables.CreateTableResponse - if err := s.executeS3TablesOperation(c.Request.Context(), "CreateTable", createReq, &resp); err != nil { - writeS3TablesError(c, err) + if err := s.executeS3TablesOperation(r.Context(), "CreateTable", createReq, &resp); err != nil { + writeS3TablesError(w, err) return } - c.JSON(201, gin.H{"table_arn": resp.TableARN, "version_token": resp.VersionToken}) + writeJSON(w, http.StatusCreated, map[string]interface{}{"table_arn": resp.TableARN, "version_token": resp.VersionToken}) } -func (s *AdminServer) DeleteS3TablesTable(c *gin.Context) { - bucketArn := c.Query("bucket") - namespace := c.Query("namespace") - name := c.Query("name") - version := c.Query("version") +func (s *AdminServer) DeleteS3TablesTable(w http.ResponseWriter, r *http.Request) { + if !requireSessionCSRFToken(w, r) { + return + } + bucketArn := r.URL.Query().Get("bucket") + namespace := r.URL.Query().Get("namespace") + name := r.URL.Query().Get("name") + version := r.URL.Query().Get("version") if bucketArn == "" || namespace == "" || name == "" { - c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"}) + writeJSONError(w, http.StatusBadRequest, "bucket, namespace, and name query parameters are required") return } - namespaceParts, ok := s.parseNamespaceFromGin(c, namespace) + namespaceParts, ok := s.parseNamespaceFromRequest(w, namespace) if !ok { return } req := &s3tables.DeleteTableRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name, VersionToken: version} - if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTable", req, nil); err != nil { - writeS3TablesError(c, err) + if err := s.executeS3TablesOperation(r.Context(), "DeleteTable", req, nil); err != nil { + writeS3TablesError(w, err) return } - c.JSON(200, gin.H{"message": "Table deleted"}) + writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Table deleted"}) } -func (s *AdminServer) PutS3TablesBucketPolicy(c *gin.Context) { +func (s *AdminServer) PutS3TablesBucketPolicy(w http.ResponseWriter, r *http.Request) { + if !requireSessionCSRFToken(w, r) { + return + } var req struct { BucketARN string `json:"bucket_arn"` Policy string `json:"policy"` } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } if req.BucketARN == "" || req.Policy == "" { - c.JSON(400, gin.H{"error": "bucket_arn and policy are required"}) + writeJSONError(w, http.StatusBadRequest, "bucket_arn and policy are required") return } putReq := &s3tables.PutTableBucketPolicyRequest{TableBucketARN: req.BucketARN, ResourcePolicy: req.Policy} - if err := s.executeS3TablesOperation(c.Request.Context(), "PutTableBucketPolicy", putReq, nil); err != nil { - writeS3TablesError(c, err) + if err := s.executeS3TablesOperation(r.Context(), "PutTableBucketPolicy", putReq, nil); err != nil { + writeS3TablesError(w, err) return } - c.JSON(200, gin.H{"message": "Policy updated"}) + writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Policy updated"}) } -func (s *AdminServer) GetS3TablesBucketPolicy(c *gin.Context) { - bucketArn := c.Query("bucket") +func (s *AdminServer) GetS3TablesBucketPolicy(w http.ResponseWriter, r *http.Request) { + bucketArn := r.URL.Query().Get("bucket") if bucketArn == "" { - c.JSON(400, gin.H{"error": "bucket query parameter is required"}) + writeJSONError(w, http.StatusBadRequest, "bucket query parameter is required") return } getReq := &s3tables.GetTableBucketPolicyRequest{TableBucketARN: bucketArn} var resp s3tables.GetTableBucketPolicyResponse - if err := s.executeS3TablesOperation(c.Request.Context(), "GetTableBucketPolicy", getReq, &resp); err != nil { - writeS3TablesError(c, err) + if err := s.executeS3TablesOperation(r.Context(), "GetTableBucketPolicy", getReq, &resp); err != nil { + writeS3TablesError(w, err) return } - c.JSON(200, gin.H{"policy": resp.ResourcePolicy}) + writeJSON(w, http.StatusOK, map[string]interface{}{"policy": resp.ResourcePolicy}) } -func (s *AdminServer) DeleteS3TablesBucketPolicy(c *gin.Context) { - bucketArn := c.Query("bucket") +func (s *AdminServer) DeleteS3TablesBucketPolicy(w http.ResponseWriter, r *http.Request) { + if !requireSessionCSRFToken(w, r) { + return + } + bucketArn := r.URL.Query().Get("bucket") if bucketArn == "" { - c.JSON(400, gin.H{"error": "bucket query parameter is required"}) + writeJSONError(w, http.StatusBadRequest, "bucket query parameter is required") return } deleteReq := &s3tables.DeleteTableBucketPolicyRequest{TableBucketARN: bucketArn} - if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTableBucketPolicy", deleteReq, nil); err != nil { - writeS3TablesError(c, err) + if err := s.executeS3TablesOperation(r.Context(), "DeleteTableBucketPolicy", deleteReq, nil); err != nil { + writeS3TablesError(w, err) return } - c.JSON(200, gin.H{"message": "Policy deleted"}) + writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Policy deleted"}) } -func (s *AdminServer) PutS3TablesTablePolicy(c *gin.Context) { +func (s *AdminServer) PutS3TablesTablePolicy(w http.ResponseWriter, r *http.Request) { + if !requireSessionCSRFToken(w, r) { + return + } var req struct { BucketARN string `json:"bucket_arn"` Namespace string `json:"namespace"` Name string `json:"name"` Policy string `json:"policy"` } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } if req.BucketARN == "" || req.Namespace == "" || req.Name == "" || req.Policy == "" { - c.JSON(400, gin.H{"error": "bucket_arn, namespace, name, and policy are required"}) + writeJSONError(w, http.StatusBadRequest, "bucket_arn, namespace, name, and policy are required") return } - namespaceParts, ok := s.parseNamespaceFromGin(c, req.Namespace) + namespaceParts, ok := s.parseNamespaceFromRequest(w, req.Namespace) if !ok { return } putReq := &s3tables.PutTablePolicyRequest{TableBucketARN: req.BucketARN, Namespace: namespaceParts, Name: req.Name, ResourcePolicy: req.Policy} - if err := s.executeS3TablesOperation(c.Request.Context(), "PutTablePolicy", putReq, nil); err != nil { - writeS3TablesError(c, err) + if err := s.executeS3TablesOperation(r.Context(), "PutTablePolicy", putReq, nil); err != nil { + writeS3TablesError(w, err) return } - c.JSON(200, gin.H{"message": "Policy updated"}) + writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Policy updated"}) } -func (s *AdminServer) GetS3TablesTablePolicy(c *gin.Context) { - bucketArn := c.Query("bucket") - namespace := c.Query("namespace") - name := c.Query("name") +func (s *AdminServer) GetS3TablesTablePolicy(w http.ResponseWriter, r *http.Request) { + bucketArn := r.URL.Query().Get("bucket") + namespace := r.URL.Query().Get("namespace") + name := r.URL.Query().Get("name") if bucketArn == "" || namespace == "" || name == "" { - c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"}) + writeJSONError(w, http.StatusBadRequest, "bucket, namespace, and name query parameters are required") return } - namespaceParts, ok := s.parseNamespaceFromGin(c, namespace) + namespaceParts, ok := s.parseNamespaceFromRequest(w, namespace) if !ok { return } getReq := &s3tables.GetTablePolicyRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name} var resp s3tables.GetTablePolicyResponse - if err := s.executeS3TablesOperation(c.Request.Context(), "GetTablePolicy", getReq, &resp); err != nil { - writeS3TablesError(c, err) + if err := s.executeS3TablesOperation(r.Context(), "GetTablePolicy", getReq, &resp); err != nil { + writeS3TablesError(w, err) return } - c.JSON(200, gin.H{"policy": resp.ResourcePolicy}) + writeJSON(w, http.StatusOK, map[string]interface{}{"policy": resp.ResourcePolicy}) } -func (s *AdminServer) DeleteS3TablesTablePolicy(c *gin.Context) { - bucketArn := c.Query("bucket") - namespace := c.Query("namespace") - name := c.Query("name") +func (s *AdminServer) DeleteS3TablesTablePolicy(w http.ResponseWriter, r *http.Request) { + if !requireSessionCSRFToken(w, r) { + return + } + bucketArn := r.URL.Query().Get("bucket") + namespace := r.URL.Query().Get("namespace") + name := r.URL.Query().Get("name") if bucketArn == "" || namespace == "" || name == "" { - c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"}) + writeJSONError(w, http.StatusBadRequest, "bucket, namespace, and name query parameters are required") return } - namespaceParts, ok := s.parseNamespaceFromGin(c, namespace) + namespaceParts, ok := s.parseNamespaceFromRequest(w, namespace) if !ok { return } deleteReq := &s3tables.DeleteTablePolicyRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name} - if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTablePolicy", deleteReq, nil); err != nil { - writeS3TablesError(c, err) + if err := s.executeS3TablesOperation(r.Context(), "DeleteTablePolicy", deleteReq, nil); err != nil { + writeS3TablesError(w, err) return } - c.JSON(200, gin.H{"message": "Policy deleted"}) + writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Policy deleted"}) } -func (s *AdminServer) TagS3TablesResource(c *gin.Context) { +func (s *AdminServer) TagS3TablesResource(w http.ResponseWriter, r *http.Request) { + if !requireSessionCSRFToken(w, r) { + return + } var req struct { ResourceARN string `json:"resource_arn"` Tags map[string]string `json:"tags"` } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } if req.ResourceARN == "" || len(req.Tags) == 0 { - c.JSON(400, gin.H{"error": "resource_arn and tags are required"}) + writeJSONError(w, http.StatusBadRequest, "resource_arn and tags are required") return } if err := s3tables.ValidateTags(req.Tags); err != nil { - c.JSON(400, gin.H{"error": "Invalid tags: " + err.Error()}) + writeJSONError(w, http.StatusBadRequest, "Invalid tags: "+err.Error()) return } tagReq := &s3tables.TagResourceRequest{ResourceARN: req.ResourceARN, Tags: req.Tags} - if err := s.executeS3TablesOperation(c.Request.Context(), "TagResource", tagReq, nil); err != nil { - writeS3TablesError(c, err) + if err := s.executeS3TablesOperation(r.Context(), "TagResource", tagReq, nil); err != nil { + writeS3TablesError(w, err) return } - c.JSON(200, gin.H{"message": "Tags updated"}) + writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Tags updated"}) } -func (s *AdminServer) ListS3TablesTags(c *gin.Context) { - resourceArn := c.Query("arn") +func (s *AdminServer) ListS3TablesTags(w http.ResponseWriter, r *http.Request) { + resourceArn := r.URL.Query().Get("arn") if resourceArn == "" { - c.JSON(400, gin.H{"error": "arn query parameter is required"}) + writeJSONError(w, http.StatusBadRequest, "arn query parameter is required") return } listReq := &s3tables.ListTagsForResourceRequest{ResourceARN: resourceArn} var resp s3tables.ListTagsForResourceResponse - if err := s.executeS3TablesOperation(c.Request.Context(), "ListTagsForResource", listReq, &resp); err != nil { - writeS3TablesError(c, err) + if err := s.executeS3TablesOperation(r.Context(), "ListTagsForResource", listReq, &resp); err != nil { + writeS3TablesError(w, err) return } - c.JSON(200, resp) + writeJSON(w, http.StatusOK, resp) } -func (s *AdminServer) UntagS3TablesResource(c *gin.Context) { +func (s *AdminServer) UntagS3TablesResource(w http.ResponseWriter, r *http.Request) { + if !requireSessionCSRFToken(w, r) { + return + } var req struct { ResourceARN string `json:"resource_arn"` TagKeys []string `json:"tag_keys"` } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } if req.ResourceARN == "" || len(req.TagKeys) == 0 { - c.JSON(400, gin.H{"error": "resource_arn and tag_keys are required"}) + writeJSONError(w, http.StatusBadRequest, "resource_arn and tag_keys are required") return } untagReq := &s3tables.UntagResourceRequest{ResourceARN: req.ResourceARN, TagKeys: req.TagKeys} - if err := s.executeS3TablesOperation(c.Request.Context(), "UntagResource", untagReq, nil); err != nil { - writeS3TablesError(c, err) + if err := s.executeS3TablesOperation(r.Context(), "UntagResource", untagReq, nil); err != nil { + writeS3TablesError(w, err) return } - c.JSON(200, gin.H{"message": "Tags removed"}) + writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Tags removed"}) } func parseS3TablesErrorMessage(err error) string { @@ -1018,8 +1047,8 @@ func parseS3TablesErrorMessage(err error) string { return err.Error() } -func writeS3TablesError(c *gin.Context, err error) { - c.JSON(s3TablesErrorStatus(err), gin.H{"error": parseS3TablesErrorMessage(err)}) +func writeS3TablesError(w http.ResponseWriter, err error) { + writeJSONError(w, s3TablesErrorStatus(err), parseS3TablesErrorMessage(err)) } func s3TablesErrorStatus(err error) int { diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go index 70b0907b3..357a30129 100644 --- a/weed/admin/handlers/admin_handlers.go +++ b/weed/admin/handlers/admin_handlers.go @@ -5,7 +5,8 @@ import ( "net/url" "time" - "github.com/gin-gonic/gin" + "github.com/gorilla/mux" + "github.com/gorilla/sessions" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/seaweedfs/seaweedfs/weed/admin/dash" "github.com/seaweedfs/seaweedfs/weed/admin/view/app" @@ -18,6 +19,7 @@ import ( // AdminHandlers contains all the HTTP handlers for the admin interface type AdminHandlers struct { adminServer *dash.AdminServer + sessionStore sessions.Store authHandlers *AuthHandlers clusterHandlers *ClusterHandlers fileBrowserHandlers *FileBrowserHandlers @@ -29,8 +31,8 @@ type AdminHandlers struct { } // NewAdminHandlers creates a new instance of AdminHandlers -func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers { - authHandlers := NewAuthHandlers(adminServer) +func NewAdminHandlers(adminServer *dash.AdminServer, store sessions.Store) *AdminHandlers { + authHandlers := NewAuthHandlers(adminServer, store) clusterHandlers := NewClusterHandlers(adminServer) fileBrowserHandlers := NewFileBrowserHandlers(adminServer) userHandlers := NewUserHandlers(adminServer) @@ -40,6 +42,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers { serviceAccountHandlers := NewServiceAccountHandlers(adminServer) return &AdminHandlers{ adminServer: adminServer, + sessionStore: store, authHandlers: authHandlers, clusterHandlers: clusterHandlers, fileBrowserHandlers: fileBrowserHandlers, @@ -52,17 +55,17 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers { } // SetupRoutes configures all the routes for the admin interface -func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser, adminPassword, readOnlyUser, readOnlyPassword string, enableUI bool) { +func (h *AdminHandlers) SetupRoutes(r *mux.Router, authRequired bool, adminUser, adminPassword, readOnlyUser, readOnlyPassword string, enableUI bool) { // Health check (no auth required) - r.GET("/health", h.HealthCheck) + r.HandleFunc("/health", h.HealthCheck).Methods(http.MethodGet) // Prometheus metrics endpoint (no auth required) - r.GET("/metrics", gin.WrapH(promhttp.HandlerFor(stats.Gather, promhttp.HandlerOpts{}))) + r.Handle("/metrics", promhttp.HandlerFor(stats.Gather, promhttp.HandlerOpts{})).Methods(http.MethodGet) // Favicon route (no auth required) - redirect to static version - r.GET("/favicon.ico", func(c *gin.Context) { - c.Redirect(http.StatusMovedPermanently, "/static/favicon.ico") - }) + r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, req *http.Request) { + http.Redirect(w, req, "/static/favicon.ico", http.StatusMovedPermanently) + }).Methods(http.MethodGet) // Skip UI routes if UI is not enabled if !enableUI { @@ -71,499 +74,321 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser, if authRequired { // Authentication routes (no auth required) - r.GET("/login", h.authHandlers.ShowLogin) - r.POST("/login", h.authHandlers.HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword)) - r.GET("/logout", h.authHandlers.HandleLogout) - - // Protected routes group - protected := r.Group("/") - protected.Use(dash.RequireAuth()) - - // Main admin interface routes - protected.GET("/", h.ShowDashboard) - protected.GET("/admin", h.ShowDashboard) - - // Object Store management routes - protected.GET("/object-store/buckets", h.ShowS3Buckets) - protected.GET("/object-store/buckets/:bucket", h.ShowBucketDetails) - protected.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers) - protected.GET("/object-store/policies", h.policyHandlers.ShowPolicies) - protected.GET("/object-store/service-accounts", h.serviceAccountHandlers.ShowServiceAccounts) - protected.GET("/object-store/s3tables/buckets", h.ShowS3TablesBuckets) - protected.GET("/object-store/s3tables/buckets/:bucket/namespaces", h.ShowS3TablesNamespaces) - protected.GET("/object-store/s3tables/buckets/:bucket/namespaces/:namespace/tables", h.ShowS3TablesTables) - protected.GET("/object-store/s3tables/buckets/:bucket/namespaces/:namespace/tables/:table", h.ShowS3TablesTableDetails) - protected.GET("/object-store/iceberg", h.ShowIcebergCatalog) - protected.GET("/object-store/iceberg/:catalog/namespaces", h.ShowIcebergNamespaces) - protected.GET("/object-store/iceberg/:catalog/namespaces/:namespace/tables", h.ShowIcebergTables) - protected.GET("/object-store/iceberg/:catalog/namespaces/:namespace/tables/:table", h.ShowIcebergTableDetails) - - // File browser routes - protected.GET("/files", h.fileBrowserHandlers.ShowFileBrowser) - - // Cluster management routes - protected.GET("/cluster/masters", h.clusterHandlers.ShowClusterMasters) - protected.GET("/cluster/filers", h.clusterHandlers.ShowClusterFilers) - protected.GET("/cluster/volume-servers", h.clusterHandlers.ShowClusterVolumeServers) - - // Storage management routes - protected.GET("/storage/volumes", h.clusterHandlers.ShowClusterVolumes) - protected.GET("/storage/volumes/:id/:server", h.clusterHandlers.ShowVolumeDetails) - protected.GET("/storage/collections", h.clusterHandlers.ShowClusterCollections) - protected.GET("/storage/collections/:name", h.clusterHandlers.ShowCollectionDetails) - protected.GET("/storage/ec-shards", h.clusterHandlers.ShowClusterEcShards) - protected.GET("/storage/ec-volumes/:id", h.clusterHandlers.ShowEcVolumeDetails) - - // Message Queue management routes - protected.GET("/mq/brokers", h.mqHandlers.ShowBrokers) - protected.GET("/mq/topics", h.mqHandlers.ShowTopics) - protected.GET("/mq/topics/:namespace/:topic", h.mqHandlers.ShowTopicDetails) - - protected.GET("/plugin", h.pluginHandlers.ShowPlugin) - protected.GET("/plugin/configuration", h.pluginHandlers.ShowPluginConfiguration) - protected.GET("/plugin/queue", h.pluginHandlers.ShowPluginQueue) - protected.GET("/plugin/detection", h.pluginHandlers.ShowPluginDetection) - protected.GET("/plugin/execution", h.pluginHandlers.ShowPluginExecution) - protected.GET("/plugin/monitoring", h.pluginHandlers.ShowPluginMonitoring) - - // API routes for AJAX calls - api := r.Group("/api") - api.Use(dash.RequireAuthAPI()) // Use API-specific auth middleware - { - api.GET("/cluster/topology", h.clusterHandlers.GetClusterTopology) - api.GET("/cluster/masters", h.clusterHandlers.GetMasters) - api.GET("/cluster/volumes", h.clusterHandlers.GetVolumeServers) - api.GET("/admin", h.adminServer.ShowAdmin) // JSON API for admin data - api.GET("/config", h.adminServer.GetConfigInfo) // Configuration information - - // S3 API routes - s3Api := api.Group("/s3") - { - s3Api.GET("/buckets", h.adminServer.ListBucketsAPI) - s3Api.POST("/buckets", dash.RequireWriteAccess(), h.adminServer.CreateBucket) - s3Api.DELETE("/buckets/:bucket", dash.RequireWriteAccess(), h.adminServer.DeleteBucket) - s3Api.GET("/buckets/:bucket", h.adminServer.ShowBucketDetails) - s3Api.PUT("/buckets/:bucket/quota", dash.RequireWriteAccess(), h.adminServer.UpdateBucketQuota) - s3Api.PUT("/buckets/:bucket/owner", dash.RequireWriteAccess(), h.adminServer.UpdateBucketOwner) - } - - // User management API routes - usersApi := api.Group("/users") - { - usersApi.GET("", h.userHandlers.GetUsers) - usersApi.POST("", dash.RequireWriteAccess(), h.userHandlers.CreateUser) - usersApi.GET("/:username", h.userHandlers.GetUserDetails) - usersApi.PUT("/:username", dash.RequireWriteAccess(), h.userHandlers.UpdateUser) - usersApi.DELETE("/:username", dash.RequireWriteAccess(), h.userHandlers.DeleteUser) - usersApi.POST("/:username/access-keys", dash.RequireWriteAccess(), h.userHandlers.CreateAccessKey) - usersApi.DELETE("/:username/access-keys/:accessKeyId", dash.RequireWriteAccess(), h.userHandlers.DeleteAccessKey) - usersApi.PUT("/:username/access-keys/:accessKeyId/status", dash.RequireWriteAccess(), h.userHandlers.UpdateAccessKeyStatus) - usersApi.GET("/:username/policies", h.userHandlers.GetUserPolicies) - usersApi.PUT("/:username/policies", dash.RequireWriteAccess(), h.userHandlers.UpdateUserPolicies) - } - - // Service Account management API routes - saApi := api.Group("/service-accounts") - { - saApi.GET("", h.serviceAccountHandlers.GetServiceAccounts) - saApi.POST("", dash.RequireWriteAccess(), h.serviceAccountHandlers.CreateServiceAccount) - saApi.GET("/:id", h.serviceAccountHandlers.GetServiceAccountDetails) - saApi.PUT("/:id", dash.RequireWriteAccess(), h.serviceAccountHandlers.UpdateServiceAccount) - saApi.DELETE("/:id", dash.RequireWriteAccess(), h.serviceAccountHandlers.DeleteServiceAccount) - } - - // Object Store Policy management API routes - objectStorePoliciesApi := api.Group("/object-store/policies") - { - objectStorePoliciesApi.GET("", h.policyHandlers.GetPolicies) - objectStorePoliciesApi.POST("", dash.RequireWriteAccess(), h.policyHandlers.CreatePolicy) - objectStorePoliciesApi.GET("/:name", h.policyHandlers.GetPolicy) - objectStorePoliciesApi.PUT("/:name", dash.RequireWriteAccess(), h.policyHandlers.UpdatePolicy) - objectStorePoliciesApi.DELETE("/:name", dash.RequireWriteAccess(), h.policyHandlers.DeletePolicy) - objectStorePoliciesApi.POST("/validate", h.policyHandlers.ValidatePolicy) - } - - // S3 Tables API routes - s3TablesApi := api.Group("/s3tables") - { - s3TablesApi.GET("/buckets", h.adminServer.ListS3TablesBucketsAPI) - s3TablesApi.POST("/buckets", dash.RequireWriteAccess(), h.adminServer.CreateS3TablesBucket) - s3TablesApi.DELETE("/buckets", dash.RequireWriteAccess(), h.adminServer.DeleteS3TablesBucket) - s3TablesApi.GET("/namespaces", h.adminServer.ListS3TablesNamespacesAPI) - s3TablesApi.POST("/namespaces", dash.RequireWriteAccess(), h.adminServer.CreateS3TablesNamespace) - s3TablesApi.DELETE("/namespaces", dash.RequireWriteAccess(), h.adminServer.DeleteS3TablesNamespace) - s3TablesApi.GET("/tables", h.adminServer.ListS3TablesTablesAPI) - s3TablesApi.POST("/tables", dash.RequireWriteAccess(), h.adminServer.CreateS3TablesTable) - s3TablesApi.DELETE("/tables", dash.RequireWriteAccess(), h.adminServer.DeleteS3TablesTable) - s3TablesApi.PUT("/bucket-policy", dash.RequireWriteAccess(), h.adminServer.PutS3TablesBucketPolicy) - s3TablesApi.GET("/bucket-policy", h.adminServer.GetS3TablesBucketPolicy) - s3TablesApi.DELETE("/bucket-policy", dash.RequireWriteAccess(), h.adminServer.DeleteS3TablesBucketPolicy) - s3TablesApi.PUT("/table-policy", dash.RequireWriteAccess(), h.adminServer.PutS3TablesTablePolicy) - s3TablesApi.GET("/table-policy", h.adminServer.GetS3TablesTablePolicy) - s3TablesApi.DELETE("/table-policy", dash.RequireWriteAccess(), h.adminServer.DeleteS3TablesTablePolicy) - s3TablesApi.PUT("/tags", dash.RequireWriteAccess(), h.adminServer.TagS3TablesResource) - s3TablesApi.GET("/tags", h.adminServer.ListS3TablesTags) - s3TablesApi.DELETE("/tags", dash.RequireWriteAccess(), h.adminServer.UntagS3TablesResource) - } - - // File management API routes - filesApi := api.Group("/files") - { - filesApi.DELETE("/delete", dash.RequireWriteAccess(), h.fileBrowserHandlers.DeleteFile) - filesApi.DELETE("/delete-multiple", dash.RequireWriteAccess(), h.fileBrowserHandlers.DeleteMultipleFiles) - filesApi.POST("/create-folder", dash.RequireWriteAccess(), h.fileBrowserHandlers.CreateFolder) - filesApi.POST("/upload", dash.RequireWriteAccess(), h.fileBrowserHandlers.UploadFile) - filesApi.GET("/download", h.fileBrowserHandlers.DownloadFile) - filesApi.GET("/view", h.fileBrowserHandlers.ViewFile) - filesApi.GET("/properties", h.fileBrowserHandlers.GetFileProperties) - } - - // Volume management API routes - volumeApi := api.Group("/volumes") - { - volumeApi.POST("/:id/:server/vacuum", dash.RequireWriteAccess(), h.clusterHandlers.VacuumVolume) - } + r.HandleFunc("/login", h.authHandlers.ShowLogin).Methods(http.MethodGet) + r.Handle("/login", h.authHandlers.HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword)).Methods(http.MethodPost) + r.HandleFunc("/logout", h.authHandlers.HandleLogout).Methods(http.MethodGet) - // Plugin API routes - pluginApi := api.Group("/plugin") - { - pluginApi.GET("/status", h.adminServer.GetPluginStatusAPI) - pluginApi.GET("/workers", h.adminServer.GetPluginWorkersAPI) - pluginApi.GET("/job-types", h.adminServer.GetPluginJobTypesAPI) - pluginApi.GET("/jobs", h.adminServer.GetPluginJobsAPI) - pluginApi.GET("/jobs/:jobId", h.adminServer.GetPluginJobAPI) - pluginApi.GET("/jobs/:jobId/detail", h.adminServer.GetPluginJobDetailAPI) - pluginApi.GET("/activities", h.adminServer.GetPluginActivitiesAPI) - pluginApi.GET("/scheduler-states", h.adminServer.GetPluginSchedulerStatesAPI) - pluginApi.GET("/job-types/:jobType/descriptor", h.adminServer.GetPluginJobTypeDescriptorAPI) - pluginApi.POST("/job-types/:jobType/schema", h.adminServer.RequestPluginJobTypeSchemaAPI) - pluginApi.GET("/job-types/:jobType/config", h.adminServer.GetPluginJobTypeConfigAPI) - pluginApi.PUT("/job-types/:jobType/config", dash.RequireWriteAccess(), h.adminServer.UpdatePluginJobTypeConfigAPI) - pluginApi.GET("/job-types/:jobType/runs", h.adminServer.GetPluginRunHistoryAPI) - pluginApi.POST("/job-types/:jobType/detect", dash.RequireWriteAccess(), h.adminServer.TriggerPluginDetectionAPI) - pluginApi.POST("/job-types/:jobType/run", dash.RequireWriteAccess(), h.adminServer.RunPluginJobTypeAPI) - pluginApi.POST("/jobs/execute", dash.RequireWriteAccess(), h.adminServer.ExecutePluginJobAPI) - } - - // Message Queue API routes - mqApi := api.Group("/mq") - { - mqApi.GET("/topics/:namespace/:topic", h.mqHandlers.GetTopicDetailsAPI) - mqApi.POST("/topics/create", dash.RequireWriteAccess(), h.mqHandlers.CreateTopicAPI) - mqApi.POST("/topics/retention/update", dash.RequireWriteAccess(), h.mqHandlers.UpdateTopicRetentionAPI) - mqApi.POST("/retention/purge", dash.RequireWriteAccess(), h.adminServer.TriggerTopicRetentionPurgeAPI) - } - } - } else { - // No authentication required - all routes are public - r.GET("/", h.ShowDashboard) - r.GET("/admin", h.ShowDashboard) - - // Object Store management routes - r.GET("/object-store/buckets", h.ShowS3Buckets) - r.GET("/object-store/buckets/:bucket", h.ShowBucketDetails) - r.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers) - r.GET("/object-store/policies", h.policyHandlers.ShowPolicies) - r.GET("/object-store/service-accounts", h.serviceAccountHandlers.ShowServiceAccounts) - r.GET("/object-store/s3tables/buckets", h.ShowS3TablesBuckets) - r.GET("/object-store/s3tables/buckets/:bucket/namespaces", h.ShowS3TablesNamespaces) - r.GET("/object-store/s3tables/buckets/:bucket/namespaces/:namespace/tables", h.ShowS3TablesTables) - r.GET("/object-store/s3tables/buckets/:bucket/namespaces/:namespace/tables/:table", h.ShowS3TablesTableDetails) - r.GET("/object-store/iceberg", h.ShowIcebergCatalog) - r.GET("/object-store/iceberg/:catalog/namespaces", h.ShowIcebergNamespaces) - r.GET("/object-store/iceberg/:catalog/namespaces/:namespace/tables", h.ShowIcebergTables) - r.GET("/object-store/iceberg/:catalog/namespaces/:namespace/tables/:table", h.ShowIcebergTableDetails) - - // File browser routes - r.GET("/files", h.fileBrowserHandlers.ShowFileBrowser) - - // Cluster management routes - r.GET("/cluster/masters", h.clusterHandlers.ShowClusterMasters) - r.GET("/cluster/filers", h.clusterHandlers.ShowClusterFilers) - r.GET("/cluster/volume-servers", h.clusterHandlers.ShowClusterVolumeServers) - - // Storage management routes - r.GET("/storage/volumes", h.clusterHandlers.ShowClusterVolumes) - r.GET("/storage/volumes/:id/:server", h.clusterHandlers.ShowVolumeDetails) - r.GET("/storage/collections", h.clusterHandlers.ShowClusterCollections) - r.GET("/storage/collections/:name", h.clusterHandlers.ShowCollectionDetails) - r.GET("/storage/ec-shards", h.clusterHandlers.ShowClusterEcShards) - r.GET("/storage/ec-volumes/:id", h.clusterHandlers.ShowEcVolumeDetails) - - // Message Queue management routes - r.GET("/mq/brokers", h.mqHandlers.ShowBrokers) - r.GET("/mq/topics", h.mqHandlers.ShowTopics) - r.GET("/mq/topics/:namespace/:topic", h.mqHandlers.ShowTopicDetails) - - r.GET("/plugin", h.pluginHandlers.ShowPlugin) - r.GET("/plugin/configuration", h.pluginHandlers.ShowPluginConfiguration) - r.GET("/plugin/queue", h.pluginHandlers.ShowPluginQueue) - r.GET("/plugin/detection", h.pluginHandlers.ShowPluginDetection) - r.GET("/plugin/execution", h.pluginHandlers.ShowPluginExecution) - r.GET("/plugin/monitoring", h.pluginHandlers.ShowPluginMonitoring) - - // API routes for AJAX calls - api := r.Group("/api") - { - api.GET("/cluster/topology", h.clusterHandlers.GetClusterTopology) - api.GET("/cluster/masters", h.clusterHandlers.GetMasters) - api.GET("/cluster/volumes", h.clusterHandlers.GetVolumeServers) - api.GET("/admin", h.adminServer.ShowAdmin) // JSON API for admin data - api.GET("/config", h.adminServer.GetConfigInfo) // Configuration information - - // S3 API routes - s3Api := api.Group("/s3") - { - s3Api.GET("/buckets", h.adminServer.ListBucketsAPI) - s3Api.POST("/buckets", h.adminServer.CreateBucket) - s3Api.DELETE("/buckets/:bucket", h.adminServer.DeleteBucket) - s3Api.GET("/buckets/:bucket", h.adminServer.ShowBucketDetails) - s3Api.PUT("/buckets/:bucket/quota", h.adminServer.UpdateBucketQuota) - s3Api.PUT("/buckets/:bucket/owner", h.adminServer.UpdateBucketOwner) - } - - // User management API routes - usersApi := api.Group("/users") - { - usersApi.GET("", h.userHandlers.GetUsers) - usersApi.POST("", h.userHandlers.CreateUser) - usersApi.GET("/:username", h.userHandlers.GetUserDetails) - usersApi.PUT("/:username", h.userHandlers.UpdateUser) - usersApi.DELETE("/:username", h.userHandlers.DeleteUser) - usersApi.POST("/:username/access-keys", h.userHandlers.CreateAccessKey) - usersApi.DELETE("/:username/access-keys/:accessKeyId", h.userHandlers.DeleteAccessKey) - usersApi.PUT("/:username/access-keys/:accessKeyId/status", h.userHandlers.UpdateAccessKeyStatus) - usersApi.GET("/:username/policies", h.userHandlers.GetUserPolicies) - usersApi.PUT("/:username/policies", h.userHandlers.UpdateUserPolicies) - } - - // Service Account management API routes - saApi := api.Group("/service-accounts") - { - saApi.GET("", h.serviceAccountHandlers.GetServiceAccounts) - saApi.POST("", h.serviceAccountHandlers.CreateServiceAccount) - saApi.GET("/:id", h.serviceAccountHandlers.GetServiceAccountDetails) - saApi.PUT("/:id", h.serviceAccountHandlers.UpdateServiceAccount) - saApi.DELETE("/:id", h.serviceAccountHandlers.DeleteServiceAccount) - } - - // Object Store Policy management API routes - objectStorePoliciesApi := api.Group("/object-store/policies") - { - objectStorePoliciesApi.GET("", h.policyHandlers.GetPolicies) - objectStorePoliciesApi.POST("", h.policyHandlers.CreatePolicy) - objectStorePoliciesApi.GET("/:name", h.policyHandlers.GetPolicy) - objectStorePoliciesApi.PUT("/:name", h.policyHandlers.UpdatePolicy) - objectStorePoliciesApi.DELETE("/:name", h.policyHandlers.DeletePolicy) - objectStorePoliciesApi.POST("/validate", h.policyHandlers.ValidatePolicy) - } - - // S3 Tables API routes - s3TablesApi := api.Group("/s3tables") - { - s3TablesApi.GET("/buckets", h.adminServer.ListS3TablesBucketsAPI) - s3TablesApi.POST("/buckets", h.adminServer.CreateS3TablesBucket) - s3TablesApi.DELETE("/buckets", h.adminServer.DeleteS3TablesBucket) - s3TablesApi.GET("/namespaces", h.adminServer.ListS3TablesNamespacesAPI) - s3TablesApi.POST("/namespaces", h.adminServer.CreateS3TablesNamespace) - s3TablesApi.DELETE("/namespaces", h.adminServer.DeleteS3TablesNamespace) - s3TablesApi.GET("/tables", h.adminServer.ListS3TablesTablesAPI) - s3TablesApi.POST("/tables", h.adminServer.CreateS3TablesTable) - s3TablesApi.DELETE("/tables", h.adminServer.DeleteS3TablesTable) - s3TablesApi.PUT("/bucket-policy", h.adminServer.PutS3TablesBucketPolicy) - s3TablesApi.GET("/bucket-policy", h.adminServer.GetS3TablesBucketPolicy) - s3TablesApi.DELETE("/bucket-policy", h.adminServer.DeleteS3TablesBucketPolicy) - s3TablesApi.PUT("/table-policy", h.adminServer.PutS3TablesTablePolicy) - s3TablesApi.GET("/table-policy", h.adminServer.GetS3TablesTablePolicy) - s3TablesApi.DELETE("/table-policy", h.adminServer.DeleteS3TablesTablePolicy) - s3TablesApi.PUT("/tags", h.adminServer.TagS3TablesResource) - s3TablesApi.GET("/tags", h.adminServer.ListS3TablesTags) - s3TablesApi.DELETE("/tags", h.adminServer.UntagS3TablesResource) - } - - // File management API routes - filesApi := api.Group("/files") - { - filesApi.DELETE("/delete", h.fileBrowserHandlers.DeleteFile) - filesApi.DELETE("/delete-multiple", h.fileBrowserHandlers.DeleteMultipleFiles) - filesApi.POST("/create-folder", h.fileBrowserHandlers.CreateFolder) - filesApi.POST("/upload", h.fileBrowserHandlers.UploadFile) - filesApi.GET("/download", h.fileBrowserHandlers.DownloadFile) - filesApi.GET("/view", h.fileBrowserHandlers.ViewFile) - filesApi.GET("/properties", h.fileBrowserHandlers.GetFileProperties) - } - - // Volume management API routes - volumeApi := api.Group("/volumes") - { - volumeApi.POST("/:id/:server/vacuum", h.clusterHandlers.VacuumVolume) - } + protected := r.NewRoute().Subrouter() + protected.Use(dash.RequireAuth(h.sessionStore)) + h.registerUIRoutes(protected) - // Plugin API routes - pluginApi := api.Group("/plugin") - { - pluginApi.GET("/status", h.adminServer.GetPluginStatusAPI) - pluginApi.GET("/workers", h.adminServer.GetPluginWorkersAPI) - pluginApi.GET("/job-types", h.adminServer.GetPluginJobTypesAPI) - pluginApi.GET("/jobs", h.adminServer.GetPluginJobsAPI) - pluginApi.GET("/jobs/:jobId", h.adminServer.GetPluginJobAPI) - pluginApi.GET("/jobs/:jobId/detail", h.adminServer.GetPluginJobDetailAPI) - pluginApi.GET("/activities", h.adminServer.GetPluginActivitiesAPI) - pluginApi.GET("/scheduler-states", h.adminServer.GetPluginSchedulerStatesAPI) - pluginApi.GET("/job-types/:jobType/descriptor", h.adminServer.GetPluginJobTypeDescriptorAPI) - pluginApi.POST("/job-types/:jobType/schema", h.adminServer.RequestPluginJobTypeSchemaAPI) - pluginApi.GET("/job-types/:jobType/config", h.adminServer.GetPluginJobTypeConfigAPI) - pluginApi.PUT("/job-types/:jobType/config", h.adminServer.UpdatePluginJobTypeConfigAPI) - pluginApi.GET("/job-types/:jobType/runs", h.adminServer.GetPluginRunHistoryAPI) - pluginApi.POST("/job-types/:jobType/detect", h.adminServer.TriggerPluginDetectionAPI) - pluginApi.POST("/job-types/:jobType/run", h.adminServer.RunPluginJobTypeAPI) - pluginApi.POST("/jobs/execute", h.adminServer.ExecutePluginJobAPI) - } - - // Message Queue API routes - mqApi := api.Group("/mq") - { - mqApi.GET("/topics/:namespace/:topic", h.mqHandlers.GetTopicDetailsAPI) - mqApi.POST("/topics/create", h.mqHandlers.CreateTopicAPI) - mqApi.POST("/topics/retention/update", h.mqHandlers.UpdateTopicRetentionAPI) - mqApi.POST("/retention/purge", h.adminServer.TriggerTopicRetentionPurgeAPI) - } + api := r.PathPrefix("/api").Subrouter() + api.Use(dash.RequireAuthAPI(h.sessionStore)) + h.registerAPIRoutes(api, true) + return + } + + // No authentication required - all routes are public + h.registerUIRoutes(r) + api := r.PathPrefix("/api").Subrouter() + h.registerAPIRoutes(api, false) +} + +func (h *AdminHandlers) registerUIRoutes(r *mux.Router) { + // Main admin interface routes + r.HandleFunc("/", h.ShowDashboard).Methods(http.MethodGet) + r.HandleFunc("/admin", h.ShowDashboard).Methods(http.MethodGet) + + // Object Store management routes + r.HandleFunc("/object-store/buckets", h.ShowS3Buckets).Methods(http.MethodGet) + r.HandleFunc("/object-store/buckets/{bucket}", h.ShowBucketDetails).Methods(http.MethodGet) + r.HandleFunc("/object-store/users", h.userHandlers.ShowObjectStoreUsers).Methods(http.MethodGet) + r.HandleFunc("/object-store/policies", h.policyHandlers.ShowPolicies).Methods(http.MethodGet) + r.HandleFunc("/object-store/service-accounts", h.serviceAccountHandlers.ShowServiceAccounts).Methods(http.MethodGet) + r.HandleFunc("/object-store/s3tables/buckets", h.ShowS3TablesBuckets).Methods(http.MethodGet) + r.HandleFunc("/object-store/s3tables/buckets/{bucket}/namespaces", h.ShowS3TablesNamespaces).Methods(http.MethodGet) + r.HandleFunc("/object-store/s3tables/buckets/{bucket}/namespaces/{namespace}/tables", h.ShowS3TablesTables).Methods(http.MethodGet) + r.HandleFunc("/object-store/s3tables/buckets/{bucket}/namespaces/{namespace}/tables/{table}", h.ShowS3TablesTableDetails).Methods(http.MethodGet) + r.HandleFunc("/object-store/iceberg", h.ShowIcebergCatalog).Methods(http.MethodGet) + r.HandleFunc("/object-store/iceberg/{catalog}/namespaces", h.ShowIcebergNamespaces).Methods(http.MethodGet) + r.HandleFunc("/object-store/iceberg/{catalog}/namespaces/{namespace}/tables", h.ShowIcebergTables).Methods(http.MethodGet) + r.HandleFunc("/object-store/iceberg/{catalog}/namespaces/{namespace}/tables/{table}", h.ShowIcebergTableDetails).Methods(http.MethodGet) + + // File browser routes + r.HandleFunc("/files", h.fileBrowserHandlers.ShowFileBrowser).Methods(http.MethodGet) + + // Cluster management routes + r.HandleFunc("/cluster/masters", h.clusterHandlers.ShowClusterMasters).Methods(http.MethodGet) + r.HandleFunc("/cluster/filers", h.clusterHandlers.ShowClusterFilers).Methods(http.MethodGet) + r.HandleFunc("/cluster/volume-servers", h.clusterHandlers.ShowClusterVolumeServers).Methods(http.MethodGet) + + // Storage management routes + r.HandleFunc("/storage/volumes", h.clusterHandlers.ShowClusterVolumes).Methods(http.MethodGet) + r.HandleFunc("/storage/volumes/{id}/{server}", h.clusterHandlers.ShowVolumeDetails).Methods(http.MethodGet) + r.HandleFunc("/storage/collections", h.clusterHandlers.ShowClusterCollections).Methods(http.MethodGet) + r.HandleFunc("/storage/collections/{name}", h.clusterHandlers.ShowCollectionDetails).Methods(http.MethodGet) + r.HandleFunc("/storage/ec-shards", h.clusterHandlers.ShowClusterEcShards).Methods(http.MethodGet) + r.HandleFunc("/storage/ec-volumes/{id}", h.clusterHandlers.ShowEcVolumeDetails).Methods(http.MethodGet) + + // Message Queue management routes + r.HandleFunc("/mq/brokers", h.mqHandlers.ShowBrokers).Methods(http.MethodGet) + r.HandleFunc("/mq/topics", h.mqHandlers.ShowTopics).Methods(http.MethodGet) + r.HandleFunc("/mq/topics/{namespace}/{topic}", h.mqHandlers.ShowTopicDetails).Methods(http.MethodGet) + + // Plugin pages + r.HandleFunc("/plugin", h.pluginHandlers.ShowPlugin).Methods(http.MethodGet) + r.HandleFunc("/plugin/configuration", h.pluginHandlers.ShowPluginConfiguration).Methods(http.MethodGet) + r.HandleFunc("/plugin/queue", h.pluginHandlers.ShowPluginQueue).Methods(http.MethodGet) + r.HandleFunc("/plugin/detection", h.pluginHandlers.ShowPluginDetection).Methods(http.MethodGet) + r.HandleFunc("/plugin/execution", h.pluginHandlers.ShowPluginExecution).Methods(http.MethodGet) + r.HandleFunc("/plugin/monitoring", h.pluginHandlers.ShowPluginMonitoring).Methods(http.MethodGet) +} + +func (h *AdminHandlers) registerAPIRoutes(api *mux.Router, enforceWrite bool) { + wrapWrite := func(handler http.HandlerFunc) http.Handler { + if !enforceWrite { + return handler } + return dash.RequireWriteAccess()(handler) } + + api.HandleFunc("/cluster/topology", h.clusterHandlers.GetClusterTopology).Methods(http.MethodGet) + api.HandleFunc("/cluster/masters", h.clusterHandlers.GetMasters).Methods(http.MethodGet) + api.HandleFunc("/cluster/volumes", h.clusterHandlers.GetVolumeServers).Methods(http.MethodGet) + api.HandleFunc("/admin", h.adminServer.ShowAdmin).Methods(http.MethodGet) + api.HandleFunc("/config", h.adminServer.GetConfigInfo).Methods(http.MethodGet) + + s3Api := api.PathPrefix("/s3").Subrouter() + s3Api.HandleFunc("/buckets", h.adminServer.ListBucketsAPI).Methods(http.MethodGet) + s3Api.Handle("/buckets", wrapWrite(h.adminServer.CreateBucket)).Methods(http.MethodPost) + s3Api.Handle("/buckets/{bucket}", wrapWrite(h.adminServer.DeleteBucket)).Methods(http.MethodDelete) + s3Api.HandleFunc("/buckets/{bucket}", h.adminServer.ShowBucketDetails).Methods(http.MethodGet) + s3Api.Handle("/buckets/{bucket}/quota", wrapWrite(h.adminServer.UpdateBucketQuota)).Methods(http.MethodPut) + s3Api.Handle("/buckets/{bucket}/owner", wrapWrite(h.adminServer.UpdateBucketOwner)).Methods(http.MethodPut) + + usersApi := api.PathPrefix("/users").Subrouter() + usersApi.HandleFunc("", h.userHandlers.GetUsers).Methods(http.MethodGet) + usersApi.Handle("", wrapWrite(h.userHandlers.CreateUser)).Methods(http.MethodPost) + usersApi.HandleFunc("/{username}", h.userHandlers.GetUserDetails).Methods(http.MethodGet) + usersApi.Handle("/{username}", wrapWrite(h.userHandlers.UpdateUser)).Methods(http.MethodPut) + usersApi.Handle("/{username}", wrapWrite(h.userHandlers.DeleteUser)).Methods(http.MethodDelete) + usersApi.Handle("/{username}/access-keys", wrapWrite(h.userHandlers.CreateAccessKey)).Methods(http.MethodPost) + usersApi.Handle("/{username}/access-keys/{accessKeyId}", wrapWrite(h.userHandlers.DeleteAccessKey)).Methods(http.MethodDelete) + usersApi.Handle("/{username}/access-keys/{accessKeyId}/status", wrapWrite(h.userHandlers.UpdateAccessKeyStatus)).Methods(http.MethodPut) + usersApi.HandleFunc("/{username}/policies", h.userHandlers.GetUserPolicies).Methods(http.MethodGet) + usersApi.Handle("/{username}/policies", wrapWrite(h.userHandlers.UpdateUserPolicies)).Methods(http.MethodPut) + + saApi := api.PathPrefix("/service-accounts").Subrouter() + saApi.HandleFunc("", h.serviceAccountHandlers.GetServiceAccounts).Methods(http.MethodGet) + saApi.Handle("", wrapWrite(h.serviceAccountHandlers.CreateServiceAccount)).Methods(http.MethodPost) + saApi.HandleFunc("/{id}", h.serviceAccountHandlers.GetServiceAccountDetails).Methods(http.MethodGet) + saApi.Handle("/{id}", wrapWrite(h.serviceAccountHandlers.UpdateServiceAccount)).Methods(http.MethodPut) + saApi.Handle("/{id}", wrapWrite(h.serviceAccountHandlers.DeleteServiceAccount)).Methods(http.MethodDelete) + + policyApi := api.PathPrefix("/object-store/policies").Subrouter() + policyApi.HandleFunc("", h.policyHandlers.GetPolicies).Methods(http.MethodGet) + policyApi.Handle("", wrapWrite(h.policyHandlers.CreatePolicy)).Methods(http.MethodPost) + policyApi.HandleFunc("/{name}", h.policyHandlers.GetPolicy).Methods(http.MethodGet) + policyApi.Handle("/{name}", wrapWrite(h.policyHandlers.UpdatePolicy)).Methods(http.MethodPut) + policyApi.Handle("/{name}", wrapWrite(h.policyHandlers.DeletePolicy)).Methods(http.MethodDelete) + policyApi.HandleFunc("/validate", h.policyHandlers.ValidatePolicy).Methods(http.MethodPost) + + s3TablesApi := api.PathPrefix("/s3tables").Subrouter() + s3TablesApi.HandleFunc("/buckets", h.adminServer.ListS3TablesBucketsAPI).Methods(http.MethodGet) + s3TablesApi.Handle("/buckets", wrapWrite(h.adminServer.CreateS3TablesBucket)).Methods(http.MethodPost) + s3TablesApi.Handle("/buckets", wrapWrite(h.adminServer.DeleteS3TablesBucket)).Methods(http.MethodDelete) + s3TablesApi.HandleFunc("/namespaces", h.adminServer.ListS3TablesNamespacesAPI).Methods(http.MethodGet) + s3TablesApi.Handle("/namespaces", wrapWrite(h.adminServer.CreateS3TablesNamespace)).Methods(http.MethodPost) + s3TablesApi.Handle("/namespaces", wrapWrite(h.adminServer.DeleteS3TablesNamespace)).Methods(http.MethodDelete) + s3TablesApi.HandleFunc("/tables", h.adminServer.ListS3TablesTablesAPI).Methods(http.MethodGet) + s3TablesApi.Handle("/tables", wrapWrite(h.adminServer.CreateS3TablesTable)).Methods(http.MethodPost) + s3TablesApi.Handle("/tables", wrapWrite(h.adminServer.DeleteS3TablesTable)).Methods(http.MethodDelete) + s3TablesApi.Handle("/bucket-policy", wrapWrite(h.adminServer.PutS3TablesBucketPolicy)).Methods(http.MethodPut) + s3TablesApi.HandleFunc("/bucket-policy", h.adminServer.GetS3TablesBucketPolicy).Methods(http.MethodGet) + s3TablesApi.Handle("/bucket-policy", wrapWrite(h.adminServer.DeleteS3TablesBucketPolicy)).Methods(http.MethodDelete) + s3TablesApi.Handle("/table-policy", wrapWrite(h.adminServer.PutS3TablesTablePolicy)).Methods(http.MethodPut) + s3TablesApi.HandleFunc("/table-policy", h.adminServer.GetS3TablesTablePolicy).Methods(http.MethodGet) + s3TablesApi.Handle("/table-policy", wrapWrite(h.adminServer.DeleteS3TablesTablePolicy)).Methods(http.MethodDelete) + s3TablesApi.Handle("/tags", wrapWrite(h.adminServer.TagS3TablesResource)).Methods(http.MethodPut) + s3TablesApi.HandleFunc("/tags", h.adminServer.ListS3TablesTags).Methods(http.MethodGet) + s3TablesApi.Handle("/tags", wrapWrite(h.adminServer.UntagS3TablesResource)).Methods(http.MethodDelete) + + filesApi := api.PathPrefix("/files").Subrouter() + filesApi.Handle("/delete", wrapWrite(h.fileBrowserHandlers.DeleteFile)).Methods(http.MethodDelete) + filesApi.Handle("/delete-multiple", wrapWrite(h.fileBrowserHandlers.DeleteMultipleFiles)).Methods(http.MethodDelete) + filesApi.Handle("/create-folder", wrapWrite(h.fileBrowserHandlers.CreateFolder)).Methods(http.MethodPost) + filesApi.Handle("/upload", wrapWrite(h.fileBrowserHandlers.UploadFile)).Methods(http.MethodPost) + filesApi.HandleFunc("/download", h.fileBrowserHandlers.DownloadFile).Methods(http.MethodGet) + filesApi.HandleFunc("/view", h.fileBrowserHandlers.ViewFile).Methods(http.MethodGet) + filesApi.HandleFunc("/properties", h.fileBrowserHandlers.GetFileProperties).Methods(http.MethodGet) + + volumeApi := api.PathPrefix("/volumes").Subrouter() + volumeApi.Handle("/{id}/{server}/vacuum", wrapWrite(h.clusterHandlers.VacuumVolume)).Methods(http.MethodPost) + + pluginApi := api.PathPrefix("/plugin").Subrouter() + pluginApi.HandleFunc("/status", h.adminServer.GetPluginStatusAPI).Methods(http.MethodGet) + pluginApi.HandleFunc("/workers", h.adminServer.GetPluginWorkersAPI).Methods(http.MethodGet) + pluginApi.HandleFunc("/job-types", h.adminServer.GetPluginJobTypesAPI).Methods(http.MethodGet) + pluginApi.HandleFunc("/jobs", h.adminServer.GetPluginJobsAPI).Methods(http.MethodGet) + pluginApi.HandleFunc("/jobs/{jobId}", h.adminServer.GetPluginJobAPI).Methods(http.MethodGet) + pluginApi.HandleFunc("/jobs/{jobId}/detail", h.adminServer.GetPluginJobDetailAPI).Methods(http.MethodGet) + pluginApi.HandleFunc("/activities", h.adminServer.GetPluginActivitiesAPI).Methods(http.MethodGet) + pluginApi.HandleFunc("/scheduler-states", h.adminServer.GetPluginSchedulerStatesAPI).Methods(http.MethodGet) + pluginApi.HandleFunc("/job-types/{jobType}/descriptor", h.adminServer.GetPluginJobTypeDescriptorAPI).Methods(http.MethodGet) + pluginApi.HandleFunc("/job-types/{jobType}/schema", h.adminServer.RequestPluginJobTypeSchemaAPI).Methods(http.MethodPost) + pluginApi.HandleFunc("/job-types/{jobType}/config", h.adminServer.GetPluginJobTypeConfigAPI).Methods(http.MethodGet) + pluginApi.Handle("/job-types/{jobType}/config", wrapWrite(h.adminServer.UpdatePluginJobTypeConfigAPI)).Methods(http.MethodPut) + pluginApi.HandleFunc("/job-types/{jobType}/runs", h.adminServer.GetPluginRunHistoryAPI).Methods(http.MethodGet) + pluginApi.Handle("/job-types/{jobType}/detect", wrapWrite(h.adminServer.TriggerPluginDetectionAPI)).Methods(http.MethodPost) + pluginApi.Handle("/job-types/{jobType}/run", wrapWrite(h.adminServer.RunPluginJobTypeAPI)).Methods(http.MethodPost) + pluginApi.Handle("/jobs/execute", wrapWrite(h.adminServer.ExecutePluginJobAPI)).Methods(http.MethodPost) + + mqApi := api.PathPrefix("/mq").Subrouter() + mqApi.HandleFunc("/topics/{namespace}/{topic}", h.mqHandlers.GetTopicDetailsAPI).Methods(http.MethodGet) + mqApi.Handle("/topics/create", wrapWrite(h.mqHandlers.CreateTopicAPI)).Methods(http.MethodPost) + mqApi.Handle("/topics/retention/update", wrapWrite(h.mqHandlers.UpdateTopicRetentionAPI)).Methods(http.MethodPost) + mqApi.Handle("/retention/purge", wrapWrite(h.adminServer.TriggerTopicRetentionPurgeAPI)).Methods(http.MethodPost) } // HealthCheck returns the health status of the admin interface -func (h *AdminHandlers) HealthCheck(c *gin.Context) { - c.JSON(200, gin.H{"health": "ok"}) +func (h *AdminHandlers) HealthCheck(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"health": "ok"}) } // ShowDashboard renders the main admin dashboard -func (h *AdminHandlers) ShowDashboard(c *gin.Context) { +func (h *AdminHandlers) ShowDashboard(w http.ResponseWriter, r *http.Request) { // Get admin data from the server - adminData := h.getAdminData(c) + adminData := h.getAdminData(r) + username := h.getUsername(r) // Render HTML template - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") adminComponent := app.Admin(adminData) - layoutComponent := layout.Layout(c, adminComponent) - err := layoutComponent.Render(c.Request.Context(), c.Writer) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, adminComponent) + if err := layoutComponent.Render(r.Context(), w); err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) return } } // ShowS3Buckets renders the Object Store buckets management page -func (h *AdminHandlers) ShowS3Buckets(c *gin.Context) { +func (h *AdminHandlers) ShowS3Buckets(w http.ResponseWriter, r *http.Request) { // Get Object Store buckets data from the server - s3Data := h.getS3BucketsData(c) + s3Data := h.getS3BucketsData(r) + username := h.getUsername(r) // Render HTML template - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") s3Component := app.S3Buckets(s3Data) - layoutComponent := layout.Layout(c, s3Component) - err := layoutComponent.Render(c.Request.Context(), c.Writer) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, s3Component) + if err := layoutComponent.Render(r.Context(), w); err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) return } } // ShowS3TablesBuckets renders the S3 Tables buckets page -func (h *AdminHandlers) ShowS3TablesBuckets(c *gin.Context) { - username := h.getUsername(c) +func (h *AdminHandlers) ShowS3TablesBuckets(w http.ResponseWriter, r *http.Request) { + username := h.getUsername(r) - data, err := h.adminServer.GetS3TablesBucketsData(c.Request.Context()) + data, err := h.adminServer.GetS3TablesBucketsData(r.Context()) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get S3 Tables buckets: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get S3 Tables buckets: "+err.Error()) return } data.Username = username - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") component := app.S3TablesBuckets(data) - layoutComponent := layout.Layout(c, component) - if err := layoutComponent.Render(c.Request.Context(), c.Writer); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, component) + if err := layoutComponent.Render(r.Context(), w); err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) } } // ShowS3TablesNamespaces renders namespaces for a table bucket -func (h *AdminHandlers) ShowS3TablesNamespaces(c *gin.Context) { - username := h.getUsername(c) +func (h *AdminHandlers) ShowS3TablesNamespaces(w http.ResponseWriter, r *http.Request) { + username := h.getUsername(r) - bucketName := c.Param("bucket") + bucketName := mux.Vars(r)["bucket"] arn, err := buildS3TablesBucketArn(bucketName) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusBadRequest, err.Error()) return } - data, err := h.adminServer.GetS3TablesNamespacesData(c.Request.Context(), arn) + data, err := h.adminServer.GetS3TablesNamespacesData(r.Context(), arn) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get S3 Tables namespaces: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get S3 Tables namespaces: "+err.Error()) return } data.Username = username - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") component := app.S3TablesNamespaces(data) - layoutComponent := layout.Layout(c, component) - if err := layoutComponent.Render(c.Request.Context(), c.Writer); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, component) + if err := layoutComponent.Render(r.Context(), w); err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) } } // ShowS3TablesTables renders tables for a namespace -func (h *AdminHandlers) ShowS3TablesTables(c *gin.Context) { - username := h.getUsername(c) +func (h *AdminHandlers) ShowS3TablesTables(w http.ResponseWriter, r *http.Request) { + username := h.getUsername(r) - bucketName := c.Param("bucket") - namespace := c.Param("namespace") + bucketName := mux.Vars(r)["bucket"] + namespace := mux.Vars(r)["namespace"] arn, err := buildS3TablesBucketArn(bucketName) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusBadRequest, err.Error()) return } - data, err := h.adminServer.GetS3TablesTablesData(c.Request.Context(), arn, namespace) + data, err := h.adminServer.GetS3TablesTablesData(r.Context(), arn, namespace) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get S3 Tables tables: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get S3 Tables tables: "+err.Error()) return } data.Username = username - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") component := app.S3TablesTables(data) - layoutComponent := layout.Layout(c, component) - if err := layoutComponent.Render(c.Request.Context(), c.Writer); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, component) + if err := layoutComponent.Render(r.Context(), w); err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) } } // ShowS3TablesTableDetails renders Iceberg table metadata and snapshot details on the merged S3 Tables path. -func (h *AdminHandlers) ShowS3TablesTableDetails(c *gin.Context) { - bucketName := c.Param("bucket") - namespace := c.Param("namespace") - tableName := c.Param("table") +func (h *AdminHandlers) ShowS3TablesTableDetails(w http.ResponseWriter, r *http.Request) { + bucketName := mux.Vars(r)["bucket"] + namespace := mux.Vars(r)["namespace"] + tableName := mux.Vars(r)["table"] arn, err := buildS3TablesBucketArn(bucketName) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusBadRequest, err.Error()) return } - data, err := h.adminServer.GetIcebergTableDetailsData(c.Request.Context(), bucketName, arn, namespace, tableName) + username := h.getUsername(r) + data, err := h.adminServer.GetIcebergTableDetailsData(r.Context(), bucketName, arn, namespace, tableName) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get table details: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get table details: "+err.Error()) return } - data.Username = h.getUsername(c) + data.Username = username - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") component := app.IcebergTableDetails(data) - layoutComponent := layout.Layout(c, component) - if err := layoutComponent.Render(c.Request.Context(), c.Writer); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, component) + if err := layoutComponent.Render(r.Context(), w); err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) } } @@ -572,8 +397,8 @@ func buildS3TablesBucketArn(bucketName string) (string, error) { } // getUsername returns the username from context, defaulting to "admin" if not set -func (h *AdminHandlers) getUsername(c *gin.Context) string { - username := c.GetString("username") +func (h *AdminHandlers) getUsername(r *http.Request) string { + username := dash.UsernameFromContext(r.Context()) if username == "" { username = "admin" } @@ -581,45 +406,45 @@ func (h *AdminHandlers) getUsername(c *gin.Context) string { } // ShowIcebergCatalog redirects legacy Iceberg catalog URL to the merged S3 Tables buckets page. -func (h *AdminHandlers) ShowIcebergCatalog(c *gin.Context) { - c.Redirect(http.StatusMovedPermanently, "/object-store/s3tables/buckets") +func (h *AdminHandlers) ShowIcebergCatalog(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/object-store/s3tables/buckets", http.StatusMovedPermanently) } // ShowIcebergNamespaces redirects legacy Iceberg namespaces URL to the merged S3 Tables namespaces page. -func (h *AdminHandlers) ShowIcebergNamespaces(c *gin.Context) { - catalogName := c.Param("catalog") - c.Redirect(http.StatusMovedPermanently, "/object-store/s3tables/buckets/"+url.PathEscape(catalogName)+"/namespaces") +func (h *AdminHandlers) ShowIcebergNamespaces(w http.ResponseWriter, r *http.Request) { + catalogName := mux.Vars(r)["catalog"] + http.Redirect(w, r, "/object-store/s3tables/buckets/"+url.PathEscape(catalogName)+"/namespaces", http.StatusMovedPermanently) } // ShowIcebergTables redirects legacy Iceberg tables URL to the merged S3 Tables tables page. -func (h *AdminHandlers) ShowIcebergTables(c *gin.Context) { - catalogName := c.Param("catalog") - namespace := c.Param("namespace") - c.Redirect(http.StatusMovedPermanently, "/object-store/s3tables/buckets/"+url.PathEscape(catalogName)+"/namespaces/"+url.PathEscape(namespace)+"/tables") +func (h *AdminHandlers) ShowIcebergTables(w http.ResponseWriter, r *http.Request) { + catalogName := mux.Vars(r)["catalog"] + namespace := mux.Vars(r)["namespace"] + http.Redirect(w, r, "/object-store/s3tables/buckets/"+url.PathEscape(catalogName)+"/namespaces/"+url.PathEscape(namespace)+"/tables", http.StatusMovedPermanently) } // ShowIcebergTableDetails redirects legacy Iceberg table details URL to the merged S3 Tables details page. -func (h *AdminHandlers) ShowIcebergTableDetails(c *gin.Context) { - catalogName := c.Param("catalog") - namespace := c.Param("namespace") - tableName := c.Param("table") - c.Redirect(http.StatusMovedPermanently, "/object-store/s3tables/buckets/"+url.PathEscape(catalogName)+"/namespaces/"+url.PathEscape(namespace)+"/tables/"+url.PathEscape(tableName)) +func (h *AdminHandlers) ShowIcebergTableDetails(w http.ResponseWriter, r *http.Request) { + catalogName := mux.Vars(r)["catalog"] + namespace := mux.Vars(r)["namespace"] + tableName := mux.Vars(r)["table"] + http.Redirect(w, r, "/object-store/s3tables/buckets/"+url.PathEscape(catalogName)+"/namespaces/"+url.PathEscape(namespace)+"/tables/"+url.PathEscape(tableName), http.StatusMovedPermanently) } // ShowBucketDetails returns detailed information about a specific bucket -func (h *AdminHandlers) ShowBucketDetails(c *gin.Context) { - bucketName := c.Param("bucket") +func (h *AdminHandlers) ShowBucketDetails(w http.ResponseWriter, r *http.Request) { + bucketName := mux.Vars(r)["bucket"] details, err := h.adminServer.GetBucketDetails(bucketName) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get bucket details: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get bucket details: "+err.Error()) return } - c.JSON(http.StatusOK, details) + writeJSON(w, http.StatusOK, details) } // getS3BucketsData retrieves Object Store buckets data from the server -func (h *AdminHandlers) getS3BucketsData(c *gin.Context) dash.S3BucketsData { - username := c.GetString("username") +func (h *AdminHandlers) getS3BucketsData(r *http.Request) dash.S3BucketsData { + username := dash.UsernameFromContext(r.Context()) if username == "" { username = "admin" } @@ -642,8 +467,8 @@ func (h *AdminHandlers) getS3BucketsData(c *gin.Context) dash.S3BucketsData { } // getAdminData retrieves admin data from the server (now uses consolidated method) -func (h *AdminHandlers) getAdminData(c *gin.Context) dash.AdminData { - username := c.GetString("username") +func (h *AdminHandlers) getAdminData(r *http.Request) dash.AdminData { + username := dash.UsernameFromContext(r.Context()) // Use the consolidated GetAdminData method from AdminServer adminData, err := h.adminServer.GetAdminData(username) diff --git a/weed/admin/handlers/admin_handlers_routes_test.go b/weed/admin/handlers/admin_handlers_routes_test.go index 162435002..ab33922e3 100644 --- a/weed/admin/handlers/admin_handlers_routes_test.go +++ b/weed/admin/handlers/admin_handlers_routes_test.go @@ -1,73 +1,74 @@ package handlers import ( + "net/http" + "net/http/httptest" "testing" - "github.com/gin-gonic/gin" + "github.com/gorilla/mux" + "github.com/gorilla/sessions" "github.com/seaweedfs/seaweedfs/weed/admin/dash" ) func TestSetupRoutes_RegistersPluginSchedulerStatesAPI_NoAuth(t *testing.T) { - gin.SetMode(gin.TestMode) - router := gin.New() + router := mux.NewRouter() newRouteTestAdminHandlers().SetupRoutes(router, false, "", "", "", "", true) - if !hasRoute(router, "GET", "/api/plugin/scheduler-states") { + if !hasRoute(router, http.MethodGet, "/api/plugin/scheduler-states") { t.Fatalf("expected GET /api/plugin/scheduler-states to be registered in no-auth mode") } - if !hasRoute(router, "GET", "/api/plugin/jobs/:jobId/detail") { + if !hasRoute(router, http.MethodGet, "/api/plugin/jobs/example/detail") { t.Fatalf("expected GET /api/plugin/jobs/:jobId/detail to be registered in no-auth mode") } } func TestSetupRoutes_RegistersPluginSchedulerStatesAPI_WithAuth(t *testing.T) { - gin.SetMode(gin.TestMode) - router := gin.New() + router := mux.NewRouter() newRouteTestAdminHandlers().SetupRoutes(router, true, "admin", "password", "", "", true) - if !hasRoute(router, "GET", "/api/plugin/scheduler-states") { + if !hasRoute(router, http.MethodGet, "/api/plugin/scheduler-states") { t.Fatalf("expected GET /api/plugin/scheduler-states to be registered in auth mode") } - if !hasRoute(router, "GET", "/api/plugin/jobs/:jobId/detail") { + if !hasRoute(router, http.MethodGet, "/api/plugin/jobs/example/detail") { t.Fatalf("expected GET /api/plugin/jobs/:jobId/detail to be registered in auth mode") } } func TestSetupRoutes_RegistersPluginPages_NoAuth(t *testing.T) { - gin.SetMode(gin.TestMode) - router := gin.New() + router := mux.NewRouter() newRouteTestAdminHandlers().SetupRoutes(router, false, "", "", "", "", true) - assertHasRoute(t, router, "GET", "/plugin") - assertHasRoute(t, router, "GET", "/plugin/configuration") - assertHasRoute(t, router, "GET", "/plugin/queue") - assertHasRoute(t, router, "GET", "/plugin/detection") - assertHasRoute(t, router, "GET", "/plugin/execution") - assertHasRoute(t, router, "GET", "/plugin/monitoring") + assertHasRoute(t, router, http.MethodGet, "/plugin") + assertHasRoute(t, router, http.MethodGet, "/plugin/configuration") + assertHasRoute(t, router, http.MethodGet, "/plugin/queue") + assertHasRoute(t, router, http.MethodGet, "/plugin/detection") + assertHasRoute(t, router, http.MethodGet, "/plugin/execution") + assertHasRoute(t, router, http.MethodGet, "/plugin/monitoring") } func TestSetupRoutes_RegistersPluginPages_WithAuth(t *testing.T) { - gin.SetMode(gin.TestMode) - router := gin.New() + router := mux.NewRouter() newRouteTestAdminHandlers().SetupRoutes(router, true, "admin", "password", "", "", true) - assertHasRoute(t, router, "GET", "/plugin") - assertHasRoute(t, router, "GET", "/plugin/configuration") - assertHasRoute(t, router, "GET", "/plugin/queue") - assertHasRoute(t, router, "GET", "/plugin/detection") - assertHasRoute(t, router, "GET", "/plugin/execution") - assertHasRoute(t, router, "GET", "/plugin/monitoring") + assertHasRoute(t, router, http.MethodGet, "/plugin") + assertHasRoute(t, router, http.MethodGet, "/plugin/configuration") + assertHasRoute(t, router, http.MethodGet, "/plugin/queue") + assertHasRoute(t, router, http.MethodGet, "/plugin/detection") + assertHasRoute(t, router, http.MethodGet, "/plugin/execution") + assertHasRoute(t, router, http.MethodGet, "/plugin/monitoring") } func newRouteTestAdminHandlers() *AdminHandlers { adminServer := &dash.AdminServer{} + store := sessions.NewCookieStore([]byte("test-session-key")) return &AdminHandlers{ adminServer: adminServer, - authHandlers: &AuthHandlers{adminServer: adminServer}, + sessionStore: store, + authHandlers: &AuthHandlers{adminServer: adminServer, sessionStore: store}, clusterHandlers: &ClusterHandlers{adminServer: adminServer}, fileBrowserHandlers: &FileBrowserHandlers{adminServer: adminServer}, userHandlers: &UserHandlers{adminServer: adminServer}, @@ -78,16 +79,13 @@ func newRouteTestAdminHandlers() *AdminHandlers { } } -func hasRoute(router *gin.Engine, method string, path string) bool { - for _, route := range router.Routes() { - if route.Method == method && route.Path == path { - return true - } - } - return false +func hasRoute(router *mux.Router, method string, path string) bool { + req := httptest.NewRequest(method, path, nil) + var match mux.RouteMatch + return router.Match(req, &match) } -func assertHasRoute(t *testing.T, router *gin.Engine, method string, path string) { +func assertHasRoute(t *testing.T, router *mux.Router, method string, path string) { t.Helper() if !hasRoute(router, method, path) { t.Fatalf("expected %s %s to be registered", method, path) diff --git a/weed/admin/handlers/auth_handlers.go b/weed/admin/handlers/auth_handlers.go index ff6f6250e..dd401b1f3 100644 --- a/weed/admin/handlers/auth_handlers.go +++ b/weed/admin/handlers/auth_handlers.go @@ -3,52 +3,65 @@ package handlers import ( "net/http" - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" + "github.com/gorilla/sessions" "github.com/seaweedfs/seaweedfs/weed/admin/dash" "github.com/seaweedfs/seaweedfs/weed/admin/view/layout" + "github.com/seaweedfs/seaweedfs/weed/glog" ) // AuthHandlers contains authentication-related HTTP handlers type AuthHandlers struct { - adminServer *dash.AdminServer + adminServer *dash.AdminServer + sessionStore sessions.Store } // NewAuthHandlers creates a new instance of AuthHandlers -func NewAuthHandlers(adminServer *dash.AdminServer) *AuthHandlers { +func NewAuthHandlers(adminServer *dash.AdminServer, store sessions.Store) *AuthHandlers { return &AuthHandlers{ - adminServer: adminServer, + adminServer: adminServer, + sessionStore: store, } } // ShowLogin displays the login page -func (a *AuthHandlers) ShowLogin(c *gin.Context) { - session := sessions.Default(c) +func (a *AuthHandlers) ShowLogin(w http.ResponseWriter, r *http.Request) { + session, err := a.sessionStore.Get(r, dash.SessionName()) + var csrfToken string + if err == nil { + if authenticated, _ := session.Values["authenticated"].(bool); authenticated { + http.Redirect(w, r, "/admin", http.StatusSeeOther) + return + } + } else { + glog.V(1).Infof("Failed to load session for login page: %v", err) + } - // If already authenticated, redirect to admin - if session.Get("authenticated") == true { - c.Redirect(http.StatusSeeOther, "/admin") - return + if session != nil { + token, tokenErr := dash.EnsureSessionCSRFToken(session, r, w) + if tokenErr != nil { + glog.V(1).Infof("Failed to ensure CSRF token for login page: %v", tokenErr) + } else { + csrfToken = token + } } - errorMessage := c.Query("error") + errorMessage := r.URL.Query().Get("error") // Render login template - c.Header("Content-Type", "text/html") - loginComponent := layout.LoginForm(c, "SeaweedFS Admin", errorMessage) - err := loginComponent.Render(c.Request.Context(), c.Writer) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render login template: " + err.Error()}) + w.Header().Set("Content-Type", "text/html") + loginComponent := layout.LoginForm("SeaweedFS Admin", errorMessage, csrfToken) + if err := loginComponent.Render(r.Context(), w); err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to render login template: "+err.Error()) return } } // HandleLogin handles login form submission -func (a *AuthHandlers) HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword string) gin.HandlerFunc { - return a.adminServer.HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword) +func (a *AuthHandlers) HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword string) http.HandlerFunc { + return a.adminServer.HandleLogin(a.sessionStore, adminUser, adminPassword, readOnlyUser, readOnlyPassword) } // HandleLogout handles user logout -func (a *AuthHandlers) HandleLogout(c *gin.Context) { - a.adminServer.HandleLogout(c) +func (a *AuthHandlers) HandleLogout(w http.ResponseWriter, r *http.Request) { + a.adminServer.HandleLogout(a.sessionStore, w, r) } diff --git a/weed/admin/handlers/cluster_handlers.go b/weed/admin/handlers/cluster_handlers.go index 9034ed688..c5303458f 100644 --- a/weed/admin/handlers/cluster_handlers.go +++ b/weed/admin/handlers/cluster_handlers.go @@ -5,7 +5,7 @@ import ( "net/http" "strconv" - "github.com/gin-gonic/gin" + "github.com/gorilla/mux" "github.com/seaweedfs/seaweedfs/weed/admin/dash" "github.com/seaweedfs/seaweedfs/weed/admin/view/app" "github.com/seaweedfs/seaweedfs/weed/admin/view/layout" @@ -24,402 +24,387 @@ func NewClusterHandlers(adminServer *dash.AdminServer) *ClusterHandlers { } // ShowClusterVolumeServers renders the cluster volume servers page -func (h *ClusterHandlers) ShowClusterVolumeServers(c *gin.Context) { +func (h *ClusterHandlers) ShowClusterVolumeServers(w http.ResponseWriter, r *http.Request) { // Get cluster volume servers data volumeServersData, err := h.adminServer.GetClusterVolumeServers() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster volume servers: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get cluster volume servers: "+err.Error()) return } - // Set username - username := c.GetString("username") - if username == "" { - username = "admin" - } + username := usernameOrDefault(r) volumeServersData.Username = username // Render HTML template - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") volumeServersComponent := app.ClusterVolumeServers(*volumeServersData) - layoutComponent := layout.Layout(c, volumeServersComponent) - err = layoutComponent.Render(c.Request.Context(), c.Writer) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, volumeServersComponent) + if err := layoutComponent.Render(r.Context(), w); err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) return } } // ShowClusterVolumes renders the cluster volumes page -func (h *ClusterHandlers) ShowClusterVolumes(c *gin.Context) { +func (h *ClusterHandlers) ShowClusterVolumes(w http.ResponseWriter, r *http.Request) { // Get pagination and sorting parameters from query string page := 1 - if p := c.Query("page"); p != "" { + if p := r.URL.Query().Get("page"); p != "" { if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 { page = parsed } } pageSize := 100 - if ps := c.Query("pageSize"); ps != "" { + if ps := r.URL.Query().Get("pageSize"); ps != "" { if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 1000 { pageSize = parsed } } - sortBy := c.DefaultQuery("sortBy", "id") - sortOrder := c.DefaultQuery("sortOrder", "asc") - collection := c.Query("collection") // Optional collection filter + sortBy := defaultQuery(r.URL.Query().Get("sortBy"), "id") + sortOrder := defaultQuery(r.URL.Query().Get("sortOrder"), "asc") + collection := r.URL.Query().Get("collection") // Optional collection filter // Get cluster volumes data volumesData, err := h.adminServer.GetClusterVolumes(page, pageSize, sortBy, sortOrder, collection) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster volumes: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get cluster volumes: "+err.Error()) return } - // Set username - username := c.GetString("username") - if username == "" { - username = "admin" - } + username := usernameOrDefault(r) volumesData.Username = username // Render HTML template - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") volumesComponent := app.ClusterVolumes(*volumesData) - layoutComponent := layout.Layout(c, volumesComponent) - err = layoutComponent.Render(c.Request.Context(), c.Writer) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, volumesComponent) + if err := layoutComponent.Render(r.Context(), w); err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) return } } // ShowVolumeDetails renders the volume details page -func (h *ClusterHandlers) ShowVolumeDetails(c *gin.Context) { - volumeIDStr := c.Param("id") - server := c.Param("server") +func (h *ClusterHandlers) ShowVolumeDetails(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + volumeIDStr := vars["id"] + server := vars["server"] if volumeIDStr == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Volume ID is required"}) + writeJSONError(w, http.StatusBadRequest, "Volume ID is required") return } if server == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Server is required"}) + writeJSONError(w, http.StatusBadRequest, "Server is required") return } volumeID, err := strconv.Atoi(volumeIDStr) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid volume ID"}) + writeJSONError(w, http.StatusBadRequest, "Invalid volume ID") return } // Get volume details volumeDetails, err := h.adminServer.GetVolumeDetails(volumeID, server) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get volume details: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get volume details: "+err.Error()) return } + username := usernameOrDefault(r) // Render HTML template - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") volumeDetailsComponent := app.VolumeDetails(*volumeDetails) - layoutComponent := layout.Layout(c, volumeDetailsComponent) - err = layoutComponent.Render(c.Request.Context(), c.Writer) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, volumeDetailsComponent) + if err := layoutComponent.Render(r.Context(), w); err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) return } } // ShowClusterCollections renders the cluster collections page -func (h *ClusterHandlers) ShowClusterCollections(c *gin.Context) { +func (h *ClusterHandlers) ShowClusterCollections(w http.ResponseWriter, r *http.Request) { // Get cluster collections data collectionsData, err := h.adminServer.GetClusterCollections() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster collections: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get cluster collections: "+err.Error()) return } - // Set username - username := c.GetString("username") - if username == "" { - username = "admin" - } + username := usernameOrDefault(r) collectionsData.Username = username // Render HTML template - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") collectionsComponent := app.ClusterCollections(*collectionsData) - layoutComponent := layout.Layout(c, collectionsComponent) - err = layoutComponent.Render(c.Request.Context(), c.Writer) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, collectionsComponent) + if err := layoutComponent.Render(r.Context(), w); err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) return } } // ShowCollectionDetails renders the collection detail page -func (h *ClusterHandlers) ShowCollectionDetails(c *gin.Context) { - collectionName := c.Param("name") +func (h *ClusterHandlers) ShowCollectionDetails(w http.ResponseWriter, r *http.Request) { + collectionName := mux.Vars(r)["name"] if collectionName == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Collection name is required"}) + writeJSONError(w, http.StatusBadRequest, "Collection name is required") return } // Parse query parameters - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "25")) - sortBy := c.DefaultQuery("sort_by", "volume_id") - sortOrder := c.DefaultQuery("sort_order", "asc") + query := r.URL.Query() + page, _ := strconv.Atoi(defaultQuery(query.Get("page"), "1")) + pageSize, _ := strconv.Atoi(defaultQuery(query.Get("page_size"), "25")) + sortBy := defaultQuery(query.Get("sort_by"), "volume_id") + sortOrder := defaultQuery(query.Get("sort_order"), "asc") // Get collection details data (volumes and EC volumes) collectionDetailsData, err := h.adminServer.GetCollectionDetails(collectionName, page, pageSize, sortBy, sortOrder) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get collection details: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get collection details: "+err.Error()) return } - // Set username - username := c.GetString("username") - if username == "" { - username = "admin" - } + username := usernameOrDefault(r) collectionDetailsData.Username = username // Render HTML template - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") collectionDetailsComponent := app.CollectionDetails(*collectionDetailsData) - layoutComponent := layout.Layout(c, collectionDetailsComponent) - err = layoutComponent.Render(c.Request.Context(), c.Writer) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, collectionDetailsComponent) + if err := layoutComponent.Render(r.Context(), w); err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) return } } // ShowClusterEcShards handles the cluster EC shards page (individual shards view) -func (h *ClusterHandlers) ShowClusterEcShards(c *gin.Context) { +func (h *ClusterHandlers) ShowClusterEcShards(w http.ResponseWriter, r *http.Request) { // Parse query parameters - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "100")) - sortBy := c.DefaultQuery("sort_by", "volume_id") - sortOrder := c.DefaultQuery("sort_order", "asc") - collection := c.DefaultQuery("collection", "") + query := r.URL.Query() + page, _ := strconv.Atoi(defaultQuery(query.Get("page"), "1")) + pageSize, _ := strconv.Atoi(defaultQuery(query.Get("page_size"), "100")) + sortBy := defaultQuery(query.Get("sort_by"), "volume_id") + sortOrder := defaultQuery(query.Get("sort_order"), "asc") + collection := defaultQuery(query.Get("collection"), "") // Get data from admin server data, err := h.adminServer.GetClusterEcVolumes(page, pageSize, sortBy, sortOrder, collection) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusInternalServerError, err.Error()) return } - // Set username - username := c.GetString("username") - if username == "" { - username = "admin" - } + username := usernameOrDefault(r) data.Username = username // Render template - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") ecVolumesComponent := app.ClusterEcVolumes(*data) - layoutComponent := layout.Layout(c, ecVolumesComponent) - err = layoutComponent.Render(c.Request.Context(), c.Writer) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, ecVolumesComponent) + if err := layoutComponent.Render(r.Context(), w); err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) return } } // ShowEcVolumeDetails renders the EC volume details page -func (h *ClusterHandlers) ShowEcVolumeDetails(c *gin.Context) { - volumeIDStr := c.Param("id") +func (h *ClusterHandlers) ShowEcVolumeDetails(w http.ResponseWriter, r *http.Request) { + volumeIDStr := mux.Vars(r)["id"] if volumeIDStr == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Volume ID is required"}) + writeJSONError(w, http.StatusBadRequest, "Volume ID is required") return } volumeID, err := strconv.Atoi(volumeIDStr) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid volume ID"}) + writeJSONError(w, http.StatusBadRequest, "Invalid volume ID") return } // Check that volumeID is within uint32 range if volumeID < 0 || uint64(volumeID) > math.MaxUint32 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Volume ID out of range"}) + writeJSONError(w, http.StatusBadRequest, "Volume ID out of range") return } // Parse sorting parameters - sortBy := c.DefaultQuery("sort_by", "shard_id") - sortOrder := c.DefaultQuery("sort_order", "asc") + query := r.URL.Query() + sortBy := defaultQuery(query.Get("sort_by"), "shard_id") + sortOrder := defaultQuery(query.Get("sort_order"), "asc") // Get EC volume details ecVolumeDetails, err := h.adminServer.GetEcVolumeDetails(uint32(volumeID), sortBy, sortOrder) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get EC volume details: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get EC volume details: "+err.Error()) return } - // Set username - username := c.GetString("username") - if username == "" { - username = "admin" - } + username := usernameOrDefault(r) ecVolumeDetails.Username = username // Render HTML template - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") ecVolumeDetailsComponent := app.EcVolumeDetails(*ecVolumeDetails) - layoutComponent := layout.Layout(c, ecVolumeDetailsComponent) - err = layoutComponent.Render(c.Request.Context(), c.Writer) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, ecVolumeDetailsComponent) + if err := layoutComponent.Render(r.Context(), w); err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) return } } // ShowClusterMasters renders the cluster masters page -func (h *ClusterHandlers) ShowClusterMasters(c *gin.Context) { +func (h *ClusterHandlers) ShowClusterMasters(w http.ResponseWriter, r *http.Request) { // Get cluster masters data mastersData, err := h.adminServer.GetClusterMasters() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster masters: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get cluster masters: "+err.Error()) return } - // Set username - username := c.GetString("username") - if username == "" { - username = "admin" - } + username := usernameOrDefault(r) mastersData.Username = username // Render HTML template - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") mastersComponent := app.ClusterMasters(*mastersData) - layoutComponent := layout.Layout(c, mastersComponent) - err = layoutComponent.Render(c.Request.Context(), c.Writer) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, mastersComponent) + if err := layoutComponent.Render(r.Context(), w); err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) return } } // ShowClusterFilers renders the cluster filers page -func (h *ClusterHandlers) ShowClusterFilers(c *gin.Context) { +func (h *ClusterHandlers) ShowClusterFilers(w http.ResponseWriter, r *http.Request) { // Get cluster filers data filersData, err := h.adminServer.GetClusterFilers() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster filers: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get cluster filers: "+err.Error()) return } - // Set username - username := c.GetString("username") - if username == "" { - username = "admin" - } + username := usernameOrDefault(r) filersData.Username = username // Render HTML template - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") filersComponent := app.ClusterFilers(*filersData) - layoutComponent := layout.Layout(c, filersComponent) - err = layoutComponent.Render(c.Request.Context(), c.Writer) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, filersComponent) + if err := layoutComponent.Render(r.Context(), w); err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) return } } // ShowClusterBrokers renders the cluster message brokers page -func (h *ClusterHandlers) ShowClusterBrokers(c *gin.Context) { +func (h *ClusterHandlers) ShowClusterBrokers(w http.ResponseWriter, r *http.Request) { // Get cluster brokers data brokersData, err := h.adminServer.GetClusterBrokers() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster brokers: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get cluster brokers: "+err.Error()) return } - // Set username - username := c.GetString("username") - if username == "" { - username = "admin" - } + username := usernameOrDefault(r) brokersData.Username = username // Render HTML template - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") brokersComponent := app.ClusterBrokers(*brokersData) - layoutComponent := layout.Layout(c, brokersComponent) - err = layoutComponent.Render(c.Request.Context(), c.Writer) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, brokersComponent) + if err := layoutComponent.Render(r.Context(), w); err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) return } } // GetClusterTopology returns the cluster topology as JSON -func (h *ClusterHandlers) GetClusterTopology(c *gin.Context) { +func (h *ClusterHandlers) GetClusterTopology(w http.ResponseWriter, r *http.Request) { topology, err := h.adminServer.GetClusterTopology() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusInternalServerError, err.Error()) return } - c.JSON(http.StatusOK, topology) + writeJSON(w, http.StatusOK, topology) } // GetMasters returns master node information -func (h *ClusterHandlers) GetMasters(c *gin.Context) { - // Simple master info - c.JSON(http.StatusOK, gin.H{"masters": []gin.H{{"address": "localhost:9333"}}}) +func (h *ClusterHandlers) GetMasters(w http.ResponseWriter, r *http.Request) { + mastersData, err := h.adminServer.GetClusterMasters() + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to get cluster masters: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, mastersData) } // GetVolumeServers returns volume server information -func (h *ClusterHandlers) GetVolumeServers(c *gin.Context) { +func (h *ClusterHandlers) GetVolumeServers(w http.ResponseWriter, r *http.Request) { topology, err := h.adminServer.GetClusterTopology() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusInternalServerError, err.Error()) return } - c.JSON(http.StatusOK, gin.H{"volume_servers": topology.VolumeServers}) + writeJSON(w, http.StatusOK, map[string]interface{}{"volume_servers": topology.VolumeServers}) } // VacuumVolume handles volume vacuum requests via API -func (h *ClusterHandlers) VacuumVolume(c *gin.Context) { - volumeIDStr := c.Param("id") - server := c.Param("server") +func (h *ClusterHandlers) VacuumVolume(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + volumeIDStr := vars["id"] + server := vars["server"] if volumeIDStr == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Volume ID is required"}) + writeJSONError(w, http.StatusBadRequest, "Volume ID is required") return } volumeID, err := strconv.Atoi(volumeIDStr) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid volume ID"}) + writeJSONError(w, http.StatusBadRequest, "Invalid volume ID") + return + } + + if server == "" { + writeJSONError(w, http.StatusBadRequest, "Server is required") return } // Perform vacuum operation err = h.adminServer.VacuumVolume(volumeID, server) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to vacuum volume: " + err.Error(), - }) + writeJSONError(w, http.StatusInternalServerError, "Failed to vacuum volume: "+err.Error()) return } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "message": "Volume vacuum started successfully", "volume_id": volumeID, "server": server, }) } + +func usernameOrDefault(r *http.Request) string { + username := dash.UsernameFromContext(r.Context()) + if username == "" { + return "admin" + } + return username +} diff --git a/weed/admin/handlers/file_browser_handlers.go b/weed/admin/handlers/file_browser_handlers.go index 9c050e034..a4b8475c0 100644 --- a/weed/admin/handlers/file_browser_handlers.go +++ b/weed/admin/handlers/file_browser_handlers.go @@ -16,7 +16,6 @@ import ( "strings" "time" - "github.com/gin-gonic/gin" "github.com/seaweedfs/seaweedfs/weed/admin/dash" "github.com/seaweedfs/seaweedfs/weed/admin/view/app" "github.com/seaweedfs/seaweedfs/weed/admin/view/layout" @@ -59,16 +58,16 @@ func (h *FileBrowserHandlers) newClientWithTimeout(timeout time.Duration) http.C } // ShowFileBrowser renders the file browser page -func (h *FileBrowserHandlers) ShowFileBrowser(c *gin.Context) { +func (h *FileBrowserHandlers) ShowFileBrowser(w http.ResponseWriter, r *http.Request) { // Get path from query parameter, default to root - path := c.DefaultQuery("path", "/") + path := defaultQuery(r.URL.Query().Get("path"), "/") // Normalize Windows-style paths for consistency path = util.CleanWindowsPath(path) // Get pagination parameters - lastFileName := c.DefaultQuery("lastFileName", "") + lastFileName := r.URL.Query().Get("lastFileName") - pageSize, err := strconv.Atoi(c.DefaultQuery("limit", "20")) + pageSize, err := strconv.Atoi(defaultQuery(r.URL.Query().Get("limit"), "20")) if err != nil || pageSize < 1 { pageSize = 20 } @@ -79,36 +78,42 @@ func (h *FileBrowserHandlers) ShowFileBrowser(c *gin.Context) { // Get file browser data with cursor-based pagination browserData, err := h.adminServer.GetFileBrowser(path, lastFileName, pageSize) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get file browser data: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get file browser data: "+err.Error()) return } // Set username - username := c.GetString("username") + username := dash.UsernameFromContext(r.Context()) if username == "" { username = "admin" } browserData.Username = username // Render HTML template - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") browserComponent := app.FileBrowser(*browserData) - layoutComponent := layout.Layout(c, browserComponent) - err = layoutComponent.Render(c.Request.Context(), c.Writer) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, browserComponent) + err = layoutComponent.Render(r.Context(), w) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) return } } // DeleteFile handles file deletion API requests -func (h *FileBrowserHandlers) DeleteFile(c *gin.Context) { +func (h *FileBrowserHandlers) DeleteFile(w http.ResponseWriter, r *http.Request) { var request struct { Path string `json:"path" binding:"required"` } - if err := c.ShouldBindJSON(&request); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &request); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + + if strings.TrimSpace(request.Path) == "" { + writeJSONError(w, http.StatusBadRequest, "path is required") return } @@ -124,29 +129,36 @@ func (h *FileBrowserHandlers) DeleteFile(c *gin.Context) { return err }) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete file: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to delete file: "+err.Error()) return } - c.JSON(http.StatusOK, gin.H{"message": "File deleted successfully"}) + writeJSON(w, http.StatusOK, map[string]interface{}{"message": "File deleted successfully"}) } // DeleteMultipleFiles handles multiple file deletion API requests -func (h *FileBrowserHandlers) DeleteMultipleFiles(c *gin.Context) { +func (h *FileBrowserHandlers) DeleteMultipleFiles(w http.ResponseWriter, r *http.Request) { var request struct { Paths []string `json:"paths" binding:"required"` } - if err := c.ShouldBindJSON(&request); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &request); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } if len(request.Paths) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "No paths provided"}) + writeJSONError(w, http.StatusBadRequest, "No paths provided") return } + for _, path := range request.Paths { + if strings.TrimSpace(path) == "" { + writeJSONError(w, http.StatusBadRequest, "path is required") + return + } + } + var deletedCount int var failedCount int var errors []string @@ -189,37 +201,40 @@ func (h *FileBrowserHandlers) DeleteMultipleFiles(c *gin.Context) { } else { response["message"] = fmt.Sprintf("Deleted %d item(s), failed to delete %d item(s)", deletedCount, failedCount) } - c.JSON(http.StatusOK, response) + writeJSON(w, http.StatusOK, response) } else { response["message"] = "Failed to delete all selected items" - c.JSON(http.StatusInternalServerError, response) + writeJSON(w, http.StatusInternalServerError, response) } } // CreateFolder handles folder creation requests -func (h *FileBrowserHandlers) CreateFolder(c *gin.Context) { +func (h *FileBrowserHandlers) CreateFolder(w http.ResponseWriter, r *http.Request) { var request struct { Path string `json:"path" binding:"required"` FolderName string `json:"folder_name" binding:"required"` } - if err := c.ShouldBindJSON(&request); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &request); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + + if strings.TrimSpace(request.Path) == "" { + writeJSONError(w, http.StatusBadRequest, "path is required") return } // Clean and validate folder name folderName := strings.TrimSpace(request.FolderName) if folderName == "" || strings.Contains(folderName, "/") || strings.Contains(folderName, "\\") { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid folder name"}) + writeJSONError(w, http.StatusBadRequest, "Invalid folder name") return } // Create full path for new folder - fullPath := filepath.Join(request.Path, folderName) - if !strings.HasPrefix(fullPath, "/") { - fullPath = "/" + fullPath - } + base := "/" + strings.TrimPrefix(request.Path, "/") + fullPath := path.Join(base, folderName) // Create folder via filer err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error { @@ -241,32 +256,32 @@ func (h *FileBrowserHandlers) CreateFolder(c *gin.Context) { return err }) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create folder: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to create folder: "+err.Error()) return } - c.JSON(http.StatusOK, gin.H{"message": "Folder created successfully"}) + writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Folder created successfully"}) } // UploadFile handles file upload requests -func (h *FileBrowserHandlers) UploadFile(c *gin.Context) { +func (h *FileBrowserHandlers) UploadFile(w http.ResponseWriter, r *http.Request) { // Get the current path - currentPath := c.PostForm("path") + currentPath := r.FormValue("path") if currentPath == "" { currentPath = "/" } // Parse multipart form - err := c.Request.ParseMultipartForm(1 << 30) // 1GB max memory for large file uploads + err := r.ParseMultipartForm(1 << 30) // 1GB max memory for large file uploads if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse multipart form: " + err.Error()}) + writeJSONError(w, http.StatusBadRequest, "Failed to parse multipart form: "+err.Error()) return } // Get uploaded files (supports multiple files) - files := c.Request.MultipartForm.File["files"] + files := r.MultipartForm.File["files"] if len(files) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "No files uploaded"}) + writeJSONError(w, http.StatusBadRequest, "No files uploaded") return } @@ -292,16 +307,8 @@ func (h *FileBrowserHandlers) UploadFile(c *gin.Context) { fullPath = "/" + fullPath } - // Open the file - file, err := fileHeader.Open() - if err != nil { - failedUploads = append(failedUploads, fmt.Sprintf("%s: %v", fileName, err)) - continue - } - // Upload file to filer err = h.uploadFileToFiler(fullPath, fileHeader) - file.Close() if err != nil { failedUploads = append(failedUploads, fmt.Sprintf("%s: %v", fileName, err)) @@ -331,10 +338,10 @@ func (h *FileBrowserHandlers) UploadFile(c *gin.Context) { } else { response["message"] = fmt.Sprintf("Uploaded %d file(s), %d failed", len(uploadResults), len(failedUploads)) } - c.JSON(http.StatusOK, response) + writeJSON(w, http.StatusOK, response) } else { response["message"] = "All file uploads failed" - c.JSON(http.StatusInternalServerError, response) + writeJSON(w, http.StatusInternalServerError, response) } } @@ -561,23 +568,23 @@ func (h *FileBrowserHandlers) fetchFileContent(filePath string, timeout time.Dur // DownloadFile handles file download requests by proxying through the Admin UI server // This ensures mTLS works correctly since the Admin UI server has the client certificates -func (h *FileBrowserHandlers) DownloadFile(c *gin.Context) { - filePath := c.Query("path") +func (h *FileBrowserHandlers) DownloadFile(w http.ResponseWriter, r *http.Request) { + filePath := r.URL.Query().Get("path") if filePath == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "File path is required"}) + writeJSONError(w, http.StatusBadRequest, "File path is required") return } // Get filer address filerAddress := h.adminServer.GetFilerAddress() if filerAddress == "" { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Filer address not configured"}) + writeJSONError(w, http.StatusInternalServerError, "Filer address not configured") return } // Validate filer address to prevent SSRF if err := h.validateFilerAddress(filerAddress); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid filer address configuration"}) + writeJSONError(w, http.StatusInternalServerError, "Invalid filer address configuration") return } filerHttpAddress := pb.ServerAddress(filerAddress).ToHttpAddress() @@ -585,7 +592,7 @@ func (h *FileBrowserHandlers) DownloadFile(c *gin.Context) { // Validate and sanitize the file path cleanFilePath, err := h.validateAndCleanFilePath(filePath) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file path: " + err.Error()}) + writeJSONError(w, http.StatusBadRequest, "Invalid file path: "+err.Error()) return } @@ -593,7 +600,7 @@ func (h *FileBrowserHandlers) DownloadFile(c *gin.Context) { downloadURL := fmt.Sprintf("%s%s", filerHttpAddress, cleanFilePath) downloadURL, err = h.httpClient.NormalizeHttpScheme(downloadURL) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to construct download URL: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to construct download URL: "+err.Error()) return } @@ -602,9 +609,9 @@ func (h *FileBrowserHandlers) DownloadFile(c *gin.Context) { // Safe: filerAddress validated by validateFilerAddress() to match configured filer // Safe: cleanFilePath validated and cleaned by validateAndCleanFilePath() to prevent path traversal // Use request context so download is cancelled when client disconnects - req, err := http.NewRequestWithContext(c.Request.Context(), "GET", downloadURL, nil) + req, err := http.NewRequestWithContext(r.Context(), "GET", downloadURL, nil) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to create request: "+err.Error()) return } client := h.newClientWithTimeout(5 * time.Minute) // Longer timeout for large file downloads @@ -613,7 +620,7 @@ func (h *FileBrowserHandlers) DownloadFile(c *gin.Context) { resp, err := client.Do(req) if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch file from filer: " + err.Error()}) + writeJSONError(w, http.StatusBadGateway, "Failed to fetch file from filer: "+err.Error()) return } defer resp.Body.Close() @@ -621,10 +628,10 @@ func (h *FileBrowserHandlers) DownloadFile(c *gin.Context) { if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - c.JSON(resp.StatusCode, gin.H{"error": fmt.Sprintf("Filer returned status %d but failed to read response body: %v", resp.StatusCode, err)}) + writeJSONError(w, resp.StatusCode, fmt.Sprintf("Filer returned status %d but failed to read response body: %v", resp.StatusCode, err)) return } - c.JSON(resp.StatusCode, gin.H{"error": fmt.Sprintf("Filer returned status %d: %s", resp.StatusCode, string(body))}) + writeJSONError(w, resp.StatusCode, fmt.Sprintf("Filer returned status %d: %s", resp.StatusCode, string(body))) return } @@ -632,33 +639,33 @@ func (h *FileBrowserHandlers) DownloadFile(c *gin.Context) { fileName := filepath.Base(cleanFilePath) // Use mime.FormatMediaType for RFC 6266 compliant Content-Disposition, // properly handling non-ASCII characters and special characters - c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": fileName})) + w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": fileName})) // Use content type from filer response, or default to octet-stream contentType := resp.Header.Get("Content-Type") if contentType == "" { contentType = "application/octet-stream" } - c.Header("Content-Type", contentType) + w.Header().Set("Content-Type", contentType) // Set content length if available if resp.ContentLength > 0 { - c.Header("Content-Length", fmt.Sprintf("%d", resp.ContentLength)) + w.Header().Set("Content-Length", fmt.Sprintf("%d", resp.ContentLength)) } // Stream the response body to the client - c.Status(http.StatusOK) - _, err = io.Copy(c.Writer, resp.Body) + w.WriteHeader(http.StatusOK) + _, err = io.Copy(w, resp.Body) if err != nil { glog.Errorf("Error streaming file download: %v", err) } } // ViewFile handles file viewing requests (for text files, images, etc.) -func (h *FileBrowserHandlers) ViewFile(c *gin.Context) { - filePath := c.Query("path") +func (h *FileBrowserHandlers) ViewFile(w http.ResponseWriter, r *http.Request) { + filePath := r.URL.Query().Get("path") if filePath == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "File path is required"}) + writeJSONError(w, http.StatusBadRequest, "File path is required") return } @@ -704,7 +711,7 @@ func (h *FileBrowserHandlers) ViewFile(c *gin.Context) { return nil }) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get file metadata: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get file metadata: "+err.Error()) return } @@ -752,7 +759,7 @@ func (h *FileBrowserHandlers) ViewFile(c *gin.Context) { } } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "file": fileEntry, "content": content, "viewable": viewable, @@ -761,10 +768,10 @@ func (h *FileBrowserHandlers) ViewFile(c *gin.Context) { } // GetFileProperties handles file properties requests -func (h *FileBrowserHandlers) GetFileProperties(c *gin.Context) { - filePath := c.Query("path") +func (h *FileBrowserHandlers) GetFileProperties(w http.ResponseWriter, r *http.Request) { + filePath := r.URL.Query().Get("path") if filePath == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "File path is required"}) + writeJSONError(w, http.StatusBadRequest, "File path is required") return } @@ -853,11 +860,11 @@ func (h *FileBrowserHandlers) GetFileProperties(c *gin.Context) { return nil }) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get file properties: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get file properties: "+err.Error()) return } - c.JSON(http.StatusOK, properties) + writeJSON(w, http.StatusOK, properties) } // Helper function to format bytes diff --git a/weed/admin/handlers/http_helpers.go b/weed/admin/handlers/http_helpers.go new file mode 100644 index 000000000..59ffab481 --- /dev/null +++ b/weed/admin/handlers/http_helpers.go @@ -0,0 +1,28 @@ +package handlers + +import ( + "io" + "net/http" + + "github.com/seaweedfs/seaweedfs/weed/admin/internal/httputil" +) + +func writeJSON(w http.ResponseWriter, status int, payload interface{}) { + httputil.WriteJSON(w, status, payload) +} + +func writeJSONError(w http.ResponseWriter, status int, message string) { + httputil.WriteJSONError(w, status, message) +} + +func decodeJSONBody(r io.Reader, v interface{}) error { + return httputil.DecodeJSONBody(r, v) +} + +func newJSONMaxReader(w http.ResponseWriter, r *http.Request) io.Reader { + return httputil.NewJSONMaxReader(w, r) +} + +func defaultQuery(value, fallback string) string { + return httputil.DefaultQuery(value, fallback) +} diff --git a/weed/admin/handlers/mq_handlers.go b/weed/admin/handlers/mq_handlers.go index 8508998e6..5efa3cc3a 100644 --- a/weed/admin/handlers/mq_handlers.go +++ b/weed/admin/handlers/mq_handlers.go @@ -4,7 +4,7 @@ import ( "fmt" "net/http" - "github.com/gin-gonic/gin" + "github.com/gorilla/mux" "github.com/seaweedfs/seaweedfs/weed/admin/dash" "github.com/seaweedfs/seaweedfs/weed/admin/view/app" "github.com/seaweedfs/seaweedfs/weed/admin/view/layout" @@ -23,146 +23,152 @@ func NewMessageQueueHandlers(adminServer *dash.AdminServer) *MessageQueueHandler } // ShowBrokers renders the message queue brokers page -func (h *MessageQueueHandlers) ShowBrokers(c *gin.Context) { +func (h *MessageQueueHandlers) ShowBrokers(w http.ResponseWriter, r *http.Request) { // Get cluster brokers data brokersData, err := h.adminServer.GetClusterBrokers() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster brokers: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get cluster brokers: "+err.Error()) return } // Set username - username := c.GetString("username") + username := dash.UsernameFromContext(r.Context()) if username == "" { username = "admin" } brokersData.Username = username // Render HTML template - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") brokersComponent := app.ClusterBrokers(*brokersData) - layoutComponent := layout.Layout(c, brokersComponent) - err = layoutComponent.Render(c.Request.Context(), c.Writer) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, brokersComponent) + err = layoutComponent.Render(r.Context(), w) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) return } } // ShowTopics renders the message queue topics page -func (h *MessageQueueHandlers) ShowTopics(c *gin.Context) { +func (h *MessageQueueHandlers) ShowTopics(w http.ResponseWriter, r *http.Request) { // Get topics data topicsData, err := h.adminServer.GetTopics() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get topics: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get topics: "+err.Error()) return } // Set username - username := c.GetString("username") + username := dash.UsernameFromContext(r.Context()) if username == "" { username = "admin" } topicsData.Username = username // Render HTML template - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") topicsComponent := app.Topics(*topicsData) - layoutComponent := layout.Layout(c, topicsComponent) - err = layoutComponent.Render(c.Request.Context(), c.Writer) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, topicsComponent) + err = layoutComponent.Render(r.Context(), w) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) return } } // ShowSubscribers renders the message queue subscribers page -func (h *MessageQueueHandlers) ShowSubscribers(c *gin.Context) { +func (h *MessageQueueHandlers) ShowSubscribers(w http.ResponseWriter, r *http.Request) { // Get subscribers data subscribersData, err := h.adminServer.GetSubscribers() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscribers: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get subscribers: "+err.Error()) return } // Set username - username := c.GetString("username") + username := dash.UsernameFromContext(r.Context()) if username == "" { username = "admin" } subscribersData.Username = username // Render HTML template - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") subscribersComponent := app.Subscribers(*subscribersData) - layoutComponent := layout.Layout(c, subscribersComponent) - err = layoutComponent.Render(c.Request.Context(), c.Writer) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, subscribersComponent) + err = layoutComponent.Render(r.Context(), w) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) return } } // ShowTopicDetails renders the topic details page -func (h *MessageQueueHandlers) ShowTopicDetails(c *gin.Context) { +func (h *MessageQueueHandlers) ShowTopicDetails(w http.ResponseWriter, r *http.Request) { // Get topic parameters from URL - namespace := c.Param("namespace") - topicName := c.Param("topic") + vars := mux.Vars(r) + namespace := vars["namespace"] + topicName := vars["topic"] if namespace == "" || topicName == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Missing namespace or topic name"}) + writeJSONError(w, http.StatusBadRequest, "Missing namespace or topic name") return } // Get topic details data topicDetailsData, err := h.adminServer.GetTopicDetails(namespace, topicName) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get topic details: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get topic details: "+err.Error()) return } // Set username - username := c.GetString("username") + username := dash.UsernameFromContext(r.Context()) if username == "" { username = "admin" } topicDetailsData.Username = username // Render HTML template - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") topicDetailsComponent := app.TopicDetails(*topicDetailsData) - layoutComponent := layout.Layout(c, topicDetailsComponent) - err = layoutComponent.Render(c.Request.Context(), c.Writer) + viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, topicDetailsComponent) + err = layoutComponent.Render(r.Context(), w) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) return } } // GetTopicDetailsAPI returns topic details as JSON for AJAX calls -func (h *MessageQueueHandlers) GetTopicDetailsAPI(c *gin.Context) { +func (h *MessageQueueHandlers) GetTopicDetailsAPI(w http.ResponseWriter, r *http.Request) { // Get topic parameters from URL - namespace := c.Param("namespace") - topicName := c.Param("topic") + vars := mux.Vars(r) + namespace := vars["namespace"] + topicName := vars["topic"] if namespace == "" || topicName == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Missing namespace or topic name"}) + writeJSONError(w, http.StatusBadRequest, "Missing namespace or topic name") return } // Get topic details data topicDetailsData, err := h.adminServer.GetTopicDetails(namespace, topicName) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get topic details: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get topic details: "+err.Error()) return } // Return JSON data - c.JSON(http.StatusOK, topicDetailsData) + writeJSON(w, http.StatusOK, topicDetailsData) } // CreateTopicAPI creates a new topic with retention configuration -func (h *MessageQueueHandlers) CreateTopicAPI(c *gin.Context) { +func (h *MessageQueueHandlers) CreateTopicAPI(w http.ResponseWriter, r *http.Request) { var req struct { Namespace string `json:"namespace" binding:"required"` Name string `json:"name" binding:"required"` @@ -173,30 +179,30 @@ func (h *MessageQueueHandlers) CreateTopicAPI(c *gin.Context) { } `json:"retention"` } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } // Validate inputs if req.PartitionCount < 1 || req.PartitionCount > 100 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Partition count must be between 1 and 100"}) + writeJSONError(w, http.StatusBadRequest, "Partition count must be between 1 and 100") return } if req.Retention.Enabled && req.Retention.RetentionSeconds <= 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Retention seconds must be positive when retention is enabled"}) + writeJSONError(w, http.StatusBadRequest, "Retention seconds must be positive when retention is enabled") return } // Create the topic via admin server err := h.adminServer.CreateTopicWithRetention(req.Namespace, req.Name, req.PartitionCount, req.Retention.Enabled, req.Retention.RetentionSeconds) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create topic: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to create topic: "+err.Error()) return } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "message": "Topic created successfully", "topic": fmt.Sprintf("%s.%s", req.Namespace, req.Name), }) @@ -211,27 +217,27 @@ type UpdateTopicRetentionRequest struct { } `json:"retention"` } -func (h *MessageQueueHandlers) UpdateTopicRetentionAPI(c *gin.Context) { +func (h *MessageQueueHandlers) UpdateTopicRetentionAPI(w http.ResponseWriter, r *http.Request) { var request UpdateTopicRetentionRequest - if err := c.ShouldBindJSON(&request); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &request); err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) return } // Validate required fields if request.Namespace == "" || request.Name == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "namespace and name are required"}) + writeJSONError(w, http.StatusBadRequest, "namespace and name are required") return } // Update the topic retention err := h.adminServer.UpdateTopicRetention(request.Namespace, request.Name, request.Retention.Enabled, request.Retention.RetentionSeconds) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + writeJSONError(w, http.StatusInternalServerError, err.Error()) return } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "message": "Topic retention updated successfully", "topic": request.Namespace + "." + request.Name, }) diff --git a/weed/admin/handlers/plugin_handlers.go b/weed/admin/handlers/plugin_handlers.go index d43a0ec2c..cb3782d93 100644 --- a/weed/admin/handlers/plugin_handlers.go +++ b/weed/admin/handlers/plugin_handlers.go @@ -4,7 +4,6 @@ import ( "bytes" "net/http" - "github.com/gin-gonic/gin" "github.com/seaweedfs/seaweedfs/weed/admin/dash" "github.com/seaweedfs/seaweedfs/weed/admin/view/app" "github.com/seaweedfs/seaweedfs/weed/admin/view/layout" @@ -23,45 +22,48 @@ func NewPluginHandlers(adminServer *dash.AdminServer) *PluginHandlers { } // ShowPlugin displays plugin overview page. -func (h *PluginHandlers) ShowPlugin(c *gin.Context) { - h.renderPluginPage(c, "overview") +func (h *PluginHandlers) ShowPlugin(w http.ResponseWriter, r *http.Request) { + h.renderPluginPage(w, r, "overview") } // ShowPluginConfiguration displays plugin configuration page. -func (h *PluginHandlers) ShowPluginConfiguration(c *gin.Context) { - h.renderPluginPage(c, "configuration") +func (h *PluginHandlers) ShowPluginConfiguration(w http.ResponseWriter, r *http.Request) { + h.renderPluginPage(w, r, "configuration") } // ShowPluginDetection displays plugin detection jobs page. -func (h *PluginHandlers) ShowPluginDetection(c *gin.Context) { - h.renderPluginPage(c, "detection") +func (h *PluginHandlers) ShowPluginDetection(w http.ResponseWriter, r *http.Request) { + h.renderPluginPage(w, r, "detection") } // ShowPluginQueue displays plugin job queue page. -func (h *PluginHandlers) ShowPluginQueue(c *gin.Context) { - h.renderPluginPage(c, "queue") +func (h *PluginHandlers) ShowPluginQueue(w http.ResponseWriter, r *http.Request) { + h.renderPluginPage(w, r, "queue") } // ShowPluginExecution displays plugin execution jobs page. -func (h *PluginHandlers) ShowPluginExecution(c *gin.Context) { - h.renderPluginPage(c, "execution") +func (h *PluginHandlers) ShowPluginExecution(w http.ResponseWriter, r *http.Request) { + h.renderPluginPage(w, r, "execution") } // ShowPluginMonitoring displays plugin monitoring page. -func (h *PluginHandlers) ShowPluginMonitoring(c *gin.Context) { +func (h *PluginHandlers) ShowPluginMonitoring(w http.ResponseWriter, r *http.Request) { // Backward-compatible alias for the old monitoring URL. - h.renderPluginPage(c, "detection") + h.renderPluginPage(w, r, "detection") } -func (h *PluginHandlers) renderPluginPage(c *gin.Context, page string) { +func (h *PluginHandlers) renderPluginPage(w http.ResponseWriter, r *http.Request, page string) { component := app.Plugin(page) - layoutComponent := layout.Layout(c, component) + viewCtx := layout.NewViewContext(r, dash.UsernameFromContext(r.Context()), dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, component) var buf bytes.Buffer - if err := layoutComponent.Render(c.Request.Context(), &buf); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + if err := layoutComponent.Render(r.Context(), &buf); err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) return } - c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes()) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(buf.Bytes()) } diff --git a/weed/admin/handlers/policy_handlers.go b/weed/admin/handlers/policy_handlers.go index c9850b219..81868213f 100644 --- a/weed/admin/handlers/policy_handlers.go +++ b/weed/admin/handlers/policy_handlers.go @@ -5,7 +5,7 @@ import ( "net/http" "time" - "github.com/gin-gonic/gin" + "github.com/gorilla/mux" "github.com/seaweedfs/seaweedfs/weed/admin/dash" "github.com/seaweedfs/seaweedfs/weed/admin/view/app" "github.com/seaweedfs/seaweedfs/weed/admin/view/layout" @@ -26,53 +26,53 @@ func NewPolicyHandlers(adminServer *dash.AdminServer) *PolicyHandlers { } // ShowPolicies renders the policies management page -func (h *PolicyHandlers) ShowPolicies(c *gin.Context) { +func (h *PolicyHandlers) ShowPolicies(w http.ResponseWriter, r *http.Request) { // Get policies data from the server - policiesData := h.getPoliciesData(c) + policiesData := h.getPoliciesData(r) // Render HTML template - c.Header("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html") policiesComponent := app.Policies(policiesData) - layoutComponent := layout.Layout(c, policiesComponent) - err := layoutComponent.Render(c.Request.Context(), c.Writer) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + viewCtx := layout.NewViewContext(r, dash.UsernameFromContext(r.Context()), dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, policiesComponent) + if err := layoutComponent.Render(r.Context(), w); err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) return } } // GetPolicies returns the list of policies as JSON -func (h *PolicyHandlers) GetPolicies(c *gin.Context) { +func (h *PolicyHandlers) GetPolicies(w http.ResponseWriter, r *http.Request) { policies, err := h.adminServer.GetPolicies() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get policies: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get policies: "+err.Error()) return } - c.JSON(http.StatusOK, gin.H{"policies": policies}) + writeJSON(w, http.StatusOK, map[string]interface{}{"policies": policies}) } // CreatePolicy handles policy creation -func (h *PolicyHandlers) CreatePolicy(c *gin.Context) { +func (h *PolicyHandlers) CreatePolicy(w http.ResponseWriter, r *http.Request) { var req dash.CreatePolicyRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } // Validate policy name if req.Name == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"}) + writeJSONError(w, http.StatusBadRequest, "Policy name is required") return } // Check if policy already exists existingPolicy, err := h.adminServer.GetPolicy(req.Name) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing policy: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to check existing policy: "+err.Error()) return } if existingPolicy != nil { - c.JSON(http.StatusConflict, gin.H{"error": "Policy with this name already exists"}) + writeJSONError(w, http.StatusConflict, "Policy with this name already exists") return } @@ -80,11 +80,11 @@ func (h *PolicyHandlers) CreatePolicy(c *gin.Context) { err = h.adminServer.CreatePolicy(req.Name, req.Document) if err != nil { glog.Errorf("Failed to create policy %s: %v", req.Name, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create policy: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to create policy: "+err.Error()) return } - c.JSON(http.StatusCreated, gin.H{ + writeJSON(w, http.StatusCreated, map[string]interface{}{ "success": true, "message": "Policy created successfully", "policy": req.Name, @@ -92,49 +92,49 @@ func (h *PolicyHandlers) CreatePolicy(c *gin.Context) { } // GetPolicy returns a specific policy -func (h *PolicyHandlers) GetPolicy(c *gin.Context) { - policyName := c.Param("name") +func (h *PolicyHandlers) GetPolicy(w http.ResponseWriter, r *http.Request) { + policyName := mux.Vars(r)["name"] if policyName == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"}) + writeJSONError(w, http.StatusBadRequest, "Policy name is required") return } policy, err := h.adminServer.GetPolicy(policyName) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get policy: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get policy: "+err.Error()) return } if policy == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"}) + writeJSONError(w, http.StatusNotFound, "Policy not found") return } - c.JSON(http.StatusOK, policy) + writeJSON(w, http.StatusOK, policy) } // UpdatePolicy handles policy updates -func (h *PolicyHandlers) UpdatePolicy(c *gin.Context) { - policyName := c.Param("name") +func (h *PolicyHandlers) UpdatePolicy(w http.ResponseWriter, r *http.Request) { + policyName := mux.Vars(r)["name"] if policyName == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"}) + writeJSONError(w, http.StatusBadRequest, "Policy name is required") return } var req dash.UpdatePolicyRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } // Check if policy exists existingPolicy, err := h.adminServer.GetPolicy(policyName) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing policy: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to check existing policy: "+err.Error()) return } if existingPolicy == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"}) + writeJSONError(w, http.StatusNotFound, "Policy not found") return } @@ -142,11 +142,11 @@ func (h *PolicyHandlers) UpdatePolicy(c *gin.Context) { err = h.adminServer.UpdatePolicy(policyName, req.Document) if err != nil { glog.Errorf("Failed to update policy %s: %v", policyName, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update policy: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to update policy: "+err.Error()) return } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "success": true, "message": "Policy updated successfully", "policy": policyName, @@ -154,21 +154,21 @@ func (h *PolicyHandlers) UpdatePolicy(c *gin.Context) { } // DeletePolicy handles policy deletion -func (h *PolicyHandlers) DeletePolicy(c *gin.Context) { - policyName := c.Param("name") +func (h *PolicyHandlers) DeletePolicy(w http.ResponseWriter, r *http.Request) { + policyName := mux.Vars(r)["name"] if policyName == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"}) + writeJSONError(w, http.StatusBadRequest, "Policy name is required") return } // Check if policy exists existingPolicy, err := h.adminServer.GetPolicy(policyName) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing policy: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to check existing policy: "+err.Error()) return } if existingPolicy == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"}) + writeJSONError(w, http.StatusNotFound, "Policy not found") return } @@ -176,11 +176,11 @@ func (h *PolicyHandlers) DeletePolicy(c *gin.Context) { err = h.adminServer.DeletePolicy(policyName) if err != nil { glog.Errorf("Failed to delete policy %s: %v", policyName, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete policy: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to delete policy: "+err.Error()) return } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "success": true, "message": "Policy deleted successfully", "policy": policyName, @@ -188,60 +188,54 @@ func (h *PolicyHandlers) DeletePolicy(c *gin.Context) { } // ValidatePolicy validates a policy document without saving it -func (h *PolicyHandlers) ValidatePolicy(c *gin.Context) { +func (h *PolicyHandlers) ValidatePolicy(w http.ResponseWriter, r *http.Request) { var req struct { - Document policy_engine.PolicyDocument `json:"document" binding:"required"` + Document policy_engine.PolicyDocument `json:"document"` } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } // Basic validation if req.Document.Version == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Policy version is required"}) + writeJSONError(w, http.StatusBadRequest, "Policy version is required") return } if len(req.Document.Statement) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Policy must have at least one statement"}) + writeJSONError(w, http.StatusBadRequest, "Policy must have at least one statement") return } // Validate each statement for i, statement := range req.Document.Statement { if statement.Effect != "Allow" && statement.Effect != "Deny" { - c.JSON(http.StatusBadRequest, gin.H{ - "error": fmt.Sprintf("Statement %d: Effect must be 'Allow' or 'Deny'", i+1), - }) + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Statement %d: Effect must be 'Allow' or 'Deny'", i+1)) return } if len(statement.Action.Strings()) == 0 { - c.JSON(http.StatusBadRequest, gin.H{ - "error": fmt.Sprintf("Statement %d: Action is required", i+1), - }) + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Statement %d: Action is required", i+1)) return } if len(statement.Resource.Strings()) == 0 { - c.JSON(http.StatusBadRequest, gin.H{ - "error": fmt.Sprintf("Statement %d: Resource is required", i+1), - }) + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Statement %d: Resource is required", i+1)) return } } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "valid": true, "message": "Policy document is valid", }) } // getPoliciesData retrieves policies data from the server -func (h *PolicyHandlers) getPoliciesData(c *gin.Context) dash.PoliciesData { - username := c.GetString("username") +func (h *PolicyHandlers) getPoliciesData(r *http.Request) dash.PoliciesData { + username := dash.UsernameFromContext(r.Context()) if username == "" { username = "admin" } diff --git a/weed/admin/handlers/service_account_handlers.go b/weed/admin/handlers/service_account_handlers.go index 9c8c85f9a..6e572a88b 100644 --- a/weed/admin/handlers/service_account_handlers.go +++ b/weed/admin/handlers/service_account_handlers.go @@ -6,7 +6,7 @@ import ( "net/http" "time" - "github.com/gin-gonic/gin" + "github.com/gorilla/mux" "github.com/seaweedfs/seaweedfs/weed/admin/dash" "github.com/seaweedfs/seaweedfs/weed/admin/view/app" "github.com/seaweedfs/seaweedfs/weed/admin/view/layout" @@ -26,153 +26,154 @@ func NewServiceAccountHandlers(adminServer *dash.AdminServer) *ServiceAccountHan } // ShowServiceAccounts renders the service accounts management page -func (h *ServiceAccountHandlers) ShowServiceAccounts(c *gin.Context) { - data := h.getServiceAccountsData(c) +func (h *ServiceAccountHandlers) ShowServiceAccounts(w http.ResponseWriter, r *http.Request) { + data := h.getServiceAccountsData(r) // Render to buffer first to avoid partial writes on error var buf bytes.Buffer component := app.ServiceAccounts(data) - layoutComponent := layout.Layout(c, component) - err := layoutComponent.Render(c.Request.Context(), &buf) + viewCtx := layout.NewViewContext(r, dash.UsernameFromContext(r.Context()), dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, component) + err := layoutComponent.Render(r.Context(), &buf) if err != nil { glog.Errorf("Failed to render service accounts template: %v", err) - c.AbortWithStatus(http.StatusInternalServerError) + w.WriteHeader(http.StatusInternalServerError) return } // Only write to response if render succeeded - c.Header("Content-Type", "text/html") - c.Writer.Write(buf.Bytes()) + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write(buf.Bytes()) } // GetServiceAccounts returns the list of service accounts as JSON -func (h *ServiceAccountHandlers) GetServiceAccounts(c *gin.Context) { - parentUser := c.Query("parent_user") +func (h *ServiceAccountHandlers) GetServiceAccounts(w http.ResponseWriter, r *http.Request) { + parentUser := r.URL.Query().Get("parent_user") - accounts, err := h.adminServer.GetServiceAccounts(c.Request.Context(), parentUser) + accounts, err := h.adminServer.GetServiceAccounts(r.Context(), parentUser) if err != nil { glog.Errorf("Failed to get service accounts: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get service accounts"}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get service accounts") return } - c.JSON(http.StatusOK, gin.H{"service_accounts": accounts}) + writeJSON(w, http.StatusOK, map[string]interface{}{"service_accounts": accounts}) } // CreateServiceAccount handles service account creation -func (h *ServiceAccountHandlers) CreateServiceAccount(c *gin.Context) { +func (h *ServiceAccountHandlers) CreateServiceAccount(w http.ResponseWriter, r *http.Request) { var req dash.CreateServiceAccountRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } if req.ParentUser == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "ParentUser is required"}) + writeJSONError(w, http.StatusBadRequest, "ParentUser is required") return } - sa, err := h.adminServer.CreateServiceAccount(c.Request.Context(), req) + sa, err := h.adminServer.CreateServiceAccount(r.Context(), req) if err != nil { glog.Errorf("Failed to create service account for user %s: %v", req.ParentUser, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service account"}) + writeJSONError(w, http.StatusInternalServerError, "Failed to create service account") return } - c.JSON(http.StatusCreated, gin.H{ + writeJSON(w, http.StatusCreated, map[string]interface{}{ "message": "Service account created successfully", "service_account": sa, }) } // GetServiceAccountDetails returns detailed information about a service account -func (h *ServiceAccountHandlers) GetServiceAccountDetails(c *gin.Context) { - id := c.Param("id") +func (h *ServiceAccountHandlers) GetServiceAccountDetails(w http.ResponseWriter, r *http.Request) { + id := mux.Vars(r)["id"] if id == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Service account ID is required"}) + writeJSONError(w, http.StatusBadRequest, "Service account ID is required") return } - sa, err := h.adminServer.GetServiceAccountDetails(c.Request.Context(), id) + sa, err := h.adminServer.GetServiceAccountDetails(r.Context(), id) if err != nil { // Distinguish not-found errors from internal errors if errors.Is(err, dash.ErrServiceAccountNotFound) { - c.JSON(http.StatusNotFound, gin.H{"error": "Service account not found: " + err.Error()}) + writeJSONError(w, http.StatusNotFound, "Service account not found: "+err.Error()) } else { glog.Errorf("Failed to get service account details for %s: %v", id, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get service account details"}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get service account details") } return } - c.JSON(http.StatusOK, sa) + writeJSON(w, http.StatusOK, sa) } // UpdateServiceAccount handles service account updates -func (h *ServiceAccountHandlers) UpdateServiceAccount(c *gin.Context) { - id := c.Param("id") +func (h *ServiceAccountHandlers) UpdateServiceAccount(w http.ResponseWriter, r *http.Request) { + id := mux.Vars(r)["id"] if id == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Service account ID is required"}) + writeJSONError(w, http.StatusBadRequest, "Service account ID is required") return } var req dash.UpdateServiceAccountRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } - sa, err := h.adminServer.UpdateServiceAccount(c.Request.Context(), id, req) + sa, err := h.adminServer.UpdateServiceAccount(r.Context(), id, req) if err != nil { // Distinguish not-found errors from internal errors if errors.Is(err, dash.ErrServiceAccountNotFound) { - c.JSON(http.StatusNotFound, gin.H{"error": "Service account not found"}) + writeJSONError(w, http.StatusNotFound, "Service account not found") } else { glog.Errorf("Failed to update service account %s: %v", id, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service account"}) + writeJSONError(w, http.StatusInternalServerError, "Failed to update service account") } return } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "message": "Service account updated successfully", "service_account": sa, }) } // DeleteServiceAccount handles service account deletion -func (h *ServiceAccountHandlers) DeleteServiceAccount(c *gin.Context) { - id := c.Param("id") +func (h *ServiceAccountHandlers) DeleteServiceAccount(w http.ResponseWriter, r *http.Request) { + id := mux.Vars(r)["id"] if id == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Service account ID is required"}) + writeJSONError(w, http.StatusBadRequest, "Service account ID is required") return } - err := h.adminServer.DeleteServiceAccount(c.Request.Context(), id) + err := h.adminServer.DeleteServiceAccount(r.Context(), id) if err != nil { // Distinguish not-found errors from internal errors if errors.Is(err, dash.ErrServiceAccountNotFound) { - c.JSON(http.StatusNotFound, gin.H{"error": "Service account not found"}) + writeJSONError(w, http.StatusNotFound, "Service account not found") } else { glog.Errorf("Failed to delete service account %s: %v", id, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete service account"}) + writeJSONError(w, http.StatusInternalServerError, "Failed to delete service account") } return } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "message": "Service account deleted successfully", }) } // getServiceAccountsData retrieves service accounts data for the template -func (h *ServiceAccountHandlers) getServiceAccountsData(c *gin.Context) dash.ServiceAccountsData { - username := c.GetString("username") +func (h *ServiceAccountHandlers) getServiceAccountsData(r *http.Request) dash.ServiceAccountsData { + username := dash.UsernameFromContext(r.Context()) if username == "" { username = "admin" } // Get all service accounts - accounts, err := h.adminServer.GetServiceAccounts(c.Request.Context(), "") + accounts, err := h.adminServer.GetServiceAccounts(r.Context(), "") if err != nil { glog.Errorf("Failed to get service accounts: %v", err) return dash.ServiceAccountsData{ @@ -193,7 +194,7 @@ func (h *ServiceAccountHandlers) getServiceAccountsData(c *gin.Context) dash.Ser // Get available users for dropdown var availableUsers []string - users, err := h.adminServer.GetObjectStoreUsers(c.Request.Context()) + users, err := h.adminServer.GetObjectStoreUsers(r.Context()) if err != nil { glog.Errorf("Failed to get users for dropdown: %v", err) } else { diff --git a/weed/admin/handlers/user_handlers.go b/weed/admin/handlers/user_handlers.go index 827e21dc1..fa08a71fc 100644 --- a/weed/admin/handlers/user_handlers.go +++ b/weed/admin/handlers/user_handlers.go @@ -5,7 +5,7 @@ import ( "net/http" "time" - "github.com/gin-gonic/gin" + "github.com/gorilla/mux" "github.com/seaweedfs/seaweedfs/weed/admin/dash" "github.com/seaweedfs/seaweedfs/weed/admin/view/app" "github.com/seaweedfs/seaweedfs/weed/admin/view/layout" @@ -25,256 +25,259 @@ func NewUserHandlers(adminServer *dash.AdminServer) *UserHandlers { } // ShowObjectStoreUsers renders the object store users management page -func (h *UserHandlers) ShowObjectStoreUsers(c *gin.Context) { +func (h *UserHandlers) ShowObjectStoreUsers(w http.ResponseWriter, r *http.Request) { // Get object store users data from the server - usersData := h.getObjectStoreUsersData(c) + usersData := h.getObjectStoreUsersData(r) // Render HTML template // Add cache-control headers to prevent browser caching of inline JavaScript - c.Header("Content-Type", "text/html") - c.Header("Cache-Control", "no-cache, no-store, must-revalidate") - c.Header("Pragma", "no-cache") - c.Header("Expires", "0") - c.Header("ETag", fmt.Sprintf("\"%d\"", time.Now().Unix())) + w.Header().Set("Content-Type", "text/html") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + w.Header().Set("ETag", fmt.Sprintf("\"%d\"", time.Now().Unix())) usersComponent := app.ObjectStoreUsers(usersData) - layoutComponent := layout.Layout(c, usersComponent) - err := layoutComponent.Render(c.Request.Context(), c.Writer) + viewCtx := layout.NewViewContext(r, dash.UsernameFromContext(r.Context()), dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, usersComponent) + err := layoutComponent.Render(r.Context(), w) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error()) return } } // GetUsers returns the list of users as JSON -func (h *UserHandlers) GetUsers(c *gin.Context) { - users, err := h.adminServer.GetObjectStoreUsers(c.Request.Context()) +func (h *UserHandlers) GetUsers(w http.ResponseWriter, r *http.Request) { + users, err := h.adminServer.GetObjectStoreUsers(r.Context()) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get users: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get users: "+err.Error()) return } - c.JSON(http.StatusOK, gin.H{"users": users}) + writeJSON(w, http.StatusOK, map[string]interface{}{"users": users}) } // CreateUser handles user creation -func (h *UserHandlers) CreateUser(c *gin.Context) { +func (h *UserHandlers) CreateUser(w http.ResponseWriter, r *http.Request) { var req dash.CreateUserRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } // Validate required fields if req.Username == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"}) + writeJSONError(w, http.StatusBadRequest, "Username is required") return } user, err := h.adminServer.CreateObjectStoreUser(req) if err != nil { glog.Errorf("Failed to create user %s: %v", req.Username, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to create user: "+err.Error()) return } - c.JSON(http.StatusCreated, gin.H{ + writeJSON(w, http.StatusCreated, map[string]interface{}{ "message": "User created successfully", "user": user, }) } // UpdateUser handles user updates -func (h *UserHandlers) UpdateUser(c *gin.Context) { - username := c.Param("username") +func (h *UserHandlers) UpdateUser(w http.ResponseWriter, r *http.Request) { + username := mux.Vars(r)["username"] if username == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"}) + writeJSONError(w, http.StatusBadRequest, "Username is required") return } var req dash.UpdateUserRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } user, err := h.adminServer.UpdateObjectStoreUser(username, req) if err != nil { glog.Errorf("Failed to update user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to update user: "+err.Error()) return } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "message": "User updated successfully", "user": user, }) } // DeleteUser handles user deletion -func (h *UserHandlers) DeleteUser(c *gin.Context) { - username := c.Param("username") +func (h *UserHandlers) DeleteUser(w http.ResponseWriter, r *http.Request) { + username := mux.Vars(r)["username"] if username == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"}) + writeJSONError(w, http.StatusBadRequest, "Username is required") return } err := h.adminServer.DeleteObjectStoreUser(username) if err != nil { glog.Errorf("Failed to delete user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to delete user: "+err.Error()) return } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "message": "User deleted successfully", }) } // GetUserDetails returns detailed information about a specific user -func (h *UserHandlers) GetUserDetails(c *gin.Context) { - username := c.Param("username") +func (h *UserHandlers) GetUserDetails(w http.ResponseWriter, r *http.Request) { + username := mux.Vars(r)["username"] if username == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"}) + writeJSONError(w, http.StatusBadRequest, "Username is required") return } user, err := h.adminServer.GetObjectStoreUserDetails(username) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "User not found: " + err.Error()}) + writeJSONError(w, http.StatusNotFound, "User not found: "+err.Error()) return } - c.JSON(http.StatusOK, user) + writeJSON(w, http.StatusOK, user) } // CreateAccessKey creates a new access key for a user -func (h *UserHandlers) CreateAccessKey(c *gin.Context) { - username := c.Param("username") +func (h *UserHandlers) CreateAccessKey(w http.ResponseWriter, r *http.Request) { + username := mux.Vars(r)["username"] if username == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"}) + writeJSONError(w, http.StatusBadRequest, "Username is required") return } accessKey, err := h.adminServer.CreateAccessKey(username) if err != nil { glog.Errorf("Failed to create access key for user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create access key: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to create access key: "+err.Error()) return } - c.JSON(http.StatusCreated, gin.H{ + writeJSON(w, http.StatusCreated, map[string]interface{}{ "message": "Access key created successfully", "access_key": accessKey, }) } // DeleteAccessKey deletes an access key for a user -func (h *UserHandlers) DeleteAccessKey(c *gin.Context) { - username := c.Param("username") - accessKeyId := c.Param("accessKeyId") +func (h *UserHandlers) DeleteAccessKey(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + username := vars["username"] + accessKeyId := vars["accessKeyId"] if username == "" || accessKeyId == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Username and access key ID are required"}) + writeJSONError(w, http.StatusBadRequest, "Username and access key ID are required") return } err := h.adminServer.DeleteAccessKey(username, accessKeyId) if err != nil { glog.Errorf("Failed to delete access key %s for user %s: %v", accessKeyId, username, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete access key: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to delete access key: "+err.Error()) return } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "message": "Access key deleted successfully", }) } // UpdateAccessKeyStatus updates the status of an access key for a user -func (h *UserHandlers) UpdateAccessKeyStatus(c *gin.Context) { - username := c.Param("username") - accessKeyId := c.Param("accessKeyId") +func (h *UserHandlers) UpdateAccessKeyStatus(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + username := vars["username"] + accessKeyId := vars["accessKeyId"] if username == "" || accessKeyId == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Username and access key ID are required"}) + writeJSONError(w, http.StatusBadRequest, "Username and access key ID are required") return } var req dash.UpdateAccessKeyStatusRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } // Validate status if req.Status != dash.AccessKeyStatusActive && req.Status != dash.AccessKeyStatusInactive { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Status must be '%s' or '%s'", dash.AccessKeyStatusActive, dash.AccessKeyStatusInactive)}) + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Status must be '%s' or '%s'", dash.AccessKeyStatusActive, dash.AccessKeyStatusInactive)) return } err := h.adminServer.UpdateAccessKeyStatus(username, accessKeyId, req.Status) if err != nil { glog.Errorf("Failed to update access key status %s for user %s: %v", accessKeyId, username, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update access key status: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to update access key status: "+err.Error()) return } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "message": "Access key updated successfully", }) } // GetUserPolicies returns the policies for a user -func (h *UserHandlers) GetUserPolicies(c *gin.Context) { - username := c.Param("username") +func (h *UserHandlers) GetUserPolicies(w http.ResponseWriter, r *http.Request) { + username := mux.Vars(r)["username"] if username == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"}) + writeJSONError(w, http.StatusBadRequest, "Username is required") return } policies, err := h.adminServer.GetUserPolicies(username) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user policies: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to get user policies: "+err.Error()) return } - c.JSON(http.StatusOK, gin.H{"policies": policies}) + writeJSON(w, http.StatusOK, map[string]interface{}{"policies": policies}) } // UpdateUserPolicies updates the policies for a user -func (h *UserHandlers) UpdateUserPolicies(c *gin.Context) { - username := c.Param("username") +func (h *UserHandlers) UpdateUserPolicies(w http.ResponseWriter, r *http.Request) { + username := mux.Vars(r)["username"] if username == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"}) + writeJSONError(w, http.StatusBadRequest, "Username is required") return } var req dash.UpdateUserPoliciesRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } err := h.adminServer.UpdateUserPolicies(username, req.Actions) if err != nil { glog.Errorf("Failed to update policies for user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user policies: " + err.Error()}) + writeJSONError(w, http.StatusInternalServerError, "Failed to update user policies: "+err.Error()) return } - c.JSON(http.StatusOK, gin.H{ + writeJSON(w, http.StatusOK, map[string]interface{}{ "message": "User policies updated successfully", }) } // getObjectStoreUsersData retrieves object store users data from the server -func (h *UserHandlers) getObjectStoreUsersData(c *gin.Context) dash.ObjectStoreUsersData { - username := c.GetString("username") +func (h *UserHandlers) getObjectStoreUsersData(r *http.Request) dash.ObjectStoreUsersData { + username := dash.UsernameFromContext(r.Context()) if username == "" { username = "admin" } // Get object store users - users, err := h.adminServer.GetObjectStoreUsers(c.Request.Context()) + users, err := h.adminServer.GetObjectStoreUsers(r.Context()) if err != nil { glog.Errorf("Failed to get object store users: %v", err) // Return empty data on error diff --git a/weed/admin/internal/httputil/httputil.go b/weed/admin/internal/httputil/httputil.go new file mode 100644 index 000000000..75b1a3283 --- /dev/null +++ b/weed/admin/internal/httputil/httputil.go @@ -0,0 +1,42 @@ +package httputil + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/seaweedfs/seaweedfs/weed/glog" +) + +const MaxJSONBodyBytes = 1 << 20 + +func NewJSONMaxReader(w http.ResponseWriter, r *http.Request) io.Reader { + return http.MaxBytesReader(w, r.Body, MaxJSONBodyBytes) +} + +func DecodeJSONBody(r io.Reader, v interface{}) error { + decoder := json.NewDecoder(r) + return decoder.Decode(v) +} + +func WriteJSON(w http.ResponseWriter, status int, payload interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if payload == nil { + return + } + if err := json.NewEncoder(w).Encode(payload); err != nil { + glog.Errorf("failed to encode JSON response (status=%d, payload=%T): %v", status, payload, err) + } +} + +func WriteJSONError(w http.ResponseWriter, status int, message string) { + WriteJSON(w, status, map[string]string{"error": message}) +} + +func DefaultQuery(value, fallback string) string { + if value == "" { + return fallback + } + return value +} diff --git a/weed/admin/view/layout/context.go b/weed/admin/view/layout/context.go new file mode 100644 index 000000000..482085895 --- /dev/null +++ b/weed/admin/view/layout/context.go @@ -0,0 +1,19 @@ +package layout + +import "net/http" + +// ViewContext contains per-request metadata needed by layout templates. +type ViewContext struct { + Request *http.Request + Username string + CSRFToken string +} + +// NewViewContext builds a ViewContext from request metadata. +func NewViewContext(r *http.Request, username, csrfToken string) ViewContext { + return ViewContext{ + Request: r, + Username: username, + CSRFToken: csrfToken, + } +} diff --git a/weed/admin/view/layout/layout.templ b/weed/admin/view/layout/layout.templ index 0627b1b58..359bb1b1a 100644 --- a/weed/admin/view/layout/layout.templ +++ b/weed/admin/view/layout/layout.templ @@ -4,19 +4,21 @@ import ( "fmt" "strings" "time" - "github.com/gin-gonic/gin" "github.com/seaweedfs/seaweedfs/weed/util/version" ) -templ Layout(c *gin.Context, content templ.Component) { +templ Layout(view ViewContext, content templ.Component) { {{ - username := c.GetString("username") + username := view.Username if username == "" { - username = "admin" + username = "unknown" } - csrfToken := c.GetString("csrf_token") + csrfToken := view.CSRFToken - currentPath := c.Request.URL.Path + currentPath := "" + if view.Request != nil { + currentPath = view.Request.URL.Path + } // Detect if we're on a message queue page to keep submenu expanded isMQPage := strings.HasPrefix(currentPath, "/mq/") @@ -357,7 +359,7 @@ templ Layout(c *gin.Context, content templ.Component) { } -templ LoginForm(c *gin.Context, title string, errorMessage string) { +templ LoginForm(title string, errorMessage string, csrfToken string) { @@ -388,6 +390,7 @@ templ LoginForm(c *gin.Context, title string, errorMessage string) { }
+
diff --git a/weed/admin/view/layout/layout_templ.go b/weed/admin/view/layout/layout_templ.go index df82e7ce5..10868a83e 100644 --- a/weed/admin/view/layout/layout_templ.go +++ b/weed/admin/view/layout/layout_templ.go @@ -10,13 +10,12 @@ import templruntime "github.com/a-h/templ/runtime" import ( "fmt" - "github.com/gin-gonic/gin" "github.com/seaweedfs/seaweedfs/weed/util/version" "strings" "time" ) -func Layout(c *gin.Context, content templ.Component) templ.Component { +func Layout(view ViewContext, content templ.Component) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -37,13 +36,16 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - username := c.GetString("username") + username := view.Username if username == "" { - username = "admin" + username = "unknown" } - csrfToken := c.GetString("csrf_token") + csrfToken := view.CSRFToken - currentPath := c.Request.URL.Path + currentPath := "" + if view.Request != nil { + currentPath = view.Request.URL.Path + } // Detect if we're on a message queue page to keep submenu expanded isMQPage := strings.HasPrefix(currentPath, "/mq/") @@ -63,7 +65,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(csrfToken) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 39, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 41, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -76,7 +78,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(username) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 70, Col: 73} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 72, Col: 73} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -111,7 +113,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", isClusterPage)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 97, Col: 207} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 99, Col: 207} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -168,7 +170,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", isStoragePage)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 122, Col: 207} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 124, Col: 207} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -342,7 +344,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", time.Now().Year())) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 337, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 339, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -355,7 +357,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(version.VERSION_NUMBER) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 337, Col: 102} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 339, Col: 102} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -379,7 +381,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { }) } -func LoginForm(c *gin.Context, title string, errorMessage string) templ.Component { +func LoginForm(title string, errorMessage string, csrfToken string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -407,7 +409,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 365, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 367, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { @@ -420,7 +422,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 379, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 381, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { @@ -438,7 +440,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen var templ_7745c5c3_Var19 string templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 386, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 388, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { @@ -449,7 +451,24 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/command/admin.go b/weed/command/admin.go index 8f90b8f32..f843af39d 100644 --- a/weed/command/admin.go +++ b/weed/command/admin.go @@ -1,22 +1,24 @@ package command import ( + "bufio" "context" "crypto/rand" "fmt" "log" + "net" "net/http" "os" "os/signal" "os/user" "path/filepath" + "runtime/debug" "strings" "syscall" "time" - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/cookie" - "github.com/gin-gonic/gin" + "github.com/gorilla/mux" + "github.com/gorilla/sessions" "github.com/spf13/viper" "github.com/seaweedfs/seaweedfs/weed/admin" @@ -232,25 +234,10 @@ func runAdmin(cmd *Command, args []string) bool { // startAdminServer starts the actual admin server func startAdminServer(ctx context.Context, options AdminOptions, enableUI bool, icebergPort int) error { - // Set Gin mode - gin.SetMode(gin.ReleaseMode) - // Create router - r := gin.New() - r.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { - if param.StatusCode == 200 { - return "" - } - return fmt.Sprintf("[GIN] %v | %3d | %13v | %15s | %-7s %s\n%s", - param.TimeStamp.Format("2006/01/02 - 15:04:05"), - param.StatusCode, - param.Latency, - param.ClientIP, - param.Method, - param.Path, - param.ErrorMessage, - ) - }), gin.Recovery()) + r := mux.NewRouter() + r.Use(loggingMiddleware) + r.Use(recoveryMiddleware) // Create data directory first if specified (needed for session key storage) var dataDir string @@ -276,30 +263,30 @@ func startAdminServer(ctx context.Context, options AdminOptions, enableUI bool, // Detect TLS configuration to set Secure cookie flag cookieSecure := viper.GetString("https.admin.key") != "" - // Session store - load or generate session key - sessionKeyBytes, err := loadOrGenerateSessionKey(dataDir) + // Session store - load or generate session keys + authKey, encKey, err := loadOrGenerateSessionKeys(dataDir) if err != nil { return fmt.Errorf("failed to get session key: %w", err) } - store := cookie.NewStore(sessionKeyBytes) + store := sessions.NewCookieStore(authKey, encKey) // Configure session options to ensure cookies are properly saved - store.Options(sessions.Options{ + store.Options = &sessions.Options{ Path: "/", MaxAge: 3600 * 24, // 24 hours HttpOnly: true, // Prevent JavaScript access Secure: cookieSecure, // Set based on actual TLS configuration SameSite: http.SameSiteLaxMode, - }) - - r.Use(sessions.Sessions("admin-session", store)) + } // Static files - serve from embedded filesystem staticFS, err := admin.GetStaticFS() if err != nil { log.Printf("Warning: Failed to load embedded static files: %v", err) } else { - r.StaticFS("/static", http.FS(staticFS)) + staticHandler := http.FileServer(http.FS(staticFS)) + r.Handle("/static", http.RedirectHandler("/static/", http.StatusMovedPermanently)) + r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", staticHandler)) } // Create admin server (plugin is always enabled) @@ -328,7 +315,7 @@ func startAdminServer(ctx context.Context, options AdminOptions, enableUI bool, // Create handlers and setup routes authRequired := *options.adminPassword != "" - adminHandlers := handlers.NewAdminHandlers(adminServer) + adminHandlers := handlers.NewAdminHandlers(adminServer, store) adminHandlers.SetupRoutes(r, authRequired, *options.adminUser, *options.adminPassword, *options.readOnlyUser, *options.readOnlyPassword, enableUI) // Server configuration @@ -395,49 +382,156 @@ func startAdminServer(ctx context.Context, options AdminOptions, enableUI bool, return nil } +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(status int) { + r.status = status + r.ResponseWriter.WriteHeader(status) +} + +func (r *statusRecorder) Write(b []byte) (int, error) { + if r.status == 0 { + r.status = http.StatusOK + } + return r.ResponseWriter.Write(b) +} + +func (r *statusRecorder) Flush() { + if f, ok := r.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if h, ok := r.ResponseWriter.(http.Hijacker); ok { + return h.Hijack() + } + return nil, nil, http.ErrNotSupported +} + +func (r *statusRecorder) Push(target string, opts *http.PushOptions) error { + if p, ok := r.ResponseWriter.(http.Pusher); ok { + return p.Push(target, opts) + } + return http.ErrNotSupported +} + +func (r *statusRecorder) Unwrap() http.ResponseWriter { + return r.ResponseWriter +} + +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + recorder := &statusRecorder{ResponseWriter: w} + + next.ServeHTTP(recorder, r) + + status := recorder.status + if status == 0 { + status = http.StatusOK + } + if status >= 200 && status < 300 { + return + } + + log.Printf("[HTTP] %v | %3d | %13v | %15s | %-7s %s", + time.Now().Format("2006/01/02 - 15:04:05"), + status, + time.Since(start), + r.RemoteAddr, + r.Method, + r.URL.Path, + ) + }) +} + +func recoveryMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + log.Printf("panic: %v\n%s", err, debug.Stack()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + }() + + next.ServeHTTP(w, r) + }) +} + // GetAdminOptions returns the admin command options for testing func GetAdminOptions() *AdminOptions { return &AdminOptions{} } -// loadOrGenerateSessionKey loads an existing session key from dataDir or generates a new one -func loadOrGenerateSessionKey(dataDir string) ([]byte, error) { - const sessionKeyLength = 32 +// loadOrGenerateSessionKeys loads or creates authentication/encryption keys for session cookies. +func loadOrGenerateSessionKeys(dataDir string) ([]byte, []byte, error) { + const keyLen = 32 + if dataDir == "" { - // No persistence, generate random key - log.Println("No dataDir specified, generating ephemeral session key") - key := make([]byte, sessionKeyLength) - _, err := rand.Read(key) - return key, err + // No persistence, generate ephemeral keys + log.Println("No dataDir specified, generating ephemeral session keys") + authKey := make([]byte, keyLen) + encKey := make([]byte, keyLen) + if _, err := rand.Read(authKey); err != nil { + return nil, nil, err + } + if _, err := rand.Read(encKey); err != nil { + return nil, nil, err + } + return authKey, encKey, nil } sessionKeyPath := filepath.Join(dataDir, ".session_key") - // Try to load existing key if data, err := os.ReadFile(sessionKeyPath); err == nil { - if len(data) == sessionKeyLength { + switch len(data) { + case keyLen: + authKey := make([]byte, keyLen) + copy(authKey, data) + + encKey := make([]byte, keyLen) + if _, err := rand.Read(encKey); err != nil { + return nil, nil, err + } + log.Printf("Warning: Upgrading session key at %s by adding an encryption key; existing cookies will be invalidated", sessionKeyPath) + + combined := append(authKey, encKey...) + if err := os.WriteFile(sessionKeyPath, combined, 0600); err != nil { + log.Printf("Warning: Failed to persist upgraded session key: %v", err) + } else { + log.Printf("Upgraded session key file to include encryption key: %s", sessionKeyPath) + } + return authKey, encKey, nil + case 2 * keyLen: + authKey := make([]byte, keyLen) + encKey := make([]byte, keyLen) + copy(authKey, data[:keyLen]) + copy(encKey, data[keyLen:]) log.Printf("Loaded persisted session key from %s", sessionKeyPath) - return data, nil + return authKey, encKey, nil + default: + log.Printf("Warning: Invalid session key file (expected %d or %d bytes, got %d), generating new key", keyLen, 2*keyLen, len(data)) } - log.Printf("Warning: Invalid session key file (expected %d bytes, got %d), generating new key", sessionKeyLength, len(data)) } else if !os.IsNotExist(err) { log.Printf("Warning: Failed to read session key from %s: %v. A new key will be generated.", sessionKeyPath, err) } - // Generate new key - key := make([]byte, sessionKeyLength) + key := make([]byte, 2*keyLen) if _, err := rand.Read(key); err != nil { - return nil, err + return nil, nil, err } - // Save key for future use if err := os.WriteFile(sessionKeyPath, key, 0600); err != nil { log.Printf("Warning: Failed to persist session key: %v", err) } else { log.Printf("Generated and persisted new session key to %s", sessionKeyPath) } - return key, nil + return key[:keyLen], key[keyLen:], nil } // expandHomeDir expands the tilde (~) in a path to the user's home directory @@ -466,20 +560,16 @@ func expandHomeDir(path string) (string, error) { } // Handle ~username/ patterns - if strings.HasPrefix(path, "~") { - parts := strings.SplitN(path[1:], "/", 2) - username := parts[0] + parts := strings.SplitN(path[1:], "/", 2) + username := parts[0] - targetUser, err := user.Lookup(username) - if err != nil { - return "", fmt.Errorf("user %s not found: %v", username, err) - } - - if len(parts) == 1 { - return targetUser.HomeDir, nil - } - return filepath.Join(targetUser.HomeDir, parts[1]), nil + targetUser, err := user.Lookup(username) + if err != nil { + return "", fmt.Errorf("user %s not found: %v", username, err) } - return path, nil + if len(parts) == 1 { + return targetUser.HomeDir, nil + } + return filepath.Join(targetUser.HomeDir, parts[1]), nil } diff --git a/weed/command/filer_copy.go b/weed/command/filer_copy.go index 38e4eb7b9..56c3a7f07 100644 --- a/weed/command/filer_copy.go +++ b/weed/command/filer_copy.go @@ -25,8 +25,8 @@ import ( ) var ( - copy CopyOptions - waitGroup sync.WaitGroup + copyOptions CopyOptions + waitGroup sync.WaitGroup ) type CopyOptions struct { @@ -50,17 +50,17 @@ type CopyOptions struct { func init() { cmdFilerCopy.Run = runCopy // break init cycle cmdFilerCopy.IsDebug = cmdFilerCopy.Flag.Bool("debug", false, "verbose debug information") - copy.include = cmdFilerCopy.Flag.String("include", "", "pattens of files to copy, e.g., *.pdf, *.html, ab?d.txt, works together with -dir") - copy.replication = cmdFilerCopy.Flag.String("replication", "", "replication type") - copy.collection = cmdFilerCopy.Flag.String("collection", "", "optional collection name") - copy.ttl = cmdFilerCopy.Flag.String("ttl", "", "time to live, e.g.: 1m, 1h, 1d, 1M, 1y") - copy.diskType = cmdFilerCopy.Flag.String("disk", "", "[hdd|ssd|] hard drive or solid state drive or any tag") - copy.maxMB = cmdFilerCopy.Flag.Int("maxMB", 4, "split files larger than the limit") - copy.concurrentFiles = cmdFilerCopy.Flag.Int("c", 8, "concurrent file copy goroutines") - copy.concurrentChunks = cmdFilerCopy.Flag.Int("concurrentChunks", 8, "concurrent chunk copy goroutines for each file") - copy.checkSize = cmdFilerCopy.Flag.Bool("check.size", false, "copy when the target file size is different from the source file") - copy.verbose = cmdFilerCopy.Flag.Bool("verbose", false, "print out details during copying") - copy.volumeServerAccess = cmdFilerCopy.Flag.String("volumeServerAccess", "direct", "access volume servers by [direct|publicUrl]") + copyOptions.include = cmdFilerCopy.Flag.String("include", "", "patterns of files to copy, e.g., *.pdf, *.html, ab?d.txt, works together with -dir") + copyOptions.replication = cmdFilerCopy.Flag.String("replication", "", "replication type") + copyOptions.collection = cmdFilerCopy.Flag.String("collection", "", "optional collection name") + copyOptions.ttl = cmdFilerCopy.Flag.String("ttl", "", "time to live, e.g.: 1m, 1h, 1d, 1M, 1y") + copyOptions.diskType = cmdFilerCopy.Flag.String("disk", "", "[hdd|ssd|] hard drive or solid state drive or any tag") + copyOptions.maxMB = cmdFilerCopy.Flag.Int("maxMB", 4, "split files larger than the limit") + copyOptions.concurrentFiles = cmdFilerCopy.Flag.Int("c", 8, "concurrent file copy goroutines") + copyOptions.concurrentChunks = cmdFilerCopy.Flag.Int("concurrentChunks", 8, "concurrent chunk copy goroutines for each file") + copyOptions.checkSize = cmdFilerCopy.Flag.Bool("check.size", false, "copy when the target file size is different from the source file") + copyOptions.verbose = cmdFilerCopy.Flag.Bool("verbose", false, "print out details during copying") + copyOptions.volumeServerAccess = cmdFilerCopy.Flag.String("volumeServerAccess", "direct", "access volume servers by [direct|publicUrl]") } var cmdFilerCopy = &Command{ @@ -99,9 +99,9 @@ func runCopy(cmd *Command, args []string) bool { return false } - copy.grpcDialOption = security.LoadClientTLS(util.GetViper(), "grpc.client") + copyOptions.grpcDialOption = security.LoadClientTLS(util.GetViper(), "grpc.client") - masters, collection, replication, dirBuckets, maxMB, cipher, err := readFilerConfiguration(copy.grpcDialOption, filerAddress) + masters, collection, replication, dirBuckets, maxMB, cipher, err := readFilerConfiguration(copyOptions.grpcDialOption, filerAddress) if err != nil { fmt.Printf("read from filer %s: %v\n", filerAddress, err) return false @@ -110,38 +110,45 @@ func runCopy(cmd *Command, args []string) bool { restPath := urlPath[len(dirBuckets)+1:] if strings.Index(restPath, "/") > 0 { expectedBucket := restPath[:strings.Index(restPath, "/")] - if *copy.collection == "" { - *copy.collection = expectedBucket - } else if *copy.collection != expectedBucket { - fmt.Printf("destination %s uses collection \"%s\": unexpected collection \"%v\"\n", urlPath, expectedBucket, *copy.collection) + if *copyOptions.collection == "" { + *copyOptions.collection = expectedBucket + } else if *copyOptions.collection != expectedBucket { + fmt.Printf("destination %s uses collection \"%s\": unexpected collection \"%v\"\n", urlPath, expectedBucket, *copyOptions.collection) return true } } } - if *copy.collection == "" { - *copy.collection = collection + if *copyOptions.collection == "" { + *copyOptions.collection = collection } - if *copy.replication == "" { - *copy.replication = replication + if *copyOptions.replication == "" { + *copyOptions.replication = replication } - if *copy.maxMB == 0 { - *copy.maxMB = int(maxMB) + if *copyOptions.maxMB == 0 { + *copyOptions.maxMB = int(maxMB) } - copy.masters = masters - copy.cipher = cipher + copyOptions.masters = masters + copyOptions.cipher = cipher - ttl, err := needle.ReadTTL(*copy.ttl) + ttl, err := needle.ReadTTL(*copyOptions.ttl) if err != nil { - fmt.Printf("parsing ttl %s: %v\n", *copy.ttl, err) + fmt.Printf("parsing ttl %s: %v\n", *copyOptions.ttl, err) return false } - copy.ttlSec = int32(ttl.Minutes()) * 60 + copyOptions.ttlSec = int32(ttl.Minutes()) * 60 if *cmdFilerCopy.IsDebug { - grace.SetupProfiling("filer.copy.cpu.pprof", "filer.copy.mem.pprof") + grace.SetupProfiling("filer.copyOptions.cpu.pprof", "filer.copyOptions.mem.pprof") } - fileCopyTaskChan := make(chan FileCopyTask, *copy.concurrentFiles) + concurrentFiles := *copyOptions.concurrentFiles + if concurrentFiles <= 0 { + fmt.Fprintf(os.Stderr, "Invalid concurrency %d; using at least 1 worker\n", concurrentFiles) + concurrentFiles = 1 + } + *copyOptions.concurrentFiles = concurrentFiles + + fileCopyTaskChan := make(chan FileCopyTask, concurrentFiles) go func() { defer close(fileCopyTaskChan) @@ -152,12 +159,12 @@ func runCopy(cmd *Command, args []string) bool { } } }() - for i := 0; i < *copy.concurrentFiles; i++ { + for i := 0; i < concurrentFiles; i++ { waitGroup.Add(1) go func() { defer waitGroup.Done() worker := FileCopyWorker{ - options: ©, + options: ©Options, filerAddress: filerAddress, signature: util.RandomInt32(), } @@ -481,8 +488,11 @@ func (worker *FileCopyWorker) uploadFileInChunks(task FileCopyTask, f *os.File, for _, chunk := range chunks { fileIds = append(fileIds, chunk.FileId) } + if len(worker.options.masters) == 0 { + return fmt.Errorf("upload data %v: %w (cleanup skipped: no masters configured)", fileName, uploadError) + } operation.DeleteFileIds(func(_ context.Context) pb.ServerAddress { - return pb.ServerAddress(copy.masters[0]) + return pb.ServerAddress(worker.options.masters[0]) }, false, worker.options.grpcDialOption, fileIds) return uploadError }