* fix: use path instead of filepath to handle urls in weed admin file browser * test: add comprehensive tests for file browser path handling - Test breadcrumb generation for various path scenarios - Test path handling with forward slashes (URL compatibility) - Test parent path calculation for Windows compatibility - Test file extension handling using path.Ext - Test bucket path detection logic These tests verify that the switch from filepath to path package works correctly and handles URLs properly across all platforms. * refactor: simplify fullPath construction using path.Join Replace verbose manual path construction with path.Join which: - Handles trailing slashes automatically - Is more concise and readable - Is more robust for edge cases * fix: normalize path in ShowFileBrowser and rename generateBreadcrumbs parameter Critical fix: - Add util.CleanWindowsPath() normalization to path parameter in ShowFileBrowser handler, matching the pattern used in other file operation handlers (lines 273, 464) - This ensures Windows-style backslashes are converted to forward slashes before processing, fixing path handling issues on Windows Consistency improvement: - Rename path parameter to dir in generateBreadcrumbs function - Aligns with parameter rename in GetFileBrowser for consistent naming throughout the file * test: improve coverage for Windows path handling and production code behavior Address reviewer feedback by enhancing test quality: 1. Improved test documentation: - Added clear comments explaining what each test validates - Clarified that some tests validate expected behavior vs production code - Documented the Windows path normalization flow 2. Enhanced actual production code testing: - TestGenerateBreadcrumbs: Calls actual production function - TestBreadcrumbPathFormatting: Validates production output format - TestDirectoryNavigation: Integration-style test for complete flow 3. Added new test functions for better coverage: - TestPathJoinHandlesEdgeCases: Verifies path.Join behavior - TestWindowsPathNormalizationBehavior: Documents expected normalization - TestDirectoryNavigation: Complete navigation flow test 4. Improved test organization: - Fixed duplicate field naming issues - Better test names for clarity - More comprehensive edge case coverage These improvements ensure the fix for issue #7628 (Windows path handling) is properly validated across the complete flow from handler to path logic. * test: use actual util.CleanWindowsPath function in Windows path normalization test Address reviewer feedback by testing the actual production function: - Import util package for CleanWindowsPath - Call the real util.CleanWindowsPath() instead of reimplementing logic - Ensures test validates actual implementation, not just expected behavior - Added more test cases for edge cases (simple path, deep nesting) This change validates that the Windows path normalization in the ShowFileBrowser handler (handlers/file_browser_handlers.go:64) works correctly with the actual util.CleanWindowsPath function. * style: fix indentation in TestPathJoinHandlesEdgeCases Align t.Errorf statement inside the if block with proper indentation. The error message now correctly aligns with the if block body, maintaining consistent indentation throughout the function. * test: restore backslash validation check in TestPathJoinHandlesEdgeCases --------- Co-authored-by: Chris Lu <chris.lu@gmail.com>
269 lines
6.4 KiB
Go
269 lines
6.4 KiB
Go
package dash
|
|
|
|
import (
|
|
"context"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
)
|
|
|
|
// FileEntry represents a file or directory entry in the file browser
|
|
type FileEntry struct {
|
|
Name string `json:"name"`
|
|
FullPath string `json:"full_path"`
|
|
IsDirectory bool `json:"is_directory"`
|
|
Size int64 `json:"size"`
|
|
ModTime time.Time `json:"mod_time"`
|
|
Mode string `json:"mode"`
|
|
Uid uint32 `json:"uid"`
|
|
Gid uint32 `json:"gid"`
|
|
Mime string `json:"mime"`
|
|
Replication string `json:"replication"`
|
|
Collection string `json:"collection"`
|
|
TtlSec int32 `json:"ttl_sec"`
|
|
}
|
|
|
|
// BreadcrumbItem represents a single breadcrumb in the navigation
|
|
type BreadcrumbItem struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
// FileBrowserData contains all data needed for the file browser view
|
|
type FileBrowserData struct {
|
|
Username string `json:"username"`
|
|
CurrentPath string `json:"current_path"`
|
|
ParentPath string `json:"parent_path"`
|
|
Breadcrumbs []BreadcrumbItem `json:"breadcrumbs"`
|
|
Entries []FileEntry `json:"entries"`
|
|
TotalEntries int `json:"total_entries"`
|
|
TotalSize int64 `json:"total_size"`
|
|
LastUpdated time.Time `json:"last_updated"`
|
|
IsBucketPath bool `json:"is_bucket_path"`
|
|
BucketName string `json:"bucket_name"`
|
|
}
|
|
|
|
// GetFileBrowser retrieves file browser data for a given path
|
|
func (s *AdminServer) GetFileBrowser(dir string) (*FileBrowserData, error) {
|
|
if dir == "" {
|
|
dir = "/"
|
|
}
|
|
|
|
var entries []FileEntry
|
|
var totalSize int64
|
|
|
|
// Get directory listing from filer
|
|
err := s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
|
stream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{
|
|
Directory: dir,
|
|
Prefix: "",
|
|
Limit: 1000,
|
|
InclusiveStartFrom: false,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for {
|
|
resp, err := stream.Recv()
|
|
if err != nil {
|
|
if err.Error() == "EOF" {
|
|
break
|
|
}
|
|
return err
|
|
}
|
|
|
|
entry := resp.Entry
|
|
if entry == nil {
|
|
continue
|
|
}
|
|
|
|
fullPath := path.Join(dir, entry.Name)
|
|
|
|
var modTime time.Time
|
|
if entry.Attributes != nil && entry.Attributes.Mtime > 0 {
|
|
modTime = time.Unix(entry.Attributes.Mtime, 0)
|
|
}
|
|
|
|
var mode string
|
|
var uid, gid uint32
|
|
var size int64
|
|
var replication, collection string
|
|
var ttlSec int32
|
|
|
|
if entry.Attributes != nil {
|
|
mode = FormatFileMode(entry.Attributes.FileMode)
|
|
uid = entry.Attributes.Uid
|
|
gid = entry.Attributes.Gid
|
|
size = int64(entry.Attributes.FileSize)
|
|
ttlSec = entry.Attributes.TtlSec
|
|
}
|
|
|
|
// Get replication and collection from entry extended attributes or chunks
|
|
if entry.Extended != nil {
|
|
if repl, ok := entry.Extended["replication"]; ok {
|
|
replication = string(repl)
|
|
}
|
|
if coll, ok := entry.Extended["collection"]; ok {
|
|
collection = string(coll)
|
|
}
|
|
}
|
|
|
|
// Determine MIME type based on file extension
|
|
mime := "application/octet-stream"
|
|
if entry.IsDirectory {
|
|
mime = "inode/directory"
|
|
} else {
|
|
ext := strings.ToLower(path.Ext(entry.Name))
|
|
switch ext {
|
|
case ".txt", ".log":
|
|
mime = "text/plain"
|
|
case ".html", ".htm":
|
|
mime = "text/html"
|
|
case ".css":
|
|
mime = "text/css"
|
|
case ".js":
|
|
mime = "application/javascript"
|
|
case ".json":
|
|
mime = "application/json"
|
|
case ".xml":
|
|
mime = "application/xml"
|
|
case ".pdf":
|
|
mime = "application/pdf"
|
|
case ".jpg", ".jpeg":
|
|
mime = "image/jpeg"
|
|
case ".png":
|
|
mime = "image/png"
|
|
case ".gif":
|
|
mime = "image/gif"
|
|
case ".svg":
|
|
mime = "image/svg+xml"
|
|
case ".mp4":
|
|
mime = "video/mp4"
|
|
case ".mp3":
|
|
mime = "audio/mpeg"
|
|
case ".zip":
|
|
mime = "application/zip"
|
|
case ".tar":
|
|
mime = "application/x-tar"
|
|
case ".gz":
|
|
mime = "application/gzip"
|
|
}
|
|
}
|
|
|
|
fileEntry := FileEntry{
|
|
Name: entry.Name,
|
|
FullPath: fullPath,
|
|
IsDirectory: entry.IsDirectory,
|
|
Size: size,
|
|
ModTime: modTime,
|
|
Mode: mode,
|
|
Uid: uid,
|
|
Gid: gid,
|
|
Mime: mime,
|
|
Replication: replication,
|
|
Collection: collection,
|
|
TtlSec: ttlSec,
|
|
}
|
|
|
|
entries = append(entries, fileEntry)
|
|
if !entry.IsDirectory {
|
|
totalSize += size
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Sort entries: directories first, then files, both alphabetically
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
if entries[i].IsDirectory != entries[j].IsDirectory {
|
|
return entries[i].IsDirectory
|
|
}
|
|
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
|
|
})
|
|
|
|
// Generate breadcrumbs
|
|
breadcrumbs := s.generateBreadcrumbs(dir)
|
|
|
|
// Calculate parent path
|
|
parentPath := "/"
|
|
if dir != "/" {
|
|
parentPath = path.Dir(dir)
|
|
if parentPath == "." {
|
|
parentPath = "/"
|
|
}
|
|
}
|
|
|
|
// Check if this is a bucket path
|
|
isBucketPath := false
|
|
bucketName := ""
|
|
if strings.HasPrefix(dir, "/buckets/") {
|
|
isBucketPath = true
|
|
pathParts := strings.Split(strings.Trim(dir, "/"), "/")
|
|
if len(pathParts) >= 2 {
|
|
bucketName = pathParts[1]
|
|
}
|
|
}
|
|
|
|
return &FileBrowserData{
|
|
CurrentPath: dir,
|
|
ParentPath: parentPath,
|
|
Breadcrumbs: breadcrumbs,
|
|
Entries: entries,
|
|
TotalEntries: len(entries),
|
|
TotalSize: totalSize,
|
|
LastUpdated: time.Now(),
|
|
IsBucketPath: isBucketPath,
|
|
BucketName: bucketName,
|
|
}, nil
|
|
}
|
|
|
|
// generateBreadcrumbs creates breadcrumb navigation for the current path
|
|
func (s *AdminServer) generateBreadcrumbs(dir string) []BreadcrumbItem {
|
|
var breadcrumbs []BreadcrumbItem
|
|
|
|
// Always start with root
|
|
breadcrumbs = append(breadcrumbs, BreadcrumbItem{
|
|
Name: "Root",
|
|
Path: "/",
|
|
})
|
|
|
|
if dir == "/" {
|
|
return breadcrumbs
|
|
}
|
|
|
|
// Split path and build breadcrumbs
|
|
parts := strings.Split(strings.Trim(dir, "/"), "/")
|
|
currentPath := ""
|
|
|
|
for _, part := range parts {
|
|
if part == "" {
|
|
continue
|
|
}
|
|
currentPath += "/" + part
|
|
|
|
// Special handling for bucket paths
|
|
displayName := part
|
|
if len(breadcrumbs) == 1 && part == "buckets" {
|
|
displayName = "Object Store Buckets"
|
|
} else if len(breadcrumbs) == 2 && strings.HasPrefix(dir, "/buckets/") {
|
|
displayName = "📦 " + part // Add bucket icon to bucket name
|
|
}
|
|
|
|
breadcrumbs = append(breadcrumbs, BreadcrumbItem{
|
|
Name: displayName,
|
|
Path: currentPath,
|
|
})
|
|
}
|
|
|
|
return breadcrumbs
|
|
}
|