package recipes import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" ) const ( // MaxSearchResults is the most candidates one search may ask for; it is // Brave's largest page. MaxSearchResults = 20 // SearchTimeout bounds one search request, reading the body included. SearchTimeout = 10 * time.Second // MaxSearchBodyBytes caps a search response body. A larger one is an // error, never truncated. MaxSearchBodyBytes = 1 << 20 // BraveEndpoint is the Brave Web Search API. BraveEndpoint = "https://api.search.brave.com/res/v1/web/search" ) var ( // ErrSearchRefused is returned for HTTP 401 or 403: Brave rejected the // API key, or SearXNG does not allow format=json. ErrSearchRefused = errors.New("recipes: search provider refused the request") // ErrSearchRateLimited is returned for HTTP 429. ErrSearchRateLimited = errors.New("recipes: search provider rate limit reached") // ErrSearchResponse is returned for a response body that is not the // provider's JSON shape — SearXNG without a results array, Brave without // "type": "search" or with a web object lacking a results array — is // over MaxSearchBodyBytes, or holds a result without an absolute http(s) // URL. ErrSearchResponse = errors.New("recipes: malformed search response") ) // SearchStatusError is returned for any other non-2xx search response, // redirects included: a search endpoint is called directly and never // followed elsewhere. type SearchStatusError struct { Provider string Status int } func (e *SearchStatusError) Error() string { return fmt.Sprintf("recipes: %s search: HTTP %d", e.Provider, e.Status) } // Candidate is one search result: a page that may hold a recipe. type Candidate struct { URL string Title string Snippet string } // SearchProvider finds recipe pages on the web. type SearchProvider interface { // Search returns up to n candidates for query, best first. The query // gets " recipe" appended unless it already has the word. n must be // 1 to MaxSearchResults. Search(ctx context.Context, query string, n int) ([]Candidate, error) } // NewSearchProvider returns the provider s names. SearXNG is called at // s.BaseUrl; Brave is called with the API key in s.ApiKeyCt, decrypted by // box. func NewSearchProvider(s db.RecipeSearchSetting, box *secretbox.Box) (SearchProvider, error) { switch s.Provider { case "searxng": return NewSearXNG(s.BaseUrl.String) case "brave": if len(s.ApiKeyCt) == 0 { return nil, errors.New("recipes: Brave search has no API key") } key, err := box.Decrypt(s.ApiKeyCt) if err != nil { return nil, fmt.Errorf("recipes: Brave API key: %w", err) } return NewBrave(string(key)) default: return nil, fmt.Errorf("recipes: unknown search provider %q", s.Provider) } } // SearXNG searches through a SearXNG instance. Its base URL is a configured // endpoint and may be on a private network, so it is called directly rather // than through the public-only Fetcher. type SearXNG struct { endpoint *url.URL client *http.Client } // NewSearXNG returns a SearXNG provider for the instance at baseURL, an // absolute http:// or https:// URL without credentials, query or fragment. func NewSearXNG(baseURL string) (*SearXNG, error) { if baseURL == "" { return nil, errors.New("recipes: SearXNG URL is not set") } u, err := url.Parse(baseURL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return nil, errors.New("recipes: SearXNG URL must be an absolute http:// or https:// URL") } if u.User != nil || u.RawQuery != "" || u.Fragment != "" { return nil, errors.New("recipes: SearXNG URL must not have credentials, a query or a fragment") } return &SearXNG{endpoint: u.JoinPath("search"), client: newSearchClient()}, nil } // Search implements SearchProvider with // {base}/search?q=…&format=json&categories=general. func (s *SearXNG) Search(ctx context.Context, query string, n int) ([]Candidate, error) { q, err := searchQuery(query, n) if err != nil { return nil, err } u := *s.endpoint u.RawQuery = url.Values{"q": {q}, "format": {"json"}, "categories": {"general"}}.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, fmt.Errorf("recipes: build SearXNG request: %w", err) } // Results is a pointer so a body without the array is told apart from // an empty search. var body struct { Results *[]struct { URL string `json:"url"` Title string `json:"title"` Content string `json:"content"` } `json:"results"` } if err := doSearch(s.client, req, "SearXNG", &body); err != nil { return nil, err } if body.Results == nil { return nil, fmt.Errorf("%w: SearXNG body has no results array", ErrSearchResponse) } var out []Candidate for _, r := range *body.Results { out = append(out, Candidate{URL: r.URL, Title: r.Title, Snippet: r.Content}) } return candidates("SearXNG", out, n) } // Brave searches through the Brave Web Search API. type Brave struct { key string endpoint string client *http.Client } // NewBrave returns a Brave provider that authenticates with key, the // decrypted API key. func NewBrave(key string) (*Brave, error) { if key == "" { return nil, errors.New("recipes: Brave API key is empty") } for i := 0; i < len(key); i++ { if key[i] < 0x21 || key[i] > 0x7e { // The key is never echoed. return nil, errors.New("recipes: Brave API key has a character not allowed in a header") } } return &Brave{key: key, endpoint: BraveEndpoint, client: newSearchClient()}, nil } // Search implements SearchProvider with GET BraveEndpoint?q=…&count=n. func (b *Brave) Search(ctx context.Context, query string, n int) ([]Candidate, error) { q, err := searchQuery(query, n) if err != nil { return nil, err } u := b.endpoint + "?" + url.Values{"q": {q}, "count": {strconv.Itoa(n)}}.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, fmt.Errorf("recipes: build Brave request: %w", err) } req.Header.Set("X-Subscription-Token", b.key) // Brave leaves out "web" when nothing matched, so a search response is // recognised by its "type"; a web object present must hold the results // array. var body struct { Type string `json:"type"` Web *struct { Results *[]struct { URL string `json:"url"` Title string `json:"title"` Description string `json:"description"` } `json:"results"` } `json:"web"` } if err := doSearch(b.client, req, "Brave", &body); err != nil { return nil, err } if body.Type != "search" { return nil, fmt.Errorf("%w: Brave body type is %q, not \"search\"", ErrSearchResponse, body.Type) } if body.Web == nil { return nil, nil } if body.Web.Results == nil { return nil, fmt.Errorf("%w: Brave web object has no results array", ErrSearchResponse) } var out []Candidate for _, r := range *body.Web.Results { out = append(out, Candidate{URL: r.URL, Title: r.Title, Snippet: r.Description}) } return candidates("Brave", out, n) } func newSearchClient() *http.Client { return &http.Client{ Timeout: SearchTimeout, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } } // searchQuery validates n and returns query with " recipe" appended unless // one of its words already is "recipe", in any case. func searchQuery(query string, n int) (string, error) { if n < 1 || n > MaxSearchResults { return "", fmt.Errorf("recipes: search result count %d is outside 1-%d", n, MaxSearchResults) } q := strings.TrimSpace(query) if q == "" { return "", errors.New("recipes: search query is empty") } for _, w := range strings.Fields(q) { if strings.EqualFold(w, "recipe") { return q, nil } } return q + " recipe", nil } // doSearch sends req and decodes a 2xx JSON body into v, mapping failures to // the search errors. func doSearch(client *http.Client, req *http.Request, provider string, v any) error { req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", UserAgent) resp, err := client.Do(req) if err != nil { if ctxErr := req.Context().Err(); ctxErr != nil { return ctxErr } return fmt.Errorf("recipes: %s search: %w", provider, err) } defer resp.Body.Close() switch { case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden: return fmt.Errorf("%w: %s HTTP %d", ErrSearchRefused, provider, resp.StatusCode) case resp.StatusCode == http.StatusTooManyRequests: return fmt.Errorf("%w: %s", ErrSearchRateLimited, provider) case resp.StatusCode < 200 || resp.StatusCode > 299: return &SearchStatusError{Provider: provider, Status: resp.StatusCode} } data, err := io.ReadAll(io.LimitReader(resp.Body, MaxSearchBodyBytes+1)) if err != nil { return fmt.Errorf("recipes: %s search: read body: %w", provider, err) } if len(data) > MaxSearchBodyBytes { return fmt.Errorf("%w: %s body is over %d bytes", ErrSearchResponse, provider, MaxSearchBodyBytes) } if err := json.Unmarshal(data, v); err != nil { return fmt.Errorf("%w: %s: %v", ErrSearchResponse, provider, err) } return nil } // candidates cleans results — HTML tags and entities, which both providers // leave in titles and snippets, are removed — checks every URL is absolute // http(s) and keeps the first n. func candidates(provider string, results []Candidate, n int) ([]Candidate, error) { if len(results) > n { results = results[:n] } out := make([]Candidate, 0, len(results)) for i, r := range results { u, err := url.Parse(r.URL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return nil, fmt.Errorf("%w: %s result %d has no absolute http(s) URL", ErrSearchResponse, provider, i) } out = append(out, Candidate{URL: r.URL, Title: stripTags(r.Title), Snippet: stripTags(r.Snippet)}) } return out, nil } // stripTags drops HTML tags such as Brave's highlights, then // decodes entities and trims space. func stripTags(s string) string { var b strings.Builder inTag := false for _, r := range s { switch { case r == '<': inTag = true case r == '>' && inTag: inTag = false case !inTag: b.WriteRune(r) } } return cleanText(b.String()) }