aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc
diff options
context:
space:
mode:
Diffstat (limited to 'packages/trpc')
-rw-r--r--packages/trpc/lib/__tests__/search.test.ts69
-rw-r--r--packages/trpc/lib/attachments.ts2
-rw-r--r--packages/trpc/lib/impersonate.ts30
-rw-r--r--packages/trpc/lib/search.ts110
-rw-r--r--packages/trpc/models/bookmarks.ts422
-rw-r--r--packages/trpc/models/lists.ts140
-rw-r--r--packages/trpc/routers/_app.ts2
-rw-r--r--packages/trpc/routers/admin.ts36
-rw-r--r--packages/trpc/routers/bookmarks.test.ts6
-rw-r--r--packages/trpc/routers/bookmarks.ts286
-rw-r--r--packages/trpc/routers/feeds.ts2
-rw-r--r--packages/trpc/routers/lists.ts43
-rw-r--r--packages/trpc/routers/publicBookmarks.ts49
-rw-r--r--packages/trpc/routers/tags.ts36
-rw-r--r--packages/trpc/routers/users.test.ts37
-rw-r--r--packages/trpc/routers/users.ts43
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));
+ }),
});