Multi-user groundwork: request-scoped user identity
Petal ran as a single hardcoded user, with db.LocalUserID named directly
at ~35 query sites. That made the caller's identity a compile-time
constant scattered across every package — nothing a real login could
replace without touching all of them.
New internal/auth moves it into the request context:
- Middleware(Resolver) resolves the caller once per API request
- handlers read auth.UserID(r.Context()) instead of naming a user
- Resolver is the seam an Authentik session check drops into
- StaticResolver(db.LocalUserID) keeps Petal single-user today
Behavior is unchanged. UserID returns "" rather than panicking when the
middleware is absent, so a mis-wired route fails closed: every query is
WHERE user_id = ?, which then matches nothing.
main.go splits /api into a public group (/health, /version) and an
authenticated group for everything else — a monitoring probe must not
need a session.
Two pre-existing access-control gaps fixed while threading, both
harmless with one user and not with two:
- setStatus (accept/dismiss) updated a suggestion by bare id with no
ownership check at all
- listForDoc/fetchPending read a document's suggestions by doc_id
alone; a suggestion quotes the sentence it corrects, so that leaked
the source prose
Both now scope through documents.user_id.
Tests: internal/auth covers the context round-trip, the absent-context
case, and both 401 paths. Two-user isolation suites in docs and
suggestions mount the same routers twice behind two resolvers over one
database and assert a stranger gets 404 on every id-taking path, sees
nothing in list/search, and leaves the owner's data untouched.
Those suites earned their keep immediately: docs.fetch gained a userID
parameter but kept binding db.LocalUserID in the query. Unused
parameters are legal Go, so it compiled clean, vet was silent, and every
existing test passed while the lookup stayed unscoped.
Still global, out of scope and flagged in BUILD_PLAN.md: the image store
has no per-user association, and frontend localStorage keys are
per-browser rather than per-account.
This commit is contained in:
+17
-13
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/auth"
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||
)
|
||||
@@ -68,15 +69,15 @@ func scanWord(s interface {
|
||||
}
|
||||
|
||||
// list returns the full garden, newest blossoms first.
|
||||
func (h *Handler) list(w http.ResponseWriter, _ *http.Request) {
|
||||
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||
h.queryList(w, `SELECT `+vocabColumns+` FROM vocab_words
|
||||
WHERE user_id = ? ORDER BY created_at DESC`, db.LocalUserID)
|
||||
WHERE user_id = ? ORDER BY created_at DESC`, auth.UserID(r.Context()))
|
||||
}
|
||||
|
||||
// due returns only the cards whose review time has arrived, soonest first.
|
||||
func (h *Handler) due(w http.ResponseWriter, _ *http.Request) {
|
||||
func (h *Handler) due(w http.ResponseWriter, r *http.Request) {
|
||||
h.queryList(w, `SELECT `+vocabColumns+` FROM vocab_words
|
||||
WHERE user_id = ? AND due_at <= datetime('now') ORDER BY due_at ASC`, db.LocalUserID)
|
||||
WHERE user_id = ? AND due_at <= datetime('now') ORDER BY due_at ASC`, auth.UserID(r.Context()))
|
||||
}
|
||||
|
||||
func (h *Handler) queryList(w http.ResponseWriter, query string, args ...any) {
|
||||
@@ -138,6 +139,8 @@ func clamp(s string, max int) string {
|
||||
// schedule untouched but refreshes its gloss/phonetic/example/doc_id so the most
|
||||
// recent context wins. Looking words up IS the data source — no extra effort.
|
||||
func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.UserID(r.Context())
|
||||
|
||||
var req captureRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httputil.ErrorJSON(w, http.StatusBadRequest, "invalid body")
|
||||
@@ -169,7 +172,7 @@ func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
|
||||
var ok int
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT 1 FROM documents WHERE id = ? AND user_id = ?`,
|
||||
*req.DocID, db.LocalUserID,
|
||||
*req.DocID, userID,
|
||||
).Scan(&ok)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
httputil.ErrorJSON(w, http.StatusBadRequest, "unknown doc_id")
|
||||
@@ -194,14 +197,14 @@ func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
|
||||
phonetic = excluded.phonetic,
|
||||
example = CASE WHEN excluded.example != '' THEN excluded.example ELSE vocab_words.example END,
|
||||
doc_id = COALESCE(excluded.doc_id, vocab_words.doc_id)`,
|
||||
db.LocalUserID, word, req.Gloss, req.Definition, req.Phonetic, req.Example, req.DocID,
|
||||
userID, word, req.Gloss, req.Definition, req.Phonetic, req.Example, req.DocID,
|
||||
)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
out, err := h.fetch(word)
|
||||
out, err := h.fetch(userID, word)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
@@ -210,10 +213,10 @@ func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// fetch loads one word row by its (user, word) key.
|
||||
func (h *Handler) fetch(word string) (Word, error) {
|
||||
func (h *Handler) fetch(userID, word string) (Word, error) {
|
||||
return scanWord(h.DB.QueryRow(
|
||||
`SELECT `+vocabColumns+` FROM vocab_words WHERE user_id = ? AND word = ?`,
|
||||
db.LocalUserID, word,
|
||||
userID, word,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -225,6 +228,7 @@ type reviewRequest struct {
|
||||
// scheduler; the new interval is applied as `due_at = now + interval days`.
|
||||
func (h *Handler) review(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
userID := auth.UserID(r.Context())
|
||||
var req reviewRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httputil.ErrorJSON(w, http.StatusBadRequest, "invalid body")
|
||||
@@ -249,7 +253,7 @@ func (h *Handler) review(w http.ResponseWriter, r *http.Request) {
|
||||
var cur State
|
||||
err = tx.QueryRow(
|
||||
`SELECT reps, interval_days, ease, lapses FROM vocab_words WHERE id = ? AND user_id = ?`,
|
||||
id, db.LocalUserID,
|
||||
id, userID,
|
||||
).Scan(&cur.Reps, &cur.Interval, &cur.Ease, &cur.Lapses)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
httputil.ErrorJSON(w, http.StatusNotFound, "word not found")
|
||||
@@ -269,14 +273,14 @@ func (h *Handler) review(w http.ResponseWriter, r *http.Request) {
|
||||
reps = ?, interval_days = ?, ease = ?, lapses = ?,
|
||||
last_reviewed = datetime('now'), due_at = datetime('now', ?)
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
nxt.Reps, nxt.Interval, nxt.Ease, nxt.Lapses, offset, id, db.LocalUserID,
|
||||
nxt.Reps, nxt.Interval, nxt.Ease, nxt.Lapses, offset, id, userID,
|
||||
); err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
out, err := scanWord(tx.QueryRow(
|
||||
`SELECT `+vocabColumns+` FROM vocab_words WHERE id = ? AND user_id = ?`, id, db.LocalUserID,
|
||||
`SELECT `+vocabColumns+` FROM vocab_words WHERE id = ? AND user_id = ?`, id, userID,
|
||||
))
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
@@ -293,7 +297,7 @@ func (h *Handler) review(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := h.DB.Exec(
|
||||
`DELETE FROM vocab_words WHERE id = ? AND user_id = ?`,
|
||||
chi.URLParam(r, "id"), db.LocalUserID,
|
||||
chi.URLParam(r, "id"), auth.UserID(r.Context()),
|
||||
)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/auth"
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
@@ -23,7 +24,12 @@ func newTestServer(t *testing.T) (http.Handler, *db.DB) {
|
||||
t.Cleanup(func() { database.Close() })
|
||||
r := chi.NewRouter()
|
||||
r.Mount("/vocab", New(database).Routes())
|
||||
return r, database
|
||||
|
||||
// Behind the same auth middleware main.go installs: handlers resolve the
|
||||
// caller from the request context, so a bare router would see no user and
|
||||
// every user-scoped query would match nothing.
|
||||
authed := auth.Middleware(auth.StaticResolver(db.LocalUserID))(r)
|
||||
return authed, database
|
||||
}
|
||||
|
||||
func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
|
||||
|
||||
Reference in New Issue
Block a user