From 76d773bf882c3e67e703a5fa755ad98d30697e1a Mon Sep 17 00:00:00 2001 From: chrislu Date: Tue, 1 Jul 2025 21:27:38 -0700 Subject: [PATCH] viewer, download, properties --- weed/admin/handlers/file_browser_handlers.go | 494 +++++++++++++++++++ weed/admin/handlers/handlers.go | 6 + weed/admin/static/js/admin.js | 372 +++++++++++++- 3 files changed, 864 insertions(+), 8 deletions(-) diff --git a/weed/admin/handlers/file_browser_handlers.go b/weed/admin/handlers/file_browser_handlers.go index 1ddd6781e..97621192e 100644 --- a/weed/admin/handlers/file_browser_handlers.go +++ b/weed/admin/handlers/file_browser_handlers.go @@ -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 +} diff --git a/weed/admin/handlers/handlers.go b/weed/admin/handlers/handlers.go index d57fb0d14..ba1cf8e0d 100644 --- a/weed/admin/handlers/handlers.go +++ b/weed/admin/handlers/handlers.go @@ -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) } } } diff --git a/weed/admin/static/js/admin.js b/weed/admin/static/js/admin.js index a3cb09af7..caca57a12 100644 --- a/weed/admin/static/js/admin.js +++ b/weed/admin/static/js/admin.js @@ -1331,21 +1331,49 @@ function exportFileList() { // Download file function downloadFile(filePath) { - // Create download link using filer direct access - const downloadUrl = `/files/download?path=${encodeURIComponent(filePath)}`; + // Create download link using admin API + const downloadUrl = `/api/files/download?path=${encodeURIComponent(filePath)}`; window.open(downloadUrl, '_blank'); } // View file -function viewFile(filePath) { - // TODO: Implement file viewer functionality - showAlert('info', `File viewer for ${filePath} will be implemented`); +async function viewFile(filePath) { + try { + const response = await fetch(`/api/files/view?path=${encodeURIComponent(filePath)}`); + + if (!response.ok) { + const error = await response.json(); + showAlert('error', `Failed to view file: ${error.error || 'Unknown error'}`); + return; + } + + const data = await response.json(); + showFileViewer(data); + + } catch (error) { + console.error('View file error:', error); + showAlert('error', 'Failed to view file'); + } } // Show file properties -function showProperties(filePath) { - // TODO: Implement file properties modal - showAlert('info', `Properties for ${filePath} will be implemented`); +async function showProperties(filePath) { + try { + const response = await fetch(`/api/files/properties?path=${encodeURIComponent(filePath)}`); + + if (!response.ok) { + const error = await response.json(); + showAlert('error', `Failed to get file properties: ${error.error || 'Unknown error'}`); + return; + } + + const properties = await response.json(); + showPropertiesModal(properties); + + } catch (error) { + console.error('Properties error:', error); + showAlert('error', 'Failed to get file properties'); + } } // Confirm delete file/folder @@ -1711,4 +1739,332 @@ async function handleUpdateQuota(event) { } } +// Show file viewer modal +function showFileViewer(data) { + const file = data.file; + const content = data.content || ''; + const viewable = data.viewable !== false; + + // Create modal HTML + const modalHtml = ` + + `; + + // Remove existing modal if any + const existingModal = document.getElementById('fileViewerModal'); + if (existingModal) { + existingModal.remove(); + } + + // Add modal to DOM + document.body.insertAdjacentHTML('beforeend', modalHtml); + + // Show modal + const modal = new bootstrap.Modal(document.getElementById('fileViewerModal')); + modal.show(); + + // Clean up when modal is hidden + document.getElementById('fileViewerModal').addEventListener('hidden.bs.modal', function () { + this.remove(); + }); +} + +// Create file viewer content based on file type +function createFileViewerContent(file, content) { + if (file.mime.startsWith('image/')) { + return ` +
+ ${file.name} +
+ `; + } else if (file.mime.startsWith('text/') || file.mime === 'application/json' || file.mime === 'application/javascript') { + const language = getLanguageFromMime(file.mime, file.name); + return ` +
+ + + Size: ${formatBytes(file.size)} | Type: ${file.mime} + +
+
${escapeHtml(content)}
+ `; + } else if (file.mime === 'application/pdf') { + return ` +
+ +
+ `; + } else { + return createNonViewableContent('This file type cannot be previewed in the browser.'); + } +} + +// Create non-viewable content message +function createNonViewableContent(reason) { + return ` +
+ +
Cannot preview file
+

${reason}

+
+ `; +} + +// Get language for syntax highlighting +function getLanguageFromMime(mime, filename) { + // First check MIME type + switch (mime) { + case 'application/json': return 'json'; + case 'application/javascript': return 'javascript'; + case 'text/html': return 'html'; + case 'text/css': return 'css'; + case 'application/xml': return 'xml'; + case 'text/typescript': return 'typescript'; + case 'text/x-python': return 'python'; + case 'text/x-go': return 'go'; + case 'text/x-java': return 'java'; + case 'text/x-c': return 'c'; + case 'text/x-c++': return 'cpp'; + case 'text/x-c-header': return 'c'; + case 'text/x-shellscript': return 'bash'; + case 'text/x-php': return 'php'; + case 'text/x-ruby': return 'ruby'; + case 'text/x-perl': return 'perl'; + case 'text/x-rust': return 'rust'; + case 'text/x-swift': return 'swift'; + case 'text/x-kotlin': return 'kotlin'; + case 'text/x-scala': return 'scala'; + case 'text/x-dockerfile': return 'dockerfile'; + case 'text/yaml': return 'yaml'; + case 'text/csv': return 'csv'; + case 'text/sql': return 'sql'; + case 'text/markdown': return 'markdown'; + } + + // Fallback to file extension + const ext = filename.split('.').pop().toLowerCase(); + switch (ext) { + case 'js': case 'mjs': return 'javascript'; + case 'ts': return 'typescript'; + case 'py': return 'python'; + case 'go': return 'go'; + case 'java': return 'java'; + case 'cpp': case 'cc': case 'cxx': case 'c++': return 'cpp'; + case 'c': return 'c'; + case 'h': case 'hpp': return 'c'; + case 'sh': case 'bash': case 'zsh': case 'fish': return 'bash'; + case 'php': return 'php'; + case 'rb': return 'ruby'; + case 'pl': return 'perl'; + case 'rs': return 'rust'; + case 'swift': return 'swift'; + case 'kt': return 'kotlin'; + case 'scala': return 'scala'; + case 'yml': case 'yaml': return 'yaml'; + case 'md': case 'markdown': return 'markdown'; + case 'sql': return 'sql'; + case 'csv': return 'csv'; + case 'dockerfile': return 'dockerfile'; + case 'gitignore': case 'gitattributes': return 'text'; + case 'env': return 'bash'; + case 'cfg': case 'conf': case 'ini': case 'properties': return 'ini'; + default: return 'text'; + } +} + +// Show properties modal +function showPropertiesModal(properties) { + // Create modal HTML + const modalHtml = ` + + `; + + // Remove existing modal if any + const existingModal = document.getElementById('propertiesModal'); + if (existingModal) { + existingModal.remove(); + } + + // Add modal to DOM + document.body.insertAdjacentHTML('beforeend', modalHtml); + + // Show modal + const modal = new bootstrap.Modal(document.getElementById('propertiesModal')); + modal.show(); + + // Clean up when modal is hidden + document.getElementById('propertiesModal').addEventListener('hidden.bs.modal', function () { + this.remove(); + }); +} + +// Create properties content +function createPropertiesContent(properties) { + let html = ` +
+
+
Basic Information
+ + + + + `; + + if (!properties.is_directory) { + html += ` + + + `; + } + + html += ` +
Name:${properties.name}
Full Path:${properties.full_path}
Type:${properties.is_directory ? 'Directory' : 'File'}
Size:${properties.size_formatted || formatBytes(properties.size || 0)}
MIME Type:${properties.mime_type || 'Unknown'}
+
+
+
Timestamps
+ + `; + + if (properties.modified_time) { + html += ``; + } + if (properties.created_time) { + html += ``; + } + + html += ` +
Modified:${properties.modified_time}
Created:${properties.created_time}
+ +
Permissions
+ + + + +
Mode:${properties.file_mode_formatted || properties.file_mode}
UID:${properties.uid || 'N/A'}
GID:${properties.gid || 'N/A'}
+
+
+ `; + + // Add TTL information if available + if (properties.ttl_seconds && properties.ttl_seconds > 0) { + html += ` +
+
+
TTL (Time To Live)
+ + +
TTL:${properties.ttl_formatted || properties.ttl_seconds + ' seconds'}
+
+
+ `; + } + + // Add chunk information if available + if (properties.chunks && properties.chunks.length > 0) { + html += ` +
+
+
Chunks (${properties.chunk_count})
+
+ + + + + + + + + + + `; + + properties.chunks.forEach(chunk => { + html += ` + + + + + + + `; + }); + + html += ` + +
File IDOffsetSizeETag
${chunk.file_id}${formatBytes(chunk.offset)}${formatBytes(chunk.size)}${chunk.e_tag || 'N/A'}
+
+
+
+ `; + } + + // Add extended attributes if available + if (properties.extended && Object.keys(properties.extended).length > 0) { + html += ` +
+
+
Extended Attributes
+ + `; + + Object.entries(properties.extended).forEach(([key, value]) => { + html += ``; + }); + + html += ` +
${key}:${value}
+
+
+ `; + } + + return html; +} + +// Utility function to escape HTML +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + \ No newline at end of file