mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2025-08-20 07:02:09 +08:00
viewer, download, properties
This commit is contained in:
parent
d4f4c04361
commit
76d773bf88
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 = `
|
||||
<div class="modal fade" id="fileViewerModal" tabindex="-1" aria-labelledby="fileViewerModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="fileViewerModalLabel">
|
||||
<i class="fas fa-eye me-2"></i>File Viewer: ${file.name}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
${viewable ? createFileViewerContent(file, content) : createNonViewableContent(data.reason || 'File cannot be viewed')}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" onclick="downloadFile('${file.full_path}')">
|
||||
<i class="fas fa-download me-1"></i>Download
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 `
|
||||
<div class="text-center">
|
||||
<img src="/api/files/download?path=${encodeURIComponent(file.full_path)}"
|
||||
class="img-fluid" alt="${file.name}" style="max-height: 500px;">
|
||||
</div>
|
||||
`;
|
||||
} else if (file.mime.startsWith('text/') || file.mime === 'application/json' || file.mime === 'application/javascript') {
|
||||
const language = getLanguageFromMime(file.mime, file.name);
|
||||
return `
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Size: ${formatBytes(file.size)} | Type: ${file.mime}
|
||||
</small>
|
||||
</div>
|
||||
<pre><code class="language-${language}" style="max-height: 400px; overflow-y: auto;">${escapeHtml(content)}</code></pre>
|
||||
`;
|
||||
} else if (file.mime === 'application/pdf') {
|
||||
return `
|
||||
<div class="text-center">
|
||||
<embed src="/api/files/download?path=${encodeURIComponent(file.full_path)}"
|
||||
type="application/pdf" width="100%" height="500px">
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
return createNonViewableContent('This file type cannot be previewed in the browser.');
|
||||
}
|
||||
}
|
||||
|
||||
// Create non-viewable content message
|
||||
function createNonViewableContent(reason) {
|
||||
return `
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-file fa-3x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">Cannot preview file</h5>
|
||||
<p class="text-muted">${reason}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 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 = `
|
||||
<div class="modal fade" id="propertiesModal" tabindex="-1" aria-labelledby="propertiesModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="propertiesModalLabel">
|
||||
<i class="fas fa-info me-2"></i>Properties: ${properties.name}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
${createPropertiesContent(properties)}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 = `
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-primary"><i class="fas fa-file me-1"></i>Basic Information</h6>
|
||||
<table class="table table-sm">
|
||||
<tr><td><strong>Name:</strong></td><td>${properties.name}</td></tr>
|
||||
<tr><td><strong>Full Path:</strong></td><td><code>${properties.full_path}</code></td></tr>
|
||||
<tr><td><strong>Type:</strong></td><td>${properties.is_directory ? 'Directory' : 'File'}</td></tr>
|
||||
`;
|
||||
|
||||
if (!properties.is_directory) {
|
||||
html += `
|
||||
<tr><td><strong>Size:</strong></td><td>${properties.size_formatted || formatBytes(properties.size || 0)}</td></tr>
|
||||
<tr><td><strong>MIME Type:</strong></td><td>${properties.mime_type || 'Unknown'}</td></tr>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-primary"><i class="fas fa-clock me-1"></i>Timestamps</h6>
|
||||
<table class="table table-sm">
|
||||
`;
|
||||
|
||||
if (properties.modified_time) {
|
||||
html += `<tr><td><strong>Modified:</strong></td><td>${properties.modified_time}</td></tr>`;
|
||||
}
|
||||
if (properties.created_time) {
|
||||
html += `<tr><td><strong>Created:</strong></td><td>${properties.created_time}</td></tr>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</table>
|
||||
|
||||
<h6 class="text-primary"><i class="fas fa-shield-alt me-1"></i>Permissions</h6>
|
||||
<table class="table table-sm">
|
||||
<tr><td><strong>Mode:</strong></td><td><code>${properties.file_mode_formatted || properties.file_mode}</code></td></tr>
|
||||
<tr><td><strong>UID:</strong></td><td>${properties.uid || 'N/A'}</td></tr>
|
||||
<tr><td><strong>GID:</strong></td><td>${properties.gid || 'N/A'}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add TTL information if available
|
||||
if (properties.ttl_seconds && properties.ttl_seconds > 0) {
|
||||
html += `
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<h6 class="text-primary"><i class="fas fa-hourglass-half me-1"></i>TTL (Time To Live)</h6>
|
||||
<table class="table table-sm">
|
||||
<tr><td><strong>TTL:</strong></td><td>${properties.ttl_formatted || properties.ttl_seconds + ' seconds'}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add chunk information if available
|
||||
if (properties.chunks && properties.chunks.length > 0) {
|
||||
html += `
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<h6 class="text-primary"><i class="fas fa-puzzle-piece me-1"></i>Chunks (${properties.chunk_count})</h6>
|
||||
<div class="table-responsive" style="max-height: 200px; overflow-y: auto;">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File ID</th>
|
||||
<th>Offset</th>
|
||||
<th>Size</th>
|
||||
<th>ETag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
properties.chunks.forEach(chunk => {
|
||||
html += `
|
||||
<tr>
|
||||
<td><code class="small">${chunk.file_id}</code></td>
|
||||
<td>${formatBytes(chunk.offset)}</td>
|
||||
<td>${formatBytes(chunk.size)}</td>
|
||||
<td><code class="small">${chunk.e_tag || 'N/A'}</code></td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add extended attributes if available
|
||||
if (properties.extended && Object.keys(properties.extended).length > 0) {
|
||||
html += `
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<h6 class="text-primary"><i class="fas fa-tags me-1"></i>Extended Attributes</h6>
|
||||
<table class="table table-sm">
|
||||
`;
|
||||
|
||||
Object.entries(properties.extended).forEach(([key, value]) => {
|
||||
html += `<tr><td><strong>${key}:</strong></td><td>${value}</td></tr>`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// Utility function to escape HTML
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user