Add trust features: history, trash, export, and collection sharing
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:
Andrew S Erwin
2026-10-19 23:07:38 -05:00
parent 9db69efe4b
commit 5eab58f99c
22 changed files with 1496 additions and 76 deletions
+81 -8
View File
@@ -63,6 +63,15 @@ var (
func collectionOwned(r *http.Request, q db.Queryer, id string) (object, error) {
return db.One(r.Context(), q, `SELECT to_jsonb(c) FROM "Collection" c WHERE c.id=$1 AND c."ownerId"=$2`, id, user(r))
}
// collectionReadable admits the owner and explicit recipients of a
// CollectionShare. Mutating surfaces keep using collectionOwned, so a shared
// reader can never rename, delete, reorder, or edit collection content.
func collectionReadable(r *http.Request, q db.Queryer, id string) (object, error) {
return db.One(r.Context(), q, `SELECT to_jsonb(c)||jsonb_build_object('shared',c."ownerId"<>$2)
FROM "Collection" c WHERE c.id=$1 AND (c."ownerId"=$2 OR EXISTS(
SELECT 1 FROM "CollectionShare" s WHERE s."collectionId"=c.id AND s."userId"=$2))`, id, user(r))
}
func collectionProperties(r *http.Request, q db.Queryer, collectionID string) ([]object, error) {
return db.Many(r.Context(), q, `SELECT to_jsonb(p) FROM "CollectionProperty" p WHERE p."collectionId"=$1 ORDER BY p.position,p.id`, collectionID)
}
@@ -99,6 +108,19 @@ func collectionDetail(r *http.Request, q db.Queryer, col object) (object, error)
out[k] = v
}
out["properties"], out["views"], out["_count"] = props, views, counts
shared := str(col, "ownerId") != user(r)
out["shared"] = shared
if shared {
// Recipients see who shared it, but never the other recipients.
out["owner"], _ = db.One(r.Context(), q, `SELECT jsonb_build_object('id',u.id,'name',u.name,'email',u.email,'image',u.image) FROM "User" u WHERE u.id=$1`, str(col, "ownerId"))
out["sharedWith"] = []object{}
} else {
shares, e := db.Many(r.Context(), q, `SELECT jsonb_build_object('userId',s."userId",'email',u.email,'name',u.name,'createdAt',s."createdAt") FROM "CollectionShare" s JOIN "User" u ON u.id=s."userId" WHERE s."collectionId"=$1 ORDER BY s."createdAt"`, str(col, "id"))
if e != nil {
return nil, e
}
out["sharedWith"] = shares
}
return out, nil
}
@@ -159,11 +181,18 @@ func HandleCollectionsList(w http.ResponseWriter, r *http.Request) {
// Item counts are authorized counts (N-10 edge case): a bookmark the
// caller can no longer see never inflates them. Collections are
// owner-scoped in phase 2, so $1 (owner) and $2 (BookmarkVisible) match.
v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(c)||jsonb_build_object('_count',jsonb_build_object(
// Owner collections and collections explicitly shared with this user.
// `shared` is disclosed by the UI; item counts are authorized counts and
// therefore only include bookmarks the caller may see.
v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(c)||jsonb_build_object(
'shared',c."ownerId"<>$1,
'owner',(SELECT jsonb_build_object('id',u.id,'name',u.name,'email',u.email,'image',u.image) FROM "User" u WHERE u.id=c."ownerId"),
'_count',jsonb_build_object(
'items',(SELECT count(*) FROM "CollectionItem" ci JOIN "Bookmark" b ON b.id=ci."bookmarkId" WHERE ci."collectionId"=c.id AND `+db.BookmarkVisible+`),
'properties',(SELECT count(*) FROM "CollectionProperty" p WHERE p."collectionId"=c.id),
'views',(SELECT count(*) FROM "CollectionView" v WHERE v."collectionId"=c.id)))
FROM "Collection" c WHERE c."ownerId"=$1 ORDER BY c."createdAt",c.id`, user(r), user(r))
FROM "Collection" c WHERE c."ownerId"=$1 OR EXISTS(SELECT 1 FROM "CollectionShare" s WHERE s."collectionId"=c.id AND s."userId"=$1)
ORDER BY (c."ownerId"<>$1),c."createdAt",c.id`, user(r), user(r))
respond(w, 200, v, e)
}
@@ -204,7 +233,7 @@ func HandleCollectionsCreate(w http.ResponseWriter, r *http.Request) {
}
func HandleCollectionsGet(w http.ResponseWriter, r *http.Request) {
col, e := collectionOwned(r, database(r), r.PathValue("id"))
col, e := collectionReadable(r, database(r), r.PathValue("id"))
if e != nil {
respond(w, 0, nil, e)
return
@@ -252,8 +281,51 @@ func HandleCollectionsUpdate(w http.ResponseWriter, r *http.Request) {
func HandleCollectionsDelete(w http.ResponseWriter, r *http.Request) {
// Memberships, properties and views cascade; bookmarks are never touched
// (N-01 acceptance criterion).
respond(w, 200, OK{true}, remove(r, "Collection", r.PathValue("id"), "ownerId"))
// (N-01 acceptance criterion). The collection structure is snapshotted for
// restore; shares are not, so a revoked grant never comes back.
e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
return trashCollection(r, tx, r.PathValue("id"))
})
respond(w, 200, OK{true}, e)
}
// HandleCollectionShare grants read-only access to explicitly named users.
// The share is disclosed in the collection UI and revocable; recipients get
// the narrowed bookmark projection for member bookmarks and cannot mutate the
// collection or its items. Sharing an already-shared user is idempotent.
func HandleCollectionShare(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, e := collectionOwned(r, database(r), id); e != nil {
respond(w, 0, nil, e)
return
}
b, ok := read(w, r)
if !ok {
return
}
list, e := ids(b, "userIds")
if e != nil {
respond(w, 0, nil, e)
return
}
// Sharing with yourself is meaningless; skip it rather than creating an
// ownership-adjacent row that would confuse the recipient list.
filtered := make([]string, 0, len(list))
for _, uid := range list {
if uid != user(r) {
filtered = append(filtered, uid)
}
}
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
return shareRows(r, tx, "CollectionShare", "collectionId", id, filtered)
})
respond(w, 200, OK{true}, e)
}
// --- properties -----------------------------------------------------------
@@ -1203,8 +1275,9 @@ func HandleCollectionItemsList(w http.ResponseWriter, r *http.Request) {
}
limit = n
}
// Ownership is the gate: a foreign collection is a 404, not an empty list.
if _, e := collectionOwned(r, database(r), cid); e != nil {
// Owner or explicit share is the read gate: a foreign collection is a
// 404, not an empty list. Mutations below still require ownership.
if _, e := collectionReadable(r, database(r), cid); e != nil {
respond(w, 0, nil, e)
return
}
@@ -1217,7 +1290,7 @@ func HandleCollectionItemsList(w http.ResponseWriter, r *http.Request) {
// $1 stays a permanently-nil placeholder (BookmarkVisible binds $2), the
// same port artifact the bookmarks list documents.
args := []any{nil, user(r), cid}
where := `c."ownerId"=$2 AND ci."collectionId"=$3 AND ` + db.BookmarkVisible + ` AND $1::text IS NULL`
where := `(c."ownerId"=$2 OR EXISTS(SELECT 1 FROM "CollectionShare" cs WHERE cs."collectionId"=c.id AND cs."userId"=$2)) AND ci."collectionId"=$3 AND ` + db.BookmarkVisible + ` AND $1::text IS NULL`
var sorts []any
if viewID := sp.Get("viewId"); viewID != "" {
view, e2 := db.One(r.Context(), database(r), `SELECT to_jsonb(v) FROM "CollectionView" v WHERE v.id=$1 AND v."collectionId"=$2`, viewID, cid)