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 = `
+
+
+
+
+
+ ${viewable ? createFileViewerContent(file, content) : createNonViewableContent(data.reason || 'File cannot be viewed')}
+
+
+
+
+
+ `;
+
+ // 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 `
+
+
})
+
+ `;
+ } 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 = `
+
+
+
+
+
+ ${createPropertiesContent(properties)}
+
+
+
+
+
+ `;
+
+ // 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
+
+ Name: | ${properties.name} |
+ Full Path: | ${properties.full_path} |
+ Type: | ${properties.is_directory ? 'Directory' : 'File'} |
+ `;
+
+ if (!properties.is_directory) {
+ html += `
+ Size: | ${properties.size_formatted || formatBytes(properties.size || 0)} |
+ MIME Type: | ${properties.mime_type || 'Unknown'} |
+ `;
+ }
+
+ html += `
+
+
+
+
Timestamps
+
+ `;
+
+ if (properties.modified_time) {
+ html += `Modified: | ${properties.modified_time} |
`;
+ }
+ if (properties.created_time) {
+ html += `Created: | ${properties.created_time} |
`;
+ }
+
+ html += `
+
+
+
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})
+
+
+
+
+ File ID |
+ Offset |
+ Size |
+ ETag |
+
+
+
+ `;
+
+ properties.chunks.forEach(chunk => {
+ html += `
+
+ ${chunk.file_id} |
+ ${formatBytes(chunk.offset)} |
+ ${formatBytes(chunk.size)} |
+ ${chunk.e_tag || 'N/A'} |
+
+ `;
+ });
+
+ html += `
+
+
+
+
+
+ `;
+ }
+
+ // 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 += `${key}: | ${value} |
`;
+ });
+
+ html += `
+
+
+
+ `;
+ }
+
+ 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