diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-06-01 20:46:41 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-06-01 20:46:41 +0100 |
| commit | ea1d0023bfee55358ebb1a96f3d06e783a219c0d (patch) | |
| tree | 5bddd451728cb7dd377574a9ea1ea591bca069c4 /packages/trpc | |
| parent | 3afe1e21df6dcc0483e74e0db02d9d82af32ecea (diff) | |
| download | karakeep-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.ts | 59 | ||||
| -rw-r--r-- | packages/trpc/models/lists.ts | 22 | ||||
| -rw-r--r-- | packages/trpc/routers/_app.ts | 2 | ||||
| -rw-r--r-- | packages/trpc/routers/publicBookmarks.ts | 49 |
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, + }, + ); + }), +}); |
