1package favicon
2
3import (
4 "net"
5 "net/url"
6 "path"
7 "slices"
8 "strings"
9
10 "golang.org/x/net/idna"
11 "golang.org/x/net/publicsuffix"
12)
13
14type Key string
15
16func CanonicalKey(raw string) (Key, bool) {
17 candidates := Candidates(raw)
18 if len(candidates) == 0 {
19 return "", false
20 }
21 return candidates[0], true
22}
23
24func CanonicalHostKey(raw string) (Key, bool) {
25 candidates := hostCandidates(raw)
26 if len(candidates) == 0 {
27 return "", false
28 }
29 return candidates[0], true
30}
31
32func Candidates(raw string) []Key {
33 parsed := parseHost(raw)
34 if !parsed.ok {
35 return nil
36 }
37 return withPathCandidates(hostCandidatesForParsed(parsed), parsed.rawPath)
38}
39
40func hostCandidates(raw string) []Key {
41 parsed := parseHost(raw)
42 if !parsed.ok {
43 return nil
44 }
45 return hostCandidatesForParsed(parsed)
46}
47
48func hostCandidatesForParsed(parsed parsedHost) []Key {
49 host := normalizeHost(parsed.host)
50 if host == "" {
51 return nil
52 }
53
54 var candidates []Key
55 if isExactHost(host) {
56 if parsed.port != "" && (!parsed.hasScheme || !isDefaultPort(parsed.scheme, parsed.port)) {
57 return []Key{Key(net.JoinHostPort(host, parsed.port))}
58 }
59 return []Key{Key(host)}
60 }
61
62 ascii, err := idna.Lookup.ToASCII(host)
63 if err != nil {
64 return nil
65 }
66 host = strings.ToLower(strings.TrimSuffix(ascii, "."))
67 host = stripWWW(host)
68
69 if parsed.port != "" {
70 if !parsed.hasScheme || !isDefaultPort(parsed.scheme, parsed.port) {
71 return []Key{Key(net.JoinHostPort(host, parsed.port))}
72 }
73 }
74
75 labels := strings.Split(host, ".")
76 if slices.Contains(labels, "") {
77 return nil
78 }
79
80 registrable, err := publicsuffix.EffectiveTLDPlusOne(host)
81 if err != nil {
82 return []Key{Key(host)}
83 }
84
85 for i := range len(labels) {
86 candidate := strings.Join(labels[i:], ".")
87 candidates = append(candidates, Key(candidate))
88 if candidate == registrable {
89 break
90 }
91 }
92 return candidates
93}
94
95func withPathCandidates(hostCandidates []Key, rawPath string) []Key {
96 if len(hostCandidates) == 0 {
97 return nil
98 }
99 paths := pathCandidates(rawPath)
100 if len(paths) == 0 {
101 return hostCandidates
102 }
103
104 out := make([]Key, 0, len(paths)+len(hostCandidates))
105 base := string(hostCandidates[0])
106 for _, p := range paths {
107 out = append(out, Key(base+p))
108 }
109 out = append(out, hostCandidates...)
110 return out
111}
112
113func pathCandidates(rawPath string) []string {
114 p := normalizePath(rawPath)
115 if p == "" {
116 return nil
117 }
118
119 var out []string
120 for p != "" && p != "/" {
121 out = append(out, p)
122 idx := strings.LastIndex(p, "/")
123 if idx <= 0 {
124 break
125 }
126 p = p[:idx]
127 }
128 return out
129}
130
131func normalizePath(rawPath string) string {
132 if rawPath == "" {
133 return ""
134 }
135 if cut := strings.IndexAny(rawPath, "?#"); cut >= 0 {
136 rawPath = rawPath[:cut]
137 }
138 if rawPath == "" || rawPath == "/" {
139 return ""
140 }
141 if !strings.HasPrefix(rawPath, "/") {
142 rawPath = "/" + rawPath
143 }
144 cleaned := path.Clean(rawPath)
145 if cleaned == "." || cleaned == "/" {
146 return ""
147 }
148 return strings.TrimRight(cleaned, "/")
149}
150
151type parsedHost struct {
152 host string
153 port string
154 hasScheme bool
155 scheme string
156 rawPath string
157 ok bool
158}
159
160func parseHost(raw string) parsedHost {
161 raw = strings.TrimSpace(raw)
162 if raw == "" {
163 return parsedHost{}
164 }
165
166 if strings.Contains(raw, "://") {
167 u, err := url.Parse(raw)
168 if err != nil || u.Scheme == "" {
169 return parsedHost{}
170 }
171 scheme := strings.ToLower(u.Scheme)
172 if scheme != "http" && scheme != "https" {
173 return parsedHost{hasScheme: true, scheme: scheme}
174 }
175 host := u.Hostname()
176 return parsedHost{
177 host: host,
178 port: u.Port(),
179 hasScheme: true,
180 scheme: scheme,
181 rawPath: u.EscapedPath(),
182 ok: host != "",
183 }
184 }
185 if strings.HasPrefix(strings.ToLower(raw), "about:") {
186 return parsedHost{}
187 }
188
189 hostport := raw
190 rawPath := ""
191 if cut := strings.IndexAny(hostport, "/?#"); cut >= 0 {
192 rawPath = hostport[cut:]
193 hostport = hostport[:cut]
194 }
195 hostport = strings.TrimSuffix(hostport, ".")
196
197 if h, p, err := net.SplitHostPort(hostport); err == nil {
198 return parsedHost{host: h, port: p, rawPath: rawPath, ok: h != ""}
199 }
200 if strings.Count(hostport, ":") == 1 {
201 if h, p, ok := strings.Cut(hostport, ":"); ok && h != "" && p != "" {
202 return parsedHost{host: h, port: p, rawPath: rawPath, ok: true}
203 }
204 }
205 return parsedHost{host: hostport, rawPath: rawPath, ok: hostport != ""}
206}
207
208func normalizeHost(host string) string {
209 host = strings.TrimSpace(host)
210 host = strings.Trim(host, "[]")
211 host = strings.TrimSuffix(host, ".")
212 return strings.ToLower(host)
213}
214
215func stripWWW(host string) string {
216 return strings.TrimPrefix(host, "www.")
217}
218
219func isDefaultPort(scheme, port string) bool {
220 return (scheme == "http" && port == "80") || (scheme == "https" && port == "443")
221}
222
223func isExactHost(host string) bool {
224 return host == "localhost" || net.ParseIP(host) != nil
225}