aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/models/bookmarks.ts
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-11-17 01:12:41 +0000
committerGitHub <noreply@github.com>2025-11-17 01:12:41 +0000
commit88c73e212c4510ce41ad8c6557fa7d5c8f72d199 (patch)
tree11f47349b8c34de1bf541febd9ba48cc44aa305a /packages/trpc/models/bookmarks.ts
parentcc8fee0d28d87299ee9a3ad11dcb4ae5a7b86c15 (diff)
downloadkarakeep-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.ts309
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();
+ }
+ }
}