diff options
Diffstat (limited to '')
| -rw-r--r-- | packages/trpc/models/bookmarks.ts | 406 |
1 files changed, 276 insertions, 130 deletions
diff --git a/packages/trpc/models/bookmarks.ts b/packages/trpc/models/bookmarks.ts index 07fa8693..c8cd1f00 100644 --- a/packages/trpc/models/bookmarks.ts +++ b/packages/trpc/models/bookmarks.ts @@ -4,13 +4,14 @@ import { asc, desc, eq, - exists, + getTableColumns, gt, gte, inArray, lt, lte, or, + SQL, } from "drizzle-orm"; import invariant from "tiny-invariant"; import { z } from "zod"; @@ -21,23 +22,16 @@ import { AssetTypes, bookmarkAssets, bookmarkLinks, - bookmarkLists, bookmarks, bookmarksInLists, bookmarkTags, bookmarkTexts, - listCollaborators, rssFeedImportsTable, tagsOnBookmarks, } from "@karakeep/db/schema"; import { SearchIndexingQueue, triggerWebhook } from "@karakeep/shared-server"; import { deleteAsset, readAsset } from "@karakeep/shared/assetdb"; -import serverConfig from "@karakeep/shared/config"; -import { - createSignedToken, - getAlignedExpiry, -} from "@karakeep/shared/signedTokens"; -import { zAssetSignedTokenSchema } from "@karakeep/shared/types/assets"; +import { getAlignedExpiry } from "@karakeep/shared/signedTokens"; import { BookmarkTypes, DEFAULT_NUM_BOOKMARKS_PER_PAGE, @@ -56,6 +50,7 @@ import { htmlToPlainText } from "@karakeep/shared/utils/htmlUtils"; import { AuthedContext } from ".."; import { mapDBAssetTypeToUserType } from "../lib/attachments"; +import { Asset } from "./assets"; import { List } from "./lists"; async function dummyDrizzleReturnType() { @@ -162,6 +157,7 @@ export class Bookmark extends BareBookmark { screenshotAssetId: assets.find( (a) => a.assetType == AssetTypes.LINK_SCREENSHOT, )?.id, + pdfAssetId: assets.find((a) => a.assetType == AssetTypes.LINK_PDF)?.id, fullPageArchiveAssetId: assets.find( (a) => a.assetType == AssetTypes.LINK_FULL_PAGE_ARCHIVE, )?.id, @@ -182,6 +178,7 @@ export class Bookmark extends BareBookmark { ? await Bookmark.getBookmarkHtmlContent(link, bookmark.userId) : null, crawledAt: link.crawledAt, + crawlStatus: link.crawlStatus, author: link.author, publisher: link.publisher, datePublished: link.datePublished, @@ -270,6 +267,130 @@ export class Bookmark extends BareBookmark { return new Bookmark(ctx, data); } + static async buildDebugInfo(ctx: AuthedContext, bookmarkId: string) { + // Verify the user is an admin + if (ctx.user.role !== "admin") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Admin access required", + }); + } + + const PRIVACY_REDACTED_ASSET_TYPES = new Set<AssetTypes>([ + AssetTypes.USER_UPLOADED, + AssetTypes.BOOKMARK_ASSET, + ]); + + const bookmark = await ctx.db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, bookmarkId), + with: { + link: true, + text: true, + asset: true, + tagsOnBookmarks: { + with: { + tag: true, + }, + }, + assets: true, + }, + }); + + if (!bookmark) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Bookmark not found", + }); + } + + // Build link info + let linkInfo = null; + if (bookmark.link) { + const htmlContentPreview = await (async () => { + try { + const content = await Bookmark.getBookmarkHtmlContent( + bookmark.link!, + bookmark.userId, + ); + return content ? content.substring(0, 1000) : null; + } catch { + return null; + } + })(); + + linkInfo = { + url: bookmark.link.url, + crawlStatus: bookmark.link.crawlStatus ?? "pending", + crawlStatusCode: bookmark.link.crawlStatusCode, + crawledAt: bookmark.link.crawledAt, + hasHtmlContent: !!bookmark.link.htmlContent, + hasContentAsset: !!bookmark.link.contentAssetId, + htmlContentPreview, + }; + } + + // Build text info + let textInfo = null; + if (bookmark.text) { + textInfo = { + hasText: !!bookmark.text.text, + sourceUrl: bookmark.text.sourceUrl, + }; + } + + // Build asset info + let assetInfo = null; + if (bookmark.asset) { + assetInfo = { + assetType: bookmark.asset.assetType, + hasContent: !!bookmark.asset.content, + fileName: bookmark.asset.fileName, + }; + } + + // Build tags + const tags = bookmark.tagsOnBookmarks.map((t) => ({ + id: t.tag.id, + name: t.tag.name, + attachedBy: t.attachedBy, + })); + + // Build assets list with signed URLs (exclude userUploaded) + const assetsWithUrls = bookmark.assets.map((a) => { + // Generate signed token with 10 mins expiry + const expiresAt = Date.now() + 10 * 60 * 1000; // 10 mins + // Exclude userUploaded assets for privacy reasons + const url = !PRIVACY_REDACTED_ASSET_TYPES.has(a.assetType) + ? Asset.getPublicSignedAssetUrl(a.id, bookmark.userId, expiresAt) + : null; + + return { + id: a.id, + assetType: a.assetType, + size: a.size, + url, + }; + }); + + return { + id: bookmark.id, + type: bookmark.type, + source: bookmark.source, + createdAt: bookmark.createdAt, + modifiedAt: bookmark.modifiedAt, + title: bookmark.title, + summary: bookmark.summary, + taggingStatus: bookmark.taggingStatus, + summarizationStatus: bookmark.summarizationStatus, + userId: bookmark.userId, + linkInfo, + textInfo, + assetInfo, + tags, + assets: assetsWithUrls, + }; + } + static async loadMulti( ctx: AuthedContext, input: z.infer<typeof zGetBookmarksRequestSchema>, @@ -283,6 +404,21 @@ export class Bookmark extends BareBookmark { if (!input.limit) { input.limit = DEFAULT_NUM_BOOKMARKS_PER_PAGE; } + + // Validate that only one of listId, tagId, or rssFeedId is specified + // Combined filters are not supported as they would require different query strategies + const filterCount = [input.listId, input.tagId, input.rssFeedId].filter( + (f) => f !== undefined, + ).length; + if (filterCount > 1) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Cannot filter by multiple of listId, tagId, and rssFeedId simultaneously", + }); + } + + // Handle smart lists by converting to bookmark IDs if (input.listId) { const list = await List.fromId(ctx, input.listId); if (list.type === "smart") { @@ -291,121 +427,132 @@ export class Bookmark extends BareBookmark { } } - const sq = ctx.db.$with("bookmarksSq").as( - ctx.db - .select() - .from(bookmarks) - .where( + // Build cursor condition for pagination + const buildCursorCondition = ( + createdAtCol: typeof bookmarks.createdAt, + idCol: typeof bookmarks.id, + ): SQL | undefined => { + if (!input.cursor) return undefined; + + if (input.sortOrder === "asc") { + return or( + gt(createdAtCol, input.cursor.createdAt), and( - // 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, - input.favourited !== undefined - ? eq(bookmarks.favourited, input.favourited) - : undefined, - input.ids ? inArray(bookmarks.id, input.ids) : undefined, - input.tagId !== undefined - ? exists( - ctx.db - .select() - .from(tagsOnBookmarks) - .where( - and( - eq(tagsOnBookmarks.bookmarkId, bookmarks.id), - eq(tagsOnBookmarks.tagId, input.tagId), - ), - ), - ) - : undefined, - input.rssFeedId !== undefined - ? exists( - ctx.db - .select() - .from(rssFeedImportsTable) - .where( - and( - eq(rssFeedImportsTable.bookmarkId, bookmarks.id), - eq(rssFeedImportsTable.rssFeedId, input.rssFeedId), - ), - ), - ) - : undefined, - input.listId !== undefined - ? exists( - ctx.db - .select() - .from(bookmarksInLists) - .where( - and( - eq(bookmarksInLists.bookmarkId, bookmarks.id), - eq(bookmarksInLists.listId, input.listId), - ), - ), - ) - : undefined, - input.cursor - ? input.sortOrder === "asc" - ? or( - gt(bookmarks.createdAt, input.cursor.createdAt), - and( - eq(bookmarks.createdAt, input.cursor.createdAt), - gte(bookmarks.id, input.cursor.id), - ), - ) - : or( - lt(bookmarks.createdAt, input.cursor.createdAt), - and( - eq(bookmarks.createdAt, input.cursor.createdAt), - lte(bookmarks.id, input.cursor.id), - ), - ) - : undefined, + eq(createdAtCol, input.cursor.createdAt), + gte(idCol, input.cursor.id), ), - ) - .limit(input.limit + 1) - .orderBy( - input.sortOrder === "asc" - ? asc(bookmarks.createdAt) - : desc(bookmarks.createdAt), - desc(bookmarks.id), + ); + } + return or( + lt(createdAtCol, input.cursor.createdAt), + and( + eq(createdAtCol, input.cursor.createdAt), + lte(idCol, input.cursor.id), ), - ); + ); + }; + + // Build common filter conditions (archived, favourited, ids) + const buildCommonFilters = (): (SQL | undefined)[] => [ + input.archived !== undefined + ? eq(bookmarks.archived, input.archived) + : undefined, + input.favourited !== undefined + ? eq(bookmarks.favourited, input.favourited) + : undefined, + input.ids ? inArray(bookmarks.id, input.ids) : undefined, + ]; + + // Build ORDER BY clause + const buildOrderBy = () => + [ + input.sortOrder === "asc" + ? asc(bookmarks.createdAt) + : desc(bookmarks.createdAt), + desc(bookmarks.id), + ] as const; + + // Choose query strategy based on filters + // Strategy: Use the most selective filter as the driving table + let sq; + + if (input.listId !== undefined) { + // PATH: List filter - start from bookmarksInLists (more selective) + // Access control is already verified by List.fromId() called above + sq = ctx.db.$with("bookmarksSq").as( + ctx.db + .select(getTableColumns(bookmarks)) + .from(bookmarksInLists) + .innerJoin(bookmarks, eq(bookmarks.id, bookmarksInLists.bookmarkId)) + .where( + and( + eq(bookmarksInLists.listId, input.listId), + ...buildCommonFilters(), + buildCursorCondition(bookmarks.createdAt, bookmarks.id), + ), + ) + .limit(input.limit + 1) + .orderBy(...buildOrderBy()), + ); + } else if (input.tagId !== undefined) { + // PATH: Tag filter - start from tagsOnBookmarks (more selective) + sq = ctx.db.$with("bookmarksSq").as( + ctx.db + .select(getTableColumns(bookmarks)) + .from(tagsOnBookmarks) + .innerJoin(bookmarks, eq(bookmarks.id, tagsOnBookmarks.bookmarkId)) + .where( + and( + eq(tagsOnBookmarks.tagId, input.tagId), + eq(bookmarks.userId, ctx.user.id), // Access control + ...buildCommonFilters(), + buildCursorCondition(bookmarks.createdAt, bookmarks.id), + ), + ) + .limit(input.limit + 1) + .orderBy(...buildOrderBy()), + ); + } else if (input.rssFeedId !== undefined) { + // PATH: RSS feed filter - start from rssFeedImportsTable (more selective) + sq = ctx.db.$with("bookmarksSq").as( + ctx.db + .select(getTableColumns(bookmarks)) + .from(rssFeedImportsTable) + .innerJoin( + bookmarks, + eq(bookmarks.id, rssFeedImportsTable.bookmarkId), + ) + .where( + and( + eq(rssFeedImportsTable.rssFeedId, input.rssFeedId), + eq(bookmarks.userId, ctx.user.id), // Access control + ...buildCommonFilters(), + buildCursorCondition(bookmarks.createdAt, bookmarks.id), + ), + ) + .limit(input.limit + 1) + .orderBy(...buildOrderBy()), + ); + } else { + // PATH: No list/tag/rssFeed filter - query bookmarks directly + // Uses composite index: bookmarks_userId_createdAt_id_idx (or archived/favourited variants) + sq = ctx.db.$with("bookmarksSq").as( + ctx.db + .select() + .from(bookmarks) + .where( + and( + eq(bookmarks.userId, ctx.user.id), + ...buildCommonFilters(), + buildCursorCondition(bookmarks.createdAt, bookmarks.id), + ), + ) + .limit(input.limit + 1) + .orderBy(...buildOrderBy()), + ); + } + + // Execute the query with joins for related data // TODO: Consider not inlining the tags in the response of getBookmarks as this query is getting kinda expensive const results = await ctx.db .with(sq) @@ -438,6 +585,7 @@ export class Bookmark extends BareBookmark { : row.bookmarkLinks.htmlContent : null, contentAssetId: row.bookmarkLinks.contentAssetId, + crawlStatus: row.bookmarkLinks.crawlStatus, crawledAt: row.bookmarkLinks.crawledAt, author: row.bookmarkLinks.author, publisher: row.bookmarkLinks.publisher, @@ -500,6 +648,9 @@ export class Bookmark extends BareBookmark { if (row.assets.assetType == AssetTypes.LINK_SCREENSHOT) { content.screenshotAssetId = row.assets.id; } + if (row.assets.assetType == AssetTypes.LINK_PDF) { + content.pdfAssetId = row.assets.id; + } if (row.assets.assetType == AssetTypes.LINK_FULL_PAGE_ARCHIVE) { content.fullPageArchiveAssetId = row.assets.id; } @@ -610,17 +761,12 @@ export class Bookmark extends BareBookmark { asPublicBookmark(): ZPublicBookmark { const getPublicSignedAssetUrl = (assetId: string) => { - const payload: z.infer<typeof zAssetSignedTokenSchema> = { + // Tokens will expire in 1 hour and will have a grace period of 15mins + return Asset.getPublicSignedAssetUrl( assetId, - userId: this.ctx.user.id, - }; - const signedToken = createSignedToken( - payload, - serverConfig.signingSecret(), - // Tokens will expire in 1 hour and will have a grace period of 15mins - getAlignedExpiry(/* interval */ 3600, /* grace */ 900), + this.bookmark.userId, + getAlignedExpiry(3600, 900), ); - return `${serverConfig.publicApiUrl}/public/assets/${assetId}?token=${signedToken}`; }; const getContent = ( content: ZBookmarkContent, |
