Files
petal/internal/docs/versions.go
prosolis 6901cdbbe4 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.
2026-07-26 21:42:37 -07:00

303 lines
9.3 KiB
Go

package docs
import (
"database/sql"
"errors"
"net/http"
"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"
)
// Version-history tuning.
const (
// autoSnapshotInterval is the minimum gap between background ('auto')
// snapshots. Auto-save fires every ~1.5s; without a floor we'd store a
// version per keystroke-burst. A few minutes keeps history useful for
// recovery without unbounded growth.
autoSnapshotInterval = 3 * time.Minute
// maxAutoVersions caps how many 'auto' snapshots we retain per document.
// 'manual' and 'pre_restore' versions are never pruned — they're explicit
// restore points the writer (or a restore) deliberately created.
maxAutoVersions = 40
)
// versionRoutes registers the history endpoints on the docs sub-router. Paths
// resolve to /api/docs/{id}/versions...
func (h *Handler) versionRoutes(r chi.Router) {
r.Get("/{id}/versions", h.listVersions)
r.Post("/{id}/versions", h.createVersion) // explicit "save a restore point"
r.Get("/{id}/versions/{vid}", h.getVersion) // full body for preview
r.Post("/{id}/versions/{vid}/restore", h.restoreVersion)
r.Get("/{id}/passport", h.passport) // authorship report over that history
}
// listVersions returns the document's snapshots, newest first, without the heavy
// content fields (those load on preview/restore).
func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
// Scope through the documents table so a snapshot is only visible to the
// owner of its parent document.
rows, err := h.DB.Query(
`SELECT v.id, v.doc_id, v.title, v.word_count, v.kind, v.created_at
FROM document_versions v
JOIN documents d ON d.id = v.doc_id
WHERE v.doc_id = ? AND d.user_id = ?
ORDER BY v.created_at DESC`,
docID, auth.UserID(r.Context()),
)
if err != nil {
httputil.ServerError(w, err)
return
}
defer rows.Close()
out := []db.DocumentVersion{} // non-nil so an empty history serializes as []
for rows.Next() {
var v db.DocumentVersion
if err := rows.Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt); err != nil {
httputil.ServerError(w, err)
return
}
out = append(out, v)
}
if err := rows.Err(); err != nil {
httputil.ServerError(w, err)
return
}
httputil.WriteJSON(w, http.StatusOK, out)
}
// getVersion returns one snapshot in full (including content) for preview.
func (h *Handler) getVersion(w http.ResponseWriter, r *http.Request) {
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
}
if err != nil {
httputil.ServerError(w, err)
return
}
httputil.WriteJSON(w, http.StatusOK, v)
}
// createVersion takes an explicit, user-requested ('manual') restore point from
// the document's current saved state.
func (h *Handler) createVersion(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
doc, err := h.fetch(auth.UserID(r.Context()), docID)
if errors.Is(err, sql.ErrNoRows) {
notFound(w)
return
}
if err != nil {
httputil.ServerError(w, err)
return
}
v, err := h.insertVersion(doc, db.VersionKindManual)
if err != nil {
httputil.ServerError(w, err)
return
}
httputil.WriteJSON(w, http.StatusCreated, v)
}
// restoreVersion copies a snapshot back onto the live document. Before
// overwriting, it captures the current state as a 'pre_restore' version so the
// restore is itself undoable. Returns the restored document.
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(userID, docID, vid)
if errors.Is(err, sql.ErrNoRows) {
notFoundMsg(w, "version not found")
return
}
if err != nil {
httputil.ServerError(w, err)
return
}
current, err := h.fetch(userID, docID)
if err != nil {
httputil.ServerError(w, err)
return
}
if _, err := h.insertVersion(current, db.VersionKindPreRestore); err != nil {
httputil.ServerError(w, err)
return
}
res, err := h.DB.Exec(
`UPDATE documents
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, userID,
)
if err != nil {
httputil.ServerError(w, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
notFound(w)
return
}
doc, err := h.fetch(userID, docID)
if err != nil {
httputil.ServerError(w, err)
return
}
httputil.WriteJSON(w, http.StatusOK, doc)
}
// maybeAutoSnapshot records a throttled background snapshot of the just-saved
// document. It no-ops when the newest snapshot is younger than
// autoSnapshotInterval, or when the body is unchanged since the last snapshot,
// so an idle or rename-only save costs nothing. Best-effort: callers log but do
// not fail the save if this errors. Prunes old 'auto' versions on success.
func (h *Handler) maybeAutoSnapshot(doc db.Document) error {
var (
lastAt time.Time
lastText string
hasPrev bool
)
err := h.DB.QueryRow(
`SELECT created_at, content_text FROM document_versions
WHERE doc_id = ? ORDER BY created_at DESC LIMIT 1`,
doc.ID,
).Scan(&lastAt, &lastText)
switch {
case errors.Is(err, sql.ErrNoRows):
hasPrev = false
case err != nil:
return err
default:
hasPrev = true
}
if hasPrev {
if lastText == doc.ContentText {
return nil // nothing meaningful changed
}
if time.Since(lastAt) < autoSnapshotInterval {
return nil // too soon; let edits accumulate
}
}
if _, err := h.insertVersion(doc, db.VersionKindAuto); err != nil {
return err
}
return h.pruneAutoVersions(doc.ID)
}
// insertVersion writes a snapshot row of the given kind and returns it (without
// the heavy content fields, matching the list shape).
//
// The row is linked into the document's hash chain: it carries the previous
// snapshot's hash, and its own hash covers that link plus its content. The hash
// can only be computed once the database has assigned created_at, so the insert
// and the hash write share a transaction — a snapshot is never visible with a
// hash that doesn't cover its own timestamp.
func (h *Handler) insertVersion(doc db.Document, kind string) (db.DocumentVersion, error) {
tx, err := h.DB.Begin()
if err != nil {
return db.DocumentVersion{}, err
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
// Chain onto the newest existing snapshot. created_at has second
// granularity, so rowid breaks ties in true insertion order; verification
// walks the same ordering in reverse.
var prevHash string
err = tx.QueryRow(
`SELECT content_hash FROM document_versions
WHERE doc_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1`,
doc.ID,
).Scan(&prevHash)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return db.DocumentVersion{}, err
}
var v db.DocumentVersion
if err := tx.QueryRow(
`INSERT INTO document_versions (doc_id, title, content, content_text, word_count, kind, prev_hash)
VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id, doc_id, title, word_count, kind, created_at`,
doc.ID, doc.Title, doc.Content, doc.ContentText, doc.WordCount, kind, prevHash,
).Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt); err != nil {
return db.DocumentVersion{}, err
}
v.PrevHash = prevHash
v.ContentHash = chainHash(prevHash, v.DocID, v.CreatedAt, v.WordCount, doc.ContentText)
if _, err := tx.Exec(
`UPDATE document_versions SET content_hash = ? WHERE id = ?`, v.ContentHash, v.ID,
); err != nil {
return db.DocumentVersion{}, err
}
if err := tx.Commit(); err != nil {
return db.DocumentVersion{}, err
}
return v, nil
}
// pruneAutoVersions trims a document's 'auto' snapshots to the newest
// maxAutoVersions, leaving 'manual' and 'pre_restore' restore points intact.
//
// Documents flagged preserve_history are exempt entirely: their history is
// authorship evidence, and evidence with the oldest entries dropped is exactly
// the part a reader would want — the early, sparse, figuring-it-out edits that
// distinguish writing from pasting.
func (h *Handler) pruneAutoVersions(docID string) error {
var preserve bool
if err := h.DB.QueryRow(
`SELECT preserve_history FROM documents WHERE id = ?`, docID,
).Scan(&preserve); err != nil {
return err
}
if preserve {
return nil
}
_, err := h.DB.Exec(
`DELETE FROM document_versions
WHERE doc_id = ? AND kind = 'auto'
AND id NOT IN (
SELECT id FROM document_versions
WHERE doc_id = ? AND kind = 'auto'
ORDER BY created_at DESC LIMIT ?
)`,
docID, docID, maxAutoVersions,
)
return err
}
// fetchVersion loads one full snapshot, scoped to its owner via the parent doc.
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, userID,
).Scan(
&v.ID, &v.DocID, &v.Title, &v.Content, &v.ContentText,
&v.WordCount, &v.Kind, &v.CreatedAt,
)
return v, err
}