diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-11-17 01:12:41 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-11-17 01:12:41 +0000 |
| commit | 88c73e212c4510ce41ad8c6557fa7d5c8f72d199 (patch) | |
| tree | 11f47349b8c34de1bf541febd9ba48cc44aa305a /packages/trpc/models/bookmarks.ts | |
| parent | cc8fee0d28d87299ee9a3ad11dcb4ae5a7b86c15 (diff) | |
| download | karakeep-88c73e212c4510ce41ad8c6557fa7d5c8f72d199.tar.zst | |
feat: Add collaborative lists (#2146)
* feat: Add collaborative lists backend implementation
This commit implements the core backend functionality for collaborative
lists, allowing multiple users to share and interact with bookmark lists.
Database changes:
- Add listCollaborators table to track users with access to lists and
their roles (viewer/editor)
- Add addedBy field to bookmarksInLists to track who added bookmarks
- Add relations for collaborative list functionality
Access control updates:
- Update List model to support role-based access (owner/editor/viewer)
- Add methods to check and enforce permissions for list operations
- Update Bookmark model to allow access through collaborative lists
- Modify bookmark queries to include bookmarks from collaborative lists
List collaboration features:
- Add/remove/update collaborators
- Get list of collaborators
- Get lists shared with current user
- Only manual lists can have collaborators
tRPC procedures:
- addCollaborator: Add a user as a collaborator to a list
- removeCollaborator: Remove a collaborator from a list
- updateCollaboratorRole: Change a collaborator's role
- getCollaborators: Get all collaborators for a list
- getSharedWithMe: Get all lists shared with the current user
- cloneBookmark: Clone a bookmark to the current user's collection
Implementation notes:
- Editors can add/remove bookmarks from the list (must own the bookmark)
- Viewers can only view bookmarks in the list
- Only the list owner can manage collaborators and list metadata
- Smart lists cannot have collaborators (only manual lists)
- Users cannot edit bookmarks they don't own, even in shared lists
* feat: Add collaborative lists frontend UI
This commit implements the frontend user interface for collaborative lists,
allowing users to view shared bookmarks and manage list collaborators.
New pages:
- /dashboard/shared: Shows bookmarks from lists shared with the user
- Displays bookmarks from all collaborative lists
- Uses SharedBookmarks component
- Shows empty state when no lists are shared
Navigation:
- Added "Shared with you" link to sidebar with Users icon
- Positioned after "Home" in main navigation
- Available in both desktop and mobile sidebar
Collaborator management:
- ManageCollaboratorsModal component for managing list collaborators
- Add collaborators by user ID with viewer/editor role
- View current collaborators with their roles
- Update collaborator roles inline
- Remove collaborators
- Shows empty state when no collaborators
- Integrated into ListOptions dropdown menu
- Accessible via "Manage Collaborators" menu item
Components created:
- SharedBookmarks.tsx: Server component fetching shared lists/bookmarks
- ManageCollaboratorsModal.tsx: Client component with tRPC mutations
- /dashboard/shared/page.tsx: Route for shared bookmarks page
UI features:
- Role selector for viewer/editor permissions
- Real-time collaborator list updates
- Toast notifications for success/error states
- Loading states for async operations
- Responsive design matching existing UI patterns
Implementation notes:
- Uses existing tRPC endpoints (getSharedWithMe, getCollaborators, etc.)
- Follows established modal patterns from ShareListModal
- Integrates seamlessly with existing list UI
- Currently uses user ID for adding collaborators (email lookup TBD)
* fix typecheck
* add collaborator by email
* add shared list in the sidebar
* fix perm issue
* hide UI components from non list owners
* list leaving
* fix shared bookmarks showing up in homepage
* fix getBookmark access check
* e2e tests
* hide user specific fields from shared lists
* simplify bookmark perm checks
* disable editable fields in bookmark preview
* hide lists if they don't have options
* fix list ownership
* fix highlights
* move tests to trpc
* fix alignment of leave list
* make tag lists unclickable
* allow editors to remove from list
* add a badge for shared lists
* remove bookmarks of user when they're removed from a list
* fix tests
* show owner in the manage collab modal
* fix hasCollab
* drop shared with you
* i18n
* beta badge
* correctly invalidate caches on collab change
* reduce unnecessary changes
* Add ratelimits
* stop manually removing bookmarks on remove
* some fixes
* fixes
* remove unused function
* improve tests
---------
Co-authored-by: Claude <noreply@anthropic.com>
Diffstat (limited to 'packages/trpc/models/bookmarks.ts')
| -rw-r--r-- | packages/trpc/models/bookmarks.ts | 309 |
1 files changed, 298 insertions, 11 deletions
diff --git a/packages/trpc/models/bookmarks.ts b/packages/trpc/models/bookmarks.ts index c689f64d..e4bfdab2 100644 --- a/packages/trpc/models/bookmarks.ts +++ b/packages/trpc/models/bookmarks.ts @@ -15,19 +15,23 @@ import { import invariant from "tiny-invariant"; import { z } from "zod"; +import { db as DONT_USE_db } from "@karakeep/db"; import { assets, AssetTypes, bookmarkAssets, bookmarkLinks, + bookmarkLists, bookmarks, bookmarksInLists, bookmarkTags, bookmarkTexts, + listCollaborators, rssFeedImportsTable, tagsOnBookmarks, } from "@karakeep/db/schema"; -import { readAsset } from "@karakeep/shared/assetdb"; +import { SearchIndexingQueue, triggerWebhook } from "@karakeep/shared-server"; +import { deleteAsset, readAsset } from "@karakeep/shared/assetdb"; import serverConfig from "@karakeep/shared/config"; import { createSignedToken, @@ -37,6 +41,7 @@ import { zAssetSignedTokenSchema } from "@karakeep/shared/types/assets"; import { BookmarkTypes, DEFAULT_NUM_BOOKMARKS_PER_PAGE, + ZBareBookmark, ZBookmark, ZBookmarkContent, zGetBookmarksRequestSchema, @@ -54,26 +59,225 @@ import { mapDBAssetTypeToUserType } from "../lib/attachments"; import { List } from "./lists"; import { PrivacyAware } from "./privacy"; -export class Bookmark implements PrivacyAware { +async function dummyDrizzleReturnType() { + const x = await DONT_USE_db.query.bookmarks.findFirst({ + with: { + tagsOnBookmarks: { + with: { + tag: true, + }, + }, + link: true, + text: true, + asset: true, + assets: true, + }, + }); + if (!x) { + throw new Error(); + } + return x; +} + +type BookmarkQueryReturnType = Awaited< + ReturnType<typeof dummyDrizzleReturnType> +>; + +export class BareBookmark implements PrivacyAware { protected constructor( protected ctx: AuthedContext, - public bookmark: ZBookmark & { userId: string }, + private bareBookmark: ZBareBookmark, ) {} + get id() { + return this.bareBookmark.id; + } + + get createdAt() { + return this.bareBookmark.createdAt; + } + + static async bareFromId(ctx: AuthedContext, bookmarkId: string) { + const bookmark = await ctx.db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, bookmarkId), + }); + + if (!bookmark) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Bookmark not found", + }); + } + + if (!(await BareBookmark.isAllowedToAccessBookmark(ctx, bookmark))) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Bookmark not found", + }); + } + + return new BareBookmark(ctx, bookmark); + } + + protected static async isAllowedToAccessBookmark( + ctx: AuthedContext, + { id: bookmarkId, userId: bookmarkOwnerId }: { id: string; userId: string }, + ): Promise<boolean> { + if (bookmarkOwnerId == ctx.user.id) { + return true; + } + const bookmarkLists = await List.forBookmark(ctx, bookmarkId); + return bookmarkLists.some((l) => l.canUserView()); + } + + ensureOwnership() { + if (this.bareBookmark.userId != this.ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User is not allowed to access resource", + }); + } + } + ensureCanAccess(ctx: AuthedContext): void { - if (this.bookmark.userId != ctx.user.id) { + if (this.bareBookmark.userId != ctx.user.id) { throw new TRPCError({ code: "FORBIDDEN", message: "User is not allowed to access resource", }); } } +} - static fromData(ctx: AuthedContext, data: ZBookmark) { - return new Bookmark(ctx, { - ...data, - userId: ctx.user.id, +export class Bookmark extends BareBookmark { + protected constructor( + ctx: AuthedContext, + private bookmark: ZBookmark, + ) { + super(ctx, bookmark); + } + + private static async toZodSchema( + bookmark: BookmarkQueryReturnType, + includeContent: boolean, + ): Promise<ZBookmark> { + const { tagsOnBookmarks, link, text, asset, assets, ...rest } = bookmark; + + let content: ZBookmarkContent = { + type: BookmarkTypes.UNKNOWN, + }; + if (bookmark.link) { + content = { + type: BookmarkTypes.LINK, + screenshotAssetId: assets.find( + (a) => a.assetType == AssetTypes.LINK_SCREENSHOT, + )?.id, + fullPageArchiveAssetId: assets.find( + (a) => a.assetType == AssetTypes.LINK_FULL_PAGE_ARCHIVE, + )?.id, + precrawledArchiveAssetId: assets.find( + (a) => a.assetType == AssetTypes.LINK_PRECRAWLED_ARCHIVE, + )?.id, + imageAssetId: assets.find( + (a) => a.assetType == AssetTypes.LINK_BANNER_IMAGE, + )?.id, + videoAssetId: assets.find((a) => a.assetType == AssetTypes.LINK_VIDEO) + ?.id, + url: link.url, + title: link.title, + description: link.description, + imageUrl: link.imageUrl, + favicon: link.favicon, + htmlContent: includeContent + ? await Bookmark.getBookmarkHtmlContent(link, bookmark.userId) + : null, + crawledAt: link.crawledAt, + author: link.author, + publisher: link.publisher, + datePublished: link.datePublished, + dateModified: link.dateModified, + }; + } + if (bookmark.text) { + content = { + type: BookmarkTypes.TEXT, + // It's ok to include the text content as it's usually not big and is used to render the text bookmark card. + text: text.text ?? "", + sourceUrl: text.sourceUrl, + }; + } + if (bookmark.asset) { + content = { + type: BookmarkTypes.ASSET, + assetType: asset.assetType, + assetId: asset.assetId, + fileName: asset.fileName, + sourceUrl: asset.sourceUrl, + size: assets.find((a) => a.id == asset.assetId)?.size, + content: includeContent ? asset.content : null, + }; + } + + return { + tags: tagsOnBookmarks + .map((t) => ({ + attachedBy: t.attachedBy, + ...t.tag, + })) + .sort((a, b) => + a.attachedBy === "ai" ? 1 : b.attachedBy === "ai" ? -1 : 0, + ), + content, + assets: assets.map((a) => ({ + id: a.id, + assetType: mapDBAssetTypeToUserType(a.assetType), + fileName: a.fileName, + })), + ...rest, + }; + } + + static async fromId( + ctx: AuthedContext, + bookmarkId: string, + includeContent: boolean, + ) { + const bookmark = await ctx.db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, bookmarkId), + with: { + tagsOnBookmarks: { + with: { + tag: true, + }, + }, + link: true, + text: true, + asset: true, + assets: true, + }, }); + + if (!bookmark) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Bookmark not found", + }); + } + + if (!(await BareBookmark.isAllowedToAccessBookmark(ctx, bookmark))) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Bookmark not found", + }); + } + return Bookmark.fromData( + ctx, + await Bookmark.toZodSchema(bookmark, includeContent), + ); + } + + static fromData(ctx: AuthedContext, data: ZBookmark) { + return new Bookmark(ctx, data); } static async loadMulti( @@ -103,7 +307,42 @@ export class Bookmark implements PrivacyAware { .from(bookmarks) .where( and( - eq(bookmarks.userId, ctx.user.id), + // Access control: User can access bookmarks if they either: + // 1. Own the bookmark (always) + // 2. The bookmark is in a specific shared list being viewed + // When listId is specified, we need special handling to show all bookmarks in that list + input.listId !== undefined + ? // If querying a specific list, check if user has access to that list + or( + eq(bookmarks.userId, ctx.user.id), + // User is the owner of the list being queried + exists( + ctx.db + .select() + .from(bookmarkLists) + .where( + and( + eq(bookmarkLists.id, input.listId), + eq(bookmarkLists.userId, ctx.user.id), + ), + ), + ), + // User is a collaborator on the list being queried + exists( + ctx.db + .select() + .from(listCollaborators) + .where( + and( + eq(listCollaborators.listId, input.listId), + eq(listCollaborators.userId, ctx.user.id), + ), + ), + ), + ) + : // If not querying a specific list, only show bookmarks the user owns + // Shared bookmarks should only appear when viewing the specific shared list + eq(bookmarks.userId, ctx.user.id), input.archived !== undefined ? eq(bookmarks.archived, input.archived) : undefined, @@ -317,7 +556,7 @@ export class Bookmark implements PrivacyAware { ) { try { const asset = await readAsset({ - userId: ctx.user.id, + userId: bookmark.userId, assetId: bookmark.content.contentAssetId, }); bookmark.content.htmlContent = asset.asset.toString("utf8"); @@ -365,7 +604,18 @@ export class Bookmark implements PrivacyAware { } asZBookmark(): ZBookmark { - return this.bookmark; + if (this.bookmark.userId === this.ctx.user.id) { + return this.bookmark; + } + + // Collaborators shouldn't see owner-specific state such as favourites, + // archived flag, or personal notes. + return { + ...this.bookmark, + archived: false, + favourited: false, + note: null, + }; } asPublicBookmark(): ZPublicBookmark { @@ -512,4 +762,41 @@ export class Bookmark implements PrivacyAware { } return htmlToPlainText(content); } + + private async cleanupAssets() { + const assetIds: Set<string> = new Set<string>( + this.bookmark.assets.map((a) => a.id), + ); + // Todo: Remove when the bookmark asset is also in the assets table + if (this.bookmark.content.type == BookmarkTypes.ASSET) { + assetIds.add(this.bookmark.content.assetId); + } + await Promise.all( + Array.from(assetIds).map((assetId) => + deleteAsset({ userId: this.bookmark.userId, assetId }), + ), + ); + } + + async delete() { + this.ensureOwnership(); + const deleted = await this.ctx.db + .delete(bookmarks) + .where( + and( + eq(bookmarks.userId, this.ctx.user.id), + eq(bookmarks.id, this.bookmark.id), + ), + ); + + await SearchIndexingQueue.enqueue({ + bookmarkId: this.bookmark.id, + type: "delete", + }); + + await triggerWebhook(this.bookmark.id, "deleted", this.ctx.user.id); + if (deleted.changes > 0) { + await this.cleanupAssets(); + } + } } |
