diff options
Diffstat (limited to 'packages/trpc')
| -rw-r--r-- | packages/trpc/lib/__tests__/search.test.ts | 69 | ||||
| -rw-r--r-- | packages/trpc/lib/attachments.ts | 2 | ||||
| -rw-r--r-- | packages/trpc/lib/impersonate.ts | 30 | ||||
| -rw-r--r-- | packages/trpc/lib/search.ts | 110 | ||||
| -rw-r--r-- | packages/trpc/models/bookmarks.ts | 422 | ||||
| -rw-r--r-- | packages/trpc/models/lists.ts | 140 | ||||
| -rw-r--r-- | packages/trpc/routers/_app.ts | 2 | ||||
| -rw-r--r-- | packages/trpc/routers/admin.ts | 36 | ||||
| -rw-r--r-- | packages/trpc/routers/bookmarks.test.ts | 6 | ||||
| -rw-r--r-- | packages/trpc/routers/bookmarks.ts | 286 | ||||
| -rw-r--r-- | packages/trpc/routers/feeds.ts | 2 | ||||
| -rw-r--r-- | packages/trpc/routers/lists.ts | 43 | ||||
| -rw-r--r-- | packages/trpc/routers/publicBookmarks.ts | 49 | ||||
| -rw-r--r-- | packages/trpc/routers/tags.ts | 36 | ||||
| -rw-r--r-- | packages/trpc/routers/users.test.ts | 37 | ||||
| -rw-r--r-- | packages/trpc/routers/users.ts | 43 |
16 files changed, 882 insertions, 431 deletions
diff --git a/packages/trpc/lib/__tests__/search.test.ts b/packages/trpc/lib/__tests__/search.test.ts index 72b53368..9d9b39d7 100644 --- a/packages/trpc/lib/__tests__/search.test.ts +++ b/packages/trpc/lib/__tests__/search.test.ts @@ -34,12 +34,6 @@ beforeEach(async () => { email: "test@example.com", role: "user", }, - { - id: "another-user", - name: "Another User", - email: "another@example.com", - role: "user", - }, ]); // Setup test data @@ -92,14 +86,6 @@ beforeEach(async () => { favourited: false, createdAt: new Date("2024-01-06"), }, - { - id: "b7", - type: BookmarkTypes.ASSET, - userId: "another-user", - archived: true, - favourited: false, - createdAt: new Date("2024-01-06"), - }, ]); await db.insert(bookmarkLinks).values([ @@ -157,21 +143,6 @@ beforeEach(async () => { type: "manual", }, { id: "l4", userId: testUserId, name: "work", icon: "💼", type: "manual" }, - { - id: "l5", - userId: testUserId, - name: "smartlist", - icon: "🧠", - type: "smart", - query: "#tag1 or #tag2", - }, - { - id: "l6", - userId: testUserId, - name: "emptylist", - icon: "∅", - type: "manual", - }, ]); await db.insert(bookmarksInLists).values([ @@ -253,26 +224,6 @@ describe("getBookmarkIdsFromMatcher", () => { expect(result).toEqual(["b1", "b6"]); }); - it("should handle listName matcher with smartList", async () => { - const matcher: Matcher = { - type: "listName", - listName: "smartlist", - inverse: false, - }; - const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); - expect(result).toEqual(["b1", "b2"]); - }); - - it("should handle listName matcher with empty list", async () => { - const matcher: Matcher = { - type: "listName", - listName: "emptylist", - inverse: false, - }; - const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); - expect(result).toEqual([]); - }); - it("should handle listName matcher with inverse=true", async () => { const matcher: Matcher = { type: "listName", @@ -283,26 +234,6 @@ describe("getBookmarkIdsFromMatcher", () => { expect(result.sort()).toEqual(["b2", "b3", "b4", "b5"]); }); - it("should handle listName matcher with smartList with inverse=true", async () => { - const matcher: Matcher = { - type: "listName", - listName: "smartlist", - inverse: true, - }; - const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); - expect(result).toEqual(["b3", "b4", "b5", "b6"]); - }); - - it("should handle listName matcher with empty list with inverse=true", async () => { - const matcher: Matcher = { - type: "listName", - listName: "emptylist", - inverse: true, - }; - const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); - expect(result).toEqual(["b1", "b2", "b3", "b4", "b5", "b6"]); - }); - it("should handle archived matcher", async () => { const matcher: Matcher = { type: "archived", archived: true }; const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); diff --git a/packages/trpc/lib/attachments.ts b/packages/trpc/lib/attachments.ts index 15cbba74..739aa8f5 100644 --- a/packages/trpc/lib/attachments.ts +++ b/packages/trpc/lib/attachments.ts @@ -55,7 +55,7 @@ export function isAllowedToAttachAsset(type: ZAssetType) { screenshot: true, assetScreenshot: true, fullPageArchive: false, - precrawledArchive: false, + precrawledArchive: true, bannerImage: true, video: false, bookmarkAsset: false, diff --git a/packages/trpc/lib/impersonate.ts b/packages/trpc/lib/impersonate.ts new file mode 100644 index 00000000..f44a2c70 --- /dev/null +++ b/packages/trpc/lib/impersonate.ts @@ -0,0 +1,30 @@ +import { eq } from "drizzle-orm"; + +import { db } from "@karakeep/db"; +import { users } from "@karakeep/db/schema"; + +import { AuthedContext } from ".."; + +export async function buildImpersonatingAuthedContext( + userId: string, +): Promise<AuthedContext> { + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + }); + if (!user) { + throw new Error("User not found"); + } + + return { + user: { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + }, + db, + req: { + ip: null, + }, + }; +} diff --git a/packages/trpc/lib/search.ts b/packages/trpc/lib/search.ts index 7bb78a01..d4130798 100644 --- a/packages/trpc/lib/search.ts +++ b/packages/trpc/lib/search.ts @@ -4,21 +4,21 @@ import { exists, gt, gte, - inArray, isNotNull, like, lt, lte, ne, notExists, - notInArray, notLike, } from "drizzle-orm"; import { bookmarkAssets, bookmarkLinks, + bookmarkLists, bookmarks, + bookmarksInLists, bookmarkTags, rssFeedImportsTable, rssFeedsTable, @@ -28,7 +28,6 @@ import { Matcher } from "@karakeep/shared/types/search"; import { toAbsoluteDate } from "@karakeep/shared/utils/relativeDateUtils"; import { AuthedContext } from ".."; -import { List } from "../models/lists"; interface BookmarkQueryReturnType { id: string; @@ -88,20 +87,21 @@ function union(vals: BookmarkQueryReturnType[][]): BookmarkQueryReturnType[] { } async function getIds( - ctx: AuthedContext, + db: AuthedContext["db"], + userId: string, matcher: Matcher, ): Promise<BookmarkQueryReturnType[]> { switch (matcher.type) { case "tagName": { const comp = matcher.inverse ? notExists : exists; - return ctx.db + return db .selectDistinct({ id: bookmarks.id }) .from(bookmarks) .where( and( - eq(bookmarks.userId, ctx.user.id), + eq(bookmarks.userId, userId), comp( - ctx.db + db .select() .from(tagsOnBookmarks) .innerJoin( @@ -111,7 +111,7 @@ async function getIds( .where( and( eq(tagsOnBookmarks.bookmarkId, bookmarks.id), - eq(bookmarkTags.userId, ctx.user.id), + eq(bookmarkTags.userId, userId), eq(bookmarkTags.name, matcher.tagName), ), ), @@ -121,14 +121,14 @@ async function getIds( } case "tagged": { const comp = matcher.tagged ? exists : notExists; - return ctx.db + return db .select({ id: bookmarks.id }) .from(bookmarks) .where( and( - eq(bookmarks.userId, ctx.user.id), + eq(bookmarks.userId, userId), comp( - ctx.db + db .select() .from(tagsOnBookmarks) .where(and(eq(tagsOnBookmarks.bookmarkId, bookmarks.id))), @@ -137,43 +137,59 @@ async function getIds( ); } case "listName": { - const lists = await List.fromName(ctx, matcher.listName); - const ids = await Promise.all(lists.map((l) => l.getBookmarkIds())); - const comp = matcher.inverse ? notInArray : inArray; - return ctx.db - .select({ id: bookmarks.id }) + const comp = matcher.inverse ? notExists : exists; + return db + .selectDistinct({ id: bookmarks.id }) .from(bookmarks) .where( and( - eq(bookmarks.userId, ctx.user.id), - comp(bookmarks.id, ids.flat()), + eq(bookmarks.userId, userId), + comp( + db + .select() + .from(bookmarksInLists) + .innerJoin( + bookmarkLists, + eq(bookmarksInLists.listId, bookmarkLists.id), + ) + .where( + and( + eq(bookmarksInLists.bookmarkId, bookmarks.id), + eq(bookmarkLists.userId, userId), + eq(bookmarkLists.name, matcher.listName), + ), + ), + ), ), ); } case "inlist": { - const lists = await List.getAll(ctx); - const ids = await Promise.all(lists.map((l) => l.getBookmarkIds())); - const comp = matcher.inList ? inArray : notInArray; - return ctx.db + const comp = matcher.inList ? exists : notExists; + return db .select({ id: bookmarks.id }) .from(bookmarks) .where( and( - eq(bookmarks.userId, ctx.user.id), - comp(bookmarks.id, ids.flat()), + eq(bookmarks.userId, userId), + comp( + db + .select() + .from(bookmarksInLists) + .where(and(eq(bookmarksInLists.bookmarkId, bookmarks.id))), + ), ), ); } case "rssFeedName": { const comp = matcher.inverse ? notExists : exists; - return ctx.db + return db .selectDistinct({ id: bookmarks.id }) .from(bookmarks) .where( and( - eq(bookmarks.userId, ctx.user.id), + eq(bookmarks.userId, userId), comp( - ctx.db + db .select() .from(rssFeedImportsTable) .innerJoin( @@ -183,7 +199,7 @@ async function getIds( .where( and( eq(rssFeedImportsTable.bookmarkId, bookmarks.id), - eq(rssFeedsTable.userId, ctx.user.id), + eq(rssFeedsTable.userId, userId), eq(rssFeedsTable.name, matcher.feedName), ), ), @@ -192,36 +208,36 @@ async function getIds( ); } case "archived": { - return ctx.db + return db .select({ id: bookmarks.id }) .from(bookmarks) .where( and( - eq(bookmarks.userId, ctx.user.id), + eq(bookmarks.userId, userId), eq(bookmarks.archived, matcher.archived), ), ); } case "url": { const comp = matcher.inverse ? notLike : like; - return ctx.db + return db .select({ id: bookmarkLinks.id }) .from(bookmarkLinks) .leftJoin(bookmarks, eq(bookmarks.id, bookmarkLinks.id)) .where( and( - eq(bookmarks.userId, ctx.user.id), + eq(bookmarks.userId, userId), comp(bookmarkLinks.url, `%${matcher.url}%`), ), ) .union( - ctx.db + db .select({ id: bookmarkAssets.id }) .from(bookmarkAssets) .leftJoin(bookmarks, eq(bookmarks.id, bookmarkAssets.id)) .where( and( - eq(bookmarks.userId, ctx.user.id), + eq(bookmarks.userId, userId), // When a user is asking for a link, the inverse matcher should match only assets with URLs. isNotNull(bookmarkAssets.sourceUrl), comp(bookmarkAssets.sourceUrl, `%${matcher.url}%`), @@ -230,73 +246,73 @@ async function getIds( ); } case "favourited": { - return ctx.db + return db .select({ id: bookmarks.id }) .from(bookmarks) .where( and( - eq(bookmarks.userId, ctx.user.id), + eq(bookmarks.userId, userId), eq(bookmarks.favourited, matcher.favourited), ), ); } case "dateAfter": { const comp = matcher.inverse ? lt : gte; - return ctx.db + return db .select({ id: bookmarks.id }) .from(bookmarks) .where( and( - eq(bookmarks.userId, ctx.user.id), + eq(bookmarks.userId, userId), comp(bookmarks.createdAt, matcher.dateAfter), ), ); } case "dateBefore": { const comp = matcher.inverse ? gt : lte; - return ctx.db + return db .select({ id: bookmarks.id }) .from(bookmarks) .where( and( - eq(bookmarks.userId, ctx.user.id), + eq(bookmarks.userId, userId), comp(bookmarks.createdAt, matcher.dateBefore), ), ); } case "age": { const comp = matcher.relativeDate.direction === "newer" ? gte : lt; - return ctx.db + return db .select({ id: bookmarks.id }) .from(bookmarks) .where( and( - eq(bookmarks.userId, ctx.user.id), + eq(bookmarks.userId, userId), comp(bookmarks.createdAt, toAbsoluteDate(matcher.relativeDate)), ), ); } case "type": { const comp = matcher.inverse ? ne : eq; - return ctx.db + return db .select({ id: bookmarks.id }) .from(bookmarks) .where( and( - eq(bookmarks.userId, ctx.user.id), + eq(bookmarks.userId, userId), comp(bookmarks.type, matcher.typeName), ), ); } case "and": { const vals = await Promise.all( - matcher.matchers.map((m) => getIds(ctx, m)), + matcher.matchers.map((m) => getIds(db, userId, m)), ); return intersect(vals); } case "or": { const vals = await Promise.all( - matcher.matchers.map((m) => getIds(ctx, m)), + matcher.matchers.map((m) => getIds(db, userId, m)), ); return union(vals); } @@ -311,6 +327,6 @@ export async function getBookmarkIdsFromMatcher( ctx: AuthedContext, matcher: Matcher, ): Promise<string[]> { - const results = await getIds(ctx, matcher); + const results = await getIds(ctx.db, ctx.user.id, matcher); return results.map((r) => r.id); } diff --git a/packages/trpc/models/bookmarks.ts b/packages/trpc/models/bookmarks.ts new file mode 100644 index 00000000..6e9e5651 --- /dev/null +++ b/packages/trpc/models/bookmarks.ts @@ -0,0 +1,422 @@ +import { TRPCError } from "@trpc/server"; +import { + and, + asc, + desc, + eq, + exists, + gt, + gte, + inArray, + lt, + lte, + or, +} from "drizzle-orm"; +import invariant from "tiny-invariant"; +import { z } from "zod"; + +import { + assets, + AssetTypes, + bookmarkAssets, + bookmarkLinks, + bookmarks, + bookmarksInLists, + bookmarkTags, + bookmarkTexts, + rssFeedImportsTable, + tagsOnBookmarks, +} from "@karakeep/db/schema"; +import serverConfig from "@karakeep/shared/config"; +import { createSignedToken } from "@karakeep/shared/signedTokens"; +import { zAssetSignedTokenSchema } from "@karakeep/shared/types/assets"; +import { + BookmarkTypes, + DEFAULT_NUM_BOOKMARKS_PER_PAGE, + ZBookmark, + ZBookmarkContent, + zGetBookmarksRequestSchema, + ZPublicBookmark, +} from "@karakeep/shared/types/bookmarks"; +import { ZCursor } from "@karakeep/shared/types/pagination"; +import { + getBookmarkLinkAssetIdOrUrl, + getBookmarkTitle, +} from "@karakeep/shared/utils/bookmarkUtils"; + +import { AuthedContext } from ".."; +import { mapDBAssetTypeToUserType } from "../lib/attachments"; +import { List } from "./lists"; +import { PrivacyAware } from "./privacy"; + +export class Bookmark implements PrivacyAware { + protected constructor( + protected ctx: AuthedContext, + public bookmark: ZBookmark & { userId: string }, + ) {} + + ensureCanAccess(ctx: AuthedContext): void { + if (this.bookmark.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, + }); + } + + static async loadMulti( + ctx: AuthedContext, + input: z.infer<typeof zGetBookmarksRequestSchema>, + ): Promise<{ + bookmarks: Bookmark[]; + nextCursor: ZCursor | null; + }> { + if (input.ids && input.ids.length == 0) { + return { bookmarks: [], nextCursor: null }; + } + if (!input.limit) { + input.limit = DEFAULT_NUM_BOOKMARKS_PER_PAGE; + } + if (input.listId) { + const list = await List.fromId(ctx, input.listId); + if (list.type === "smart") { + input.ids = await list.getBookmarkIds(); + delete input.listId; + } + } + + const sq = ctx.db.$with("bookmarksSq").as( + ctx.db + .select() + .from(bookmarks) + .where( + and( + 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, + ), + ) + .limit(input.limit + 1) + .orderBy( + input.sortOrder === "asc" + ? asc(bookmarks.createdAt) + : desc(bookmarks.createdAt), + desc(bookmarks.id), + ), + ); + // 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) + .select() + .from(sq) + .leftJoin(tagsOnBookmarks, eq(sq.id, tagsOnBookmarks.bookmarkId)) + .leftJoin(bookmarkTags, eq(tagsOnBookmarks.tagId, bookmarkTags.id)) + .leftJoin(bookmarkLinks, eq(bookmarkLinks.id, sq.id)) + .leftJoin(bookmarkTexts, eq(bookmarkTexts.id, sq.id)) + .leftJoin(bookmarkAssets, eq(bookmarkAssets.id, sq.id)) + .leftJoin(assets, eq(assets.bookmarkId, sq.id)) + .orderBy(desc(sq.createdAt), desc(sq.id)); + + const bookmarksRes = results.reduce<Record<string, ZBookmark>>( + (acc, row) => { + const bookmarkId = row.bookmarksSq.id; + if (!acc[bookmarkId]) { + let content: ZBookmarkContent; + if (row.bookmarkLinks) { + content = { + type: BookmarkTypes.LINK, + url: row.bookmarkLinks.url, + title: row.bookmarkLinks.title, + description: row.bookmarkLinks.description, + imageUrl: row.bookmarkLinks.imageUrl, + favicon: row.bookmarkLinks.favicon, + htmlContent: input.includeContent + ? row.bookmarkLinks.htmlContent + : null, + crawledAt: row.bookmarkLinks.crawledAt, + author: row.bookmarkLinks.author, + publisher: row.bookmarkLinks.publisher, + datePublished: row.bookmarkLinks.datePublished, + dateModified: row.bookmarkLinks.dateModified, + }; + } else if (row.bookmarkTexts) { + content = { + type: BookmarkTypes.TEXT, + text: row.bookmarkTexts.text ?? "", + sourceUrl: row.bookmarkTexts.sourceUrl ?? null, + }; + } else if (row.bookmarkAssets) { + content = { + type: BookmarkTypes.ASSET, + assetId: row.bookmarkAssets.assetId, + assetType: row.bookmarkAssets.assetType, + fileName: row.bookmarkAssets.fileName, + sourceUrl: row.bookmarkAssets.sourceUrl ?? null, + size: null, // This will get filled in the asset loop + content: input.includeContent + ? (row.bookmarkAssets.content ?? null) + : null, + }; + } else { + content = { + type: BookmarkTypes.UNKNOWN, + }; + } + acc[bookmarkId] = { + ...row.bookmarksSq, + content, + tags: [], + assets: [], + }; + } + + if ( + row.bookmarkTags && + // Duplicates may occur because of the join, so we need to make sure we're not adding the same tag twice + !acc[bookmarkId].tags.some((t) => t.id == row.bookmarkTags!.id) + ) { + invariant( + row.tagsOnBookmarks, + "if bookmark tag is set, its many-to-many relation must also be set", + ); + acc[bookmarkId].tags.push({ + ...row.bookmarkTags, + attachedBy: row.tagsOnBookmarks.attachedBy, + }); + } + + if ( + row.assets && + !acc[bookmarkId].assets.some((a) => a.id == row.assets!.id) + ) { + if (acc[bookmarkId].content.type == BookmarkTypes.LINK) { + const content = acc[bookmarkId].content; + invariant(content.type == BookmarkTypes.LINK); + if (row.assets.assetType == AssetTypes.LINK_SCREENSHOT) { + content.screenshotAssetId = row.assets.id; + } + if (row.assets.assetType == AssetTypes.LINK_FULL_PAGE_ARCHIVE) { + content.fullPageArchiveAssetId = row.assets.id; + } + if (row.assets.assetType == AssetTypes.LINK_BANNER_IMAGE) { + content.imageAssetId = row.assets.id; + } + if (row.assets.assetType == AssetTypes.LINK_VIDEO) { + content.videoAssetId = row.assets.id; + } + if (row.assets.assetType == AssetTypes.LINK_PRECRAWLED_ARCHIVE) { + content.precrawledArchiveAssetId = row.assets.id; + } + acc[bookmarkId].content = content; + } + if (acc[bookmarkId].content.type == BookmarkTypes.ASSET) { + const content = acc[bookmarkId].content; + if (row.assets.id == content.assetId) { + // If this is the bookmark's main aset, caputure its size. + content.size = row.assets.size; + } + } + acc[bookmarkId].assets.push({ + id: row.assets.id, + assetType: mapDBAssetTypeToUserType(row.assets.assetType), + }); + } + + return acc; + }, + {}, + ); + + const bookmarksArr = Object.values(bookmarksRes); + + bookmarksArr.sort((a, b) => { + if (a.createdAt != b.createdAt) { + return input.sortOrder === "asc" + ? a.createdAt.getTime() - b.createdAt.getTime() + : b.createdAt.getTime() - a.createdAt.getTime(); + } else { + return b.id.localeCompare(a.id); + } + }); + + let nextCursor = null; + if (bookmarksArr.length > input.limit) { + const nextItem = bookmarksArr.pop()!; + nextCursor = { + id: nextItem.id, + createdAt: nextItem.createdAt, + }; + } + + return { + bookmarks: bookmarksArr.map((b) => Bookmark.fromData(ctx, b)), + nextCursor, + }; + } + + asZBookmark(): ZBookmark { + return this.bookmark; + } + + asPublicBookmark(): ZPublicBookmark { + const getPublicSignedAssetUrl = (assetId: string) => { + const payload: z.infer<typeof zAssetSignedTokenSchema> = { + assetId, + userId: this.ctx.user.id, + }; + const signedToken = createSignedToken(payload); + return `${serverConfig.publicApiUrl}/public/assets/${assetId}?token=${signedToken}`; + }; + const getContent = ( + content: ZBookmarkContent, + ): ZPublicBookmark["content"] => { + switch (content.type) { + case BookmarkTypes.LINK: { + return { + type: BookmarkTypes.LINK, + url: content.url, + }; + } + case BookmarkTypes.TEXT: { + return { + type: BookmarkTypes.TEXT, + text: content.text, + }; + } + case BookmarkTypes.ASSET: { + return { + type: BookmarkTypes.ASSET, + assetType: content.assetType, + assetId: content.assetId, + assetUrl: getPublicSignedAssetUrl(content.assetId), + fileName: content.fileName, + sourceUrl: content.sourceUrl, + }; + } + default: { + throw new Error("Unknown bookmark content type"); + } + } + }; + + const getBannerImageUrl = (content: ZBookmarkContent): string | null => { + switch (content.type) { + case BookmarkTypes.LINK: { + const assetIdOrUrl = getBookmarkLinkAssetIdOrUrl(content); + if (!assetIdOrUrl) { + return null; + } + if (assetIdOrUrl.localAsset) { + return getPublicSignedAssetUrl(assetIdOrUrl.assetId); + } else { + return assetIdOrUrl.url; + } + } + case BookmarkTypes.TEXT: { + return null; + } + case BookmarkTypes.ASSET: { + switch (content.assetType) { + case "image": + return `${getPublicSignedAssetUrl(content.assetId)}`; + case "pdf": { + const screenshotAssetId = this.bookmark.assets.find( + (r) => r.assetType === "assetScreenshot", + )?.id; + if (!screenshotAssetId) { + return null; + } + return getPublicSignedAssetUrl(screenshotAssetId); + } + default: { + const _exhaustiveCheck: never = content.assetType; + return null; + } + } + } + default: { + throw new Error("Unknown bookmark content type"); + } + } + }; + + // WARNING: Everything below is exposed in the public APIs, don't use spreads! + return { + id: this.bookmark.id, + createdAt: this.bookmark.createdAt, + modifiedAt: this.bookmark.modifiedAt, + title: getBookmarkTitle(this.bookmark), + tags: this.bookmark.tags.map((t) => t.name), + content: getContent(this.bookmark.content), + bannerImageUrl: getBannerImageUrl(this.bookmark.content), + }; + } +} diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts index d278f8d9..2631ca7e 100644 --- a/packages/trpc/models/lists.ts +++ b/packages/trpc/models/lists.ts @@ -1,5 +1,6 @@ +import crypto from "node:crypto"; import { TRPCError } from "@trpc/server"; -import { and, count, eq } from "drizzle-orm"; +import { and, count, eq, or } from "drizzle-orm"; import invariant from "tiny-invariant"; import { z } from "zod"; @@ -7,14 +8,18 @@ import { SqliteError } from "@karakeep/db"; import { bookmarkLists, bookmarksInLists } from "@karakeep/db/schema"; import { triggerRuleEngineOnEvent } from "@karakeep/shared/queues"; import { parseSearchQuery } from "@karakeep/shared/searchQueryParser"; +import { ZSortOrder } from "@karakeep/shared/types/bookmarks"; import { ZBookmarkList, zEditBookmarkListSchemaWithValidation, zNewBookmarkListSchema, } from "@karakeep/shared/types/lists"; +import { ZCursor } from "@karakeep/shared/types/pagination"; -import { AuthedContext } from ".."; +import { AuthedContext, Context } from ".."; +import { buildImpersonatingAuthedContext } from "../lib/impersonate"; import { getBookmarkIdsFromMatcher } from "../lib/search"; +import { Bookmark } from "./bookmarks"; import { PrivacyAware } from "./privacy"; export abstract class List implements PrivacyAware { @@ -26,7 +31,7 @@ export abstract class List implements PrivacyAware { private static fromData( ctx: AuthedContext, data: ZBookmarkList & { userId: string }, - ): ManualList | SmartList { + ) { if (data.type === "smart") { return new SmartList(ctx, data); } else { @@ -34,21 +39,6 @@ export abstract class List implements PrivacyAware { } } - static async fromName( - ctx: AuthedContext, - name: string, - ): Promise<(ManualList | SmartList)[]> { - // Names are not unique, so we need to find all lists with the same name - const lists = await ctx.db.query.bookmarkLists.findMany({ - where: and( - eq(bookmarkLists.name, name), - eq(bookmarkLists.userId, ctx.user.id), - ), - }); - - return lists.map((l) => this.fromData(ctx, l)); - } - static async fromId( ctx: AuthedContext, id: string, @@ -66,7 +56,64 @@ export abstract class List implements PrivacyAware { message: "List not found", }); } - return this.fromData(ctx, list); + if (list.type === "smart") { + return new SmartList(ctx, list); + } else { + return new ManualList(ctx, list); + } + } + + static async getPublicListContents( + ctx: Context, + listId: string, + token: string | null, + pagination: { + limit: number; + order: Exclude<ZSortOrder, "relevance">; + cursor: ZCursor | null | undefined; + }, + ) { + const listdb = await ctx.db.query.bookmarkLists.findFirst({ + where: and( + eq(bookmarkLists.id, listId), + or( + eq(bookmarkLists.public, true), + token !== null ? eq(bookmarkLists.rssToken, token) : undefined, + ), + ), + }); + if (!listdb) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "List not found", + }); + } + + // The token here acts as an authed context, so we can create + // an impersonating context for the list owner as long as + // we don't leak the context. + const authedCtx = await buildImpersonatingAuthedContext(listdb.userId); + const list = List.fromData(authedCtx, listdb); + const bookmarkIds = await list.getBookmarkIds(); + + const bookmarks = await Bookmark.loadMulti(authedCtx, { + ids: bookmarkIds, + includeContent: false, + limit: pagination.limit, + sortOrder: pagination.order, + cursor: pagination.cursor, + }); + + return { + list: { + icon: list.list.icon, + name: list.list.name, + description: list.list.description, + numItems: bookmarkIds.length, + }, + bookmarks: bookmarks.bookmarks.map((b) => b.asPublicBookmark()), + nextCursor: bookmarks.nextCursor, + }; } static async create( @@ -90,6 +137,9 @@ export abstract class List implements PrivacyAware { static async getAll(ctx: AuthedContext): Promise<(ManualList | SmartList)[]> { const lists = await ctx.db.query.bookmarkLists.findMany({ + columns: { + rssToken: false, + }, where: and(eq(bookmarkLists.userId, ctx.user.id)), }); return lists.map((l) => this.fromData(ctx, l)); @@ -99,7 +149,11 @@ export abstract class List implements PrivacyAware { const lists = await ctx.db.query.bookmarksInLists.findMany({ where: and(eq(bookmarksInLists.bookmarkId, bookmarkId)), with: { - list: true, + list: { + columns: { + rssToken: false, + }, + }, }, }); invariant(lists.map((l) => l.list.userId).every((id) => id == ctx.user.id)); @@ -140,6 +194,7 @@ export abstract class List implements PrivacyAware { icon: input.icon, parentId: input.parentId, query: input.query, + public: input.public, }) .where( and( @@ -154,6 +209,45 @@ export abstract class List implements PrivacyAware { this.list = result[0]; } + private async setRssToken(token: string | null) { + const result = await this.ctx.db + .update(bookmarkLists) + .set({ rssToken: token }) + .where( + and( + eq(bookmarkLists.id, this.list.id), + eq(bookmarkLists.userId, this.ctx.user.id), + ), + ) + .returning(); + if (result.length == 0) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + return result[0].rssToken; + } + + async getRssToken(): Promise<string | null> { + const [result] = await this.ctx.db + .select({ rssToken: bookmarkLists.rssToken }) + .from(bookmarkLists) + .where( + and( + eq(bookmarkLists.id, this.list.id), + eq(bookmarkLists.userId, this.ctx.user.id), + ), + ) + .limit(1); + return result.rssToken ?? null; + } + + async regenRssToken() { + return await this.setRssToken(crypto.randomBytes(32).toString("hex")); + } + + async clearRssToken() { + await this.setRssToken(null); + } + abstract get type(): "manual" | "smart"; abstract getBookmarkIds(ctx: AuthedContext): Promise<string[]>; abstract getSize(ctx: AuthedContext): Promise<number>; @@ -271,10 +365,8 @@ export class ManualList extends List { } catch (e) { if (e instanceof SqliteError) { if (e.code == "SQLITE_CONSTRAINT_PRIMARYKEY") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Bookmark ${bookmarkId} is already in the list ${this.list.id}`, - }); + // this is fine, it just means the bookmark is already in the list + return; } } throw new TRPCError({ diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts index 394e95e7..e09f959e 100644 --- a/packages/trpc/routers/_app.ts +++ b/packages/trpc/routers/_app.ts @@ -7,6 +7,7 @@ import { feedsAppRouter } from "./feeds"; import { highlightsAppRouter } from "./highlights"; import { listsAppRouter } from "./lists"; import { promptsAppRouter } from "./prompts"; +import { publicBookmarks } from "./publicBookmarks"; import { rulesAppRouter } from "./rules"; import { tagsAppRouter } from "./tags"; import { usersAppRouter } from "./users"; @@ -25,6 +26,7 @@ export const appRouter = router({ webhooks: webhooksAppRouter, assets: assetsAppRouter, rules: rulesAppRouter, + publicBookmarks: publicBookmarks, }); // export type definition of API export type AppRouter = typeof appRouter; diff --git a/packages/trpc/routers/admin.ts b/packages/trpc/routers/admin.ts index e4985b5c..91f4a34f 100644 --- a/packages/trpc/routers/admin.ts +++ b/packages/trpc/routers/admin.ts @@ -1,5 +1,5 @@ import { TRPCError } from "@trpc/server"; -import { count, eq, sum } from "drizzle-orm"; +import { count, eq, or, sum } from "drizzle-orm"; import { z } from "zod"; import { assets, bookmarkLinks, bookmarks, users } from "@karakeep/db/schema"; @@ -129,11 +129,21 @@ export const adminAppRouter = router({ ctx.db .select({ value: count() }) .from(bookmarks) - .where(eq(bookmarks.taggingStatus, "pending")), + .where( + or( + eq(bookmarks.taggingStatus, "pending"), + eq(bookmarks.summarizationStatus, "pending"), + ), + ), ctx.db .select({ value: count() }) .from(bookmarks) - .where(eq(bookmarks.taggingStatus, "failure")), + .where( + or( + eq(bookmarks.taggingStatus, "failure"), + eq(bookmarks.summarizationStatus, "failure"), + ), + ), // Tidy Assets TidyAssetsQueue.stats(), @@ -233,7 +243,8 @@ export const adminAppRouter = router({ reRunInferenceOnAllBookmarks: adminProcedure .input( z.object({ - taggingStatus: z.enum(["success", "failure", "all"]), + type: z.enum(["tag", "summarize"]), + status: z.enum(["success", "failure", "all"]), }), ) .mutation(async ({ input, ctx }) => { @@ -241,13 +252,22 @@ export const adminAppRouter = router({ columns: { id: true, }, - ...(input.taggingStatus === "all" - ? {} - : { where: eq(bookmarks.taggingStatus, input.taggingStatus) }), + ...{ + tag: + input.status === "all" + ? {} + : { where: eq(bookmarks.taggingStatus, input.status) }, + summarize: + input.status === "all" + ? {} + : { where: eq(bookmarks.summarizationStatus, input.status) }, + }[input.type], }); await Promise.all( - bookmarkIds.map((b) => OpenAIQueue.enqueue({ bookmarkId: b.id })), + bookmarkIds.map((b) => + OpenAIQueue.enqueue({ bookmarkId: b.id, type: input.type }), + ), ); }), tidyAssets: adminProcedure.mutation(async () => { diff --git a/packages/trpc/routers/bookmarks.test.ts b/packages/trpc/routers/bookmarks.test.ts index e2179542..575b4d9a 100644 --- a/packages/trpc/routers/bookmarks.test.ts +++ b/packages/trpc/routers/bookmarks.test.ts @@ -25,7 +25,11 @@ describe("Bookmark Routes", () => { feedUrl: string, ) { // Create an RSS feed and return its ID - const feed = await api.feeds.create({ name: feedName, url: feedUrl }); + const feed = await api.feeds.create({ + name: feedName, + url: feedUrl, + enabled: true, + }); return feed.id; } diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts index b9a21400..04d15d1f 100644 --- a/packages/trpc/routers/bookmarks.ts +++ b/packages/trpc/routers/bookmarks.ts @@ -1,17 +1,5 @@ import { experimental_trpcMiddleware, TRPCError } from "@trpc/server"; -import { - and, - asc, - desc, - eq, - exists, - gt, - gte, - inArray, - lt, - lte, - or, -} from "drizzle-orm"; +import { and, eq, gt, inArray, lt, or } from "drizzle-orm"; import invariant from "tiny-invariant"; import { z } from "zod"; @@ -27,11 +15,9 @@ import { bookmarkAssets, bookmarkLinks, bookmarks, - bookmarksInLists, bookmarkTags, bookmarkTexts, customPrompts, - rssFeedImportsTable, tagsOnBookmarks, } from "@karakeep/db/schema"; import { @@ -69,7 +55,7 @@ import type { AuthedContext, Context } from "../index"; import { authedProcedure, router } from "../index"; import { mapDBAssetTypeToUserType } from "../lib/attachments"; import { getBookmarkIdsFromMatcher } from "../lib/search"; -import { List } from "../models/lists"; +import { Bookmark } from "../models/bookmarks"; import { ensureAssetOwnership } from "./assets"; export const ensureBookmarkOwnership = experimental_trpcMiddleware<{ @@ -420,6 +406,7 @@ export const bookmarksAppRouter = router({ case BookmarkTypes.TEXT: { await OpenAIQueue.enqueue({ bookmarkId: bookmark.id, + type: "tag", }); break; } @@ -659,6 +646,7 @@ export const bookmarksAppRouter = router({ ), ); await triggerSearchDeletion(input.bookmarkId); + await triggerWebhook(input.bookmarkId, "deleted"); if (deleted.changes > 0 && bookmark) { await cleanupAssetForBookmark({ asset: bookmark.asset, @@ -712,7 +700,7 @@ export const bookmarksAppRouter = router({ if (!input.limit) { input.limit = DEFAULT_NUM_BOOKMARKS_PER_PAGE; } - const sortOrder = input.sortOrder || "desc"; + const sortOrder = input.sortOrder || "relevance"; const client = await getSearchIdxClient(); if (!client) { throw new TRPCError({ @@ -735,11 +723,16 @@ export const bookmarksAppRouter = router({ filter = [`userId = '${ctx.user.id}'`]; } + /** + * preserve legacy behaviour + */ + const createdAtSortOrder = sortOrder === "relevance" ? "desc" : sortOrder; + const resp = await client.search(parsedQuery.text, { filter, showRankingScore: true, attributesToRetrieve: ["id"], - sort: [`createdAt:${sortOrder}`], + sort: [`createdAt:${createdAtSortOrder}`], limit: input.limit, ...(input.cursor ? { @@ -775,7 +768,18 @@ export const bookmarksAppRouter = router({ assets: true, }, }); - results.sort((a, b) => idToRank[b.id] - idToRank[a.id]); + + switch (true) { + case sortOrder === "relevance": + results.sort((a, b) => idToRank[b.id] - idToRank[a.id]); + break; + case sortOrder === "desc": + results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + break; + case sortOrder === "asc": + results.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + break; + } return { bookmarks: results.map((b) => toZodSchema(b, input.includeContent)), @@ -792,245 +796,11 @@ export const bookmarksAppRouter = router({ .input(zGetBookmarksRequestSchema) .output(zGetBookmarksResponseSchema) .query(async ({ input, ctx }) => { - if (input.ids && input.ids.length == 0) { - return { bookmarks: [], nextCursor: null }; - } - if (!input.limit) { - input.limit = DEFAULT_NUM_BOOKMARKS_PER_PAGE; - } - if (input.listId) { - const list = await List.fromId(ctx, input.listId); - if (list.type === "smart") { - input.ids = await list.getBookmarkIds(); - delete input.listId; - } - } - - const sq = ctx.db.$with("bookmarksSq").as( - ctx.db - .select() - .from(bookmarks) - .where( - and( - 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, - ), - ) - .limit(input.limit + 1) - .orderBy( - input.sortOrder === "asc" - ? asc(bookmarks.createdAt) - : desc(bookmarks.createdAt), - desc(bookmarks.id), - ), - ); - // 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) - .select() - .from(sq) - .leftJoin(tagsOnBookmarks, eq(sq.id, tagsOnBookmarks.bookmarkId)) - .leftJoin(bookmarkTags, eq(tagsOnBookmarks.tagId, bookmarkTags.id)) - .leftJoin(bookmarkLinks, eq(bookmarkLinks.id, sq.id)) - .leftJoin(bookmarkTexts, eq(bookmarkTexts.id, sq.id)) - .leftJoin(bookmarkAssets, eq(bookmarkAssets.id, sq.id)) - .leftJoin(assets, eq(assets.bookmarkId, sq.id)) - .orderBy(desc(sq.createdAt), desc(sq.id)); - - const bookmarksRes = results.reduce<Record<string, ZBookmark>>( - (acc, row) => { - const bookmarkId = row.bookmarksSq.id; - if (!acc[bookmarkId]) { - let content: ZBookmarkContent; - if (row.bookmarkLinks) { - content = { - type: BookmarkTypes.LINK, - url: row.bookmarkLinks.url, - title: row.bookmarkLinks.title, - description: row.bookmarkLinks.description, - imageUrl: row.bookmarkLinks.imageUrl, - favicon: row.bookmarkLinks.favicon, - htmlContent: input.includeContent - ? row.bookmarkLinks.htmlContent - : null, - crawledAt: row.bookmarkLinks.crawledAt, - author: row.bookmarkLinks.author, - publisher: row.bookmarkLinks.publisher, - datePublished: row.bookmarkLinks.datePublished, - dateModified: row.bookmarkLinks.dateModified, - }; - } else if (row.bookmarkTexts) { - content = { - type: BookmarkTypes.TEXT, - text: row.bookmarkTexts.text ?? "", - sourceUrl: row.bookmarkTexts.sourceUrl ?? null, - }; - } else if (row.bookmarkAssets) { - content = { - type: BookmarkTypes.ASSET, - assetId: row.bookmarkAssets.assetId, - assetType: row.bookmarkAssets.assetType, - fileName: row.bookmarkAssets.fileName, - sourceUrl: row.bookmarkAssets.sourceUrl ?? null, - size: null, // This will get filled in the asset loop - content: input.includeContent - ? (row.bookmarkAssets.content ?? null) - : null, - }; - } else { - content = { - type: BookmarkTypes.UNKNOWN, - }; - } - acc[bookmarkId] = { - ...row.bookmarksSq, - content, - tags: [], - assets: [], - }; - } - - if ( - row.bookmarkTags && - // Duplicates may occur because of the join, so we need to make sure we're not adding the same tag twice - !acc[bookmarkId].tags.some((t) => t.id == row.bookmarkTags!.id) - ) { - invariant( - row.tagsOnBookmarks, - "if bookmark tag is set, its many-to-many relation must also be set", - ); - acc[bookmarkId].tags.push({ - ...row.bookmarkTags, - attachedBy: row.tagsOnBookmarks.attachedBy, - }); - } - - if ( - row.assets && - !acc[bookmarkId].assets.some((a) => a.id == row.assets!.id) - ) { - if (acc[bookmarkId].content.type == BookmarkTypes.LINK) { - const content = acc[bookmarkId].content; - invariant(content.type == BookmarkTypes.LINK); - if (row.assets.assetType == AssetTypes.LINK_SCREENSHOT) { - content.screenshotAssetId = row.assets.id; - } - if (row.assets.assetType == AssetTypes.LINK_FULL_PAGE_ARCHIVE) { - content.fullPageArchiveAssetId = row.assets.id; - } - if (row.assets.assetType == AssetTypes.LINK_BANNER_IMAGE) { - content.imageAssetId = row.assets.id; - } - if (row.assets.assetType == AssetTypes.LINK_VIDEO) { - content.videoAssetId = row.assets.id; - } - if (row.assets.assetType == AssetTypes.LINK_PRECRAWLED_ARCHIVE) { - content.precrawledArchiveAssetId = row.assets.id; - } - acc[bookmarkId].content = content; - } - if (acc[bookmarkId].content.type == BookmarkTypes.ASSET) { - const content = acc[bookmarkId].content; - if (row.assets.id == content.assetId) { - // If this is the bookmark's main aset, caputure its size. - content.size = row.assets.size; - } - } - acc[bookmarkId].assets.push({ - id: row.assets.id, - assetType: mapDBAssetTypeToUserType(row.assets.assetType), - }); - } - - return acc; - }, - {}, - ); - - const bookmarksArr = Object.values(bookmarksRes); - - bookmarksArr.sort((a, b) => { - if (a.createdAt != b.createdAt) { - return input.sortOrder === "asc" - ? a.createdAt.getTime() - b.createdAt.getTime() - : b.createdAt.getTime() - a.createdAt.getTime(); - } else { - return b.id.localeCompare(a.id); - } - }); - - let nextCursor = null; - if (bookmarksArr.length > input.limit) { - const nextItem = bookmarksArr.pop()!; - nextCursor = { - id: nextItem.id, - createdAt: nextItem.createdAt, - }; - } - - return { bookmarks: bookmarksArr, nextCursor }; + const res = await Bookmark.loadMulti(ctx, input); + return { + bookmarks: res.bookmarks.map((b) => b.asZBookmark()), + nextCursor: res.nextCursor, + }; }), updateTags: authedProcedure diff --git a/packages/trpc/routers/feeds.ts b/packages/trpc/routers/feeds.ts index 819ba8ef..07c48f0a 100644 --- a/packages/trpc/routers/feeds.ts +++ b/packages/trpc/routers/feeds.ts @@ -55,6 +55,7 @@ export const feedsAppRouter = router({ name: input.name, url: input.url, userId: ctx.user.id, + enabled: input.enabled, }) .returning(); return feed; @@ -69,6 +70,7 @@ export const feedsAppRouter = router({ .set({ name: input.name, url: input.url, + enabled: input.enabled, }) .where( and( diff --git a/packages/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts index 65cffd2d..bb949962 100644 --- a/packages/trpc/routers/lists.ts +++ b/packages/trpc/routers/lists.ts @@ -131,4 +131,47 @@ export const listsAppRouter = router({ const sizes = await Promise.all(lists.map((l) => l.getSize())); return { stats: new Map(lists.map((l, i) => [l.list.id, sizes[i]])) }; }), + + // Rss endpoints + regenRssToken: authedProcedure + .input( + z.object({ + listId: z.string(), + }), + ) + .output( + z.object({ + token: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + const list = await List.fromId(ctx, input.listId); + const token = await list.regenRssToken(); + return { token: token! }; + }), + clearRssToken: authedProcedure + .input( + z.object({ + listId: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + const list = await List.fromId(ctx, input.listId); + await list.clearRssToken(); + }), + getRssToken: authedProcedure + .input( + z.object({ + listId: z.string(), + }), + ) + .output( + z.object({ + token: z.string().nullable(), + }), + ) + .query(async ({ input, ctx }) => { + const list = await List.fromId(ctx, input.listId); + return { token: await list.getRssToken() }; + }), }); diff --git a/packages/trpc/routers/publicBookmarks.ts b/packages/trpc/routers/publicBookmarks.ts new file mode 100644 index 00000000..6b643354 --- /dev/null +++ b/packages/trpc/routers/publicBookmarks.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; + +import { + MAX_NUM_BOOKMARKS_PER_PAGE, + zPublicBookmarkSchema, + zSortOrder, +} from "@karakeep/shared/types/bookmarks"; +import { zBookmarkListSchema } from "@karakeep/shared/types/lists"; +import { zCursorV2 } from "@karakeep/shared/types/pagination"; + +import { publicProcedure, router } from "../index"; +import { List } from "../models/lists"; + +export const publicBookmarks = router({ + getPublicBookmarksInList: publicProcedure + .input( + z.object({ + listId: z.string(), + cursor: zCursorV2.nullish(), + limit: z.number().max(MAX_NUM_BOOKMARKS_PER_PAGE).default(20), + sortOrder: zSortOrder.exclude(["relevance"]).optional().default("desc"), + }), + ) + .output( + z.object({ + list: zBookmarkListSchema + .pick({ + name: true, + description: true, + icon: true, + }) + .merge(z.object({ numItems: z.number() })), + bookmarks: z.array(zPublicBookmarkSchema), + nextCursor: zCursorV2.nullable(), + }), + ) + .query(async ({ input, ctx }) => { + return await List.getPublicListContents( + ctx, + input.listId, + /* token */ null, + { + limit: input.limit, + order: input.sortOrder, + cursor: input.cursor, + }, + ); + }), +}); diff --git a/packages/trpc/routers/tags.ts b/packages/trpc/routers/tags.ts index 7f75c16e..cade4b45 100644 --- a/packages/trpc/routers/tags.ts +++ b/packages/trpc/routers/tags.ts @@ -7,7 +7,9 @@ import { SqliteError } from "@karakeep/db"; import { bookmarkTags, tagsOnBookmarks } from "@karakeep/db/schema"; import { triggerSearchReindex } from "@karakeep/shared/queues"; import { + zCreateTagRequestSchema, zGetTagResponseSchema, + zTagBasicSchema, zUpdateTagRequestSchema, } from "@karakeep/shared/types/tags"; @@ -53,19 +55,8 @@ export const ensureTagOwnership = experimental_trpcMiddleware<{ export const tagsAppRouter = router({ create: authedProcedure - .input( - z.object({ - name: z.string().min(1), // Ensure the name is provided and not empty - }), - ) - .output( - z.object({ - id: z.string(), - name: z.string(), - userId: z.string(), - createdAt: z.date(), - }), - ) + .input(zCreateTagRequestSchema) + .output(zTagBasicSchema) .mutation(async ({ input, ctx }) => { try { const [newTag] = await ctx.db @@ -76,7 +67,10 @@ export const tagsAppRouter = router({ }) .returning(); - return newTag; + return { + id: newTag.id, + name: newTag.name, + }; } catch (e) { if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { throw new TRPCError({ @@ -195,14 +189,7 @@ export const tagsAppRouter = router({ }), update: authedProcedure .input(zUpdateTagRequestSchema) - .output( - z.object({ - id: z.string(), - name: z.string(), - userId: z.string(), - createdAt: z.date(), - }), - ) + .output(zTagBasicSchema) .use(ensureTagOwnership) .mutation(async ({ input, ctx }) => { try { @@ -242,7 +229,10 @@ export const tagsAppRouter = router({ console.error("Failed to reindex affected bookmarks", e); } - return res[0]; + return { + id: res[0].id, + name: res[0].name, + }; } catch (e) { if (e instanceof SqliteError) { if (e.code == "SQLITE_CONSTRAINT_UNIQUE") { diff --git a/packages/trpc/routers/users.test.ts b/packages/trpc/routers/users.test.ts index ea342d33..3fd939b1 100644 --- a/packages/trpc/routers/users.test.ts +++ b/packages/trpc/routers/users.test.ts @@ -94,4 +94,41 @@ describe("User Routes", () => { // A normal user can't list all users await expect(() => user2Caller.users.list()).rejects.toThrow(/FORBIDDEN/); }); + + test<CustomTestContext>("get/update user settings", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "testupdate@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + const caller = getApiCaller(db, user.id); + + const settings = await caller.users.settings(); + // The default settings + expect(settings).toEqual({ + bookmarkClickAction: "open_original_link", + archiveDisplayBehaviour: "show", + }); + + // Update settings + await caller.users.updateSettings({ + bookmarkClickAction: "expand_bookmark_preview", + }); + + // Verify updated settings + const updatedSettings = await caller.users.settings(); + expect(updatedSettings).toEqual({ + bookmarkClickAction: "expand_bookmark_preview", + archiveDisplayBehaviour: "show", + }); + + // Test invalid update (e.g., empty input, if schema enforces it) + await expect(() => caller.users.updateSettings({})).rejects.toThrow( + /No settings provided/, + ); + }); }); diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts index c56daaee..3813387f 100644 --- a/packages/trpc/routers/users.ts +++ b/packages/trpc/routers/users.ts @@ -10,11 +10,14 @@ import { bookmarkTags, highlights, users, + userSettings, } from "@karakeep/db/schema"; import { deleteUserAssets } from "@karakeep/shared/assetdb"; import serverConfig from "@karakeep/shared/config"; import { zSignUpSchema, + zUpdateUserSettingsSchema, + zUserSettingsSchema, zUserStatsResponseSchema, zWhoAmIResponseSchema, } from "@karakeep/shared/types/users"; @@ -59,6 +62,12 @@ export async function createUser( email: users.email, role: users.role, }); + + // Insert user settings for the new user + await trx.insert(userSettings).values({ + userId: result[0].id, + }); + return result[0]; } catch (e) { if (e instanceof SqliteError) { @@ -242,4 +251,38 @@ export const usersAppRouter = router({ numHighlights, }; }), + settings: authedProcedure + .output(zUserSettingsSchema) + .query(async ({ ctx }) => { + const settings = await ctx.db.query.userSettings.findFirst({ + where: eq(userSettings.userId, ctx.user.id), + }); + if (!settings) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User settings not found", + }); + } + return { + bookmarkClickAction: settings.bookmarkClickAction, + archiveDisplayBehaviour: settings.archiveDisplayBehaviour, + }; + }), + updateSettings: authedProcedure + .input(zUpdateUserSettingsSchema) + .mutation(async ({ input, ctx }) => { + if (Object.keys(input).length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No settings provided", + }); + } + await ctx.db + .update(userSettings) + .set({ + bookmarkClickAction: input.bookmarkClickAction, + archiveDisplayBehaviour: input.archiveDisplayBehaviour, + }) + .where(eq(userSettings.userId, ctx.user.id)); + }), }); |
