Add admin component (#6928)
* init version * relocate * add s3 bucket link * refactor handlers into weed/admin folder * fix login logout * adding favicon * remove fall back to http get topology * grpc dial option, disk total capacity * show filer count * fix each volume disk usage * add filers to dashboard * adding hosts, volumes, collections * refactor code and menu * remove "refresh" button * fix data for collections * rename cluster hosts into volume servers * add masters, filers * reorder * adding file browser * create folder and upload files * add filer version, created at time * remove mock data * remove fields * fix submenu item highlighting * fix bucket creation * purge files * delete multiple * fix bucket creation * remove region from buckets * add object store with buckets and users * rendering permission * refactor * get bucket objects and size * link to file browser * add file size and count for collections page * paginate the volumes * fix possible SSRF https://github.com/seaweedfs/seaweedfs/pull/6928/checks?check_run_id=45108469801 * Update weed/command/admin.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update weed/command/admin.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix build * import * remove filer CLI option * remove filer option * remove CLI options --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
45
weed/admin/handlers/auth.go
Normal file
45
weed/admin/handlers/auth.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
|
||||
)
|
||||
|
||||
// AuthHandlers contains authentication-related HTTP handlers
|
||||
type AuthHandlers struct {
|
||||
adminServer *dash.AdminServer
|
||||
}
|
||||
|
||||
// NewAuthHandlers creates a new instance of AuthHandlers
|
||||
func NewAuthHandlers(adminServer *dash.AdminServer) *AuthHandlers {
|
||||
return &AuthHandlers{
|
||||
adminServer: adminServer,
|
||||
}
|
||||
}
|
||||
|
||||
// ShowLogin displays the login page
|
||||
func (a *AuthHandlers) ShowLogin(c *gin.Context) {
|
||||
errorMessage := c.Query("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()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleLogin handles login form submission
|
||||
func (a *AuthHandlers) HandleLogin(username, password string) gin.HandlerFunc {
|
||||
return a.adminServer.HandleLogin(username, password)
|
||||
}
|
||||
|
||||
// HandleLogout handles user logout
|
||||
func (a *AuthHandlers) HandleLogout(c *gin.Context) {
|
||||
a.adminServer.HandleLogout(c)
|
||||
}
|
||||
202
weed/admin/handlers/cluster_handlers.go
Normal file
202
weed/admin/handlers/cluster_handlers.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// ClusterHandlers contains all the HTTP handlers for cluster management
|
||||
type ClusterHandlers struct {
|
||||
adminServer *dash.AdminServer
|
||||
}
|
||||
|
||||
// NewClusterHandlers creates a new instance of ClusterHandlers
|
||||
func NewClusterHandlers(adminServer *dash.AdminServer) *ClusterHandlers {
|
||||
return &ClusterHandlers{
|
||||
adminServer: adminServer,
|
||||
}
|
||||
}
|
||||
|
||||
// ShowClusterVolumeServers renders the cluster volume servers page
|
||||
func (h *ClusterHandlers) ShowClusterVolumeServers(c *gin.Context) {
|
||||
// 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()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set username
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
volumeServersData.Username = username
|
||||
|
||||
// Render HTML template
|
||||
c.Header("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()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ShowClusterVolumes renders the cluster volumes page
|
||||
func (h *ClusterHandlers) ShowClusterVolumes(c *gin.Context) {
|
||||
// Get pagination and sorting parameters from query string
|
||||
page := 1
|
||||
if p := c.Query("page"); p != "" {
|
||||
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
|
||||
page = parsed
|
||||
}
|
||||
}
|
||||
|
||||
pageSize := 100
|
||||
if ps := c.Query("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")
|
||||
|
||||
// Get cluster volumes data
|
||||
volumesData, err := h.adminServer.GetClusterVolumes(page, pageSize, sortBy, sortOrder)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster volumes: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set username
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
volumesData.Username = username
|
||||
|
||||
// Render HTML template
|
||||
c.Header("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()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ShowClusterCollections renders the cluster collections page
|
||||
func (h *ClusterHandlers) ShowClusterCollections(c *gin.Context) {
|
||||
// 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()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set username
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
collectionsData.Username = username
|
||||
|
||||
// Render HTML template
|
||||
c.Header("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()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ShowClusterMasters renders the cluster masters page
|
||||
func (h *ClusterHandlers) ShowClusterMasters(c *gin.Context) {
|
||||
// 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()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set username
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
mastersData.Username = username
|
||||
|
||||
// Render HTML template
|
||||
c.Header("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()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ShowClusterFilers renders the cluster filers page
|
||||
func (h *ClusterHandlers) ShowClusterFilers(c *gin.Context) {
|
||||
// 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()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set username
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
filersData.Username = username
|
||||
|
||||
// Render HTML template
|
||||
c.Header("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()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// GetClusterTopology returns the cluster topology as JSON
|
||||
func (h *ClusterHandlers) GetClusterTopology(c *gin.Context) {
|
||||
topology, err := h.adminServer.GetClusterTopology()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(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", "status": "active"}}})
|
||||
}
|
||||
|
||||
// GetVolumeServers returns volume server information
|
||||
func (h *ClusterHandlers) GetVolumeServers(c *gin.Context) {
|
||||
topology, err := h.adminServer.GetClusterTopology()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"volume_servers": topology.VolumeServers})
|
||||
}
|
||||
447
weed/admin/handlers/file_browser_handlers.go
Normal file
447
weed/admin/handlers/file_browser_handlers.go
Normal file
@@ -0,0 +1,447 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"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"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
)
|
||||
|
||||
type FileBrowserHandlers struct {
|
||||
adminServer *dash.AdminServer
|
||||
}
|
||||
|
||||
func NewFileBrowserHandlers(adminServer *dash.AdminServer) *FileBrowserHandlers {
|
||||
return &FileBrowserHandlers{
|
||||
adminServer: adminServer,
|
||||
}
|
||||
}
|
||||
|
||||
// ShowFileBrowser renders the file browser page
|
||||
func (h *FileBrowserHandlers) ShowFileBrowser(c *gin.Context) {
|
||||
// Get path from query parameter, default to root
|
||||
path := c.DefaultQuery("path", "/")
|
||||
|
||||
// Get file browser data
|
||||
browserData, err := h.adminServer.GetFileBrowser(path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get file browser data: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set username
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
browserData.Username = username
|
||||
|
||||
// Render HTML template
|
||||
c.Header("Content-Type", "text/html")
|
||||
browserComponent := app.FileBrowser(*browserData)
|
||||
layoutComponent := layout.Layout(c, browserComponent)
|
||||
err = layoutComponent.Render(c.Request.Context(), c.Writer)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteFile handles file deletion API requests
|
||||
func (h *FileBrowserHandlers) DeleteFile(c *gin.Context) {
|
||||
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()})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete file via filer
|
||||
err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
_, err := client.DeleteEntry(context.Background(), &filer_pb.DeleteEntryRequest{
|
||||
Directory: filepath.Dir(request.Path),
|
||||
Name: filepath.Base(request.Path),
|
||||
IsDeleteData: true,
|
||||
IsRecursive: true,
|
||||
IgnoreRecursiveError: false,
|
||||
})
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete file: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "File deleted successfully"})
|
||||
}
|
||||
|
||||
// DeleteMultipleFiles handles multiple file deletion API requests
|
||||
func (h *FileBrowserHandlers) DeleteMultipleFiles(c *gin.Context) {
|
||||
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()})
|
||||
return
|
||||
}
|
||||
|
||||
if len(request.Paths) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No paths provided"})
|
||||
return
|
||||
}
|
||||
|
||||
var deletedCount int
|
||||
var failedCount int
|
||||
var errors []string
|
||||
|
||||
// Delete each file/folder
|
||||
for _, path := range request.Paths {
|
||||
err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
_, err := client.DeleteEntry(context.Background(), &filer_pb.DeleteEntryRequest{
|
||||
Directory: filepath.Dir(path),
|
||||
Name: filepath.Base(path),
|
||||
IsDeleteData: true,
|
||||
IsRecursive: true,
|
||||
IgnoreRecursiveError: false,
|
||||
})
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
failedCount++
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", path, err))
|
||||
} else {
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare response
|
||||
response := map[string]interface{}{
|
||||
"deleted": deletedCount,
|
||||
"failed": failedCount,
|
||||
"total": len(request.Paths),
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
response["errors"] = errors
|
||||
}
|
||||
|
||||
if deletedCount > 0 {
|
||||
if failedCount == 0 {
|
||||
response["message"] = fmt.Sprintf("Successfully deleted %d item(s)", deletedCount)
|
||||
} else {
|
||||
response["message"] = fmt.Sprintf("Deleted %d item(s), failed to delete %d item(s)", deletedCount, failedCount)
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
} else {
|
||||
response["message"] = "Failed to delete all selected items"
|
||||
c.JSON(http.StatusInternalServerError, response)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateFolder handles folder creation requests
|
||||
func (h *FileBrowserHandlers) CreateFolder(c *gin.Context) {
|
||||
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()})
|
||||
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"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create full path for new folder
|
||||
fullPath := filepath.Join(request.Path, folderName)
|
||||
if !strings.HasPrefix(fullPath, "/") {
|
||||
fullPath = "/" + fullPath
|
||||
}
|
||||
|
||||
// Create folder via filer
|
||||
err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
_, err := client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
|
||||
Directory: filepath.Dir(fullPath),
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: filepath.Base(fullPath),
|
||||
IsDirectory: true,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
FileMode: uint32(0755 | (1 << 31)), // Directory mode
|
||||
Uid: uint32(1000),
|
||||
Gid: uint32(1000),
|
||||
Crtime: time.Now().Unix(),
|
||||
Mtime: time.Now().Unix(),
|
||||
TtlSec: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create folder: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Folder created successfully"})
|
||||
}
|
||||
|
||||
// UploadFile handles file upload requests
|
||||
func (h *FileBrowserHandlers) UploadFile(c *gin.Context) {
|
||||
// Get the current path
|
||||
currentPath := c.PostForm("path")
|
||||
if currentPath == "" {
|
||||
currentPath = "/"
|
||||
}
|
||||
|
||||
// Parse multipart form
|
||||
err := c.Request.ParseMultipartForm(100 << 20) // 100MB max memory
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse multipart form: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get uploaded files (supports multiple files)
|
||||
files := c.Request.MultipartForm.File["files"]
|
||||
if len(files) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No files uploaded"})
|
||||
return
|
||||
}
|
||||
|
||||
var uploadResults []map[string]interface{}
|
||||
var failedUploads []string
|
||||
|
||||
// Process each uploaded file
|
||||
for _, fileHeader := range files {
|
||||
// Validate file name
|
||||
fileName := fileHeader.Filename
|
||||
if fileName == "" {
|
||||
failedUploads = append(failedUploads, "invalid filename")
|
||||
continue
|
||||
}
|
||||
|
||||
// Create full path for the file
|
||||
fullPath := filepath.Join(currentPath, fileName)
|
||||
if !strings.HasPrefix(fullPath, "/") {
|
||||
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))
|
||||
} else {
|
||||
uploadResults = append(uploadResults, map[string]interface{}{
|
||||
"name": fileName,
|
||||
"size": fileHeader.Size,
|
||||
"path": fullPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare response
|
||||
response := map[string]interface{}{
|
||||
"uploaded": len(uploadResults),
|
||||
"failed": len(failedUploads),
|
||||
"files": uploadResults,
|
||||
}
|
||||
|
||||
if len(failedUploads) > 0 {
|
||||
response["errors"] = failedUploads
|
||||
}
|
||||
|
||||
if len(uploadResults) > 0 {
|
||||
if len(failedUploads) == 0 {
|
||||
response["message"] = fmt.Sprintf("Successfully uploaded %d file(s)", len(uploadResults))
|
||||
} else {
|
||||
response["message"] = fmt.Sprintf("Uploaded %d file(s), %d failed", len(uploadResults), len(failedUploads))
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
} else {
|
||||
response["message"] = "All file uploads failed"
|
||||
c.JSON(http.StatusInternalServerError, response)
|
||||
}
|
||||
}
|
||||
|
||||
// uploadFileToFiler uploads a file directly to the filer using multipart form data
|
||||
func (h *FileBrowserHandlers) uploadFileToFiler(filePath string, fileHeader *multipart.FileHeader) error {
|
||||
// Get filer address from admin server
|
||||
filerAddress := h.adminServer.GetFilerAddress()
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address not configured")
|
||||
}
|
||||
|
||||
// Validate and sanitize the filer address
|
||||
if err := h.validateFilerAddress(filerAddress); err != nil {
|
||||
return fmt.Errorf("invalid filer address: %v", err)
|
||||
}
|
||||
|
||||
// Validate and sanitize the file path
|
||||
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid file path: %v", err)
|
||||
}
|
||||
|
||||
// Open the file
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Create multipart form data
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
// Create form file field
|
||||
part, err := writer.CreateFormFile("file", fileHeader.Filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create form file: %v", err)
|
||||
}
|
||||
|
||||
// Copy file content to form
|
||||
_, err = io.Copy(part, file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to copy file content: %v", err)
|
||||
}
|
||||
|
||||
// Close the writer to finalize the form
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
// Create the upload URL with validated components
|
||||
uploadURL := fmt.Sprintf("http://%s%s", filerAddress, cleanFilePath)
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequest("POST", uploadURL, &body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
// Set content type with boundary
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
// Send request
|
||||
client := &http.Client{Timeout: 60 * time.Second} // Increased timeout for larger files
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload file: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check response
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
responseBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(responseBody))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFilerAddress validates that the filer address is safe to use
|
||||
func (h *FileBrowserHandlers) validateFilerAddress(address string) error {
|
||||
if address == "" {
|
||||
return fmt.Errorf("filer address cannot be empty")
|
||||
}
|
||||
|
||||
// Parse the address to validate it's a proper host:port format
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid address format: %v", err)
|
||||
}
|
||||
|
||||
// Validate host is not empty
|
||||
if host == "" {
|
||||
return fmt.Errorf("host cannot be empty")
|
||||
}
|
||||
|
||||
// Validate port is numeric and in valid range
|
||||
if port == "" {
|
||||
return fmt.Errorf("port cannot be empty")
|
||||
}
|
||||
|
||||
portNum, err := strconv.Atoi(port)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid port number: %v", err)
|
||||
}
|
||||
|
||||
if portNum < 1 || portNum > 65535 {
|
||||
return fmt.Errorf("port number must be between 1 and 65535")
|
||||
}
|
||||
|
||||
// Additional security: prevent private network access unless explicitly allowed
|
||||
// This helps prevent SSRF attacks to internal services
|
||||
ip := net.ParseIP(host)
|
||||
if ip != nil {
|
||||
// Check for localhost, private networks, and other dangerous addresses
|
||||
if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() {
|
||||
// Only allow if it's the configured filer (trusted)
|
||||
// In production, you might want to be more restrictive
|
||||
glog.V(2).Infof("Allowing access to private/local address: %s (configured filer)", address)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAndCleanFilePath validates and cleans the file path to prevent path traversal
|
||||
func (h *FileBrowserHandlers) validateAndCleanFilePath(filePath string) (string, error) {
|
||||
if filePath == "" {
|
||||
return "", fmt.Errorf("file path cannot be empty")
|
||||
}
|
||||
|
||||
// Clean the path to remove any .. or . components
|
||||
cleanPath := filepath.Clean(filePath)
|
||||
|
||||
// Ensure the path starts with /
|
||||
if !strings.HasPrefix(cleanPath, "/") {
|
||||
cleanPath = "/" + cleanPath
|
||||
}
|
||||
|
||||
// Prevent path traversal attacks
|
||||
if strings.Contains(cleanPath, "..") {
|
||||
return "", fmt.Errorf("path traversal not allowed")
|
||||
}
|
||||
|
||||
// Additional validation: ensure path doesn't contain dangerous characters
|
||||
if strings.ContainsAny(cleanPath, "\x00\r\n") {
|
||||
return "", fmt.Errorf("path contains invalid characters")
|
||||
}
|
||||
|
||||
return cleanPath, nil
|
||||
}
|
||||
320
weed/admin/handlers/handlers.go
Normal file
320
weed/admin/handlers/handlers.go
Normal file
@@ -0,0 +1,320 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"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"
|
||||
)
|
||||
|
||||
// AdminHandlers contains all the HTTP handlers for the admin interface
|
||||
type AdminHandlers struct {
|
||||
adminServer *dash.AdminServer
|
||||
authHandlers *AuthHandlers
|
||||
clusterHandlers *ClusterHandlers
|
||||
fileBrowserHandlers *FileBrowserHandlers
|
||||
}
|
||||
|
||||
// NewAdminHandlers creates a new instance of AdminHandlers
|
||||
func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers {
|
||||
authHandlers := NewAuthHandlers(adminServer)
|
||||
clusterHandlers := NewClusterHandlers(adminServer)
|
||||
fileBrowserHandlers := NewFileBrowserHandlers(adminServer)
|
||||
return &AdminHandlers{
|
||||
adminServer: adminServer,
|
||||
authHandlers: authHandlers,
|
||||
clusterHandlers: clusterHandlers,
|
||||
fileBrowserHandlers: fileBrowserHandlers,
|
||||
}
|
||||
}
|
||||
|
||||
// SetupRoutes configures all the routes for the admin interface
|
||||
func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username, password string) {
|
||||
// Health check (no auth required)
|
||||
r.GET("/health", h.HealthCheck)
|
||||
|
||||
if authRequired {
|
||||
// Authentication routes (no auth required)
|
||||
r.GET("/login", h.authHandlers.ShowLogin)
|
||||
r.POST("/login", h.authHandlers.HandleLogin(username, password))
|
||||
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.ShowObjectStoreUsers)
|
||||
|
||||
// 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)
|
||||
protected.GET("/cluster/volumes", h.clusterHandlers.ShowClusterVolumes)
|
||||
protected.GET("/cluster/collections", h.clusterHandlers.ShowClusterCollections)
|
||||
|
||||
// API routes for AJAX calls
|
||||
api := protected.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
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
} 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.ShowObjectStoreUsers)
|
||||
|
||||
// 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)
|
||||
r.GET("/cluster/volumes", h.clusterHandlers.ShowClusterVolumes)
|
||||
r.GET("/cluster/collections", h.clusterHandlers.ShowClusterCollections)
|
||||
|
||||
// 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
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HealthCheck returns the health status of the admin interface
|
||||
func (h *AdminHandlers) HealthCheck(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// ShowDashboard renders the main admin dashboard
|
||||
func (h *AdminHandlers) ShowDashboard(c *gin.Context) {
|
||||
// Get admin data from the server
|
||||
adminData := h.getAdminData(c)
|
||||
|
||||
// Render HTML template
|
||||
c.Header("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()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ShowS3Buckets renders the S3 buckets management page
|
||||
func (h *AdminHandlers) ShowS3Buckets(c *gin.Context) {
|
||||
// Get S3 buckets data from the server
|
||||
s3Data := h.getS3BucketsData(c)
|
||||
|
||||
// Render HTML template
|
||||
c.Header("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()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ShowBucketDetails returns detailed information about a specific bucket
|
||||
func (h *AdminHandlers) ShowBucketDetails(c *gin.Context) {
|
||||
bucketName := c.Param("bucket")
|
||||
details, err := h.adminServer.GetBucketDetails(bucketName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get bucket details: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, details)
|
||||
}
|
||||
|
||||
// ShowObjectStoreUsers renders the object store users management page
|
||||
func (h *AdminHandlers) ShowObjectStoreUsers(c *gin.Context) {
|
||||
// Get object store users data from the server
|
||||
usersData := h.getObjectStoreUsersData(c)
|
||||
|
||||
// Render HTML template
|
||||
c.Header("Content-Type", "text/html")
|
||||
usersComponent := app.ObjectStoreUsers(usersData)
|
||||
layoutComponent := layout.Layout(c, usersComponent)
|
||||
err := layoutComponent.Render(c.Request.Context(), c.Writer)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// getS3BucketsData retrieves S3 buckets data from the server
|
||||
func (h *AdminHandlers) getS3BucketsData(c *gin.Context) dash.S3BucketsData {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
|
||||
// Get S3 buckets
|
||||
buckets, err := h.adminServer.GetS3Buckets()
|
||||
if err != nil {
|
||||
// Return empty data on error
|
||||
return dash.S3BucketsData{
|
||||
Username: username,
|
||||
Buckets: []dash.S3Bucket{},
|
||||
TotalBuckets: 0,
|
||||
TotalSize: 0,
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
var totalSize int64
|
||||
for _, bucket := range buckets {
|
||||
totalSize += bucket.Size
|
||||
}
|
||||
|
||||
return dash.S3BucketsData{
|
||||
Username: username,
|
||||
Buckets: buckets,
|
||||
TotalBuckets: len(buckets),
|
||||
TotalSize: totalSize,
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// getObjectStoreUsersData retrieves object store users data from the server
|
||||
func (h *AdminHandlers) getObjectStoreUsersData(c *gin.Context) dash.ObjectStoreUsersData {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
|
||||
// Get object store users
|
||||
users, err := h.adminServer.GetObjectStoreUsers()
|
||||
if err != nil {
|
||||
// Return empty data on error
|
||||
return dash.ObjectStoreUsersData{
|
||||
Username: username,
|
||||
Users: []dash.ObjectStoreUser{},
|
||||
TotalUsers: 0,
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
return dash.ObjectStoreUsersData{
|
||||
Username: username,
|
||||
Users: users,
|
||||
TotalUsers: len(users),
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// getAdminData retrieves admin data from the server (now uses consolidated method)
|
||||
func (h *AdminHandlers) getAdminData(c *gin.Context) dash.AdminData {
|
||||
username := c.GetString("username")
|
||||
|
||||
// Use the consolidated GetAdminData method from AdminServer
|
||||
adminData, err := h.adminServer.GetAdminData(username)
|
||||
if err != nil {
|
||||
// Return default data when services are not available
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
|
||||
masterNodes := []dash.MasterNode{
|
||||
{
|
||||
Address: "localhost:9333",
|
||||
IsLeader: true,
|
||||
Status: "unreachable",
|
||||
},
|
||||
}
|
||||
|
||||
return dash.AdminData{
|
||||
Username: username,
|
||||
ClusterStatus: "warning",
|
||||
TotalVolumes: 0,
|
||||
TotalFiles: 0,
|
||||
TotalSize: 0,
|
||||
MasterNodes: masterNodes,
|
||||
VolumeServers: []dash.VolumeServer{},
|
||||
FilerNodes: []dash.FilerNode{},
|
||||
DataCenters: []dash.DataCenter{},
|
||||
LastUpdated: time.Now(),
|
||||
SystemHealth: "poor",
|
||||
}
|
||||
}
|
||||
|
||||
return adminData
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func (h *AdminHandlers) determineClusterStatus(topology *dash.ClusterTopology, masters []dash.MasterNode) string {
|
||||
if len(topology.VolumeServers) == 0 {
|
||||
return "warning"
|
||||
}
|
||||
return "healthy"
|
||||
}
|
||||
|
||||
func (h *AdminHandlers) determineSystemHealth(topology *dash.ClusterTopology, masters []dash.MasterNode) string {
|
||||
if len(topology.VolumeServers) > 0 && len(masters) > 0 {
|
||||
return "good"
|
||||
}
|
||||
return "fair"
|
||||
}
|
||||
Reference in New Issue
Block a user