// Package recipes fetches and holds real recipe pages that ground dinners. package recipes import ( "bytes" "context" "crypto/tls" "errors" "fmt" "io" "mime" "net" "net/http" "net/netip" "net/url" "slices" "strings" "sync" "time" ) const ( // UserAgent identifies MealQuest to the recipe sites it fetches. UserAgent = "MealQuest/1.0 (household meal planner; recipe fetcher)" // MaxBodyBytes caps a fetched body. A larger page is an error, never // silently truncated: a cut-off recipe page would parse as a wrong one. MaxBodyBytes = 2 << 20 // MaxImageBytes caps a fetched image body, as MaxBodyBytes caps a page. MaxImageBytes = 8 << 20 // MaxRedirects is how many redirects a fetch follows; each hop is // re-checked for scheme and address. MaxRedirects = 3 // Timeout bounds a whole fetch: rate-limit waits, every redirect hop // and reading the body. Timeout = 10 * time.Second // HostInterval is the least time between two requests to one host. HostInterval = time.Second // CacheTTL is how long a fetched page is served from the cache. CacheTTL = time.Hour // CacheSize is how many pages the cache holds; the oldest is evicted // first. CacheSize = 64 ) var ( // ErrNotHTTPS is returned for a URL, or a redirect, that is not // https://. ErrNotHTTPS = errors.New("recipes: only https:// URLs are fetched") // ErrBlockedAddress is returned when a host resolves to an address // that is not public, such as loopback or a private network. ErrBlockedAddress = errors.New("recipes: address is not public") // ErrTooManyRedirects is returned after MaxRedirects redirects. ErrTooManyRedirects = errors.New("recipes: too many redirects") // ErrTooLarge is returned for a body over MaxBodyBytes. ErrTooLarge = errors.New("recipes: body exceeds size cap") // ErrTimeout is returned when a fetch runs past Timeout. ErrTimeout = errors.New("recipes: fetch timed out") // ErrNotImage is returned by FetchImage for a response whose // Content-Type is not one of ImageContentTypes. ErrNotImage = errors.New("recipes: response is not a supported image") ) // ImageContentTypes are the media types FetchImage accepts. var ImageContentTypes = []string{"image/jpeg", "image/png", "image/webp", "image/gif"} // fetchKind is what a request asks for: its Accept header, the body cap and // the media types allowed, where nil allows any. type fetchKind struct { accept string maxBytes int64 contentTypes []string } var ( pageKind = fetchKind{accept: "text/html,application/xhtml+xml", maxBytes: MaxBodyBytes} imageKind = fetchKind{accept: strings.Join(ImageContentTypes, ","), maxBytes: MaxImageBytes, contentTypes: ImageContentTypes} ) // StatusError is returned for a final response whose status is not 2xx. type StatusError struct { URL string Status int } func (e *StatusError) Error() string { return fmt.Sprintf("recipes: GET %s: HTTP %d", e.URL, e.Status) } // Page is a fetched page. type Page struct { // URL is the final URL, after redirects. URL string ContentType string Body []byte } // Resolver looks up a host's addresses; *net.Resolver satisfies it. type Resolver interface { LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) } // dialFunc dials an address that has already passed the public-address // check. type dialFunc func(ctx context.Context, network, address string) (net.Conn, error) // Fetcher retrieves recipe pages without reaching private networks. Its // dialer resolves each host itself, refuses the connection unless every // answer is a public address, and dials the checked address, so a second DNS // answer cannot swap in a private one. Keep-alives are off: every request, // redirect hops included, dials and checks afresh. Proxies from the // environment are not used, since a proxy would dial on the Fetcher's // behalf. Requests are HTTP/1.1, so each connection carries exactly one // request and its first write is that request. A Fetcher is safe for // concurrent use. type Fetcher struct { client *http.Client resolver Resolver dial dialFunc tlsConfig *tls.Config // timeout bounds a fetch; tests shorten it. timeout time.Duration // now is the clock; tests replace it. now func() time.Time // sleep waits d or until ctx is done; tests replace it. sleep func(ctx context.Context, d time.Duration) error mu sync.Mutex // lastSent is when each host's latest request began to be written to // its connection; a request whose write fails before any byte is sent // hands its slot back. lastSent map[string]time.Time // released is closed, then replaced, whenever a slot is handed back, // waking requests waiting for their turn. released chan struct{} cache map[string]cacheEntry } type cacheEntry struct { page Page fetched time.Time } // NewFetcher returns a Fetcher that resolves through the system resolver. func NewFetcher() *Fetcher { return newFetcher(net.DefaultResolver, (&net.Dialer{}).DialContext, nil) } // newFetcher returns a Fetcher over resolver and dial; tlsConfig, when not // nil, replaces the default TLS settings so tests can trust their servers. func newFetcher(resolver Resolver, dial dialFunc, tlsConfig *tls.Config) *Fetcher { f := &Fetcher{ resolver: resolver, dial: dial, tlsConfig: tlsConfig, timeout: Timeout, now: time.Now, sleep: sleep, lastSent: map[string]time.Time{}, released: make(chan struct{}), cache: map[string]cacheEntry{}, } transport := &http.Transport{ Proxy: nil, DialTLSContext: f.dialTLS, DisableKeepAlives: true, } f.client = &http.Client{ Transport: &requestTransport{next: transport}, CheckRedirect: checkRedirect, } return f } // Fetch returns the page at rawURL, from the cache when it was fetched // within CacheTTL. Only 2xx responses are returned and cached. The // returned Body is the caller's own copy. func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (Page, error) { u, err := url.Parse(rawURL) if err != nil { return Page{}, fmt.Errorf("recipes: parse URL: %w", err) } if err := checkURL(u); err != nil { return Page{}, err } key := u.String() if page, ok := f.cached(key); ok { return page, nil } page, err := f.fetch(ctx, key, pageKind) if err != nil { return Page{}, err } f.store(key, page) return page, nil } // FetchImage returns the image at rawURL under the same safeguards as // Fetch, but with a body cap of MaxImageBytes, and only for a response // whose Content-Type is one of ImageContentTypes. Images are not cached. func (f *Fetcher) FetchImage(ctx context.Context, rawURL string) (Page, error) { u, err := url.Parse(rawURL) if err != nil { return Page{}, fmt.Errorf("recipes: parse URL: %w", err) } if err := checkURL(u); err != nil { return Page{}, err } return f.fetch(ctx, u.String(), imageKind) } // fetch gets rawURL as kind within the Fetcher's timeout. func (f *Fetcher) fetch(ctx context.Context, rawURL string, kind fetchKind) (Page, error) { fetchCtx, cancel := context.WithTimeout(ctx, f.timeout) defer cancel() page, err := f.get(fetchCtx, rawURL, kind) if err != nil { if ctx.Err() == nil && errors.Is(fetchCtx.Err(), context.DeadlineExceeded) { return Page{}, fmt.Errorf("%w after %s: %s", ErrTimeout, f.timeout, rawURL) } return Page{}, err } return page, nil } func (f *Fetcher) get(ctx context.Context, rawURL string, kind fetchKind) (Page, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return Page{}, fmt.Errorf("recipes: build request: %w", err) } req.Header.Set("User-Agent", UserAgent) req.Header.Set("Accept", kind.accept) resp, err := f.client.Do(req) if err != nil { return Page{}, err } defer resp.Body.Close() final := resp.Request.URL.String() if resp.StatusCode < 200 || resp.StatusCode > 299 { return Page{}, &StatusError{URL: final, Status: resp.StatusCode} } contentType := resp.Header.Get("Content-Type") if kind.contentTypes != nil { mediaType, _, err := mime.ParseMediaType(contentType) if err != nil || !slices.Contains(kind.contentTypes, mediaType) { return Page{}, fmt.Errorf("%w: %s has Content-Type %q", ErrNotImage, final, contentType) } } if resp.ContentLength > kind.maxBytes { return Page{}, fmt.Errorf("%w: %s declares %d bytes", ErrTooLarge, final, resp.ContentLength) } body, err := io.ReadAll(io.LimitReader(resp.Body, kind.maxBytes+1)) if err != nil { return Page{}, fmt.Errorf("recipes: read %s: %w", final, err) } if int64(len(body)) > kind.maxBytes { return Page{}, fmt.Errorf("%w: %s is over %d bytes", ErrTooLarge, final, kind.maxBytes) } return Page{URL: final, ContentType: contentType, Body: body}, nil } // cached returns the page cached under key if it is younger than CacheTTL. func (f *Fetcher) cached(key string) (Page, bool) { f.mu.Lock() defer f.mu.Unlock() e, ok := f.cache[key] if !ok { return Page{}, false } if f.now().Sub(e.fetched) >= CacheTTL { delete(f.cache, key) return Page{}, false } page := e.page page.Body = bytes.Clone(page.Body) return page, true } // store caches a copy of page under key, evicting the oldest entry when // full. func (f *Fetcher) store(key string, page Page) { page.Body = bytes.Clone(page.Body) f.mu.Lock() defer f.mu.Unlock() if _, ok := f.cache[key]; !ok && len(f.cache) >= CacheSize { var oldest string var oldestAt time.Time for k, e := range f.cache { if oldest == "" || e.fetched.Before(oldestAt) { oldest, oldestAt = k, e.fetched } } delete(f.cache, oldest) } f.cache[key] = cacheEntry{page: page, fetched: f.now()} } // waitTurn blocks until a request to host may be sent, keeping requests to // one host at least HostInterval apart. A wait holds no reservation, and a // request ending with ctx before its turn takes no slot. The request let // through takes the host's slot and must be sent at once, or call release // if sending fails, so that it delays no other request. Fetch calls it from // turnConn's first Write, so the slot is taken after DNS, dialing and the // TLS handshake, as the request is written. func (f *Fetcher) waitTurn(ctx context.Context, host string) (release func(), err error) { for { if err := ctx.Err(); err != nil { return nil, err } f.mu.Lock() now := f.now() last, sent := f.lastSent[host] wait := last.Add(HostInterval).Sub(now) if !sent || wait <= 0 { f.lastSent[host] = now f.mu.Unlock() return func() { f.release(host, now, last, sent) }, nil } released := f.released f.mu.Unlock() if err := f.waitOrRelease(ctx, wait, released); err != nil { return nil, err } } } // release hands back the slot taken at taken for a request that was not // sent, restoring the host's previous send time, and wakes waiting requests. func (f *Fetcher) release(host string, taken, prev time.Time, hadPrev bool) { f.mu.Lock() defer f.mu.Unlock() if !f.lastSent[host].Equal(taken) { // A later request already holds the slot. return } if hadPrev { f.lastSent[host] = prev } else { delete(f.lastSent, host) } close(f.released) f.released = make(chan struct{}) } // waitOrRelease sleeps for d, returning early without error when a slot is // handed back so the caller can look again. func (f *Fetcher) waitOrRelease(ctx context.Context, d time.Duration, released <-chan struct{}) error { sleepCtx, cancel := context.WithCancel(ctx) defer cancel() go func() { select { case <-released: cancel() case <-sleepCtx.Done(): } }() err := f.sleep(sleepCtx, d) select { case <-released: return nil default: return err } } // dialPublic resolves address's host once, refuses unless every answer is // public, then dials the checked addresses in turn. func (f *Fetcher) dialPublic(ctx context.Context, network, address string) (net.Conn, error) { host, port, err := net.SplitHostPort(address) if err != nil { return nil, fmt.Errorf("recipes: dial address %q: %w", address, err) } var addrs []netip.Addr if ip, err := netip.ParseAddr(host); err == nil { addrs = []netip.Addr{ip} } else { answers, err := f.resolver.LookupIPAddr(ctx, host) if err != nil { return nil, fmt.Errorf("recipes: resolve %s: %w", host, err) } for _, a := range answers { ip, ok := netip.AddrFromSlice(a.IP) if !ok { return nil, fmt.Errorf("recipes: resolve %s: malformed address %v", host, a.IP) } addrs = append(addrs, ip) } } if len(addrs) == 0 { return nil, fmt.Errorf("recipes: resolve %s: no addresses", host) } for _, ip := range addrs { if !IsPublic(ip) { return nil, fmt.Errorf("%w: %s resolves to %s", ErrBlockedAddress, host, ip) } } var errs []error for _, ip := range addrs { conn, err := f.dial(ctx, network, net.JoinHostPort(ip.Unmap().WithZone("").String(), port)) if err == nil { return conn, nil } errs = append(errs, err) } return nil, errors.Join(errs...) } // requestCtxKey carries a request's own context into its dial. The // transport dials with a context that keeps the request's values but not // its cancellation; the connection and the rate-limit wait must end with // the request. type requestCtxKey struct{} // requestTransport hands each request's context, redirect hops included, // to dialTLS. type requestTransport struct { next http.RoundTripper } func (t *requestTransport) RoundTrip(req *http.Request) (*http.Response, error) { ctx := req.Context() return t.next.RoundTrip(req.WithContext(context.WithValue(ctx, requestCtxKey{}, ctx))) } // dialTLS connects to address through dialPublic and completes the TLS // handshake, all under the request's context, and returns a turnConn whose // first write waits for the host's rate-limit slot. ALPN offers only // HTTP/1.1, so the connection carries this one request. func (f *Fetcher) dialTLS(dialCtx context.Context, network, address string) (net.Conn, error) { ctx, ok := dialCtx.Value(requestCtxKey{}).(context.Context) if !ok { return nil, fmt.Errorf("recipes: dial %s outside a request", address) } host, _, err := net.SplitHostPort(address) if err != nil { return nil, fmt.Errorf("recipes: dial address %q: %w", address, err) } conn, err := f.dialPublic(ctx, network, address) if err != nil { return nil, err } cfg := &tls.Config{} if f.tlsConfig != nil { cfg = f.tlsConfig.Clone() } cfg.ServerName = host cfg.NextProtos = []string{"http/1.1"} tc := tls.Client(conn, cfg) if err := tc.HandshakeContext(ctx); err != nil { conn.Close() return nil, fmt.Errorf("recipes: TLS handshake with %s: %w", host, err) } return &turnConn{Conn: tc, f: f, ctx: ctx, host: strings.ToLower(host)}, nil } // turnConn is a connection ready to carry one request. Its first Write — // the start of the request — waits for the host's rate-limit slot, so two // requests to one host are written at least HostInterval apart however // long each spent resolving, dialing and in the TLS handshake. A first // Write that sends nothing hands the slot back. type turnConn struct { net.Conn f *Fetcher ctx context.Context host string // started is set by the first Write; the transport writes a request // from one goroutine, so it needs no lock. started bool } func (c *turnConn) Write(p []byte) (int, error) { if c.started { return c.Conn.Write(p) } release, err := c.f.waitTurn(c.ctx, c.host) if err != nil { return 0, err } c.started = true n, err := c.Conn.Write(p) if n == 0 && err != nil { release() } return n, err } // checkURL refuses anything but an absolute https:// URL with a host. func checkURL(u *url.URL) error { if u.Scheme != "https" { return fmt.Errorf("%w: %q", ErrNotHTTPS, u.Redacted()) } if u.Hostname() == "" { return fmt.Errorf("recipes: URL %q has no host", u.Redacted()) } return nil } // checkRedirect applies checkURL to every hop and stops after // MaxRedirects. The hop's address is checked when it is dialed. func checkRedirect(req *http.Request, via []*http.Request) error { if len(via) > MaxRedirects { return fmt.Errorf("%w: more than %d", ErrTooManyRedirects, MaxRedirects) } return checkURL(req.URL) } // blockedIPv4 are the IPv4 special-purpose ranges that are not globally // reachable (IANA IPv4 Special-Purpose Address Registry). IPv4-mapped IPv6 // addresses are unmapped before the check, so these cover them too. var blockedIPv4 = []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/8"), // "this network" netip.MustParsePrefix("10.0.0.0/8"), // RFC 1918 netip.MustParsePrefix("100.64.0.0/10"), // carrier-grade NAT netip.MustParsePrefix("127.0.0.0/8"), // loopback netip.MustParsePrefix("169.254.0.0/16"), // link-local netip.MustParsePrefix("172.16.0.0/12"), // RFC 1918 netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments netip.MustParsePrefix("192.0.2.0/24"), // documentation, TEST-NET-1 netip.MustParsePrefix("192.88.99.0/24"), // 6to4 relay anycast (deprecated) netip.MustParsePrefix("192.168.0.0/16"), // RFC 1918 netip.MustParsePrefix("198.18.0.0/15"), // benchmarking netip.MustParsePrefix("198.51.100.0/24"), // documentation, TEST-NET-2 netip.MustParsePrefix("203.0.113.0/24"), // documentation, TEST-NET-3 netip.MustParsePrefix("224.0.0.0/4"), // multicast netip.MustParsePrefix("240.0.0.0/4"), // reserved, broadcast } // globalIPv6 is the only IPv6 range dialed: global unicast. Everything // outside it is refused, including ::, ::1, IPv4-compatible and // IPv4-translated forms, NAT64 (64:ff9b::/96), discard-only (100::/64), // unique local (fc00::/7), link-local (fe80::/10) and multicast (ff00::/8). var globalIPv6 = netip.MustParsePrefix("2000::/3") // blockedIPv6 are the special-purpose ranges inside global unicast that are // not public or embed an arbitrary IPv4 address. var blockedIPv6 = []netip.Prefix{ netip.MustParsePrefix("2001::/23"), // IETF protocol assignments, Teredo, benchmarking netip.MustParsePrefix("2001:db8::/32"), // documentation netip.MustParsePrefix("2002::/16"), // 6to4, embeds any IPv4 netip.MustParsePrefix("3fff::/20"), // documentation } // IsPublic reports whether ip is a public unicast address the Fetcher may // dial. IPv4 addresses are public unless in a special-purpose range; IPv6 // addresses must be global unicast and outside its special-purpose ranges. func IsPublic(ip netip.Addr) bool { if !ip.IsValid() { return false } ip = ip.Unmap().WithZone("") blocked := blockedIPv4 if ip.Is6() { if !globalIPv6.Contains(ip) { return false } blocked = blockedIPv6 } for _, p := range blocked { if p.Contains(ip) { return false } } return true } // sleep waits d or until ctx is done, returning ctx's error if it is. func sleep(ctx context.Context, d time.Duration) error { t := time.NewTimer(d) defer t.Stop() select { case <-t.C: return nil case <-ctx.Done(): return ctx.Err() } }