1package download
2
3import (
4 "fmt"
5 "mime"
6 "net/url"
7 "path/filepath"
8 "strings"
9 "time"
10)
11
12const (
13 // DefaultFilename is used when no valid filename can be determined.
14 DefaultFilename = "download"
15)
16
17// SanitizeFilename sanitizes a filename to prevent path traversal attacks.
18// It extracts only the base name and handles edge cases like "." or "..".
19func SanitizeFilename(name string) string {
20 // Normalize Windows-style separators to forward slashes.
21 // filepath.Base only handles the OS-native separator, so on Linux
22 // backslashes would not be treated as path separators.
23 name = strings.ReplaceAll(name, "\\", "/")
24
25 // Get only the base name (removes any directory components).
26 clean := filepath.Base(name)
27
28 // If Base returns "." or ".." (edge cases), use fallback.
29 if clean == "." || clean == ".." || clean == "" {
30 return DefaultFilename
31 }
32
33 return clean
34}
35
36// SanitizeFilenameWithExtension sanitizes a filename and adds an extension
37// inferred from the MIME type if the filename has no extension.
38func SanitizeFilenameWithExtension(name, mimeType string) string {
39 clean := SanitizeFilename(name)
40 if filepath.Ext(clean) == "" {
41 if ext := GetExtensionFromMimeType(mimeType); ext != "" {
42 return clean + ext
43 }
44 }
45
46 return clean
47}
48
49// preferredExtensions maps MIME types whose stdlib extension list is
50// platform-dependent (alphabetical order) to the canonical extension.
51// Without this, mime.ExtensionsByType("text/html") may return ".ehtml"
52// instead of ".html" depending on the system MIME database.
53var preferredExtensions = map[string]string{
54 "text/html": ".html",
55 "text/plain": ".txt",
56 "text/xml": ".xml",
57 "application/xhtml+xml": ".xhtml",
58 "image/jpeg": ".jpg",
59 "image/svg+xml": ".svg",
60 "audio/mpeg": ".mp3",
61 "video/mp4": ".mp4",
62 "application/octet-stream": ".bin",
63}
64
65// GetExtensionFromMimeType returns a file extension for a given MIME type.
66// Returns empty string if MIME type is unknown or empty.
67// Handles MIME types with parameters (e.g., "application/pdf; charset=binary").
68func GetExtensionFromMimeType(mimeType string) string {
69 if mimeType == "" {
70 return ""
71 }
72
73 // Parse media type to extract bare type without parameters.
74 // mime.ExtensionsByType expects "application/pdf", not "application/pdf; charset=binary".
75 mediaType, _, err := mime.ParseMediaType(mimeType)
76 if err != nil || mediaType == "" {
77 return ""
78 }
79
80 if ext, ok := preferredExtensions[mediaType]; ok {
81 return ext
82 }
83
84 exts, err := mime.ExtensionsByType(mediaType)
85 if err != nil || len(exts) == 0 {
86 return ""
87 }
88
89 return exts[0]
90}
91
92// ExtractFilenameFromURI extracts the filename from a URI path component.
93// Handles both URIs and plain paths. Returns DefaultFilename for edge cases.
94func ExtractFilenameFromURI(uri string) string {
95 if uri == "" {
96 return DefaultFilename
97 }
98
99 parsed, err := url.Parse(uri)
100 if err != nil {
101 // Fall back to treating as plain path
102 return extractFromPath(uri)
103 }
104
105 return extractFromPath(parsed.Path)
106}
107
108// ExtractFilenameFromDestination extracts the filename from a file:// URI or path.
109// Used to get filename from WebKit download destination.
110func ExtractFilenameFromDestination(dest string) string {
111 // Remove file:// prefix if present.
112 path := strings.TrimPrefix(dest, "file://")
113 base := filepath.Base(path)
114
115 // Handle edge cases consistently with SanitizeFilename.
116 if base == "." || base == "" {
117 return DefaultFilename
118 }
119 return base
120}
121
122// extractFromPath extracts filename from a path string.
123func extractFromPath(path string) string {
124 base := filepath.Base(path)
125 if base == "." || base == "" || base == "/" {
126 return DefaultFilename
127 }
128 return base
129}
130
131// MakeUniqueFilename generates a unique filename by appending _(N) if needed.
132// The exists function should return true if the given path already exists.
133func MakeUniqueFilename(dir, filename string, exists func(path string) bool) string {
134 destPath := filepath.Join(dir, filename)
135 if !exists(destPath) {
136 return filename
137 }
138
139 // Split filename into base and extension
140 ext := filepath.Ext(filename)
141 base := strings.TrimSuffix(filename, ext)
142
143 // Try incrementing numbers until we find a unique name
144 for i := 1; i < 1000; i++ {
145 candidate := fmt.Sprintf("%s_(%d)%s", base, i, ext)
146 candidatePath := filepath.Join(dir, candidate)
147 if !exists(candidatePath) {
148 return candidate
149 }
150 }
151
152 // Fallback: use timestamp
153 candidate := fmt.Sprintf("%s_%d%s", base, time.Now().UnixNano(), ext)
154 return candidate
155}