CI / ci (push) Successful in 2m0s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
246 lines
8.0 KiB
Go
246 lines
8.0 KiB
Go
package recipes
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
)
|
|
|
|
const (
|
|
// SearchTestQuery is what TestSearch searches for.
|
|
SearchTestQuery = "chicken tikka masala"
|
|
// MinBraveKeyLength is the shortest Brave API key accepted, so its last
|
|
// four characters never give away most of it.
|
|
MinBraveKeyLength = 8
|
|
)
|
|
|
|
// ErrSearchNotConfigured is returned when no recipe search provider has
|
|
// been chosen yet.
|
|
var ErrSearchNotConfigured = errors.New("recipes: recipe search is not configured")
|
|
|
|
// SearchSettings is the household's recipe search configuration as the API
|
|
// shows it. The Brave API key is never part of it: only whether one is
|
|
// stored and its last four characters.
|
|
type SearchSettings struct {
|
|
// Provider is "searxng" or "brave", nil until first chosen.
|
|
Provider *string `json:"provider"`
|
|
// BaseURL is the SearXNG instance's URL, nil when not set.
|
|
BaseURL *string `json:"base_url"`
|
|
APIKeySet bool `json:"api_key_set"`
|
|
APIKeyLast4 *string `json:"api_key_last4"`
|
|
// UpdatedAt is nil until the settings are first saved.
|
|
UpdatedAt *string `json:"updated_at"`
|
|
}
|
|
|
|
// SearchSettingsPatch changes the settings. A nil field is left as it is;
|
|
// an empty BaseURL or APIKey removes the stored one.
|
|
type SearchSettingsPatch struct {
|
|
Provider *string `json:"provider"`
|
|
BaseURL *string `json:"base_url"`
|
|
// APIKey is the Brave API key in plain text. It is stored encrypted
|
|
// and never returned.
|
|
APIKey *string `json:"api_key"`
|
|
}
|
|
|
|
// SearchSettingsProblem is one invalid field of a SearchSettingsPatch,
|
|
// named by its JSON key.
|
|
type SearchSettingsProblem struct {
|
|
Field string
|
|
Reason string
|
|
}
|
|
|
|
// SearchSettingsError lists every problem with a SearchSettingsPatch, or
|
|
// with the settings it would leave.
|
|
type SearchSettingsError struct {
|
|
Problems []SearchSettingsProblem
|
|
}
|
|
|
|
func (e *SearchSettingsError) Error() string {
|
|
parts := make([]string, len(e.Problems))
|
|
for i, p := range e.Problems {
|
|
parts[i] = p.Field + ": " + p.Reason
|
|
}
|
|
return "invalid recipe search settings: " + strings.Join(parts, "; ")
|
|
}
|
|
|
|
// SearchSettingsService reads and changes the recipe_search_settings row
|
|
// and builds the search provider it names. Brave API keys are sealed with
|
|
// its secret box.
|
|
type SearchSettingsService struct {
|
|
st *store.Store
|
|
box *secretbox.Box
|
|
// newProvider is NewSearchProvider; tests point Brave elsewhere.
|
|
newProvider func(db.RecipeSearchSetting, *secretbox.Box) (SearchProvider, error)
|
|
}
|
|
|
|
// NewSearchSettingsService returns the service over st, sealing keys with
|
|
// box.
|
|
func NewSearchSettingsService(st *store.Store, box *secretbox.Box) (*SearchSettingsService, error) {
|
|
if st == nil {
|
|
return nil, errors.New("recipes: nil store")
|
|
}
|
|
if box == nil {
|
|
return nil, errors.New("recipes: nil secret box")
|
|
}
|
|
return &SearchSettingsService{st: st, box: box, newProvider: NewSearchProvider}, nil
|
|
}
|
|
|
|
// Get returns the settings; before any are saved every field is unset.
|
|
func (s *SearchSettingsService) Get(ctx context.Context) (SearchSettings, error) {
|
|
row, err := db.New(s.st.Reader).GetRecipeSearchSettings(ctx)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return SearchSettings{}, nil
|
|
}
|
|
if err != nil {
|
|
return SearchSettings{}, fmt.Errorf("recipes: read recipe search settings: %w", err)
|
|
}
|
|
return searchSettingsFrom(row), nil
|
|
}
|
|
|
|
// Update applies patch and returns the settings it leaves. The first update
|
|
// must choose a provider. The result must be usable: SearXNG needs a base
|
|
// URL and Brave an API key. Every problem is reported in a
|
|
// *SearchSettingsError.
|
|
func (s *SearchSettingsService) Update(ctx context.Context, patch SearchSettingsPatch) (SearchSettings, error) {
|
|
var saved db.RecipeSearchSetting
|
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
|
q := db.New(tx)
|
|
row, err := q.GetRecipeSearchSettings(ctx)
|
|
exists := err == nil
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return fmt.Errorf("recipes: read recipe search settings: %w", err)
|
|
}
|
|
next, err := s.apply(row, exists, patch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := store.Now().String()
|
|
created := now
|
|
if exists {
|
|
created = row.CreatedAt
|
|
}
|
|
saved, err = q.UpsertRecipeSearchSettings(ctx, db.UpsertRecipeSearchSettingsParams{
|
|
Provider: next.Provider,
|
|
BaseUrl: next.BaseUrl,
|
|
ApiKeyCt: next.ApiKeyCt,
|
|
ApiKeyLast4: next.ApiKeyLast4,
|
|
CreatedAt: created,
|
|
UpdatedAt: now,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("recipes: save recipe search settings: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return SearchSettings{}, err
|
|
}
|
|
return searchSettingsFrom(saved), nil
|
|
}
|
|
|
|
// apply returns row with patch applied, validated.
|
|
func (s *SearchSettingsService) apply(row db.RecipeSearchSetting, exists bool, patch SearchSettingsPatch) (db.RecipeSearchSetting, error) {
|
|
var problems []SearchSettingsProblem
|
|
if patch.Provider != nil {
|
|
switch *patch.Provider {
|
|
case "searxng", "brave":
|
|
row.Provider = *patch.Provider
|
|
default:
|
|
problems = append(problems, SearchSettingsProblem{"provider", `must be "searxng" or "brave"`})
|
|
}
|
|
} else if !exists {
|
|
problems = append(problems, SearchSettingsProblem{"provider", "is required until recipe search is configured"})
|
|
}
|
|
if patch.BaseURL != nil {
|
|
row.BaseUrl = sql.NullString{}
|
|
if u := *patch.BaseURL; u != "" {
|
|
if _, err := NewSearXNG(u); err != nil {
|
|
problems = append(problems, SearchSettingsProblem{"base_url", strings.TrimPrefix(err.Error(), "recipes: ")})
|
|
} else {
|
|
row.BaseUrl = sql.NullString{String: u, Valid: true}
|
|
}
|
|
}
|
|
}
|
|
if patch.APIKey != nil {
|
|
row.ApiKeyCt, row.ApiKeyLast4 = nil, sql.NullString{}
|
|
if key := *patch.APIKey; key != "" {
|
|
// The key is never echoed in a problem.
|
|
if _, err := NewBrave(key); err != nil {
|
|
problems = append(problems, SearchSettingsProblem{"api_key", "has a character not allowed in a header"})
|
|
} else if len(key) < MinBraveKeyLength {
|
|
problems = append(problems, SearchSettingsProblem{"api_key", fmt.Sprintf("must be at least %d characters", MinBraveKeyLength)})
|
|
} else {
|
|
ct, err := s.box.Encrypt([]byte(key))
|
|
if err != nil {
|
|
return db.RecipeSearchSetting{}, fmt.Errorf("recipes: seal Brave API key: %w", err)
|
|
}
|
|
row.ApiKeyCt = ct
|
|
row.ApiKeyLast4 = sql.NullString{String: key[len(key)-4:], Valid: true}
|
|
}
|
|
}
|
|
}
|
|
if len(problems) == 0 {
|
|
switch {
|
|
case row.Provider == "searxng" && !row.BaseUrl.Valid:
|
|
problems = append(problems, SearchSettingsProblem{"base_url", "is required for SearXNG"})
|
|
case row.Provider == "brave" && len(row.ApiKeyCt) == 0:
|
|
problems = append(problems, SearchSettingsProblem{"api_key", "is required for Brave"})
|
|
}
|
|
}
|
|
if len(problems) > 0 {
|
|
return db.RecipeSearchSetting{}, &SearchSettingsError{Problems: problems}
|
|
}
|
|
return row, nil
|
|
}
|
|
|
|
// Provider returns the search provider the settings name, or
|
|
// ErrSearchNotConfigured.
|
|
func (s *SearchSettingsService) Provider(ctx context.Context) (SearchProvider, error) {
|
|
row, err := db.New(s.st.Reader).GetRecipeSearchSettings(ctx)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrSearchNotConfigured
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("recipes: read recipe search settings: %w", err)
|
|
}
|
|
return s.newProvider(row, s.box)
|
|
}
|
|
|
|
// TestSearch searches for SearchTestQuery with the configured provider and
|
|
// returns how many candidates it found, up to MaxSearchResults.
|
|
func (s *SearchSettingsService) TestSearch(ctx context.Context) (int, error) {
|
|
p, err := s.Provider(ctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
cands, err := p.Search(ctx, SearchTestQuery, MaxSearchResults)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return len(cands), nil
|
|
}
|
|
|
|
func searchSettingsFrom(row db.RecipeSearchSetting) SearchSettings {
|
|
provider, updated := row.Provider, row.UpdatedAt
|
|
return SearchSettings{
|
|
Provider: &provider,
|
|
BaseURL: nullString(row.BaseUrl),
|
|
APIKeySet: len(row.ApiKeyCt) > 0,
|
|
APIKeyLast4: nullString(row.ApiKeyLast4),
|
|
UpdatedAt: &updated,
|
|
}
|
|
}
|
|
|
|
func nullString(s sql.NullString) *string {
|
|
if !s.Valid {
|
|
return nil
|
|
}
|
|
return &s.String
|
|
}
|