viewer, download, properties
This commit is contained in:
@@ -445,3 +445,497 @@ func (h *FileBrowserHandlers) validateAndCleanFilePath(filePath string) (string,
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -90,6 +90,9 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
|
||||
filesApi.DELETE("/delete-multiple", h.fileBrowserHandlers.DeleteMultipleFiles)
|
||||
filesApi.POST("/create-folder", h.fileBrowserHandlers.CreateFolder)
|
||||
filesApi.POST("/upload", h.fileBrowserHandlers.UploadFile)
|
||||
filesApi.GET("/download", h.fileBrowserHandlers.DownloadFile)
|
||||
filesApi.GET("/view", h.fileBrowserHandlers.ViewFile)
|
||||
filesApi.GET("/properties", h.fileBrowserHandlers.GetFileProperties)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -137,6 +140,9 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
|
||||
filesApi.DELETE("/delete-multiple", h.fileBrowserHandlers.DeleteMultipleFiles)
|
||||
filesApi.POST("/create-folder", h.fileBrowserHandlers.CreateFolder)
|
||||
filesApi.POST("/upload", h.fileBrowserHandlers.UploadFile)
|
||||
filesApi.GET("/download", h.fileBrowserHandlers.DownloadFile)
|
||||
filesApi.GET("/view", h.fileBrowserHandlers.ViewFile)
|
||||
filesApi.GET("/properties", h.fileBrowserHandlers.GetFileProperties)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user