Add trust features: history, trash, export, and collection sharing
release gate / check (push) Failing after 6m36s
release gate / check (push) Failing after 6m36s
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.
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/andrew/go-computer/internal/db"
|
||||
)
|
||||
|
||||
// Recoverable deletions (trust phase).
|
||||
//
|
||||
// Trash stores a private, per-owner JSON snapshot taken in the same
|
||||
// transaction as the hard delete. Restoring inserts the intrinsic record and
|
||||
// owner-scoped relationships, but deliberately NOT access grants:
|
||||
// - bookmark isPublic is forced false and bookmark/tag shares are not
|
||||
// snapshotted, so a restored bookmark never resurrects a public link or a
|
||||
// share removed while it was in the trash;
|
||||
// - collection shares are not snapshotted;
|
||||
// - server-driven watcher rows are not snapshotted.
|
||||
// Contextual collection memberships and page links are owner-scoped data and
|
||||
// are restored when their parent still exists.
|
||||
|
||||
func jsonSnapshot(v any) (string, error) {
|
||||
raw, e := json.Marshal(v)
|
||||
return string(raw), e
|
||||
}
|
||||
|
||||
func addTrash(r *http.Request, tx *sql.Tx, kind, originalID string, data object) error {
|
||||
raw, e := jsonSnapshot(data)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
_, e = db.Insert(r.Context(), tx, "Trash", object{
|
||||
"id": db.NewID(), "ownerId": user(r), "type": kind,
|
||||
"originalId": originalID, "data": raw,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
func deleteOwnedRow(r *http.Request, tx *sql.Tx, table, id, ownerCol string, deleted *int64) error {
|
||||
res, e := tx.ExecContext(r.Context(), `DELETE FROM `+db.Quote(table)+` WHERE id=$1 AND `+db.Quote(ownerCol)+`=$2`, id, user(r))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return db.ErrNotFound
|
||||
}
|
||||
if deleted != nil {
|
||||
*deleted = n
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- delete snapshots -----------------------------------------------------
|
||||
|
||||
func trashBookmark(r *http.Request, tx *sql.Tx, id string) error {
|
||||
row, e := owned(r, tx, "Bookmark", id, "ownerId")
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
tags, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('tagId',bt."tagId") FROM "BookmarkTag" bt JOIN "Tag" t ON t.id=bt."tagId" WHERE bt."bookmarkId"=$1 AND (t."ownerId"=$2 OR EXISTS(SELECT 1 FROM "TagShare" s WHERE s."tagId"=t.id AND s."userId"=$2))`, id, user(r))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
pageLinks, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('pageId',pb."pageId",'position',pb.position) FROM "PageBookmark" pb JOIN "Page" p ON p.id=pb."pageId" WHERE pb."bookmarkId"=$1 AND p."ownerId"=$2`, id, user(r))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
collectionItems, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('id',ci.id,'collectionId',ci."collectionId",'bookmarkId',ci."bookmarkId",'propertyValues',ci."propertyValues",'position',ci.position,'version',ci.version,'addedAt',ci."addedAt",'updatedAt',ci."updatedAt") FROM "CollectionItem" ci JOIN "Collection" c ON c.id=ci."collectionId" WHERE ci."bookmarkId"=$1 AND c."ownerId"=$2`, id, user(r))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if e := addTrash(r, tx, "bookmark", id, object{
|
||||
"bookmark": row, "tags": tags, "pageLinks": pageLinks, "collectionItems": collectionItems,
|
||||
}); e != nil {
|
||||
return e
|
||||
}
|
||||
return deleteOwnedRow(r, tx, "Bookmark", id, "ownerId", nil)
|
||||
}
|
||||
|
||||
func trashPage(r *http.Request, tx *sql.Tx, id string) error {
|
||||
row, e := owned(r, tx, "Page", id, "ownerId")
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
bookmarks, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('bookmarkId',pb."bookmarkId",'position',pb.position) FROM "PageBookmark" pb WHERE pb."pageId"=$1`, id)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
revisions, e := db.Many(r.Context(), tx, `SELECT to_jsonb(r) FROM "PageRevision" r WHERE r."pageId"=$1 ORDER BY r.version`, id)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if e := addTrash(r, tx, "page", id, object{"page": row, "bookmarks": bookmarks, "revisions": revisions}); e != nil {
|
||||
return e
|
||||
}
|
||||
return deleteOwnedRow(r, tx, "Page", id, "ownerId", nil)
|
||||
}
|
||||
|
||||
func trashCollection(r *http.Request, tx *sql.Tx, id string) error {
|
||||
row, e := owned(r, tx, "Collection", id, "ownerId")
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
properties, e := db.Many(r.Context(), tx, `SELECT to_jsonb(p) FROM "CollectionProperty" p WHERE p."collectionId"=$1 ORDER BY p.position,p.id`, id)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
views, e := db.Many(r.Context(), tx, `SELECT to_jsonb(v) FROM "CollectionView" v WHERE v."collectionId"=$1 ORDER BY v.position,v.id`, id)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
items, e := db.Many(r.Context(), tx, `SELECT to_jsonb(ci) FROM "CollectionItem" ci WHERE ci."collectionId"=$1 ORDER BY ci.position,ci.id`, id)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if e := addTrash(r, tx, "collection", id, object{
|
||||
"collection": row, "properties": properties, "views": views, "items": items,
|
||||
}); e != nil {
|
||||
return e
|
||||
}
|
||||
return deleteOwnedRow(r, tx, "Collection", id, "ownerId", nil)
|
||||
}
|
||||
|
||||
// --- restore --------------------------------------------------------------
|
||||
|
||||
func objectValue(v any) object {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
return m
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func objectList(v any) []object {
|
||||
arr, _ := v.([]any)
|
||||
out := make([]object, 0, len(arr))
|
||||
for _, x := range arr {
|
||||
if m, ok := x.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func insertSnapshot(r *http.Request, tx *sql.Tx, table, kind string, row object) error {
|
||||
raw, e := jsonSnapshot(row)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
_, e = tx.ExecContext(r.Context(), `INSERT INTO `+db.Quote(table)+` SELECT * FROM jsonb_populate_record(NULL::`+db.Quote(table)+`, $1::jsonb)`, raw)
|
||||
return e
|
||||
}
|
||||
|
||||
func restoreBookmark(r *http.Request, tx *sql.Tx, data object) (string, error) {
|
||||
row := objectValue(data["bookmark"])
|
||||
if row == nil {
|
||||
return "", bad("Trash entry is damaged")
|
||||
}
|
||||
id := str(row, "id")
|
||||
// Restore access decisions to their safe default. A public link or a
|
||||
// direct share is a grant, not intrinsic content.
|
||||
row["isPublic"] = false
|
||||
for _, pair := range [][2]string{{"contactId", "Contact"}, {"kanbanColumnId", "KanbanColumn"}} {
|
||||
if ref := str(row, pair[0]); ref != "" {
|
||||
col := "userId"
|
||||
var exists bool
|
||||
if e := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM `+db.Quote(pair[1])+` WHERE id=$1 AND `+db.Quote(col)+`=$2)`, ref, user(r)).Scan(&exists); e != nil {
|
||||
return "", e
|
||||
}
|
||||
if !exists {
|
||||
row[pair[0]] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if e := insertSnapshot(r, tx, "Bookmark", "bookmark", row); e != nil {
|
||||
return "", e
|
||||
}
|
||||
for _, t := range objectList(data["tags"]) {
|
||||
tagID := str(t, "tagId")
|
||||
var exists bool
|
||||
if e := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM "Tag" t WHERE t.id=$1 AND (t."ownerId"=$2 OR EXISTS(SELECT 1 FROM "TagShare" s WHERE s."tagId"=t.id AND s."userId"=$2)))`, tagID, user(r)).Scan(&exists); e != nil {
|
||||
return "", e
|
||||
}
|
||||
if exists {
|
||||
if _, e := tx.ExecContext(r.Context(), `INSERT INTO "BookmarkTag"("bookmarkId","tagId") VALUES($1,$2) ON CONFLICT DO NOTHING`, id, tagID); e != nil {
|
||||
return "", e
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, l := range objectList(data["pageLinks"]) {
|
||||
var exists bool
|
||||
if e := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM "Page" WHERE id=$1 AND "ownerId"=$2)`, str(l, "pageId"), user(r)).Scan(&exists); e != nil {
|
||||
return "", e
|
||||
}
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if _, e := tx.ExecContext(r.Context(), `INSERT INTO "PageBookmark"("pageId","bookmarkId",position) VALUES($1,$2,$3) ON CONFLICT DO NOTHING`, str(l, "pageId"), id, intFieldValue(l, "position")); e != nil {
|
||||
return "", e
|
||||
}
|
||||
}
|
||||
for _, item := range objectList(data["collectionItems"]) {
|
||||
var exists bool
|
||||
if e := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM "Collection" WHERE id=$1 AND "ownerId"=$2)`, str(item, "collectionId"), user(r)).Scan(&exists); e != nil {
|
||||
return "", e
|
||||
}
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if e := insertSnapshot(r, tx, "CollectionItem", "collectionItem", item); e != nil {
|
||||
return "", e
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func intFieldValue(b object, key string) int {
|
||||
if f, ok := b[key].(float64); ok {
|
||||
return int(f)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func restorePage(r *http.Request, tx *sql.Tx, data object) (string, error) {
|
||||
row := objectValue(data["page"])
|
||||
if row == nil {
|
||||
return "", bad("Trash entry is damaged")
|
||||
}
|
||||
id := str(row, "id")
|
||||
parent, e := pageParent(r.Context(), tx, id, user(r), row["parentId"])
|
||||
if e != nil {
|
||||
// A parent deleted while this page sat in the trash simply promotes
|
||||
// the restored page to the top level.
|
||||
if _, ok := e.(clientError); !ok {
|
||||
return "", e
|
||||
}
|
||||
parent = nil
|
||||
}
|
||||
row["parentId"] = parent
|
||||
if e := insertSnapshot(r, tx, "Page", "page", row); e != nil {
|
||||
return "", e
|
||||
}
|
||||
for _, l := range objectList(data["bookmarks"]) {
|
||||
var visible bool
|
||||
if e := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM "Bookmark" b WHERE b.id=$1 AND `+db.BookmarkVisible+`)`, str(l, "bookmarkId"), user(r)).Scan(&visible); e != nil {
|
||||
return "", e
|
||||
}
|
||||
if !visible {
|
||||
continue
|
||||
}
|
||||
if _, e := tx.ExecContext(r.Context(), `INSERT INTO "PageBookmark"("pageId","bookmarkId",position) VALUES($1,$2,$3) ON CONFLICT DO NOTHING`, id, str(l, "bookmarkId"), intFieldValue(l, "position")); e != nil {
|
||||
return "", e
|
||||
}
|
||||
}
|
||||
for _, rev := range objectList(data["revisions"]) {
|
||||
if e := insertSnapshot(r, tx, "PageRevision", "pageRevision", rev); e != nil {
|
||||
return "", e
|
||||
}
|
||||
}
|
||||
if e := syncPageGraph(r.Context(), tx, id, user(r), str(row, "content")); e != nil {
|
||||
return "", e
|
||||
}
|
||||
// A restore is a new trust event: bump the revision and record it, so the
|
||||
// state that came back from the trash is itself auditable.
|
||||
if _, e := tx.ExecContext(r.Context(), `UPDATE "Page" SET version=version+1,"updatedAt"=now() WHERE id=$1 AND "ownerId"=$2`, id, user(r)); e != nil {
|
||||
return "", e
|
||||
}
|
||||
if e := capturePageRevision(r, tx, id); e != nil {
|
||||
return "", e
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func restoreCollection(r *http.Request, tx *sql.Tx, data object) (string, error) {
|
||||
row := objectValue(data["collection"])
|
||||
if row == nil {
|
||||
return "", bad("Trash entry is damaged")
|
||||
}
|
||||
id := str(row, "id")
|
||||
if e := insertSnapshot(r, tx, "Collection", "collection", row); e != nil {
|
||||
return "", e
|
||||
}
|
||||
for _, prop := range objectList(data["properties"]) {
|
||||
if e := insertSnapshot(r, tx, "CollectionProperty", "collectionProperty", prop); e != nil {
|
||||
return "", e
|
||||
}
|
||||
}
|
||||
for _, view := range objectList(data["views"]) {
|
||||
if e := insertSnapshot(r, tx, "CollectionView", "collectionView", view); e != nil {
|
||||
return "", e
|
||||
}
|
||||
}
|
||||
for _, item := range objectList(data["items"]) {
|
||||
var visible bool
|
||||
if e := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM "Bookmark" b WHERE b.id=$1 AND `+db.BookmarkVisible+`)`, str(item, "bookmarkId"), user(r)).Scan(&visible); e != nil {
|
||||
return "", e
|
||||
}
|
||||
if !visible {
|
||||
continue
|
||||
}
|
||||
if e := insertSnapshot(r, tx, "CollectionItem", "collectionItem", item); e != nil {
|
||||
return "", e
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// --- HTTP surfaces --------------------------------------------------------
|
||||
|
||||
func HandleTrashList(w http.ResponseWriter, r *http.Request) {
|
||||
v, e := db.Many(r.Context(), database(r), `SELECT jsonb_build_object(
|
||||
'id',t.id,'type',t.type,'originalId',t."originalId",'deletedAt',t."deletedAt",
|
||||
'title',COALESCE(
|
||||
NULLIF(t.data->'bookmark'->>'title',''),
|
||||
t.data->'bookmark'->>'url',
|
||||
NULLIF(t.data->'page'->>'title',''),
|
||||
t.data->'collection'->>'name',
|
||||
'Untitled'))
|
||||
FROM "Trash" t WHERE t."ownerId"=$1 ORDER BY t."deletedAt" DESC,t.id`, user(r))
|
||||
respond(w, 200, v, e)
|
||||
}
|
||||
|
||||
func HandleTrashRestore(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var kind, originalID string
|
||||
e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
|
||||
if e := lockUser(r, tx); e != nil {
|
||||
return e
|
||||
}
|
||||
entry, e := db.One(r.Context(), tx, `SELECT to_jsonb(t) FROM "Trash" t WHERE t.id=$1 AND t."ownerId"=$2 FOR UPDATE`, id, user(r))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
kind, originalID = str(entry, "type"), str(entry, "originalId")
|
||||
data := objectValue(entry["data"])
|
||||
if data == nil {
|
||||
return bad("Trash entry is damaged")
|
||||
}
|
||||
switch kind {
|
||||
case "bookmark":
|
||||
_, e = restoreBookmark(r, tx, data)
|
||||
case "page":
|
||||
_, e = restorePage(r, tx, data)
|
||||
case "collection":
|
||||
_, e = restoreCollection(r, tx, data)
|
||||
default:
|
||||
return bad("Unknown trash entry type")
|
||||
}
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
_, e = tx.ExecContext(r.Context(), `DELETE FROM "Trash" WHERE id=$1 AND "ownerId"=$2`, id, user(r))
|
||||
return e
|
||||
})
|
||||
respond(w, 200, object{"ok": true, "type": kind, "id": originalID}, e)
|
||||
}
|
||||
|
||||
func HandleTrashPurge(w http.ResponseWriter, r *http.Request) {
|
||||
// Permanent removal is explicit (the default delete moves to the trash).
|
||||
_, e := database(r).ExecContext(r.Context(), `DELETE FROM "Trash" WHERE id=$1 AND "ownerId"=$2`, r.PathValue("id"), user(r))
|
||||
respond(w, 200, OK{true}, e)
|
||||
}
|
||||
Reference in New Issue
Block a user