mirror of
https://github.com/chrislusf/seaweedfs
synced 2025-07-05 11:12:47 +02:00
941 lines
25 KiB
Go
941 lines
25 KiB
Go
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: filer_pb.OS_UID,
|
|
Gid: filer_pb.OS_GID,
|
|
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
|
|
}
|
|
|
|
// DownloadFile handles file download requests
|
|
func (h *FileBrowserHandlers) DownloadFile(c *gin.Context) {
|
|
filePath := c.Query("path")
|
|
if filePath == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "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"})
|
|
return
|
|
}
|
|
|
|
// 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()})
|
|
return
|
|
}
|
|
|
|
// Create the download URL
|
|
downloadURL := fmt.Sprintf("http://%s%s", filerAddress, cleanFilePath)
|
|
|
|
// Set headers for file download
|
|
fileName := filepath.Base(cleanFilePath)
|
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
|
|
c.Header("Content-Type", "application/octet-stream")
|
|
|
|
// Proxy the request to filer
|
|
c.Redirect(http.StatusFound, downloadURL)
|
|
}
|
|
|
|
// ViewFile handles file viewing requests (for text files, images, etc.)
|
|
func (h *FileBrowserHandlers) ViewFile(c *gin.Context) {
|
|
filePath := c.Query("path")
|
|
if filePath == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "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: filepath.Dir(filePath),
|
|
Name: filepath.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)
|
|
}
|
|
|
|
// Determine MIME type with comprehensive extension support
|
|
mime := h.determineMimeType(entry.Name)
|
|
|
|
fileEntry = dash.FileEntry{
|
|
Name: entry.Name,
|
|
FullPath: filePath,
|
|
IsDirectory: entry.IsDirectory,
|
|
Size: size,
|
|
ModTime: modTime,
|
|
Mime: mime,
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "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 {
|
|
// Get file content from filer
|
|
filerAddress := h.adminServer.GetFilerAddress()
|
|
if filerAddress != "" {
|
|
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
|
|
if err == nil {
|
|
fileURL := fmt.Sprintf("http://%s%s", filerAddress, cleanFilePath)
|
|
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := client.Get(fileURL)
|
|
if err == nil && resp.StatusCode == http.StatusOK {
|
|
defer resp.Body.Close()
|
|
contentBytes, err := io.ReadAll(resp.Body)
|
|
if err == nil {
|
|
content = string(contentBytes)
|
|
viewable = true
|
|
} else {
|
|
viewable = false
|
|
reason = "Failed to read file content"
|
|
}
|
|
} else {
|
|
viewable = false
|
|
reason = "Failed to fetch file from filer"
|
|
}
|
|
} else {
|
|
viewable = false
|
|
reason = "Invalid file path"
|
|
}
|
|
} else {
|
|
viewable = false
|
|
reason = "Filer address not configured"
|
|
}
|
|
}
|
|
} 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"
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"file": fileEntry,
|
|
"content": content,
|
|
"viewable": viewable,
|
|
"reason": reason,
|
|
})
|
|
}
|
|
|
|
// GetFileProperties handles file properties requests
|
|
func (h *FileBrowserHandlers) GetFileProperties(c *gin.Context) {
|
|
filePath := c.Query("path")
|
|
if filePath == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "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: filepath.Dir(filePath),
|
|
Name: filepath.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"] = fmt.Sprintf("%o", entry.Attributes.FileMode)
|
|
properties["file_mode_formatted"] = h.formatFileMode(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)
|
|
}
|
|
|
|
// Determine MIME type
|
|
if !entry.IsDirectory {
|
|
mime := h.determineMimeType(entry.Name)
|
|
properties["mime_type"] = mime
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get file properties: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, properties)
|
|
}
|
|
|
|
// 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 format file mode
|
|
func (h *FileBrowserHandlers) formatFileMode(mode uint32) string {
|
|
// Convert to octal and format as rwx permissions
|
|
perm := mode & 0777
|
|
return fmt.Sprintf("%03o", perm)
|
|
}
|
|
|
|
// Helper function to determine MIME type from filename
|
|
func (h *FileBrowserHandlers) determineMimeType(filename string) string {
|
|
ext := strings.ToLower(filepath.Ext(filename))
|
|
|
|
// Text files
|
|
switch ext {
|
|
case ".txt", ".log", ".cfg", ".conf", ".ini", ".properties":
|
|
return "text/plain"
|
|
case ".md", ".markdown":
|
|
return "text/markdown"
|
|
case ".html", ".htm":
|
|
return "text/html"
|
|
case ".css":
|
|
return "text/css"
|
|
case ".js", ".mjs":
|
|
return "application/javascript"
|
|
case ".ts":
|
|
return "text/typescript"
|
|
case ".json":
|
|
return "application/json"
|
|
case ".xml":
|
|
return "application/xml"
|
|
case ".yaml", ".yml":
|
|
return "text/yaml"
|
|
case ".csv":
|
|
return "text/csv"
|
|
case ".sql":
|
|
return "text/sql"
|
|
case ".sh", ".bash", ".zsh", ".fish":
|
|
return "text/x-shellscript"
|
|
case ".py":
|
|
return "text/x-python"
|
|
case ".go":
|
|
return "text/x-go"
|
|
case ".java":
|
|
return "text/x-java"
|
|
case ".c":
|
|
return "text/x-c"
|
|
case ".cpp", ".cc", ".cxx", ".c++":
|
|
return "text/x-c++"
|
|
case ".h", ".hpp":
|
|
return "text/x-c-header"
|
|
case ".php":
|
|
return "text/x-php"
|
|
case ".rb":
|
|
return "text/x-ruby"
|
|
case ".pl":
|
|
return "text/x-perl"
|
|
case ".rs":
|
|
return "text/x-rust"
|
|
case ".swift":
|
|
return "text/x-swift"
|
|
case ".kt":
|
|
return "text/x-kotlin"
|
|
case ".scala":
|
|
return "text/x-scala"
|
|
case ".dockerfile":
|
|
return "text/x-dockerfile"
|
|
case ".gitignore", ".gitattributes":
|
|
return "text/plain"
|
|
case ".env":
|
|
return "text/plain"
|
|
|
|
// Image files
|
|
case ".jpg", ".jpeg":
|
|
return "image/jpeg"
|
|
case ".png":
|
|
return "image/png"
|
|
case ".gif":
|
|
return "image/gif"
|
|
case ".bmp":
|
|
return "image/bmp"
|
|
case ".webp":
|
|
return "image/webp"
|
|
case ".svg":
|
|
return "image/svg+xml"
|
|
case ".ico":
|
|
return "image/x-icon"
|
|
|
|
// Document files
|
|
case ".pdf":
|
|
return "application/pdf"
|
|
case ".doc":
|
|
return "application/msword"
|
|
case ".docx":
|
|
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
case ".xls":
|
|
return "application/vnd.ms-excel"
|
|
case ".xlsx":
|
|
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
case ".ppt":
|
|
return "application/vnd.ms-powerpoint"
|
|
case ".pptx":
|
|
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
|
|
// Archive files
|
|
case ".zip":
|
|
return "application/zip"
|
|
case ".tar":
|
|
return "application/x-tar"
|
|
case ".gz":
|
|
return "application/gzip"
|
|
case ".bz2":
|
|
return "application/x-bzip2"
|
|
case ".7z":
|
|
return "application/x-7z-compressed"
|
|
case ".rar":
|
|
return "application/x-rar-compressed"
|
|
|
|
// Video files
|
|
case ".mp4":
|
|
return "video/mp4"
|
|
case ".avi":
|
|
return "video/x-msvideo"
|
|
case ".mov":
|
|
return "video/quicktime"
|
|
case ".wmv":
|
|
return "video/x-ms-wmv"
|
|
case ".flv":
|
|
return "video/x-flv"
|
|
case ".webm":
|
|
return "video/webm"
|
|
|
|
// Audio files
|
|
case ".mp3":
|
|
return "audio/mpeg"
|
|
case ".wav":
|
|
return "audio/wav"
|
|
case ".flac":
|
|
return "audio/flac"
|
|
case ".aac":
|
|
return "audio/aac"
|
|
case ".ogg":
|
|
return "audio/ogg"
|
|
|
|
default:
|
|
// For files without extension or unknown extensions,
|
|
// we'll check if they might be text files by content
|
|
return "application/octet-stream"
|
|
}
|
|
}
|
|
|
|
// Helper function to check if a file is likely a text file by checking content
|
|
func (h *FileBrowserHandlers) isLikelyTextFile(filePath string, maxCheckSize int64) bool {
|
|
filerAddress := h.adminServer.GetFilerAddress()
|
|
if filerAddress == "" {
|
|
return false
|
|
}
|
|
|
|
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
fileURL := fmt.Sprintf("http://%s%s", filerAddress, cleanFilePath)
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Get(fileURL)
|
|
if err != nil || resp.StatusCode != http.StatusOK {
|
|
return false
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Read first few bytes to check if it's text
|
|
buffer := make([]byte, min(maxCheckSize, 512))
|
|
n, err := resp.Body.Read(buffer)
|
|
if err != nil && err != io.EOF {
|
|
return false
|
|
}
|
|
|
|
if n == 0 {
|
|
return true // Empty file can be considered text
|
|
}
|
|
|
|
// Check if content is printable text
|
|
return h.isPrintableText(buffer[:n])
|
|
}
|
|
|
|
// 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
|
|
}
|