aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-06-01 20:46:41 +0100
committerGitHub <noreply@github.com>2025-06-01 20:46:41 +0100
commitea1d0023bfee55358ebb1a96f3d06e783a219c0d (patch)
tree5bddd451728cb7dd377574a9ea1ea591bca069c4 /packages/trpc
parent3afe1e21df6dcc0483e74e0db02d9d82af32ecea (diff)
downloadkarakeep-ea1d0023bfee55358ebb1a96f3d06e783a219c0d.tar.zst
feat: Add support for public lists (#1511)
* WIP: public lists * Drop viewing modes * Add the public endpoint for assets * regen the openapi spec * proper handling for different asset types * Add num bookmarks and a no bookmark banner * Correctly set page title * Add a not-found page * merge the RSS and public list endpoints * Add e2e tests for the public endpoints * Redesign the share list modal * Make NEXTAUTH_SECRET not required * propery render text bookmarks * rebase migration * fix public token tests * Add more tests
Diffstat (limited to 'packages/trpc')
-rw-r--r--packages/trpc/models/bookmarks.ts59
-rw-r--r--packages/trpc/models/lists.ts22
-rw-r--r--packages/trpc/routers/_app.ts2
-rw-r--r--packages/trpc/routers/publicBookmarks.ts49
4 files changed, 125 insertions, 7 deletions
diff --git a/packages/trpc/models/bookmarks.ts b/packages/trpc/models/bookmarks.ts
index 524749f9..6e9e5651 100644
--- a/packages/trpc/models/bookmarks.ts
+++ b/packages/trpc/models/bookmarks.ts
@@ -27,6 +27,9 @@ import {
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,
@@ -36,7 +39,10 @@ import {
ZPublicBookmark,
} from "@karakeep/shared/types/bookmarks";
import { ZCursor } from "@karakeep/shared/types/pagination";
-import { getBookmarkTitle } from "@karakeep/shared/utils/bookmarkUtils";
+import {
+ getBookmarkLinkAssetIdOrUrl,
+ getBookmarkTitle,
+} from "@karakeep/shared/utils/bookmarkUtils";
import { AuthedContext } from "..";
import { mapDBAssetTypeToUserType } from "../lib/attachments";
@@ -321,6 +327,14 @@ export class Bookmark implements PrivacyAware {
}
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"] => {
@@ -342,6 +356,7 @@ export class Bookmark implements PrivacyAware {
type: BookmarkTypes.ASSET,
assetType: content.assetType,
assetId: content.assetId,
+ assetUrl: getPublicSignedAssetUrl(content.assetId),
fileName: content.fileName,
sourceUrl: content.sourceUrl,
};
@@ -352,6 +367,47 @@ export class Bookmark implements PrivacyAware {
}
};
+ 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,
@@ -360,6 +416,7 @@ export class Bookmark implements PrivacyAware {
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 4413a8cd..2631ca7e 100644
--- a/packages/trpc/models/lists.ts
+++ b/packages/trpc/models/lists.ts
@@ -1,6 +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";
@@ -8,11 +8,13 @@ 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, Context } from "..";
import { buildImpersonatingAuthedContext } from "../lib/impersonate";
@@ -61,18 +63,23 @@ export abstract class List implements PrivacyAware {
}
}
- static async getForRss(
+ static async getPublicListContents(
ctx: Context,
listId: string,
- token: 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),
- eq(bookmarkLists.rssToken, token),
+ or(
+ eq(bookmarkLists.public, true),
+ token !== null ? eq(bookmarkLists.rssToken, token) : undefined,
+ ),
),
});
if (!listdb) {
@@ -85,7 +92,6 @@ export abstract class List implements PrivacyAware {
// 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();
@@ -94,7 +100,8 @@ export abstract class List implements PrivacyAware {
ids: bookmarkIds,
includeContent: false,
limit: pagination.limit,
- sortOrder: "desc",
+ sortOrder: pagination.order,
+ cursor: pagination.cursor,
});
return {
@@ -102,8 +109,10 @@ export abstract class List implements PrivacyAware {
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,
};
}
@@ -185,6 +194,7 @@ export abstract class List implements PrivacyAware {
icon: input.icon,
parentId: input.parentId,
query: input.query,
+ public: input.public,
})
.where(
and(
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/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,
+ },
+ );
+ }),
+});