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:
@@ -41,4 +41,11 @@ const BookmarkViewerView = `CASE WHEN b."ownerId"=$2 THEN ` + BookmarkOwnerView
|
||||
// it, including bookmarks other recipients added. The share UI says so.
|
||||
const BookmarkVisible = `(b."ownerId"=$2 OR EXISTS(SELECT 1 FROM "BookmarkShare" s WHERE s."bookmarkId"=b.id AND s."userId"=$2)
|
||||
OR EXISTS(SELECT 1 FROM "BookmarkTag" bt JOIN "TagShare" ts ON ts."tagId"=bt."tagId" WHERE bt."bookmarkId"=b.id AND ts."userId"=$2)
|
||||
-- Explicit collection sharing (trust phase): membership in a collection the
|
||||
-- bookmark's OWNER deliberately shared grants the narrowed bookmark
|
||||
-- projection. The owner match is essential: a collection can contain
|
||||
-- bookmarks merely shared with its owner, and sharing that collection must
|
||||
-- not widen a third party's grant. Removing the item or revoking the share
|
||||
-- removes this edge immediately.
|
||||
OR EXISTS(SELECT 1 FROM "CollectionItem" ci JOIN "Collection" c ON c.id=ci."collectionId" JOIN "CollectionShare" cs ON cs."collectionId"=ci."collectionId" WHERE ci."bookmarkId"=b.id AND cs."userId"=$2 AND c."ownerId"=b."ownerId")
|
||||
OR EXISTS(SELECT 1 FROM "SharedDate" sd WHERE sd."creatorId"=b."ownerId" AND sd."recipientId"=$2 AND (b."dueDate" AT TIME ZONE 'UTC')::date=sd.date))`
|
||||
|
||||
@@ -363,7 +363,15 @@ func HandleBookmarksUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, 200, v, e)
|
||||
}
|
||||
func HandleBookmarksDelete(w http.ResponseWriter, r *http.Request) {
|
||||
e := remove(r, "Bookmark", r.PathValue("id"), "ownerId")
|
||||
// Recoverable delete (trust phase): snapshot intrinsic fields and
|
||||
// owner-scoped links in the same transaction as the hard delete. Shares
|
||||
// and the public flag are deliberately not restored.
|
||||
e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
|
||||
if e := lockUser(r, tx); e != nil {
|
||||
return e
|
||||
}
|
||||
return trashBookmark(r, tx, r.PathValue("id"))
|
||||
})
|
||||
respond(w, 200, OK{true}, e)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/andrew/go-computer/internal/db"
|
||||
)
|
||||
|
||||
// Structured export (trust phase): a single owner-scoped JSON archive that is
|
||||
// richer than the per-item Markdown downloads. It deliberately excludes
|
||||
// credentials (IMAP config, CardDAV passwords) and includes only data the
|
||||
// caller owns or may read through the owner projection.
|
||||
func HandleExport(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
uid := user(r)
|
||||
me, e := db.One(ctx, database(r), `SELECT jsonb_build_object('id',u.id,'name',u.name,'email',u.email,'role',u.role,'createdAt',u."createdAt") FROM "User" u WHERE u.id=$1`, uid)
|
||||
if e != nil {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
}
|
||||
bookmarks, e := db.Many(ctx, database(r), `SELECT `+db.BookmarkOwnerView+` FROM "Bookmark" b WHERE b."ownerId"=$1 ORDER BY b."createdAt",b.id`, uid)
|
||||
if e != nil {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
}
|
||||
tags, e := db.Many(ctx, database(r), `SELECT to_jsonb(t)||jsonb_build_object('sharedWith',COALESCE((SELECT jsonb_agg(jsonb_build_object('userId',s."userId",'email',u.email,'name',u.name)) FROM "TagShare" s JOIN "User" u ON u.id=s."userId" WHERE s."tagId"=t.id),'[]'::jsonb)) FROM "Tag" t WHERE t."ownerId"=$1 ORDER BY t."createdAt",t.id`, uid)
|
||||
if e != nil {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
}
|
||||
pages, e := db.Many(ctx, database(r), `SELECT to_jsonb(p)||jsonb_build_object(
|
||||
'bookmarks',COALESCE((SELECT jsonb_agg(to_jsonb(pb)||jsonb_build_object('bookmark',`+db.BookmarkOwnerView+`) ORDER BY pb.position) FROM "PageBookmark" pb JOIN "Bookmark" b ON b.id=pb."bookmarkId" WHERE pb."pageId"=p.id AND b."ownerId"=$1),'[]'::jsonb),
|
||||
'revisions',COALESCE((SELECT jsonb_agg(to_jsonb(r) ORDER BY r.version) FROM "PageRevision" r WHERE r."pageId"=p.id),'[]'::jsonb))
|
||||
FROM "Page" p WHERE p."ownerId"=$1 ORDER BY p."createdAt",p.id`, uid)
|
||||
if e != nil {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
}
|
||||
contacts, e := db.Many(ctx, database(r), `SELECT to_jsonb(c) FROM "Contact" c WHERE c."userId"=$1 ORDER BY c."displayName",c.id`, uid)
|
||||
if e != nil {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
}
|
||||
collections, e := db.Many(ctx, database(r), `SELECT to_jsonb(c) FROM "Collection" c WHERE c."ownerId"=$1 ORDER BY c."createdAt",c.id`, uid)
|
||||
if e != nil {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
}
|
||||
exported := make([]object, 0, len(collections))
|
||||
for _, col := range collections {
|
||||
detail, e := collectionDetail(r, database(r), col)
|
||||
if e != nil {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
}
|
||||
cid := str(col, "id")
|
||||
items, e := db.Many(ctx, database(r), `SELECT to_jsonb(ci)||jsonb_build_object('bookmark',`+db.BookmarkOwnerView+`) FROM "CollectionItem" ci JOIN "Bookmark" b ON b.id=ci."bookmarkId" WHERE ci."collectionId"=$1 ORDER BY ci.position,ci.id`, cid)
|
||||
if e != nil {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
}
|
||||
shares, e := db.Many(ctx, database(r), `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"`, cid)
|
||||
if e != nil {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
}
|
||||
detail["items"], detail["sharedWith"] = items, shares
|
||||
exported = append(exported, detail)
|
||||
}
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="computer-export.json"`)
|
||||
respond(w, 200, object{
|
||||
"format": "computer-export",
|
||||
"version": 1,
|
||||
"exportedAt": time.Now().UTC(),
|
||||
"user": me,
|
||||
"bookmarks": bookmarks,
|
||||
"tags": tags,
|
||||
"pages": pages,
|
||||
"collections": exported,
|
||||
"contacts": contacts,
|
||||
}, nil)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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)
|
||||
}
|
||||
@@ -121,6 +121,9 @@ func HandlePagesCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return e
|
||||
}
|
||||
}
|
||||
if e := capturePageRevision(r, tx, d["id"].(string)); e != nil {
|
||||
return e
|
||||
}
|
||||
// Acknowledge the same snapshot the write produced (the update path's
|
||||
// CR-18 discipline), so the 201 already carries links/embeds.
|
||||
v, e = pageView(r.Context(), tx, d["id"].(string), user(r))
|
||||
@@ -218,6 +221,9 @@ func HandlePagesUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if e := syncPageGraph(r.Context(), tx, id, user(r), content); e != nil {
|
||||
return e
|
||||
}
|
||||
if e := capturePageRevision(r, tx, id); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
if bid := str(b, "addBookmarkId"); bid != "" {
|
||||
if _, e := db.One(r.Context(), tx, `SELECT to_jsonb(b) FROM "Bookmark" b WHERE b.id=$1 AND `+db.BookmarkVisible, bid, user(r)); e != nil {
|
||||
@@ -254,8 +260,15 @@ func HandlePagesBacklinks(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
func HandlePagesDelete(w http.ResponseWriter, r *http.Request) {
|
||||
// Children are promoted to the top level by the self-FK's SET NULL; a
|
||||
// delete never silently takes nested pages with it.
|
||||
respond(w, 200, OK{true}, remove(r, "Page", r.PathValue("id"), "ownerId"))
|
||||
// delete never silently takes nested pages with it. The page, its history
|
||||
// and its bookmark links are snapshotted for restore.
|
||||
e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
|
||||
if e := lockUser(r, tx); e != nil {
|
||||
return e
|
||||
}
|
||||
return trashPage(r, tx, r.PathValue("id"))
|
||||
})
|
||||
respond(w, 200, OK{true}, e)
|
||||
}
|
||||
func HandlePagesMarkdown(w http.ResponseWriter, r *http.Request) {
|
||||
v, e := page(r, r.PathValue("id"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -20,6 +20,9 @@ func HandleBookmarkUnshare(w http.ResponseWriter, r *http.Request) {
|
||||
func HandleTagUnshare(w http.ResponseWriter, r *http.Request) {
|
||||
unshare(w, r, "Tag", "TagShare", "tagId")
|
||||
}
|
||||
func HandleCollectionUnshare(w http.ResponseWriter, r *http.Request) {
|
||||
unshare(w, r, "Collection", "CollectionShare", "collectionId")
|
||||
}
|
||||
func HandleSharedDateDelete(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, 200, OK{true}, remove(r, "SharedDate", r.PathValue("id"), "creatorId"))
|
||||
}
|
||||
|
||||
@@ -89,8 +89,17 @@ func NewServer(cfg *config.Config, database *db.DB) *http.Server {
|
||||
mux.HandleFunc("PATCH /api/pages/{id}", authed(handlers.HandlePagesUpdate))
|
||||
mux.HandleFunc("DELETE /api/pages/{id}", authed(handlers.HandlePagesDelete))
|
||||
mux.HandleFunc("GET /api/pages/{id}/backlinks", authed(handlers.HandlePagesBacklinks))
|
||||
mux.HandleFunc("GET /api/pages/{id}/revisions", authed(handlers.HandlePageRevisionsList))
|
||||
mux.HandleFunc("GET /api/pages/{id}/revisions/{revisionId}", authed(handlers.HandlePageRevisionsGet))
|
||||
mux.HandleFunc("POST /api/pages/{id}/revisions/{revisionId}/restore", authed(handlers.HandlePageRevisionsRestore))
|
||||
mux.HandleFunc("GET /api/pages/{id}/markdown", authed(handlers.HandlePagesMarkdown))
|
||||
|
||||
// Trust phase: recoverable deletions and structured export.
|
||||
mux.HandleFunc("GET /api/trash", authed(handlers.HandleTrashList))
|
||||
mux.HandleFunc("POST /api/trash/{id}/restore", authed(handlers.HandleTrashRestore))
|
||||
mux.HandleFunc("DELETE /api/trash/{id}", authed(handlers.HandleTrashPurge))
|
||||
mux.HandleFunc("GET /api/export", authed(handlers.HandleExport))
|
||||
|
||||
// 4.7 Signals
|
||||
mux.HandleFunc("GET /api/bookmarks/{id}/signals", authed(handlers.HandleSignalsList))
|
||||
mux.HandleFunc("POST /api/bookmarks/{id}/signals", authed(handlers.HandleSignalsCreate))
|
||||
@@ -118,6 +127,8 @@ func NewServer(cfg *config.Config, database *db.DB) *http.Server {
|
||||
mux.HandleFunc("GET /api/collections/{id}", authed(handlers.HandleCollectionsGet))
|
||||
mux.HandleFunc("PATCH /api/collections/{id}", authed(handlers.HandleCollectionsUpdate))
|
||||
mux.HandleFunc("DELETE /api/collections/{id}", authed(handlers.HandleCollectionsDelete))
|
||||
mux.HandleFunc("POST /api/collections/{id}/share", authed(handlers.HandleCollectionShare))
|
||||
mux.HandleFunc("DELETE /api/collections/{id}/share/{userId}", authed(handlers.HandleCollectionUnshare))
|
||||
mux.HandleFunc("POST /api/collections/{id}/properties", authed(handlers.HandleCollectionPropertiesCreate))
|
||||
mux.HandleFunc("PATCH /api/collections/{id}/properties/{propertyId}", authed(handlers.HandleCollectionPropertiesUpdate))
|
||||
mux.HandleFunc("DELETE /api/collections/{id}/properties/{propertyId}", authed(handlers.HandleCollectionPropertiesDelete))
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Trust phase: page revision history, recoverable deletions that never
|
||||
// resurrect access grants, structured export, and explicit collection sharing.
|
||||
|
||||
func TestPageRevisionHistory(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
alice := newAPIUser(t, srv, "trust-history@computer.test")
|
||||
bob := newAPIUser(t, srv, "trust-history-other@computer.test")
|
||||
|
||||
page := alice.request("POST", "/api/pages", map[string]any{"title": "History", "content": "version one"}, 201)["id"].(string)
|
||||
revisions := alice.getJSON("/api/pages/"+page+"/revisions", 200)
|
||||
if len(revisions) != 1 || revisions[0]["version"].(float64) != 1 || revisions[0]["content"] != nil {
|
||||
t.Fatalf("initial revision metadata: %v", revisions)
|
||||
}
|
||||
first := revisions[0]["id"].(string)
|
||||
body := alice.request("GET", "/api/pages/"+page+"/revisions/"+first, nil, 200)
|
||||
if body["content"] != "version one" {
|
||||
t.Fatalf("revision body: %v", body)
|
||||
}
|
||||
|
||||
alice.request("PATCH", "/api/pages/"+page, map[string]any{"content": "version two", "version": 1}, 200)
|
||||
revisions = alice.getJSON("/api/pages/"+page+"/revisions", 200)
|
||||
if len(revisions) != 2 || revisions[0]["version"].(float64) != 2 {
|
||||
t.Fatalf("history did not append: %v", revisions)
|
||||
}
|
||||
|
||||
// Restore writes a NEW version rather than rewriting history.
|
||||
restored := alice.request("POST", "/api/pages/"+page+"/revisions/"+first+"/restore", map[string]any{"version": 2}, 200)
|
||||
if restored["content"] != "version one" || restored["version"].(float64) != 3 {
|
||||
t.Fatalf("restore did not create a new version: %v", restored)
|
||||
}
|
||||
revisions = alice.getJSON("/api/pages/"+page+"/revisions", 200)
|
||||
if len(revisions) != 3 || revisions[0]["version"].(float64) != 3 {
|
||||
t.Fatalf("restore was not recorded as history: %v", revisions)
|
||||
}
|
||||
if got := alice.request("GET", "/api/pages/"+page+"/revisions/"+revisions[0]["id"].(string), nil, 200); got["content"] != "version one" {
|
||||
t.Fatalf("restored revision body: %v", got)
|
||||
}
|
||||
|
||||
// A stale base is a 409, not a silent overwrite.
|
||||
alice.request("POST", "/api/pages/"+page+"/revisions/"+first+"/restore", map[string]any{"version": 2}, 409)
|
||||
alice.request("POST", "/api/pages/"+page+"/revisions/"+first+"/restore", map[string]any{}, 400)
|
||||
bob.request("GET", "/api/pages/"+page+"/revisions", nil, 404)
|
||||
bob.request("GET", "/api/pages/"+page+"/revisions/"+first, nil, 404)
|
||||
bob.request("POST", "/api/pages/"+page+"/revisions/"+first+"/restore", map[string]any{"version": 3}, 404)
|
||||
}
|
||||
|
||||
func TestTrashRestoreNeverResurrectsGrants(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
alice := newAPIUser(t, srv, "trust-trash@computer.test")
|
||||
bob := newAPIUser(t, srv, "trust-trash-other@computer.test")
|
||||
bobID := meID(bob)
|
||||
|
||||
tag := alice.request("POST", "/api/tags", map[string]any{"name": "Restored tag"}, 201)["id"].(string)
|
||||
page := alice.request("POST", "/api/pages", map[string]any{"title": "Host page"}, 201)["id"].(string)
|
||||
col := alice.request("POST", "/api/collections", map[string]any{"name": "Restored project"}, 201)
|
||||
cid := col["id"].(string)
|
||||
prop := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Status", "type": "TEXT"}, 201)["id"].(string)
|
||||
|
||||
bm := alice.request("POST", "/api/bookmarks", map[string]any{
|
||||
"url": "https://127.0.0.1/restore-me", "title": "Restore me", "tagIds": []string{tag}, "isPublic": true,
|
||||
}, 201)
|
||||
bid := bm["id"].(string)
|
||||
alice.request("PATCH", "/api/pages/"+page, map[string]any{"addBookmarkId": bid}, 200)
|
||||
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bid, "propertyValues": map[string]any{prop: "kept"}}, 201)
|
||||
alice.request("POST", "/api/bookmarks/"+bid+"/share", map[string]any{"userIds": []string{bobID}}, 200)
|
||||
bob.request("GET", "/api/bookmarks/"+bid, nil, 200)
|
||||
alice.request("GET", "/api/public/bookmarks/"+bid, nil, 200)
|
||||
|
||||
alice.request("DELETE", "/api/bookmarks/"+bid, nil, 200)
|
||||
bob.request("GET", "/api/bookmarks/"+bid, nil, 404)
|
||||
alice.request("GET", "/api/public/bookmarks/"+bid, nil, 404)
|
||||
trash := alice.getJSON("/api/trash", 200)
|
||||
if len(trash) != 1 || trash[0]["type"] != "bookmark" || trash[0]["title"] != "Restore me" {
|
||||
t.Fatalf("bookmark trash entry: %v", trash)
|
||||
}
|
||||
entry := trash[0]["id"].(string)
|
||||
bob.request("POST", "/api/trash/"+entry+"/restore", map[string]any{}, 404)
|
||||
|
||||
restored := alice.request("POST", "/api/trash/"+entry+"/restore", map[string]any{}, 200)
|
||||
if restored["ok"] != true || restored["id"] != bid {
|
||||
t.Fatalf("restore response: %v", restored)
|
||||
}
|
||||
// Intrinsic links return; access grants do not.
|
||||
got := alice.request("GET", "/api/bookmarks/"+bid, nil, 200)
|
||||
if got["title"] != "Restore me" || len(got["tags"].([]any)) != 1 || got["isPublic"] != false {
|
||||
t.Fatalf("restored bookmark: %v", got)
|
||||
}
|
||||
if len(got["pageLinks"].([]any)) != 1 {
|
||||
t.Fatalf("page link was not restored: %v", got["pageLinks"])
|
||||
}
|
||||
bob.request("GET", "/api/bookmarks/"+bid, nil, 404)
|
||||
alice.request("GET", "/api/public/bookmarks/"+bid, nil, 404)
|
||||
items := alice.getJSON("/api/collections/"+cid+"/items", 200)
|
||||
if len(items) != 1 || items[0]["propertyValues"].(map[string]any)[prop] != "kept" {
|
||||
t.Fatalf("collection membership was not restored: %v", items)
|
||||
}
|
||||
if trash := alice.getJSON("/api/trash", 200); len(trash) != 0 {
|
||||
t.Fatalf("restored entry stayed in trash: %v", trash)
|
||||
}
|
||||
|
||||
// Pages restore with their history, and the restore is itself a revision.
|
||||
alice.request("PATCH", "/api/pages/"+page, map[string]any{"content": "page body", "version": 1}, 200)
|
||||
alice.request("DELETE", "/api/pages/"+page, nil, 200)
|
||||
alice.request("GET", "/api/pages/"+page, nil, 404)
|
||||
trash = alice.getJSON("/api/trash", 200)
|
||||
var pageEntry string
|
||||
for _, row := range trash {
|
||||
if row["type"] == "page" {
|
||||
pageEntry = row["id"].(string)
|
||||
}
|
||||
}
|
||||
if pageEntry == "" {
|
||||
t.Fatalf("page missing from trash: %v", trash)
|
||||
}
|
||||
alice.request("POST", "/api/trash/"+pageEntry+"/restore", map[string]any{}, 200)
|
||||
restoredPage := alice.request("GET", "/api/pages/"+page, nil, 200)
|
||||
if restoredPage["content"] != "page body" || len(restoredPage["bookmarks"].([]any)) != 1 {
|
||||
t.Fatalf("restored page: %v", restoredPage)
|
||||
}
|
||||
revs := alice.getJSON("/api/pages/"+page+"/revisions", 200)
|
||||
if len(revs) < 3 {
|
||||
t.Fatalf("page history was not restored/extended: %v", revs)
|
||||
}
|
||||
if revs[0]["version"].(float64) != restoredPage["version"].(float64) {
|
||||
t.Fatalf("restore revision is not the current version: revs=%v page=%v", revs[0], restoredPage["version"])
|
||||
}
|
||||
|
||||
// Collections restore structure and items; prior shares do not come back.
|
||||
alice.request("POST", "/api/collections/"+cid+"/share", map[string]any{"userIds": []string{bobID}}, 200)
|
||||
bob.request("GET", "/api/collections/"+cid, nil, 200)
|
||||
alice.request("DELETE", "/api/collections/"+cid, nil, 200)
|
||||
bob.request("GET", "/api/collections/"+cid, nil, 404)
|
||||
trash = alice.getJSON("/api/trash", 200)
|
||||
var collectionEntry string
|
||||
for _, row := range trash {
|
||||
if row["type"] == "collection" {
|
||||
collectionEntry = row["id"].(string)
|
||||
}
|
||||
}
|
||||
if collectionEntry == "" {
|
||||
t.Fatalf("collection missing from trash: %v", trash)
|
||||
}
|
||||
alice.request("POST", "/api/trash/"+collectionEntry+"/restore", map[string]any{}, 200)
|
||||
detail := alice.request("GET", "/api/collections/"+cid, nil, 200)
|
||||
if detail["name"] != "Restored project" || len(detail["properties"].([]any)) != 1 {
|
||||
t.Fatalf("restored collection: %v", detail)
|
||||
}
|
||||
if items := alice.getJSON("/api/collections/"+cid+"/items", 200); len(items) != 1 {
|
||||
t.Fatalf("restored collection items: %v", items)
|
||||
}
|
||||
bob.request("GET", "/api/collections/"+cid, nil, 404)
|
||||
|
||||
// Permanent purge is explicit and owner-scoped.
|
||||
alice.request("POST", "/api/trash/"+entry+"/restore", map[string]any{}, 404)
|
||||
}
|
||||
|
||||
func TestCollectionSharingPermissionBoundary(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
alice := newAPIUser(t, srv, "trust-coll-share@computer.test")
|
||||
bob := newAPIUser(t, srv, "trust-coll-recipient@computer.test")
|
||||
carol := newAPIUser(t, srv, "trust-coll-third@computer.test")
|
||||
bobID, carolID := meID(bob), meID(carol)
|
||||
|
||||
bm := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/collection-share", "title": "Shared via project", "notes": "secret owner notes", "isPublic": true}, 201)
|
||||
bid := bm["id"].(string)
|
||||
col := alice.request("POST", "/api/collections", map[string]any{"name": "Team project"}, 201)
|
||||
cid := col["id"].(string)
|
||||
prop := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Status", "type": "TEXT"}, 201)["id"].(string)
|
||||
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bid, "propertyValues": map[string]any{prop: "Doing"}}, 201)
|
||||
|
||||
bob.request("GET", "/api/collections/"+cid, nil, 404)
|
||||
bob.request("GET", "/api/bookmarks/"+bid, nil, 404)
|
||||
alice.request("POST", "/api/collections/"+cid+"/share", map[string]any{"userIds": []string{bobID}}, 200)
|
||||
|
||||
// Owner sees the disclosure.
|
||||
detail := alice.request("GET", "/api/collections/"+cid, nil, 200)
|
||||
if detail["shared"] != false || len(detail["sharedWith"].([]any)) != 1 {
|
||||
t.Fatalf("owner share disclosure: %v", detail)
|
||||
}
|
||||
// Recipient sees the collection, its items, and the narrowed bookmark
|
||||
// projection — never the owner's contact/share/recipient metadata.
|
||||
listed := bob.getJSON("/api/collections", 200)
|
||||
found := false
|
||||
for _, c := range listed {
|
||||
if c["id"] == cid {
|
||||
found = c["shared"] == true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("shared collection missing from recipient list: %v", listed)
|
||||
}
|
||||
bobDetail := bob.request("GET", "/api/collections/"+cid, nil, 200)
|
||||
if bobDetail["shared"] != true || len(bobDetail["sharedWith"].([]any)) != 0 {
|
||||
t.Fatalf("recipient detail disclosure: %v", bobDetail)
|
||||
}
|
||||
items := bob.getJSON("/api/collections/"+cid+"/items", 200)
|
||||
if len(items) != 1 || items[0]["propertyValues"].(map[string]any)[prop] != "Doing" {
|
||||
t.Fatalf("recipient items: %v", items)
|
||||
}
|
||||
sharedBookmark := bob.request("GET", "/api/bookmarks/"+bid, nil, 200)
|
||||
if sharedBookmark["title"] != "Shared via project" || sharedBookmark["notes"] != "secret owner notes" {
|
||||
t.Fatalf("recipient bookmark projection: %v", sharedBookmark)
|
||||
}
|
||||
if _, leaked := sharedBookmark["sharedWith"]; leaked {
|
||||
t.Fatalf("recipient saw the owner's recipient list: %v", sharedBookmark)
|
||||
}
|
||||
|
||||
// Read-only: mutations remain owner-only, including membership changes.
|
||||
bob.request("PATCH", "/api/collections/"+cid, map[string]any{"version": 1, "name": "hijack"}, 404)
|
||||
bob.request("DELETE", "/api/collections/"+cid, nil, 404)
|
||||
bob.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bid}, 404)
|
||||
bob.request("PATCH", "/api/collections/"+cid+"/items/"+items[0]["id"].(string), map[string]any{"version": 1, "propertyValues": map[string]any{prop: "Hijacked"}}, 404)
|
||||
bob.request("POST", "/api/collections/"+cid+"/share", map[string]any{"userIds": []string{carolID}}, 404)
|
||||
bob.request("DELETE", "/api/collections/"+cid+"/share/"+bobID, nil, 404)
|
||||
|
||||
// Revocation removes collection and bookmark access immediately.
|
||||
alice.request("DELETE", "/api/collections/"+cid+"/share/"+bobID, nil, 200)
|
||||
bob.request("GET", "/api/collections/"+cid, nil, 404)
|
||||
bob.request("GET", "/api/collections/"+cid+"/items", nil, 404)
|
||||
bob.request("GET", "/api/bookmarks/"+bid, nil, 404)
|
||||
|
||||
// Removing an item also removes the implicit bookmark grant.
|
||||
alice.request("POST", "/api/collections/"+cid+"/share", map[string]any{"userIds": []string{bobID}}, 200)
|
||||
bob.request("GET", "/api/bookmarks/"+bid, nil, 200)
|
||||
alice.request("DELETE", "/api/collections/"+cid+"/items/"+items[0]["id"].(string), nil, 200)
|
||||
bob.request("GET", "/api/bookmarks/"+bid, nil, 404)
|
||||
|
||||
// Transitive leaks are refused: Carol can see Alice's bookmark only because
|
||||
// Alice shared it with Carol. Carol's collection share must not pass that
|
||||
// grant on to Bob.
|
||||
thirdParty := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/third-party", "title": "Alice's private link"}, 201)["id"].(string)
|
||||
alice.request("POST", "/api/bookmarks/"+thirdParty+"/share", map[string]any{"userIds": []string{carolID}}, 200)
|
||||
carol.request("GET", "/api/bookmarks/"+thirdParty, nil, 200)
|
||||
carolCol := carol.request("POST", "/api/collections", map[string]any{"name": "Carol's project"}, 201)["id"].(string)
|
||||
carol.request("POST", "/api/collections/"+carolCol+"/items", map[string]any{"bookmarkId": thirdParty}, 201)
|
||||
carol.request("POST", "/api/collections/"+carolCol+"/share", map[string]any{"userIds": []string{bobID}}, 200)
|
||||
bob.request("GET", "/api/collections/"+carolCol, nil, 200)
|
||||
if leakedItems := bob.getJSON("/api/collections/"+carolCol+"/items", 200); len(leakedItems) != 0 {
|
||||
t.Fatalf("collection share leaked a third-party bookmark: %v", leakedItems)
|
||||
}
|
||||
bob.request("GET", "/api/bookmarks/"+thirdParty, nil, 404)
|
||||
}
|
||||
|
||||
func TestStructuredExport(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
alice := newAPIUser(t, srv, "trust-export@computer.test")
|
||||
|
||||
tag := alice.request("POST", "/api/tags", map[string]any{"name": "Export tag"}, 201)["id"].(string)
|
||||
bm := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/export", "title": "Exported bookmark", "tagIds": []string{tag}}, 201)["id"].(string)
|
||||
page := alice.request("POST", "/api/pages", map[string]any{"title": "Exported page", "content": "exported body"}, 201)["id"].(string)
|
||||
col := alice.request("POST", "/api/collections", map[string]any{"name": "Exported collection"}, 201)["id"].(string)
|
||||
alice.request("POST", "/api/collections/"+col+"/items", map[string]any{"bookmarkId": bm}, 201)
|
||||
|
||||
v := alice.request("GET", "/api/export", nil, 200)
|
||||
if v["format"] != "computer-export" || v["version"].(float64) != 1 {
|
||||
t.Fatalf("export envelope: %v", v)
|
||||
}
|
||||
if len(v["bookmarks"].([]any)) != 1 || len(v["pages"].([]any)) != 1 || len(v["tags"].([]any)) != 1 || len(v["collections"].([]any)) != 1 {
|
||||
t.Fatalf("export sections: %v", v)
|
||||
}
|
||||
exPage := v["pages"].([]any)[0].(map[string]any)
|
||||
if exPage["content"] != "exported body" || len(exPage["revisions"].([]any)) == 0 {
|
||||
t.Fatalf("export page content/history: %v", exPage)
|
||||
}
|
||||
exCol := v["collections"].([]any)[0].(map[string]any)
|
||||
if len(exCol["items"].([]any)) != 1 {
|
||||
t.Fatalf("export collection items: %v", exCol)
|
||||
}
|
||||
if _, ok := v["user"].(map[string]any)["id"]; !ok {
|
||||
t.Fatalf("export user identity: %v", v["user"])
|
||||
}
|
||||
if page == "" {
|
||||
t.Fatal("page id missing")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user