Files
seaweedfs/weed/admin/handlers/file_browser_handlers.go
T
Chris Lu 4c050ad76b Don't mangle filer paths with the OS separator on Windows (#9878)
fix: don't mangle filer paths with the OS separator on Windows

filepath.Dir/Join use the platform separator, so on Windows they rewrite
a forward-slash filer path like /buckets/x into \buckets\x. The mangled
value then goes into a filer RPC and operates on the wrong key, so the
op silently targets nothing.

The admin file browser hit this in New Folder (the entry landed under
\buckets\my-bucket and never showed up under /buckets/my-bucket), and
the same way in delete, view and properties. MQ topic retention and
consumer-offset listing, and the SFTP home dir plus create-permission
parent lookup, had the same bug.

Switch all of these to the path package, which always uses "/".
2026-06-08 13:56:02 -07:00

781 lines
23 KiB
Go

package handlers
import (
"bufio"
"compress/gzip"
"context"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"os"
"path"
"strconv"
"strings"
"time"
"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/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/util/http/client"
"google.golang.org/protobuf/proto"
)
type FileBrowserHandlers struct {
adminServer *dash.AdminServer
httpClient *client.HTTPClient
}
func NewFileBrowserHandlers(adminServer *dash.AdminServer) *FileBrowserHandlers {
// Create HTTP client with TLS support from https.client configuration
// The client is created without a timeout - each operation will set its own timeout
// If TLS is enabled but misconfigured, fail fast to alert the operator immediately
// rather than silently falling back to HTTP and causing confusing runtime errors
httpClient, err := client.NewHttpClient(client.Client)
if err != nil {
glog.Fatalf("Failed to create HTTPS client for file browser: %v", err)
}
return &FileBrowserHandlers{
adminServer: adminServer,
httpClient: httpClient,
}
}
// ShowFileBrowser renders the file browser page
func (h *FileBrowserHandlers) ShowFileBrowser(w http.ResponseWriter, r *http.Request) {
// Get path from query parameter, default to root
path := defaultQuery(r.URL.Query().Get("path"), "/")
// Normalize Windows-style paths for consistency
path = util.CleanWindowsPath(path)
// Get pagination parameters
lastFileName := r.URL.Query().Get("lastFileName")
pageSize, err := strconv.Atoi(defaultQuery(r.URL.Query().Get("limit"), "20"))
if err != nil || pageSize < 1 {
pageSize = 20
}
if pageSize > 200 {
pageSize = 200
}
// Get file browser data with cursor-based pagination
browserData, err := h.adminServer.GetFileBrowser(path, lastFileName, pageSize)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to get file browser data: "+err.Error())
return
}
// Set username
username := dash.UsernameFromContext(r.Context())
if username == "" {
username = "admin"
}
browserData.Username = username
// Render HTML template
w.Header().Set("Content-Type", "text/html")
browserComponent := app.FileBrowser(*browserData)
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, browserComponent)
err = layoutComponent.Render(r.Context(), w)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
return
}
}
// DeleteFile handles file deletion API requests
func (h *FileBrowserHandlers) DeleteFile(w http.ResponseWriter, r *http.Request) {
var request struct {
Path string `json:"path" binding:"required"`
}
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
}
// Delete file via filer
err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
_, err := client.DeleteEntry(context.Background(), &filer_pb.DeleteEntryRequest{
Directory: path.Dir(request.Path),
Name: path.Base(request.Path),
IsDeleteData: true,
IsRecursive: true,
IgnoreRecursiveError: false,
})
return err
})
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to delete file: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "File deleted successfully"})
}
// DeleteMultipleFiles handles multiple file deletion API requests
func (h *FileBrowserHandlers) DeleteMultipleFiles(w http.ResponseWriter, r *http.Request) {
var request struct {
Paths []string `json:"paths" binding:"required"`
}
if err := decodeJSONBody(newJSONMaxReader(w, r), &request); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if len(request.Paths) == 0 {
writeJSONError(w, http.StatusBadRequest, "No paths provided")
return
}
for _, p := range request.Paths {
if strings.TrimSpace(p) == "" {
writeJSONError(w, http.StatusBadRequest, "path is required")
return
}
}
var deletedCount int
var failedCount int
var errors []string
// Delete each file/folder
for _, p := range request.Paths {
err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
_, err := client.DeleteEntry(context.Background(), &filer_pb.DeleteEntryRequest{
Directory: path.Dir(p),
Name: path.Base(p),
IsDeleteData: true,
IsRecursive: true,
IgnoreRecursiveError: false,
})
return err
})
if err != nil {
failedCount++
errors = append(errors, fmt.Sprintf("%s: %v", p, 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)
}
writeJSON(w, http.StatusOK, response)
} else {
response["message"] = "Failed to delete all selected items"
writeJSON(w, http.StatusInternalServerError, response)
}
}
// CreateFolder handles folder creation requests
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 := 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, "\\") {
writeJSONError(w, http.StatusBadRequest, "Invalid folder name")
return
}
// Create full path for new folder
base := "/" + strings.TrimPrefix(request.Path, "/")
fullPath := path.Join(base, folderName)
// Create folder via filer
err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
_, err := client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
Directory: path.Dir(fullPath),
Entry: &filer_pb.Entry{
Name: path.Base(fullPath),
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{
FileMode: uint32(0o755 | os.ModeDir), // Directory mode
Uid: filer_pb.OS_UID,
Gid: filer_pb.OS_GID,
Crtime: time.Now().Unix(),
Mtime: time.Now().Unix(),
TtlSec: 0,
},
},
})
return err
})
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to create folder: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Folder created successfully"})
}
// UploadFile handles file upload requests
func (h *FileBrowserHandlers) UploadFile(w http.ResponseWriter, r *http.Request) {
// Get the current path
currentPath := r.FormValue("path")
if currentPath == "" {
currentPath = "/"
}
// Parse multipart form
err := r.ParseMultipartForm(1 << 30) // 1GB max memory for large file uploads
if err != nil {
writeJSONError(w, http.StatusBadRequest, "Failed to parse multipart form: "+err.Error())
return
}
// Get uploaded files (supports multiple files)
files := r.MultipartForm.File["files"]
if len(files) == 0 {
writeJSONError(w, http.StatusBadRequest, "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
}
// Normalize Windows-style backslashes to forward slashes
fileName = util.CleanWindowsPath(fileName)
// Create full path for the file using path.Join for URL path semantics
// path.Join handles double slashes and is not OS-specific like filepath.Join
fullPath := path.Join(currentPath, fileName)
if !strings.HasPrefix(fullPath, "/") {
fullPath = "/" + fullPath
}
// Upload file to filer
err = h.uploadFileToFiler(r.Context(), fullPath, fileHeader)
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))
}
writeJSON(w, http.StatusOK, response)
} else {
response["message"] = "All file uploads failed"
writeJSON(w, http.StatusInternalServerError, response)
}
}
// uploadFileToFiler uploads a file to the cluster via filer gRPC + volume
// HTTP. This works whether or not the filer is running with -disableHttp=true,
// since the bytes never traverse the filer's HTTP listener. The multipart
// file is streamed through the chunked uploader, so the admin process never
// buffers the entire payload in memory. The caller passes the request
// context so a client disconnect cancels the in-flight chunk uploads instead
// of letting them run to completion against the volume servers.
func (h *FileBrowserHandlers) uploadFileToFiler(ctx context.Context, filePath string, fileHeader *multipart.FileHeader) error {
file, err := fileHeader.Open()
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
return h.uploadFileGrpc(ctx, filePath, fileHeader.Filename, fileHeader.Header.Get("Content-Type"), file)
}
// 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")
}
// Normalize Windows-style backslashes to forward slashes
filePath = util.CleanWindowsPath(filePath)
// Clean the path to remove any .. or . components
// Use path.Clean (not filepath.Clean) since this is a URL path
cleanPath := path.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")
}
return cleanPath, nil
}
// fetchFileContent fetches file content via the filer gRPC service. It is
// used for the "view as text" path, so the maxBytes cap matches the 1 MB
// limit the caller already applies before invoking us.
func (h *FileBrowserHandlers) fetchFileContent(filePath string, timeout time.Duration) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return h.fetchFileContentGrpc(ctx, filePath, 0)
}
// DownloadFile streams a file straight from the volume servers via the filer
// gRPC service, so the admin file browser keeps working even when the filer
// is started with -disableHttp=true.
func (h *FileBrowserHandlers) DownloadFile(w http.ResponseWriter, r *http.Request) {
filePath := r.URL.Query().Get("path")
if filePath == "" {
writeJSONError(w, http.StatusBadRequest, "File path is required")
return
}
inline := r.URL.Query().Get("inline") == "true"
tracker := &responseWriteTracker{ResponseWriter: w}
if err := h.downloadFileGrpc(r.Context(), filePath, tracker, inline); err != nil {
// Once bytes have been written we can't switch to a JSON error body
// without corrupting the partial response — log and stop. Before any
// write the response is still uncommitted, so a 502 with details is
// safe.
if tracker.committed {
glog.Errorf("Error streaming file download: %v", err)
return
}
writeJSONError(w, http.StatusBadGateway, "Failed to fetch file: "+err.Error())
}
}
// responseWriteTracker wraps http.ResponseWriter to record whether the
// response has been committed (status line + headers sent). DownloadFile
// uses this instead of probing Header() so future header-setting code
// reorganization can't silently break the "did we already send bytes?"
// detection.
type responseWriteTracker struct {
http.ResponseWriter
committed bool
}
func (t *responseWriteTracker) WriteHeader(code int) {
t.committed = true
t.ResponseWriter.WriteHeader(code)
}
func (t *responseWriteTracker) Write(p []byte) (int, error) {
t.committed = true
return t.ResponseWriter.Write(p)
}
// ViewFile handles file viewing requests (for text files, images, etc.)
func (h *FileBrowserHandlers) ViewFile(w http.ResponseWriter, r *http.Request) {
filePath := r.URL.Query().Get("path")
if filePath == "" {
writeJSONError(w, http.StatusBadRequest, "File path is required")
return
}
// Get file metadata first
var fileEntry dash.FileEntry
err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
resp, err := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
Directory: path.Dir(filePath),
Name: path.Base(filePath),
})
if err != nil {
return err
}
entry := resp.Entry
if entry == nil {
return fmt.Errorf("file not found")
}
// Convert to FileEntry
var modTime time.Time
if entry.Attributes != nil && entry.Attributes.Mtime > 0 {
modTime = time.Unix(entry.Attributes.Mtime, 0)
}
var size int64
if entry.Attributes != nil {
size = int64(entry.Attributes.FileSize)
}
mime := dash.ResolveEntryMime(entry)
fileEntry = dash.FileEntry{
Name: entry.Name,
FullPath: filePath,
IsDirectory: entry.IsDirectory,
Size: size,
ModTime: modTime,
Mime: mime,
}
return nil
})
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to get file metadata: "+err.Error())
return
}
// Check if file is viewable as text
var content string
var viewable bool
var reason string
// First check if it's a known text type or if we should check content
isKnownTextType := strings.HasPrefix(fileEntry.Mime, "text/") ||
fileEntry.Mime == "application/json" ||
fileEntry.Mime == "application/javascript" ||
fileEntry.Mime == "application/xml"
// For unknown types, check if it might be text by content
if !isKnownTextType && fileEntry.Mime == "application/octet-stream" {
isKnownTextType = h.isLikelyTextFile(filePath, 512)
if isKnownTextType {
// Update MIME type for better display
fileEntry.Mime = "text/plain"
}
}
if isKnownTextType {
// Limit text file size for viewing (max 1MB)
if fileEntry.Size > 1024*1024 {
viewable = false
reason = "File too large for viewing (>1MB)"
} else {
// Fetch file content from filer
var err error
content, err = h.fetchFileContent(filePath, 30*time.Second)
if err != nil {
reason = err.Error()
}
viewable = (err == nil)
}
} else {
// Not a text file, but might be viewable as image or PDF
if strings.HasPrefix(fileEntry.Mime, "image/") || fileEntry.Mime == "application/pdf" {
viewable = true
} else {
viewable = false
reason = "File type not supported for viewing"
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"file": fileEntry,
"content": content,
"viewable": viewable,
"reason": reason,
})
}
// GetFileProperties handles file properties requests
func (h *FileBrowserHandlers) GetFileProperties(w http.ResponseWriter, r *http.Request) {
filePath := r.URL.Query().Get("path")
if filePath == "" {
writeJSONError(w, http.StatusBadRequest, "File path is required")
return
}
// Get detailed file information from filer
var properties map[string]interface{}
err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
resp, err := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
Directory: path.Dir(filePath),
Name: path.Base(filePath),
})
if err != nil {
return err
}
entry := resp.Entry
if entry == nil {
return fmt.Errorf("file not found")
}
properties = make(map[string]interface{})
properties["name"] = entry.Name
properties["full_path"] = filePath
properties["is_directory"] = entry.IsDirectory
if entry.Attributes != nil {
properties["size"] = entry.Attributes.FileSize
properties["size_formatted"] = h.formatBytes(int64(entry.Attributes.FileSize))
if entry.Attributes.Mtime > 0 {
modTime := time.Unix(entry.Attributes.Mtime, 0)
properties["modified_time"] = modTime.Format("2006-01-02 15:04:05")
properties["modified_timestamp"] = entry.Attributes.Mtime
}
if entry.Attributes.Crtime > 0 {
createTime := time.Unix(entry.Attributes.Crtime, 0)
properties["created_time"] = createTime.Format("2006-01-02 15:04:05")
properties["created_timestamp"] = entry.Attributes.Crtime
}
properties["file_mode"] = dash.FormatFileMode(entry.Attributes.FileMode)
properties["file_mode_formatted"] = dash.FormatFileMode(entry.Attributes.FileMode)
properties["file_mode_octal"] = fmt.Sprintf("%o", entry.Attributes.FileMode)
properties["uid"] = entry.Attributes.Uid
properties["gid"] = entry.Attributes.Gid
properties["ttl_seconds"] = entry.Attributes.TtlSec
if entry.Attributes.TtlSec > 0 {
properties["ttl_formatted"] = fmt.Sprintf("%d seconds", entry.Attributes.TtlSec)
}
}
// Get extended attributes
if entry.Extended != nil {
extended := make(map[string]string)
for key, value := range entry.Extended {
extended[key] = string(value)
}
properties["extended"] = extended
}
// Get chunk information for files
if !entry.IsDirectory && len(entry.Chunks) > 0 {
chunks := make([]map[string]interface{}, 0, len(entry.Chunks))
for _, chunk := range entry.Chunks {
chunkInfo := map[string]interface{}{
"file_id": chunk.FileId,
"offset": chunk.Offset,
"size": chunk.Size,
"modified_ts": chunk.ModifiedTsNs,
"e_tag": chunk.ETag,
"source_fid": chunk.SourceFileId,
}
chunks = append(chunks, chunkInfo)
}
properties["chunks"] = chunks
properties["chunk_count"] = len(entry.Chunks)
}
if !entry.IsDirectory {
properties["mime_type"] = dash.ResolveEntryMime(entry)
}
return nil
})
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to get file properties: "+err.Error())
return
}
writeJSON(w, http.StatusOK, properties)
}
// ExportMetadata streams a file or folder's metadata as a gzipped, length-prefixed
// FullEntry stream — the format weed shell fs.meta.load reads. Directories are
// walked recursively via the filer BFS metadata stream.
func (h *FileBrowserHandlers) ExportMetadata(w http.ResponseWriter, r *http.Request) {
filePath := r.URL.Query().Get("path")
if filePath == "" {
writeJSONError(w, http.StatusBadRequest, "File path is required")
return
}
cleanPath, err := h.validateAndCleanFilePath(filePath)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
tracker := &responseWriteTracker{ResponseWriter: w}
err = h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
stream, err := client.TraverseBfsMetadata(r.Context(), &filer_pb.TraverseBfsMetadataRequest{
Directory: cleanPath,
ExcludedPrefixes: []string{filer.SystemLogDir},
})
if err != nil {
return err
}
// Read the first entry before sending headers so a bad path returns a clean error.
first, err := stream.Recv()
if err != nil {
return err
}
downloadName := path.Base(cleanPath)
if downloadName == "/" || downloadName == "." || downloadName == "" {
downloadName = "root"
}
tracker.Header().Set("Content-Type", "application/gzip")
tracker.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": downloadName + ".meta.gz"}))
tracker.WriteHeader(http.StatusOK)
bw := bufio.NewWriter(tracker)
gw := gzip.NewWriter(bw)
sizeBuf := make([]byte, 4)
writeEntry := func(resp *filer_pb.TraverseBfsMetadataResponse) error {
b, err := proto.Marshal(&filer_pb.FullEntry{Dir: resp.Directory, Entry: resp.Entry})
if err != nil {
return err
}
util.Uint32toBytes(sizeBuf, uint32(len(b)))
if _, err := gw.Write(sizeBuf); err != nil {
return err
}
_, err = gw.Write(b)
return err
}
if err := writeEntry(first); err != nil {
return err
}
for {
resp, recvErr := stream.Recv()
if recvErr == io.EOF {
break
}
if recvErr != nil {
return recvErr
}
if err := writeEntry(resp); err != nil {
return err
}
}
if err := gw.Close(); err != nil {
return err
}
return bw.Flush()
})
if err != nil {
if tracker.committed {
glog.Errorf("Error exporting metadata for %s: %v", cleanPath, err)
return
}
writeJSONError(w, http.StatusInternalServerError, "Failed to export metadata: "+err.Error())
}
}
// Helper function to format bytes
func (h *FileBrowserHandlers) formatBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
// Helper function to check if a file is likely a text file by checking content
func (h *FileBrowserHandlers) isLikelyTextFile(filePath string, maxCheckSize int64) bool {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
content, err := h.fetchFileContentGrpc(ctx, filePath, int(maxCheckSize))
if err != nil {
return false
}
if len(content) == 0 {
return true
}
return h.isPrintableText([]byte(content))
}
// Helper function to check if content is printable text
func (h *FileBrowserHandlers) isPrintableText(data []byte) bool {
if len(data) == 0 {
return true
}
// Count printable characters
printable := 0
for _, b := range data {
if b >= 32 && b <= 126 || b == 9 || b == 10 || b == 13 {
// Printable ASCII, tab, newline, carriage return
printable++
} else if b >= 128 {
// Potential UTF-8 character
printable++
}
}
// If more than 95% of characters are printable, consider it text
return float64(printable)/float64(len(data)) > 0.95
}
// Helper function for min
func min(a, b int64) int64 {
if a < b {
return a
}
return b
}