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:
+53
-35
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/auth"
|
||||
"gitea.parodia.dev/drwily/petal/internal/config"
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/docs"
|
||||
@@ -65,48 +66,65 @@ func main() {
|
||||
_, _ = w.Write([]byte(`{"version":"` + version + `"}`))
|
||||
})
|
||||
|
||||
llmClient := llm.NewLLMClient(cfg)
|
||||
sug := suggestions.New(database, llmClient)
|
||||
// Everything below serves or mutates a particular user's data, so it sits
|
||||
// behind the auth middleware. /health and /version deliberately stay
|
||||
// outside it: they carry no user data, and a monitoring probe (or the
|
||||
// client's update poll) must not need a session to reach them.
|
||||
//
|
||||
// The middleware resolves the caller once and hands handlers the answer via
|
||||
// auth.UserID(r.Context()), replacing the db.LocalUserID constant those
|
||||
// queries used to name directly. Petal is still single-user — StaticResolver
|
||||
// returns that same local user for every request — but the identity now
|
||||
// travels the same path a real one will. Swapping this line for an Authentik
|
||||
// session resolver is the whole remaining change; no handler or query moves.
|
||||
api.Group(func(pr chi.Router) {
|
||||
pr.Use(auth.Middleware(auth.StaticResolver(db.LocalUserID)))
|
||||
|
||||
// Document CRUD plus the doc-scoped checkpoint/list suggestion routes,
|
||||
// both under /api/docs.
|
||||
docsHandler := docs.New(database)
|
||||
docsRouter := docsHandler.Routes()
|
||||
sug.RegisterDocRoutes(docsRouter)
|
||||
api.Mount("/docs", docsRouter)
|
||||
llmClient := llm.NewLLMClient(cfg)
|
||||
sug := suggestions.New(database, llmClient)
|
||||
|
||||
// Tag management (the roster) and cross-document full-text search.
|
||||
api.Mount("/tags", docsHandler.TagRoutes())
|
||||
api.Mount("/search", docsHandler.SearchRoutes())
|
||||
// Document CRUD plus the doc-scoped checkpoint/list suggestion routes,
|
||||
// both under /api/docs.
|
||||
docsHandler := docs.New(database)
|
||||
docsRouter := docsHandler.Routes()
|
||||
sug.RegisterDocRoutes(docsRouter)
|
||||
pr.Mount("/docs", docsRouter)
|
||||
|
||||
// Per-suggestion actions (accept/dismiss) under /api/suggestions.
|
||||
api.Mount("/suggestions", sug.Routes())
|
||||
// Tag management (the roster) and cross-document full-text search.
|
||||
pr.Mount("/tags", docsHandler.TagRoutes())
|
||||
pr.Mount("/search", docsHandler.SearchRoutes())
|
||||
|
||||
// Offline lexicon: full word lookups (gloss + definition + synonyms) for
|
||||
// the right-click popover, and the lightweight Chinese-only gloss for the
|
||||
// inline hover/select tooltip. One handler so the datasets load once.
|
||||
lex := lexicon.NewHandler()
|
||||
api.Mount("/word", lex.Routes())
|
||||
api.Mount("/gloss", lex.GlossRoutes())
|
||||
// Per-suggestion actions (accept/dismiss) under /api/suggestions.
|
||||
pr.Mount("/suggestions", sug.Routes())
|
||||
|
||||
// Vocabulary garden: words the writer looks up are captured here and
|
||||
// surfaced for gentle spaced-repetition review.
|
||||
api.Mount("/vocab", vocab.New(database).Routes())
|
||||
// Offline lexicon: full word lookups (gloss + definition + synonyms) for
|
||||
// the right-click popover, and the lightweight Chinese-only gloss for the
|
||||
// inline hover/select tooltip. One handler so the datasets load once.
|
||||
// The dataset is static and identical for everyone, but it stays behind
|
||||
// auth so the API surface has no unauthenticated read holes.
|
||||
lex := lexicon.NewHandler()
|
||||
pr.Mount("/word", lex.Routes())
|
||||
pr.Mount("/gloss", lex.GlossRoutes())
|
||||
|
||||
// Editor image uploads, stored on disk and served back by content hash.
|
||||
imgHandler, err := images.New(cfg.ImageDir)
|
||||
if err != nil {
|
||||
log.Fatalf("image store: %v", err)
|
||||
}
|
||||
api.Mount("/images", imgHandler.Routes())
|
||||
// Vocabulary garden: words the writer looks up are captured here and
|
||||
// surfaced for gentle spaced-repetition review.
|
||||
pr.Mount("/vocab", vocab.New(database).Routes())
|
||||
|
||||
// Read-aloud: proxy short passages to a local Piper TTS server. Only
|
||||
// mounted when TTS_ENDPOINT is configured; otherwise the frontend falls
|
||||
// back to the browser's Web Speech API on its own.
|
||||
if ttsHandler, ok := tts.New(cfg); ok {
|
||||
api.Mount("/tts", ttsHandler.Routes())
|
||||
log.Printf("read-aloud enabled (TTS endpoint=%s)", cfg.TTSEndpoint)
|
||||
}
|
||||
// Editor image uploads, stored on disk and served back by content hash.
|
||||
imgHandler, err := images.New(cfg.ImageDir)
|
||||
if err != nil {
|
||||
log.Fatalf("image store: %v", err)
|
||||
}
|
||||
pr.Mount("/images", imgHandler.Routes())
|
||||
|
||||
// Read-aloud: proxy short passages to a local Piper TTS server. Only
|
||||
// mounted when TTS_ENDPOINT is configured; otherwise the frontend falls
|
||||
// back to the browser's Web Speech API on its own.
|
||||
if ttsHandler, ok := tts.New(cfg); ok {
|
||||
pr.Mount("/tts", ttsHandler.Routes())
|
||||
log.Printf("read-aloud enabled (TTS endpoint=%s)", cfg.TTSEndpoint)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Everything else: serve the embedded SPA (with index.html fallback for client routing).
|
||||
|
||||
Reference in New Issue
Block a user