aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/models/lists.ts
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-05-31 18:46:04 +0100
committerGitHub <noreply@github.com>2025-05-31 18:46:04 +0100
commit9695bba2e993b48ae333da622fa459dbaacb9349 (patch)
treec6bffbcdd73151671343f27012e82bea5a05ab6b /packages/trpc/models/lists.ts
parentb218118b84291de4a9c1cd400dc58afab7054b78 (diff)
downloadkarakeep-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.ts99
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>;