Add per-user item ownership and isolation

Items are now private to their owner. Add items.user_id (migrated in
place, existing items backfilled to the first admin) and scope every
item, results, and dashboard view to the signed-in user via owner-scoped
queries plus an ownedItem 404 guard. Admins get strict isolation too;
elevation stays limited to settings and user management.

Alert routing follows ownership: deal emails go only to the item owner
and the weekly digest is built per-recipient from their own items. The
scheduler still polls every active item; the Apify/eBay budget stays a
shared pool visible to all.

Add TestItemsArePrivatePerUser and seed owners in db tests.
This commit is contained in:
prosolis
2026-06-20 13:39:19 -07:00
parent 90a74967e0
commit feea126c5e
11 changed files with 327 additions and 72 deletions

View File

@@ -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
}

View File

@@ -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 }

View File

@@ -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 {

View File

@@ -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

View File

@@ -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"),