ai cooking
0
fork

Configure Feed

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

Make a user admin page so we can see whats going on. (#265)

* initial user page

* just give me the email

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
2827a3f0 affc0130

+285 -63
+1
AGENTS.md
··· 44 44 ## Security & Configuration Notes 45 45 - Required env vars: `KROGER_CLIENT_ID`, `KROGER_CLIENT_SECRET`, `AI_API_KEY`; optional `CLARITY_PROJECT_ID`, `HISTORY_PATH`. Azure logging uses `AZURE_STORAGE_ACCOUNT_NAME` and `AZURE_STORAGE_PRIMARY_ACCOUNT_KEY`. 46 46 - Never commit secrets or generated recipe outputs. If testing against real APIs, use minimal scopes and rotate keys promptly. 47 + - Any handler that lets you see data from multiple users should go behind the /admin mux to secure it.
+1
cmd/careme/web.go
··· 67 67 recipeHandler.Register(mux) 68 68 69 69 adminMux := http.NewServeMux() 70 + adminMux.Handle("/users", users.AdminUsersPage(userStorage)) 70 71 mux.Handle("/admin/", admin.New(cfg, authClient).Enforce(http.StripPrefix("/admin", adminMux))) 71 72 72 73 if logsinkCfg.Enabled() {
-61
cmd/users/main.go
··· 1 - package main 2 - 3 - import ( 4 - "careme/internal/cache" 5 - "careme/internal/users" 6 - utypes "careme/internal/users/types" 7 - "context" 8 - "flag" 9 - "log" 10 - "strings" 11 - ) 12 - 13 - func main() { 14 - var move bool 15 - var userEmail string 16 - flag.BoolVar(&move, "move", false, "Move ingredient to the top of the list") 17 - flag.StringVar(&userEmail, "email", "nobody", "email of user id to move") 18 - flag.Parse() 19 - ctx := context.Background() 20 - cache, err := cache.MakeCache() 21 - if err != nil { 22 - log.Fatalf("failed to create cache: %s", err) 23 - } 24 - 25 - userStorage := users.NewStorage(cache) 26 - userList, err := userStorage.List(ctx) 27 - if err != nil { 28 - log.Fatalf("failed to list users: %v", err) 29 - } 30 - log.Printf("found %d users", len(userList)) 31 - log.Printf("looking for user with email containing \"%s\"", userEmail) 32 - var old utypes.User 33 - var new []utypes.User 34 - for _, u := range userList { 35 - //if !slices.Contains(u.Email, userEmail) { 36 - // continue 37 - //} 38 - log.Printf("user: %s, email: %s recipes: %d", u.ID, u.Email, len(u.LastRecipes)) 39 - if isOld(u.ID) { 40 - old = u 41 - } else { 42 - new = append(new, u) 43 - } 44 - } 45 - 46 - for _, n := range new { 47 - log.Printf("%s -> %s, email: %s ", old.ID, n.ID, n.Email) 48 - if move { 49 - n.LastRecipes = append(old.LastRecipes, n.LastRecipes...) 50 - n.FavoriteStore = old.FavoriteStore 51 - if err := userStorage.Update(&n); err != nil { 52 - log.Fatalf("failed to save user: %v", err) 53 - } 54 - } 55 - } 56 - 57 - } 58 - 59 - func isOld(userid string) bool { 60 - return !strings.HasPrefix(userid, "user_") 61 - }
+14 -2
internal/cache/file.go
··· 6 6 "io" 7 7 "os" 8 8 "path/filepath" 9 + "sort" 9 10 "strings" 10 11 ) 11 12 ··· 61 62 if err != nil { 62 63 return err 63 64 } 64 - if !info.IsDir() && strings.HasPrefix(path, prefix) { 65 - keys = append(keys, strings.TrimPrefix(path, prefix)) 65 + if info.IsDir() { 66 + return nil 67 + } 68 + 69 + relativePath, err := filepath.Rel(fc.Dir, path) 70 + if err != nil { 71 + return err 72 + } 73 + 74 + relativePath = filepath.ToSlash(relativePath) 75 + if strings.HasPrefix(relativePath, prefix) { 76 + keys = append(keys, strings.TrimPrefix(relativePath, prefix)) 66 77 } 67 78 return nil 68 79 }) 69 80 if err != nil { 70 81 return nil, err 71 82 } 83 + sort.Strings(keys) 72 84 return keys, nil 73 85 } 74 86
+139
internal/users/admin_page.go
··· 1 + package users 2 + 3 + import ( 4 + utypes "careme/internal/users/types" 5 + "html/template" 6 + "log/slog" 7 + "net/http" 8 + "sort" 9 + "strings" 10 + ) 11 + 12 + type adminUserView struct { 13 + ID string 14 + Emails []string 15 + SavedRecipeCount int 16 + } 17 + 18 + var adminUsersPageTmpl = template.Must(template.New("admin-users").Parse(`<!doctype html> 19 + <html lang="en"> 20 + <head> 21 + <meta charset="utf-8" /> 22 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 23 + <title>Admin Users</title> 24 + </head> 25 + <body> 26 + <h1>Users</h1> 27 + <p>Total users: {{len .Users}}</p> 28 + <table border="1" cellpadding="6" cellspacing="0"> 29 + <thead> 30 + <tr> 31 + <th>User ID</th> 32 + <th>Emails</th> 33 + <th>Saved Recipe Count</th> 34 + </tr> 35 + </thead> 36 + <tbody> 37 + {{range .Users}} 38 + <tr> 39 + <td>{{.ID}}</td> 40 + <td> 41 + {{if .Emails}} 42 + <ul> 43 + {{range .Emails}} 44 + <li>{{.}}</li> 45 + {{end}} 46 + </ul> 47 + {{else}} 48 + none 49 + {{end}} 50 + </td> 51 + <td> 52 + {{.SavedRecipeCount}} 53 + </td> 54 + </tr> 55 + {{end}} 56 + </tbody> 57 + </table> 58 + </body> 59 + </html>`)) 60 + 61 + func AdminUsersPage(storage *Storage) http.Handler { 62 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 63 + if r.Method != http.MethodGet && r.Method != http.MethodHead { 64 + w.WriteHeader(http.StatusMethodNotAllowed) 65 + return 66 + } 67 + 68 + list, err := storage.List(r.Context()) 69 + if err != nil { 70 + slog.ErrorContext(r.Context(), "failed to list users for admin page", "error", err) 71 + http.Error(w, "unable to load users", http.StatusInternalServerError) 72 + return 73 + } 74 + if strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("format")), "emails") { 75 + renderAdminEmailsText(w, list) 76 + return 77 + } 78 + 79 + views := make([]adminUserView, 0, len(list)) 80 + for _, user := range list { 81 + views = append(views, adminUserView{ 82 + ID: user.ID, 83 + Emails: append([]string(nil), user.Email...), 84 + SavedRecipeCount: len(user.LastRecipes), 85 + }) 86 + } 87 + 88 + sort.Slice(views, func(i, j int) bool { 89 + iEmail := primaryAdminEmail(views[i]) 90 + jEmail := primaryAdminEmail(views[j]) 91 + if iEmail == jEmail { 92 + return views[i].ID < views[j].ID 93 + } 94 + return iEmail < jEmail 95 + }) 96 + 97 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 98 + w.Header().Set("X-Content-Type-Options", "nosniff") 99 + if err := adminUsersPageTmpl.Execute(w, struct { 100 + Users []adminUserView 101 + }{Users: views}); err != nil { 102 + slog.ErrorContext(r.Context(), "failed to render admin users page", "error", err) 103 + http.Error(w, "unable to render users", http.StatusInternalServerError) 104 + return 105 + } 106 + }) 107 + } 108 + 109 + func primaryAdminEmail(v adminUserView) string { 110 + if len(v.Emails) == 0 { 111 + return "" 112 + } 113 + return strings.ToLower(strings.TrimSpace(v.Emails[0])) 114 + } 115 + 116 + func renderAdminEmailsText(w http.ResponseWriter, users []utypes.User) { 117 + unique := make(map[string]struct{}) 118 + for _, user := range users { 119 + for _, email := range user.Email { 120 + normalized := strings.ToLower(strings.TrimSpace(email)) 121 + if normalized == "" { 122 + continue 123 + } 124 + unique[normalized] = struct{}{} 125 + } 126 + } 127 + 128 + emails := make([]string, 0, len(unique)) 129 + for email := range unique { 130 + emails = append(emails, email) 131 + } 132 + sort.Strings(emails) 133 + 134 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 135 + w.Header().Set("X-Content-Type-Options", "nosniff") 136 + for _, email := range emails { 137 + _, _ = w.Write([]byte(email + "\n")) 138 + } 139 + }
+130
internal/users/admin_page_test.go
··· 1 + package users 2 + 3 + import ( 4 + "careme/internal/cache" 5 + utypes "careme/internal/users/types" 6 + "net/http" 7 + "net/http/httptest" 8 + "regexp" 9 + "strings" 10 + "testing" 11 + "time" 12 + ) 13 + 14 + func TestAdminUsersPageRendersEmailsAndRecipes(t *testing.T) { 15 + t.Parallel() 16 + 17 + fc := cache.NewFileCache(t.TempDir()) 18 + storage := NewStorage(fc) 19 + now := time.Now() 20 + 21 + if err := storage.Update(&utypes.User{ 22 + ID: "user_1", 23 + Email: []string{"alice@example.com"}, 24 + ShoppingDay: time.Monday.String(), 25 + LastRecipes: []utypes.Recipe{ 26 + {Title: "Tomato Soup", CreatedAt: now}, 27 + {Title: "Veggie Tacos", CreatedAt: now.Add(-1 * time.Hour)}, 28 + }, 29 + }); err != nil { 30 + t.Fatalf("update user_1: %v", err) 31 + } 32 + 33 + if err := storage.Update(&utypes.User{ 34 + ID: "user_2", 35 + Email: []string{"bob@example.com", "bobby@example.com"}, 36 + ShoppingDay: time.Tuesday.String(), 37 + }); err != nil { 38 + t.Fatalf("update user_2: %v", err) 39 + } 40 + 41 + req := httptest.NewRequest(http.MethodGet, "/users", nil) 42 + rr := httptest.NewRecorder() 43 + 44 + AdminUsersPage(storage).ServeHTTP(rr, req) 45 + 46 + if rr.Code != http.StatusOK { 47 + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) 48 + } 49 + 50 + if got := rr.Header().Get("Content-Type"); !strings.Contains(got, "text/html") { 51 + t.Fatalf("content-type = %q, want text/html", got) 52 + } 53 + 54 + body := rr.Body.String() 55 + for _, want := range []string{ 56 + "alice@example.com", 57 + "bob@example.com", 58 + "bobby@example.com", 59 + } { 60 + if !strings.Contains(body, want) { 61 + t.Fatalf("response body missing %q: %s", want, body) 62 + } 63 + } 64 + if !regexp.MustCompile(`<td>\s*2\s*</td>`).MatchString(body) { 65 + t.Fatalf("response body missing saved recipe count 2: %s", body) 66 + } 67 + if !regexp.MustCompile(`<td>\s*0\s*</td>`).MatchString(body) { 68 + t.Fatalf("response body missing saved recipe count 0: %s", body) 69 + } 70 + for _, unwanted := range []string{"Tomato Soup", "Veggie Tacos"} { 71 + if strings.Contains(body, unwanted) { 72 + t.Fatalf("response body should not include recipe title %q: %s", unwanted, body) 73 + } 74 + } 75 + } 76 + 77 + func TestAdminUsersPageMethodNotAllowed(t *testing.T) { 78 + t.Parallel() 79 + 80 + fc := cache.NewFileCache(t.TempDir()) 81 + storage := NewStorage(fc) 82 + 83 + req := httptest.NewRequest(http.MethodPost, "/users", nil) 84 + rr := httptest.NewRecorder() 85 + 86 + AdminUsersPage(storage).ServeHTTP(rr, req) 87 + 88 + if rr.Code != http.StatusMethodNotAllowed { 89 + t.Fatalf("status = %d, want %d", rr.Code, http.StatusMethodNotAllowed) 90 + } 91 + } 92 + 93 + func TestAdminUsersPageFormatEmails(t *testing.T) { 94 + t.Parallel() 95 + 96 + fc := cache.NewFileCache(t.TempDir()) 97 + storage := NewStorage(fc) 98 + 99 + if err := storage.Update(&utypes.User{ 100 + ID: "user_1", 101 + Email: []string{"alice@example.com", "Bob@example.com"}, 102 + ShoppingDay: time.Wednesday.String(), 103 + }); err != nil { 104 + t.Fatalf("update user_1: %v", err) 105 + } 106 + if err := storage.Update(&utypes.User{ 107 + ID: "user_2", 108 + Email: []string{" bob@example.com ", "charlie@example.com"}, 109 + ShoppingDay: time.Thursday.String(), 110 + }); err != nil { 111 + t.Fatalf("update user_2: %v", err) 112 + } 113 + 114 + req := httptest.NewRequest(http.MethodGet, "/users?format=emails", nil) 115 + rr := httptest.NewRecorder() 116 + 117 + AdminUsersPage(storage).ServeHTTP(rr, req) 118 + 119 + if rr.Code != http.StatusOK { 120 + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) 121 + } 122 + if got := rr.Header().Get("Content-Type"); !strings.Contains(got, "text/plain") { 123 + t.Fatalf("content-type = %q, want text/plain", got) 124 + } 125 + 126 + want := "alice@example.com\nbob@example.com\ncharlie@example.com\n" 127 + if rr.Body.String() != want { 128 + t.Fatalf("body = %q, want %q", rr.Body.String(), want) 129 + } 130 + }