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		// Classified miss: callers keep ErrFaviconMiss matching via Is,
135		// while the use case can distinguish genuine absence (404/410)
136		// from auth/transient failures for negative caching.
137		return nil, "", &appport.FaviconFetchError{StatusCode: resp.StatusCode}
138	}
139	limit := f.maxBytes
140	if limit <= 0 {
141		limit = maxFaviconBytes
142	}
143	body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
144	if err != nil {
145		return nil, "", err
146	}
147	if int64(len(body)) > limit {
148		return nil, "", fmt.Errorf("favicon response exceeds %d bytes", limit)
149	}
150	if len(body) == 0 {
151		return nil, "", appport.ErrFaviconMiss
152	}
153	declared := normalizeContentType(resp.Header.Get("Content-Type"))
154	if declared == "" || declared == "application/octet-stream" {
155		declared = normalizeContentType(http.DetectContentType(body[:min(len(body), sniffLen)]))
156	}
157	if !supportedFetchedContentType(declared) || !bodyMatchesContentType(declared, body) {
158		return nil, "", appport.ErrFaviconMiss
159	}
160	return body, declared, nil
161}
162
163func (*Fetcher) checkRedirect(req *http.Request, via []*http.Request) error {
164	if len(via) >= maxFaviconRedirects {
165		return http.ErrUseLastResponse
166	}
167	if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
168		return http.ErrUseLastResponse
169	}
170	if !allowedURLForOrigin(req.Context(), req.URL) {
171		return http.ErrUseLastResponse
172	}
173	return nil
174}
175
176func (*Fetcher) safeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
177	host, port, err := net.SplitHostPort(address)
178	if err != nil {
179		return nil, err
180	}
181	allowed := ctx.Value(allowedLocalOriginKey) == normalizeOriginHostPort(host, port, "")
182	ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
183	if err != nil {
184		return nil, err
185	}
186	var dialIP net.IP
187	for _, ip := range ips {
188		addr, ok := netip.AddrFromSlice(ip.IP)
189		if (!ok || isPrivateAddr(addr)) && !allowed {
190			continue
191		}
192		dialIP = ip.IP
193		break
194	}
195	if dialIP == nil {
196		return nil, fmt.Errorf("no allowed resolved address for favicon host %s", host)
197	}
198	var d net.Dialer
199	return d.DialContext(ctx, network, net.JoinHostPort(dialIP.String(), port))
200}
201
202func supportedFetchedContentType(ct string) bool {
203	switch ct {
204	case "image/png", "image/x-icon", "image/vnd.microsoft.icon", "image/jpeg", "image/gif", "image/svg+xml", "image/webp":
205		return true
206	}
207	return false
208}
209
210func bodyMatchesContentType(ct string, body []byte) bool {
211	switch ct {
212	case "image/png":
213		return bytes.HasPrefix(body, []byte("\x89PNG\r\n\x1a\n"))
214	case "image/jpeg":
215		return len(body) >= 3 && body[0] == 0xff && body[1] == 0xd8 && body[2] == 0xff
216	case "image/gif":
217		return bytes.HasPrefix(body, []byte("GIF87a")) || bytes.HasPrefix(body, []byte("GIF89a"))
218	case "image/x-icon", "image/vnd.microsoft.icon":
219		return len(body) >= 4 && body[0] == 0 && body[1] == 0 && (body[2] == 1 || body[2] == 2) && body[3] == 0
220	case "image/webp":
221		return len(body) >= 12 && bytes.HasPrefix(body, []byte("RIFF")) && string(body[8:12]) == "WEBP"
222	case "image/svg+xml":
223		trimmed := bytes.TrimSpace(body)
224		return bytes.HasPrefix(trimmed, []byte("<svg")) || (bytes.HasPrefix(trimmed, []byte("<?xml")) && bytes.Contains(trimmed, []byte("<svg")))
225	default:
226		return false
227	}
228}
229
230func allowedURLForPage(pageURL string, iconURL *url.URL) bool {
231	if !isInternalHost(iconURL.Hostname()) {
232		return true
233	}
234	page, err := url.Parse(pageURL)
235	if err != nil {
236		return false
237	}
238	return isLocalhost(page.Hostname()) && sameOrigin(page, iconURL)
239}
240
241func allowedURLForOrigin(ctx context.Context, target *url.URL) bool {
242	if !isInternalHost(target.Hostname()) {
243		return true
244	}
245	allowed, _ := ctx.Value(allowedLocalOriginKey).(string)
246	targetOrigin := normalizeOriginHostPort(target.Hostname(), target.Port(), target.Scheme)
247	return allowed != "" && strings.EqualFold(allowed, targetOrigin)
248}
249
250func allowedLocalOrigin(pageURL string) string {
251	page, err := url.Parse(pageURL)
252	if err != nil || !isLocalhost(page.Hostname()) {
253		return ""
254	}
255	return normalizeOriginHostPort(page.Hostname(), page.Port(), page.Scheme)
256}
257
258func sameOrigin(a, b *url.URL) bool {
259	return strings.EqualFold(a.Scheme, b.Scheme) &&
260		normalizeOriginHostPort(a.Hostname(), a.Port(), a.Scheme) == normalizeOriginHostPort(b.Hostname(), b.Port(), b.Scheme)
261}
262
263func normalizeOriginHostPort(host, port, scheme string) string {
264	if port == "" {
265		switch strings.ToLower(scheme) {
266		case "http":
267			port = "80"
268		case "https":
269			port = "443"
270		}
271	}
272	if port == "" {
273		return strings.ToLower(host)
274	}
275	return strings.ToLower(net.JoinHostPort(host, port))
276}
277
278func isInternalHost(host string) bool {
279	if isLocalhost(host) {
280		return true
281	}
282	ips, err := net.LookupIP(host)
283	if err != nil {
284		return true
285	}
286	for _, ip := range ips {
287		addr, ok := netip.AddrFromSlice(ip)
288		if !ok || isPrivateAddr(addr) {
289			return true
290		}
291	}
292	return false
293}
294
295func isLocalhost(host string) bool {
296	return strings.EqualFold(host, "localhost") || host == "127.0.0.1" || host == "::1"
297}
298
299func isPrivateAddr(addr netip.Addr) bool {
300	return addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() || addr.IsUnspecified()
301}

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