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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user