diff --git a/internal/db/db.go b/internal/db/db.go index 6ff1fbb..5b2e4ed 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -53,6 +53,30 @@ func Open(path string) (*sql.DB, error) { conn.Close() return nil, err } + // Per-user item ownership. On a migrated DB the column is added without a + // REFERENCES clause (ALTER TABLE ADD COLUMN can't carry one cleanly), then + // orphaned items are assigned to the first admin (or, failing that, the + // first user) so the operator keeps their existing watchlist. Fresh DBs get + // the FK from schema.sql and have no rows to backfill. + if err := addColumnIfMissing(conn, "items", "user_id", "INTEGER"); err != nil { + conn.Close() + return nil, err + } + if _, err := conn.Exec(` + UPDATE items SET user_id = ( + SELECT id FROM users + ORDER BY (role = 'admin') DESC, id ASC + LIMIT 1 + ) + WHERE user_id IS NULL AND EXISTS (SELECT 1 FROM users) + `); err != nil { + conn.Close() + return nil, fmt.Errorf("backfill item ownership: %w", err) + } + if _, err := conn.Exec(`CREATE INDEX IF NOT EXISTS idx_items_user ON items(user_id)`); err != nil { + conn.Close() + return nil, fmt.Errorf("create items user index: %w", err) + } // Multi-user / email columns (Authentik forward-auth + Resend mail). for _, c := range []struct{ col, typ string }{ {"email", "TEXT"}, diff --git a/internal/db/dedup_test.go b/internal/db/dedup_test.go index 6a1385c..bc0553b 100644 --- a/internal/db/dedup_test.go +++ b/internal/db/dedup_test.go @@ -23,15 +23,27 @@ func newTestStore(t *testing.T) *Store { return NewStore(conn, key) } +// seedUser creates an owner so item rows satisfy the items.user_id foreign key. +func seedUser(t *testing.T, s *Store) int64 { + t.Helper() + id, err := s.CreateUser(context.Background(), "tester", "!x", models.RoleAdmin) + if err != nil { + t.Fatal(err) + } + return id +} + func TestDedupByItemAndURL(t *testing.T) { if os.Getenv("CI_SKIP_SQLITE") != "" { t.Skip() } s := newTestStore(t) ctx := context.Background() + uid := seedUser(t, s) id, err := s.CreateItem(ctx, &models.Item{ - Name: "TwinBee", NtfyTopic: "veola", Active: true, + UserID: uid, + Name: "TwinBee", NtfyTopic: "veola", Active: true, PollIntervalMinutes: 60, NtfyPriority: "default", }) if err != nil { @@ -62,7 +74,7 @@ func TestDedupByItemAndURL(t *testing.T) { } // Different item, same URL should not collide. - id2, _ := s.CreateItem(ctx, &models.Item{Name: "Other", NtfyTopic: "veola", PollIntervalMinutes: 60, NtfyPriority: "default"}) + id2, _ := s.CreateItem(ctx, &models.Item{UserID: uid, Name: "Other", NtfyTopic: "veola", PollIntervalMinutes: 60, NtfyPriority: "default"}) other, _ := s.ResultExists(ctx, id2, "https://example.com/listing/1") if other { t.Error("dedup should be scoped to item_id") @@ -81,10 +93,12 @@ func TestDashboardMoneySavedWindow(t *testing.T) { } s := newTestStore(t) ctx := context.Background() + uid := seedUser(t, s) bp := 100.0 id, err := s.CreateItem(ctx, &models.Item{ - Name: "Windowed", NtfyTopic: "veola", Active: true, + UserID: uid, + Name: "Windowed", NtfyTopic: "veola", Active: true, PollIntervalMinutes: 60, NtfyPriority: "default", BestPrice: &bp, }) @@ -123,7 +137,9 @@ func TestDashboardMoneySavedWindow(t *testing.T) { func TestCJKRoundTrip(t *testing.T) { s := newTestStore(t) ctx := context.Background() + uid := seedUser(t, s) id, err := s.CreateItem(ctx, &models.Item{ + UserID: uid, Name: "ツインビー", SearchQuery: "ツインビー グラディウス パロディウス", NtfyTopic: "veola", diff --git a/internal/db/queries.go b/internal/db/queries.go index ad3429e..8e08eb6 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -257,9 +257,23 @@ func (s *Store) usersWhereEmailPref(ctx context.Context, col string) ([]models.U return out, rows.Err() } +// DeleteUser removes a user and all items they own. Owned items are deleted +// explicitly (rather than relying on an ON DELETE CASCADE that exists only on +// fresh databases) so results, price history, and marketplace rows cascade off +// the always-present items foreign keys. func (s *Store) DeleteUser(ctx context.Context, id int64) error { - _, err := s.DB.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id) - return err + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `DELETE FROM items WHERE user_id = ?`, id); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id); err != nil { + return err + } + return tx.Commit() } // ============ settings ============ @@ -410,15 +424,15 @@ func (s *Store) ApifyUsageMonth(ctx context.Context) (int, error) { func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error) { res, err := s.DB.ExecContext(ctx, ` INSERT INTO items ( - name, search_query, url, category, target_price, ntfy_topic, ntfy_priority, + user_id, name, search_query, url, category, target_price, ntfy_topic, ntfy_priority, poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords, listing_type, condition, region, actor_active, actor_sold, actor_price_compare, use_price_comparison, active, best_price, best_price_currency, best_price_store, best_price_url, best_price_image_url, best_price_title, last_polled_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, - it.Name, s.enc(it.SearchQuery), nullStr(it.URL), nullStr(it.Category), + it.UserID, it.Name, s.enc(it.SearchQuery), nullStr(it.URL), nullStr(it.Category), nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority, it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock), nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)), @@ -571,14 +585,29 @@ func (s *Store) GetItem(ctx context.Context, id int64) (*models.Item, error) { return it, nil } +// ListItems returns every item regardless of owner. Reserved for system-level +// callers (e.g. the scheduler); user-facing handlers must use ListItemsForUser. func (s *Store) ListItems(ctx context.Context) ([]models.Item, error) { return s.listItemsWhere(ctx, itemSelect+` ORDER BY name COLLATE NOCASE`) } +// ListItemsForUser returns only the items owned by the given user. +func (s *Store) ListItemsForUser(ctx context.Context, userID int64) ([]models.Item, error) { + return s.listItemsWhere(ctx, itemSelect+` WHERE user_id = ? ORDER BY name COLLATE NOCASE`, userID) +} + +// ListActiveItems returns every active item across all owners. The scheduler +// polls these on a global cadence; ownership only governs who can see and edit +// an item, not whether it is polled. func (s *Store) ListActiveItems(ctx context.Context) ([]models.Item, error) { return s.listItemsWhere(ctx, itemSelect+` WHERE active = 1 ORDER BY id`) } +// ListActiveItemsForUser returns one user's active items, for per-user digests. +func (s *Store) ListActiveItemsForUser(ctx context.Context, userID int64) ([]models.Item, error) { + return s.listItemsWhere(ctx, itemSelect+` WHERE active = 1 AND user_id = ? ORDER BY id`, userID) +} + func (s *Store) listItemsWhere(ctx context.Context, q string, args ...any) ([]models.Item, error) { rows, err := s.DB.QueryContext(ctx, q, args...) if err != nil { @@ -609,6 +638,25 @@ func (s *Store) listItemsWhere(ctx context.Context, q string, args ...any) ([]mo return out, nil } +// ListCategoriesForUser returns the distinct categories used across one user's +// items, for the category filter and the add-item datalist. +func (s *Store) ListCategoriesForUser(ctx context.Context, userID int64) ([]string, error) { + rows, err := s.DB.QueryContext(ctx, `SELECT DISTINCT category FROM items WHERE user_id = ? AND category IS NOT NULL AND category != '' ORDER BY category COLLATE NOCASE`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var c string + if err := rows.Scan(&c); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + func (s *Store) ListCategories(ctx context.Context) ([]string, error) { rows, err := s.DB.QueryContext(ctx, `SELECT DISTINCT category FROM items WHERE category IS NOT NULL AND category != '' ORDER BY category COLLATE NOCASE`) if err != nil { @@ -654,7 +702,7 @@ func (s *Store) UpdateItemPollResult(ctx context.Context, id int64, best *models } const itemSelect = ` - SELECT id, name, search_query, url, category, target_price, ntfy_topic, ntfy_priority, + SELECT id, user_id, name, search_query, url, category, target_price, ntfy_topic, ntfy_priority, poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords, listing_type, condition, region, actor_active, actor_sold, actor_price_compare, use_price_comparison, @@ -678,9 +726,10 @@ func scanItem(r rowScanner) (*models.Item, error) { targetPrice, minPrice, bestPrice sql.NullFloat64 includeOOS, usePC, active int lastPolledAt sql.NullTime + userID sql.NullInt64 ) if err := r.Scan( - &it.ID, &it.Name, &searchQuery, &urlS, &category, &targetPrice, &ntfyTopic, &it.NtfyPriority, + &it.ID, &userID, &it.Name, &searchQuery, &urlS, &category, &targetPrice, &ntfyTopic, &it.NtfyPriority, &it.PollIntervalMinutes, &includeOOS, &minPrice, &excludeKw, &listingType, &condition, ®ion, &actorA, &actorS, &actorP, &usePC, @@ -689,6 +738,7 @@ func scanItem(r rowScanner) (*models.Item, error) { ); err != nil { return nil, err } + it.UserID = userID.Int64 it.ExcludeKeywords = excludeKw.String it.MinPrice = ptrFloat(minPrice) it.SearchQuery = searchQuery.String @@ -783,8 +833,11 @@ func (s *Store) BackfillResultEndsAt(ctx context.Context, itemID int64, urlStr s type ResultsQuery struct { ItemID int64 // 0 = all items - Limit int - Offset int + // OwnerID, when non-zero, restricts results to items owned by that user. + // Combine with ItemID=0 to scope "all of my items". + OwnerID int64 + Limit int + Offset int Order string // "price_asc", "price_desc", "found_desc" (default), "found_asc" // ExcludeEnded drops rows whose ends_at is in the past. Fixed-price // listings (ends_at IS NULL) are kept regardless: they don't expire. @@ -815,6 +868,10 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul conds = append(conds, `item_id = ?`) args = append(args, q.ItemID) } + if q.OwnerID != 0 { + conds = append(conds, `item_id IN (SELECT id FROM items WHERE user_id = ?)`) + args = append(args, q.OwnerID) + } if q.ExcludeEnded { conds = append(conds, `((ends_at IS NULL AND source != 'yahoo-auctions-jp') OR ends_at > ?)`) args = append(args, time.Now().UTC()) @@ -874,7 +931,7 @@ type EndingSoon struct { // NextEndingResult returns the soonest-ending result whose ends_at lies in the // window (now, now+within]. If itemID is 0 the search spans all items; nil is // returned when no auction falls inside the window. -func (s *Store) NextEndingResult(ctx context.Context, itemID int64, within time.Duration) (*EndingSoon, error) { +func (s *Store) NextEndingResult(ctx context.Context, itemID, ownerID int64, within time.Duration) (*EndingSoon, error) { now := time.Now().UTC() cutoff := now.Add(within) q := `SELECT r.item_id, r.title, r.url, r.ends_at, i.name @@ -885,6 +942,10 @@ func (s *Store) NextEndingResult(ctx context.Context, itemID int64, within time. q += ` AND r.item_id = ?` args = append(args, itemID) } + if ownerID != 0 { + q += ` AND i.user_id = ?` + args = append(args, ownerID) + } q += ` ORDER BY r.ends_at ASC LIMIT 1` row := s.DB.QueryRowContext(ctx, q, args...) var ( @@ -1021,28 +1082,55 @@ type DashboardStats struct { SavedItemCount int } +// GetDashboardStatsForUser returns dashboard stats scoped to one user's items. +func (s *Store) GetDashboardStatsForUser(ctx context.Context, userID int64) (*DashboardStats, error) { + return s.dashboardStats(ctx, userID) +} + +// GetDashboardStats returns stats across all owners. Reserved for system-level +// callers; user-facing views must use GetDashboardStatsForUser. func (s *Store) GetDashboardStats(ctx context.Context) (*DashboardStats, error) { + return s.dashboardStats(ctx, 0) +} + +// dashboardStats computes the dashboard aggregates, optionally restricted to a +// single owner. When ownerID is 0 it spans every item. +func (s *Store) dashboardStats(ctx context.Context, ownerID int64) (*DashboardStats, error) { d := &DashboardStats{} - queries := map[string]any{ - `SELECT COUNT(*) FROM items`: &d.TotalItems, - `SELECT COUNT(*) FROM items WHERE active = 1`: &d.ActiveItems, - `SELECT COUNT(*) FROM results WHERE found_at >= datetime('now', '-1 day')`: &d.ResultsToday, - `SELECT COUNT(*) FROM results WHERE alerted = 1 AND found_at >= datetime('now', '-1 day')`: &d.AlertsToday, + // itemOwner is an AND-able predicate on the items table; resultOwner is the + // equivalent for the results table (scoped via the item it belongs to). + itemOwner, resultOwner := "", "" + var ia, ra []any + if ownerID != 0 { + itemOwner = ` AND user_id = ?` + ia = []any{ownerID} + resultOwner = ` AND item_id IN (SELECT id FROM items WHERE user_id = ?)` + ra = []any{ownerID} } - for q, dst := range queries { - if err := s.DB.QueryRowContext(ctx, q).Scan(dst); err != nil { + countQueries := []struct { + q string + dst *int + arg []any + }{ + {`SELECT COUNT(*) FROM items WHERE 1=1` + itemOwner, &d.TotalItems, ia}, + {`SELECT COUNT(*) FROM items WHERE active = 1` + itemOwner, &d.ActiveItems, ia}, + {`SELECT COUNT(*) FROM results WHERE found_at >= datetime('now', '-1 day')` + resultOwner, &d.ResultsToday, ra}, + {`SELECT COUNT(*) FROM results WHERE alerted = 1 AND found_at >= datetime('now', '-1 day')` + resultOwner, &d.AlertsToday, ra}, + } + for _, cq := range countQueries { + if err := s.DB.QueryRowContext(ctx, cq.q, cq.arg...).Scan(cq.dst); err != nil { return nil, err } } if err := s.DB.QueryRowContext(ctx, ` SELECT COALESCE(SUM(best_price), 0), COUNT(*) - FROM items WHERE active = 1 AND best_price IS NOT NULL - `).Scan(&d.PotentialSpend, &d.PricedItemCount); err != nil { + FROM items WHERE active = 1 AND best_price IS NOT NULL`+itemOwner, + ia...).Scan(&d.PotentialSpend, &d.PricedItemCount); err != nil { return nil, err } if err := s.DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM items WHERE active = 1 AND best_price IS NULL - `).Scan(&d.UnpricedCount); err != nil { + SELECT COUNT(*) FROM items WHERE active = 1 AND best_price IS NULL`+itemOwner, + ia...).Scan(&d.UnpricedCount); err != nil { return nil, err } // "Money saved" compares each item's current best price against its recent @@ -1054,9 +1142,15 @@ func (s *Store) GetDashboardStats(ctx context.Context) (*DashboardStats, error) FROM items i JOIN price_history p ON p.item_id = i.id WHERE i.active = 1 AND i.best_price IS NOT NULL - AND p.polled_at >= datetime('now', '-30 day') + AND p.polled_at >= datetime('now', '-30 day')`+ + func() string { + if ownerID != 0 { + return ` AND i.user_id = ?` + } + return `` + }()+` GROUP BY i.id - `) + `, ia...) if err != nil { return nil, err } diff --git a/internal/db/schema.sql b/internal/db/schema.sql index 8ef5ca1..79d185d 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -21,6 +21,13 @@ CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, + -- user_id is the owning user. Items are private to their owner; the + -- scheduler still polls every active item regardless of owner. On existing + -- databases the column is added by addColumnIfMissing in db.go (without a + -- REFERENCES clause) and backfilled to the first admin, so user deletion + -- removes owned items explicitly in Store.DeleteUser rather than relying on + -- the cascade below. + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, search_query TEXT, url TEXT, @@ -53,6 +60,10 @@ CREATE TABLE IF NOT EXISTS items ( ); CREATE INDEX IF NOT EXISTS idx_items_active ON items(active); +-- idx_items_user is created in db.go AFTER addColumnIfMissing adds user_id, so +-- it works on databases whose items table predates the column (this CREATE +-- INDEX would otherwise fail on them, since IF NOT EXISTS on the table is a +-- no-op and the old table lacks user_id). CREATE TABLE IF NOT EXISTS item_marketplaces ( item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index 3d1d5bd..2feec15 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -30,16 +30,17 @@ func (a *App) GetDashboardRefresh(w http.ResponseWriter, r *http.Request) { } func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) { - stats, err := a.Store.GetDashboardStats(r.Context()) + uid := a.userID(r) + stats, err := a.Store.GetDashboardStatsForUser(r.Context(), uid) if err != nil { return templates.DashboardData{}, err } - results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{Limit: 8, ExcludeEnded: true}) + results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{OwnerID: uid, Limit: 8, ExcludeEnded: true}) if err != nil { return templates.DashboardData{}, err } itemNames := map[int64]string{} - all, _ := a.Store.ListItems(r.Context()) + all, _ := a.Store.ListItemsForUser(r.Context(), uid) for _, it := range all { itemNames[it.ID] = it.Name } @@ -79,7 +80,7 @@ func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) { } func alertsRecent(a *App, r *http.Request, itemNames map[int64]string) ([]templates.AlertRow, error) { - results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{OnlyAlerted: true, Limit: 5}) + results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{OwnerID: a.userID(r), OnlyAlerted: true, Limit: 5}) if err != nil { return nil, err } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index ac01abd..2ff2557 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -190,6 +190,15 @@ func (a *App) page(r *http.Request, title, active string) templates.Page { } } +// userID returns the signed-in user's id. Handlers in this group all run under +// RequireAuth, so a user is always present; 0 is only returned defensively. +func (a *App) userID(r *http.Request) int64 { + if u := auth.CurrentUserFromRequest(r); u != nil { + return u.ID + } + return 0 +} + // noListFS wraps an http.FileSystem and refuses to open directories, which // stops http.FileServer from emitting an auto-generated directory listing. type noListFS struct{ fs http.FileSystem } diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index ffbbb1c..ddaf5ee 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -324,6 +324,74 @@ func TestForwardAuthEmailPrefsRoundTrip(t *testing.T) { } } +func TestItemsArePrivatePerUser(t *testing.T) { + app, h := newTestApp(t) + enableForwardAuth(t, app) + + // Two distinct forward-auth identities behind the trusted proxy. + alice := newClient(h, "10.0.0.5:5555", map[string]string{ + hdrEmail: "alice@example.com", hdrGroups: "users", + }) + bob := newClient(h, "10.0.0.6:5555", map[string]string{ + hdrEmail: "bob@example.com", hdrGroups: "users", + }) + + // Alice creates an item. Pull a CSRF token from the add-item form first. + rec := alice.do(http.MethodGet, "/items/new", nil) + if rec.Code != http.StatusOK { + t.Fatalf("GET /items/new = %d, want 200", rec.Code) + } + token := extractCSRF(t, rec.Body.String()) + form := url.Values{ + "csrf_token": {token}, + "name": {"Alice TwinBee"}, + "search_query": {"twinbee"}, + "marketplace": {"ebay.com"}, + "poll_interval_minutes": {"720"}, + "ntfy_priority": {"default"}, + } + rec = alice.do(http.MethodPost, "/items", form) + if rec.Code != http.StatusSeeOther { + t.Fatalf("POST /items = %d, want 303; body: %s", rec.Code, rec.Body.String()) + } + loc := rec.Header().Get("Location") // /items/{id}/results + if !strings.HasPrefix(loc, "/items/") { + t.Fatalf("Location = %q, want /items/{id}/results", loc) + } + itemPath := strings.TrimSuffix(loc, "/results") // /items/{id} + + // Alice sees her item; Bob's list does not. + if rec := alice.do(http.MethodGet, "/items", nil); !strings.Contains(rec.Body.String(), "Alice TwinBee") { + t.Errorf("Alice's /items missing her own item") + } + if rec := bob.do(http.MethodGet, "/items", nil); strings.Contains(rec.Body.String(), "Alice TwinBee") { + t.Errorf("Bob's /items leaked Alice's item") + } + + // Bob cannot reach Alice's item by direct id: results, edit, error, run, + // toggle, and delete all 404 rather than acting on someone else's item. + if rec := bob.do(http.MethodGet, loc, nil); rec.Code != http.StatusNotFound { + t.Errorf("Bob GET %s = %d, want 404", loc, rec.Code) + } + if rec := bob.do(http.MethodGet, itemPath+"/edit", nil); rec.Code != http.StatusNotFound { + t.Errorf("Bob GET %s/edit = %d, want 404", itemPath, rec.Code) + } + + // A state-changing attempt needs Bob's own CSRF token (from any rendered + // form); it must still 404 on ownership, not act. + br := bob.do(http.MethodGet, "/settings", nil) + btoken := extractCSRF(t, br.Body.String()) + del := url.Values{"csrf_token": {btoken}} + if rec := bob.do(http.MethodPost, itemPath+"/delete", del); rec.Code != http.StatusNotFound { + t.Errorf("Bob POST %s/delete = %d, want 404", itemPath, rec.Code) + } + + // Alice's item still exists after Bob's delete attempt. + if rec := alice.do(http.MethodGet, loc, nil); rec.Code != http.StatusOK { + t.Errorf("Alice GET %s after Bob's delete attempt = %d, want 200", loc, rec.Code) + } +} + func containsEmail(us []models.User, email string) bool { for _, u := range us { if u.Email == email { diff --git a/internal/handlers/items.go b/internal/handlers/items.go index ee40bd2..be09e88 100644 --- a/internal/handlers/items.go +++ b/internal/handlers/items.go @@ -20,9 +20,23 @@ import ( // form: 12 hours, chosen to keep Apify spend low by default. const defaultPollIntervalMinutes = 720 +// ownedItem fetches an item and confirms the current user owns it. It returns +// nil when the item is missing or owned by someone else, so callers treat both +// cases as a 404 and never leak another user's items. +func (a *App) ownedItem(r *http.Request, id int64) (*models.Item, error) { + it, err := a.Store.GetItem(r.Context(), id) + if err != nil { + return nil, err + } + if it == nil || it.UserID != a.userID(r) { + return nil, nil + } + return it, nil +} + func (a *App) GetItems(w http.ResponseWriter, r *http.Request) { cat := r.URL.Query().Get("category") - all, err := a.Store.ListItems(r.Context()) + all, err := a.Store.ListItemsForUser(r.Context(), a.userID(r)) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return @@ -33,7 +47,7 @@ func (a *App) GetItems(w http.ResponseWriter, r *http.Request) { items = append(items, it) } } - cats, _ := a.Store.ListCategories(r.Context()) + cats, _ := a.Store.ListCategoriesForUser(r.Context(), a.userID(r)) // Bulk-load recent price history so each row can render a sparkline // without N+1 queries. 20 points is enough for a meaningful trend line @@ -54,7 +68,7 @@ func (a *App) GetItems(w http.ResponseWriter, r *http.Request) { } func (a *App) GetNewItem(w http.ResponseWriter, r *http.Request) { - cats, _ := a.Store.ListCategories(r.Context()) + cats, _ := a.Store.ListCategoriesForUser(r.Context(), a.userID(r)) render(w, r, templates.ItemForm(templates.ItemFormData{ Page: a.page(r, "Add Item", "items"), IsEdit: false, @@ -73,12 +87,12 @@ func (a *App) GetNewItem(w http.ResponseWriter, r *http.Request) { func (a *App) GetEditItem(w http.ResponseWriter, r *http.Request) { id := intParam(r, "id") - it, err := a.Store.GetItem(r.Context(), id) + it, err := a.ownedItem(r, id) if err != nil || it == nil { http.NotFound(w, r) return } - cats, _ := a.Store.ListCategories(r.Context()) + cats, _ := a.Store.ListCategoriesForUser(r.Context(), a.userID(r)) render(w, r, templates.ItemForm(templates.ItemFormData{ Page: a.page(r, "Edit "+it.Name, "items"), IsEdit: true, @@ -341,6 +355,7 @@ func (a *App) PostCreateItem(w http.ResponseWriter, r *http.Request) { http.Error(w, strings.Join(errs, "; "), http.StatusBadRequest) return } + it.UserID = a.userID(r) id, err := a.Store.CreateItem(r.Context(), &it) if err != nil { a.serverError(w, r, err) @@ -364,14 +379,14 @@ func (a *App) PostCreateItem(w http.ResponseWriter, r *http.Request) { func (a *App) PostUpdateItem(w http.ResponseWriter, r *http.Request) { id := intParam(r, "id") - existing, err := a.Store.GetItem(r.Context(), id) + existing, err := a.ownedItem(r, id) if err != nil || existing == nil { http.NotFound(w, r) return } updated, errs := parseItemForm(r) if len(errs) > 0 { - cats, _ := a.Store.ListCategories(r.Context()) + cats, _ := a.Store.ListCategoriesForUser(r.Context(), a.userID(r)) updated.ID = id render(w, r, templates.ItemForm(templates.ItemFormData{ Page: a.page(r, "Edit "+updated.Name, "items"), @@ -394,7 +409,7 @@ func (a *App) PostUpdateItem(w http.ResponseWriter, r *http.Request) { func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) { id := intParam(r, "id") - it, err := a.Store.GetItem(r.Context(), id) + it, err := a.ownedItem(r, id) if err != nil || it == nil { http.NotFound(w, r) return @@ -411,6 +426,11 @@ func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) { func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) { id := intParam(r, "id") + it, err := a.ownedItem(r, id) + if err != nil || it == nil { + http.NotFound(w, r) + return + } if err := a.Store.DeleteItem(r.Context(), id); err != nil { a.serverError(w, r, err) return @@ -421,7 +441,7 @@ func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) { func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) { id := intParam(r, "id") - it, err := a.Store.GetItem(r.Context(), id) + it, err := a.ownedItem(r, id) if err != nil || it == nil { http.NotFound(w, r) return @@ -455,7 +475,7 @@ func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) { func (a *App) GetItemError(w http.ResponseWriter, r *http.Request) { id := intParam(r, "id") - it, err := a.Store.GetItem(r.Context(), id) + it, err := a.ownedItem(r, id) if err != nil || it == nil { http.NotFound(w, r) return diff --git a/internal/handlers/results.go b/internal/handlers/results.go index ceab01b..2782563 100644 --- a/internal/handlers/results.go +++ b/internal/handlers/results.go @@ -16,7 +16,7 @@ const resultsPerPage = 20 func (a *App) GetItemResults(w http.ResponseWriter, r *http.Request) { id := intParam(r, "id") - it, err := a.Store.GetItem(r.Context(), id) + it, err := a.ownedItem(r, id) if err != nil || it == nil { http.NotFound(w, r) return @@ -71,7 +71,7 @@ func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, o // 24h surface for the "ending soon" strip — beyond that, a static // "ends in 4 days" in the per-row cell carries enough signal on its own. - endingSoon, _ := a.Store.NextEndingResult(r.Context(), it.ID, 24*time.Hour) + endingSoon, _ := a.Store.NextEndingResult(r.Context(), it.ID, 0, 24*time.Hour) return templates.ItemResultsData{ Page: a.page(r, it.Name, "items"), @@ -105,7 +105,8 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) { from := strings.TrimSpace(q.Get("from")) to := strings.TrimSpace(q.Get("to")) - items, err := a.Store.ListItems(r.Context()) + uid := a.userID(r) + items, err := a.Store.ListItemsForUser(r.Context(), uid) if err != nil { a.serverError(w, r, err) return @@ -117,6 +118,7 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) { results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{ ItemID: itemID, + OwnerID: uid, Limit: 200, ExcludeEnded: true, }) @@ -145,7 +147,7 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) { }) } - endingSoon, _ := a.Store.NextEndingResult(r.Context(), itemID, 24*time.Hour) + endingSoon, _ := a.Store.NextEndingResult(r.Context(), itemID, uid, 24*time.Hour) render(w, r, templates.GlobalResults(templates.GlobalResultsData{ Page: a.page(r, "Results", "results"), diff --git a/internal/models/models.go b/internal/models/models.go index b3fa11d..6e00ab5 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -30,6 +30,7 @@ type User struct { type Item struct { ID int64 + UserID int64 Name string SearchQuery string URL string diff --git a/internal/scheduler/email.go b/internal/scheduler/email.go index 9c9cfc1..6c626cd 100644 --- a/internal/scheduler/email.go +++ b/internal/scheduler/email.go @@ -28,20 +28,24 @@ func (s *Scheduler) emailClient(ctx context.Context) *email.Client { return email.New(apiKey, from) } -// sendDealEmails mails a deal alert to every user who opted into deal-alert -// email. Entirely best-effort: an unconfigured Resend account or a per-user -// send failure is logged and swallowed. +// sendDealEmails mails a deal alert to the item's owner, if that user opted +// into deal-alert email and has a destination address. Items are private, so a +// deal only ever notifies the person who is watching it. Entirely best-effort: +// an unconfigured Resend account or a send failure is logged and swallowed. func (s *Scheduler) sendDealEmails(ctx context.Context, it models.Item, r apify.UnifiedResult) { client := s.emailClient(ctx) if !client.Configured() { return } - recipients, err := s.store.UsersWithDealAlerts(ctx) - if err != nil { - slog.Error("load deal-alert recipients failed", "err", err) + if it.UserID == 0 { return } - if len(recipients) == 0 { + owner, err := s.store.GetUserByID(ctx, it.UserID) + if err != nil { + slog.Error("load item owner failed", "item_id", it.ID, "err", err) + return + } + if owner == nil || !owner.EmailDealAlerts || owner.Email == "" { return } subject := fmt.Sprintf("Veola deal: %s", it.Name) @@ -52,12 +56,10 @@ func (s *Scheduler) sendDealEmails(ctx context.Context, it models.Item, r apify. } body := dealEmailHTML(it.Name, r.Title, r.Store, price, target, r.URL) text := dealEmailText(it.Name, r.Title, r.Store, price, target, r.URL) - for _, u := range recipients { - if err := client.Send(ctx, email.Message{ - To: u.Email, Subject: subject, HTML: body, Text: text, - }); err != nil { - slog.Error("deal email send failed", "to", u.Email, "err", err) - } + if err := client.Send(ctx, email.Message{ + To: owner.Email, Subject: subject, HTML: body, Text: text, + }); err != nil { + slog.Error("deal email send failed", "to", owner.Email, "err", err) } } @@ -76,27 +78,34 @@ func (s *Scheduler) RunWeeklyDigest(ctx context.Context) { if len(recipients) == 0 { return } - items, err := s.store.ListActiveItems(ctx) - if err != nil { - slog.Error("digest item load failed", "err", err) - return - } - stats, err := s.store.GetDashboardStats(ctx) - if err != nil { - slog.Error("digest stats load failed", "err", err) - return - } - subject := fmt.Sprintf("Veola weekly digest — %d items watched", len(items)) - body := digestHTML(items, stats) - text := digestText(items, stats) + // Each digest is built from only the recipient's own items and stats, since + // items are private. A user watching nothing is skipped rather than mailed + // an empty table. + sent := 0 for _, u := range recipients { + items, err := s.store.ListActiveItemsForUser(ctx, u.ID) + if err != nil { + slog.Error("digest item load failed", "user_id", u.ID, "err", err) + continue + } + if len(items) == 0 { + continue + } + stats, err := s.store.GetDashboardStatsForUser(ctx, u.ID) + if err != nil { + slog.Error("digest stats load failed", "user_id", u.ID, "err", err) + continue + } + subject := fmt.Sprintf("Veola weekly digest — %d items watched", len(items)) if err := client.Send(ctx, email.Message{ - To: u.Email, Subject: subject, HTML: body, Text: text, + To: u.Email, Subject: subject, HTML: digestHTML(items, stats), Text: digestText(items, stats), }); err != nil { slog.Error("digest email send failed", "to", u.Email, "err", err) + continue } + sent++ } - slog.Info("weekly digest sent", "recipients", len(recipients), "items", len(items)) + slog.Info("weekly digest sent", "recipients", sent) } func dealEmailHTML(itemName, title, store, price, target, url string) string {