aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/models/bookmarks.ts
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--packages/trpc/models/bookmarks.ts406
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,