Files
brain/internal/handlers/page_revisions.go
T
Andrew S Erwin 5eab58f99c
release gate / check (push) Failing after 6m36s
Add trust features: history, trash, export, and collection sharing
Implement Notion-like upgrade phase 4: append-only page revisions with restore-as-a-new-version; recoverable bookmark/page/collection deletions whose restores never resurrect public flags or shares; owner-scoped JSON export; and explicit, revocable read-only collection sharing with owner-only mutations and no transitive third-party bookmark leaks. Add migration 009, API/docs, integration coverage, and page/collections/trash UI.
2026-10-19 23:07:38 -05:00

96 lines
3.6 KiB
Go

package handlers
import (
"database/sql"
"net/http"
"time"
"github.com/andrew/go-computer/internal/db"
)
// Page revision history (trust phase). Every committed content version gets
// an immutable snapshot; restoring an old snapshot writes a new version rather
// than rewriting history. Snapshots are owner-scoped through their page.
func capturePageRevision(r *http.Request, tx *sql.Tx, pageID string) error {
_, e := tx.ExecContext(r.Context(), `INSERT INTO "PageRevision"(id,"pageId",version,title,content,icon)
SELECT $1,p.id,p.version,p.title,p.content,p.icon FROM "Page" p WHERE p.id=$2
ON CONFLICT ("pageId",version) DO NOTHING`, db.NewID(), pageID)
return e
}
func revisionOwned(r *http.Request, q db.Queryer, pageID, revisionID string) (object, error) {
if _, e := owned(r, q, "Page", pageID, "ownerId"); e != nil {
return nil, e
}
return db.One(r.Context(), q, `SELECT to_jsonb(r) FROM "PageRevision" r WHERE r.id=$1 AND r."pageId"=$2`, revisionID, pageID)
}
// HandlePageRevisionsList returns metadata only — the editor fetches a body on
// demand (a page can have a long history and each body is up to 512 KiB).
func HandlePageRevisionsList(w http.ResponseWriter, r *http.Request) {
pageID := r.PathValue("id")
if _, e := owned(r, database(r), "Page", pageID, "ownerId"); e != nil {
respond(w, 0, nil, e)
return
}
v, e := db.Many(r.Context(), database(r), `SELECT jsonb_build_object(
'id',r.id,'version',r.version,'title',r.title,'icon',r.icon,
'createdAt',r."createdAt",'length',length(r.content))
FROM "PageRevision" r WHERE r."pageId"=$1 ORDER BY r.version DESC`, pageID)
respond(w, 200, v, e)
}
func HandlePageRevisionsGet(w http.ResponseWriter, r *http.Request) {
v, e := revisionOwned(r, database(r), r.PathValue("id"), r.PathValue("revisionId"))
respond(w, 200, v, e)
}
// HandlePageRevisionsRestore copies a snapshot into the live page as a new
// version. It requires the current base version (the same DATA-05 rule as a
// content save), so restoring while another tab writes is a 409, not a silent
// overwrite. The restored state itself becomes a history entry.
func HandlePageRevisionsRestore(w http.ResponseWriter, r *http.Request) {
pageID, revisionID := r.PathValue("id"), r.PathValue("revisionId")
b, ok := read(w, r)
if !ok {
return
}
baseVersion, e := patchVersion(b)
if e != nil {
respond(w, 0, nil, e)
return
}
var v object
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
current, e := owned(r, tx, "Page", pageID, "ownerId")
if e != nil {
return e
}
if stored, _ := current["version"].(float64); int(stored) != baseVersion {
return conflict("Page was modified elsewhere; reload to see the latest version")
}
rev, e := db.One(r.Context(), tx, `SELECT to_jsonb(r) FROM "PageRevision" r WHERE r.id=$1 AND r."pageId"=$2`, revisionID, pageID)
if e != nil {
return e
}
if _, e := tx.ExecContext(r.Context(), `UPDATE "Page" SET title=$1,content=$2,icon=$3,version=version+1,"updatedAt"=$4 WHERE id=$5 AND "ownerId"=$6`,
str(rev, "title"), str(rev, "content"), rev["icon"], time.Now().UTC(), pageID, user(r)); e != nil {
return e
}
// Restoring content also restores its derived graph. Targets that no
// longer exist (or are no longer owned) are ignored by syncPageGraph.
if e := syncPageGraph(r.Context(), tx, pageID, user(r), str(rev, "content")); e != nil {
return e
}
if e := capturePageRevision(r, tx, pageID); e != nil {
return e
}
v, e = pageView(r.Context(), tx, pageID, user(r))
return e
})
respond(w, 200, v, e)
}