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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user