v0.31.0 bf0756b

fetcher.go RawHistoryFolder

1// Package favicon provides favicon fetching and caching infrastructure.
2package favicon
3
4import (
5	"bytes"
6	"context"
7	"errors"
8	"fmt"
9	"io"
10	"net"
11	"net/http"
12	"net/netip"
13	"net/url"
14	"strings"
15	"time"
16
17	appport "github.com/bnema/dumber/internal/application/port"
18	domainfavicon "github.com/bnema/dumber/internal/domain/favicon"
19)
20
21const (
22	duckduckgoFaviconURL = "https://icons.duckduckgo.com/ip3/%s.ico"
23	fetchTimeout         = 5 * time.Second
24	maxFaviconBytes      = 1 << 20
25	maxFaviconRedirects  = 3
26	sniffLen             = 512
27)
28
29type fetchContextKey string
30
31const allowedLocalOriginKey fetchContextKey = "allowed-local-origin"
32
33type Fetcher struct {
34	client        *http.Client
35	duckDuckGoURL string
36	maxBytes      int64
37}
38
39func NewFetcher() *Fetcher {
40	f := &Fetcher{duckDuckGoURL: duckduckgoFaviconURL, maxBytes: maxFaviconBytes}
41	transport := http.DefaultTransport.(*http.Transport).Clone()
42	transport.DialContext = f.safeDialContext
43	f.client = &http.Client{Timeout: fetchTimeout, Transport: transport, CheckRedirect: f.checkRedirect}
44	return f
45}
46
47func NewFetcherWithClient(client *http.Client, duckDuckGoURL string) *Fetcher {
48	f := &Fetcher{client: client, duckDuckGoURL: duckDuckGoURL, maxBytes: maxFaviconBytes}
49	if f.client == nil {
50		f.client = &http.Client{Timeout: fetchTimeout}
51	}
52	f.client.Transport = f.safeTransport(f.client.Transport)
53	f.client.CheckRedirect = f.checkRedirect
54	if f.duckDuckGoURL == "" {
55		f.duckDuckGoURL = duckduckgoFaviconURL
56	}
57	return f
58}
59
60func (f *Fetcher) safeTransport(base http.RoundTripper) http.RoundTripper {
61	transport, ok := base.(*http.Transport)
62	if !ok || transport == nil {
63		transport = http.DefaultTransport.(*http.Transport)
64	}
65	clone := transport.Clone()
66	clone.DialContext = f.safeDialContext
67	return clone
68}
69
70func (f *Fetcher) Fetch(ctx context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
71	fetchURL, resolvedKey, source, err := f.resolveURL(req)
72	if err != nil {
73		return nil, err
74	}
75	data, ct, err := f.fetchURL(ctx, fetchURL, req.PageURL)
76	if err != nil {
77		return nil, err
78	}
79	return &appport.FaviconFetchedIcon{
80		PageURL:     req.PageURL,
81		IconURL:     fetchURL,
82		ResolvedKey: resolvedKey,
83		Bytes:       data,
84		Source:      source,
85		ContentType: ct,
86	}, nil
87}
88
89// FetchDomain preserves the old DuckDuckGo-only API for legacy callers.
90func (f *Fetcher) FetchDomain(ctx context.Context, domain string) ([]byte, error) {
91	if strings.TrimSpace(domain) == "" {
92		return nil, nil
93	}
94	got, err := f.Fetch(ctx, appport.FaviconFetchRequest{PageURL: "https://" + domain})
95	if errors.Is(err, appport.ErrFaviconMiss) {
96		return nil, nil
97	}
98	if err != nil {
99		return nil, err
100	}
101	return got.Bytes, nil
102}
103
104func (f *Fetcher) resolveURL(req appport.FaviconFetchRequest) (string, domainfavicon.Key, domainfavicon.Source, error) {
105	if req.IconURL != "" {
106		u, err := url.Parse(req.IconURL)
107		if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
108			return "", "", "", appport.ErrFaviconMiss
109		}
110		if !allowedURLForPage(req.PageURL, u) {
111			return "", "", "", appport.ErrFaviconMiss
112		}
113		return u.String(), "", domainfavicon.SourcePageDiscovery, nil
114	}
115	key, ok := domainfavicon.CanonicalHostKey(req.PageURL)
116	if !ok {
117		return "", "", "", appport.ErrFaviconMiss
118	}
119	return fmt.Sprintf(f.duckDuckGoURL, url.QueryEscape(string(key))), key, domainfavicon.SourceDuckDuckGo, nil
120}
121
122func (f *Fetcher) fetchURL(ctx context.Context, raw, pageURL string) ([]byte, string, error) {
123	ctx = context.WithValue(ctx, allowedLocalOriginKey, allowedLocalOrigin(pageURL))
124	req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, http.NoBody)
125	if err != nil {
126		return nil, "", err
127	}
128	resp, err := f.client.Do(req)
129	if err != nil {
130		return nil, "", err
131	}
132	defer func() { _ = resp.Body.Close() }()
133	if resp.StatusCode != http.StatusOK {
134		return nil, "", appport.ErrFaviconMiss
135	}
136	limit := f.maxBytes
137	if limit <= 0 {
138		limit = maxFaviconBytes
139	}
140	body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
141	if err != nil {
142		return nil, "", err
143	}
144	if int64(len(body)) > limit {
145		return nil, "", fmt.Errorf("favicon response exceeds %d bytes", limit)
146	}
147	if len(body) == 0 {
148		return nil, "", appport.ErrFaviconMiss
149	}
150	declared := normalizeContentType(resp.Header.Get("Content-Type"))
151	if declared == "" || declared == "application/octet-stream" {
152		declared = normalizeContentType(http.DetectContentType(body[:min(len(body), sniffLen)]))
153	}
154	if !supportedFetchedContentType(declared) || !bodyMatchesContentType(declared, body) {
155		return nil, "", appport.ErrFaviconMiss
156	}
157	return body, declared, nil
158}
159
160func (*Fetcher) checkRedirect(req *http.Request, via []*http.Request) error {
161	if len(via) >= maxFaviconRedirects {
162		return http.ErrUseLastResponse
163	}
164	if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
165		return http.ErrUseLastResponse
166	}
167	if !allowedURLForOrigin(req.Context(), req.URL) {
168		return http.ErrUseLastResponse
169	}
170	return nil
171}
172
173func (*Fetcher) safeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
174	host, port, err := net.SplitHostPort(address)
175	if err != nil {
176		return nil, err
177	}
178	allowed := ctx.Value(allowedLocalOriginKey) == normalizeOriginHostPort(host, port, "")
179	ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
180	if err != nil {
181		return nil, err
182	}
183	var dialIP net.IP
184	for _, ip := range ips {
185		addr, ok := netip.AddrFromSlice(ip.IP)
186		if (!ok || isPrivateAddr(addr)) && !allowed {
187			continue
188		}
189		dialIP = ip.IP
190		break
191	}
192	if dialIP == nil {
193		return nil, fmt.Errorf("no allowed resolved address for favicon host %s", host)
194	}
195	var d net.Dialer
196	return d.DialContext(ctx, network, net.JoinHostPort(dialIP.String(), port))
197}
198
199func supportedFetchedContentType(ct string) bool {
200	switch ct {
201	case "image/png", "image/x-icon", "image/vnd.microsoft.icon", "image/jpeg", "image/gif", "image/svg+xml", "image/webp":
202		return true
203	}
204	return false
205}
206
207func bodyMatchesContentType(ct string, body []byte) bool {
208	switch ct {
209	case "image/png":
210		return bytes.HasPrefix(body, []byte("\x89PNG\r\n\x1a\n"))
211	case "image/jpeg":
212		return len(body) >= 3 && body[0] == 0xff && body[1] == 0xd8 && body[2] == 0xff
213	case "image/gif":
214		return bytes.HasPrefix(body, []byte("GIF87a")) || bytes.HasPrefix(body, []byte("GIF89a"))
215	case "image/x-icon", "image/vnd.microsoft.icon":
216		return len(body) >= 4 && body[0] == 0 && body[1] == 0 && (body[2] == 1 || body[2] == 2) && body[3] == 0
217	case "image/webp":
218		return len(body) >= 12 && bytes.HasPrefix(body, []byte("RIFF")) && string(body[8:12]) == "WEBP"
219	case "image/svg+xml":
220		trimmed := bytes.TrimSpace(body)
221		return bytes.HasPrefix(trimmed, []byte("<svg")) || (bytes.HasPrefix(trimmed, []byte("<?xml")) && bytes.Contains(trimmed, []byte("<svg")))
222	default:
223		return false
224	}
225}
226
227func allowedURLForPage(pageURL string, iconURL *url.URL) bool {
228	if !isInternalHost(iconURL.Hostname()) {
229		return true
230	}
231	page, err := url.Parse(pageURL)
232	if err != nil {
233		return false
234	}
235	return isLocalhost(page.Hostname()) && sameOrigin(page, iconURL)
236}
237
238func allowedURLForOrigin(ctx context.Context, target *url.URL) bool {
239	if !isInternalHost(target.Hostname()) {
240		return true
241	}
242	allowed, _ := ctx.Value(allowedLocalOriginKey).(string)
243	targetOrigin := normalizeOriginHostPort(target.Hostname(), target.Port(), target.Scheme)
244	return allowed != "" && strings.EqualFold(allowed, targetOrigin)
245}
246
247func allowedLocalOrigin(pageURL string) string {
248	page, err := url.Parse(pageURL)
249	if err != nil || !isLocalhost(page.Hostname()) {
250		return ""
251	}
252	return normalizeOriginHostPort(page.Hostname(), page.Port(), page.Scheme)
253}
254
255func sameOrigin(a, b *url.URL) bool {
256	return strings.EqualFold(a.Scheme, b.Scheme) &&
257		normalizeOriginHostPort(a.Hostname(), a.Port(), a.Scheme) == normalizeOriginHostPort(b.Hostname(), b.Port(), b.Scheme)
258}
259
260func normalizeOriginHostPort(host, port, scheme string) string {
261	if port == "" {
262		switch strings.ToLower(scheme) {
263		case "http":
264			port = "80"
265		case "https":
266			port = "443"
267		}
268	}
269	if port == "" {
270		return strings.ToLower(host)
271	}
272	return strings.ToLower(net.JoinHostPort(host, port))
273}
274
275func isInternalHost(host string) bool {
276	if isLocalhost(host) {
277		return true
278	}
279	ips, err := net.LookupIP(host)
280	if err != nil {
281		return true
282	}
283	for _, ip := range ips {
284		addr, ok := netip.AddrFromSlice(ip)
285		if !ok || isPrivateAddr(addr) {
286			return true
287		}
288	}
289	return false
290}
291
292func isLocalhost(host string) bool {
293	return strings.EqualFold(host, "localhost") || host == "127.0.0.1" || host == "::1"
294}
295
296func isPrivateAddr(addr netip.Addr) bool {
297	return addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() || addr.IsUnspecified()
298}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close