ai cooking
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Use consistent prefixes in cache. (#232)

* migration

* doc cache layout

* centralize serialize/deserialze in io.go

* get rid of legacy prefixes and point sitemap at new place

* a little closer

* sitemap is dead simple

* lint suppression

* whoops

* unused

---------

Co-authored-by: paul miller <paul.miller>

authored by

Paul Miller
paul miller
and committed by
GitHub
bde11e77 1bd2a7eb

+932 -66
+253
cmd/moveshoppinglists/main.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "careme/internal/cache" 6 + "context" 7 + "encoding/base64" 8 + "errors" 9 + "flag" 10 + "fmt" 11 + "io" 12 + "io/fs" 13 + "log" 14 + "os" 15 + "path/filepath" 16 + "slices" 17 + "strings" 18 + 19 + "github.com/samber/lo" 20 + ) 21 + 22 + const shoppingListPrefix = "shoppinglist/" 23 + const paramsPrefix = "params/" 24 + const ingredientsPrefix = "ingredients/" 25 + const legacyRecipeHashSeed = "recipe" 26 + const legacyIngredientsHashSeed = "ingredients" 27 + 28 + type migrationStats struct { 29 + RootKeys int 30 + ShoppingListKeys int 31 + ParamsKeys int 32 + IngredientsKeys int 33 + Copied int 34 + SkippedExisting int 35 + SkippedUnsupported int 36 + } 37 + 38 + func main() { 39 + var apply bool 40 + flag.BoolVar(&apply, "apply", false, "Apply changes. Default is dry-run.") 41 + flag.Parse() 42 + 43 + ctx := context.Background() 44 + c, err := cache.MakeCache() 45 + if err != nil { 46 + log.Fatalf("failed to create cache: %v", err) 47 + } 48 + 49 + stats, err := migrateShoppingLists(ctx, c, apply, os.Stdout) 50 + if err != nil { 51 + log.Fatalf("migration failed: %v", err) 52 + } 53 + 54 + fmt.Printf( 55 + "done: root=%d shoppinglists=%d params=%d ingredients=%d copied=%d skipped_existing=%d skipped_unsupported=%d mode=%s\n", 56 + stats.RootKeys, 57 + stats.ShoppingListKeys, 58 + stats.ParamsKeys, 59 + stats.IngredientsKeys, 60 + stats.Copied, 61 + stats.SkippedExisting, 62 + stats.SkippedUnsupported, 63 + mode(apply), 64 + ) 65 + } 66 + 67 + func mode(apply bool) string { 68 + if apply { 69 + return "apply" 70 + } 71 + return "dry-run" 72 + } 73 + 74 + func migrateShoppingLists(ctx context.Context, c cache.ListCache, apply bool, out io.Writer) (migrationStats, error) { 75 + var stats migrationStats 76 + 77 + rootKeys, err := listRootKeys(ctx, c) 78 + if err != nil { 79 + return stats, err 80 + } 81 + stats.RootKeys = len(rootKeys) 82 + 83 + for _, key := range rootKeys { 84 + switch { 85 + case strings.HasSuffix(key, ".params"): 86 + stats.ParamsKeys++ 87 + recipeHash := strings.TrimSuffix(key, ".params") 88 + newKey := paramsPrefix + canonicalOrOriginalHash(recipeHash, legacyRecipeHashSeed) 89 + copied, skipped, err := copyKey(ctx, c, key, newKey, apply, out) 90 + if err != nil { 91 + return stats, err 92 + } 93 + stats.Copied += copied 94 + stats.SkippedExisting += skipped 95 + continue 96 + case hasLegacyHashSeed(key, legacyIngredientsHashSeed): 97 + stats.IngredientsKeys++ 98 + newKey := ingredientsPrefix + canonicalOrOriginalHash(key, legacyIngredientsHashSeed) 99 + copied, skipped, err := copyKey(ctx, c, key, newKey, apply, out) 100 + if err != nil { 101 + return stats, err 102 + } 103 + stats.Copied += copied 104 + stats.SkippedExisting += skipped 105 + continue 106 + case hasLegacyHashSeed(key, legacyRecipeHashSeed): 107 + stats.ShoppingListKeys++ 108 + newKey := shoppingListPrefix + canonicalOrOriginalHash(key, legacyRecipeHashSeed) 109 + copied, skipped, err := copyKey(ctx, c, key, newKey, apply, out) 110 + if err != nil { 111 + return stats, err 112 + } 113 + stats.Copied += copied 114 + stats.SkippedExisting += skipped 115 + default: 116 + stats.SkippedUnsupported++ 117 + continue 118 + } 119 + } 120 + 121 + return stats, nil 122 + } 123 + 124 + func copyKey(ctx context.Context, c cache.Cache, srcKey, dstKey string, apply bool, out io.Writer) (copied int, skippedExisting int, err error) { 125 + exists, err := c.Exists(ctx, dstKey) 126 + if err != nil { 127 + return 0, 0, fmt.Errorf("check destination %q: %w", dstKey, err) 128 + } 129 + if exists { 130 + _ = lo.Must(fmt.Fprintf(out, "skip existing %s -> %s\n", srcKey, dstKey)) 131 + return 0, 1, nil 132 + } 133 + 134 + if !apply { 135 + _ = lo.Must(fmt.Fprintf(out, "would copy %s -> %s\n", srcKey, dstKey)) 136 + return 1, 0, nil 137 + } 138 + 139 + payload, err := readKey(ctx, c, srcKey) 140 + if err != nil { 141 + return 0, 0, fmt.Errorf("read %q: %w", srcKey, err) 142 + } 143 + if err := c.Put(ctx, dstKey, string(payload), cache.IfNoneMatch()); err != nil { 144 + if errors.Is(err, cache.ErrAlreadyExists) { 145 + _ = lo.Must(fmt.Fprintf(out, "skip existing %s -> %s\n", srcKey, dstKey)) 146 + return 0, 1, nil 147 + } 148 + return 0, 0, fmt.Errorf("write %q: %w", dstKey, err) 149 + } 150 + 151 + _ = lo.Must(fmt.Fprintf(out, "copied %s -> %s\n", srcKey, dstKey)) 152 + return 1, 0, nil 153 + } 154 + 155 + func listRootKeys(ctx context.Context, c cache.ListCache) ([]string, error) { 156 + if fc, ok := c.(*cache.FileCache); ok { 157 + return listFileRootKeys(fc.Dir) 158 + } 159 + 160 + keys, err := c.List(ctx, "", "") 161 + if err != nil { 162 + return nil, err 163 + } 164 + return normalizeRootKeys(keys), nil 165 + } 166 + 167 + func listFileRootKeys(dir string) ([]string, error) { 168 + if _, err := os.Stat(dir); err != nil { 169 + if os.IsNotExist(err) { 170 + return nil, nil 171 + } 172 + return nil, err 173 + } 174 + 175 + keys := make([]string, 0, 128) 176 + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { 177 + if err != nil { 178 + return err 179 + } 180 + if d.IsDir() { 181 + return nil 182 + } 183 + 184 + rel, err := filepath.Rel(dir, path) 185 + if err != nil { 186 + return err 187 + } 188 + key := filepath.ToSlash(rel) 189 + if key == "." || strings.Contains(key, "/") { 190 + return nil 191 + } 192 + keys = append(keys, key) 193 + return nil 194 + }) 195 + if err != nil { 196 + return nil, err 197 + } 198 + 199 + slices.Sort(keys) 200 + return keys, nil 201 + } 202 + 203 + func normalizeRootKeys(keys []string) []string { 204 + root := make([]string, 0, len(keys)) 205 + seen := make(map[string]struct{}, len(keys)) 206 + for _, key := range keys { 207 + k := filepath.ToSlash(strings.TrimSpace(key)) 208 + k = strings.TrimPrefix(k, "./") 209 + k = strings.TrimPrefix(k, "/") 210 + if k == "" || strings.Contains(k, "/") { 211 + continue 212 + } 213 + if _, ok := seen[k]; ok { 214 + continue 215 + } 216 + seen[k] = struct{}{} 217 + root = append(root, k) 218 + } 219 + slices.Sort(root) 220 + return root 221 + } 222 + 223 + func readKey(ctx context.Context, c cache.Cache, key string) ([]byte, error) { 224 + r, err := c.Get(ctx, key) 225 + if err != nil { 226 + return nil, err 227 + } 228 + defer func() { 229 + _ = r.Close() 230 + }() 231 + return io.ReadAll(r) 232 + } 233 + 234 + func hasLegacyHashSeed(hash string, seed string) bool { 235 + decoded, err := base64.URLEncoding.DecodeString(hash) 236 + if err != nil { 237 + return false 238 + } 239 + seedBytes := []byte(seed) 240 + return bytes.HasPrefix(decoded, seedBytes) && len(decoded) > len(seedBytes) 241 + } 242 + 243 + func canonicalOrOriginalHash(hash string, seed string) string { 244 + decoded, err := base64.URLEncoding.DecodeString(hash) 245 + if err != nil { 246 + return hash 247 + } 248 + seedBytes := []byte(seed) 249 + if !bytes.HasPrefix(decoded, seedBytes) || len(decoded) == len(seedBytes) { 250 + return hash 251 + } 252 + return base64.RawURLEncoding.EncodeToString(decoded[len(seedBytes):]) 253 + }
+146
cmd/moveshoppinglists/main_test.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "careme/internal/ai" 6 + "careme/internal/cache" 7 + "context" 8 + "encoding/base64" 9 + "encoding/json" 10 + "errors" 11 + "testing" 12 + ) 13 + 14 + func TestMigrateShoppingListsApply(t *testing.T) { 15 + ctx := context.Background() 16 + fc := cache.NewFileCache(t.TempDir()) 17 + canonicalShopping := "5paGKJp_BFc" 18 + canonicalIngredients := "4MVQVRdNr8M" 19 + legacyShopping := toLegacyHash(t, canonicalShopping, legacyRecipeHashSeed) 20 + legacyIngredients := toLegacyHash(t, canonicalIngredients, legacyIngredientsHashSeed) 21 + 22 + shopping := ai.ShoppingList{ 23 + ConversationID: "conv_123", 24 + Recipes: []ai.Recipe{ 25 + {Title: "Tacos"}, 26 + }, 27 + } 28 + shoppingJSON, err := json.Marshal(shopping) 29 + if err != nil { 30 + t.Fatalf("marshal shopping list: %v", err) 31 + } 32 + if err := fc.Put(ctx, legacyShopping, string(shoppingJSON), cache.Unconditional()); err != nil { 33 + t.Fatalf("seed shopping list: %v", err) 34 + } 35 + if err := fc.Put(ctx, legacyShopping+".params", `{"location":{"id":"1"}}`, cache.Unconditional()); err != nil { 36 + t.Fatalf("seed params: %v", err) 37 + } 38 + if err := fc.Put(ctx, legacyIngredients, `[{"description":"kale"}]`, cache.Unconditional()); err != nil { 39 + t.Fatalf("seed ingredients: %v", err) 40 + } 41 + if err := fc.Put(ctx, "recipe/abc", `{"title":"abc"}`, cache.Unconditional()); err != nil { 42 + t.Fatalf("seed recipe key: %v", err) 43 + } 44 + 45 + stats, err := migrateShoppingLists(ctx, fc, true, &bytes.Buffer{}) 46 + if err != nil { 47 + t.Fatalf("migrate: %v", err) 48 + } 49 + if stats.Copied != 3 { 50 + t.Fatalf("expected 3 copied keys, got %d", stats.Copied) 51 + } 52 + if stats.ShoppingListKeys != 1 || stats.ParamsKeys != 1 || stats.IngredientsKeys != 1 { 53 + t.Fatalf("expected shopping=1 params=1 ingredients=1, got shopping=%d params=%d ingredients=%d", stats.ShoppingListKeys, stats.ParamsKeys, stats.IngredientsKeys) 54 + } 55 + 56 + if _, err := fc.Get(ctx, legacyShopping); err != nil { 57 + t.Fatalf("expected old shopping list key to remain after copy: %v", err) 58 + } 59 + 60 + newBlob, err := fc.Get(ctx, "shoppinglist/"+canonicalShopping) 61 + if err != nil { 62 + t.Fatalf("expected moved shopping list: %v", err) 63 + } 64 + _ = newBlob.Close() 65 + 66 + if _, err := fc.Get(ctx, "params/"+canonicalShopping); err != nil { 67 + t.Fatalf("expected params copied to prefixed key: %v", err) 68 + } 69 + if _, err := fc.Get(ctx, "ingredients/"+canonicalIngredients); err != nil { 70 + t.Fatalf("expected ingredients copied to prefixed key: %v", err) 71 + } 72 + } 73 + 74 + func TestMigrateShoppingListsDryRun(t *testing.T) { 75 + ctx := context.Background() 76 + fc := cache.NewFileCache(t.TempDir()) 77 + canonicalShopping := "5paGKJp_BFc" 78 + legacyShopping := toLegacyHash(t, canonicalShopping, legacyRecipeHashSeed) 79 + 80 + if err := fc.Put(ctx, legacyShopping, `{"recipes":[{"title":"Soup"}]}`, cache.Unconditional()); err != nil { 81 + t.Fatalf("seed shopping list: %v", err) 82 + } 83 + 84 + stats, err := migrateShoppingLists(ctx, fc, false, &bytes.Buffer{}) 85 + if err != nil { 86 + t.Fatalf("migrate: %v", err) 87 + } 88 + if stats.Copied != 1 { 89 + t.Fatalf("expected 1 planned copy, got %d", stats.Copied) 90 + } 91 + 92 + if _, err := fc.Get(ctx, legacyShopping); err != nil { 93 + t.Fatalf("expected original key to remain in dry-run: %v", err) 94 + } 95 + if _, err := fc.Get(ctx, "shoppinglist/"+canonicalShopping); !errors.Is(err, cache.ErrNotFound) { 96 + t.Fatalf("expected destination key not created in dry-run, got err=%v", err) 97 + } 98 + } 99 + 100 + func TestMigrateShoppingListsApply_TransformsLegacySeededHashes(t *testing.T) { 101 + ctx := context.Background() 102 + fc := cache.NewFileCache(t.TempDir()) 103 + 104 + canonicalShopping := "5paGKJp_BFc" 105 + canonicalIngredients := "4MVQVRdNr8M" 106 + legacyShopping := toLegacyHash(t, canonicalShopping, legacyRecipeHashSeed) 107 + legacyIngredients := toLegacyHash(t, canonicalIngredients, legacyIngredientsHashSeed) 108 + 109 + if err := fc.Put(ctx, legacyShopping, `{"recipes":[{"title":"Soup"}]}`, cache.Unconditional()); err != nil { 110 + t.Fatalf("seed legacy shopping list: %v", err) 111 + } 112 + if err := fc.Put(ctx, legacyShopping+".params", `{"location":{"id":"1"}}`, cache.Unconditional()); err != nil { 113 + t.Fatalf("seed legacy params: %v", err) 114 + } 115 + if err := fc.Put(ctx, legacyIngredients, `[{"description":"kale"}]`, cache.Unconditional()); err != nil { 116 + t.Fatalf("seed legacy ingredients: %v", err) 117 + } 118 + 119 + stats, err := migrateShoppingLists(ctx, fc, true, &bytes.Buffer{}) 120 + if err != nil { 121 + t.Fatalf("migrate: %v", err) 122 + } 123 + if stats.Copied != 3 { 124 + t.Fatalf("expected 3 copied keys, got %d", stats.Copied) 125 + } 126 + 127 + if _, err := fc.Get(ctx, shoppingListPrefix+canonicalShopping); err != nil { 128 + t.Fatalf("expected canonical shopping list key: %v", err) 129 + } 130 + if _, err := fc.Get(ctx, paramsPrefix+canonicalShopping); err != nil { 131 + t.Fatalf("expected canonical params key: %v", err) 132 + } 133 + if _, err := fc.Get(ctx, ingredientsPrefix+canonicalIngredients); err != nil { 134 + t.Fatalf("expected canonical ingredients key: %v", err) 135 + } 136 + } 137 + 138 + func toLegacyHash(t *testing.T, canonicalHash string, seed string) string { 139 + t.Helper() 140 + decoded, err := base64.RawURLEncoding.DecodeString(canonicalHash) 141 + if err != nil { 142 + t.Fatalf("decode canonical hash %q: %v", canonicalHash, err) 143 + } 144 + legacyBytes := append([]byte(seed), decoded...) 145 + return base64.URLEncoding.EncodeToString(legacyBytes) 146 + }
+37
docs/cache-layout.md
··· 1 + # Cache Layout 2 + 3 + This project stores cache entries in: 4 + - Local filesystem under `cache/` (default) 5 + - Azure Blob container `recipes` (when `AZURE_STORAGE_ACCOUNT_NAME` is set) 6 + 7 + The same cache keys are used in both backends. Keys with `/` become subdirectories (filesystem) or blob prefixes (Azure). 8 + 9 + ## Subdirectories / Prefixes 10 + 11 + | Prefix | Stored value | Written by | Read by | 12 + | --- | --- | --- | --- | 13 + | `shoppinglist/` | JSON `ai.ShoppingList` keyed by shopping hash | `internal/recipes/io.go` (`SaveShoppingList`) | `internal/recipes/io.go` (`FromCache`) | 14 + | `ingredients/` | JSON `[]kroger.Ingredient` keyed by location hash | `internal/recipes/io.go` (`SaveIngredients`) via `internal/recipes/generator.go` (`GetStaples`) | `internal/recipes/io.go` (`IngredientsFromCache`) via `internal/recipes/generator.go` (`GetStaples`) | 15 + | `params/` | JSON `generatorParams` keyed by shopping hash | `internal/recipes/io.go` (`SaveParams`) | `internal/recipes/io.go` (`ParamsFromCache`) | 16 + | `recipe/` | JSON `ai.Recipe` (one recipe per hash) | `internal/recipes/io.go` (`SaveRecipes`) | `internal/recipes/io.go` (`SingleFromCache`) | 17 + | `recipe_thread/` | JSON `[]RecipeThreadEntry` (Q/A thread for a recipe hash) | `internal/recipes/thread.go` (`SaveThread`) | `internal/recipes/thread.go` (`ThreadFromCache`) | 18 + | `recipe_feedback/` | JSON `RecipeFeedback` (`cooked`, `stars`, `comment`, `updated_at`) per recipe hash | `internal/recipes/feedback.go` (`SaveFeedback`) via `internal/recipes/server.go` (`handleFeedback`) | `internal/recipes/feedback.go` (`FeedbackFromCache`) and `internal/recipes/server.go` (`handleSingle`, `handleFeedback`) | 19 + | `users/` | JSON `users/types.User` by user ID | `internal/users/storage.go` (`Update`) | `internal/users/storage.go` (`GetByID`, `List`) | 20 + | `email2user/` | Plain text user ID keyed by normalized email | `internal/users/storage.go` (`FindOrCreateFromClerk`) | `internal/users/storage.go` (`GetByEmail`) | 21 + 22 + ## Legacy Root Keys 23 + 24 + Legacy root keys may still exist from older deployments and are treated as migration sources: 25 + 26 + - `<legacy_seeded_shopping_hash>`: legacy shopping list payload 27 + - `<legacy_seeded_location_hash>`: legacy ingredient payload 28 + - `<legacy_seeded_shopping_hash>.params`: legacy params payload 29 + 30 + ## Notes 31 + 32 + - Cache backend selection is in `internal/cache/azure.go` (`MakeCache`). 33 + - Local cache path is `cache/` when filesystem backend is used. 34 + - Blob names in Azure match the same key strings listed above. 35 + - Back compatibility: shopping list reads check `shoppinglist/<canonical_hash>` first, then legacy root `<legacy_seeded_shopping_hash>`. Ingredient reads check `ingredients/<canonical_hash>` first, then legacy root `<legacy_seeded_location_hash>`. Params reads check `params/<canonical_hash>` first, then legacy root `<legacy_seeded_shopping_hash>.params`. 36 + - Hash compatibility: canonical shopping and location hashes are now raw FNV64 URL-safe base64. Legacy seeded hashes (prefixed with `recipe`/`ingredients` in decoded bytes) are still supported for reads; `/recipes?h=...` redirects legacy shopping hashes to canonical hashes. 37 + - Migration utility: `cmd/moveshoppinglists` copies legacy root keys into prefixed canonical keys (`shoppinglist/`, `ingredients/`, `params/`) and transforms seeded legacy hashes to canonical hashes during copy.
+7 -17
internal/recipes/generator.go
··· 8 8 "careme/internal/locations" 9 9 "context" 10 10 "encoding/json" 11 + "errors" 11 12 "fmt" 12 13 "log/slog" 13 14 "net/http" ··· 126 127 func (g *Generator) GetStaples(ctx context.Context, p *generatorParams) ([]kroger.Ingredient, error) { 127 128 lochash := p.LocationHash() 128 129 var ingredients []kroger.Ingredient 130 + rio := IO(g.cache) 129 131 130 - if ingredientblob, err := g.cache.Get(ctx, lochash); err == nil { 131 - defer func() { 132 - if err := ingredientblob.Close(); err != nil { 133 - slog.ErrorContext(ctx, "failed to close cached ingredients reader", "location", p.String(), "error", err) 134 - } 135 - }() 136 - jsonReader := json.NewDecoder(ingredientblob) 137 - if err := jsonReader.Decode(&ingredients); err == nil { 138 - slog.Info("serving cached ingredients", "location", p.String(), "hash", lochash, "count", len(ingredients)) 139 - return ingredients, nil 140 - } 132 + if cachedIngredients, err := rio.IngredientsFromCache(ctx, lochash); err == nil { 133 + slog.Info("serving cached ingredients", "location", p.String(), "hash", lochash, "count", len(cachedIngredients)) 134 + return cachedIngredients, nil 135 + } else if !errors.Is(err, cache.ErrNotFound) { 141 136 slog.ErrorContext(ctx, "failed to read cached ingredients", "location", p.String(), "error", err) 142 137 } 143 138 ··· 163 158 164 159 mutable.Shuffle(ingredients) 165 160 166 - allingredientsJSON, err := json.Marshal(ingredients) 167 - if err != nil { 168 - slog.ErrorContext(ctx, "failed to marshal ingredients", "location", p.String(), "error", err) 169 - return nil, err 170 - } 171 - if err := g.cache.Put(ctx, p.LocationHash(), string(allingredientsJSON), cache.Unconditional()); err != nil { 161 + if err := rio.SaveIngredients(ctx, p.LocationHash(), ingredients); err != nil { 172 162 slog.ErrorContext(ctx, "failed to cache ingredients", "location", p.String(), "error", err) 173 163 return nil, err 174 164 }
+32 -3
internal/recipes/generator_hash_test.go
··· 22 22 t.Fatalf("expected equal hashes for same day with different hours: got %s and %s", h1, h2) 23 23 } 24 24 25 - //make sure we're intentional about breaking hash 26 - if h1 != "cmVjaXBl5paGKJp_BFc=" { 27 - t.Fatalf("expected hash to be stable and equal to cmVjaXBl5paGKJp_BFc=, got %s", h1) 25 + // make sure we're intentional about breaking hash 26 + if h1 != "5paGKJp_BFc" { 27 + t.Fatalf("expected hash to be stable and equal to 5paGKJp_BFc, got %s", h1) 28 + } 29 + 30 + legacyHash, ok := legacyRecipeHash(h1) 31 + if !ok { 32 + t.Fatal("expected current hash passhed to legacy") 33 + } 34 + if legacyHash != "cmVjaXBl5paGKJp_BFc=" { 35 + t.Fatalf("expected legacy hash to be base64 of recipe hash with prefix, got %s", legacyHash) 28 36 } 29 37 30 38 // ensure stability across multiple calls ··· 59 67 t.Fatalf("location hash not stable across multiple calls: %s vs %s", lh1, p1.LocationHash()) 60 68 } 61 69 } 70 + 71 + func TestNormalizeLegacyRecipeHash(t *testing.T) { 72 + p := DefaultParams(&locations.Location{ID: "loc-legacy", Name: "Legacy Store"}, time.Date(2025, 9, 17, 0, 0, 0, 0, time.UTC)) 73 + hash := p.Hash() 74 + legacyHash, ok := legacyRecipeHash(hash) 75 + if !ok { 76 + t.Fatal("expected to derive legacy recipe hash") 77 + } 78 + 79 + normalized, ok := normalizeLegacyRecipeHash(legacyHash) 80 + if !ok { 81 + t.Fatal("expected legacy hash normalization to succeed") 82 + } 83 + if normalized != hash { 84 + t.Fatalf("expected normalized hash %q, got %q", hash, normalized) 85 + } 86 + 87 + if _, ok := normalizeLegacyRecipeHash(hash); ok { 88 + t.Fatalf("expected canonical hash %q not to be treated as legacy", hash) 89 + } 90 + }
+89 -4
internal/recipes/io.go
··· 3 3 import ( 4 4 "careme/internal/ai" 5 5 "careme/internal/cache" 6 + "careme/internal/kroger" 6 7 "context" 7 8 "encoding/json" 8 9 "errors" ··· 13 14 ) 14 15 15 16 const recipeCachePrefix = "recipe/" 17 + const ShoppingListCachePrefix = "shoppinglist/" 18 + const ingredientsCachePrefix = "ingredients/" 19 + const paramsCachePrefix = "params/" 16 20 17 21 type recipeio struct { 18 22 Cache cache.Cache ··· 42 46 } 43 47 44 48 func (rio recipeio) FromCache(ctx context.Context, hash string) (*ai.ShoppingList, error) { 45 - shoppinglist, err := rio.Cache.Get(ctx, hash) 49 + primaryKey := ShoppingListCachePrefix + hash 50 + shoppinglist, err := rio.Cache.Get(ctx, primaryKey) 46 51 if err != nil { 47 - return nil, err 52 + if !errors.Is(err, cache.ErrNotFound) { 53 + return nil, err 54 + } 55 + legacyHash, ok := legacyRecipeHash(hash) 56 + if !ok { 57 + return nil, err 58 + } 59 + shoppinglist, err = rio.Cache.Get(ctx, legacyHash) 60 + if err != nil { 61 + return nil, err 62 + } 63 + slog.InfoContext(ctx, "serving legacy cached shoppingList by hash", "hash", hash, "cache_key", legacyHash) 48 64 } 49 65 defer func() { 50 66 if err := shoppinglist.Close(); err != nil { ··· 63 79 return &list, nil 64 80 } 65 81 82 + func (rio recipeio) ParamsFromCache(ctx context.Context, hash string) (*generatorParams, error) { 83 + primaryKey := paramsCachePrefix + hash 84 + paramsReader, err := rio.Cache.Get(ctx, primaryKey) 85 + if err != nil { 86 + if !errors.Is(err, cache.ErrNotFound) { 87 + return nil, fmt.Errorf("params not found for hash %s: %w", hash, err) 88 + } 89 + legacyHash, ok := legacyRecipeHash(hash) 90 + if !ok { 91 + return nil, fmt.Errorf("params not found for hash %s: %w", hash, err) 92 + } 93 + legacyKey := legacyHash + ".params" 94 + paramsReader, err = rio.Cache.Get(ctx, legacyKey) 95 + if err != nil { 96 + return nil, fmt.Errorf("params not found for hash %s: %w", hash, err) 97 + } 98 + slog.InfoContext(ctx, "serving legacy cached params by hash", "hash", hash, "cache_key", legacyKey) 99 + } 100 + defer func() { 101 + if err := paramsReader.Close(); err != nil { 102 + slog.ErrorContext(ctx, "failed to close params reader", "hash", hash, "error", err) 103 + } 104 + }() 105 + 106 + var params generatorParams 107 + if err := json.NewDecoder(paramsReader).Decode(&params); err != nil { 108 + return nil, fmt.Errorf("failed to decode params: %w", err) 109 + } 110 + return &params, nil 111 + } 112 + 113 + func (rio recipeio) IngredientsFromCache(ctx context.Context, hash string) ([]kroger.Ingredient, error) { 114 + primaryKey := ingredientsCachePrefix + hash 115 + ingredientBlob, err := rio.Cache.Get(ctx, primaryKey) 116 + if err != nil { 117 + if !errors.Is(err, cache.ErrNotFound) { 118 + return nil, err 119 + } 120 + legacyHash, ok := legacyLocationHash(hash) 121 + if !ok { 122 + return nil, err 123 + } 124 + ingredientBlob, err = rio.Cache.Get(ctx, legacyHash) 125 + if err != nil { 126 + return nil, err 127 + } 128 + slog.InfoContext(ctx, "serving legacy cached ingredients by hash", "hash", hash, "cache_key", legacyHash) 129 + } 130 + defer func() { 131 + if err := ingredientBlob.Close(); err != nil { 132 + slog.ErrorContext(ctx, "failed to close cached ingredients reader", "hash", hash, "error", err) 133 + } 134 + }() 135 + 136 + var ingredients []kroger.Ingredient 137 + if err := json.NewDecoder(ingredientBlob).Decode(&ingredients); err != nil { 138 + return nil, err 139 + } 140 + return ingredients, nil 141 + } 142 + 143 + func (rio recipeio) SaveIngredients(ctx context.Context, hash string, ingredients []kroger.Ingredient) error { 144 + ingredientsJSON, err := json.Marshal(ingredients) 145 + if err != nil { 146 + return err 147 + } 148 + return rio.Cache.Put(ctx, ingredientsCachePrefix+hash, string(ingredientsJSON), cache.Unconditional()) 149 + } 150 + 66 151 // exported for backfilling 67 152 func (rio recipeio) SaveRecipes(ctx context.Context, recipes []ai.Recipe, originHash string) error { 68 153 // Save each recipe separately by its hash ··· 89 174 90 175 func (rio *recipeio) SaveParams(ctx context.Context, p *generatorParams) error { 91 176 paramsJSON := lo.Must(json.Marshal(p)) 92 - if err := rio.Cache.Put(ctx, p.Hash()+".params", string(paramsJSON), cache.IfNoneMatch()); err != nil { 177 + if err := rio.Cache.Put(ctx, paramsCachePrefix+p.Hash(), string(paramsJSON), cache.IfNoneMatch()); err != nil { 93 178 if errors.Is(err, cache.ErrAlreadyExists) { 94 179 return ErrAlreadyExists 95 180 } ··· 106 191 } 107 192 // we could actually nuke out the rest of recipe and lazily load but not yet 108 193 shoppingJSON := lo.Must(json.Marshal(shoppingList)) 109 - if err := rio.Cache.Put(ctx, hash, string(shoppingJSON), cache.Unconditional()); err != nil { 194 + if err := rio.Cache.Put(ctx, ShoppingListCachePrefix+hash, string(shoppingJSON), cache.Unconditional()); err != nil { 110 195 slog.ErrorContext(ctx, "failed to cache shopping list document", "hash", hash, "error", err) 111 196 return err 112 197 }
+188
internal/recipes/io_test.go
··· 1 1 package recipes 2 2 3 3 import ( 4 + "careme/internal/ai" 4 5 "careme/internal/cache" 6 + "careme/internal/kroger" 5 7 "careme/internal/locations" 8 + "encoding/json" 6 9 "errors" 7 10 "os" 11 + "path/filepath" 8 12 "sync" 9 13 "testing" 10 14 "time" ··· 54 58 t.Fatalf("expected 1 success + %d ErrAlreadyExists, got ok=%d alreadyExists=%d other=%d", n-1, ok, alreadyExists, other) 55 59 } 56 60 } 61 + 62 + func TestSaveParams_UsesPrefixedKey(t *testing.T) { 63 + tmpDir := t.TempDir() 64 + cacheStore := cache.NewFileCache(tmpDir) 65 + rio := IO(cacheStore) 66 + 67 + p := DefaultParams(&locations.Location{ID: "123", Name: "Test Store"}, time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC)) 68 + if err := rio.SaveParams(t.Context(), p); err != nil { 69 + t.Fatalf("SaveParams failed: %v", err) 70 + } 71 + 72 + if _, err := os.Stat(filepath.Join(tmpDir, paramsCachePrefix, p.Hash())); err != nil { 73 + t.Fatalf("expected params at prefixed key: %v", err) 74 + } 75 + if _, err := os.Stat(filepath.Join(tmpDir, p.Hash()+".params")); !os.IsNotExist(err) { 76 + t.Fatalf("did not expect legacy params key to be written; err=%v", err) 77 + } 78 + } 79 + 80 + func TestSaveShoppingList_UsesPrefixedKey(t *testing.T) { 81 + tmpDir := t.TempDir() 82 + cacheStore := cache.NewFileCache(tmpDir) 83 + rio := IO(cacheStore) 84 + 85 + hash := "test-hash" 86 + list := &ai.ShoppingList{ 87 + ConversationID: "conversation-123", 88 + Recipes: []ai.Recipe{ 89 + { 90 + Title: "One Pan Chicken", 91 + Description: "Simple weeknight meal", 92 + Ingredients: []ai.Ingredient{{Name: "Chicken", Quantity: "1 lb", Price: "5.99"}}, 93 + Instructions: []string{"Prep ingredients", "Cook chicken"}, 94 + Health: "Balanced", 95 + DrinkPairing: "Chardonnay", 96 + }, 97 + }, 98 + } 99 + 100 + if err := rio.SaveShoppingList(t.Context(), list, hash); err != nil { 101 + t.Fatalf("SaveShoppingList failed: %v", err) 102 + } 103 + 104 + if _, err := os.Stat(filepath.Join(tmpDir, ShoppingListCachePrefix, hash)); err != nil { 105 + t.Fatalf("expected shopping list at prefixed key: %v", err) 106 + } 107 + if _, err := os.Stat(filepath.Join(tmpDir, hash)); !os.IsNotExist(err) { 108 + t.Fatalf("did not expect legacy root shopping list key to be written; err=%v", err) 109 + } 110 + 111 + got, err := rio.FromCache(t.Context(), hash) 112 + if err != nil { 113 + t.Fatalf("FromCache failed: %v", err) 114 + } 115 + if got.ConversationID != list.ConversationID { 116 + t.Fatalf("expected conversation id %q, got %q", list.ConversationID, got.ConversationID) 117 + } 118 + } 119 + 120 + func TestSaveIngredients_UsesPrefixedKey(t *testing.T) { 121 + tmpDir := t.TempDir() 122 + cacheStore := cache.NewFileCache(tmpDir) 123 + rio := IO(cacheStore) 124 + 125 + hash := "ingredient-hash" 126 + ingredients := []kroger.Ingredient{ 127 + { 128 + Description: loPtr("Chicken Breast"), 129 + Size: loPtr("1 lb"), 130 + }, 131 + } 132 + 133 + if err := rio.SaveIngredients(t.Context(), hash, ingredients); err != nil { 134 + t.Fatalf("SaveIngredients failed: %v", err) 135 + } 136 + 137 + if _, err := os.Stat(filepath.Join(tmpDir, ingredientsCachePrefix, hash)); err != nil { 138 + t.Fatalf("expected ingredients at prefixed key: %v", err) 139 + } 140 + if _, err := os.Stat(filepath.Join(tmpDir, hash)); !os.IsNotExist(err) { 141 + t.Fatalf("did not expect legacy root ingredients key to be written; err=%v", err) 142 + } 143 + 144 + got, err := rio.IngredientsFromCache(t.Context(), hash) 145 + if err != nil { 146 + t.Fatalf("IngredientsFromCache failed: %v", err) 147 + } 148 + if len(got) != 1 || got[0].Description == nil || *got[0].Description != "Chicken Breast" { 149 + t.Fatalf("unexpected ingredients payload: %+v", got) 150 + } 151 + } 152 + 153 + func TestFromCache_FallsBackToLegacyHashedKeyForCanonicalHash(t *testing.T) { 154 + tmpDir := t.TempDir() 155 + cacheStore := cache.NewFileCache(tmpDir) 156 + rio := IO(cacheStore) 157 + 158 + p := DefaultParams(&locations.Location{ID: "loc-123", Name: "Store"}, time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC)) 159 + hash := p.Hash() 160 + legacyHash, ok := legacyRecipeHash(hash) 161 + if !ok { 162 + t.Fatal("expected to derive legacy recipe hash") 163 + } 164 + 165 + list := ai.ShoppingList{ConversationID: "legacy-conversation"} 166 + listJSON, err := json.Marshal(list) 167 + if err != nil { 168 + t.Fatalf("failed to marshal shopping list: %v", err) 169 + } 170 + if err := cacheStore.Put(t.Context(), legacyHash, string(listJSON), cache.Unconditional()); err != nil { 171 + t.Fatalf("failed to store legacy shopping list key: %v", err) 172 + } 173 + 174 + got, err := rio.FromCache(t.Context(), hash) 175 + if err != nil { 176 + t.Fatalf("FromCache failed to read legacy hash key: %v", err) 177 + } 178 + if got.ConversationID != list.ConversationID { 179 + t.Fatalf("expected conversation id %q, got %q", list.ConversationID, got.ConversationID) 180 + } 181 + } 182 + 183 + func TestParamsFromCache_FallsBackToLegacyHashedKeyForCanonicalHash(t *testing.T) { 184 + tmpDir := t.TempDir() 185 + cacheStore := cache.NewFileCache(tmpDir) 186 + rio := IO(cacheStore) 187 + 188 + p := DefaultParams(&locations.Location{ID: "loc-321", Name: "Store"}, time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC)) 189 + hash := p.Hash() 190 + legacyHash, ok := legacyRecipeHash(hash) 191 + if !ok { 192 + t.Fatal("expected to derive legacy recipe hash") 193 + } 194 + 195 + paramsJSON, err := json.Marshal(p) 196 + if err != nil { 197 + t.Fatalf("failed to marshal params: %v", err) 198 + } 199 + if err := cacheStore.Put(t.Context(), legacyHash+".params", string(paramsJSON), cache.Unconditional()); err != nil { 200 + t.Fatalf("failed to store legacy params key: %v", err) 201 + } 202 + 203 + got, err := rio.ParamsFromCache(t.Context(), hash) 204 + if err != nil { 205 + t.Fatalf("ParamsFromCache failed to read legacy hash key: %v", err) 206 + } 207 + if got.Location == nil || got.Location.ID != p.Location.ID { 208 + t.Fatalf("expected location id %q, got %+v", p.Location.ID, got.Location) 209 + } 210 + } 211 + 212 + func TestIngredientsFromCache_FallsBackToLegacyHashedKeyForCanonicalHash(t *testing.T) { 213 + tmpDir := t.TempDir() 214 + cacheStore := cache.NewFileCache(tmpDir) 215 + rio := IO(cacheStore) 216 + 217 + p := DefaultParams(&locations.Location{ID: "loc-777", Name: "Store"}, time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC)) 218 + hash := p.LocationHash() 219 + legacyHash, ok := legacyLocationHash(hash) 220 + if !ok { 221 + t.Fatal("expected to derive legacy location hash") 222 + } 223 + 224 + ingredients := []kroger.Ingredient{{Description: loPtr("Legacy Chicken")}} 225 + ingredientsJSON, err := json.Marshal(ingredients) 226 + if err != nil { 227 + t.Fatalf("failed to marshal ingredients: %v", err) 228 + } 229 + if err := cacheStore.Put(t.Context(), legacyHash, string(ingredientsJSON), cache.Unconditional()); err != nil { 230 + t.Fatalf("failed to store legacy ingredients key: %v", err) 231 + } 232 + 233 + got, err := rio.IngredientsFromCache(t.Context(), hash) 234 + if err != nil { 235 + t.Fatalf("IngredientsFromCache failed to read legacy hash key: %v", err) 236 + } 237 + if len(got) != 1 || got[0].Description == nil || *got[0].Description != "Legacy Chicken" { 238 + t.Fatalf("unexpected ingredients payload: %+v", got) 239 + } 240 + } 241 + 242 + func loPtr(v string) *string { 243 + return &v 244 + }
+41 -19
internal/recipes/params.go
··· 1 1 package recipes 2 2 3 3 import ( 4 + "bytes" 4 5 "careme/internal/ai" 5 - "careme/internal/cache" 6 6 "careme/internal/locations" 7 7 "context" 8 8 "encoding/base64" ··· 17 17 "time" 18 18 19 19 "github.com/samber/lo" 20 + ) 21 + 22 + const ( 23 + legacyRecipeHashSeed = "recipe" 24 + legacyIngredientsHashSeed = "ingredients" 20 25 ) 21 26 22 27 type generatorParams struct { ··· 62 67 for _, dismissed := range g.Dismissed { 63 68 lo.Must(io.WriteString(fnv, "dismissed"+dismissed.ComputeHash())) 64 69 } 65 - // this is actually a list not a recipe and isn't necessary. TODO figure out how to remove 66 - // could fix without breaking by doing two lookups? 67 - return base64.URLEncoding.EncodeToString(fnv.Sum([]byte("recipe"))) 70 + return base64.RawURLEncoding.EncodeToString(fnv.Sum(nil)) 68 71 } 69 72 70 73 // so far just excludes instructions. Can exclude people and other things ··· 74 77 lo.Must(io.WriteString(fnv, g.Date.Format("2006-01-02"))) 75 78 bytes := lo.Must(json.Marshal(g.Staples)) // excited fro this to break in some weird way 76 79 lo.Must(fnv.Write(bytes)) 77 - // see comment above this suffix is unceessary but keeps old hashes working 78 - return base64.URLEncoding.EncodeToString(fnv.Sum([]byte("ingredients"))) 80 + return base64.RawURLEncoding.EncodeToString(fnv.Sum(nil)) 79 81 } 80 82 81 - // loadParamsFromHash loads generator params from cache using the hash 82 - func loadParamsFromHash(ctx context.Context, hash string, c cache.Cache) (*generatorParams, error) { 83 - paramsReader, err := c.Get(ctx, hash+".params") 83 + func normalizeLegacyRecipeHash(hash string) (string, bool) { 84 + return legacyHashToCurrent(hash, legacyRecipeHashSeed) 85 + } 86 + 87 + func legacyRecipeHash(hash string) (string, bool) { 88 + return currentHashToLegacy(hash, legacyRecipeHashSeed) 89 + } 90 + 91 + func legacyLocationHash(hash string) (string, bool) { 92 + return currentHashToLegacy(hash, legacyIngredientsHashSeed) 93 + } 94 + 95 + func legacyHashToCurrent(hash string, seed string) (string, bool) { 96 + decoded, err := base64.URLEncoding.DecodeString(hash) 84 97 if err != nil { 85 - return nil, fmt.Errorf("params not found for hash %s: %w", hash, err) 98 + return "", false 86 99 } 87 - defer func() { 88 - if err := paramsReader.Close(); err != nil { 89 - slog.ErrorContext(ctx, "failed to close params reader", "hash", hash, "error", err) 90 - } 91 - }() 100 + seedBytes := []byte(seed) 101 + if !bytes.HasPrefix(decoded, seedBytes) || len(decoded) == len(seedBytes) { 102 + return "", false 103 + } 104 + return base64.RawURLEncoding.EncodeToString(decoded[len(seedBytes):]), true 105 + } 92 106 93 - var params generatorParams 94 - if err := json.NewDecoder(paramsReader).Decode(&params); err != nil { 95 - return nil, fmt.Errorf("failed to decode params: %w", err) 107 + func currentHashToLegacy(hash string, seed string) (string, bool) { 108 + decoded, err := base64.RawURLEncoding.DecodeString(hash) 109 + if err != nil || len(decoded) == 0 { 110 + return "", false 111 + } 112 + seedBytes := []byte(seed) 113 + if bytes.HasPrefix(decoded, seedBytes) { 114 + return hash, false 96 115 } 97 - return &params, nil 116 + legacyDecoded := make([]byte, 0, len(seedBytes)+len(decoded)) 117 + legacyDecoded = append(legacyDecoded, seedBytes...) 118 + legacyDecoded = append(legacyDecoded, decoded...) 119 + return base64.URLEncoding.EncodeToString(legacyDecoded), true 98 120 } 99 121 100 122 func (s *server) ParseQueryArgs(ctx context.Context, r *http.Request) (*generatorParams, error) {
+9 -4
internal/recipes/server.go
··· 109 109 return 110 110 } 111 111 112 - p, err := loadParamsFromHash(ctx, recipe.OriginHash, s.cache) 112 + p, err := s.ParamsFromCache(ctx, recipe.OriginHash) 113 113 if err != nil { 114 114 slog.ErrorContext(ctx, "failed to load params for hash", "hash", recipe.OriginHash, "error", err) 115 115 //http.Error(w, "recipe not found or expired", http.StatusNotFound) ··· 294 294 hashParam := r.URL.Query().Get(queryArgHash) 295 295 if startTime, err := time.Parse(time.RFC3339Nano, startArg); err == nil { 296 296 if time.Since(startTime) > time.Minute*10 { 297 - p, err := loadParamsFromHash(ctx, hashParam, s.cache) 297 + p, err := s.ParamsFromCache(ctx, hashParam) 298 298 if err != nil { 299 299 slog.ErrorContext(ctx, "failed to load params for hash", "hash", hashParam, "error", err) 300 300 http.Error(w, "recipe not found or expired", http.StatusNotFound) ··· 322 322 func (s *server) handleRecipes(w http.ResponseWriter, r *http.Request) { 323 323 ctx := r.Context() 324 324 if hashParam := r.URL.Query().Get(queryArgHash); hashParam != "" { 325 + if normalizedHash, ok := normalizeLegacyRecipeHash(hashParam); ok { 326 + slog.InfoContext(ctx, "redirecting legacy hash to canonical hash", "legacy_hash", hashParam, "hash", normalizedHash) 327 + redirectToHash(w, r, normalizedHash, false /*useStart*/) 328 + return 329 + } 325 330 slist, err := s.FromCache(ctx, hashParam) // ideally should memory cache this so lots of reloads don't constantly go out to azure 326 331 if err != nil { 327 332 if errors.Is(err, cache.ErrNotFound) { ··· 337 342 return 338 343 } 339 344 340 - p, err := loadParamsFromHash(ctx, hashParam, s.cache) 345 + p, err := s.ParamsFromCache(ctx, hashParam) 341 346 if err != nil { 342 347 slog.ErrorContext(ctx, "failed to load params for hash", "hash", hashParam, "error", err) 343 348 http.Error(w, "failed to load recipe parameters", http.StatusInternalServerError) ··· 507 512 508 513 func redirectToHash(w http.ResponseWriter, r *http.Request, hash string, useStart bool) { 509 514 u := url.URL{Path: "/recipes"} 510 - args := url.Values{} 515 + args := r.URL.Query() 511 516 args.Set(queryArgHash, hash) 512 517 if useStart { 513 518 args.Set(queryArgStart, time.Now().Format(time.RFC3339Nano))
+62
internal/recipes/server_test.go
··· 4 4 "careme/internal/ai" 5 5 "careme/internal/auth" 6 6 "careme/internal/cache" 7 + "careme/internal/locations" 7 8 "careme/internal/users" 8 9 "context" 9 10 "fmt" ··· 13 14 "path/filepath" 14 15 "strings" 15 16 "testing" 17 + "time" 16 18 ) 17 19 18 20 func TestRedirectToHash(t *testing.T) { ··· 34 36 location := rr.Header().Get("Location") 35 37 if !strings.HasPrefix(location, expectedLocation) { 36 38 t.Errorf("handler returned wrong location: got %v want prefix %v", location, expectedLocation) 39 + } 40 + } 41 + 42 + func TestHandleRecipes_RedirectsLegacyHashToCanonicalHash(t *testing.T) { 43 + p := DefaultParams(&locations.Location{ID: "loc-123", Name: "Test"}, time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC)) 44 + hash := p.Hash() 45 + legacyHash, ok := legacyRecipeHash(hash) 46 + if !ok { 47 + t.Fatal("expected to derive legacy recipe hash") 48 + } 49 + 50 + req := httptest.NewRequest(http.MethodGet, "/recipes?h="+url.QueryEscape(legacyHash), nil) 51 + rr := httptest.NewRecorder() 52 + 53 + s := &server{} 54 + s.handleRecipes(rr, req) 55 + 56 + if rr.Code != http.StatusSeeOther { 57 + t.Fatalf("expected status %d, got %d", http.StatusSeeOther, rr.Code) 58 + } 59 + location := rr.Header().Get("Location") 60 + u, err := url.Parse(location) 61 + if err != nil { 62 + t.Fatalf("failed to parse redirect location %q: %v", location, err) 63 + } 64 + if got := u.Query().Get("h"); got != hash { 65 + t.Fatalf("expected redirect hash %q, got %q", hash, got) 66 + } 67 + } 68 + 69 + func TestHandleRecipes_RedirectsLegacyHashAndPreservesQuery(t *testing.T) { 70 + p := DefaultParams(&locations.Location{ID: "loc-abc", Name: "Test"}, time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC)) 71 + hash := p.Hash() 72 + legacyHash, ok := legacyRecipeHash(hash) 73 + if !ok { 74 + t.Fatal("expected to derive legacy recipe hash") 75 + } 76 + 77 + req := httptest.NewRequest(http.MethodGet, "/recipes?h="+url.QueryEscape(legacyHash)+"&mail=true&start=2026-01-25T00%3A00%3A00Z", nil) 78 + rr := httptest.NewRecorder() 79 + 80 + s := &server{} 81 + s.handleRecipes(rr, req) 82 + 83 + if rr.Code != http.StatusSeeOther { 84 + t.Fatalf("expected status %d, got %d", http.StatusSeeOther, rr.Code) 85 + } 86 + location := rr.Header().Get("Location") 87 + u, err := url.Parse(location) 88 + if err != nil { 89 + t.Fatalf("failed to parse redirect location %q: %v", location, err) 90 + } 91 + if got := u.Query().Get("h"); got != hash { 92 + t.Fatalf("expected redirect hash %q, got %q", hash, got) 93 + } 94 + if got := u.Query().Get("mail"); got != "true" { 95 + t.Fatalf("expected mail=true to be preserved, got %q", got) 96 + } 97 + if got := u.Query().Get("start"); got != "2026-01-25T00:00:00Z" { 98 + t.Fatalf("expected start to be preserved, got %q", got) 37 99 } 38 100 } 39 101
+5 -18
internal/sitemap/sitemap.go
··· 2 2 3 3 import ( 4 4 "careme/internal/cache" 5 - "encoding/base64" 5 + "careme/internal/recipes" 6 6 "encoding/xml" 7 7 "fmt" 8 8 "log/slog" ··· 46 46 LastMod string `xml:"lastmod,omitempty"` 47 47 } 48 48 49 - func fnv64hash(hash string) bool { 50 - b, err := base64.URLEncoding.DecodeString(hash) 51 - if err != nil || len(b) != 14 { 52 - slog.Error("invalid hash in sitemap", "hash", hash, "error", err, "length", len(b)) 53 - return false 54 - } 55 - return true 56 - } 49 + func (s *Server) handleSitemap(w http.ResponseWriter, r *http.Request) { 57 50 58 - func (s *Server) handleSitemap(w http.ResponseWriter, r *http.Request) { 59 - hashes, err := s.cache.List(r.Context(), "", "") 51 + hashes, err := s.cache.List(r.Context(), recipes.ShoppingListCachePrefix, "") 60 52 if err != nil { 61 53 http.Error(w, "failed to load sitemap", http.StatusInternalServerError) 62 54 slog.ErrorContext(r.Context(), "failed to read sitemap urls", "error", err) ··· 66 58 67 59 //this is going to get too big. at some point we need a real db to find latest 68 60 //or we track new entries and expire a lsit. 69 - for _, hash := range hashes { 70 - if hash == "" || strings.Contains(hash, "/") || strings.HasSuffix(hash, ".params") { 71 - continue 72 - } 73 - if !fnv64hash(hash) { 74 - continue 75 - } 61 + for _, key := range hashes { 62 + hash := strings.TrimPrefix(key, recipes.ShoppingListCachePrefix) 76 63 entries = append(entries, urlEntry{Loc: domain + "/recipes?h=" + hash}) 77 64 } 78 65 slog.InfoContext(r.Context(), "serving sitemap with recipe urls", "count", len(entries), "blobcount", len(hashes))
+63 -1
internal/sitemap/sitemap_test.go
··· 29 29 } 30 30 params := recipes.DefaultParams(loc, start.AddDate(0, 0, i)) 31 31 hash := params.Hash() 32 - if err := cacheStore.Put(context.Background(), hash, `{"mock":"shopping-list"}`, cache.Unconditional()); err != nil { 32 + if err := cacheStore.Put(context.Background(), "shoppinglist/"+hash, `{"mock":"shopping-list"}`, cache.Unconditional()); err != nil { 33 33 t.Fatalf("failed to save hash %q to cache: %v", hash, err) 34 34 } 35 35 hashes = append(hashes, hash) ··· 61 61 if !containsSitemapURL(parsed.URLs, wantURL) { 62 62 t.Fatalf("missing expected URL %q in sitemap body: %s", wantURL, rr.Body.String()) 63 63 } 64 + } 65 + } 66 + 67 + func TestHandleSitemapNormalizesLegacyShoppingListHashToCanonical(t *testing.T) { 68 + t.Chdir(t.TempDir()) 69 + 70 + cacheStore := cache.NewFileCache(".") 71 + params := recipes.DefaultParams(&locations.Location{ID: "store", Name: "Store"}, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) 72 + hash := params.Hash() 73 + 74 + if err := cacheStore.Put(context.Background(), "shoppinglist/"+hash, `{"mock":"legacy"}`, cache.Unconditional()); err != nil { 75 + t.Fatalf("failed to save prefixed key: %v", err) 76 + } 77 + 78 + server := New(cacheStore) 79 + rr := httptest.NewRecorder() 80 + req := httptest.NewRequest(http.MethodGet, "/sitemap.xml", nil) 81 + server.handleSitemap(rr, req) 82 + 83 + if rr.Code != http.StatusOK { 84 + t.Fatalf("expected status 200, got %d", rr.Code) 85 + } 86 + 87 + var parsed urlSet 88 + if err := xml.Unmarshal(rr.Body.Bytes(), &parsed); err != nil { 89 + t.Fatalf("expected valid XML sitemap, got error: %v\nbody: %s", err, rr.Body.String()) 90 + } 91 + if len(parsed.URLs) != 1 { 92 + t.Fatalf("expected one URL, got %d", len(parsed.URLs)) 93 + } 94 + wantURL := "https://careme.cooking/recipes?h=" + hash 95 + if parsed.URLs[0].Loc != wantURL { 96 + t.Fatalf("expected URL %q, got %q", wantURL, parsed.URLs[0].Loc) 97 + } 98 + } 99 + 100 + func TestHandleSitemap_IgnoresNonShoppingListKeys(t *testing.T) { 101 + t.Chdir(t.TempDir()) 102 + 103 + cacheStore := cache.NewFileCache(".") 104 + params := recipes.DefaultParams(&locations.Location{ID: "store", Name: "Store"}, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) 105 + hash := params.Hash() 106 + 107 + if err := cacheStore.Put(context.Background(), hash, `{"mock":"legacy-root-shopping-list"}`, cache.Unconditional()); err != nil { 108 + t.Fatalf("failed to save legacy root key: %v", err) 109 + } 110 + 111 + server := New(cacheStore) 112 + rr := httptest.NewRecorder() 113 + req := httptest.NewRequest(http.MethodGet, "/sitemap.xml", nil) 114 + server.handleSitemap(rr, req) 115 + 116 + if rr.Code != http.StatusOK { 117 + t.Fatalf("expected status 200, got %d", rr.Code) 118 + } 119 + 120 + var parsed urlSet 121 + if err := xml.Unmarshal(rr.Body.Bytes(), &parsed); err != nil { 122 + t.Fatalf("expected valid XML sitemap, got error: %v\nbody: %s", err, rr.Body.String()) 123 + } 124 + if len(parsed.URLs) != 0 { 125 + t.Fatalf("expected no URLs from non-shoppinglist keys, got %d", len(parsed.URLs)) 64 126 } 65 127 } 66 128