package dinners import ( "context" "database/sql" "errors" "fmt" "net" "net/url" "strconv" "strings" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" ) // Detail is a dinner with its recipe, as the recipe detail screen and // Kitchen reading mode show it. type Detail struct { ID string `json:"id"` Name string `json:"name"` Status string `json:"status"` Origin string `json:"origin"` Method *string `json:"method"` MinutesTotal *int64 `json:"minutes_total"` Difficulty *string `json:"difficulty"` Cuisine *string `json:"cuisine"` IsFavorite bool `json:"is_favorite"` // PhotoURLs is the cached photo at each stored width, keyed by width // ("400", "1200"); null when the dinner has no photo. PhotoURLs map[string]string `json:"photo_urls"` // Ingredients and Steps are the recipe's, in recipe order; empty when // the dinner has no recipe. Ingredients []Ingredient `json:"ingredients"` Steps []string `json:"steps"` // Source is the site of the recipe page the dinner is grounded in; // null for a recipe entered by hand or a dinner with no recipe. Source *Source `json:"source"` GroundingStatus string `json:"grounding_status"` // GroundingError is why grounding failed, one of the Reason codes; // null unless GroundingStatus is failed. GroundingError *string `json:"grounding_error"` // WhyPicked is the reason of the suggestion that created the dinner // (see GetDinnerSuggestionReasons); null for a dinner no suggestion // created. WhyPicked *string `json:"why_picked"` } // Ingredient is one recipe line. RawText is the line as the recipe gives // it; Name, Qty, Unit and Note are normalize_recipe's reading of it, each // null when it has none, and Name, Qty and Unit always null for a recipe // entered by hand (see IsManualRecipe). type Ingredient struct { Name *string `json:"name"` Qty *string `json:"qty"` Unit *string `json:"unit"` Note *string `json:"note"` RawText string `json:"raw_text"` } // Source is the site a recipe was fetched from. SiteName is the name the // page itself gives its site, and null when it gives none or the stored // name could have come from a link (see pageSiteName). URL is the page's // link only when it is verifiably free of secrets (see safeSourceURL), and // null otherwise: a link pasted or offered as a candidate may carry a // personal token, so a client links the credit only when URL is not null. // Source is set for every fetched recipe, so a client always credits it: // by SiteName, else by URL's host, else without naming the site. type Source struct { SiteName *string `json:"site_name"` URL *string `json:"url"` } // Get returns dinner id with its recipe and the reason it was suggested, // read in one read transaction. An unknown or archived dinner is // ErrNotFound: an archived dinner has no detail to show. func (s *Service) Get(ctx context.Context, id string) (d Detail, err error) { tx, err := s.st.Reader.BeginTx(ctx, nil) if err != nil { return Detail{}, fmt.Errorf("dinners: begin read: %w", err) } defer func() { err = errors.Join(err, tx.Rollback()) }() q := db.New(tx) row, err := q.GetDinner(ctx, id) if errors.Is(err, sql.ErrNoRows) { return Detail{}, ErrNotFound } if err != nil { return Detail{}, fmt.Errorf("dinners: read %s: %w", id, err) } if row.Status == StatusArchived { return Detail{}, ErrNotFound } d = Detail{ ID: row.ID, Name: row.Name, Status: row.Status, Origin: row.Origin, Method: nullString(row.Method), Difficulty: nullString(row.Difficulty), Cuisine: nullString(row.Cuisine), IsFavorite: row.IsFavorite == 1, Ingredients: []Ingredient{}, Steps: []string{}, GroundingStatus: row.GroundingStatus, GroundingError: nullString(row.GroundingError), } if row.MinutesTotal.Valid { d.MinutesTotal = &row.MinutesTotal.Int64 } if row.PhotoID.Valid { d.PhotoURLs = make(map[string]string, len(recipes.PhotoSizes)) for _, size := range recipes.PhotoSizes { d.PhotoURLs[strconv.Itoa(size)] = photoURL(row.PhotoID.String, size) } } if (row.GroundingStatus == GroundingGrounded || row.GroundingStatus == GroundingManual) && !row.RecipeID.Valid { return Detail{}, fmt.Errorf("dinners: %s is %s with no recipe", id, row.GroundingStatus) } if row.RecipeID.Valid { if err := readRecipe(ctx, q, row.RecipeID.String, &d); err != nil { return Detail{}, fmt.Errorf("dinners: read recipe of %s: %w", id, err) } } reasons, err := q.GetDinnerSuggestionReasons(ctx, id) if err != nil { return Detail{}, fmt.Errorf("dinners: read suggestion reason of %s: %w", id, err) } switch len(reasons) { case 0: case 1: d.WhyPicked = &reasons[0] default: // Nothing records which of them created the dinner, and any // one picked would be a guess. return Detail{}, fmt.Errorf("dinners: %s has %d suggestions that could have created it", id, len(reasons)) } return d, nil } // readRecipe sets d's Source, Ingredients and Steps from recipe recipeID. func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail) error { rec, err := q.GetRecipe(ctx, recipeID) if err != nil { return fmt.Errorf("recipe %s: %w", recipeID, err) } if !IsManualRecipe(rec.SourceUrl) { src, err := SourceOf(rec.SiteName, rec.SourceUrl) if err != nil { return err } d.Source = &src } ings, err := q.ListRecipeIngredients(ctx, recipeID) if err != nil { return fmt.Errorf("ingredients: %w", err) } for i, ing := range ings { if i > 0 && ing.Position == ings[i-1].Position { return duplicatePosition("ingredient", ing.Position) } d.Ingredients = append(d.Ingredients, Ingredient{ Name: nullString(ing.Name), Qty: nullString(ing.Qty), Unit: nullString(ing.Unit), Note: nullString(ing.Note), RawText: ing.RawText, }) } steps, err := q.ListRecipeSteps(ctx, recipeID) if err != nil { return fmt.Errorf("steps: %w", err) } for i, step := range steps { if i > 0 && step.Position == steps[i-1].Position { return duplicatePosition("step", step.Position) } d.Steps = append(d.Steps, step.Text) } return nil } // SourceOf is the Source of a recipe fetched from sourceURL whose stored // site name is siteName: the name only when the page gave it (see // pageSiteName) and the link only when it is free of secrets (see // safeSourceURL). It is the one way a fetched recipe's source reaches a // client. A sourceURL that does not parse is an error, whether or not a // site name is stored. func SourceOf(siteName sql.NullString, sourceURL string) (Source, error) { link, err := url.Parse(sourceURL) if err != nil { // Not wrapped: url.Parse's error quotes the link. return Source{}, errors.New("source url does not parse") } return Source{SiteName: pageSiteName(siteName, link), URL: safeSourceURL(sourceURL)}, nil } // pageSiteName is the stored site name of a recipe fetched from link // when it is one the page gave, else nil. A page that names no site is // stored under recipes.HostSiteName of the URL it was served from: the // pasted link's host, which may hold a personal token (a private // subdomain), or that of a page the link redirected to, which is not // stored. Nothing records which a stored name is, so any name that could // be a host — the link's own host, an IP address, or a dotted host name — // is treated as one and withheld. A site that names itself as a domain, // such as "Food.com", loses its name with them: a credit that does not // name the site is better than one that leaks a link. func pageSiteName(stored sql.NullString, link *url.URL) *string { if !stored.Valid { return nil } name := stored.String if strings.EqualFold(name, recipes.HostSiteName(link)) || net.ParseIP(name) != nil { return nil } if u, err := url.Parse("https://" + name + "/"); err == nil && u.Host == name && strings.Contains(name, ".") { return nil } return &name } // safeSourceURL is sourceURL when it is verifiably free of secrets — an // https link to a host, with no user or password, no query (not even a // bare "?") and no fragment (not even a bare "#") — else nil. Those are // the places a link carries a token; a link failing any check, or not // parsing, is withheld rather than trimmed, since a trimmed link is not the // page the recipe came from. The client (sourceUrl in // web/src/dinners/detail.ts) parses the link with the browser's URL parser // and refuses the whole recipe when it fails, so the host and port must // also be ones it parses: see browserHost. func safeSourceURL(sourceURL string) *string { u, err := url.Parse(sourceURL) if err != nil || u.Scheme != "https" || u.Opaque != "" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(sourceURL, "#") { return nil } // url.Parse decodes a percent-encoded host; the link must spell its // host out as it is checked. authority, _, _ := strings.Cut(sourceURL[len(u.Scheme)+len("://"):], "/") if authority != u.Host || !browserHost(u) { return nil } if port := u.Port(); port != "" { if n, err := strconv.ParseUint(port, 10, 16); err != nil || n == 0 { return nil } } return &sourceURL } // browserHost says whether u's host is one the browser's URL parser (the // WHATWG URL Standard) accepts as it stands, where url.Parse accepts more, // such as "[bad]". The rule is narrower than the browser's: a bracketed // host is an IPv6 address without a zone; any other is ASCII letters, // digits and hyphens in dot-separated labels, none empty but a trailing // one, none punycode ("xn--"), since the browser refuses invalid punycode // and checking it is not worth a decoder; and a host whose last label is // a number, which the browser reads as an IPv4 address, must be a dotted // quad. A link to any other host is withheld. func browserHost(u *url.URL) bool { host := u.Hostname() if strings.HasPrefix(u.Host, "[") { ip := net.ParseIP(host) return ip != nil && strings.Contains(host, ":") } labels := strings.Split(strings.TrimSuffix(host, "."), ".") for _, label := range labels { if label == "" || len(label) >= 4 && strings.EqualFold(label[:4], "xn--") { return false } for _, c := range label { if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || c == '-') { return false } } } last := labels[len(labels)-1] if strings.Trim(last, "0123456789") == "" || len(last) >= 2 && strings.EqualFold(last[:2], "0x") { ip := net.ParseIP(host) return ip != nil && ip.To4() != nil && !strings.Contains(host, ":") } return true } // duplicatePosition is the error for two recipe items of kind at position. // Position is the only record of recipe order, and the id that breaks the // tie says nothing about it, so a recipe with a duplicate is refused rather // than shown in an order nobody can trust. func duplicatePosition(kind string, position int64) error { return fmt.Errorf("two %ss at position %d: recipe order is unknown", kind, position) }