diff --git a/API.md b/API.md index 2ad89f4..82fbb0b 100644 --- a/API.md +++ b/API.md @@ -116,8 +116,11 @@ test-only). CardDAV keeps the configured HTTPS port. | PUT | `/api/pages` | `{ok:true}`; complete `{order:[id,...]}` | | GET | `/api/pages/{id}` | Page plus ordered `bookmarks:[{bookmark}]`, owner-scoped `backlinks:[{id,title,updatedAt}]`, resolved outgoing `links:[{id,title}]`, and `embeds:[{collectionId,viewId,position,collection,view}]` | | PATCH | `/api/pages/{id}` | Updated page; `{title?,content?,pinned?,icon?,parentId?,version?,addBookmarkId?,removeBookmarkId?}`. `version` (the base revision from GET) is REQUIRED with `content`: 400 when missing, 409 when stale. Metadata-only patches (including moving a page) stay version-free and do not bump the revision. Moving under itself or a descendant is rejected 400 (no cycles) | -| DELETE | `/api/pages/{id}` | `{ok:true}`; keeps bookmarks and promotes child pages to the top level | | GET | `/api/pages/{id}/backlinks` | Owner-scoped `[{id,title,updatedAt}]` from `PageLink` — the pages whose Markdown links here | +| GET | `/api/pages/{id}/revisions` | Version history metadata `[{id,version,title,icon,createdAt,length}]`, newest first; bodies fetched separately | +| GET | `/api/pages/{id}/revisions/{revisionId}` | Full immutable revision snapshot `{...,content}` | +| POST | `/api/pages/{id}/revisions/{revisionId}/restore` | Restores a snapshot as a **new** version; body `{version}` (current base) — 400 missing, 409 stale. The restore itself is recorded in history | +| DELETE | `/api/pages/{id}` | Moves the page to Trash; keeps bookmarks and promotes child pages. Restoring also restores its revisions and creates a new revision | | GET | `/api/pages/{id}/markdown` | Markdown attachment | ### Connected pages (phase 3) @@ -139,15 +142,39 @@ in the same transaction as every content save: its bookmarks. Deleting a collection removes its embeds; deleting a view falls the embed back to the collection's default view. +### Trust (phase 4) + +Deleting a bookmark, page or collection now moves it to the owner's Trash. +The snapshot is taken in the same transaction as the hard delete and includes +intrinsic content plus owner-scoped links (tags, page links, collection +memberships, page revisions). It deliberately excludes access grants: +bookmark public flags, bookmark/tag shares and collection shares are never +restored, so revoking access while an item is in the Trash cannot be undone by +restoring it. Deleted bookmarks keep their existing delete endpoints; the UI +and docs now call this “move to trash”. + +| Method | Path | Returns / behavior | +|---|---|---| +| GET | `/api/trash` | Owner's deleted items `[{id,type,originalId,title,deletedAt}]`, newest first | +| POST | `/api/trash/{id}/restore` | Restores the item; 404 for another owner. Page restores recreate their history and append a new revision. Neither bookmark `isPublic` nor any share is restored | +| DELETE | `/api/trash/{id}` | Permanently removes one Trash entry (explicit purge) | +| GET | `/api/export` | Owner-scoped structured JSON archive: user, bookmarks, tags, pages (content + revisions), collections (properties, views, items, shares) and contacts. Credentials are never exported | + ## Collections Contextual workflows over existing bookmarks (phase 2 of the Notion-like -upgrades). Collections are owner-scoped; membership references bookmarks -without owning them — removing an item or a whole collection never touches -the bookmark. Membership never broadens access: items whose bookmark the -caller cannot see are omitted from lists/counts and behave as not-found for -mutations; re-sharing restores the membership. Every mutating PATCH requires -the expected `version` (400 missing, 409 stale). Indirect mutations advance +upgrades). Collections are owner-scoped unless explicitly shared; membership +references bookmarks without owning them — removing an item or a whole +collection never touches the bookmark. An explicit `CollectionShare` gives a +recipient read-only access to the collection and the **narrowed** bookmark +projection for member bookmarks **owned by the collection owner**. A bookmark +that the collection owner can only see through someone else's share is +omitted, so collection sharing never passes a third-party grant onwards. +Revoking the share or removing the item removes that access immediately, and +mutations remain owner-only. Items whose +bookmark the caller cannot see are omitted from lists/counts and behave as +not-found for mutations; re-sharing restores the membership. Every mutating +PATCH requires the expected `version` (400 missing, 409 stale). Indirect mutations advance revisions too (review CR-09): item reorder bumps every affected item's version, and property deletion bumps the versions of items whose value it dropped and views whose filter/sort definition it changed. Deletions are @@ -156,11 +183,13 @@ from a client snapshot. | Method | Path | Returns / behavior | |---|---|---| -| GET | `/api/collections` | Owned collections with `_count.{items,properties,views}`; item counts include only bookmarks the caller can see | +| GET | `/api/collections` | Owned **and explicitly shared** collections with `shared`, `owner`, `_count.{items,properties,views}`; item counts include only bookmarks the caller can see | | POST | `/api/collections` | 201 detail `{...collection,properties:[],views:[default Table view]}`; body `{name,description?,icon?}` | -| GET | `/api/collections/{id}` | Collection plus ordered `properties`, `views` and authorized `_count.{items,properties,views}` | -| PATCH | `/api/collections/{id}` | Updated collection; `{version,name?,description?,icon?}`, bumps `version` | -| DELETE | `/api/collections/{id}` | `{ok:true}`; memberships/properties/views cascade, bookmarks kept | +| GET | `/api/collections/{id}` | Readable collection detail (owner or share recipient) plus ordered `properties`, `views`, authorized `_count`, `shared`; owner also gets `sharedWith` | +| PATCH | `/api/collections/{id}` | Updated collection; `{version,name?,description?,icon?}`, bumps `version`; owner only | +| DELETE | `/api/collections/{id}` | Moves the collection to Trash; memberships/properties/views cascade, bookmarks kept; owner only | +| POST | `/api/collections/{id}/share` | Owner only; `{userIds:[...]}` grants read-only access, idempotent | +| DELETE | `/api/collections/{id}/share/{userId}` | Owner only; revokes read-only access immediately | | POST | `/api/collections/{id}/properties` | 201 property; `{name,type,config?}`. Types: `TEXT,NUMBER,SELECT,MULTI_SELECT,DATE,CHECKBOX,URL` (`RELATION` reserved, rejected). SELECT config `{options:[{label,color?}]}` — the server assigns stable option ids; DATE config `{mode:"date"\|"datetime"}`. Duplicate name → 409 | | PATCH | `/api/collections/{id}/properties/{propertyId}` | Updated property; `{version,name?,position?,config?}`. Type and date-mode changes are rejected (400) — conversion must never destroy values. Option updates send the full list: existing ids rename/recolor (identity preserved), omitted ids are removed but refused (400 with the using-item count) while any item still carries them | | DELETE | `/api/collections/{id}/properties/{propertyId}` | `{ok:true}`; strips the property's values from all items and its clauses/sorts/grouping from saved views | diff --git a/PLAN.md b/PLAN.md index 28cf99f..e924bf8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -80,9 +80,6 @@ keeping its own passwords.* **Product features** -- Collections can't be shared — they're private to their owner. -- No page history (can't see or restore older versions). -- No trash — deletes are permanent. - Calendar and list views for collections are stored but still display as tables. - Alerts only show inside the app — nothing is emailed or pushed to you. @@ -168,15 +165,18 @@ maintenance rather than a separate milestone. ### 3–6 months: grow the product -- **Page history and trash** — see old versions, restore them; deleted - things are recoverable. +- **Page history and trash** — done: versioned history with restore, and + recoverable bookmark/page/collection deletes. Restores never resurrect a + revoked share or public link. - **Nested pages and backlinks** — done: a real page tree, "what links here" that never shows titles you're not allowed to see, and live collection views embedded in pages. -- **Collection sharing** — the big one. Explicit, shown in the UI, and - revocable. Design the permission rules first (a project must never - accidentally widen who can see a bookmark), then build it. Once it - exists, cross-project "relation" columns can follow. +- **Collection sharing** — done: explicit, disclosed, revocable read-only + shares; recipients see the narrowed bookmark view, and removing an item or + revoking access removes it immediately. Cross-project "relation" columns + can follow. +- **Structured export** — done: an owner-scoped JSON archive richer than the + per-item Markdown downloads. - **Alerts that reach you** — email or webhook when a due date hits or an email watcher matches. Turns the app from a dashboard into a notifier. - **RSS watchers** — "tell me when this feed mentions X." Reuses everything diff --git a/db/migrations/009_trust.sql b/db/migrations/009_trust.sql new file mode 100644 index 0000000..a608007 --- /dev/null +++ b/db/migrations/009_trust.sql @@ -0,0 +1,48 @@ +-- 009_trust.sql — Notion-like upgrades phase 4 (trust). +-- +-- Server-side page revision history, recoverable deletions, and explicit +-- collection sharing. +-- +-- Trust rules: +-- * Every committed page content version is snapshotted. Restoring a +-- revision writes a NEW version (history is append-only) and the restore +-- itself is recorded. +-- * Trash is a per-owner snapshot taken in the same transaction as the +-- delete. It restores intrinsic data and owner-scoped relationships, but +-- never access grants (bookmark public flags, bookmark/tag shares, +-- collection shares). A granted access revoked while an item was in the +-- trash cannot come back with the item. +-- * Collection sharing is read-only and revocable. Recipients see member +-- bookmarks through the narrowed shared projection; mutations stay +-- owner-only. BookmarkVisible gains the explicit collection-share edge. + +CREATE TABLE IF NOT EXISTS "PageRevision" ( + id text PRIMARY KEY, + "pageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE, + version integer NOT NULL, + title text NOT NULL, + content text NOT NULL, + icon text, + "createdAt" timestamptz NOT NULL DEFAULT now(), + UNIQUE ("pageId", version) +); +CREATE INDEX IF NOT EXISTS "PageRevision_pageId_version_idx" ON "PageRevision"("pageId", version DESC); + +CREATE TABLE IF NOT EXISTS "Trash" ( + id text PRIMARY KEY, + "ownerId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, + type text NOT NULL CHECK (type IN ('bookmark','page','collection')), + "originalId" text NOT NULL, + data jsonb NOT NULL, + "deletedAt" timestamptz NOT NULL DEFAULT now(), + UNIQUE (type, "originalId") +); +CREATE INDEX IF NOT EXISTS "Trash_ownerId_deletedAt_idx" ON "Trash"("ownerId", "deletedAt" DESC); + +CREATE TABLE IF NOT EXISTS "CollectionShare" ( + "collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE, + "userId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, + "createdAt" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("collectionId", "userId") +); +CREATE INDEX IF NOT EXISTS "CollectionShare_userId_idx" ON "CollectionShare"("userId"); diff --git a/db/schema.sql b/db/schema.sql index 45da369..ba6e325 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -236,6 +236,22 @@ CREATE TABLE "PageLink" ( ); CREATE INDEX "PageLink_targetPageId_idx" ON "PageLink"("targetPageId"); +-- 2.13c PageRevision: server-side content history. A snapshot is recorded for +-- every committed content version (including restore, which is a new version) +-- so an old draft can be inspected and restored without ever rewriting the +-- past. Revision rows cascade with their page. +CREATE TABLE "PageRevision" ( + id text PRIMARY KEY, + "pageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE, + version integer NOT NULL, + title text NOT NULL, + content text NOT NULL, + icon text, + "createdAt" timestamptz NOT NULL DEFAULT now(), + UNIQUE ("pageId", version) +); +CREATE INDEX "PageRevision_pageId_version_idx" ON "PageRevision"("pageId", version DESC); + -- 2.14 SignalSource CREATE TABLE "SignalSource" ( id text PRIMARY KEY, @@ -294,6 +310,22 @@ CREATE TABLE "AppSetting" ( "updatedAt" timestamptz NOT NULL DEFAULT now() ); +-- 2.16b Trash: recoverable deletions (trust phase). The row is a private +-- per-owner snapshot taken in the same transaction as the hard delete. It +-- keeps intrinsic data and owner-scoped relationships, but deliberately not +-- access grants (bookmark shares, public flags, collection shares): restoring +-- an item never resurrects a grant that was revoked while it sat in the bin. +CREATE TABLE "Trash" ( + id text PRIMARY KEY, + "ownerId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, + type text NOT NULL CHECK (type IN ('bookmark','page','collection')), + "originalId" text NOT NULL, + data jsonb NOT NULL, + "deletedAt" timestamptz NOT NULL DEFAULT now(), + UNIQUE (type, "originalId") +); +CREATE INDEX "Trash_ownerId_deletedAt_idx" ON "Trash"("ownerId", "deletedAt" DESC); + -- 2.16 SharedDate CREATE TABLE "SharedDate" ( id text PRIMARY KEY, @@ -325,6 +357,19 @@ CREATE TABLE "Collection" ( ); CREATE INDEX "Collection_ownerId_idx" ON "Collection"("ownerId"); +-- 2.17b CollectionShare: explicit, revocable read access to a collection. +-- Recipients get the narrowed bookmark projection for member bookmarks (via +-- BookmarkVisible); sharing therefore widens access only by the owner's +-- deliberate action, is disclosed in the share UI, and revocation removes +-- access immediately. Mutations remain owner-only. +CREATE TABLE "CollectionShare" ( + "collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE, + "userId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE, + "createdAt" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("collectionId", "userId") +); +CREATE INDEX "CollectionShare_userId_idx" ON "CollectionShare"("userId"); + CREATE TABLE "CollectionProperty" ( id text PRIMARY KEY, "collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE, diff --git a/internal/db/bookmarks.go b/internal/db/bookmarks.go index 2782585..a19bbfe 100644 --- a/internal/db/bookmarks.go +++ b/internal/db/bookmarks.go @@ -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))` diff --git a/internal/handlers/bookmarks.go b/internal/handlers/bookmarks.go index e932e95..ff8ec90 100644 --- a/internal/handlers/bookmarks.go +++ b/internal/handlers/bookmarks.go @@ -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) } diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index 54aae0d..9340075 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -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) diff --git a/internal/handlers/export.go b/internal/handlers/export.go new file mode 100644 index 0000000..3f46fa6 --- /dev/null +++ b/internal/handlers/export.go @@ -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) +} diff --git a/internal/handlers/page_revisions.go b/internal/handlers/page_revisions.go new file mode 100644 index 0000000..ac23300 --- /dev/null +++ b/internal/handlers/page_revisions.go @@ -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) +} diff --git a/internal/handlers/pages.go b/internal/handlers/pages.go index 776a4ae..290e16b 100644 --- a/internal/handlers/pages.go +++ b/internal/handlers/pages.go @@ -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")) diff --git a/internal/handlers/trash.go b/internal/handlers/trash.go new file mode 100644 index 0000000..911e3c3 --- /dev/null +++ b/internal/handlers/trash.go @@ -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) +} diff --git a/internal/handlers/unshare.go b/internal/handlers/unshare.go index df0c73e..3c37a3e 100644 --- a/internal/handlers/unshare.go +++ b/internal/handlers/unshare.go @@ -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")) } diff --git a/internal/http/server.go b/internal/http/server.go index e524b71..d42cc3f 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -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)) diff --git a/internal/http/trust_test.go b/internal/http/trust_test.go new file mode 100644 index 0000000..1dca6e7 --- /dev/null +++ b/internal/http/trust_test.go @@ -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") + } +} diff --git a/web/app/(app)/bookmarks/client.tsx b/web/app/(app)/bookmarks/client.tsx index 94fdffb..7172543 100644 --- a/web/app/(app)/bookmarks/client.tsx +++ b/web/app/(app)/bookmarks/client.tsx @@ -176,7 +176,7 @@ export function BookmarksClient({ initialSearch = null, initialOpen = null }: {
e.stopPropagation()}> {tab === 'mine' && ( - + )}
diff --git a/web/app/(app)/collections/client.tsx b/web/app/(app)/collections/client.tsx index 152ac03..f97ca92 100644 --- a/web/app/(app)/collections/client.tsx +++ b/web/app/(app)/collections/client.tsx @@ -11,7 +11,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' -import { CalendarDays, Columns3, Filter, GripVertical, Kanban, Layers, List, Plus, Table2, Trash2, X } from 'lucide-react' +import { CalendarDays, Columns3, Filter, GripVertical, Kanban, Layers, List, Plus, Share2, Table2, Trash2, X } from 'lucide-react' import { toast } from 'sonner' import type { Bookmark } from '@/components/app-shell' import { cn } from '@/lib/utils' @@ -40,9 +40,13 @@ type CollectionItem = { propertyValues: Record; position: number; version: number addedAt: string; updatedAt: string; bookmark: Bookmark } +type CollectionShare = { userId: string; name: string | null; email: string | null; createdAt?: string } type CollectionSummary = { id: string; name: string; description: string | null; icon: string | null version: number; createdAt: string; updatedAt: string + shared: boolean + owner?: { id: string; name: string | null; email: string | null; image: string | null } + sharedWith?: CollectionShare[] _count: { items: number; properties: number; views: number } } type CollectionDetail = CollectionSummary & { properties: CollectionProperty[]; views: CollectionView[] } @@ -95,10 +99,27 @@ function BookmarkCell({ bookmark }: { bookmark: Bookmark }) { const asText = (v: unknown) => (typeof v === 'string' || typeof v === 'number' ? String(v) : '') +function plainValue(prop: CollectionProperty, value: unknown): string { + if (prop.type === 'CHECKBOX') return value === true ? 'Yes' : 'No' + if (prop.type === 'SELECT') return prop.config.options?.find((o) => o.id === value)?.label ?? '—' + if (prop.type === 'MULTI_SELECT') { + if (!Array.isArray(value)) return '—' + return value.map((id) => prop.config.options?.find((o) => o.id === id)?.label).filter(Boolean).join(', ') || '—' + } + if (prop.type === 'DATE' && typeof value === 'string' && value) { + if (prop.config.mode === 'date') return value.slice(0, 10) + const date = new Date(value) + return Number.isNaN(date.getTime()) ? value : date.toLocaleString() + } + if (value === null || value === undefined || value === '') return '—' + return String(value) +} + // Per-type inline value editor. Saves immediately on commit gestures // (change/blur/Enter); failures toast and the prop value re-renders the -// server state, so a rejected edit never sticks locally. -function ValueEditor({ prop, value, onSave }: { prop: CollectionProperty; value: unknown; onSave: (v: unknown) => Promise }) { +// server state, so a rejected edit never sticks locally. Shared collections +// are read-only: the same cell renders plain text. +function ValueEditor({ prop, value, onSave, readOnly = false }: { prop: CollectionProperty; value: unknown; onSave: (v: unknown) => Promise; readOnly?: boolean }) { const [text, setText] = useState(() => asText(value)) const [seen, setSeen] = useState(value) // Adjusting state when a prop changes: the React-documented render-time @@ -108,6 +129,7 @@ function ValueEditor({ prop, value, onSave }: { prop: CollectionProperty; value: setText(asText(value)) } const [open, setOpen] = useState(false) + if (readOnly) return {plainValue(prop, value)} const commitText = async () => { const next = text.trim() const current = asText(value) @@ -220,6 +242,9 @@ export function CollectionsClient() { const [fProp, setFProp] = useState('') const [fOp, setFOp] = useState('eq') const [fValue, setFValue] = useState('') + const [shareOpen, setShareOpen] = useState(false) + const [shareQuery, setShareQuery] = useState('') + const [shareResults, setShareResults] = useState<{ id: string; name: string | null; email: string }[]>([]) const loadCollections = useCallback(async () => { try { @@ -293,6 +318,41 @@ export function CollectionsClient() { const activeView = detail?.views.find((v) => v.id === viewId) ?? null const groupProp = activeView?.groupByPropertyId ? detail?.properties.find((p) => p.id === activeView.groupByPropertyId) : null + // Shared collections are explicitly read-only: hiding mutation controls is + // the UI half of the server's owner-only mutation gate. + const readOnly = detail?.shared === true + + const shareCollection = async (userId: string) => { + if (!detail) return + const res = await fetch(`/api/collections/${detail.id}/share`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userIds: [userId] }), + }) + if (!res.ok) { toast.error('Could not share collection'); return } + toast.success('Collection shared') + setShareQuery(''); setShareResults([]) + loadDetail(detail.id); loadCollections() + } + const unshareCollection = async (userId: string) => { + if (!detail) return + const res = await fetch(`/api/collections/${detail.id}/share/${userId}`, { method: 'DELETE' }) + if (!res.ok) { toast.error('Could not revoke collection access'); return } + toast.success('Access revoked') + loadDetail(detail.id); loadCollections() + } + + useEffect(() => { + if (!shareOpen) return + const q = shareQuery.trim() + if (q.length < 2) return + const t = setTimeout(async () => { + try { + const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`) + if (res.ok) setShareResults(await res.json()) + } catch { /* transient */ } + }, 250) + return () => clearTimeout(t) + }, [shareOpen, shareQuery]) const createCollection = async () => { const name = newName.trim() @@ -307,7 +367,7 @@ export function CollectionsClient() { } const saveName = async () => { - if (!detail) return + if (!detail || detail.shared) return const name = nameDraft.trim() if (!name || name === detail.name) { setNameDraft(detail.name); return } const res = await patch(`/api/collections/${detail.id}`, { version: detail.version, name }) @@ -318,7 +378,7 @@ export function CollectionsClient() { } const deleteCollection = async () => { - if (!detail || !confirm(`Delete collection "${detail.name}"? Bookmarks are kept.`)) return + if (!detail || detail.shared || !confirm(`Move collection "${detail.name}" to the trash? Bookmarks are kept and it can be restored.`)) return const res = await fetch(`/api/collections/${detail.id}`, { method: 'DELETE' }) if (await mutate(res, loadCollections, 'Collection deleted')) { setActiveId(null) @@ -494,9 +554,9 @@ export function CollectionsClient() { {p.name} - + } ))} @@ -508,12 +568,12 @@ export function CollectionsClient() { {(detail?.properties ?? []).map((p) => ( - saveValue(item, p.id, v)} /> + saveValue(item, p.id, v)} readOnly={readOnly} /> ))} - + } ))} @@ -535,7 +595,7 @@ export function CollectionsClient() {

Board views group by a select property.

-
+ {!readOnly &&
-
+
}
) } @@ -602,7 +662,7 @@ export function CollectionsClient() { )} >
- { @@ -619,25 +679,29 @@ export function CollectionsClient() { aria-label={`Drag ${item.bookmark.title ?? 'bookmark'}`} > - + }
- - + }
))} @@ -674,7 +738,10 @@ export function CollectionsClient() { className={cn('flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors', c.id === activeId ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground')} > - {c.name} + + {c.name} + {c.shared && shared} + {c._count.items} ))} @@ -699,13 +766,25 @@ export function CollectionsClient() { ) : ( <>
- setNameDraft(e.target.value)} onBlur={saveName} onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} aria-label="Collection name" className="w-72 bg-transparent font-display text-2xl font-bold tracking-tight" /> + {readOnly ? ( + {detail.name} + ) : ( + setNameDraft(e.target.value)} onBlur={saveName} onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} aria-label="Collection name" className="w-72 bg-transparent font-display text-2xl font-bold tracking-tight" /> + )} {detail._count?.items ?? 0} item{(detail._count?.items ?? 0) === 1 ? '' : 's'} -
- - - -
+ {readOnly ? ( + + + Shared by {detail.owner?.name || detail.owner?.email || 'another user'} · read-only + + ) : ( +
+ + + + +
+ )}
{/* View tabs */} @@ -722,8 +801,8 @@ export function CollectionsClient() { ) })} - - {activeView && ( + {!readOnly && } + {activeView && !readOnly && (
{ setFilterOpen(o); if (o) { setFProp(detail.properties[0]?.id ?? ''); setFOp(FILTER_OPS[detail.properties[0]?.type ?? 'TEXT']?.[0] ?? 'eq') } }}> @@ -774,7 +853,7 @@ export function CollectionsClient() { {filterClauses.map((c, i) => ( {clauseLabel(c)} - + {!readOnly && } ))}
@@ -878,6 +957,48 @@ export function CollectionsClient() { + + {/* Share dialog */} + { setShareOpen(o); if (!o) { setShareQuery(''); setShareResults([]) } }}> + + Share collection +

+ Recipients get read-only access to this collection and the links you own inside it. Links you can + only see through someone else's share are omitted, so sharing never passes a third-party grant on. + They receive the same trimmed link view as other shared bookmarks; contacts, alerts and mailbox + settings stay private. Revoking access takes effect immediately. +

+ {detail && ( +
+ {(detail.sharedWith ?? []).map((s) => ( +
+ {s.name || s.email || s.userId} + +
+ ))} + {(detail.sharedWith?.length ?? 0) === 0 &&

Not shared with anyone yet.

} +
+ )} + setShareQuery(e.target.value)} placeholder="Search people by name or email" aria-label="Search people to share with" /> +
+ {shareResults.map((u) => { + const already = detail?.sharedWith?.some((s) => s.userId === u.id) + return ( +
+
+

{u.name || u.email}

+ {u.name &&

{u.email}

} +
+ +
+ ) + })} + {shareQuery.trim().length >= 2 && shareResults.length === 0 &&

No matching people

} +
+
+
) } diff --git a/web/app/(app)/pages/client.tsx b/web/app/(app)/pages/client.tsx index 49a9769..6726143 100644 --- a/web/app/(app)/pages/client.tsx +++ b/web/app/(app)/pages/client.tsx @@ -8,12 +8,13 @@ import remarkGfm from 'remark-gfm' import { toast } from 'sonner' import { ChevronLeft, ChevronRight, Code, CornerUpLeft, Download, Eye, FileText, GripVertical, - Heading1, Heading2, Heading3, Layers, Link2, List, ListOrdered, ListTodo, Minus, Pencil, Pin, PinOff, - Plus, Quote, Search, Trash2, Type, X, + Heading1, Heading2, Heading3, History, Layers, Link2, List, ListOrdered, ListTodo, Minus, Pencil, + Pin, PinOff, Plus, Quote, RotateCcw, Search, Trash2, Type, X, } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/utils' import { PageAutosave, PageConflictError, type PageSaveState } from '@/lib/page-autosave' @@ -65,6 +66,8 @@ type PageDetail = PageSummary & { links: { id: string; title: string }[] embeds: PageEmbed[] } +type PageRevisionSummary = { id: string; version: number; title: string; createdAt: string; length: number } +type PageRevisionDetail = PageRevisionSummary & { content: string; icon: string | null } const notify = notifyPagesChanged const host = (u: string) => { try { return new URL(u).hostname.replace(/^www\./, '') } catch { return u } } @@ -192,6 +195,10 @@ export function PagesClient({ initialId }: { initialId: string | null }) { const [pageLinkQuery, setPageLinkQuery] = useState('') const [embedOpen, setEmbedOpen] = useState(false) const [collections, setCollections] = useState<{ id: string; name: string; icon: string | null; views?: { id: string; name: string }[] }[]>([]) + const [historyOpen, setHistoryOpen] = useState(false) + const [revisions, setRevisions] = useState([]) + const [revision, setRevision] = useState(null) + const [historyBusy, setHistoryBusy] = useState(null) const [slash, setSlash] = useState<{ query: string; start: number } | null>(null) const [slashIndex, setSlashIndex] = useState(0) const [tabDrop, setTabDrop] = useState<{ id: string; after: boolean } | null>(null) @@ -388,6 +395,60 @@ export function PagesClient({ initialId }: { initialId: string | null }) { } } + const openHistory = async () => { + if (!page) return + setHistoryOpen(true) + setRevision(null) + try { + const res = await fetch(`/api/pages/${page.id}/revisions`, { cache: 'no-store' }) + setRevisions(res.ok ? await res.json() : []) + if (!res.ok) toast.error('Could not load page history') + } catch { toast.error('Could not load page history') } + } + const viewRevision = async (id: string) => { + if (!page) return + setHistoryBusy(id) + try { + const res = await fetch(`/api/pages/${page.id}/revisions/${id}`, { cache: 'no-store' }) + if (res.ok) setRevision(await res.json()) + else toast.error('Could not load this version') + } catch { toast.error('Could not load this version') } + finally { setHistoryBusy(null) } + } + const restoreRevision = async (rev: PageRevisionSummary) => { + if (!page) return + if (!confirm(`Restore version ${rev.version}? The current content is kept in history.`)) return + setHistoryBusy(rev.id) + try { + if (!await autosave.flush(page.id)) { toast.error('Resolve the unsaved draft before restoring history'); return } + const latest = await fetch(`/api/pages/${page.id}`, { cache: 'no-store' }) + if (!latest.ok) { toast.error('Could not reload the page'); return } + const current: PageDetail = await latest.json() + const res = await fetch(`/api/pages/${page.id}/revisions/${rev.id}/restore`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ version: current.version }), + }) + if (res.status === 409) { + toast.error('The page changed elsewhere. Reload it before restoring history.') + await loadPage(page.id) + return + } + if (!res.ok) { toast.error((await res.json().catch(() => null))?.error ?? 'Could not restore this version'); return } + const restored: PageDetail = await res.json() + autosave.discard(page.id) + autosave.setVersion(page.id, restored.version) + setPage(restored) + setTitle(restored.title) + setContent(restored.content) + setMode('preview') + notifyActivePage(restored.title) + setHistoryOpen(false) + toast.success(`Restored version ${rev.version} as version ${restored.version}`) + await loadPages(); notify() + } catch { toast.error('Could not restore this version') } + finally { setHistoryBusy(null) } + } + // Show the block menu only while the caret sits on a bare "/token" line. const updateSlash = (text: string, caret: number) => { const lineStart = text.lastIndexOf('\n', caret - 1) + 1 @@ -504,7 +565,7 @@ export function PagesClient({ initialId }: { initialId: string | null }) { } const remove = async () => { - if (!page || !confirm(`Delete page "${page.title}"? Bookmarks are kept.`)) return + if (!page || !confirm(`Move page "${page.title}" to the trash? Bookmarks are kept and it can be restored.`)) return if (!await autosave.flush(page.id)) return const res = await fetch(`/api/pages/${page.id}`, { method: 'DELETE' }) if (!res.ok) { toast.error('Could not delete page'); return } @@ -694,7 +755,8 @@ export function PagesClient({ initialId }: { initialId: string | null }) { - + +
@@ -934,6 +996,49 @@ export function PagesClient({ initialId }: { initialId: string | null }) { )}
)} + + { setHistoryOpen(open); if (!open) setRevision(null) }}> + + Version history +

+ Every saved content version is kept. Restoring writes a new version, so nothing in history is ever rewritten. +

+
+
+ {revisions.map((rev) => ( +
+ + +
+ ))} + {revisions.length === 0 &&

No saved versions yet.

} +
+
+ {revision ? ( + <> +

{revision.title}

+
{revision.content || '(empty page)'}
+ + ) : ( +

Select a version to preview it.

+ )} +
+
+
+
) } diff --git a/web/app/(app)/trash/client.tsx b/web/app/(app)/trash/client.tsx new file mode 100644 index 0000000..4bb8fe0 --- /dev/null +++ b/web/app/(app)/trash/client.tsx @@ -0,0 +1,120 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { Download, FileText, Layers, Link2, RotateCcw, Trash2 } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { notifyPagesChanged } from '@/lib/pages' + +type TrashEntry = { id: string; type: 'bookmark' | 'page' | 'collection'; originalId: string; title: string; deletedAt: string } + +const TYPE_ICON = { bookmark: Link2, page: FileText, collection: Layers } as const + +export function TrashClient() { + const [entries, setEntries] = useState([]) + const [busy, setBusy] = useState(null) + const [exporting, setExporting] = useState(false) + + const load = useCallback(async () => { + try { + const res = await fetch('/api/trash', { cache: 'no-store' }) + if (!res.ok) { toast.error('Could not load the trash'); return } + setEntries(await res.json()) + } catch { toast.error('Could not load the trash') } + }, []) + + useEffect(() => { + const timer = setTimeout(() => { void load() }, 0) + return () => clearTimeout(timer) + }, [load]) + + const restore = async (entry: TrashEntry) => { + setBusy(entry.id) + try { + const res = await fetch(`/api/trash/${entry.id}/restore`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }) + if (!res.ok) { toast.error((await res.json().catch(() => null))?.error ?? 'Could not restore'); return } + toast.success(`Restored “${entry.title}”`) + if (entry.type === 'page') notifyPagesChanged() + await load() + } catch { toast.error('Could not restore') } + finally { setBusy(null) } + } + + const purge = async (entry: TrashEntry) => { + if (!confirm(`Delete “${entry.title}” permanently? This cannot be undone.`)) return + setBusy(entry.id) + try { + const res = await fetch(`/api/trash/${entry.id}`, { method: 'DELETE' }) + if (!res.ok) { toast.error('Could not delete permanently'); return } + toast.success('Deleted permanently') + await load() + } catch { toast.error('Could not delete permanently') } + finally { setBusy(null) } + } + + const exportData = async () => { + setExporting(true) + try { + const res = await fetch('/api/export') + if (!res.ok) { toast.error('Could not export your data'); return } + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `computer-export-${new Date().toISOString().slice(0, 10)}.json` + document.body.appendChild(a); a.click(); a.remove() + URL.revokeObjectURL(url) + toast.success('Export downloaded') + } catch { toast.error('Could not export your data') } + finally { setExporting(false) } + } + + return ( +
+
+
+

Trash

+

+ Deleted bookmarks, pages and collections stay recoverable here. Restoring brings back content and + owner-scoped links, but never a share or public link that was revoked in the meantime. +

+
+ +
+ + {entries.length === 0 ? ( +
+ +

The trash is empty.

+
+ ) : ( +
+ {entries.map((entry) => { + const Icon = TYPE_ICON[entry.type] + return ( +
+ +
+

{entry.title}

+

+ {new Date(entry.deletedAt).toLocaleString()} +

+
+ {entry.type} + + +
+ ) + })} +
+ )} +
+ ) +} diff --git a/web/app/(app)/trash/page.tsx b/web/app/(app)/trash/page.tsx new file mode 100644 index 0000000..8fd7f80 --- /dev/null +++ b/web/app/(app)/trash/page.tsx @@ -0,0 +1,7 @@ +import { TrashClient } from './client' + +export const dynamic = 'force-dynamic' + +export default function TrashPage() { + return +} diff --git a/web/components/bookmark-detail-sheet.tsx b/web/components/bookmark-detail-sheet.tsx index 276f0c9..17585be 100644 --- a/web/components/bookmark-detail-sheet.tsx +++ b/web/components/bookmark-detail-sheet.tsx @@ -141,10 +141,10 @@ export function BookmarkDetailSheet({ bookmark, onClose, onUpdate }: Props) { }, [bookmark, notes, title, url, description, tagIds, columnId, contactId, kanban, dueDate, alertAt, isPublic, imapEnabled, imapQuery, onUpdate]) const handleDelete = useCallback(async () => { - if (!bookmark || !confirm('Delete this bookmark?')) return + if (!bookmark || !confirm('Move this bookmark to the trash? It can be restored.')) return const response = await fetch(`/api/bookmarks/${bookmark.id}`, { method: 'DELETE' }) - if (!response.ok) { toast.error('Could not delete bookmark'); return } - toast.success('Bookmark deleted') + if (!response.ok) { toast.error('Could not move bookmark to trash'); return } + toast.success('Bookmark moved to trash') onClose() onUpdate() }, [bookmark, onClose, onUpdate]) diff --git a/web/components/sidebar.tsx b/web/components/sidebar.tsx index 13c7f7c..da5062a 100644 --- a/web/components/sidebar.tsx +++ b/web/components/sidebar.tsx @@ -26,6 +26,7 @@ const viewItems = [ { href: '/kanban', label: 'Kanban', icon: Kanban }, { href: '/collections', label: 'Collections', icon: Layers }, { href: '/contacts', label: 'Contacts', icon: Users }, + { href: '/trash', label: 'Trash', icon: Trash2 }, ] // Section collapse state persists per browser. useSyncExternalStore keeps it @@ -205,7 +206,7 @@ function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathna } const removePage = async (p: PageSummary) => { - if (!confirm(`Delete page "${p.title}"? Bookmarks are kept.`)) return + if (!confirm(`Move page "${p.title}" to the trash? Bookmarks are kept and it can be restored.`)) return try { const res = await fetch(`/api/pages/${p.id}`, { method: 'DELETE' }) if (!res.ok) { toast.error('Could not delete page'); return } diff --git a/web/computer_porting_guide.md b/web/computer_porting_guide.md index cf07dca..1375cbd 100644 --- a/web/computer_porting_guide.md +++ b/web/computer_porting_guide.md @@ -96,6 +96,12 @@ Composite PK (`sourcePageId`, `targetPageId`), `createdAt`; both Cascade, with a ### 2.13c `PageEmbed` (live collection view inside a page) Composite PK (`pageId`, `collectionId`), `viewId?` → CollectionView (SetNull), `position` int, `createdAt`; page and collection Cascade. Rebuilt from `{{collection:|view=}}` on every content save; only owned collections are stored. Item reads still go through the collection endpoint, so an embed displays, never publishes. +### 2.13d `PageRevision` (server-side page history) +`id`, `pageId` → Page (Cascade), `version` int, `title`, `content`, `icon?`, `createdAt`; unique (`pageId`, `version`). Every committed content version is snapshotted. Restoring a revision copies it into the live page as a **new** version and records that restore. + +### 2.13e `Trash` (recoverable deletions) +`id`, `ownerId` → User (Cascade), `type` (`bookmark`/`page`/`collection`), `originalId`, `data` jsonb snapshot, `deletedAt`; unique (`type`, `originalId`). Snapshots restore intrinsic content and owner-scoped links, but never access grants (public flags, bookmark/tag/collection shares). + ### 2.14 `SignalSource` (a watcher attached to a bookmark) `id`, `type` (e.g. `email`), `config` (JSON, default `{}`), `enabled` (default true), `lastCheckedAt?`, `lastStatus?`, `bookmarkId` → Bookmark (Cascade), `ownerId` → User (Cascade).