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.
312 lines
9.9 KiB
Go
312 lines
9.9 KiB
Go
package vocab
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
// Word is one entry in the vocabulary garden: the looked-up word with its gloss,
|
|
// phonetic, and the sentence it was met in, plus its spaced-repetition state.
|
|
type Word struct {
|
|
ID string `json:"id"`
|
|
Word string `json:"word"`
|
|
Gloss string `json:"gloss"`
|
|
Definition string `json:"definition"` // English fallback meaning when there's no Chinese gloss
|
|
Phonetic string `json:"phonetic"`
|
|
Example string `json:"example"`
|
|
DocID *string `json:"doc_id"`
|
|
DueAt time.Time `json:"due_at"`
|
|
IntervalDays int `json:"interval_days"`
|
|
Ease float64 `json:"ease"`
|
|
Reps int `json:"reps"`
|
|
Lapses int `json:"lapses"`
|
|
LastReviewed *time.Time `json:"last_reviewed"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// Handler owns the vocabulary-garden routes. Everything is scoped to the local
|
|
// user while auth is deferred.
|
|
type Handler struct {
|
|
DB *db.DB
|
|
}
|
|
|
|
func New(database *db.DB) *Handler { return &Handler{DB: database} }
|
|
|
|
// Routes mounts the garden endpoints under /api/vocab.
|
|
func (h *Handler) Routes() chi.Router {
|
|
r := chi.NewRouter()
|
|
r.Get("/", h.list) // the whole garden
|
|
r.Get("/due", h.due) // only the cards due for review now
|
|
r.Post("/", h.capture) // record/upsert a looked-up word
|
|
r.Post("/{id}/review", h.review)
|
|
r.Delete("/{id}", h.remove)
|
|
return r
|
|
}
|
|
|
|
const vocabColumns = `id, word, gloss, definition, phonetic, example, doc_id,
|
|
due_at, interval_days, ease, reps, lapses, last_reviewed, created_at`
|
|
|
|
func scanWord(s interface {
|
|
Scan(dest ...any) error
|
|
}) (Word, error) {
|
|
var w Word
|
|
err := s.Scan(
|
|
&w.ID, &w.Word, &w.Gloss, &w.Definition, &w.Phonetic, &w.Example, &w.DocID,
|
|
&w.DueAt, &w.IntervalDays, &w.Ease, &w.Reps, &w.Lapses, &w.LastReviewed, &w.CreatedAt,
|
|
)
|
|
return w, err
|
|
}
|
|
|
|
// list returns the full garden, newest blossoms first.
|
|
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`, auth.UserID(r.Context()))
|
|
}
|
|
|
|
// due returns only the cards whose review time has arrived, soonest first.
|
|
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`, auth.UserID(r.Context()))
|
|
}
|
|
|
|
func (h *Handler) queryList(w http.ResponseWriter, query string, args ...any) {
|
|
rows, err := h.DB.Query(query, args...)
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
out := []Word{}
|
|
for rows.Next() {
|
|
word, err := scanWord(rows)
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
out = append(out, word)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
type captureRequest struct {
|
|
Word string `json:"word"`
|
|
Gloss string `json:"gloss"`
|
|
Definition string `json:"definition"`
|
|
Phonetic string `json:"phonetic"`
|
|
Example string `json:"example"`
|
|
DocID *string `json:"doc_id"`
|
|
}
|
|
|
|
// Field-length caps. The captured fields come from the offline lexicon and the
|
|
// editor selection, not free-typed prose, so we clamp rather than reject — a
|
|
// lookup should never fail because a definition ran long. Counts are runes so a
|
|
// Chinese gloss isn't cut mid-character. The word is the upsert key, so an absurd
|
|
// "word" is bounded too.
|
|
const (
|
|
maxWordLen = 128
|
|
maxGlossLen = 512
|
|
maxDefinitionLen = 4096
|
|
maxPhoneticLen = 256
|
|
maxExampleLen = 4096
|
|
)
|
|
|
|
// clamp trims s to at most max runes (rune-safe so multibyte glosses survive).
|
|
func clamp(s string, max int) string {
|
|
r := []rune(s)
|
|
if len(r) <= max {
|
|
return s
|
|
}
|
|
return string(r[:max])
|
|
}
|
|
|
|
// capture records a looked-up word. It's an idempotent upsert keyed on the word:
|
|
// a new word lands due tomorrow (interval 1 day); an existing word keeps its
|
|
// 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")
|
|
return
|
|
}
|
|
// Normalize the same way the lexicon does (internal/lexicon: lower+trim) so
|
|
// the UNIQUE(user_id, word) upsert is genuinely idempotent — otherwise
|
|
// "Apple" at a sentence start and "apple" mid-line would create two separate
|
|
// cards on independent schedules.
|
|
word := clamp(strings.ToLower(strings.TrimSpace(req.Word)), maxWordLen)
|
|
if word == "" {
|
|
httputil.ErrorJSON(w, http.StatusBadRequest, "word is required")
|
|
return
|
|
}
|
|
req.Gloss = clamp(req.Gloss, maxGlossLen)
|
|
req.Definition = clamp(req.Definition, maxDefinitionLen)
|
|
req.Phonetic = clamp(req.Phonetic, maxPhoneticLen)
|
|
req.Example = clamp(req.Example, maxExampleLen)
|
|
|
|
// A blank doc_id is the same as none; otherwise the word must belong to a
|
|
// document the user actually owns. Without this check a stale or forged id
|
|
// would hit the foreign key and leak a raw "FOREIGN KEY constraint" 500
|
|
// instead of a clean 400 (and, once auth lands, would let a word be attached
|
|
// to another user's document).
|
|
if req.DocID != nil {
|
|
if strings.TrimSpace(*req.DocID) == "" {
|
|
req.DocID = nil
|
|
} else {
|
|
var ok int
|
|
err := h.DB.QueryRow(
|
|
`SELECT 1 FROM documents WHERE id = ? AND user_id = ?`,
|
|
*req.DocID, userID,
|
|
).Scan(&ok)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
httputil.ErrorJSON(w, http.StatusBadRequest, "unknown doc_id")
|
|
return
|
|
}
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// New rows start due tomorrow; ON CONFLICT refreshes context but leaves the
|
|
// schedule (due_at/reps/interval/ease) alone so re-looking-up a word never
|
|
// resets its progress.
|
|
_, err := h.DB.Exec(
|
|
`INSERT INTO vocab_words (user_id, word, gloss, definition, phonetic, example, doc_id, due_at, interval_days)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', '+1 day'), 1)
|
|
ON CONFLICT(user_id, word) DO UPDATE SET
|
|
gloss = excluded.gloss,
|
|
definition = excluded.definition,
|
|
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)`,
|
|
userID, word, req.Gloss, req.Definition, req.Phonetic, req.Example, req.DocID,
|
|
)
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
|
|
out, err := h.fetch(userID, word)
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
httputil.WriteJSON(w, http.StatusCreated, out)
|
|
}
|
|
|
|
// fetch loads one word row by its (user, word) key.
|
|
func (h *Handler) fetch(userID, word string) (Word, error) {
|
|
return scanWord(h.DB.QueryRow(
|
|
`SELECT `+vocabColumns+` FROM vocab_words WHERE user_id = ? AND word = ?`,
|
|
userID, word,
|
|
))
|
|
}
|
|
|
|
type reviewRequest struct {
|
|
Grade Grade `json:"grade"`
|
|
}
|
|
|
|
// review grades one card and reschedules it. The grade drives the SM-2-lite
|
|
// 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")
|
|
return
|
|
}
|
|
if req.Grade != GradeAgain && req.Grade != GradeGood && req.Grade != GradeEasy {
|
|
httputil.ErrorJSON(w, http.StatusBadRequest, "grade must be again, good, or easy")
|
|
return
|
|
}
|
|
|
|
// The grade is computed in Go (the SM-2-lite scheduler), so the read and the
|
|
// write must be one atomic unit: a bare SELECT-then-UPDATE could interleave
|
|
// with a concurrent review of the same card and lose an update. Wrap both in a
|
|
// transaction.
|
|
tx, err := h.DB.Begin()
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
defer tx.Rollback() // no-op once committed
|
|
|
|
var cur State
|
|
err = tx.QueryRow(
|
|
`SELECT reps, interval_days, ease, lapses FROM vocab_words WHERE id = ? AND user_id = ?`,
|
|
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")
|
|
return
|
|
}
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
|
|
nxt := cur.next(req.Grade)
|
|
// `datetime('now', '+N days')` keeps the stored value in SQLite's canonical
|
|
// text format, matching CURRENT_TIMESTAMP and the due query's comparison.
|
|
offset := "+" + strconv.Itoa(nxt.Interval) + " days"
|
|
if _, err := tx.Exec(
|
|
`UPDATE vocab_words SET
|
|
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, userID,
|
|
); err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
|
|
out, err := scanWord(tx.QueryRow(
|
|
`SELECT `+vocabColumns+` FROM vocab_words WHERE id = ? AND user_id = ?`, id, userID,
|
|
))
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// remove deletes a word from the garden (e.g. the writer already knows it).
|
|
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"), auth.UserID(r.Context()),
|
|
)
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
httputil.ErrorJSON(w, http.StatusNotFound, "word not found")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|