diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-05-31 18:46:04 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-05-31 18:46:04 +0100 |
| commit | 9695bba2e993b48ae333da622fa459dbaacb9349 (patch) | |
| tree | c6bffbcdd73151671343f27012e82bea5a05ab6b /packages/trpc/models/lists.ts | |
| parent | b218118b84291de4a9c1cd400dc58afab7054b78 (diff) | |
| download | karakeep-9695bba2e993b48ae333da622fa459dbaacb9349.tar.zst | |
feat: Generate RSS feeds from lists (#1507)
* refactor: Move bookmark utils from shared-react to shared
* Expose RSS feeds for lists
* Add e2e tests
* Slightly improve the look of the share dialog
* allow specifying a limit in the rss endpoint
Diffstat (limited to 'packages/trpc/models/lists.ts')
| -rw-r--r-- | packages/trpc/models/lists.ts | 99 |
1 files changed, 97 insertions, 2 deletions
diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts index 21b23593..4413a8cd 100644 --- a/packages/trpc/models/lists.ts +++ b/packages/trpc/models/lists.ts @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import { TRPCError } from "@trpc/server"; import { and, count, eq } from "drizzle-orm"; import invariant from "tiny-invariant"; @@ -13,8 +14,10 @@ import { zNewBookmarkListSchema, } from "@karakeep/shared/types/lists"; -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 { @@ -58,6 +61,52 @@ export abstract class List implements PrivacyAware { } } + static async getForRss( + ctx: Context, + listId: string, + token: string, + pagination: { + limit: number; + }, + ) { + const listdb = await ctx.db.query.bookmarkLists.findFirst({ + where: and( + eq(bookmarkLists.id, listId), + eq(bookmarkLists.rssToken, token), + ), + }); + 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: "desc", + }); + + return { + list: { + icon: list.list.icon, + name: list.list.name, + description: list.list.description, + }, + bookmarks: bookmarks.bookmarks.map((b) => b.asPublicBookmark()), + }; + } + static async create( ctx: AuthedContext, input: z.infer<typeof zNewBookmarkListSchema>, @@ -79,6 +128,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)); @@ -88,7 +140,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)); @@ -143,6 +199,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>; |
