From db34e8b6fdbf3a8d45fd20df59f0a3edca40cb89 Mon Sep 17 00:00:00 2001 From: baracudaz Date: Wed, 29 Apr 2026 19:21:03 +0200 Subject: [PATCH] feat(admin): prefer stored S3 Content-Type metadata over key-extension MIME inference (#9286) * feat(admin): prefer stored filer mime in file browser and properties * feat(mime): enhance MIME type registration and improve fallback logic Co-authored-by: Copilot --------- Co-authored-by: baracudaz Co-authored-by: Copilot --- weed/admin/dash/file_browser_data.go | 42 +----- weed/admin/dash/file_mime.go | 150 +++++++++++++++++++ weed/admin/dash/file_mime_test.go | 68 +++++++++ weed/admin/handlers/file_browser_handlers.go | 149 +----------------- 4 files changed, 221 insertions(+), 188 deletions(-) create mode 100644 weed/admin/dash/file_mime.go create mode 100644 weed/admin/dash/file_mime_test.go diff --git a/weed/admin/dash/file_browser_data.go b/weed/admin/dash/file_browser_data.go index f5b74f84b..225b8dd41 100644 --- a/weed/admin/dash/file_browser_data.go +++ b/weed/admin/dash/file_browser_data.go @@ -134,47 +134,7 @@ func (s *AdminServer) GetFileBrowser(dir string, lastFileName string, pageSize i } } - // Determine MIME type based on file extension - mime := "application/octet-stream" - if entry.IsDirectory { - mime = "inode/directory" - } else { - ext := strings.ToLower(path.Ext(entry.Name)) - switch ext { - case ".txt", ".log": - mime = "text/plain" - case ".html", ".htm": - mime = "text/html" - case ".css": - mime = "text/css" - case ".js": - mime = "application/javascript" - case ".json": - mime = "application/json" - case ".xml": - mime = "application/xml" - case ".pdf": - mime = "application/pdf" - case ".jpg", ".jpeg": - mime = "image/jpeg" - case ".png": - mime = "image/png" - case ".gif": - mime = "image/gif" - case ".svg": - mime = "image/svg+xml" - case ".mp4": - mime = "video/mp4" - case ".mp3": - mime = "audio/mpeg" - case ".zip": - mime = "application/zip" - case ".tar": - mime = "application/x-tar" - case ".gz": - mime = "application/gzip" - } - } + mime := ResolveEntryMime(entry) fileEntry := FileEntry{ Name: entry.Name, diff --git a/weed/admin/dash/file_mime.go b/weed/admin/dash/file_mime.go new file mode 100644 index 000000000..5f50d9b5c --- /dev/null +++ b/weed/admin/dash/file_mime.go @@ -0,0 +1,150 @@ +package dash + +import ( + "mime" + "path" + "strings" + + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" +) + +func init() { + // Register text files + mime.AddExtensionType(".txt", "text/plain") + mime.AddExtensionType(".log", "text/plain") + mime.AddExtensionType(".cfg", "text/plain") + mime.AddExtensionType(".conf", "text/plain") + mime.AddExtensionType(".ini", "text/plain") + mime.AddExtensionType(".properties", "text/plain") + mime.AddExtensionType(".gitignore", "text/plain") + mime.AddExtensionType(".gitattributes", "text/plain") + mime.AddExtensionType(".env", "text/plain") + + // Register markup and styling + mime.AddExtensionType(".md", "text/markdown") + mime.AddExtensionType(".markdown", "text/markdown") + mime.AddExtensionType(".html", "text/html") + mime.AddExtensionType(".htm", "text/html") + mime.AddExtensionType(".css", "text/css") + mime.AddExtensionType(".xml", "application/xml") + + // Register code and scripting languages + mime.AddExtensionType(".js", "application/javascript") + mime.AddExtensionType(".mjs", "application/javascript") + mime.AddExtensionType(".ts", "text/typescript") + mime.AddExtensionType(".py", "text/x-python") + mime.AddExtensionType(".go", "text/x-go") + mime.AddExtensionType(".java", "text/x-java") + mime.AddExtensionType(".c", "text/x-c") + mime.AddExtensionType(".cpp", "text/x-c++") + mime.AddExtensionType(".cc", "text/x-c++") + mime.AddExtensionType(".cxx", "text/x-c++") + mime.AddExtensionType(".c++", "text/x-c++") + mime.AddExtensionType(".h", "text/x-c-header") + mime.AddExtensionType(".hpp", "text/x-c-header") + mime.AddExtensionType(".php", "text/x-php") + mime.AddExtensionType(".rb", "text/x-ruby") + mime.AddExtensionType(".pl", "text/x-perl") + mime.AddExtensionType(".rs", "text/x-rust") + mime.AddExtensionType(".swift", "text/x-swift") + mime.AddExtensionType(".kt", "text/x-kotlin") + mime.AddExtensionType(".scala", "text/x-scala") + mime.AddExtensionType(".sh", "text/x-shellscript") + mime.AddExtensionType(".bash", "text/x-shellscript") + mime.AddExtensionType(".zsh", "text/x-shellscript") + mime.AddExtensionType(".fish", "text/x-shellscript") + mime.AddExtensionType(".dockerfile", "text/x-dockerfile") + + // Register data formats + mime.AddExtensionType(".json", "application/json") + mime.AddExtensionType(".yaml", "text/yaml") + mime.AddExtensionType(".yml", "text/yaml") + mime.AddExtensionType(".csv", "text/csv") + mime.AddExtensionType(".sql", "text/sql") + + // Register image types + mime.AddExtensionType(".jpg", "image/jpeg") + mime.AddExtensionType(".jpeg", "image/jpeg") + mime.AddExtensionType(".png", "image/png") + mime.AddExtensionType(".gif", "image/gif") + mime.AddExtensionType(".bmp", "image/bmp") + mime.AddExtensionType(".webp", "image/webp") + mime.AddExtensionType(".svg", "image/svg+xml") + mime.AddExtensionType(".ico", "image/x-icon") + + // Register document types + mime.AddExtensionType(".pdf", "application/pdf") + mime.AddExtensionType(".doc", "application/msword") + mime.AddExtensionType(".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document") + mime.AddExtensionType(".xls", "application/vnd.ms-excel") + mime.AddExtensionType(".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + mime.AddExtensionType(".ppt", "application/vnd.ms-powerpoint") + mime.AddExtensionType(".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation") + + // Register archive types + mime.AddExtensionType(".zip", "application/zip") + mime.AddExtensionType(".tar", "application/x-tar") + mime.AddExtensionType(".gz", "application/gzip") + mime.AddExtensionType(".bz2", "application/x-bzip2") + mime.AddExtensionType(".7z", "application/x-7z-compressed") + mime.AddExtensionType(".rar", "application/x-rar-compressed") + + // Register video types + mime.AddExtensionType(".mp4", "video/mp4") + mime.AddExtensionType(".avi", "video/x-msvideo") + mime.AddExtensionType(".mov", "video/quicktime") + mime.AddExtensionType(".wmv", "video/x-ms-wmv") + mime.AddExtensionType(".flv", "video/x-flv") + mime.AddExtensionType(".webm", "video/webm") + + // Register audio types + mime.AddExtensionType(".mp3", "audio/mpeg") + mime.AddExtensionType(".wav", "audio/wav") + mime.AddExtensionType(".flac", "audio/flac") + mime.AddExtensionType(".aac", "audio/aac") + mime.AddExtensionType(".ogg", "audio/ogg") +} + +func ResolveEntryMime(entry *filer_pb.Entry) string { + if entry == nil { + return "application/octet-stream" + } + if entry.IsDirectory { + return "inode/directory" + } + if entry.Attributes != nil { + normalized := normalizeMimeType(entry.Attributes.Mime) + if normalized != "" { + return normalized + } + } + return inferMimeTypeFromName(entry.Name) +} + +func normalizeMimeType(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + + if mediaType, _, err := mime.ParseMediaType(value); err == nil && mediaType != "" { + return strings.ToLower(mediaType) + } + + if idx := strings.Index(value, ";"); idx >= 0 { + value = value[:idx] + } + + return strings.ToLower(strings.TrimSpace(value)) +} + +func inferMimeTypeFromName(filename string) string { + ext := path.Ext(filename) + if mimeType := mime.TypeByExtension(ext); mimeType != "" { + // mime.TypeByExtension can include parameters (e.g., charset), so we parse just the media type. + if mediaType, _, err := mime.ParseMediaType(mimeType); err == nil { + return mediaType + } + } + return "application/octet-stream" +} diff --git a/weed/admin/dash/file_mime_test.go b/weed/admin/dash/file_mime_test.go new file mode 100644 index 000000000..bb8c8f54e --- /dev/null +++ b/weed/admin/dash/file_mime_test.go @@ -0,0 +1,68 @@ +package dash + +import ( + "testing" + + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" +) + +func TestResolveEntryMimePrefersStoredMime(t *testing.T) { + entry := &filer_pb.Entry{ + Name: "report.txt", + Attributes: &filer_pb.FuseAttributes{ + Mime: " application/pdf; charset=binary ", + }, + } + + if got := ResolveEntryMime(entry); got != "application/pdf" { + t.Fatalf("ResolveEntryMime() = %q, want %q", got, "application/pdf") + } +} + +func TestResolveEntryMimePrefersStoredMimeMalformedParameter(t *testing.T) { + entry := &filer_pb.Entry{ + Name: "report.txt", + Attributes: &filer_pb.FuseAttributes{ + Mime: "APPLICATION/PDF; bad", + }, + } + + if got := ResolveEntryMime(entry); got != "application/pdf" { + t.Fatalf("ResolveEntryMime() = %q, want %q", got, "application/pdf") + } +} + +func TestResolveEntryMimeFallsBackToFilename(t *testing.T) { + entry := &filer_pb.Entry{Name: "archive.zip"} + + if got := ResolveEntryMime(entry); got != "application/zip" { + t.Fatalf("ResolveEntryMime() = %q, want %q", got, "application/zip") + } +} + +func TestResolveEntryMimeReturnsDirectoryMime(t *testing.T) { + entry := &filer_pb.Entry{ + Name: "folder", + IsDirectory: true, + Attributes: &filer_pb.FuseAttributes{ + Mime: "application/json", + }, + } + + if got := ResolveEntryMime(entry); got != "inode/directory" { + t.Fatalf("ResolveEntryMime() = %q, want %q", got, "inode/directory") + } +} + +func TestResolveEntryMimeWhitespaceMimeFallsBackToFilename(t *testing.T) { + entry := &filer_pb.Entry{ + Name: "archive.zip", + Attributes: &filer_pb.FuseAttributes{ + Mime: " ", + }, + } + + if got := ResolveEntryMime(entry); got != "application/zip" { + t.Fatalf("ResolveEntryMime() = %q, want %q", got, "application/zip") + } +} diff --git a/weed/admin/handlers/file_browser_handlers.go b/weed/admin/handlers/file_browser_handlers.go index 2ae64129b..0f8cea0d9 100644 --- a/weed/admin/handlers/file_browser_handlers.go +++ b/weed/admin/handlers/file_browser_handlers.go @@ -699,8 +699,7 @@ func (h *FileBrowserHandlers) ViewFile(w http.ResponseWriter, r *http.Request) { size = int64(entry.Attributes.FileSize) } - // Determine MIME type with comprehensive extension support - mime := h.determineMimeType(entry.Name) + mime := dash.ResolveEntryMime(entry) fileEntry = dash.FileEntry{ Name: entry.Name, @@ -854,10 +853,8 @@ func (h *FileBrowserHandlers) GetFileProperties(w http.ResponseWriter, r *http.R properties["chunk_count"] = len(entry.Chunks) } - // Determine MIME type if !entry.IsDirectory { - mime := h.determineMimeType(entry.Name) - properties["mime_type"] = mime + properties["mime_type"] = dash.ResolveEntryMime(entry) } return nil @@ -884,148 +881,6 @@ func (h *FileBrowserHandlers) formatBytes(bytes int64) string { return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) } -// 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()