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