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:
@@ -13,6 +13,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"
|
||||
)
|
||||
@@ -46,7 +47,7 @@ func (h *Handler) exportAll(w http.ResponseWriter, r *http.Request) {
|
||||
FROM documents
|
||||
WHERE user_id = ?
|
||||
ORDER BY updated_at DESC`,
|
||||
db.LocalUserID,
|
||||
auth.UserID(r.Context()),
|
||||
)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
@@ -143,7 +144,7 @@ func (h *Handler) export(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := h.fetch(chi.URLParam(r, "id"))
|
||||
doc, err := h.fetch(auth.UserID(r.Context()), chi.URLParam(r, "id"))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
notFound(w)
|
||||
return
|
||||
|
||||
+19
-12
@@ -1,6 +1,7 @@
|
||||
// Package docs implements the document CRUD HTTP handlers — the create / list /
|
||||
// read / update / delete surface that backs the editor and its 1.5s auto-save.
|
||||
// All access is scoped to the single hardcoded local user while auth is deferred.
|
||||
// Every query is scoped to the caller resolved by the auth middleware, so a
|
||||
// document is only ever reachable by the user who owns it.
|
||||
package docs
|
||||
|
||||
import (
|
||||
@@ -12,6 +13,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"
|
||||
)
|
||||
@@ -52,15 +54,16 @@ type docSummary struct {
|
||||
Tags []db.Tag `json:"tags"`
|
||||
}
|
||||
|
||||
// list returns the local user's documents, most-recently-updated first, each
|
||||
// list returns the caller's documents, most-recently-updated first, each
|
||||
// decorated with its tags.
|
||||
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.UserID(r.Context())
|
||||
rows, err := h.DB.Query(
|
||||
`SELECT id, title, word_count, updated_at
|
||||
FROM documents
|
||||
WHERE user_id = ?
|
||||
ORDER BY updated_at DESC`,
|
||||
db.LocalUserID,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
@@ -84,7 +87,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
byDoc, err := h.tagsByDoc(ids)
|
||||
byDoc, err := h.tagsByDoc(userID, ids)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
@@ -104,7 +107,7 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
|
||||
err := h.DB.QueryRow(
|
||||
`INSERT INTO documents (user_id) VALUES (?)
|
||||
RETURNING id, user_id, title, content, content_text, tone, word_count, created_at, updated_at`,
|
||||
db.LocalUserID,
|
||||
auth.UserID(r.Context()),
|
||||
).Scan(
|
||||
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
||||
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||
@@ -118,7 +121,7 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// get returns a single full document by id.
|
||||
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
|
||||
doc, err := h.fetch(chi.URLParam(r, "id"))
|
||||
doc, err := h.fetch(auth.UserID(r.Context()), chi.URLParam(r, "id"))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
notFound(w)
|
||||
return
|
||||
@@ -149,6 +152,7 @@ type updateRequest struct {
|
||||
// content and content_text are kept in sync by the client and written together.
|
||||
func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
userID := auth.UserID(r.Context())
|
||||
|
||||
var req updateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -167,7 +171,7 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
req.Title, req.Content, req.ContentText, req.Tone, req.WordCount,
|
||||
req.PreserveHistory, id, db.LocalUserID,
|
||||
req.PreserveHistory, id, userID,
|
||||
)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
@@ -178,7 +182,7 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := h.fetch(id)
|
||||
doc, err := h.fetch(userID, id)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
@@ -200,7 +204,7 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := h.DB.Exec(
|
||||
`DELETE FROM documents 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)
|
||||
@@ -213,15 +217,18 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// fetch loads one full document scoped to the local user.
|
||||
func (h *Handler) fetch(id string) (db.Document, error) {
|
||||
// fetch loads one full document, scoped to its owner. Callers pass the id from
|
||||
// [auth.UserID]; a document belonging to anyone else comes back as
|
||||
// sql.ErrNoRows, which handlers surface as a 404 rather than a 403 (a stranger's
|
||||
// document should be indistinguishable from one that doesn't exist).
|
||||
func (h *Handler) fetch(userID, id string) (db.Document, error) {
|
||||
var doc db.Document
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT id, user_id, title, content, content_text, tone, word_count,
|
||||
created_at, updated_at, preserve_history
|
||||
FROM documents
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
id, db.LocalUserID,
|
||||
id, userID,
|
||||
).Scan(
|
||||
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
||||
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt, &doc.PreserveHistory,
|
||||
|
||||
@@ -8,10 +8,14 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/auth"
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
// newTestServer spins up an isolated on-disk database and the docs router.
|
||||
// newTestServer spins up an isolated on-disk database and the docs router,
|
||||
// behind the same auth middleware main.go installs. Tests must go through it:
|
||||
// handlers read the caller from the request context, so a router mounted bare
|
||||
// would see an empty user id and match no rows.
|
||||
func newTestServer(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
@@ -19,7 +23,13 @@ func newTestServer(t *testing.T) http.Handler {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
return New(database).Routes()
|
||||
return withAuth(New(database).Routes())
|
||||
}
|
||||
|
||||
// withAuth wraps a router so every test request arrives authenticated as the
|
||||
// seeded local user — the stand-in for a real session until Authentik lands.
|
||||
func withAuth(h http.Handler) http.Handler {
|
||||
return auth.Middleware(auth.StaticResolver(db.LocalUserID))(h)
|
||||
}
|
||||
|
||||
func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/auth"
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
// This file is the point of the auth plumbing: it proves that swapping the
|
||||
// hardcoded user for a request-scoped one actually isolates accounts. Every
|
||||
// handler resolves its user from the request, so mounting the same routers twice
|
||||
// behind two different resolvers gives us two "logged-in" users over one
|
||||
// database — which is exactly the situation a real login will create.
|
||||
|
||||
// newTwoUserServer opens one database holding two users and returns a router for
|
||||
// each, identical but for who the auth middleware says is calling.
|
||||
func newTwoUserServer(t *testing.T) (alice, bob http.Handler) {
|
||||
t.Helper()
|
||||
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
|
||||
// db.Open seeds the local user; add a second so both sides have a valid FK.
|
||||
if _, err := database.Exec(
|
||||
`INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)`,
|
||||
"bob", "bob@petal.local", "Bob",
|
||||
); err != nil {
|
||||
t.Fatalf("seed second user: %v", err)
|
||||
}
|
||||
|
||||
mount := func(userID string) http.Handler {
|
||||
h := New(database)
|
||||
r := chi.NewRouter()
|
||||
r.Mount("/docs", h.Routes())
|
||||
r.Mount("/tags", h.TagRoutes())
|
||||
r.Mount("/search", h.SearchRoutes())
|
||||
return auth.Middleware(auth.StaticResolver(userID))(r)
|
||||
}
|
||||
return mount(db.LocalUserID), mount("bob")
|
||||
}
|
||||
|
||||
// TestDocumentIsolation walks every read and write path that takes a document id
|
||||
// and asserts Bob cannot reach Alice's document through any of them. A stranger's
|
||||
// document must be indistinguishable from a nonexistent one — 404, never 403.
|
||||
func TestDocumentIsolation(t *testing.T) {
|
||||
alice, bob := newTwoUserServer(t)
|
||||
|
||||
docID := createDoc(t, alice, "Alice's diary", "a private sentence about my day")
|
||||
|
||||
t.Run("not in list", func(t *testing.T) {
|
||||
rec := do(t, bob, http.MethodGet, "/docs", "")
|
||||
var out []docSummary
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("bob sees %d of alice's documents, want 0", len(out))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not in search", func(t *testing.T) {
|
||||
rec := do(t, bob, http.MethodGet, "/search?q=private", "")
|
||||
var out []searchResult
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode search: %v", err)
|
||||
}
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("search leaked %d of alice's documents", len(out))
|
||||
}
|
||||
})
|
||||
|
||||
// The FTS index is a separate table joined back to documents; a missing
|
||||
// user_id filter there would leak content even though the list query is
|
||||
// scoped, so assert the owner still finds her own document.
|
||||
t.Run("owner still finds it", func(t *testing.T) {
|
||||
rec := do(t, alice, http.MethodGet, "/search?q=private", "")
|
||||
var out []searchResult
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode search: %v", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("alice found %d results for her own document, want 1", len(out))
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, method, path, body string
|
||||
}{
|
||||
{"get", http.MethodGet, "/docs/" + docID, ""},
|
||||
{"update", http.MethodPut, "/docs/" + docID, `{"title":"defaced"}`},
|
||||
{"delete", http.MethodDelete, "/docs/" + docID, ""},
|
||||
{"export", http.MethodGet, "/docs/" + docID + "/export?format=md", ""},
|
||||
{"passport", http.MethodGet, "/docs/" + docID + "/passport", ""},
|
||||
{"snapshot", http.MethodPost, "/docs/" + docID + "/versions", ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := do(t, bob, tc.method, tc.path, tc.body)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("%s %s as bob = %d, want 404 (body: %s)",
|
||||
tc.method, tc.path, rec.Code, rec.Body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The document must have survived every attempt above unchanged.
|
||||
rec := do(t, alice, http.MethodGet, "/docs/"+docID, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("alice lost access to her own document: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
var doc db.Document
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("decode doc: %v", err)
|
||||
}
|
||||
if doc.Title != "Alice's diary" {
|
||||
t.Fatalf("title = %q, want %q — bob's update went through", doc.Title, "Alice's diary")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVersionIsolation covers the history endpoints, which scope through a join
|
||||
// to documents rather than a direct user_id column — an easy place to forget the
|
||||
// filter, and one where the leak would be the full text of every draft.
|
||||
func TestVersionIsolation(t *testing.T) {
|
||||
alice, bob := newTwoUserServer(t)
|
||||
|
||||
docID := createDoc(t, alice, "Draft", "the first version of my essay")
|
||||
rec := do(t, alice, http.MethodPost, "/docs/"+docID+"/versions", "")
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("snapshot: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
var v db.DocumentVersion
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
|
||||
t.Fatalf("decode version: %v", err)
|
||||
}
|
||||
|
||||
t.Run("list is empty for stranger", func(t *testing.T) {
|
||||
rec := do(t, bob, http.MethodGet, "/docs/"+docID+"/versions", "")
|
||||
var out []db.DocumentVersion
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("bob sees %d of alice's snapshots, want 0", len(out))
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct{ name, method, path string }{
|
||||
{"preview", http.MethodGet, "/docs/" + docID + "/versions/" + v.ID},
|
||||
{"restore", http.MethodPost, "/docs/" + docID + "/versions/" + v.ID + "/restore"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := do(t, bob, tc.method, tc.path, "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("%s as bob = %d, want 404 (body: %s)", tc.name, rec.Code, rec.Body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTagIsolation checks the tag roster and, more importantly, that a document
|
||||
// and a tag can't be cross-linked across accounts — the assignment endpoint takes
|
||||
// two ids from different tables and must own-check both.
|
||||
func TestTagIsolation(t *testing.T) {
|
||||
alice, bob := newTwoUserServer(t)
|
||||
|
||||
docID := createDoc(t, alice, "Essay", "some words")
|
||||
|
||||
rec := do(t, alice, http.MethodPost, "/tags", `{"name":"school","color":"mint"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create tag: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
var aliceTag db.Tag
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &aliceTag); err != nil {
|
||||
t.Fatalf("decode tag: %v", err)
|
||||
}
|
||||
|
||||
rec = do(t, bob, http.MethodPost, "/tags", `{"name":"bobs","color":"sky"}`)
|
||||
var bobTag db.Tag
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &bobTag); err != nil {
|
||||
t.Fatalf("decode bob tag: %v", err)
|
||||
}
|
||||
|
||||
t.Run("roster is per user", func(t *testing.T) {
|
||||
rec := do(t, bob, http.MethodGet, "/tags", "")
|
||||
var out []db.Tag
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(out) != 1 || out[0].Name != "bobs" {
|
||||
t.Fatalf("bob's roster = %+v, want just his own tag", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cannot tag a stranger's document", func(t *testing.T) {
|
||||
body, _ := json.Marshal(map[string]string{"tag_id": bobTag.ID})
|
||||
rec := do(t, bob, http.MethodPost, "/docs/"+docID+"/tags", string(body))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("bob tagging alice's doc = %d, want 404", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cannot rename a stranger's tag", func(t *testing.T) {
|
||||
rec := do(t, bob, http.MethodPatch, "/tags/"+aliceTag.ID, `{"name":"stolen"}`)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("bob renaming alice's tag = %d, want 404", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cannot delete a stranger's tag", func(t *testing.T) {
|
||||
rec := do(t, bob, http.MethodDelete, "/tags/"+aliceTag.ID, "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("bob deleting alice's tag = %d, want 404", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -20,6 +20,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"
|
||||
)
|
||||
@@ -216,8 +217,9 @@ func verifyChain(doc db.Document, versions []db.DocumentVersion) (status string,
|
||||
// printing, so "Save as PDF" in the browser produces the handoff artifact.
|
||||
func (h *Handler) passport(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
userID := auth.UserID(r.Context())
|
||||
|
||||
doc, err := h.fetch(docID)
|
||||
doc, err := h.fetch(userID, docID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
notFound(w)
|
||||
return
|
||||
@@ -227,7 +229,7 @@ func (h *Handler) passport(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
versions, err := h.passportVersions(docID)
|
||||
versions, err := h.passportVersions(userID, docID)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
@@ -251,7 +253,7 @@ func (h *Handler) passport(w http.ResponseWriter, r *http.Request) {
|
||||
// passportVersions loads every snapshot oldest-first with the fields the report
|
||||
// and the chain check need — including content_text, which the list endpoint
|
||||
// omits as too heavy but verification cannot do without.
|
||||
func (h *Handler) passportVersions(docID string) ([]db.DocumentVersion, error) {
|
||||
func (h *Handler) passportVersions(userID, docID string) ([]db.DocumentVersion, error) {
|
||||
rows, err := h.DB.Query(
|
||||
`SELECT v.id, v.doc_id, v.title, v.content_text, v.word_count, v.kind,
|
||||
v.created_at, v.content_hash, v.prev_hash
|
||||
@@ -259,7 +261,7 @@ func (h *Handler) passportVersions(docID string) ([]db.DocumentVersion, error) {
|
||||
JOIN documents d ON d.id = v.doc_id
|
||||
WHERE v.doc_id = ? AND d.user_id = ?
|
||||
ORDER BY v.created_at ASC, v.rowid ASC`,
|
||||
docID, db.LocalUserID,
|
||||
docID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -7,6 +7,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"
|
||||
)
|
||||
@@ -50,12 +51,13 @@ func (h *Handler) SearchRoutes() chi.Router {
|
||||
return r
|
||||
}
|
||||
|
||||
// search runs a cross-document full-text search for the local user. Queries of
|
||||
// search runs a cross-document full-text search for the caller. Queries of
|
||||
// three or more runes use the trigram FTS index (fast, ranked); shorter queries
|
||||
// fall back to a LIKE scan so 2-character Chinese words still resolve. Either way
|
||||
// the snippet is built in Go from the original text, for clean word boundaries
|
||||
// and a uniform highlight format.
|
||||
func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.UserID(r.Context())
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if q == "" {
|
||||
httputil.WriteJSON(w, http.StatusOK, []searchResult{})
|
||||
@@ -80,7 +82,7 @@ func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
||||
WHERE documents_fts MATCH ? AND d.user_id = ?
|
||||
ORDER BY rank
|
||||
LIMIT ?`,
|
||||
phrase, db.LocalUserID, maxSearchResults,
|
||||
phrase, userID, maxSearchResults,
|
||||
)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
@@ -110,7 +112,7 @@ func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
||||
AND (title LIKE ? ESCAPE '\' OR content_text LIKE ? ESCAPE '\')
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?`,
|
||||
db.LocalUserID, like, like, maxSearchResults,
|
||||
userID, like, like, maxSearchResults,
|
||||
)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
@@ -144,7 +146,7 @@ func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
||||
ids = append(ids, rw.id)
|
||||
}
|
||||
|
||||
byDoc, err := h.tagsByDoc(ids)
|
||||
byDoc, err := h.tagsByDoc(userID, ids)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
|
||||
+20
-17
@@ -7,6 +7,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"
|
||||
)
|
||||
@@ -55,7 +56,7 @@ func (h *Handler) listTags(w http.ResponseWriter, r *http.Request) {
|
||||
WHERE t.user_id = ?
|
||||
GROUP BY t.id
|
||||
ORDER BY t.name COLLATE NOCASE`,
|
||||
db.LocalUserID,
|
||||
auth.UserID(r.Context()),
|
||||
)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
@@ -104,7 +105,7 @@ func (h *Handler) createTag(w http.ResponseWriter, r *http.Request) {
|
||||
`INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, name) DO UPDATE SET name = excluded.name
|
||||
RETURNING id, name, color`,
|
||||
db.LocalUserID, name, normalizeColor(req.Color),
|
||||
auth.UserID(r.Context()), name, normalizeColor(req.Color),
|
||||
).Scan(&t.ID, &t.Name, &t.Color)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
@@ -117,6 +118,7 @@ func (h *Handler) createTag(w http.ResponseWriter, r *http.Request) {
|
||||
// a recolor needn't resend the name.
|
||||
func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
userID := auth.UserID(r.Context())
|
||||
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
@@ -145,7 +147,7 @@ func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) {
|
||||
SET name = COALESCE(?, name),
|
||||
color = COALESCE(?, color)
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
namePtr, colorPtr, id, db.LocalUserID,
|
||||
namePtr, colorPtr, id, userID,
|
||||
)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
@@ -159,7 +161,7 @@ func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) {
|
||||
var t db.Tag
|
||||
if err := h.DB.QueryRow(
|
||||
`SELECT id, name, color FROM tags WHERE id = ? AND user_id = ?`,
|
||||
id, db.LocalUserID,
|
||||
id, userID,
|
||||
).Scan(&t.ID, &t.Name, &t.Color); err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
@@ -171,7 +173,7 @@ func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) deleteTag(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := h.DB.Exec(
|
||||
`DELETE FROM tags 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)
|
||||
@@ -184,10 +186,11 @@ func (h *Handler) deleteTag(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// assignTag attaches a tag to a document. Both must belong to the local user;
|
||||
// assignTag attaches a tag to a document. Both must belong to the caller;
|
||||
// the assignment is idempotent (re-assigning is a no-op, not an error).
|
||||
func (h *Handler) assignTag(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
userID := auth.UserID(r.Context())
|
||||
|
||||
var req struct {
|
||||
TagID string `json:"tag_id"`
|
||||
@@ -203,11 +206,11 @@ func (h *Handler) assignTag(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Verify both the doc and the tag belong to the user before linking, so a
|
||||
// stray id can't cross-link another account's rows.
|
||||
if !h.ownsDoc(docID) {
|
||||
if !h.ownsDoc(userID, docID) {
|
||||
notFound(w)
|
||||
return
|
||||
}
|
||||
if !h.ownsTag(req.TagID) {
|
||||
if !h.ownsTag(userID, req.TagID) {
|
||||
notFoundMsg(w, "tag not found")
|
||||
return
|
||||
}
|
||||
@@ -228,7 +231,7 @@ func (h *Handler) unassignTag(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
tagID := chi.URLParam(r, "tagId")
|
||||
|
||||
if !h.ownsDoc(docID) {
|
||||
if !h.ownsDoc(auth.UserID(r.Context()), docID) {
|
||||
notFound(w)
|
||||
return
|
||||
}
|
||||
@@ -242,22 +245,22 @@ func (h *Handler) unassignTag(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ownsDoc reports whether a document belongs to the local user.
|
||||
func (h *Handler) ownsDoc(docID string) bool {
|
||||
// ownsDoc reports whether a document belongs to the given user.
|
||||
func (h *Handler) ownsDoc(userID, docID string) bool {
|
||||
var exists bool
|
||||
_ = h.DB.QueryRow(
|
||||
`SELECT EXISTS(SELECT 1 FROM documents WHERE id = ? AND user_id = ?)`,
|
||||
docID, db.LocalUserID,
|
||||
docID, userID,
|
||||
).Scan(&exists)
|
||||
return exists
|
||||
}
|
||||
|
||||
// ownsTag reports whether a tag belongs to the local user.
|
||||
func (h *Handler) ownsTag(tagID string) bool {
|
||||
// ownsTag reports whether a tag belongs to the given user.
|
||||
func (h *Handler) ownsTag(userID, tagID string) bool {
|
||||
var exists bool
|
||||
_ = h.DB.QueryRow(
|
||||
`SELECT EXISTS(SELECT 1 FROM tags WHERE id = ? AND user_id = ?)`,
|
||||
tagID, db.LocalUserID,
|
||||
tagID, userID,
|
||||
).Scan(&exists)
|
||||
return exists
|
||||
}
|
||||
@@ -265,7 +268,7 @@ func (h *Handler) ownsTag(tagID string) bool {
|
||||
// tagsByDoc loads the tags for a set of documents in one query and groups them
|
||||
// by doc id. Used to decorate the document list and search results without an
|
||||
// N+1 of per-doc queries. Returns an empty (non-nil) map when ids is empty.
|
||||
func (h *Handler) tagsByDoc(ids []string) (map[string][]db.Tag, error) {
|
||||
func (h *Handler) tagsByDoc(userID string, ids []string) (map[string][]db.Tag, error) {
|
||||
out := map[string][]db.Tag{}
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
@@ -277,7 +280,7 @@ func (h *Handler) tagsByDoc(ids []string) (map[string][]db.Tag, error) {
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
args = append(args, db.LocalUserID)
|
||||
args = append(args, userID)
|
||||
|
||||
rows, err := h.DB.Query(
|
||||
`SELECT dt.doc_id, t.id, t.name, t.color
|
||||
|
||||
@@ -27,7 +27,7 @@ func newFullServer(t *testing.T) http.Handler {
|
||||
r.Mount("/docs", h.Routes())
|
||||
r.Mount("/tags", h.TagRoutes())
|
||||
r.Mount("/search", h.SearchRoutes())
|
||||
return r
|
||||
return withAuth(r)
|
||||
}
|
||||
|
||||
// createDoc makes a document with the given title/body and returns its id.
|
||||
|
||||
@@ -8,6 +8,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"
|
||||
)
|
||||
@@ -49,7 +50,7 @@ func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
|
||||
JOIN documents d ON d.id = v.doc_id
|
||||
WHERE v.doc_id = ? AND d.user_id = ?
|
||||
ORDER BY v.created_at DESC`,
|
||||
docID, db.LocalUserID,
|
||||
docID, auth.UserID(r.Context()),
|
||||
)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
@@ -75,7 +76,7 @@ func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// getVersion returns one snapshot in full (including content) for preview.
|
||||
func (h *Handler) getVersion(w http.ResponseWriter, r *http.Request) {
|
||||
v, err := h.fetchVersion(chi.URLParam(r, "id"), chi.URLParam(r, "vid"))
|
||||
v, err := h.fetchVersion(auth.UserID(r.Context()), chi.URLParam(r, "id"), chi.URLParam(r, "vid"))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
notFoundMsg(w, "version not found")
|
||||
return
|
||||
@@ -92,7 +93,7 @@ func (h *Handler) getVersion(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) createVersion(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
|
||||
doc, err := h.fetch(docID)
|
||||
doc, err := h.fetch(auth.UserID(r.Context()), docID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
notFound(w)
|
||||
return
|
||||
@@ -116,8 +117,9 @@ func (h *Handler) createVersion(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
vid := chi.URLParam(r, "vid")
|
||||
userID := auth.UserID(r.Context())
|
||||
|
||||
v, err := h.fetchVersion(docID, vid)
|
||||
v, err := h.fetchVersion(userID, docID, vid)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
notFoundMsg(w, "version not found")
|
||||
return
|
||||
@@ -127,7 +129,7 @@ func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
current, err := h.fetch(docID)
|
||||
current, err := h.fetch(userID, docID)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
@@ -142,7 +144,7 @@ func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
|
||||
SET title = ?, content = ?, content_text = ?, word_count = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
v.Title, v.Content, v.ContentText, v.WordCount, docID, db.LocalUserID,
|
||||
v.Title, v.Content, v.ContentText, v.WordCount, docID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
@@ -153,7 +155,7 @@ func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := h.fetch(docID)
|
||||
doc, err := h.fetch(userID, docID)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
@@ -284,14 +286,14 @@ func (h *Handler) pruneAutoVersions(docID string) error {
|
||||
}
|
||||
|
||||
// fetchVersion loads one full snapshot, scoped to its owner via the parent doc.
|
||||
func (h *Handler) fetchVersion(docID, vid string) (db.DocumentVersion, error) {
|
||||
func (h *Handler) fetchVersion(userID, docID, vid string) (db.DocumentVersion, error) {
|
||||
var v db.DocumentVersion
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT v.id, v.doc_id, v.title, v.content, v.content_text, v.word_count, v.kind, v.created_at
|
||||
FROM document_versions v
|
||||
JOIN documents d ON d.id = v.doc_id
|
||||
WHERE v.id = ? AND v.doc_id = ? AND d.user_id = ?`,
|
||||
vid, docID, db.LocalUserID,
|
||||
vid, docID, userID,
|
||||
).Scan(
|
||||
&v.ID, &v.DocID, &v.Title, &v.Content, &v.ContentText,
|
||||
&v.WordCount, &v.Kind, &v.CreatedAt,
|
||||
|
||||
Reference in New Issue
Block a user