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:
@@ -116,8 +116,11 @@ test-only). CardDAV keeps the configured HTTPS port.
|
|||||||
| PUT | `/api/pages` | `{ok:true}`; complete `{order:[id,...]}` |
|
| 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}]` |
|
| 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) |
|
| 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}/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 |
|
| GET | `/api/pages/{id}/markdown` | Markdown attachment |
|
||||||
|
|
||||||
### Connected pages (phase 3)
|
### 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
|
its bookmarks. Deleting a collection removes its embeds; deleting a view
|
||||||
falls the embed back to the collection's default 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
|
## Collections
|
||||||
|
|
||||||
Contextual workflows over existing bookmarks (phase 2 of the Notion-like
|
Contextual workflows over existing bookmarks (phase 2 of the Notion-like
|
||||||
upgrades). Collections are owner-scoped; membership references bookmarks
|
upgrades). Collections are owner-scoped unless explicitly shared; membership
|
||||||
without owning them — removing an item or a whole collection never touches
|
references bookmarks without owning them — removing an item or a whole
|
||||||
the bookmark. Membership never broadens access: items whose bookmark the
|
collection never touches the bookmark. An explicit `CollectionShare` gives a
|
||||||
caller cannot see are omitted from lists/counts and behave as not-found for
|
recipient read-only access to the collection and the **narrowed** bookmark
|
||||||
mutations; re-sharing restores the membership. Every mutating PATCH requires
|
projection for member bookmarks **owned by the collection owner**. A bookmark
|
||||||
the expected `version` (400 missing, 409 stale). Indirect mutations advance
|
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
|
revisions too (review CR-09): item reorder bumps every affected item's
|
||||||
version, and property deletion bumps the versions of items whose value it
|
version, and property deletion bumps the versions of items whose value it
|
||||||
dropped and views whose filter/sort definition it changed. Deletions are
|
dropped and views whose filter/sort definition it changed. Deletions are
|
||||||
@@ -156,11 +183,13 @@ from a client snapshot.
|
|||||||
|
|
||||||
| Method | Path | Returns / behavior |
|
| 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?}` |
|
| 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}` |
|
| 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` |
|
| PATCH | `/api/collections/{id}` | Updated collection; `{version,name?,description?,icon?}`, bumps `version`; owner only |
|
||||||
| DELETE | `/api/collections/{id}` | `{ok:true}`; memberships/properties/views cascade, bookmarks kept |
|
| 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 |
|
| 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 |
|
| 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 |
|
| DELETE | `/api/collections/{id}/properties/{propertyId}` | `{ok:true}`; strips the property's values from all items and its clauses/sorts/grouping from saved views |
|
||||||
|
|||||||
@@ -80,9 +80,6 @@ keeping its own passwords.*
|
|||||||
|
|
||||||
**Product features**
|
**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
|
- Calendar and list views for collections are stored but still display as
|
||||||
tables.
|
tables.
|
||||||
- Alerts only show inside the app — nothing is emailed or pushed to you.
|
- 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
|
### 3–6 months: grow the product
|
||||||
|
|
||||||
- **Page history and trash** — see old versions, restore them; deleted
|
- **Page history and trash** — done: versioned history with restore, and
|
||||||
things are recoverable.
|
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"
|
- **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
|
that never shows titles you're not allowed to see, and live collection
|
||||||
views embedded in pages.
|
views embedded in pages.
|
||||||
- **Collection sharing** — the big one. Explicit, shown in the UI, and
|
- **Collection sharing** — done: explicit, disclosed, revocable read-only
|
||||||
revocable. Design the permission rules first (a project must never
|
shares; recipients see the narrowed bookmark view, and removing an item or
|
||||||
accidentally widen who can see a bookmark), then build it. Once it
|
revoking access removes it immediately. Cross-project "relation" columns
|
||||||
exists, cross-project "relation" columns can follow.
|
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
|
- **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.
|
email watcher matches. Turns the app from a dashboard into a notifier.
|
||||||
- **RSS watchers** — "tell me when this feed mentions X." Reuses everything
|
- **RSS watchers** — "tell me when this feed mentions X." Reuses everything
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -236,6 +236,22 @@ CREATE TABLE "PageLink" (
|
|||||||
);
|
);
|
||||||
CREATE INDEX "PageLink_targetPageId_idx" ON "PageLink"("targetPageId");
|
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
|
-- 2.14 SignalSource
|
||||||
CREATE TABLE "SignalSource" (
|
CREATE TABLE "SignalSource" (
|
||||||
id text PRIMARY KEY,
|
id text PRIMARY KEY,
|
||||||
@@ -294,6 +310,22 @@ CREATE TABLE "AppSetting" (
|
|||||||
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
"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
|
-- 2.16 SharedDate
|
||||||
CREATE TABLE "SharedDate" (
|
CREATE TABLE "SharedDate" (
|
||||||
id text PRIMARY KEY,
|
id text PRIMARY KEY,
|
||||||
@@ -325,6 +357,19 @@ CREATE TABLE "Collection" (
|
|||||||
);
|
);
|
||||||
CREATE INDEX "Collection_ownerId_idx" ON "Collection"("ownerId");
|
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" (
|
CREATE TABLE "CollectionProperty" (
|
||||||
id text PRIMARY KEY,
|
id text PRIMARY KEY,
|
||||||
"collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE,
|
"collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -41,4 +41,11 @@ const BookmarkViewerView = `CASE WHEN b."ownerId"=$2 THEN ` + BookmarkOwnerView
|
|||||||
// it, including bookmarks other recipients added. The share UI says so.
|
// 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)
|
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)
|
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))`
|
OR EXISTS(SELECT 1 FROM "SharedDate" sd WHERE sd."creatorId"=b."ownerId" AND sd."recipientId"=$2 AND (b."dueDate" AT TIME ZONE 'UTC')::date=sd.date))`
|
||||||
|
|||||||
@@ -363,7 +363,15 @@ func HandleBookmarksUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
respond(w, 200, v, e)
|
respond(w, 200, v, e)
|
||||||
}
|
}
|
||||||
func HandleBookmarksDelete(w http.ResponseWriter, r *http.Request) {
|
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)
|
respond(w, 200, OK{true}, e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,15 @@ var (
|
|||||||
func collectionOwned(r *http.Request, q db.Queryer, id string) (object, error) {
|
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))
|
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) {
|
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)
|
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[k] = v
|
||||||
}
|
}
|
||||||
out["properties"], out["views"], out["_count"] = props, views, counts
|
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
|
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
|
// Item counts are authorized counts (N-10 edge case): a bookmark the
|
||||||
// caller can no longer see never inflates them. Collections are
|
// caller can no longer see never inflates them. Collections are
|
||||||
// owner-scoped in phase 2, so $1 (owner) and $2 (BookmarkVisible) match.
|
// 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+`),
|
'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),
|
'properties',(SELECT count(*) FROM "CollectionProperty" p WHERE p."collectionId"=c.id),
|
||||||
'views',(SELECT count(*) FROM "CollectionView" v WHERE v."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)
|
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) {
|
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 {
|
if e != nil {
|
||||||
respond(w, 0, nil, e)
|
respond(w, 0, nil, e)
|
||||||
return
|
return
|
||||||
@@ -252,8 +281,51 @@ func HandleCollectionsUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func HandleCollectionsDelete(w http.ResponseWriter, r *http.Request) {
|
func HandleCollectionsDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
// Memberships, properties and views cascade; bookmarks are never touched
|
// Memberships, properties and views cascade; bookmarks are never touched
|
||||||
// (N-01 acceptance criterion).
|
// (N-01 acceptance criterion). The collection structure is snapshotted for
|
||||||
respond(w, 200, OK{true}, remove(r, "Collection", r.PathValue("id"), "ownerId"))
|
// 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 -----------------------------------------------------------
|
// --- properties -----------------------------------------------------------
|
||||||
@@ -1203,8 +1275,9 @@ func HandleCollectionItemsList(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
limit = n
|
limit = n
|
||||||
}
|
}
|
||||||
// Ownership is the gate: a foreign collection is a 404, not an empty list.
|
// Owner or explicit share is the read gate: a foreign collection is a
|
||||||
if _, e := collectionOwned(r, database(r), cid); e != nil {
|
// 404, not an empty list. Mutations below still require ownership.
|
||||||
|
if _, e := collectionReadable(r, database(r), cid); e != nil {
|
||||||
respond(w, 0, nil, e)
|
respond(w, 0, nil, e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1217,7 +1290,7 @@ func HandleCollectionItemsList(w http.ResponseWriter, r *http.Request) {
|
|||||||
// $1 stays a permanently-nil placeholder (BookmarkVisible binds $2), the
|
// $1 stays a permanently-nil placeholder (BookmarkVisible binds $2), the
|
||||||
// same port artifact the bookmarks list documents.
|
// same port artifact the bookmarks list documents.
|
||||||
args := []any{nil, user(r), cid}
|
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
|
var sorts []any
|
||||||
if viewID := sp.Get("viewId"); viewID != "" {
|
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)
|
view, e2 := db.One(r.Context(), database(r), `SELECT to_jsonb(v) FROM "CollectionView" v WHERE v.id=$1 AND v."collectionId"=$2`, viewID, cid)
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/andrew/go-computer/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Structured export (trust phase): a single owner-scoped JSON archive that is
|
||||||
|
// richer than the per-item Markdown downloads. It deliberately excludes
|
||||||
|
// credentials (IMAP config, CardDAV passwords) and includes only data the
|
||||||
|
// caller owns or may read through the owner projection.
|
||||||
|
func HandleExport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
uid := user(r)
|
||||||
|
me, e := db.One(ctx, database(r), `SELECT jsonb_build_object('id',u.id,'name',u.name,'email',u.email,'role',u.role,'createdAt',u."createdAt") FROM "User" u WHERE u.id=$1`, uid)
|
||||||
|
if e != nil {
|
||||||
|
respond(w, 0, nil, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bookmarks, e := db.Many(ctx, database(r), `SELECT `+db.BookmarkOwnerView+` FROM "Bookmark" b WHERE b."ownerId"=$1 ORDER BY b."createdAt",b.id`, uid)
|
||||||
|
if e != nil {
|
||||||
|
respond(w, 0, nil, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tags, e := db.Many(ctx, database(r), `SELECT to_jsonb(t)||jsonb_build_object('sharedWith',COALESCE((SELECT jsonb_agg(jsonb_build_object('userId',s."userId",'email',u.email,'name',u.name)) FROM "TagShare" s JOIN "User" u ON u.id=s."userId" WHERE s."tagId"=t.id),'[]'::jsonb)) FROM "Tag" t WHERE t."ownerId"=$1 ORDER BY t."createdAt",t.id`, uid)
|
||||||
|
if e != nil {
|
||||||
|
respond(w, 0, nil, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pages, e := db.Many(ctx, database(r), `SELECT to_jsonb(p)||jsonb_build_object(
|
||||||
|
'bookmarks',COALESCE((SELECT jsonb_agg(to_jsonb(pb)||jsonb_build_object('bookmark',`+db.BookmarkOwnerView+`) ORDER BY pb.position) FROM "PageBookmark" pb JOIN "Bookmark" b ON b.id=pb."bookmarkId" WHERE pb."pageId"=p.id AND b."ownerId"=$1),'[]'::jsonb),
|
||||||
|
'revisions',COALESCE((SELECT jsonb_agg(to_jsonb(r) ORDER BY r.version) FROM "PageRevision" r WHERE r."pageId"=p.id),'[]'::jsonb))
|
||||||
|
FROM "Page" p WHERE p."ownerId"=$1 ORDER BY p."createdAt",p.id`, uid)
|
||||||
|
if e != nil {
|
||||||
|
respond(w, 0, nil, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contacts, e := db.Many(ctx, database(r), `SELECT to_jsonb(c) FROM "Contact" c WHERE c."userId"=$1 ORDER BY c."displayName",c.id`, uid)
|
||||||
|
if e != nil {
|
||||||
|
respond(w, 0, nil, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
collections, e := db.Many(ctx, database(r), `SELECT to_jsonb(c) FROM "Collection" c WHERE c."ownerId"=$1 ORDER BY c."createdAt",c.id`, uid)
|
||||||
|
if e != nil {
|
||||||
|
respond(w, 0, nil, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exported := make([]object, 0, len(collections))
|
||||||
|
for _, col := range collections {
|
||||||
|
detail, e := collectionDetail(r, database(r), col)
|
||||||
|
if e != nil {
|
||||||
|
respond(w, 0, nil, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cid := str(col, "id")
|
||||||
|
items, e := db.Many(ctx, database(r), `SELECT to_jsonb(ci)||jsonb_build_object('bookmark',`+db.BookmarkOwnerView+`) FROM "CollectionItem" ci JOIN "Bookmark" b ON b.id=ci."bookmarkId" WHERE ci."collectionId"=$1 ORDER BY ci.position,ci.id`, cid)
|
||||||
|
if e != nil {
|
||||||
|
respond(w, 0, nil, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shares, e := db.Many(ctx, database(r), `SELECT jsonb_build_object('userId',s."userId",'email',u.email,'name',u.name,'createdAt',s."createdAt") FROM "CollectionShare" s JOIN "User" u ON u.id=s."userId" WHERE s."collectionId"=$1 ORDER BY s."createdAt"`, cid)
|
||||||
|
if e != nil {
|
||||||
|
respond(w, 0, nil, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail["items"], detail["sharedWith"] = items, shares
|
||||||
|
exported = append(exported, detail)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="computer-export.json"`)
|
||||||
|
respond(w, 200, object{
|
||||||
|
"format": "computer-export",
|
||||||
|
"version": 1,
|
||||||
|
"exportedAt": time.Now().UTC(),
|
||||||
|
"user": me,
|
||||||
|
"bookmarks": bookmarks,
|
||||||
|
"tags": tags,
|
||||||
|
"pages": pages,
|
||||||
|
"collections": exported,
|
||||||
|
"contacts": contacts,
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/andrew/go-computer/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Page revision history (trust phase). Every committed content version gets
|
||||||
|
// an immutable snapshot; restoring an old snapshot writes a new version rather
|
||||||
|
// than rewriting history. Snapshots are owner-scoped through their page.
|
||||||
|
func capturePageRevision(r *http.Request, tx *sql.Tx, pageID string) error {
|
||||||
|
_, e := tx.ExecContext(r.Context(), `INSERT INTO "PageRevision"(id,"pageId",version,title,content,icon)
|
||||||
|
SELECT $1,p.id,p.version,p.title,p.content,p.icon FROM "Page" p WHERE p.id=$2
|
||||||
|
ON CONFLICT ("pageId",version) DO NOTHING`, db.NewID(), pageID)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func revisionOwned(r *http.Request, q db.Queryer, pageID, revisionID string) (object, error) {
|
||||||
|
if _, e := owned(r, q, "Page", pageID, "ownerId"); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
return db.One(r.Context(), q, `SELECT to_jsonb(r) FROM "PageRevision" r WHERE r.id=$1 AND r."pageId"=$2`, revisionID, pageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandlePageRevisionsList returns metadata only — the editor fetches a body on
|
||||||
|
// demand (a page can have a long history and each body is up to 512 KiB).
|
||||||
|
func HandlePageRevisionsList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pageID := r.PathValue("id")
|
||||||
|
if _, e := owned(r, database(r), "Page", pageID, "ownerId"); e != nil {
|
||||||
|
respond(w, 0, nil, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, e := db.Many(r.Context(), database(r), `SELECT jsonb_build_object(
|
||||||
|
'id',r.id,'version',r.version,'title',r.title,'icon',r.icon,
|
||||||
|
'createdAt',r."createdAt",'length',length(r.content))
|
||||||
|
FROM "PageRevision" r WHERE r."pageId"=$1 ORDER BY r.version DESC`, pageID)
|
||||||
|
respond(w, 200, v, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandlePageRevisionsGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
v, e := revisionOwned(r, database(r), r.PathValue("id"), r.PathValue("revisionId"))
|
||||||
|
respond(w, 200, v, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandlePageRevisionsRestore copies a snapshot into the live page as a new
|
||||||
|
// version. It requires the current base version (the same DATA-05 rule as a
|
||||||
|
// content save), so restoring while another tab writes is a 409, not a silent
|
||||||
|
// overwrite. The restored state itself becomes a history entry.
|
||||||
|
func HandlePageRevisionsRestore(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pageID, revisionID := r.PathValue("id"), r.PathValue("revisionId")
|
||||||
|
b, ok := read(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
baseVersion, e := patchVersion(b)
|
||||||
|
if e != nil {
|
||||||
|
respond(w, 0, nil, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var v object
|
||||||
|
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
|
||||||
|
if e := lockUser(r, tx); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
current, e := owned(r, tx, "Page", pageID, "ownerId")
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if stored, _ := current["version"].(float64); int(stored) != baseVersion {
|
||||||
|
return conflict("Page was modified elsewhere; reload to see the latest version")
|
||||||
|
}
|
||||||
|
rev, e := db.One(r.Context(), tx, `SELECT to_jsonb(r) FROM "PageRevision" r WHERE r.id=$1 AND r."pageId"=$2`, revisionID, pageID)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if _, e := tx.ExecContext(r.Context(), `UPDATE "Page" SET title=$1,content=$2,icon=$3,version=version+1,"updatedAt"=$4 WHERE id=$5 AND "ownerId"=$6`,
|
||||||
|
str(rev, "title"), str(rev, "content"), rev["icon"], time.Now().UTC(), pageID, user(r)); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
// Restoring content also restores its derived graph. Targets that no
|
||||||
|
// longer exist (or are no longer owned) are ignored by syncPageGraph.
|
||||||
|
if e := syncPageGraph(r.Context(), tx, pageID, user(r), str(rev, "content")); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e := capturePageRevision(r, tx, pageID); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
v, e = pageView(r.Context(), tx, pageID, user(r))
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
respond(w, 200, v, e)
|
||||||
|
}
|
||||||
@@ -121,6 +121,9 @@ func HandlePagesCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return e
|
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
|
// Acknowledge the same snapshot the write produced (the update path's
|
||||||
// CR-18 discipline), so the 201 already carries links/embeds.
|
// CR-18 discipline), so the 201 already carries links/embeds.
|
||||||
v, e = pageView(r.Context(), tx, d["id"].(string), user(r))
|
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 {
|
if e := syncPageGraph(r.Context(), tx, id, user(r), content); e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
if e := capturePageRevision(r, tx, id); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if bid := str(b, "addBookmarkId"); bid != "" {
|
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 {
|
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) {
|
func HandlePagesDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
// Children are promoted to the top level by the self-FK's SET NULL; a
|
// Children are promoted to the top level by the self-FK's SET NULL; a
|
||||||
// delete never silently takes nested pages with it.
|
// delete never silently takes nested pages with it. The page, its history
|
||||||
respond(w, 200, OK{true}, remove(r, "Page", r.PathValue("id"), "ownerId"))
|
// 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) {
|
func HandlePagesMarkdown(w http.ResponseWriter, r *http.Request) {
|
||||||
v, e := page(r, r.PathValue("id"))
|
v, e := page(r, r.PathValue("id"))
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/andrew/go-computer/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Recoverable deletions (trust phase).
|
||||||
|
//
|
||||||
|
// Trash stores a private, per-owner JSON snapshot taken in the same
|
||||||
|
// transaction as the hard delete. Restoring inserts the intrinsic record and
|
||||||
|
// owner-scoped relationships, but deliberately NOT access grants:
|
||||||
|
// - bookmark isPublic is forced false and bookmark/tag shares are not
|
||||||
|
// snapshotted, so a restored bookmark never resurrects a public link or a
|
||||||
|
// share removed while it was in the trash;
|
||||||
|
// - collection shares are not snapshotted;
|
||||||
|
// - server-driven watcher rows are not snapshotted.
|
||||||
|
// Contextual collection memberships and page links are owner-scoped data and
|
||||||
|
// are restored when their parent still exists.
|
||||||
|
|
||||||
|
func jsonSnapshot(v any) (string, error) {
|
||||||
|
raw, e := json.Marshal(v)
|
||||||
|
return string(raw), e
|
||||||
|
}
|
||||||
|
|
||||||
|
func addTrash(r *http.Request, tx *sql.Tx, kind, originalID string, data object) error {
|
||||||
|
raw, e := jsonSnapshot(data)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
_, e = db.Insert(r.Context(), tx, "Trash", object{
|
||||||
|
"id": db.NewID(), "ownerId": user(r), "type": kind,
|
||||||
|
"originalId": originalID, "data": raw,
|
||||||
|
})
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteOwnedRow(r *http.Request, tx *sql.Tx, table, id, ownerCol string, deleted *int64) error {
|
||||||
|
res, e := tx.ExecContext(r.Context(), `DELETE FROM `+db.Quote(table)+` WHERE id=$1 AND `+db.Quote(ownerCol)+`=$2`, id, user(r))
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return db.ErrNotFound
|
||||||
|
}
|
||||||
|
if deleted != nil {
|
||||||
|
*deleted = n
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- delete snapshots -----------------------------------------------------
|
||||||
|
|
||||||
|
func trashBookmark(r *http.Request, tx *sql.Tx, id string) error {
|
||||||
|
row, e := owned(r, tx, "Bookmark", id, "ownerId")
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
tags, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('tagId',bt."tagId") FROM "BookmarkTag" bt JOIN "Tag" t ON t.id=bt."tagId" WHERE bt."bookmarkId"=$1 AND (t."ownerId"=$2 OR EXISTS(SELECT 1 FROM "TagShare" s WHERE s."tagId"=t.id AND s."userId"=$2))`, id, user(r))
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
pageLinks, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('pageId',pb."pageId",'position',pb.position) FROM "PageBookmark" pb JOIN "Page" p ON p.id=pb."pageId" WHERE pb."bookmarkId"=$1 AND p."ownerId"=$2`, id, user(r))
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
collectionItems, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('id',ci.id,'collectionId',ci."collectionId",'bookmarkId',ci."bookmarkId",'propertyValues',ci."propertyValues",'position',ci.position,'version',ci.version,'addedAt',ci."addedAt",'updatedAt',ci."updatedAt") FROM "CollectionItem" ci JOIN "Collection" c ON c.id=ci."collectionId" WHERE ci."bookmarkId"=$1 AND c."ownerId"=$2`, id, user(r))
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e := addTrash(r, tx, "bookmark", id, object{
|
||||||
|
"bookmark": row, "tags": tags, "pageLinks": pageLinks, "collectionItems": collectionItems,
|
||||||
|
}); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return deleteOwnedRow(r, tx, "Bookmark", id, "ownerId", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trashPage(r *http.Request, tx *sql.Tx, id string) error {
|
||||||
|
row, e := owned(r, tx, "Page", id, "ownerId")
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
bookmarks, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('bookmarkId',pb."bookmarkId",'position',pb.position) FROM "PageBookmark" pb WHERE pb."pageId"=$1`, id)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
revisions, e := db.Many(r.Context(), tx, `SELECT to_jsonb(r) FROM "PageRevision" r WHERE r."pageId"=$1 ORDER BY r.version`, id)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e := addTrash(r, tx, "page", id, object{"page": row, "bookmarks": bookmarks, "revisions": revisions}); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return deleteOwnedRow(r, tx, "Page", id, "ownerId", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trashCollection(r *http.Request, tx *sql.Tx, id string) error {
|
||||||
|
row, e := owned(r, tx, "Collection", id, "ownerId")
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
properties, e := db.Many(r.Context(), tx, `SELECT to_jsonb(p) FROM "CollectionProperty" p WHERE p."collectionId"=$1 ORDER BY p.position,p.id`, id)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
views, e := db.Many(r.Context(), tx, `SELECT to_jsonb(v) FROM "CollectionView" v WHERE v."collectionId"=$1 ORDER BY v.position,v.id`, id)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
items, e := db.Many(r.Context(), tx, `SELECT to_jsonb(ci) FROM "CollectionItem" ci WHERE ci."collectionId"=$1 ORDER BY ci.position,ci.id`, id)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e := addTrash(r, tx, "collection", id, object{
|
||||||
|
"collection": row, "properties": properties, "views": views, "items": items,
|
||||||
|
}); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return deleteOwnedRow(r, tx, "Collection", id, "ownerId", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- restore --------------------------------------------------------------
|
||||||
|
|
||||||
|
func objectValue(v any) object {
|
||||||
|
if m, ok := v.(map[string]any); ok {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func objectList(v any) []object {
|
||||||
|
arr, _ := v.([]any)
|
||||||
|
out := make([]object, 0, len(arr))
|
||||||
|
for _, x := range arr {
|
||||||
|
if m, ok := x.(map[string]any); ok {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertSnapshot(r *http.Request, tx *sql.Tx, table, kind string, row object) error {
|
||||||
|
raw, e := jsonSnapshot(row)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
_, e = tx.ExecContext(r.Context(), `INSERT INTO `+db.Quote(table)+` SELECT * FROM jsonb_populate_record(NULL::`+db.Quote(table)+`, $1::jsonb)`, raw)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreBookmark(r *http.Request, tx *sql.Tx, data object) (string, error) {
|
||||||
|
row := objectValue(data["bookmark"])
|
||||||
|
if row == nil {
|
||||||
|
return "", bad("Trash entry is damaged")
|
||||||
|
}
|
||||||
|
id := str(row, "id")
|
||||||
|
// Restore access decisions to their safe default. A public link or a
|
||||||
|
// direct share is a grant, not intrinsic content.
|
||||||
|
row["isPublic"] = false
|
||||||
|
for _, pair := range [][2]string{{"contactId", "Contact"}, {"kanbanColumnId", "KanbanColumn"}} {
|
||||||
|
if ref := str(row, pair[0]); ref != "" {
|
||||||
|
col := "userId"
|
||||||
|
var exists bool
|
||||||
|
if e := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM `+db.Quote(pair[1])+` WHERE id=$1 AND `+db.Quote(col)+`=$2)`, ref, user(r)).Scan(&exists); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
row[pair[0]] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e := insertSnapshot(r, tx, "Bookmark", "bookmark", row); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
for _, t := range objectList(data["tags"]) {
|
||||||
|
tagID := str(t, "tagId")
|
||||||
|
var exists bool
|
||||||
|
if e := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM "Tag" t WHERE t.id=$1 AND (t."ownerId"=$2 OR EXISTS(SELECT 1 FROM "TagShare" s WHERE s."tagId"=t.id AND s."userId"=$2)))`, tagID, user(r)).Scan(&exists); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
if _, e := tx.ExecContext(r.Context(), `INSERT INTO "BookmarkTag"("bookmarkId","tagId") VALUES($1,$2) ON CONFLICT DO NOTHING`, id, tagID); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, l := range objectList(data["pageLinks"]) {
|
||||||
|
var exists bool
|
||||||
|
if e := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM "Page" WHERE id=$1 AND "ownerId"=$2)`, str(l, "pageId"), user(r)).Scan(&exists); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, e := tx.ExecContext(r.Context(), `INSERT INTO "PageBookmark"("pageId","bookmarkId",position) VALUES($1,$2,$3) ON CONFLICT DO NOTHING`, str(l, "pageId"), id, intFieldValue(l, "position")); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range objectList(data["collectionItems"]) {
|
||||||
|
var exists bool
|
||||||
|
if e := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM "Collection" WHERE id=$1 AND "ownerId"=$2)`, str(item, "collectionId"), user(r)).Scan(&exists); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e := insertSnapshot(r, tx, "CollectionItem", "collectionItem", item); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func intFieldValue(b object, key string) int {
|
||||||
|
if f, ok := b[key].(float64); ok {
|
||||||
|
return int(f)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func restorePage(r *http.Request, tx *sql.Tx, data object) (string, error) {
|
||||||
|
row := objectValue(data["page"])
|
||||||
|
if row == nil {
|
||||||
|
return "", bad("Trash entry is damaged")
|
||||||
|
}
|
||||||
|
id := str(row, "id")
|
||||||
|
parent, e := pageParent(r.Context(), tx, id, user(r), row["parentId"])
|
||||||
|
if e != nil {
|
||||||
|
// A parent deleted while this page sat in the trash simply promotes
|
||||||
|
// the restored page to the top level.
|
||||||
|
if _, ok := e.(clientError); !ok {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
parent = nil
|
||||||
|
}
|
||||||
|
row["parentId"] = parent
|
||||||
|
if e := insertSnapshot(r, tx, "Page", "page", row); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
for _, l := range objectList(data["bookmarks"]) {
|
||||||
|
var visible bool
|
||||||
|
if e := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM "Bookmark" b WHERE b.id=$1 AND `+db.BookmarkVisible+`)`, str(l, "bookmarkId"), user(r)).Scan(&visible); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
if !visible {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, e := tx.ExecContext(r.Context(), `INSERT INTO "PageBookmark"("pageId","bookmarkId",position) VALUES($1,$2,$3) ON CONFLICT DO NOTHING`, id, str(l, "bookmarkId"), intFieldValue(l, "position")); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rev := range objectList(data["revisions"]) {
|
||||||
|
if e := insertSnapshot(r, tx, "PageRevision", "pageRevision", rev); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e := syncPageGraph(r.Context(), tx, id, user(r), str(row, "content")); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
// A restore is a new trust event: bump the revision and record it, so the
|
||||||
|
// state that came back from the trash is itself auditable.
|
||||||
|
if _, e := tx.ExecContext(r.Context(), `UPDATE "Page" SET version=version+1,"updatedAt"=now() WHERE id=$1 AND "ownerId"=$2`, id, user(r)); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
if e := capturePageRevision(r, tx, id); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreCollection(r *http.Request, tx *sql.Tx, data object) (string, error) {
|
||||||
|
row := objectValue(data["collection"])
|
||||||
|
if row == nil {
|
||||||
|
return "", bad("Trash entry is damaged")
|
||||||
|
}
|
||||||
|
id := str(row, "id")
|
||||||
|
if e := insertSnapshot(r, tx, "Collection", "collection", row); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
for _, prop := range objectList(data["properties"]) {
|
||||||
|
if e := insertSnapshot(r, tx, "CollectionProperty", "collectionProperty", prop); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, view := range objectList(data["views"]) {
|
||||||
|
if e := insertSnapshot(r, tx, "CollectionView", "collectionView", view); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range objectList(data["items"]) {
|
||||||
|
var visible bool
|
||||||
|
if e := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM "Bookmark" b WHERE b.id=$1 AND `+db.BookmarkVisible+`)`, str(item, "bookmarkId"), user(r)).Scan(&visible); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
if !visible {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e := insertSnapshot(r, tx, "CollectionItem", "collectionItem", item); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP surfaces --------------------------------------------------------
|
||||||
|
|
||||||
|
func HandleTrashList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
v, e := db.Many(r.Context(), database(r), `SELECT jsonb_build_object(
|
||||||
|
'id',t.id,'type',t.type,'originalId',t."originalId",'deletedAt',t."deletedAt",
|
||||||
|
'title',COALESCE(
|
||||||
|
NULLIF(t.data->'bookmark'->>'title',''),
|
||||||
|
t.data->'bookmark'->>'url',
|
||||||
|
NULLIF(t.data->'page'->>'title',''),
|
||||||
|
t.data->'collection'->>'name',
|
||||||
|
'Untitled'))
|
||||||
|
FROM "Trash" t WHERE t."ownerId"=$1 ORDER BY t."deletedAt" DESC,t.id`, user(r))
|
||||||
|
respond(w, 200, v, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleTrashRestore(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
var kind, originalID string
|
||||||
|
e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
|
||||||
|
if e := lockUser(r, tx); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
entry, e := db.One(r.Context(), tx, `SELECT to_jsonb(t) FROM "Trash" t WHERE t.id=$1 AND t."ownerId"=$2 FOR UPDATE`, id, user(r))
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
kind, originalID = str(entry, "type"), str(entry, "originalId")
|
||||||
|
data := objectValue(entry["data"])
|
||||||
|
if data == nil {
|
||||||
|
return bad("Trash entry is damaged")
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case "bookmark":
|
||||||
|
_, e = restoreBookmark(r, tx, data)
|
||||||
|
case "page":
|
||||||
|
_, e = restorePage(r, tx, data)
|
||||||
|
case "collection":
|
||||||
|
_, e = restoreCollection(r, tx, data)
|
||||||
|
default:
|
||||||
|
return bad("Unknown trash entry type")
|
||||||
|
}
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
_, e = tx.ExecContext(r.Context(), `DELETE FROM "Trash" WHERE id=$1 AND "ownerId"=$2`, id, user(r))
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
respond(w, 200, object{"ok": true, "type": kind, "id": originalID}, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleTrashPurge(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Permanent removal is explicit (the default delete moves to the trash).
|
||||||
|
_, e := database(r).ExecContext(r.Context(), `DELETE FROM "Trash" WHERE id=$1 AND "ownerId"=$2`, r.PathValue("id"), user(r))
|
||||||
|
respond(w, 200, OK{true}, e)
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ func HandleBookmarkUnshare(w http.ResponseWriter, r *http.Request) {
|
|||||||
func HandleTagUnshare(w http.ResponseWriter, r *http.Request) {
|
func HandleTagUnshare(w http.ResponseWriter, r *http.Request) {
|
||||||
unshare(w, r, "Tag", "TagShare", "tagId")
|
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) {
|
func HandleSharedDateDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
respond(w, 200, OK{true}, remove(r, "SharedDate", r.PathValue("id"), "creatorId"))
|
respond(w, 200, OK{true}, remove(r, "SharedDate", r.PathValue("id"), "creatorId"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,8 +89,17 @@ func NewServer(cfg *config.Config, database *db.DB) *http.Server {
|
|||||||
mux.HandleFunc("PATCH /api/pages/{id}", authed(handlers.HandlePagesUpdate))
|
mux.HandleFunc("PATCH /api/pages/{id}", authed(handlers.HandlePagesUpdate))
|
||||||
mux.HandleFunc("DELETE /api/pages/{id}", authed(handlers.HandlePagesDelete))
|
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}/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))
|
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
|
// 4.7 Signals
|
||||||
mux.HandleFunc("GET /api/bookmarks/{id}/signals", authed(handlers.HandleSignalsList))
|
mux.HandleFunc("GET /api/bookmarks/{id}/signals", authed(handlers.HandleSignalsList))
|
||||||
mux.HandleFunc("POST /api/bookmarks/{id}/signals", authed(handlers.HandleSignalsCreate))
|
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("GET /api/collections/{id}", authed(handlers.HandleCollectionsGet))
|
||||||
mux.HandleFunc("PATCH /api/collections/{id}", authed(handlers.HandleCollectionsUpdate))
|
mux.HandleFunc("PATCH /api/collections/{id}", authed(handlers.HandleCollectionsUpdate))
|
||||||
mux.HandleFunc("DELETE /api/collections/{id}", authed(handlers.HandleCollectionsDelete))
|
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("POST /api/collections/{id}/properties", authed(handlers.HandleCollectionPropertiesCreate))
|
||||||
mux.HandleFunc("PATCH /api/collections/{id}/properties/{propertyId}", authed(handlers.HandleCollectionPropertiesUpdate))
|
mux.HandleFunc("PATCH /api/collections/{id}/properties/{propertyId}", authed(handlers.HandleCollectionPropertiesUpdate))
|
||||||
mux.HandleFunc("DELETE /api/collections/{id}/properties/{propertyId}", authed(handlers.HandleCollectionPropertiesDelete))
|
mux.HandleFunc("DELETE /api/collections/{id}/properties/{propertyId}", authed(handlers.HandleCollectionPropertiesDelete))
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Trust phase: page revision history, recoverable deletions that never
|
||||||
|
// resurrect access grants, structured export, and explicit collection sharing.
|
||||||
|
|
||||||
|
func TestPageRevisionHistory(t *testing.T) {
|
||||||
|
srv := testServer(t)
|
||||||
|
alice := newAPIUser(t, srv, "trust-history@computer.test")
|
||||||
|
bob := newAPIUser(t, srv, "trust-history-other@computer.test")
|
||||||
|
|
||||||
|
page := alice.request("POST", "/api/pages", map[string]any{"title": "History", "content": "version one"}, 201)["id"].(string)
|
||||||
|
revisions := alice.getJSON("/api/pages/"+page+"/revisions", 200)
|
||||||
|
if len(revisions) != 1 || revisions[0]["version"].(float64) != 1 || revisions[0]["content"] != nil {
|
||||||
|
t.Fatalf("initial revision metadata: %v", revisions)
|
||||||
|
}
|
||||||
|
first := revisions[0]["id"].(string)
|
||||||
|
body := alice.request("GET", "/api/pages/"+page+"/revisions/"+first, nil, 200)
|
||||||
|
if body["content"] != "version one" {
|
||||||
|
t.Fatalf("revision body: %v", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
alice.request("PATCH", "/api/pages/"+page, map[string]any{"content": "version two", "version": 1}, 200)
|
||||||
|
revisions = alice.getJSON("/api/pages/"+page+"/revisions", 200)
|
||||||
|
if len(revisions) != 2 || revisions[0]["version"].(float64) != 2 {
|
||||||
|
t.Fatalf("history did not append: %v", revisions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore writes a NEW version rather than rewriting history.
|
||||||
|
restored := alice.request("POST", "/api/pages/"+page+"/revisions/"+first+"/restore", map[string]any{"version": 2}, 200)
|
||||||
|
if restored["content"] != "version one" || restored["version"].(float64) != 3 {
|
||||||
|
t.Fatalf("restore did not create a new version: %v", restored)
|
||||||
|
}
|
||||||
|
revisions = alice.getJSON("/api/pages/"+page+"/revisions", 200)
|
||||||
|
if len(revisions) != 3 || revisions[0]["version"].(float64) != 3 {
|
||||||
|
t.Fatalf("restore was not recorded as history: %v", revisions)
|
||||||
|
}
|
||||||
|
if got := alice.request("GET", "/api/pages/"+page+"/revisions/"+revisions[0]["id"].(string), nil, 200); got["content"] != "version one" {
|
||||||
|
t.Fatalf("restored revision body: %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stale base is a 409, not a silent overwrite.
|
||||||
|
alice.request("POST", "/api/pages/"+page+"/revisions/"+first+"/restore", map[string]any{"version": 2}, 409)
|
||||||
|
alice.request("POST", "/api/pages/"+page+"/revisions/"+first+"/restore", map[string]any{}, 400)
|
||||||
|
bob.request("GET", "/api/pages/"+page+"/revisions", nil, 404)
|
||||||
|
bob.request("GET", "/api/pages/"+page+"/revisions/"+first, nil, 404)
|
||||||
|
bob.request("POST", "/api/pages/"+page+"/revisions/"+first+"/restore", map[string]any{"version": 3}, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrashRestoreNeverResurrectsGrants(t *testing.T) {
|
||||||
|
srv := testServer(t)
|
||||||
|
alice := newAPIUser(t, srv, "trust-trash@computer.test")
|
||||||
|
bob := newAPIUser(t, srv, "trust-trash-other@computer.test")
|
||||||
|
bobID := meID(bob)
|
||||||
|
|
||||||
|
tag := alice.request("POST", "/api/tags", map[string]any{"name": "Restored tag"}, 201)["id"].(string)
|
||||||
|
page := alice.request("POST", "/api/pages", map[string]any{"title": "Host page"}, 201)["id"].(string)
|
||||||
|
col := alice.request("POST", "/api/collections", map[string]any{"name": "Restored project"}, 201)
|
||||||
|
cid := col["id"].(string)
|
||||||
|
prop := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Status", "type": "TEXT"}, 201)["id"].(string)
|
||||||
|
|
||||||
|
bm := alice.request("POST", "/api/bookmarks", map[string]any{
|
||||||
|
"url": "https://127.0.0.1/restore-me", "title": "Restore me", "tagIds": []string{tag}, "isPublic": true,
|
||||||
|
}, 201)
|
||||||
|
bid := bm["id"].(string)
|
||||||
|
alice.request("PATCH", "/api/pages/"+page, map[string]any{"addBookmarkId": bid}, 200)
|
||||||
|
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bid, "propertyValues": map[string]any{prop: "kept"}}, 201)
|
||||||
|
alice.request("POST", "/api/bookmarks/"+bid+"/share", map[string]any{"userIds": []string{bobID}}, 200)
|
||||||
|
bob.request("GET", "/api/bookmarks/"+bid, nil, 200)
|
||||||
|
alice.request("GET", "/api/public/bookmarks/"+bid, nil, 200)
|
||||||
|
|
||||||
|
alice.request("DELETE", "/api/bookmarks/"+bid, nil, 200)
|
||||||
|
bob.request("GET", "/api/bookmarks/"+bid, nil, 404)
|
||||||
|
alice.request("GET", "/api/public/bookmarks/"+bid, nil, 404)
|
||||||
|
trash := alice.getJSON("/api/trash", 200)
|
||||||
|
if len(trash) != 1 || trash[0]["type"] != "bookmark" || trash[0]["title"] != "Restore me" {
|
||||||
|
t.Fatalf("bookmark trash entry: %v", trash)
|
||||||
|
}
|
||||||
|
entry := trash[0]["id"].(string)
|
||||||
|
bob.request("POST", "/api/trash/"+entry+"/restore", map[string]any{}, 404)
|
||||||
|
|
||||||
|
restored := alice.request("POST", "/api/trash/"+entry+"/restore", map[string]any{}, 200)
|
||||||
|
if restored["ok"] != true || restored["id"] != bid {
|
||||||
|
t.Fatalf("restore response: %v", restored)
|
||||||
|
}
|
||||||
|
// Intrinsic links return; access grants do not.
|
||||||
|
got := alice.request("GET", "/api/bookmarks/"+bid, nil, 200)
|
||||||
|
if got["title"] != "Restore me" || len(got["tags"].([]any)) != 1 || got["isPublic"] != false {
|
||||||
|
t.Fatalf("restored bookmark: %v", got)
|
||||||
|
}
|
||||||
|
if len(got["pageLinks"].([]any)) != 1 {
|
||||||
|
t.Fatalf("page link was not restored: %v", got["pageLinks"])
|
||||||
|
}
|
||||||
|
bob.request("GET", "/api/bookmarks/"+bid, nil, 404)
|
||||||
|
alice.request("GET", "/api/public/bookmarks/"+bid, nil, 404)
|
||||||
|
items := alice.getJSON("/api/collections/"+cid+"/items", 200)
|
||||||
|
if len(items) != 1 || items[0]["propertyValues"].(map[string]any)[prop] != "kept" {
|
||||||
|
t.Fatalf("collection membership was not restored: %v", items)
|
||||||
|
}
|
||||||
|
if trash := alice.getJSON("/api/trash", 200); len(trash) != 0 {
|
||||||
|
t.Fatalf("restored entry stayed in trash: %v", trash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pages restore with their history, and the restore is itself a revision.
|
||||||
|
alice.request("PATCH", "/api/pages/"+page, map[string]any{"content": "page body", "version": 1}, 200)
|
||||||
|
alice.request("DELETE", "/api/pages/"+page, nil, 200)
|
||||||
|
alice.request("GET", "/api/pages/"+page, nil, 404)
|
||||||
|
trash = alice.getJSON("/api/trash", 200)
|
||||||
|
var pageEntry string
|
||||||
|
for _, row := range trash {
|
||||||
|
if row["type"] == "page" {
|
||||||
|
pageEntry = row["id"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pageEntry == "" {
|
||||||
|
t.Fatalf("page missing from trash: %v", trash)
|
||||||
|
}
|
||||||
|
alice.request("POST", "/api/trash/"+pageEntry+"/restore", map[string]any{}, 200)
|
||||||
|
restoredPage := alice.request("GET", "/api/pages/"+page, nil, 200)
|
||||||
|
if restoredPage["content"] != "page body" || len(restoredPage["bookmarks"].([]any)) != 1 {
|
||||||
|
t.Fatalf("restored page: %v", restoredPage)
|
||||||
|
}
|
||||||
|
revs := alice.getJSON("/api/pages/"+page+"/revisions", 200)
|
||||||
|
if len(revs) < 3 {
|
||||||
|
t.Fatalf("page history was not restored/extended: %v", revs)
|
||||||
|
}
|
||||||
|
if revs[0]["version"].(float64) != restoredPage["version"].(float64) {
|
||||||
|
t.Fatalf("restore revision is not the current version: revs=%v page=%v", revs[0], restoredPage["version"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collections restore structure and items; prior shares do not come back.
|
||||||
|
alice.request("POST", "/api/collections/"+cid+"/share", map[string]any{"userIds": []string{bobID}}, 200)
|
||||||
|
bob.request("GET", "/api/collections/"+cid, nil, 200)
|
||||||
|
alice.request("DELETE", "/api/collections/"+cid, nil, 200)
|
||||||
|
bob.request("GET", "/api/collections/"+cid, nil, 404)
|
||||||
|
trash = alice.getJSON("/api/trash", 200)
|
||||||
|
var collectionEntry string
|
||||||
|
for _, row := range trash {
|
||||||
|
if row["type"] == "collection" {
|
||||||
|
collectionEntry = row["id"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if collectionEntry == "" {
|
||||||
|
t.Fatalf("collection missing from trash: %v", trash)
|
||||||
|
}
|
||||||
|
alice.request("POST", "/api/trash/"+collectionEntry+"/restore", map[string]any{}, 200)
|
||||||
|
detail := alice.request("GET", "/api/collections/"+cid, nil, 200)
|
||||||
|
if detail["name"] != "Restored project" || len(detail["properties"].([]any)) != 1 {
|
||||||
|
t.Fatalf("restored collection: %v", detail)
|
||||||
|
}
|
||||||
|
if items := alice.getJSON("/api/collections/"+cid+"/items", 200); len(items) != 1 {
|
||||||
|
t.Fatalf("restored collection items: %v", items)
|
||||||
|
}
|
||||||
|
bob.request("GET", "/api/collections/"+cid, nil, 404)
|
||||||
|
|
||||||
|
// Permanent purge is explicit and owner-scoped.
|
||||||
|
alice.request("POST", "/api/trash/"+entry+"/restore", map[string]any{}, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectionSharingPermissionBoundary(t *testing.T) {
|
||||||
|
srv := testServer(t)
|
||||||
|
alice := newAPIUser(t, srv, "trust-coll-share@computer.test")
|
||||||
|
bob := newAPIUser(t, srv, "trust-coll-recipient@computer.test")
|
||||||
|
carol := newAPIUser(t, srv, "trust-coll-third@computer.test")
|
||||||
|
bobID, carolID := meID(bob), meID(carol)
|
||||||
|
|
||||||
|
bm := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/collection-share", "title": "Shared via project", "notes": "secret owner notes", "isPublic": true}, 201)
|
||||||
|
bid := bm["id"].(string)
|
||||||
|
col := alice.request("POST", "/api/collections", map[string]any{"name": "Team project"}, 201)
|
||||||
|
cid := col["id"].(string)
|
||||||
|
prop := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Status", "type": "TEXT"}, 201)["id"].(string)
|
||||||
|
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bid, "propertyValues": map[string]any{prop: "Doing"}}, 201)
|
||||||
|
|
||||||
|
bob.request("GET", "/api/collections/"+cid, nil, 404)
|
||||||
|
bob.request("GET", "/api/bookmarks/"+bid, nil, 404)
|
||||||
|
alice.request("POST", "/api/collections/"+cid+"/share", map[string]any{"userIds": []string{bobID}}, 200)
|
||||||
|
|
||||||
|
// Owner sees the disclosure.
|
||||||
|
detail := alice.request("GET", "/api/collections/"+cid, nil, 200)
|
||||||
|
if detail["shared"] != false || len(detail["sharedWith"].([]any)) != 1 {
|
||||||
|
t.Fatalf("owner share disclosure: %v", detail)
|
||||||
|
}
|
||||||
|
// Recipient sees the collection, its items, and the narrowed bookmark
|
||||||
|
// projection — never the owner's contact/share/recipient metadata.
|
||||||
|
listed := bob.getJSON("/api/collections", 200)
|
||||||
|
found := false
|
||||||
|
for _, c := range listed {
|
||||||
|
if c["id"] == cid {
|
||||||
|
found = c["shared"] == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("shared collection missing from recipient list: %v", listed)
|
||||||
|
}
|
||||||
|
bobDetail := bob.request("GET", "/api/collections/"+cid, nil, 200)
|
||||||
|
if bobDetail["shared"] != true || len(bobDetail["sharedWith"].([]any)) != 0 {
|
||||||
|
t.Fatalf("recipient detail disclosure: %v", bobDetail)
|
||||||
|
}
|
||||||
|
items := bob.getJSON("/api/collections/"+cid+"/items", 200)
|
||||||
|
if len(items) != 1 || items[0]["propertyValues"].(map[string]any)[prop] != "Doing" {
|
||||||
|
t.Fatalf("recipient items: %v", items)
|
||||||
|
}
|
||||||
|
sharedBookmark := bob.request("GET", "/api/bookmarks/"+bid, nil, 200)
|
||||||
|
if sharedBookmark["title"] != "Shared via project" || sharedBookmark["notes"] != "secret owner notes" {
|
||||||
|
t.Fatalf("recipient bookmark projection: %v", sharedBookmark)
|
||||||
|
}
|
||||||
|
if _, leaked := sharedBookmark["sharedWith"]; leaked {
|
||||||
|
t.Fatalf("recipient saw the owner's recipient list: %v", sharedBookmark)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only: mutations remain owner-only, including membership changes.
|
||||||
|
bob.request("PATCH", "/api/collections/"+cid, map[string]any{"version": 1, "name": "hijack"}, 404)
|
||||||
|
bob.request("DELETE", "/api/collections/"+cid, nil, 404)
|
||||||
|
bob.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bid}, 404)
|
||||||
|
bob.request("PATCH", "/api/collections/"+cid+"/items/"+items[0]["id"].(string), map[string]any{"version": 1, "propertyValues": map[string]any{prop: "Hijacked"}}, 404)
|
||||||
|
bob.request("POST", "/api/collections/"+cid+"/share", map[string]any{"userIds": []string{carolID}}, 404)
|
||||||
|
bob.request("DELETE", "/api/collections/"+cid+"/share/"+bobID, nil, 404)
|
||||||
|
|
||||||
|
// Revocation removes collection and bookmark access immediately.
|
||||||
|
alice.request("DELETE", "/api/collections/"+cid+"/share/"+bobID, nil, 200)
|
||||||
|
bob.request("GET", "/api/collections/"+cid, nil, 404)
|
||||||
|
bob.request("GET", "/api/collections/"+cid+"/items", nil, 404)
|
||||||
|
bob.request("GET", "/api/bookmarks/"+bid, nil, 404)
|
||||||
|
|
||||||
|
// Removing an item also removes the implicit bookmark grant.
|
||||||
|
alice.request("POST", "/api/collections/"+cid+"/share", map[string]any{"userIds": []string{bobID}}, 200)
|
||||||
|
bob.request("GET", "/api/bookmarks/"+bid, nil, 200)
|
||||||
|
alice.request("DELETE", "/api/collections/"+cid+"/items/"+items[0]["id"].(string), nil, 200)
|
||||||
|
bob.request("GET", "/api/bookmarks/"+bid, nil, 404)
|
||||||
|
|
||||||
|
// Transitive leaks are refused: Carol can see Alice's bookmark only because
|
||||||
|
// Alice shared it with Carol. Carol's collection share must not pass that
|
||||||
|
// grant on to Bob.
|
||||||
|
thirdParty := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/third-party", "title": "Alice's private link"}, 201)["id"].(string)
|
||||||
|
alice.request("POST", "/api/bookmarks/"+thirdParty+"/share", map[string]any{"userIds": []string{carolID}}, 200)
|
||||||
|
carol.request("GET", "/api/bookmarks/"+thirdParty, nil, 200)
|
||||||
|
carolCol := carol.request("POST", "/api/collections", map[string]any{"name": "Carol's project"}, 201)["id"].(string)
|
||||||
|
carol.request("POST", "/api/collections/"+carolCol+"/items", map[string]any{"bookmarkId": thirdParty}, 201)
|
||||||
|
carol.request("POST", "/api/collections/"+carolCol+"/share", map[string]any{"userIds": []string{bobID}}, 200)
|
||||||
|
bob.request("GET", "/api/collections/"+carolCol, nil, 200)
|
||||||
|
if leakedItems := bob.getJSON("/api/collections/"+carolCol+"/items", 200); len(leakedItems) != 0 {
|
||||||
|
t.Fatalf("collection share leaked a third-party bookmark: %v", leakedItems)
|
||||||
|
}
|
||||||
|
bob.request("GET", "/api/bookmarks/"+thirdParty, nil, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStructuredExport(t *testing.T) {
|
||||||
|
srv := testServer(t)
|
||||||
|
alice := newAPIUser(t, srv, "trust-export@computer.test")
|
||||||
|
|
||||||
|
tag := alice.request("POST", "/api/tags", map[string]any{"name": "Export tag"}, 201)["id"].(string)
|
||||||
|
bm := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/export", "title": "Exported bookmark", "tagIds": []string{tag}}, 201)["id"].(string)
|
||||||
|
page := alice.request("POST", "/api/pages", map[string]any{"title": "Exported page", "content": "exported body"}, 201)["id"].(string)
|
||||||
|
col := alice.request("POST", "/api/collections", map[string]any{"name": "Exported collection"}, 201)["id"].(string)
|
||||||
|
alice.request("POST", "/api/collections/"+col+"/items", map[string]any{"bookmarkId": bm}, 201)
|
||||||
|
|
||||||
|
v := alice.request("GET", "/api/export", nil, 200)
|
||||||
|
if v["format"] != "computer-export" || v["version"].(float64) != 1 {
|
||||||
|
t.Fatalf("export envelope: %v", v)
|
||||||
|
}
|
||||||
|
if len(v["bookmarks"].([]any)) != 1 || len(v["pages"].([]any)) != 1 || len(v["tags"].([]any)) != 1 || len(v["collections"].([]any)) != 1 {
|
||||||
|
t.Fatalf("export sections: %v", v)
|
||||||
|
}
|
||||||
|
exPage := v["pages"].([]any)[0].(map[string]any)
|
||||||
|
if exPage["content"] != "exported body" || len(exPage["revisions"].([]any)) == 0 {
|
||||||
|
t.Fatalf("export page content/history: %v", exPage)
|
||||||
|
}
|
||||||
|
exCol := v["collections"].([]any)[0].(map[string]any)
|
||||||
|
if len(exCol["items"].([]any)) != 1 {
|
||||||
|
t.Fatalf("export collection items: %v", exCol)
|
||||||
|
}
|
||||||
|
if _, ok := v["user"].(map[string]any)["id"]; !ok {
|
||||||
|
t.Fatalf("export user identity: %v", v["user"])
|
||||||
|
}
|
||||||
|
if page == "" {
|
||||||
|
t.Fatal("page id missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,7 +176,7 @@ export function BookmarksClient({ initialSearch = null, initialOpen = null }: {
|
|||||||
<div className="flex gap-1" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
<div className="flex gap-1" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||||
<a href={b.url} target="_blank" rel="noopener noreferrer"><ExternalLink className="h-4 w-4 text-muted-foreground hover:text-primary" /></a>
|
<a href={b.url} target="_blank" rel="noopener noreferrer"><ExternalLink className="h-4 w-4 text-muted-foreground hover:text-primary" /></a>
|
||||||
{tab === 'mine' && (
|
{tab === 'mine' && (
|
||||||
<button onClick={async () => { if (!confirm('Delete?')) return; try { const res = await fetch(`/api/bookmarks/${b.id}`, { method: 'DELETE' }); if (res.ok) { toast.success('Deleted'); load() } else toast.error('Could not delete bookmark') } catch { toast.error('Could not delete bookmark') } }}><Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" /></button>
|
<button onClick={async () => { if (!confirm('Move this bookmark to the trash? It can be restored.')) return; try { const res = await fetch(`/api/bookmarks/${b.id}`, { method: 'DELETE' }); if (res.ok) { toast.success('Moved to trash'); load() } else toast.error('Could not move bookmark to trash') } catch { toast.error('Could not move bookmark to trash') } }}><Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" /></button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
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 { toast } from 'sonner'
|
||||||
import type { Bookmark } from '@/components/app-shell'
|
import type { Bookmark } from '@/components/app-shell'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -40,9 +40,13 @@ type CollectionItem = {
|
|||||||
propertyValues: Record<string, unknown>; position: number; version: number
|
propertyValues: Record<string, unknown>; position: number; version: number
|
||||||
addedAt: string; updatedAt: string; bookmark: Bookmark
|
addedAt: string; updatedAt: string; bookmark: Bookmark
|
||||||
}
|
}
|
||||||
|
type CollectionShare = { userId: string; name: string | null; email: string | null; createdAt?: string }
|
||||||
type CollectionSummary = {
|
type CollectionSummary = {
|
||||||
id: string; name: string; description: string | null; icon: string | null
|
id: string; name: string; description: string | null; icon: string | null
|
||||||
version: number; createdAt: string; updatedAt: string
|
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 }
|
_count: { items: number; properties: number; views: number }
|
||||||
}
|
}
|
||||||
type CollectionDetail = CollectionSummary & { properties: CollectionProperty[]; views: CollectionView[] }
|
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) : '')
|
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
|
// Per-type inline value editor. Saves immediately on commit gestures
|
||||||
// (change/blur/Enter); failures toast and the prop value re-renders the
|
// (change/blur/Enter); failures toast and the prop value re-renders the
|
||||||
// server state, so a rejected edit never sticks locally.
|
// server state, so a rejected edit never sticks locally. Shared collections
|
||||||
function ValueEditor({ prop, value, onSave }: { prop: CollectionProperty; value: unknown; onSave: (v: unknown) => Promise<void> }) {
|
// are read-only: the same cell renders plain text.
|
||||||
|
function ValueEditor({ prop, value, onSave, readOnly = false }: { prop: CollectionProperty; value: unknown; onSave: (v: unknown) => Promise<void>; readOnly?: boolean }) {
|
||||||
const [text, setText] = useState(() => asText(value))
|
const [text, setText] = useState(() => asText(value))
|
||||||
const [seen, setSeen] = useState(value)
|
const [seen, setSeen] = useState(value)
|
||||||
// Adjusting state when a prop changes: the React-documented render-time
|
// 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))
|
setText(asText(value))
|
||||||
}
|
}
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
if (readOnly) return <span className="text-sm text-muted-foreground">{plainValue(prop, value)}</span>
|
||||||
const commitText = async () => {
|
const commitText = async () => {
|
||||||
const next = text.trim()
|
const next = text.trim()
|
||||||
const current = asText(value)
|
const current = asText(value)
|
||||||
@@ -220,6 +242,9 @@ export function CollectionsClient() {
|
|||||||
const [fProp, setFProp] = useState('')
|
const [fProp, setFProp] = useState('')
|
||||||
const [fOp, setFOp] = useState('eq')
|
const [fOp, setFOp] = useState('eq')
|
||||||
const [fValue, setFValue] = useState('')
|
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 () => {
|
const loadCollections = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -293,6 +318,41 @@ export function CollectionsClient() {
|
|||||||
|
|
||||||
const activeView = detail?.views.find((v) => v.id === viewId) ?? null
|
const activeView = detail?.views.find((v) => v.id === viewId) ?? null
|
||||||
const groupProp = activeView?.groupByPropertyId ? detail?.properties.find((p) => p.id === activeView.groupByPropertyId) : 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 createCollection = async () => {
|
||||||
const name = newName.trim()
|
const name = newName.trim()
|
||||||
@@ -307,7 +367,7 @@ export function CollectionsClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saveName = async () => {
|
const saveName = async () => {
|
||||||
if (!detail) return
|
if (!detail || detail.shared) return
|
||||||
const name = nameDraft.trim()
|
const name = nameDraft.trim()
|
||||||
if (!name || name === detail.name) { setNameDraft(detail.name); return }
|
if (!name || name === detail.name) { setNameDraft(detail.name); return }
|
||||||
const res = await patch(`/api/collections/${detail.id}`, { version: detail.version, name })
|
const res = await patch(`/api/collections/${detail.id}`, { version: detail.version, name })
|
||||||
@@ -318,7 +378,7 @@ export function CollectionsClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deleteCollection = async () => {
|
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' })
|
const res = await fetch(`/api/collections/${detail.id}`, { method: 'DELETE' })
|
||||||
if (await mutate(res, loadCollections, 'Collection deleted')) {
|
if (await mutate(res, loadCollections, 'Collection deleted')) {
|
||||||
setActiveId(null)
|
setActiveId(null)
|
||||||
@@ -494,9 +554,9 @@ export function CollectionsClient() {
|
|||||||
<TableHead key={p.id} className="min-w-[11rem]">
|
<TableHead key={p.id} className="min-w-[11rem]">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
{p.name}
|
{p.name}
|
||||||
<button type="button" aria-label={`Delete property ${p.name}`} className="text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100" onClick={() => deleteProperty(p)}>
|
{!readOnly && <button type="button" aria-label={`Delete property ${p.name}`} className="text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100" onClick={() => deleteProperty(p)}>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</button>
|
</button>}
|
||||||
</span>
|
</span>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
@@ -508,12 +568,12 @@ export function CollectionsClient() {
|
|||||||
<TableRow key={item.id} className="group">
|
<TableRow key={item.id} className="group">
|
||||||
<TableCell><BookmarkCell bookmark={item.bookmark} /></TableCell>
|
<TableCell><BookmarkCell bookmark={item.bookmark} /></TableCell>
|
||||||
{(detail?.properties ?? []).map((p) => (
|
{(detail?.properties ?? []).map((p) => (
|
||||||
<TableCell key={p.id}><ValueEditor prop={p} value={item.propertyValues?.[p.id]} onSave={(v) => saveValue(item, p.id, v)} /></TableCell>
|
<TableCell key={p.id}><ValueEditor prop={p} value={item.propertyValues?.[p.id]} onSave={(v) => saveValue(item, p.id, v)} readOnly={readOnly} /></TableCell>
|
||||||
))}
|
))}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<button type="button" aria-label="Remove from collection" className="text-muted-foreground hover:text-destructive" onClick={() => removeItem(item)}>
|
{!readOnly && <button type="button" aria-label="Remove from collection" className="text-muted-foreground hover:text-destructive" onClick={() => removeItem(item)}>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</button>}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -535,7 +595,7 @@ export function CollectionsClient() {
|
|||||||
<div className="rounded-lg border border-dashed border-border p-8 text-center">
|
<div className="rounded-lg border border-dashed border-border p-8 text-center">
|
||||||
<Kanban className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
|
<Kanban className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
|
||||||
<p className="text-sm text-muted-foreground">Board views group by a select property.</p>
|
<p className="text-sm text-muted-foreground">Board views group by a select property.</p>
|
||||||
<div className="mx-auto mt-3 w-56">
|
{!readOnly && <div className="mx-auto mt-3 w-56">
|
||||||
<Select
|
<Select
|
||||||
value={NONE}
|
value={NONE}
|
||||||
onValueChange={(id) => { if (id !== NONE) patchView({ groupByPropertyId: id }) }}
|
onValueChange={(id) => { if (id !== NONE) patchView({ groupByPropertyId: id }) }}
|
||||||
@@ -547,7 +607,7 @@ export function CollectionsClient() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -602,7 +662,7 @@ export function CollectionsClient() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-1">
|
<div className="flex items-start gap-1">
|
||||||
<span
|
{!readOnly && <span
|
||||||
data-drag-handle
|
data-drag-handle
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
@@ -619,25 +679,29 @@ export function CollectionsClient() {
|
|||||||
aria-label={`Drag ${item.bookmark.title ?? 'bookmark'}`}
|
aria-label={`Drag ${item.bookmark.title ?? 'bookmark'}`}
|
||||||
>
|
>
|
||||||
<GripVertical className="h-3.5 w-3.5" />
|
<GripVertical className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<BookmarkCell bookmark={item.bookmark} />
|
<BookmarkCell bookmark={item.bookmark} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 flex items-center justify-between gap-1">
|
<div className="mt-1.5 flex items-center justify-between gap-1">
|
||||||
<Select
|
{readOnly ? (
|
||||||
value={typeof item.propertyValues?.[groupProp.id] === 'string' ? (item.propertyValues[groupProp.id] as string) : NONE}
|
<span className="text-xs text-muted-foreground">{plainValue(groupProp, item.propertyValues?.[groupProp.id])}</span>
|
||||||
onValueChange={(id) => { void moveItemToGroup(item, id === NONE ? null : id) }}
|
) : (
|
||||||
>
|
<Select
|
||||||
<SelectTrigger className="h-7 w-28 text-xs" aria-label={`Move ${item.bookmark.title ?? 'card'}`}><SelectValue /></SelectTrigger>
|
value={typeof item.propertyValues?.[groupProp.id] === 'string' ? (item.propertyValues[groupProp.id] as string) : NONE}
|
||||||
<SelectContent>
|
onValueChange={(id) => { void moveItemToGroup(item, id === NONE ? null : id) }}
|
||||||
<SelectItem value={NONE}>—</SelectItem>
|
>
|
||||||
{options.map((o) => <SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>)}
|
<SelectTrigger className="h-7 w-28 text-xs" aria-label={`Move ${item.bookmark.title ?? 'card'}`}><SelectValue /></SelectTrigger>
|
||||||
</SelectContent>
|
<SelectContent>
|
||||||
</Select>
|
<SelectItem value={NONE}>—</SelectItem>
|
||||||
<button type="button" aria-label="Remove from collection" className="text-muted-foreground hover:text-destructive" onClick={() => removeItem(item)}>
|
{options.map((o) => <SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{!readOnly && <button type="button" aria-label="Remove from collection" className="text-muted-foreground hover:text-destructive" onClick={() => removeItem(item)}>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -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')}
|
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')}
|
||||||
>
|
>
|
||||||
<Layers className="h-3.5 w-3.5 shrink-0" />
|
<Layers className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="min-w-0 flex-1 truncate">{c.name}</span>
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
{c.name}
|
||||||
|
{c.shared && <span className="ml-1 text-[10px] uppercase tracking-wide text-muted-foreground">shared</span>}
|
||||||
|
</span>
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">{c._count.items}</span>
|
<span className="shrink-0 text-xs text-muted-foreground">{c._count.items}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -699,13 +766,25 @@ export function CollectionsClient() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
<Input value={nameDraft} onChange={(e) => 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 ? (
|
||||||
|
<span className="w-72 truncate font-display text-2xl font-bold tracking-tight">{detail.name}</span>
|
||||||
|
) : (
|
||||||
|
<Input value={nameDraft} onChange={(e) => 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" />
|
||||||
|
)}
|
||||||
<span className="text-xs text-muted-foreground">{detail._count?.items ?? 0} item{(detail._count?.items ?? 0) === 1 ? '' : 's'}</span>
|
<span className="text-xs text-muted-foreground">{detail._count?.items ?? 0} item{(detail._count?.items ?? 0) === 1 ? '' : 's'}</span>
|
||||||
<div className="ml-auto flex items-center gap-1.5">
|
{readOnly ? (
|
||||||
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}><Plus className="mr-1 h-4 w-4" />Add bookmarks</Button>
|
<Badge variant="secondary" className="gap-1">
|
||||||
<Button size="sm" variant="outline" onClick={() => setPropOpen(true)}><Plus className="mr-1 h-4 w-4" />Property</Button>
|
<Share2 className="h-3 w-3" />
|
||||||
<Button size="sm" variant="ghost" onClick={deleteCollection} aria-label="Delete collection" title="Delete collection"><Trash2 className="h-4 w-4 text-destructive" /></Button>
|
Shared by {detail.owner?.name || detail.owner?.email || 'another user'} · read-only
|
||||||
</div>
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}><Plus className="mr-1 h-4 w-4" />Add bookmarks</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setPropOpen(true)}><Plus className="mr-1 h-4 w-4" />Property</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { setShareOpen(true); setShareResults([]); setShareQuery('') }}><Share2 className="mr-1 h-4 w-4" />Share</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={deleteCollection} aria-label="Move collection to trash" title="Move collection to trash"><Trash2 className="h-4 w-4 text-destructive" /></Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* View tabs */}
|
{/* View tabs */}
|
||||||
@@ -722,8 +801,8 @@ export function CollectionsClient() {
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => setViewOpen(true)} aria-label="Add view"><Plus className="h-4 w-4" /></Button>
|
{!readOnly && <Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => setViewOpen(true)} aria-label="Add view"><Plus className="h-4 w-4" /></Button>}
|
||||||
{activeView && (
|
{activeView && !readOnly && (
|
||||||
<div className="ml-auto flex items-center gap-1.5">
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
<Popover open={filterOpen} onOpenChange={(o) => { setFilterOpen(o); if (o) { setFProp(detail.properties[0]?.id ?? ''); setFOp(FILTER_OPS[detail.properties[0]?.type ?? 'TEXT']?.[0] ?? 'eq') } }}>
|
<Popover open={filterOpen} onOpenChange={(o) => { setFilterOpen(o); if (o) { setFProp(detail.properties[0]?.id ?? ''); setFOp(FILTER_OPS[detail.properties[0]?.type ?? 'TEXT']?.[0] ?? 'eq') } }}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -774,7 +853,7 @@ export function CollectionsClient() {
|
|||||||
{filterClauses.map((c, i) => (
|
{filterClauses.map((c, i) => (
|
||||||
<Badge key={i} variant="secondary" className="gap-1">
|
<Badge key={i} variant="secondary" className="gap-1">
|
||||||
{clauseLabel(c)}
|
{clauseLabel(c)}
|
||||||
<button type="button" aria-label="Remove filter" onClick={() => setClause(filterClauses.filter((_, j) => j !== i))}><X className="h-3 w-3" /></button>
|
{!readOnly && <button type="button" aria-label="Remove filter" onClick={() => setClause(filterClauses.filter((_, j) => j !== i))}><X className="h-3 w-3" /></button>}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -878,6 +957,48 @@ export function CollectionsClient() {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Share dialog */}
|
||||||
|
<Dialog open={shareOpen} onOpenChange={(o) => { setShareOpen(o); if (!o) { setShareQuery(''); setShareResults([]) } }}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader><DialogTitle>Share collection</DialogTitle></DialogHeader>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
{detail && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(detail.sharedWith ?? []).map((s) => (
|
||||||
|
<div key={s.userId} className="flex items-center gap-2 rounded-md border px-2 py-1.5 text-sm">
|
||||||
|
<span className="min-w-0 flex-1 truncate">{s.name || s.email || s.userId}</span>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => void unshareCollection(s.userId)}>Revoke</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(detail.sharedWith?.length ?? 0) === 0 && <p className="py-2 text-xs text-muted-foreground">Not shared with anyone yet.</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Input autoFocus value={shareQuery} onChange={(e) => setShareQuery(e.target.value)} placeholder="Search people by name or email" aria-label="Search people to share with" />
|
||||||
|
<div className="max-h-56 space-y-1 overflow-y-auto">
|
||||||
|
{shareResults.map((u) => {
|
||||||
|
const already = detail?.sharedWith?.some((s) => s.userId === u.id)
|
||||||
|
return (
|
||||||
|
<div key={u.id} className="flex items-center gap-2 rounded-md border px-2 py-1.5 text-sm">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate">{u.name || u.email}</p>
|
||||||
|
{u.name && <p className="truncate text-xs text-muted-foreground">{u.email}</p>}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" disabled={already} onClick={() => void shareCollection(u.id)}>
|
||||||
|
{already ? 'Shared' : 'Share'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{shareQuery.trim().length >= 2 && shareResults.length === 0 && <p className="py-4 text-center text-xs text-muted-foreground">No matching people</p>}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import remarkGfm from 'remark-gfm'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
ChevronLeft, ChevronRight, Code, CornerUpLeft, Download, Eye, FileText, GripVertical,
|
ChevronLeft, ChevronRight, Code, CornerUpLeft, Download, Eye, FileText, GripVertical,
|
||||||
Heading1, Heading2, Heading3, Layers, Link2, List, ListOrdered, ListTodo, Minus, Pencil, Pin, PinOff,
|
Heading1, Heading2, Heading3, History, Layers, Link2, List, ListOrdered, ListTodo, Minus, Pencil,
|
||||||
Plus, Quote, Search, Trash2, Type, X,
|
Pin, PinOff, Plus, Quote, RotateCcw, Search, Trash2, Type, X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { PageAutosave, PageConflictError, type PageSaveState } from '@/lib/page-autosave'
|
import { PageAutosave, PageConflictError, type PageSaveState } from '@/lib/page-autosave'
|
||||||
@@ -65,6 +66,8 @@ type PageDetail = PageSummary & {
|
|||||||
links: { id: string; title: string }[]
|
links: { id: string; title: string }[]
|
||||||
embeds: PageEmbed[]
|
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 notify = notifyPagesChanged
|
||||||
const host = (u: string) => { try { return new URL(u).hostname.replace(/^www\./, '') } catch { return u } }
|
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 [pageLinkQuery, setPageLinkQuery] = useState('')
|
||||||
const [embedOpen, setEmbedOpen] = useState(false)
|
const [embedOpen, setEmbedOpen] = useState(false)
|
||||||
const [collections, setCollections] = useState<{ id: string; name: string; icon: string | null; views?: { id: string; name: string }[] }[]>([])
|
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<PageRevisionSummary[]>([])
|
||||||
|
const [revision, setRevision] = useState<PageRevisionDetail | null>(null)
|
||||||
|
const [historyBusy, setHistoryBusy] = useState<string | null>(null)
|
||||||
const [slash, setSlash] = useState<{ query: string; start: number } | null>(null)
|
const [slash, setSlash] = useState<{ query: string; start: number } | null>(null)
|
||||||
const [slashIndex, setSlashIndex] = useState(0)
|
const [slashIndex, setSlashIndex] = useState(0)
|
||||||
const [tabDrop, setTabDrop] = useState<{ id: string; after: boolean } | null>(null)
|
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.
|
// Show the block menu only while the caret sits on a bare "/token" line.
|
||||||
const updateSlash = (text: string, caret: number) => {
|
const updateSlash = (text: string, caret: number) => {
|
||||||
const lineStart = text.lastIndexOf('\n', caret - 1) + 1
|
const lineStart = text.lastIndexOf('\n', caret - 1) + 1
|
||||||
@@ -504,7 +565,7 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const remove = async () => {
|
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
|
if (!await autosave.flush(page.id)) return
|
||||||
const res = await fetch(`/api/pages/${page.id}`, { method: 'DELETE' })
|
const res = await fetch(`/api/pages/${page.id}`, { method: 'DELETE' })
|
||||||
if (!res.ok) { toast.error('Could not delete page'); return }
|
if (!res.ok) { toast.error('Could not delete page'); return }
|
||||||
@@ -694,7 +755,8 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
|
|||||||
<Button variant="ghost" size="icon-sm" title="Move tab left" onClick={() => move(-1)}><ChevronLeft className="h-4 w-4" /></Button>
|
<Button variant="ghost" size="icon-sm" title="Move tab left" onClick={() => move(-1)}><ChevronLeft className="h-4 w-4" /></Button>
|
||||||
<Button variant="ghost" size="icon-sm" title="Move tab right" onClick={() => move(1)}><ChevronRight className="h-4 w-4" /></Button>
|
<Button variant="ghost" size="icon-sm" title="Move tab right" onClick={() => move(1)}><ChevronRight className="h-4 w-4" /></Button>
|
||||||
<Button variant="ghost" size="icon-sm" title="Download .md" onClick={download}><Download className="h-4 w-4" /></Button>
|
<Button variant="ghost" size="icon-sm" title="Download .md" onClick={download}><Download className="h-4 w-4" /></Button>
|
||||||
<Button variant="ghost" size="icon-sm" title="Delete page" onClick={remove}><Trash2 className="h-4 w-4 text-destructive" /></Button>
|
<Button variant="ghost" size="icon-sm" title="Version history" onClick={() => void openHistory()}><History className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon-sm" title="Move to trash" onClick={remove}><Trash2 className="h-4 w-4 text-destructive" /></Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2 text-sm">
|
<div className="mb-4 flex flex-wrap items-center gap-2 text-sm">
|
||||||
@@ -934,6 +996,49 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Dialog open={historyOpen} onOpenChange={(open) => { setHistoryOpen(open); if (!open) setRevision(null) }}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader><DialogTitle>Version history</DialogTitle></DialogHeader>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Every saved content version is kept. Restoring writes a new version, so nothing in history is ever rewritten.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-3 md:grid-cols-[15rem_1fr]">
|
||||||
|
<div className="max-h-[24rem] space-y-1 overflow-y-auto rounded-md border p-1">
|
||||||
|
{revisions.map((rev) => (
|
||||||
|
<div key={rev.id} className={cn('rounded-md px-2 py-1.5', revision?.id === rev.id ? 'bg-accent' : 'hover:bg-muted')}>
|
||||||
|
<button type="button" className="flex w-full items-center justify-between gap-2 text-left" onClick={() => void viewRevision(rev.id)}>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-medium">Version {rev.version}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{new Date(rev.createdAt).toLocaleString()}</span>
|
||||||
|
</span>
|
||||||
|
{revisions[0]?.id === rev.id && <span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">Current</span>}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={historyBusy === rev.id}
|
||||||
|
onClick={() => void restoreRevision(rev)}
|
||||||
|
className="mt-1 flex items-center gap-1 text-xs text-primary hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" /> Restore
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{revisions.length === 0 && <p className="px-2 py-6 text-center text-xs text-muted-foreground">No saved versions yet.</p>}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[24rem] overflow-y-auto rounded-md border bg-muted/20 p-3">
|
||||||
|
{revision ? (
|
||||||
|
<>
|
||||||
|
<p className="mb-2 text-sm font-medium">{revision.title}</p>
|
||||||
|
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-muted-foreground">{revision.content || '(empty page)'}</pre>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="py-10 text-center text-sm text-muted-foreground">Select a version to preview it.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<TrashEntry[]>([])
|
||||||
|
const [busy, setBusy] = useState<string | null>(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 (
|
||||||
|
<div className="mx-auto w-full max-w-3xl px-6 py-8">
|
||||||
|
<div className="mb-6 flex flex-wrap items-center gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h1 className="font-display text-2xl font-bold tracking-tight">Trash</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => void exportData()} disabled={exporting}>
|
||||||
|
<Download className="mr-1 h-4 w-4" />{exporting ? 'Exporting…' : 'Export all data'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed p-12 text-center">
|
||||||
|
<Trash2 className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">The trash is empty.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const Icon = TYPE_ICON[entry.type]
|
||||||
|
return (
|
||||||
|
<div key={entry.id} className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-3 py-2.5">
|
||||||
|
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{entry.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(entry.deletedAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="capitalize">{entry.type}</Badge>
|
||||||
|
<Button size="sm" variant="outline" disabled={busy === entry.id} onClick={() => void restore(entry)}>
|
||||||
|
<RotateCcw className="mr-1 h-3.5 w-3.5" />Restore
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" disabled={busy === entry.id} onClick={() => void purge(entry)} aria-label={`Delete ${entry.title} permanently`}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { TrashClient } from './client'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default function TrashPage() {
|
||||||
|
return <TrashClient />
|
||||||
|
}
|
||||||
@@ -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])
|
}, [bookmark, notes, title, url, description, tagIds, columnId, contactId, kanban, dueDate, alertAt, isPublic, imapEnabled, imapQuery, onUpdate])
|
||||||
|
|
||||||
const handleDelete = useCallback(async () => {
|
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' })
|
const response = await fetch(`/api/bookmarks/${bookmark.id}`, { method: 'DELETE' })
|
||||||
if (!response.ok) { toast.error('Could not delete bookmark'); return }
|
if (!response.ok) { toast.error('Could not move bookmark to trash'); return }
|
||||||
toast.success('Bookmark deleted')
|
toast.success('Bookmark moved to trash')
|
||||||
onClose()
|
onClose()
|
||||||
onUpdate()
|
onUpdate()
|
||||||
}, [bookmark, onClose, onUpdate])
|
}, [bookmark, onClose, onUpdate])
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const viewItems = [
|
|||||||
{ href: '/kanban', label: 'Kanban', icon: Kanban },
|
{ href: '/kanban', label: 'Kanban', icon: Kanban },
|
||||||
{ href: '/collections', label: 'Collections', icon: Layers },
|
{ href: '/collections', label: 'Collections', icon: Layers },
|
||||||
{ href: '/contacts', label: 'Contacts', icon: Users },
|
{ href: '/contacts', label: 'Contacts', icon: Users },
|
||||||
|
{ href: '/trash', label: 'Trash', icon: Trash2 },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Section collapse state persists per browser. useSyncExternalStore keeps it
|
// 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) => {
|
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 {
|
try {
|
||||||
const res = await fetch(`/api/pages/${p.id}`, { method: 'DELETE' })
|
const res = await fetch(`/api/pages/${p.id}`, { method: 'DELETE' })
|
||||||
if (!res.ok) { toast.error('Could not delete page'); return }
|
if (!res.ok) { toast.error('Could not delete page'); return }
|
||||||
|
|||||||
@@ -96,6 +96,12 @@ Composite PK (`sourcePageId`, `targetPageId`), `createdAt`; both Cascade, with a
|
|||||||
### 2.13c `PageEmbed` (live collection view inside a page)
|
### 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:<id>|view=<viewId>}}` on every content save; only owned collections are stored. Item reads still go through the collection endpoint, so an embed displays, never publishes.
|
Composite PK (`pageId`, `collectionId`), `viewId?` → CollectionView (SetNull), `position` int, `createdAt`; page and collection Cascade. Rebuilt from `{{collection:<id>|view=<viewId>}}` 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)
|
### 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).
|
`id`, `type` (e.g. `email`), `config` (JSON, default `{}`), `enabled` (default true), `lastCheckedAt?`, `lastStatus?`, `bookmarkId` → Bookmark (Cascade), `ownerId` → User (Cascade).
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user