1// Package url provides URL manipulation utilities for the browser.
2package url
3
4import (
5 "fmt"
6 "net"
7 "net/url"
8 "os"
9 "path/filepath"
10 "strings"
11)
12
13// expandHome expands ~ prefix to user's home directory.
14func expandHome(path string) string {
15 if strings.HasPrefix(path, "~/") {
16 if home, err := os.UserHomeDir(); err == nil {
17 return filepath.Join(home, path[2:])
18 }
19 }
20 return path
21}
22
23// looksLikeFilePath returns true if the input looks like a filesystem path.
24// This includes absolute paths (/path), relative paths (./path, ../path),
25// and home-relative paths (~/path).
26func looksLikeFilePath(input string) bool {
27 switch {
28 case strings.HasPrefix(input, "/"):
29 return true
30 case strings.HasPrefix(input, "./"):
31 return true
32 case strings.HasPrefix(input, "../"):
33 return true
34 case strings.HasPrefix(input, "~/"):
35 return true
36 }
37 return false
38}
39
40// Normalize adds https:// prefix if missing for URL-like inputs.
41// Returns the input unchanged if it already has a scheme or doesn't look like a URL.
42// If the input is an existing local file, returns a file:// URL.
43func Normalize(input string) string {
44 if input == "" {
45 return ""
46 }
47
48 // Already has scheme
49 switch {
50 case strings.HasPrefix(input, "http://"):
51 return input
52 case strings.HasPrefix(input, "https://"):
53 return input
54 case strings.HasPrefix(input, "dumb://"):
55 return input
56 case strings.HasPrefix(input, "file://"):
57 return input
58 case strings.HasPrefix(input, "about:"):
59 return input
60 case IsExternalScheme(input):
61 return input
62 case hasExplicitSchemeURL(input):
63 return input
64 }
65
66 // Check if input looks like a file path
67 if looksLikeFilePath(input) {
68 expanded := expandHome(input)
69 absPath, err := filepath.Abs(expanded)
70 if err == nil {
71 if _, statErr := os.Stat(absPath); statErr == nil {
72 // File exists, convert to file:// URL
73 return "file://" + absPath
74 }
75 }
76 // Path-like input but file doesn't exist - return unchanged
77 // Don't treat as URL to avoid malformed URLs like https:///path
78 return input
79 }
80
81 // Check if input is an existing file in current directory (relative without ./ prefix)
82 absPath, err := filepath.Abs(input)
83 if err == nil {
84 if _, statErr := os.Stat(absPath); statErr == nil {
85 return "file://" + absPath
86 }
87 }
88
89 // Looks like a URL (contains . and no spaces)
90 if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
91 return "http://" + input
92 }
93 if looksLikeIPAddressWithOptionalPortOrPath(input) {
94 return "http://" + input
95 }
96 if strings.Contains(input, ".") && !strings.Contains(input, " ") {
97 return "https://" + input
98 }
99
100 return input
101}
102
103func looksLikeIPAddressWithOptionalPortOrPath(input string) bool {
104 if input == "" || strings.Contains(input, " ") {
105 return false
106 }
107
108 parsed, err := url.Parse("http://" + input)
109 if err != nil {
110 return false
111 }
112
113 hostname := parsed.Hostname()
114 if hostname == "" {
115 return false
116 }
117
118 return net.ParseIP(hostname) != nil
119}
120
121// LooksLikeURL checks if the input appears to be a URL (not a search query).
122// Returns true for strings like "github.com", "google.com/search", etc.
123// Also returns true for URLs with explicit schemes like "dumb://".
124func LooksLikeURL(input string) bool {
125 if input == "" {
126 return false
127 }
128
129 // Explicit schemes should always be treated as URLs.
130 switch {
131 case strings.HasPrefix(input, "http://"):
132 return true
133 case strings.HasPrefix(input, "https://"):
134 return true
135 case strings.HasPrefix(input, "dumb://"):
136 return true
137 case strings.HasPrefix(input, "file://"):
138 return true
139 case strings.HasPrefix(input, "about:"):
140 return true
141 case IsExternalScheme(input):
142 return true
143 case hasExplicitSchemeURL(input):
144 return true
145 }
146
147 // Contains a dot and no spaces = likely a URL
148 if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
149 return true
150 }
151 if looksLikeIPAddressWithOptionalPortOrPath(input) {
152 return true
153 }
154 return strings.Contains(input, ".") && !strings.Contains(input, " ")
155}
156
157func hasExplicitSchemeURL(input string) bool {
158 if !strings.Contains(input, "://") {
159 return false
160 }
161
162 parsed, err := url.Parse(input)
163 if err != nil {
164 return false
165 }
166
167 return parsed.Scheme != ""
168}
169
170// ExtractDomain extracts the normalized domain (host) from a URL string.
171// Normalizes by stripping "www." prefix so youtube.com and www.youtube.com
172// resolve to the same value.
173func ExtractDomain(rawURL string) string {
174 if rawURL == "" {
175 return ""
176 }
177 parsed, err := url.Parse(rawURL)
178 if err != nil || parsed.Host == "" {
179 return ""
180 }
181 return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.")
182}
183
184// CanonicalDomain extracts a normalized domain from either a URL string or a
185// host-like domain string. It keeps non-default ports so callers that need to
186// round-trip repository domain keys do not lose identity.
187func CanonicalDomain(raw string) string {
188 raw = strings.TrimSpace(strings.ToLower(raw))
189 if raw == "" {
190 return ""
191 }
192 if domain := ExtractDomain(raw); domain != "" {
193 return domain
194 }
195 if idx := strings.IndexAny(raw, "/?#"); idx >= 0 {
196 raw = raw[:idx]
197 }
198 return strings.TrimPrefix(raw, "www.")
199}
200
201// DisplayDomain returns a user-facing domain label with any port removed.
202func DisplayDomain(raw string) string {
203 domain := CanonicalDomain(raw)
204 if host, _, err := net.SplitHostPort(domain); err == nil {
205 return host
206 }
207 return domain
208}
209
210// SanitizeDomainForFilename converts a domain to a safe filename with .ico extension.
211// Replaces unsafe filesystem characters with underscores.
212func SanitizeDomainForFilename(domain string) string {
213 return sanitizeDomain(domain) + ".ico"
214}
215
216// SanitizeDomainForPNG converts a domain to a safe filename with .png extension.
217// Used for favicon export for tools like rofi/fuzzel that require PNG format.
218func SanitizeDomainForPNG(domain string) string {
219 return sanitizeDomain(domain) + ".png"
220}
221
222// sanitizeDomain replaces unsafe filesystem characters with underscores.
223func sanitizeDomain(domain string) string {
224 replacer := strings.NewReplacer(
225 ":", "_",
226 "/", "_",
227 "\\", "_",
228 "*", "_",
229 "?", "_",
230 "\"", "_",
231 "<", "_",
232 ">", "_",
233 "|", "_",
234 )
235 return replacer.Replace(domain)
236}
237
238// SanitizeDomainForPNGSized converts a domain to a safe filename with size suffix.
239// Example: "google.com" with size 32 -> "google.com.32.png"
240// Used for normalized favicon export for tools like rofi/fuzzel.
241func SanitizeDomainForPNGSized(domain string, size int) string {
242 return fmt.Sprintf("%s.%d.png", sanitizeDomain(domain), size)
243}
244
245// TrimLeadingSpacesIfURL removes leading whitespace from input if the trimmed
246// result looks like a URL. Returns the original input unchanged if it doesn't
247// contain leading spaces or if the trimmed result is not a URL.
248// This handles cases like pasting " https://example.com" from clipboard.
249func TrimLeadingSpacesIfURL(input string) string {
250 trimmed := strings.TrimLeft(input, " \t")
251 if trimmed != input && LooksLikeURL(trimmed) {
252 return trimmed
253 }
254 return input
255}
256
257// IsExternalScheme returns true if the URL uses a scheme that should be
258// launched externally (e.g., vscode://, vscode-insiders://, spotify://, steam://)
259// rather than handled by the browser.
260// URL schemes are case-insensitive per RFC 3986.
261func IsExternalScheme(uri string) bool {
262 if uri == "" {
263 return false
264 }
265
266 parsed, err := url.Parse(uri)
267 if err != nil || parsed.Scheme == "" {
268 return false
269 }
270 if !strings.Contains(uri, "://") {
271 rest := strings.TrimPrefix(uri, parsed.Scheme+":")
272 if strings.EqualFold(parsed.Scheme, "localhost") || strings.Contains(parsed.Scheme, ".") || isDecimalPort(rest) {
273 return false
274 }
275 }
276
277 switch strings.ToLower(parsed.Scheme) {
278 case "http", "https", "file", "dumb", "about", "data", "blob", "javascript":
279 return false
280 default:
281 return true
282 }
283}
284
285func isDecimalPort(value string) bool {
286 if value == "" {
287 return false
288 }
289 for _, r := range value {
290 if r < '0' || r > '9' {
291 return false
292 }
293 }
294 return true
295}
296
297// ExtractOrigin extracts the origin (scheme://host) from a URI.
298// This normalizes URIs to origins for permission storage and comparison.
299// Canonicalizes by lowercasing scheme and host, and omitting default ports.
300// Example: "https://example.com/path?query" -> "https://example.com"
301// Example: "HTTPS://EXAMPLE.COM:443/" -> "https://example.com"
302func ExtractOrigin(uri string) (string, error) {
303 parsed, err := url.Parse(uri)
304 if err != nil {
305 return "", fmt.Errorf("invalid URI: %w", err)
306 }
307
308 if parsed.Scheme == "" || parsed.Host == "" {
309 return "", fmt.Errorf("URI missing scheme or host: %s", uri)
310 }
311
312 // Canonicalize: lowercase scheme and hostname
313 scheme := strings.ToLower(parsed.Scheme)
314 hostname := strings.ToLower(parsed.Hostname())
315 if strings.Contains(hostname, ":") && !strings.HasPrefix(hostname, "[") {
316 hostname = "[" + hostname + "]"
317 }
318
319 // Determine if we should include the port
320 port := parsed.Port()
321 if port != "" {
322 // Omit default ports
323 if (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
324 port = ""
325 }
326 }
327
328 // Build canonical origin
329 origin := scheme + "://" + hostname
330 if port != "" {
331 origin = origin + ":" + port
332 }
333
334 return origin, nil
335}