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
+83
View File
@@ -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)
}