aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--packages/trpc/auth.ts13
-rw-r--r--packages/trpc/email.ts228
-rw-r--r--packages/trpc/index.ts5
-rw-r--r--packages/trpc/lib/__tests__/ruleEngine.test.ts70
-rw-r--r--packages/trpc/lib/__tests__/search.test.ts55
-rw-r--r--packages/trpc/lib/attachments.ts10
-rw-r--r--packages/trpc/lib/ruleEngine.ts31
-rw-r--r--packages/trpc/lib/search.ts91
-rw-r--r--packages/trpc/lib/tracing.ts63
-rw-r--r--packages/trpc/models/assets.ts282
-rw-r--r--packages/trpc/models/bookmarks.ts406
-rw-r--r--packages/trpc/models/feeds.ts17
-rw-r--r--packages/trpc/models/importSessions.ts172
-rw-r--r--packages/trpc/models/listInvitations.ts1
-rw-r--r--packages/trpc/models/lists.ts31
-rw-r--r--packages/trpc/models/tags.ts4
-rw-r--r--packages/trpc/models/users.ts481
-rw-r--r--packages/trpc/models/webhooks.ts17
-rw-r--r--packages/trpc/package.json2
-rw-r--r--packages/trpc/routers/_app.ts2
-rw-r--r--packages/trpc/routers/admin.test.ts265
-rw-r--r--packages/trpc/routers/admin.ts248
-rw-r--r--packages/trpc/routers/apiKeys.ts5
-rw-r--r--packages/trpc/routers/assets.ts160
-rw-r--r--packages/trpc/routers/bookmarks.test.ts192
-rw-r--r--packages/trpc/routers/bookmarks.ts574
-rw-r--r--packages/trpc/routers/config.ts10
-rw-r--r--packages/trpc/routers/feeds.test.ts154
-rw-r--r--packages/trpc/routers/importSessions.test.ts233
-rw-r--r--packages/trpc/routers/importSessions.ts109
-rw-r--r--packages/trpc/routers/lists.test.ts382
-rw-r--r--packages/trpc/routers/lists.ts2
-rw-r--r--packages/trpc/routers/tags.ts1
-rw-r--r--packages/trpc/routers/users.test.ts111
-rw-r--r--packages/trpc/routers/users.ts46
-rw-r--r--packages/trpc/routers/webhooks.test.ts22
36 files changed, 3657 insertions, 838 deletions
diff --git a/packages/trpc/auth.ts b/packages/trpc/auth.ts
index d252bebb..764a0904 100644
--- a/packages/trpc/auth.ts
+++ b/packages/trpc/auth.ts
@@ -125,6 +125,19 @@ export async function authenticateApiKey(key: string, database: Context["db"]) {
throw new Error("Invalid API Key");
}
+ // Update lastUsedAt with 10-minute throttle to avoid excessive DB writes
+ const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
+ if (!apiKey.lastUsedAt || apiKey.lastUsedAt < tenMinutesAgo) {
+ // Fire and forget - don't await to avoid blocking the auth response
+ database
+ .update(apiKeys)
+ .set({ lastUsedAt: new Date() })
+ .where(eq(apiKeys.id, apiKey.id))
+ .catch((err) => {
+ console.error("Failed to update API key lastUsedAt:", err);
+ });
+ }
+
return apiKey.user;
}
diff --git a/packages/trpc/email.ts b/packages/trpc/email.ts
index 3c0b8b39..15e1ef74 100644
--- a/packages/trpc/email.ts
+++ b/packages/trpc/email.ts
@@ -1,17 +1,15 @@
import { createTransport } from "nodemailer";
+import { getTracer, withSpan } from "@karakeep/shared-server";
import serverConfig from "@karakeep/shared/config";
-export async function sendVerificationEmail(
- email: string,
- name: string,
- token: string,
-) {
+const tracer = getTracer("@karakeep/trpc");
+
+function buildTransporter() {
if (!serverConfig.email.smtp) {
throw new Error("SMTP is not configured");
}
-
- const transporter = createTransport({
+ return createTransport({
host: serverConfig.email.smtp.host,
port: serverConfig.email.smtp.port,
secure: serverConfig.email.smtp.secure,
@@ -23,14 +21,52 @@ export async function sendVerificationEmail(
}
: undefined,
});
+}
- const verificationUrl = `${serverConfig.publicUrl}/verify-email?token=${encodeURIComponent(token)}&email=${encodeURIComponent(email)}`;
+type Transporter = ReturnType<typeof buildTransporter>;
- const mailOptions = {
- from: serverConfig.email.smtp.from,
- to: email,
- subject: "Verify your email address",
- html: `
+type Fn<Args extends unknown[] = unknown[]> = (
+ transport: Transporter,
+ ...args: Args
+) => Promise<void>;
+
+interface TracingOptions {
+ silentFail?: boolean;
+}
+
+function withTracing<Args extends unknown[]>(
+ name: string,
+ fn: Fn<Args>,
+ options: TracingOptions = {},
+) {
+ return async (...args: Args): Promise<void> => {
+ if (options.silentFail && !serverConfig.email.smtp) {
+ return;
+ }
+ const transporter = buildTransporter();
+ await withSpan(tracer, name, {}, () => fn(transporter, ...args));
+ };
+}
+
+export const sendVerificationEmail = withTracing(
+ "sendVerificationEmail",
+ async (
+ transporter: Transporter,
+ email: string,
+ name: string,
+ token: string,
+ redirectUrl?: string,
+ ) => {
+ let verificationUrl = `${serverConfig.publicUrl}/verify-email?token=${encodeURIComponent(token)}&email=${encodeURIComponent(email)}`;
+ if (redirectUrl) {
+ verificationUrl += `&redirectUrl=${encodeURIComponent(redirectUrl)}`;
+ }
+
+ const mailOptions = {
+ from: serverConfig.email.smtp!.from,
+ to: email,
+ subject: "Verify your email address",
+ html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Welcome to Karakeep, ${name}!</h2>
<p>Please verify your email address by clicking the link below:</p>
@@ -45,7 +81,7 @@ export async function sendVerificationEmail(
<p>If you didn't create an account with us, please ignore this email.</p>
</div>
`,
- text: `
+ text: `
Welcome to Karakeep, ${name}!
Please verify your email address by visiting this link:
@@ -55,40 +91,27 @@ This link will expire in 24 hours.
If you didn't create an account with us, please ignore this email.
`,
- };
+ };
- await transporter.sendMail(mailOptions);
-}
+ await transporter.sendMail(mailOptions);
+ },
+);
-export async function sendInviteEmail(
- email: string,
- token: string,
- inviterName: string,
-) {
- if (!serverConfig.email.smtp) {
- throw new Error("SMTP is not configured");
- }
-
- const transporter = createTransport({
- host: serverConfig.email.smtp.host,
- port: serverConfig.email.smtp.port,
- secure: serverConfig.email.smtp.secure,
- auth:
- serverConfig.email.smtp.user && serverConfig.email.smtp.password
- ? {
- user: serverConfig.email.smtp.user,
- pass: serverConfig.email.smtp.password,
- }
- : undefined,
- });
-
- const inviteUrl = `${serverConfig.publicUrl}/invite/${encodeURIComponent(token)}`;
+export const sendInviteEmail = withTracing(
+ "sendInviteEmail",
+ async (
+ transporter: Transporter,
+ email: string,
+ token: string,
+ inviterName: string,
+ ) => {
+ const inviteUrl = `${serverConfig.publicUrl}/invite/${encodeURIComponent(token)}`;
- const mailOptions = {
- from: serverConfig.email.smtp.from,
- to: email,
- subject: "You've been invited to join Karakeep",
- html: `
+ const mailOptions = {
+ from: serverConfig.email.smtp!.from,
+ to: email,
+ subject: "You've been invited to join Karakeep",
+ html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2>You've been invited to join Karakeep!</h2>
<p>${inviterName} has invited you to join Karakeep, the bookmark everything app.</p>
@@ -104,7 +127,7 @@ export async function sendInviteEmail(
<p>If you weren't expecting this invitation, you can safely ignore this email.</p>
</div>
`,
- text: `
+ text: `
You've been invited to join Karakeep!
${inviterName} has invited you to join Karakeep, a powerful bookmarking and content organization platform.
@@ -116,40 +139,27 @@ ${inviteUrl}
If you weren't expecting this invitation, you can safely ignore this email.
`,
- };
+ };
- await transporter.sendMail(mailOptions);
-}
+ await transporter.sendMail(mailOptions);
+ },
+);
-export async function sendPasswordResetEmail(
- email: string,
- name: string,
- token: string,
-) {
- if (!serverConfig.email.smtp) {
- throw new Error("SMTP is not configured");
- }
-
- const transporter = createTransport({
- host: serverConfig.email.smtp.host,
- port: serverConfig.email.smtp.port,
- secure: serverConfig.email.smtp.secure,
- auth:
- serverConfig.email.smtp.user && serverConfig.email.smtp.password
- ? {
- user: serverConfig.email.smtp.user,
- pass: serverConfig.email.smtp.password,
- }
- : undefined,
- });
-
- const resetUrl = `${serverConfig.publicUrl}/reset-password?token=${encodeURIComponent(token)}`;
+export const sendPasswordResetEmail = withTracing(
+ "sendPasswordResetEmail",
+ async (
+ transporter: Transporter,
+ email: string,
+ name: string,
+ token: string,
+ ) => {
+ const resetUrl = `${serverConfig.publicUrl}/reset-password?token=${encodeURIComponent(token)}`;
- const mailOptions = {
- from: serverConfig.email.smtp.from,
- to: email,
- subject: "Reset your password",
- html: `
+ const mailOptions = {
+ from: serverConfig.email.smtp!.from,
+ to: email,
+ subject: "Reset your password",
+ html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Password Reset Request</h2>
<p>Hi ${name},</p>
@@ -165,7 +175,7 @@ export async function sendPasswordResetEmail(
<p>If you didn't request a password reset, please ignore this email. Your password will remain unchanged.</p>
</div>
`,
- text: `
+ text: `
Hi ${name},
You requested to reset your password for your Karakeep account. Visit this link to reset your password:
@@ -175,42 +185,28 @@ This link will expire in 1 hour.
If you didn't request a password reset, please ignore this email. Your password will remain unchanged.
`,
- };
-
- await transporter.sendMail(mailOptions);
-}
-
-export async function sendListInvitationEmail(
- email: string,
- inviterName: string,
- listName: string,
- listId: string,
-) {
- if (!serverConfig.email.smtp) {
- // Silently fail if email is not configured
- return;
- }
+ };
- const transporter = createTransport({
- host: serverConfig.email.smtp.host,
- port: serverConfig.email.smtp.port,
- secure: serverConfig.email.smtp.secure,
- auth:
- serverConfig.email.smtp.user && serverConfig.email.smtp.password
- ? {
- user: serverConfig.email.smtp.user,
- pass: serverConfig.email.smtp.password,
- }
- : undefined,
- });
+ await transporter.sendMail(mailOptions);
+ },
+);
- const inviteUrl = `${serverConfig.publicUrl}/dashboard/lists?pendingInvitation=${encodeURIComponent(listId)}`;
+export const sendListInvitationEmail = withTracing(
+ "sendListInvitationEmail",
+ async (
+ transporter: Transporter,
+ email: string,
+ inviterName: string,
+ listName: string,
+ listId: string,
+ ) => {
+ const inviteUrl = `${serverConfig.publicUrl}/dashboard/lists?pendingInvitation=${encodeURIComponent(listId)}`;
- const mailOptions = {
- from: serverConfig.email.smtp.from,
- to: email,
- subject: `${inviterName} invited you to collaborate on "${listName}"`,
- html: `
+ const mailOptions = {
+ from: serverConfig.email.smtp!.from,
+ to: email,
+ subject: `${inviterName} invited you to collaborate on "${listName}"`,
+ html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2>You've been invited to collaborate on a list!</h2>
<p>${inviterName} has invited you to collaborate on the list <strong>"${listName}"</strong> in Karakeep.</p>
@@ -226,7 +222,7 @@ export async function sendListInvitationEmail(
<p>If you weren't expecting this invitation, you can safely ignore this email or decline it in your dashboard.</p>
</div>
`,
- text: `
+ text: `
You've been invited to collaborate on a list!
${inviterName} has invited you to collaborate on the list "${listName}" in Karakeep.
@@ -238,7 +234,9 @@ You can accept or decline this invitation from your Karakeep dashboard.
If you weren't expecting this invitation, you can safely ignore this email or decline it in your dashboard.
`,
- };
+ };
- await transporter.sendMail(mailOptions);
-}
+ await transporter.sendMail(mailOptions);
+ },
+ { silentFail: true },
+);
diff --git a/packages/trpc/index.ts b/packages/trpc/index.ts
index 555ca3ba..178703f0 100644
--- a/packages/trpc/index.ts
+++ b/packages/trpc/index.ts
@@ -6,6 +6,7 @@ import type { db } from "@karakeep/db";
import serverConfig from "@karakeep/shared/config";
import { createRateLimitMiddleware } from "./lib/rateLimit";
+import { createTracingMiddleware } from "./lib/tracing";
import {
apiErrorsTotalCounter,
apiRequestDurationSummary,
@@ -86,7 +87,9 @@ export const procedure = t.procedure
});
end();
return res;
- });
+ })
+ // OpenTelemetry tracing middleware
+ .use(createTracingMiddleware());
// Default public procedure rate limiting
export const publicProcedure = procedure.use(
diff --git a/packages/trpc/lib/__tests__/ruleEngine.test.ts b/packages/trpc/lib/__tests__/ruleEngine.test.ts
index ede22ec6..b737e3a5 100644
--- a/packages/trpc/lib/__tests__/ruleEngine.test.ts
+++ b/packages/trpc/lib/__tests__/ruleEngine.test.ts
@@ -126,6 +126,7 @@ describe("RuleEngine", () => {
.values({
userId,
type: BookmarkTypes.LINK,
+ title: "Example Bookmark Title",
favourited: false,
archived: false,
})
@@ -171,10 +172,9 @@ describe("RuleEngine", () => {
expect(engine).toBeInstanceOf(RuleEngine);
});
- it("should throw an error if bookmark is not found", async () => {
- await expect(
- RuleEngine.forBookmark(ctx, "nonexistent-bookmark"),
- ).rejects.toThrow("Bookmark nonexistent-bookmark not found");
+ it("should return null if bookmark is not found", async () => {
+ const engine = await RuleEngine.forBookmark(ctx, "nonexistent-bookmark");
+ expect(engine).toBeNull();
});
it("should load rules associated with the bookmark's user", async () => {
@@ -188,7 +188,7 @@ describe("RuleEngine", () => {
actions: [{ type: "addTag", tagId: tagId2 }],
});
- const engine = await RuleEngine.forBookmark(ctx, bookmarkId);
+ const engine = (await RuleEngine.forBookmark(ctx, bookmarkId))!;
// @ts-expect-error Accessing private property for test verification
expect(engine.rules).toHaveLength(1);
// @ts-expect-error Accessing private property for test verification
@@ -200,7 +200,7 @@ describe("RuleEngine", () => {
let engine: RuleEngine;
beforeEach(async () => {
- engine = await RuleEngine.forBookmark(ctx, bookmarkId);
+ engine = (await RuleEngine.forBookmark(ctx, bookmarkId))!;
});
it("should return true for urlContains condition", () => {
@@ -219,6 +219,54 @@ describe("RuleEngine", () => {
expect(engine.doesBookmarkMatchConditions(condition)).toBe(false);
});
+ it("should return false for urlDoesNotContain condition when URL contains string", () => {
+ const condition: RuleEngineCondition = {
+ type: "urlDoesNotContain",
+ str: "example.com",
+ };
+ expect(engine.doesBookmarkMatchConditions(condition)).toBe(false);
+ });
+
+ it("should return true for urlDoesNotContain condition when URL does not contain string", () => {
+ const condition: RuleEngineCondition = {
+ type: "urlDoesNotContain",
+ str: "nonexistent",
+ };
+ expect(engine.doesBookmarkMatchConditions(condition)).toBe(true);
+ });
+
+ it("should return true for titleContains condition", () => {
+ const condition: RuleEngineCondition = {
+ type: "titleContains",
+ str: "Example",
+ };
+ expect(engine.doesBookmarkMatchConditions(condition)).toBe(true);
+ });
+
+ it("should return false for titleContains condition mismatch", () => {
+ const condition: RuleEngineCondition = {
+ type: "titleContains",
+ str: "nonexistent",
+ };
+ expect(engine.doesBookmarkMatchConditions(condition)).toBe(false);
+ });
+
+ it("should return false for titleDoesNotContain condition when title contains string", () => {
+ const condition: RuleEngineCondition = {
+ type: "titleDoesNotContain",
+ str: "Example",
+ };
+ expect(engine.doesBookmarkMatchConditions(condition)).toBe(false);
+ });
+
+ it("should return true for titleDoesNotContain condition when title does not contain string", () => {
+ const condition: RuleEngineCondition = {
+ type: "titleDoesNotContain",
+ str: "nonexistent",
+ };
+ expect(engine.doesBookmarkMatchConditions(condition)).toBe(true);
+ });
+
it("should return true for importedFromFeed condition", () => {
const condition: RuleEngineCondition = {
type: "importedFromFeed",
@@ -271,7 +319,7 @@ describe("RuleEngine", () => {
.update(bookmarks)
.set({ favourited: true })
.where(eq(bookmarks.id, bookmarkId));
- const updatedEngine = await RuleEngine.forBookmark(ctx, bookmarkId);
+ const updatedEngine = (await RuleEngine.forBookmark(ctx, bookmarkId))!;
const condition: RuleEngineCondition = { type: "isFavourited" };
expect(updatedEngine.doesBookmarkMatchConditions(condition)).toBe(true);
});
@@ -286,7 +334,7 @@ describe("RuleEngine", () => {
.update(bookmarks)
.set({ archived: true })
.where(eq(bookmarks.id, bookmarkId));
- const updatedEngine = await RuleEngine.forBookmark(ctx, bookmarkId);
+ const updatedEngine = (await RuleEngine.forBookmark(ctx, bookmarkId))!;
const condition: RuleEngineCondition = { type: "isArchived" };
expect(updatedEngine.doesBookmarkMatchConditions(condition)).toBe(true);
});
@@ -354,7 +402,7 @@ describe("RuleEngine", () => {
} as Omit<RuleEngineRule, "id"> & { userId: string };
ruleId = await seedRule(tmp);
testRule = { ...tmp, id: ruleId };
- engine = await RuleEngine.forBookmark(ctx, bookmarkId);
+ engine = (await RuleEngine.forBookmark(ctx, bookmarkId))!;
});
it("should evaluate rule successfully when event and conditions match", async () => {
@@ -443,7 +491,7 @@ describe("RuleEngine", () => {
let engine: RuleEngine;
beforeEach(async () => {
- engine = await RuleEngine.forBookmark(ctx, bookmarkId);
+ engine = (await RuleEngine.forBookmark(ctx, bookmarkId))!;
});
it("should execute addTag action", async () => {
@@ -625,7 +673,7 @@ describe("RuleEngine", () => {
actions: [{ type: "addToList", listId: listId1 }],
});
- engine = await RuleEngine.forBookmark(ctx, bookmarkId);
+ engine = (await RuleEngine.forBookmark(ctx, bookmarkId))!;
});
it("should process event and return only results for matching, enabled rules", async () => {
diff --git a/packages/trpc/lib/__tests__/search.test.ts b/packages/trpc/lib/__tests__/search.test.ts
index ee8bfb60..d39e27a6 100644
--- a/packages/trpc/lib/__tests__/search.test.ts
+++ b/packages/trpc/lib/__tests__/search.test.ts
@@ -240,6 +240,61 @@ describe("getBookmarkIdsFromMatcher", () => {
expect(result.sort()).toEqual(["b2", "b3", "b4", "b5"]);
});
+ it("should handle listName matcher when multiple lists share the same name", async () => {
+ await mockCtx.db.insert(bookmarkLists).values({
+ id: "l5",
+ userId: testUserId,
+ name: "list1",
+ icon: "🚀",
+ type: "manual",
+ });
+ await mockCtx.db.insert(bookmarksInLists).values({
+ bookmarkId: "b2",
+ listId: "l5",
+ });
+
+ const matcher: Matcher = {
+ type: "listName",
+ listName: "list1",
+ inverse: false,
+ };
+ const result = await getBookmarkIdsFromMatcher(mockCtx, matcher);
+ expect(result.sort()).toEqual(["b1", "b2", "b6"]);
+ });
+
+ it("should handle inverse listName matcher when multiple lists share the same name", async () => {
+ await mockCtx.db.insert(bookmarkLists).values({
+ id: "l5",
+ userId: testUserId,
+ name: "list1",
+ icon: "🚀",
+ type: "manual",
+ });
+ await mockCtx.db.insert(bookmarksInLists).values({
+ bookmarkId: "b2",
+ listId: "l5",
+ });
+
+ const matcher: Matcher = {
+ type: "listName",
+ listName: "list1",
+ inverse: true,
+ };
+ const result = await getBookmarkIdsFromMatcher(mockCtx, matcher);
+ expect(result.sort()).toEqual(["b3", "b4", "b5"]);
+ });
+
+ it("should return empty when inverse listName references a missing list", async () => {
+ const matcher: Matcher = {
+ type: "listName",
+ listName: "does-not-exist",
+ inverse: true,
+ };
+
+ const result = await getBookmarkIdsFromMatcher(mockCtx, matcher);
+ expect(result).toEqual([]);
+ });
+
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 25d9be94..f3170c22 100644
--- a/packages/trpc/lib/attachments.ts
+++ b/packages/trpc/lib/attachments.ts
@@ -9,6 +9,7 @@ import {
export function mapDBAssetTypeToUserType(assetType: AssetTypes): ZAssetType {
const map: Record<AssetTypes, z.infer<typeof zAssetTypesSchema>> = {
[AssetTypes.LINK_SCREENSHOT]: "screenshot",
+ [AssetTypes.LINK_PDF]: "pdf",
[AssetTypes.ASSET_SCREENSHOT]: "assetScreenshot",
[AssetTypes.LINK_FULL_PAGE_ARCHIVE]: "fullPageArchive",
[AssetTypes.LINK_PRECRAWLED_ARCHIVE]: "precrawledArchive",
@@ -17,6 +18,7 @@ export function mapDBAssetTypeToUserType(assetType: AssetTypes): ZAssetType {
[AssetTypes.LINK_HTML_CONTENT]: "linkHtmlContent",
[AssetTypes.BOOKMARK_ASSET]: "bookmarkAsset",
[AssetTypes.USER_UPLOADED]: "userUploaded",
+ [AssetTypes.AVATAR]: "avatar",
[AssetTypes.BACKUP]: "unknown", // Backups are not displayed as regular assets
[AssetTypes.UNKNOWN]: "bannerImage",
};
@@ -28,6 +30,7 @@ export function mapSchemaAssetTypeToDB(
): AssetTypes {
const map: Record<ZAssetType, AssetTypes> = {
screenshot: AssetTypes.LINK_SCREENSHOT,
+ pdf: AssetTypes.LINK_PDF,
assetScreenshot: AssetTypes.ASSET_SCREENSHOT,
fullPageArchive: AssetTypes.LINK_FULL_PAGE_ARCHIVE,
precrawledArchive: AssetTypes.LINK_PRECRAWLED_ARCHIVE,
@@ -36,6 +39,7 @@ export function mapSchemaAssetTypeToDB(
bookmarkAsset: AssetTypes.BOOKMARK_ASSET,
linkHtmlContent: AssetTypes.LINK_HTML_CONTENT,
userUploaded: AssetTypes.USER_UPLOADED,
+ avatar: AssetTypes.AVATAR,
unknown: AssetTypes.UNKNOWN,
};
return map[assetType];
@@ -44,6 +48,7 @@ export function mapSchemaAssetTypeToDB(
export function humanFriendlyNameForAssertType(type: ZAssetType) {
const map: Record<ZAssetType, string> = {
screenshot: "Screenshot",
+ pdf: "PDF",
assetScreenshot: "Asset Screenshot",
fullPageArchive: "Full Page Archive",
precrawledArchive: "Precrawled Archive",
@@ -52,6 +57,7 @@ export function humanFriendlyNameForAssertType(type: ZAssetType) {
bookmarkAsset: "Bookmark Asset",
linkHtmlContent: "HTML Content",
userUploaded: "User Uploaded File",
+ avatar: "Avatar",
unknown: "Unknown",
};
return map[type];
@@ -60,6 +66,7 @@ export function humanFriendlyNameForAssertType(type: ZAssetType) {
export function isAllowedToAttachAsset(type: ZAssetType) {
const map: Record<ZAssetType, boolean> = {
screenshot: true,
+ pdf: true,
assetScreenshot: true,
fullPageArchive: false,
precrawledArchive: true,
@@ -68,6 +75,7 @@ export function isAllowedToAttachAsset(type: ZAssetType) {
bookmarkAsset: false,
linkHtmlContent: false,
userUploaded: true,
+ avatar: false,
unknown: false,
};
return map[type];
@@ -76,6 +84,7 @@ export function isAllowedToAttachAsset(type: ZAssetType) {
export function isAllowedToDetachAsset(type: ZAssetType) {
const map: Record<ZAssetType, boolean> = {
screenshot: true,
+ pdf: true,
assetScreenshot: true,
fullPageArchive: true,
precrawledArchive: true,
@@ -84,6 +93,7 @@ export function isAllowedToDetachAsset(type: ZAssetType) {
bookmarkAsset: false,
linkHtmlContent: false,
userUploaded: true,
+ avatar: false,
unknown: false,
};
return map[type];
diff --git a/packages/trpc/lib/ruleEngine.ts b/packages/trpc/lib/ruleEngine.ts
index c191619b..acfd747e 100644
--- a/packages/trpc/lib/ruleEngine.ts
+++ b/packages/trpc/lib/ruleEngine.ts
@@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm";
import { bookmarks, tagsOnBookmarks } from "@karakeep/db/schema";
import { LinkCrawlerQueue } from "@karakeep/shared-server";
+import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
import {
RuleEngineAction,
RuleEngineCondition,
@@ -21,6 +22,7 @@ async function fetchBookmark(db: AuthedContext["db"], bookmarkId: string) {
link: {
columns: {
url: true,
+ title: true,
},
},
text: true,
@@ -60,13 +62,26 @@ export class RuleEngine {
private rules: RuleEngineRule[],
) {}
- static async forBookmark(ctx: AuthedContext, bookmarkId: string) {
+ private get bookmarkTitle(): string {
+ return (
+ this.bookmark.title ??
+ (this.bookmark.type === BookmarkTypes.LINK
+ ? this.bookmark.link?.title
+ : "") ??
+ ""
+ );
+ }
+
+ static async forBookmark(
+ ctx: AuthedContext,
+ bookmarkId: string,
+ ): Promise<RuleEngine | null> {
const [bookmark, rules] = await Promise.all([
fetchBookmark(ctx.db, bookmarkId),
RuleEngineRuleModel.getAll(ctx),
]);
if (!bookmark) {
- throw new Error(`Bookmark ${bookmarkId} not found`);
+ return null;
}
return new RuleEngine(
ctx,
@@ -83,6 +98,18 @@ export class RuleEngine {
case "urlContains": {
return (this.bookmark.link?.url ?? "").includes(condition.str);
}
+ case "urlDoesNotContain": {
+ return (
+ this.bookmark.type == BookmarkTypes.LINK &&
+ !(this.bookmark.link?.url ?? "").includes(condition.str)
+ );
+ }
+ case "titleContains": {
+ return this.bookmarkTitle.includes(condition.str);
+ }
+ case "titleDoesNotContain": {
+ return !this.bookmarkTitle.includes(condition.str);
+ }
case "importedFromFeed": {
return this.bookmark.rssFeeds.some(
(f) => f.rssFeedId === condition.feedId,
diff --git a/packages/trpc/lib/search.ts b/packages/trpc/lib/search.ts
index 88f10f22..d0f529f5 100644
--- a/packages/trpc/lib/search.ts
+++ b/packages/trpc/lib/search.ts
@@ -4,6 +4,7 @@ import {
exists,
gt,
gte,
+ inArray,
isNotNull,
isNull,
like,
@@ -11,6 +12,7 @@ import {
lte,
ne,
notExists,
+ notInArray,
notLike,
or,
} from "drizzle-orm";
@@ -89,10 +91,13 @@ function union(vals: BookmarkQueryReturnType[][]): BookmarkQueryReturnType[] {
}
async function getIds(
- db: AuthedContext["db"],
- userId: string,
+ ctx: AuthedContext,
matcher: Matcher,
+ visitedListIds = new Set<string>(),
): Promise<BookmarkQueryReturnType[]> {
+ const { db } = ctx;
+ const userId = ctx.user.id;
+
switch (matcher.type) {
case "tagName": {
const comp = matcher.inverse ? notExists : exists;
@@ -139,29 +144,54 @@ async function getIds(
);
}
case "listName": {
- const comp = matcher.inverse ? notExists : exists;
+ // First, look up the list by name
+ const lists = await db.query.bookmarkLists.findMany({
+ where: and(
+ eq(bookmarkLists.userId, userId),
+ eq(bookmarkLists.name, matcher.listName),
+ ),
+ });
+
+ if (lists.length === 0) {
+ // No matching lists
+ return [];
+ }
+
+ // Use List model to resolve list membership (manual and smart)
+ // Import dynamically to avoid circular dependency
+ const { List } = await import("../models/lists");
+ const listBookmarkIds = [
+ ...new Set(
+ (
+ await Promise.all(
+ lists.map(async (list) => {
+ const listModel = await List.fromId(ctx, list.id);
+ return await listModel.getBookmarkIds(visitedListIds);
+ }),
+ )
+ ).flat(),
+ ),
+ ];
+
+ if (listBookmarkIds.length === 0) {
+ if (matcher.inverse) {
+ return db
+ .selectDistinct({ id: bookmarks.id })
+ .from(bookmarks)
+ .where(eq(bookmarks.userId, userId));
+ }
+ return [];
+ }
+
return db
.selectDistinct({ id: bookmarks.id })
.from(bookmarks)
.where(
and(
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),
- ),
- ),
- ),
+ matcher.inverse
+ ? notInArray(bookmarks.id, listBookmarkIds)
+ : inArray(bookmarks.id, listBookmarkIds),
),
);
}
@@ -373,15 +403,31 @@ async function getIds(
),
);
}
+ case "source": {
+ return db
+ .select({ id: bookmarks.id })
+ .from(bookmarks)
+ .where(
+ and(
+ eq(bookmarks.userId, userId),
+ matcher.inverse
+ ? or(
+ ne(bookmarks.source, matcher.source),
+ isNull(bookmarks.source),
+ )
+ : eq(bookmarks.source, matcher.source),
+ ),
+ );
+ }
case "and": {
const vals = await Promise.all(
- matcher.matchers.map((m) => getIds(db, userId, m)),
+ matcher.matchers.map((m) => getIds(ctx, m, visitedListIds)),
);
return intersect(vals);
}
case "or": {
const vals = await Promise.all(
- matcher.matchers.map((m) => getIds(db, userId, m)),
+ matcher.matchers.map((m) => getIds(ctx, m, visitedListIds)),
);
return union(vals);
}
@@ -395,7 +441,8 @@ async function getIds(
export async function getBookmarkIdsFromMatcher(
ctx: AuthedContext,
matcher: Matcher,
+ visitedListIds = new Set<string>(),
): Promise<string[]> {
- const results = await getIds(ctx.db, ctx.user.id, matcher);
+ const results = await getIds(ctx, matcher, visitedListIds);
return results.map((r) => r.id);
}
diff --git a/packages/trpc/lib/tracing.ts b/packages/trpc/lib/tracing.ts
new file mode 100644
index 00000000..7b4fb39f
--- /dev/null
+++ b/packages/trpc/lib/tracing.ts
@@ -0,0 +1,63 @@
+import { SpanKind } from "@opentelemetry/api";
+
+import {
+ getTracer,
+ setSpanAttributes,
+ withSpan,
+} from "@karakeep/shared-server";
+import serverConfig from "@karakeep/shared/config";
+
+import type { Context } from "../index";
+
+const tracer = getTracer("@karakeep/trpc");
+
+/**
+ * tRPC middleware that creates a span for each procedure call.
+ * This integrates OpenTelemetry tracing into the tRPC layer.
+ */
+export function createTracingMiddleware() {
+ return async function tracingMiddleware<T>(opts: {
+ ctx: Context;
+ type: "query" | "mutation" | "subscription";
+ path: string;
+ input: unknown;
+ next: () => Promise<T>;
+ }): Promise<T> {
+ // Skip if tracing is disabled
+ if (!serverConfig.tracing.enabled) {
+ return opts.next();
+ }
+
+ const spanName = `trpc.${opts.type}.${opts.path}`;
+
+ return withSpan(
+ tracer,
+ spanName,
+ {
+ kind: SpanKind.SERVER,
+ attributes: {
+ "rpc.system": "trpc",
+ "rpc.method": opts.path,
+ "rpc.type": opts.type,
+ "user.id": opts.ctx.user?.id ?? "anonymous",
+ "user.role": opts.ctx.user?.role ?? "none",
+ },
+ },
+ async () => {
+ return await opts.next();
+ },
+ );
+ };
+}
+
+/**
+ * Helper to add tracing attributes within a tRPC procedure.
+ * Use this to add custom attributes to the current span.
+ */
+export function addTracingAttributes(
+ attributes: Record<string, string | number | boolean>,
+): void {
+ if (serverConfig.tracing.enabled) {
+ setSpanAttributes(attributes);
+ }
+}
diff --git a/packages/trpc/models/assets.ts b/packages/trpc/models/assets.ts
new file mode 100644
index 00000000..f97cfffb
--- /dev/null
+++ b/packages/trpc/models/assets.ts
@@ -0,0 +1,282 @@
+import { TRPCError } from "@trpc/server";
+import { and, desc, eq, sql } from "drizzle-orm";
+import { z } from "zod";
+
+import { assets } from "@karakeep/db/schema";
+import { deleteAsset } from "@karakeep/shared/assetdb";
+import serverConfig from "@karakeep/shared/config";
+import { createSignedToken } from "@karakeep/shared/signedTokens";
+import { zAssetSignedTokenSchema } from "@karakeep/shared/types/assets";
+import { zAssetTypesSchema } from "@karakeep/shared/types/bookmarks";
+import { getAssetUrl } from "@karakeep/shared/utils/assetUtils";
+
+import { AuthedContext } from "..";
+import {
+ isAllowedToAttachAsset,
+ isAllowedToDetachAsset,
+ mapDBAssetTypeToUserType,
+ mapSchemaAssetTypeToDB,
+} from "../lib/attachments";
+import { BareBookmark } from "./bookmarks";
+
+export class Asset {
+ constructor(
+ protected ctx: AuthedContext,
+ public asset: typeof assets.$inferSelect,
+ ) {}
+
+ static async fromId(ctx: AuthedContext, id: string): Promise<Asset> {
+ const assetdb = await ctx.db.query.assets.findFirst({
+ where: eq(assets.id, id),
+ });
+
+ if (!assetdb) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Asset not found",
+ });
+ }
+
+ const asset = new Asset(ctx, assetdb);
+
+ if (!(await asset.canUserView())) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Asset not found",
+ });
+ }
+
+ return asset;
+ }
+
+ static async list(
+ ctx: AuthedContext,
+ input: {
+ limit: number;
+ cursor: number | null;
+ },
+ ) {
+ const page = input.cursor ?? 1;
+ const [results, totalCount] = await Promise.all([
+ ctx.db
+ .select()
+ .from(assets)
+ .where(eq(assets.userId, ctx.user.id))
+ .orderBy(desc(assets.size))
+ .limit(input.limit)
+ .offset((page - 1) * input.limit),
+ ctx.db
+ .select({ count: sql<number>`count(*)` })
+ .from(assets)
+ .where(eq(assets.userId, ctx.user.id)),
+ ]);
+
+ return {
+ assets: results.map((a) => ({
+ ...a,
+ assetType: mapDBAssetTypeToUserType(a.assetType),
+ })),
+ nextCursor: page * input.limit < totalCount[0].count ? page + 1 : null,
+ totalCount: totalCount[0].count,
+ };
+ }
+
+ static async attachAsset(
+ ctx: AuthedContext,
+ input: {
+ bookmarkId: string;
+ asset: {
+ id: string;
+ assetType: z.infer<typeof zAssetTypesSchema>;
+ };
+ },
+ ) {
+ const [asset] = await Promise.all([
+ Asset.fromId(ctx, input.asset.id),
+ this.ensureBookmarkOwnership(ctx, input.bookmarkId),
+ ]);
+ asset.ensureOwnership();
+
+ if (!isAllowedToAttachAsset(input.asset.assetType)) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "You can't attach this type of asset",
+ });
+ }
+
+ const [updatedAsset] = await ctx.db
+ .update(assets)
+ .set({
+ assetType: mapSchemaAssetTypeToDB(input.asset.assetType),
+ bookmarkId: input.bookmarkId,
+ })
+ .where(and(eq(assets.id, input.asset.id), eq(assets.userId, ctx.user.id)))
+ .returning();
+
+ return {
+ id: updatedAsset.id,
+ assetType: mapDBAssetTypeToUserType(updatedAsset.assetType),
+ fileName: updatedAsset.fileName,
+ };
+ }
+
+ static async replaceAsset(
+ ctx: AuthedContext,
+ input: {
+ bookmarkId: string;
+ oldAssetId: string;
+ newAssetId: string;
+ },
+ ) {
+ const [oldAsset, newAsset] = await Promise.all([
+ Asset.fromId(ctx, input.oldAssetId),
+ Asset.fromId(ctx, input.newAssetId),
+ this.ensureBookmarkOwnership(ctx, input.bookmarkId),
+ ]);
+ oldAsset.ensureOwnership();
+ newAsset.ensureOwnership();
+
+ if (
+ !isAllowedToAttachAsset(
+ mapDBAssetTypeToUserType(oldAsset.asset.assetType),
+ )
+ ) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "You can't attach this type of asset",
+ });
+ }
+
+ await ctx.db.transaction(async (tx) => {
+ await tx.delete(assets).where(eq(assets.id, input.oldAssetId));
+ await tx
+ .update(assets)
+ .set({
+ bookmarkId: input.bookmarkId,
+ assetType: oldAsset.asset.assetType,
+ })
+ .where(eq(assets.id, input.newAssetId));
+ });
+
+ await deleteAsset({
+ userId: ctx.user.id,
+ assetId: input.oldAssetId,
+ }).catch(() => ({}));
+ }
+
+ static async detachAsset(
+ ctx: AuthedContext,
+ input: {
+ bookmarkId: string;
+ assetId: string;
+ },
+ ) {
+ const [asset] = await Promise.all([
+ Asset.fromId(ctx, input.assetId),
+ this.ensureBookmarkOwnership(ctx, input.bookmarkId),
+ ]);
+
+ if (
+ !isAllowedToDetachAsset(mapDBAssetTypeToUserType(asset.asset.assetType))
+ ) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "You can't detach this type of asset",
+ });
+ }
+
+ const result = await ctx.db
+ .delete(assets)
+ .where(
+ and(
+ eq(assets.id, input.assetId),
+ eq(assets.bookmarkId, input.bookmarkId),
+ ),
+ );
+ if (result.changes == 0) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+ await deleteAsset({ userId: ctx.user.id, assetId: input.assetId }).catch(
+ () => ({}),
+ );
+ }
+
+ private static async ensureBookmarkOwnership(
+ ctx: AuthedContext,
+ bookmarkId: string,
+ ) {
+ const bookmark = await BareBookmark.bareFromId(ctx, bookmarkId);
+ bookmark.ensureOwnership();
+ }
+
+ ensureOwnership() {
+ if (this.asset.userId != this.ctx.user.id) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "User is not allowed to access resource",
+ });
+ }
+ }
+
+ static async ensureOwnership(ctx: AuthedContext, assetId: string) {
+ return (await Asset.fromId(ctx, assetId)).ensureOwnership();
+ }
+
+ async canUserView(): Promise<boolean> {
+ // Asset owner can always view it
+ if (this.asset.userId === this.ctx.user.id) {
+ return true;
+ }
+
+ // Avatars are always public
+ if (this.asset.assetType === "avatar") {
+ return true;
+ }
+
+ // If asset is attached to a bookmark, check bookmark access permissions
+ if (this.asset.bookmarkId) {
+ try {
+ // This throws if the user doesn't have access to the bookmark
+ await BareBookmark.bareFromId(this.ctx, this.asset.bookmarkId);
+ return true;
+ } catch (e) {
+ if (e instanceof TRPCError && e.code === "FORBIDDEN") {
+ return false;
+ }
+ throw e;
+ }
+ }
+
+ return false;
+ }
+
+ async ensureCanView() {
+ if (!(await this.canUserView())) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Asset not found",
+ });
+ }
+ }
+
+ getUrl() {
+ return getAssetUrl(this.asset.id);
+ }
+
+ static getPublicSignedAssetUrl(
+ assetId: string,
+ assetOwnerId: string,
+ expireAt: number,
+ ) {
+ const payload: z.infer<typeof zAssetSignedTokenSchema> = {
+ assetId,
+ userId: assetOwnerId,
+ };
+ const signedToken = createSignedToken(
+ payload,
+ serverConfig.signingSecret(),
+ expireAt,
+ );
+ return `${serverConfig.publicApiUrl}/public/assets/${assetId}?token=${signedToken}`;
+ }
+}
diff --git a/packages/trpc/models/bookmarks.ts b/packages/trpc/models/bookmarks.ts
index 07fa8693..c8cd1f00 100644
--- a/packages/trpc/models/bookmarks.ts
+++ b/packages/trpc/models/bookmarks.ts
@@ -4,13 +4,14 @@ import {
asc,
desc,
eq,
- exists,
+ getTableColumns,
gt,
gte,
inArray,
lt,
lte,
or,
+ SQL,
} from "drizzle-orm";
import invariant from "tiny-invariant";
import { z } from "zod";
@@ -21,23 +22,16 @@ import {
AssetTypes,
bookmarkAssets,
bookmarkLinks,
- bookmarkLists,
bookmarks,
bookmarksInLists,
bookmarkTags,
bookmarkTexts,
- listCollaborators,
rssFeedImportsTable,
tagsOnBookmarks,
} from "@karakeep/db/schema";
import { SearchIndexingQueue, triggerWebhook } from "@karakeep/shared-server";
import { deleteAsset, readAsset } from "@karakeep/shared/assetdb";
-import serverConfig from "@karakeep/shared/config";
-import {
- createSignedToken,
- getAlignedExpiry,
-} from "@karakeep/shared/signedTokens";
-import { zAssetSignedTokenSchema } from "@karakeep/shared/types/assets";
+import { getAlignedExpiry } from "@karakeep/shared/signedTokens";
import {
BookmarkTypes,
DEFAULT_NUM_BOOKMARKS_PER_PAGE,
@@ -56,6 +50,7 @@ import { htmlToPlainText } from "@karakeep/shared/utils/htmlUtils";
import { AuthedContext } from "..";
import { mapDBAssetTypeToUserType } from "../lib/attachments";
+import { Asset } from "./assets";
import { List } from "./lists";
async function dummyDrizzleReturnType() {
@@ -162,6 +157,7 @@ export class Bookmark extends BareBookmark {
screenshotAssetId: assets.find(
(a) => a.assetType == AssetTypes.LINK_SCREENSHOT,
)?.id,
+ pdfAssetId: assets.find((a) => a.assetType == AssetTypes.LINK_PDF)?.id,
fullPageArchiveAssetId: assets.find(
(a) => a.assetType == AssetTypes.LINK_FULL_PAGE_ARCHIVE,
)?.id,
@@ -182,6 +178,7 @@ export class Bookmark extends BareBookmark {
? await Bookmark.getBookmarkHtmlContent(link, bookmark.userId)
: null,
crawledAt: link.crawledAt,
+ crawlStatus: link.crawlStatus,
author: link.author,
publisher: link.publisher,
datePublished: link.datePublished,
@@ -270,6 +267,130 @@ export class Bookmark extends BareBookmark {
return new Bookmark(ctx, data);
}
+ static async buildDebugInfo(ctx: AuthedContext, bookmarkId: string) {
+ // Verify the user is an admin
+ if (ctx.user.role !== "admin") {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Admin access required",
+ });
+ }
+
+ const PRIVACY_REDACTED_ASSET_TYPES = new Set<AssetTypes>([
+ AssetTypes.USER_UPLOADED,
+ AssetTypes.BOOKMARK_ASSET,
+ ]);
+
+ const bookmark = await ctx.db.query.bookmarks.findFirst({
+ where: eq(bookmarks.id, bookmarkId),
+ with: {
+ link: true,
+ text: true,
+ asset: true,
+ tagsOnBookmarks: {
+ with: {
+ tag: true,
+ },
+ },
+ assets: true,
+ },
+ });
+
+ if (!bookmark) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+
+ // Build link info
+ let linkInfo = null;
+ if (bookmark.link) {
+ const htmlContentPreview = await (async () => {
+ try {
+ const content = await Bookmark.getBookmarkHtmlContent(
+ bookmark.link!,
+ bookmark.userId,
+ );
+ return content ? content.substring(0, 1000) : null;
+ } catch {
+ return null;
+ }
+ })();
+
+ linkInfo = {
+ url: bookmark.link.url,
+ crawlStatus: bookmark.link.crawlStatus ?? "pending",
+ crawlStatusCode: bookmark.link.crawlStatusCode,
+ crawledAt: bookmark.link.crawledAt,
+ hasHtmlContent: !!bookmark.link.htmlContent,
+ hasContentAsset: !!bookmark.link.contentAssetId,
+ htmlContentPreview,
+ };
+ }
+
+ // Build text info
+ let textInfo = null;
+ if (bookmark.text) {
+ textInfo = {
+ hasText: !!bookmark.text.text,
+ sourceUrl: bookmark.text.sourceUrl,
+ };
+ }
+
+ // Build asset info
+ let assetInfo = null;
+ if (bookmark.asset) {
+ assetInfo = {
+ assetType: bookmark.asset.assetType,
+ hasContent: !!bookmark.asset.content,
+ fileName: bookmark.asset.fileName,
+ };
+ }
+
+ // Build tags
+ const tags = bookmark.tagsOnBookmarks.map((t) => ({
+ id: t.tag.id,
+ name: t.tag.name,
+ attachedBy: t.attachedBy,
+ }));
+
+ // Build assets list with signed URLs (exclude userUploaded)
+ const assetsWithUrls = bookmark.assets.map((a) => {
+ // Generate signed token with 10 mins expiry
+ const expiresAt = Date.now() + 10 * 60 * 1000; // 10 mins
+ // Exclude userUploaded assets for privacy reasons
+ const url = !PRIVACY_REDACTED_ASSET_TYPES.has(a.assetType)
+ ? Asset.getPublicSignedAssetUrl(a.id, bookmark.userId, expiresAt)
+ : null;
+
+ return {
+ id: a.id,
+ assetType: a.assetType,
+ size: a.size,
+ url,
+ };
+ });
+
+ return {
+ id: bookmark.id,
+ type: bookmark.type,
+ source: bookmark.source,
+ createdAt: bookmark.createdAt,
+ modifiedAt: bookmark.modifiedAt,
+ title: bookmark.title,
+ summary: bookmark.summary,
+ taggingStatus: bookmark.taggingStatus,
+ summarizationStatus: bookmark.summarizationStatus,
+ userId: bookmark.userId,
+ linkInfo,
+ textInfo,
+ assetInfo,
+ tags,
+ assets: assetsWithUrls,
+ };
+ }
+
static async loadMulti(
ctx: AuthedContext,
input: z.infer<typeof zGetBookmarksRequestSchema>,
@@ -283,6 +404,21 @@ export class Bookmark extends BareBookmark {
if (!input.limit) {
input.limit = DEFAULT_NUM_BOOKMARKS_PER_PAGE;
}
+
+ // Validate that only one of listId, tagId, or rssFeedId is specified
+ // Combined filters are not supported as they would require different query strategies
+ const filterCount = [input.listId, input.tagId, input.rssFeedId].filter(
+ (f) => f !== undefined,
+ ).length;
+ if (filterCount > 1) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "Cannot filter by multiple of listId, tagId, and rssFeedId simultaneously",
+ });
+ }
+
+ // Handle smart lists by converting to bookmark IDs
if (input.listId) {
const list = await List.fromId(ctx, input.listId);
if (list.type === "smart") {
@@ -291,121 +427,132 @@ export class Bookmark extends BareBookmark {
}
}
- const sq = ctx.db.$with("bookmarksSq").as(
- ctx.db
- .select()
- .from(bookmarks)
- .where(
+ // Build cursor condition for pagination
+ const buildCursorCondition = (
+ createdAtCol: typeof bookmarks.createdAt,
+ idCol: typeof bookmarks.id,
+ ): SQL | undefined => {
+ if (!input.cursor) return undefined;
+
+ if (input.sortOrder === "asc") {
+ return or(
+ gt(createdAtCol, input.cursor.createdAt),
and(
- // Access control: User can access bookmarks if they either:
- // 1. Own the bookmark (always)
- // 2. The bookmark is in a specific shared list being viewed
- // When listId is specified, we need special handling to show all bookmarks in that list
- input.listId !== undefined
- ? // If querying a specific list, check if user has access to that list
- or(
- eq(bookmarks.userId, ctx.user.id),
- // User is the owner of the list being queried
- exists(
- ctx.db
- .select()
- .from(bookmarkLists)
- .where(
- and(
- eq(bookmarkLists.id, input.listId),
- eq(bookmarkLists.userId, ctx.user.id),
- ),
- ),
- ),
- // User is a collaborator on the list being queried
- exists(
- ctx.db
- .select()
- .from(listCollaborators)
- .where(
- and(
- eq(listCollaborators.listId, input.listId),
- eq(listCollaborators.userId, ctx.user.id),
- ),
- ),
- ),
- )
- : // If not querying a specific list, only show bookmarks the user owns
- // Shared bookmarks should only appear when viewing the specific shared list
- 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,
+ eq(createdAtCol, input.cursor.createdAt),
+ gte(idCol, input.cursor.id),
),
- )
- .limit(input.limit + 1)
- .orderBy(
- input.sortOrder === "asc"
- ? asc(bookmarks.createdAt)
- : desc(bookmarks.createdAt),
- desc(bookmarks.id),
+ );
+ }
+ return or(
+ lt(createdAtCol, input.cursor.createdAt),
+ and(
+ eq(createdAtCol, input.cursor.createdAt),
+ lte(idCol, input.cursor.id),
),
- );
+ );
+ };
+
+ // Build common filter conditions (archived, favourited, ids)
+ const buildCommonFilters = (): (SQL | undefined)[] => [
+ 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,
+ ];
+
+ // Build ORDER BY clause
+ const buildOrderBy = () =>
+ [
+ input.sortOrder === "asc"
+ ? asc(bookmarks.createdAt)
+ : desc(bookmarks.createdAt),
+ desc(bookmarks.id),
+ ] as const;
+
+ // Choose query strategy based on filters
+ // Strategy: Use the most selective filter as the driving table
+ let sq;
+
+ if (input.listId !== undefined) {
+ // PATH: List filter - start from bookmarksInLists (more selective)
+ // Access control is already verified by List.fromId() called above
+ sq = ctx.db.$with("bookmarksSq").as(
+ ctx.db
+ .select(getTableColumns(bookmarks))
+ .from(bookmarksInLists)
+ .innerJoin(bookmarks, eq(bookmarks.id, bookmarksInLists.bookmarkId))
+ .where(
+ and(
+ eq(bookmarksInLists.listId, input.listId),
+ ...buildCommonFilters(),
+ buildCursorCondition(bookmarks.createdAt, bookmarks.id),
+ ),
+ )
+ .limit(input.limit + 1)
+ .orderBy(...buildOrderBy()),
+ );
+ } else if (input.tagId !== undefined) {
+ // PATH: Tag filter - start from tagsOnBookmarks (more selective)
+ sq = ctx.db.$with("bookmarksSq").as(
+ ctx.db
+ .select(getTableColumns(bookmarks))
+ .from(tagsOnBookmarks)
+ .innerJoin(bookmarks, eq(bookmarks.id, tagsOnBookmarks.bookmarkId))
+ .where(
+ and(
+ eq(tagsOnBookmarks.tagId, input.tagId),
+ eq(bookmarks.userId, ctx.user.id), // Access control
+ ...buildCommonFilters(),
+ buildCursorCondition(bookmarks.createdAt, bookmarks.id),
+ ),
+ )
+ .limit(input.limit + 1)
+ .orderBy(...buildOrderBy()),
+ );
+ } else if (input.rssFeedId !== undefined) {
+ // PATH: RSS feed filter - start from rssFeedImportsTable (more selective)
+ sq = ctx.db.$with("bookmarksSq").as(
+ ctx.db
+ .select(getTableColumns(bookmarks))
+ .from(rssFeedImportsTable)
+ .innerJoin(
+ bookmarks,
+ eq(bookmarks.id, rssFeedImportsTable.bookmarkId),
+ )
+ .where(
+ and(
+ eq(rssFeedImportsTable.rssFeedId, input.rssFeedId),
+ eq(bookmarks.userId, ctx.user.id), // Access control
+ ...buildCommonFilters(),
+ buildCursorCondition(bookmarks.createdAt, bookmarks.id),
+ ),
+ )
+ .limit(input.limit + 1)
+ .orderBy(...buildOrderBy()),
+ );
+ } else {
+ // PATH: No list/tag/rssFeed filter - query bookmarks directly
+ // Uses composite index: bookmarks_userId_createdAt_id_idx (or archived/favourited variants)
+ sq = ctx.db.$with("bookmarksSq").as(
+ ctx.db
+ .select()
+ .from(bookmarks)
+ .where(
+ and(
+ eq(bookmarks.userId, ctx.user.id),
+ ...buildCommonFilters(),
+ buildCursorCondition(bookmarks.createdAt, bookmarks.id),
+ ),
+ )
+ .limit(input.limit + 1)
+ .orderBy(...buildOrderBy()),
+ );
+ }
+
+ // Execute the query with joins for related data
// 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)
@@ -438,6 +585,7 @@ export class Bookmark extends BareBookmark {
: row.bookmarkLinks.htmlContent
: null,
contentAssetId: row.bookmarkLinks.contentAssetId,
+ crawlStatus: row.bookmarkLinks.crawlStatus,
crawledAt: row.bookmarkLinks.crawledAt,
author: row.bookmarkLinks.author,
publisher: row.bookmarkLinks.publisher,
@@ -500,6 +648,9 @@ export class Bookmark extends BareBookmark {
if (row.assets.assetType == AssetTypes.LINK_SCREENSHOT) {
content.screenshotAssetId = row.assets.id;
}
+ if (row.assets.assetType == AssetTypes.LINK_PDF) {
+ content.pdfAssetId = row.assets.id;
+ }
if (row.assets.assetType == AssetTypes.LINK_FULL_PAGE_ARCHIVE) {
content.fullPageArchiveAssetId = row.assets.id;
}
@@ -610,17 +761,12 @@ export class Bookmark extends BareBookmark {
asPublicBookmark(): ZPublicBookmark {
const getPublicSignedAssetUrl = (assetId: string) => {
- const payload: z.infer<typeof zAssetSignedTokenSchema> = {
+ // Tokens will expire in 1 hour and will have a grace period of 15mins
+ return Asset.getPublicSignedAssetUrl(
assetId,
- userId: this.ctx.user.id,
- };
- const signedToken = createSignedToken(
- payload,
- serverConfig.signingSecret(),
- // Tokens will expire in 1 hour and will have a grace period of 15mins
- getAlignedExpiry(/* interval */ 3600, /* grace */ 900),
+ this.bookmark.userId,
+ getAlignedExpiry(3600, 900),
);
- return `${serverConfig.publicApiUrl}/public/assets/${assetId}?token=${signedToken}`;
};
const getContent = (
content: ZBookmarkContent,
diff --git a/packages/trpc/models/feeds.ts b/packages/trpc/models/feeds.ts
index c0828bbf..ea22da8f 100644
--- a/packages/trpc/models/feeds.ts
+++ b/packages/trpc/models/feeds.ts
@@ -1,8 +1,9 @@
import { TRPCError } from "@trpc/server";
-import { and, eq } from "drizzle-orm";
+import { and, count, eq } from "drizzle-orm";
import { z } from "zod";
import { rssFeedsTable } from "@karakeep/db/schema";
+import serverConfig from "@karakeep/shared/config";
import {
zFeedSchema,
zNewFeedSchema,
@@ -44,6 +45,20 @@ export class Feed {
ctx: AuthedContext,
input: z.infer<typeof zNewFeedSchema>,
): Promise<Feed> {
+ // Check if user has reached the maximum number of feeds
+ const [feedCount] = await ctx.db
+ .select({ count: count() })
+ .from(rssFeedsTable)
+ .where(eq(rssFeedsTable.userId, ctx.user.id));
+
+ const maxFeeds = serverConfig.feeds.maxRssFeedsPerUser;
+ if (feedCount.count >= maxFeeds) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Maximum number of RSS feeds (${maxFeeds}) reached`,
+ });
+ }
+
const [result] = await ctx.db
.insert(rssFeedsTable)
.values({
diff --git a/packages/trpc/models/importSessions.ts b/packages/trpc/models/importSessions.ts
index c324cf7f..ee0eb5b2 100644
--- a/packages/trpc/models/importSessions.ts
+++ b/packages/trpc/models/importSessions.ts
@@ -2,12 +2,7 @@ import { TRPCError } from "@trpc/server";
import { and, count, eq } from "drizzle-orm";
import { z } from "zod";
-import {
- bookmarkLinks,
- bookmarks,
- importSessionBookmarks,
- importSessions,
-} from "@karakeep/db/schema";
+import { importSessions, importStagingBookmarks } from "@karakeep/db/schema";
import {
zCreateImportSessionRequestSchema,
ZImportSession,
@@ -81,38 +76,17 @@ export class ImportSession {
);
}
- async attachBookmark(bookmarkId: string): Promise<void> {
- await this.ctx.db.insert(importSessionBookmarks).values({
- importSessionId: this.session.id,
- bookmarkId,
- });
- }
-
async getWithStats(): Promise<ZImportSessionWithStats> {
- // Get bookmark counts by status
+ // Count by staging status - this now reflects the true state since
+ // items stay in "processing" until downstream crawl/tag is complete
const statusCounts = await this.ctx.db
.select({
- crawlStatus: bookmarkLinks.crawlStatus,
- taggingStatus: bookmarks.taggingStatus,
+ status: importStagingBookmarks.status,
count: count(),
})
- .from(importSessionBookmarks)
- .innerJoin(
- importSessions,
- eq(importSessions.id, importSessionBookmarks.importSessionId),
- )
- .leftJoin(bookmarks, eq(bookmarks.id, importSessionBookmarks.bookmarkId))
- .leftJoin(
- bookmarkLinks,
- eq(bookmarkLinks.id, importSessionBookmarks.bookmarkId),
- )
- .where(
- and(
- eq(importSessionBookmarks.importSessionId, this.session.id),
- eq(importSessions.userId, this.ctx.user.id),
- ),
- )
- .groupBy(bookmarkLinks.crawlStatus, bookmarks.taggingStatus);
+ .from(importStagingBookmarks)
+ .where(eq(importStagingBookmarks.importSessionId, this.session.id))
+ .groupBy(importStagingBookmarks.status);
const stats = {
totalBookmarks: 0,
@@ -122,41 +96,27 @@ export class ImportSession {
processingBookmarks: 0,
};
- statusCounts.forEach((statusCount) => {
- const { crawlStatus, taggingStatus, count } = statusCount;
-
- stats.totalBookmarks += count;
-
- const isCrawlFailure = crawlStatus === "failure";
- const isTagFailure = taggingStatus === "failure";
- if (isCrawlFailure || isTagFailure) {
- stats.failedBookmarks += count;
- return;
- }
-
- const isCrawlPending = crawlStatus === "pending";
- const isTagPending = taggingStatus === "pending";
- if (isCrawlPending || isTagPending) {
- stats.pendingBookmarks += count;
- return;
- }
-
- const isCrawlSuccessfulOrNotRequired =
- crawlStatus === "success" || crawlStatus === null;
- const isTagSuccessfulOrUnknown =
- taggingStatus === "success" || taggingStatus === null;
+ statusCounts.forEach(({ status, count: itemCount }) => {
+ stats.totalBookmarks += itemCount;
- if (isCrawlSuccessfulOrNotRequired && isTagSuccessfulOrUnknown) {
- stats.completedBookmarks += count;
- } else {
- // Fallback to pending to avoid leaving imports unclassified
- stats.pendingBookmarks += count;
+ switch (status) {
+ case "pending":
+ stats.pendingBookmarks += itemCount;
+ break;
+ case "processing":
+ stats.processingBookmarks += itemCount;
+ break;
+ case "completed":
+ stats.completedBookmarks += itemCount;
+ break;
+ case "failed":
+ stats.failedBookmarks += itemCount;
+ break;
}
});
return {
...this.session,
- status: stats.pendingBookmarks > 0 ? "in_progress" : "completed",
...stats,
};
}
@@ -179,4 +139,92 @@ export class ImportSession {
});
}
}
+
+ async stageBookmarks(
+ bookmarks: {
+ type: "link" | "text" | "asset";
+ url?: string;
+ title?: string;
+ content?: string;
+ note?: string;
+ tags: string[];
+ listIds: string[];
+ sourceAddedAt?: Date;
+ }[],
+ ): Promise<void> {
+ if (this.session.status !== "staging") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Session not in staging status",
+ });
+ }
+
+ // Filter out invalid bookmarks (link without url, text without content)
+ const validBookmarks = bookmarks.filter((bookmark) => {
+ if (bookmark.type === "link" && !bookmark.url) return false;
+ if (bookmark.type === "text" && !bookmark.content) return false;
+ return true;
+ });
+
+ if (validBookmarks.length === 0) {
+ return;
+ }
+
+ await this.ctx.db.insert(importStagingBookmarks).values(
+ validBookmarks.map((bookmark) => ({
+ importSessionId: this.session.id,
+ type: bookmark.type,
+ url: bookmark.url,
+ title: bookmark.title,
+ content: bookmark.content,
+ note: bookmark.note,
+ tags: bookmark.tags,
+ listIds: bookmark.listIds,
+ sourceAddedAt: bookmark.sourceAddedAt,
+ status: "pending" as const,
+ })),
+ );
+ }
+
+ async finalize(): Promise<void> {
+ if (this.session.status !== "staging") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Session not in staging status",
+ });
+ }
+
+ await this.ctx.db
+ .update(importSessions)
+ .set({ status: "pending" })
+ .where(eq(importSessions.id, this.session.id));
+ }
+
+ async pause(): Promise<void> {
+ if (!["pending", "running"].includes(this.session.status)) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Session cannot be paused in current status",
+ });
+ }
+
+ await this.ctx.db
+ .update(importSessions)
+ .set({ status: "paused" })
+ .where(eq(importSessions.id, this.session.id));
+ }
+
+ async resume(): Promise<void> {
+ if (this.session.status !== "paused") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Session not paused",
+ });
+ }
+
+ await this.ctx.db
+ .update(importSessions)
+ .set({ status: "pending" })
+ .where(eq(importSessions.id, this.session.id));
+ }
}
diff --git a/packages/trpc/models/listInvitations.ts b/packages/trpc/models/listInvitations.ts
index 6bdc8ffa..2e17fa2e 100644
--- a/packages/trpc/models/listInvitations.ts
+++ b/packages/trpc/models/listInvitations.ts
@@ -372,6 +372,7 @@ export class ListInvitation {
// This protects user privacy until they accept
name: "Pending User",
email: invitation.user.email || "",
+ image: null,
},
}));
}
diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts
index 0968492a..10d7d9bf 100644
--- a/packages/trpc/models/lists.ts
+++ b/packages/trpc/models/lists.ts
@@ -719,6 +719,7 @@ export abstract class List {
id: true,
name: true,
email: true,
+ image: true,
},
},
},
@@ -738,6 +739,7 @@ export abstract class List {
id: true,
name: true,
email: true,
+ image: true,
},
});
@@ -754,6 +756,7 @@ export abstract class List {
name: c.user.name,
// Only show email to the owner for privacy
email: isOwner ? c.user.email : null,
+ image: c.user.image,
},
};
});
@@ -766,6 +769,7 @@ export abstract class List {
name: owner.name,
// Only show owner email to the owner for privacy
email: isOwner ? owner.email : null,
+ image: owner.image,
}
: null,
};
@@ -805,8 +809,8 @@ export abstract class List {
}
abstract get type(): "manual" | "smart";
- abstract getBookmarkIds(ctx: AuthedContext): Promise<string[]>;
- abstract getSize(ctx: AuthedContext): Promise<number>;
+ abstract getBookmarkIds(visitedListIds?: Set<string>): Promise<string[]>;
+ abstract getSize(): Promise<number>;
abstract addBookmark(bookmarkId: string): Promise<void>;
abstract removeBookmark(bookmarkId: string): Promise<void>;
abstract mergeInto(
@@ -816,6 +820,8 @@ export abstract class List {
}
export class SmartList extends List {
+ private static readonly MAX_VISITED_LISTS = 30;
+
parsedQuery: ReturnType<typeof parseSearchQuery> | null = null;
constructor(ctx: AuthedContext, list: ZBookmarkList & { userId: string }) {
@@ -843,12 +849,27 @@ export class SmartList extends List {
return this.parsedQuery;
}
- async getBookmarkIds(): Promise<string[]> {
+ async getBookmarkIds(visitedListIds = new Set<string>()): Promise<string[]> {
+ if (visitedListIds.size >= SmartList.MAX_VISITED_LISTS) {
+ return [];
+ }
+
+ if (visitedListIds.has(this.list.id)) {
+ return [];
+ }
+
+ const newVisitedListIds = new Set(visitedListIds);
+ newVisitedListIds.add(this.list.id);
+
const parsedQuery = this.getParsedQuery();
if (!parsedQuery.matcher) {
return [];
}
- return await getBookmarkIdsFromMatcher(this.ctx, parsedQuery.matcher);
+ return await getBookmarkIdsFromMatcher(
+ this.ctx,
+ parsedQuery.matcher,
+ newVisitedListIds,
+ );
}
async getSize(): Promise<number> {
@@ -894,7 +915,7 @@ export class ManualList extends List {
return this.list.type;
}
- async getBookmarkIds(): Promise<string[]> {
+ async getBookmarkIds(_visitedListIds?: Set<string>): Promise<string[]> {
const results = await this.ctx.db
.select({ id: bookmarksInLists.bookmarkId })
.from(bookmarksInLists)
diff --git a/packages/trpc/models/tags.ts b/packages/trpc/models/tags.ts
index 55532077..1d8f90b9 100644
--- a/packages/trpc/models/tags.ts
+++ b/packages/trpc/models/tags.ts
@@ -85,6 +85,7 @@ export class Tag {
ctx: AuthedContext,
opts: {
nameContains?: string;
+ ids?: string[];
attachedBy?: "ai" | "human" | "none";
sortBy?: "name" | "usage" | "relevance";
pagination?: {
@@ -119,6 +120,9 @@ export class Tag {
opts.nameContains
? like(bookmarkTags.name, `%${opts.nameContains}%`)
: undefined,
+ opts.ids && opts.ids.length > 0
+ ? inArray(bookmarkTags.id, opts.ids)
+ : undefined,
),
)
.groupBy(bookmarkTags.id, bookmarkTags.name)
diff --git a/packages/trpc/models/users.ts b/packages/trpc/models/users.ts
index a1f32f02..3340956a 100644
--- a/packages/trpc/models/users.ts
+++ b/packages/trpc/models/users.ts
@@ -1,12 +1,13 @@
import { randomBytes } from "crypto";
import { TRPCError } from "@trpc/server";
-import { and, count, desc, eq, gte, sql } from "drizzle-orm";
+import { and, count, desc, eq, gte, lte, sql } from "drizzle-orm";
import invariant from "tiny-invariant";
import { z } from "zod";
import { SqliteError } from "@karakeep/db";
import {
assets,
+ AssetTypes,
bookmarkLinks,
bookmarkLists,
bookmarks,
@@ -17,7 +18,7 @@ import {
users,
verificationTokens,
} from "@karakeep/db/schema";
-import { deleteUserAssets } from "@karakeep/shared/assetdb";
+import { deleteAsset, deleteUserAssets } from "@karakeep/shared/assetdb";
import serverConfig from "@karakeep/shared/config";
import {
zResetPasswordSchema,
@@ -26,6 +27,7 @@ import {
zUserSettingsSchema,
zUserStatsResponseSchema,
zWhoAmIResponseSchema,
+ zWrappedStatsResponseSchema,
} from "@karakeep/shared/types/users";
import { AuthedContext, Context } from "..";
@@ -59,7 +61,7 @@ export class User {
static async create(
ctx: Context,
- input: z.infer<typeof zSignUpSchema>,
+ input: z.infer<typeof zSignUpSchema> & { redirectUrl?: string },
role?: "user" | "admin",
) {
const salt = generatePasswordSalt();
@@ -74,7 +76,12 @@ export class User {
if (serverConfig.auth.emailVerificationRequired) {
const token = await User.genEmailVerificationToken(ctx.db, input.email);
try {
- await sendVerificationEmail(input.email, input.name, token);
+ await sendVerificationEmail(
+ input.email,
+ input.name,
+ token,
+ input.redirectUrl,
+ );
} catch (error) {
console.error("Failed to send verification email:", error);
}
@@ -225,6 +232,7 @@ export class User {
static async resendVerificationEmail(
ctx: Context,
email: string,
+ redirectUrl?: string,
): Promise<void> {
if (
!serverConfig.auth.emailVerificationRequired ||
@@ -253,7 +261,7 @@ export class User {
const token = await User.genEmailVerificationToken(ctx.db, email);
try {
- await sendVerificationEmail(email, user.name, token);
+ await sendVerificationEmail(email, user.name, token, redirectUrl);
} catch (error) {
console.error("Failed to send verification email:", error);
throw new TRPCError({
@@ -433,6 +441,14 @@ export class User {
backupsEnabled: true,
backupsFrequency: true,
backupsRetentionDays: true,
+ readerFontSize: true,
+ readerLineHeight: true,
+ readerFontFamily: true,
+ autoTaggingEnabled: true,
+ autoSummarizationEnabled: true,
+ tagStyle: true,
+ curatedTagIds: true,
+ inferredTagLang: true,
},
});
@@ -450,6 +466,14 @@ export class User {
backupsEnabled: settings.backupsEnabled,
backupsFrequency: settings.backupsFrequency,
backupsRetentionDays: settings.backupsRetentionDays,
+ readerFontSize: settings.readerFontSize,
+ readerLineHeight: settings.readerLineHeight,
+ readerFontFamily: settings.readerFontFamily,
+ autoTaggingEnabled: settings.autoTaggingEnabled,
+ autoSummarizationEnabled: settings.autoSummarizationEnabled,
+ tagStyle: settings.tagStyle ?? "as-generated",
+ curatedTagIds: settings.curatedTagIds ?? null,
+ inferredTagLang: settings.inferredTagLang,
};
}
@@ -472,10 +496,116 @@ export class User {
backupsEnabled: input.backupsEnabled,
backupsFrequency: input.backupsFrequency,
backupsRetentionDays: input.backupsRetentionDays,
+ readerFontSize: input.readerFontSize,
+ readerLineHeight: input.readerLineHeight,
+ readerFontFamily: input.readerFontFamily,
+ autoTaggingEnabled: input.autoTaggingEnabled,
+ autoSummarizationEnabled: input.autoSummarizationEnabled,
+ tagStyle: input.tagStyle,
+ curatedTagIds: input.curatedTagIds,
+ inferredTagLang: input.inferredTagLang,
})
.where(eq(users.id, this.user.id));
}
+ async updateAvatar(assetId: string | null): Promise<void> {
+ const previousImage = this.user.image ?? null;
+ const [asset, previousAsset] = await Promise.all([
+ assetId
+ ? this.ctx.db.query.assets.findFirst({
+ where: and(eq(assets.id, assetId), eq(assets.userId, this.user.id)),
+ columns: {
+ id: true,
+ bookmarkId: true,
+ contentType: true,
+ assetType: true,
+ },
+ })
+ : Promise.resolve(null),
+ previousImage && previousImage !== assetId
+ ? this.ctx.db.query.assets.findFirst({
+ where: and(
+ eq(assets.id, previousImage),
+ eq(assets.userId, this.user.id),
+ ),
+ columns: {
+ id: true,
+ bookmarkId: true,
+ },
+ })
+ : Promise.resolve(null),
+ ]);
+
+ if (assetId) {
+ if (!asset) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Avatar asset not found",
+ });
+ }
+
+ if (asset.bookmarkId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Avatar asset must not be attached to a bookmark",
+ });
+ }
+
+ if (asset.contentType && !asset.contentType.startsWith("image/")) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Avatar asset must be an image",
+ });
+ }
+
+ if (
+ asset.assetType !== AssetTypes.AVATAR &&
+ asset.assetType !== AssetTypes.UNKNOWN
+ ) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Avatar asset type is not supported",
+ });
+ }
+
+ if (asset.assetType !== AssetTypes.AVATAR) {
+ await this.ctx.db
+ .update(assets)
+ .set({ assetType: AssetTypes.AVATAR })
+ .where(eq(assets.id, asset.id));
+ }
+ }
+ if (previousImage === assetId) {
+ return;
+ }
+
+ await this.ctx.db.transaction(async (tx) => {
+ await tx
+ .update(users)
+ .set({ image: assetId })
+ .where(eq(users.id, this.user.id));
+
+ if (!previousImage || previousImage === assetId) {
+ return;
+ }
+
+ if (previousAsset && !previousAsset.bookmarkId) {
+ await tx.delete(assets).where(eq(assets.id, previousAsset.id));
+ }
+ });
+
+ this.user.image = assetId;
+
+ if (!previousImage || previousImage === assetId) {
+ return;
+ }
+
+ await deleteAsset({
+ userId: this.user.id,
+ assetId: previousImage,
+ }).catch(() => ({}));
+ }
+
async getStats(): Promise<z.infer<typeof zUserStatsResponseSchema>> {
const userObj = await this.ctx.db.query.users.findFirst({
where: eq(users.id, this.user.id),
@@ -553,23 +683,23 @@ export class User {
// Top domains
this.ctx.db
.select({
- domain: sql<string>`CASE
- WHEN ${bookmarkLinks.url} LIKE 'https://%' THEN
- CASE
+ domain: sql<string>`CASE
+ WHEN ${bookmarkLinks.url} LIKE 'https://%' THEN
+ CASE
WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') > 0 THEN
SUBSTR(${bookmarkLinks.url}, 9, INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') - 1)
ELSE
SUBSTR(${bookmarkLinks.url}, 9)
END
- WHEN ${bookmarkLinks.url} LIKE 'http://%' THEN
- CASE
+ WHEN ${bookmarkLinks.url} LIKE 'http://%' THEN
+ CASE
WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') > 0 THEN
SUBSTR(${bookmarkLinks.url}, 8, INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') - 1)
ELSE
SUBSTR(${bookmarkLinks.url}, 8)
END
- ELSE
- CASE
+ ELSE
+ CASE
WHEN INSTR(${bookmarkLinks.url}, '/') > 0 THEN
SUBSTR(${bookmarkLinks.url}, 1, INSTR(${bookmarkLinks.url}, '/') - 1)
ELSE
@@ -582,23 +712,23 @@ export class User {
.innerJoin(bookmarks, eq(bookmarks.id, bookmarkLinks.id))
.where(eq(bookmarks.userId, this.user.id))
.groupBy(
- sql`CASE
- WHEN ${bookmarkLinks.url} LIKE 'https://%' THEN
- CASE
+ sql`CASE
+ WHEN ${bookmarkLinks.url} LIKE 'https://%' THEN
+ CASE
WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') > 0 THEN
SUBSTR(${bookmarkLinks.url}, 9, INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') - 1)
ELSE
SUBSTR(${bookmarkLinks.url}, 9)
END
- WHEN ${bookmarkLinks.url} LIKE 'http://%' THEN
- CASE
+ WHEN ${bookmarkLinks.url} LIKE 'http://%' THEN
+ CASE
WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') > 0 THEN
SUBSTR(${bookmarkLinks.url}, 8, INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') - 1)
ELSE
SUBSTR(${bookmarkLinks.url}, 8)
END
- ELSE
- CASE
+ ELSE
+ CASE
WHEN INSTR(${bookmarkLinks.url}, '/') > 0 THEN
SUBSTR(${bookmarkLinks.url}, 1, INSTR(${bookmarkLinks.url}, '/') - 1)
ELSE
@@ -750,11 +880,324 @@ export class User {
};
}
+ async hasWrapped(): Promise<boolean> {
+ // Check for bookmarks created in 2025
+ const yearStart = new Date("2025-01-01T00:00:00Z");
+ const yearEnd = new Date("2025-12-31T23:59:59Z");
+
+ const [{ numBookmarks }] = await this.ctx.db
+ .select({
+ numBookmarks: count(bookmarks.id),
+ })
+ .from(bookmarks)
+ .where(
+ and(
+ eq(bookmarks.userId, this.user.id),
+ gte(bookmarks.createdAt, yearStart),
+ lte(bookmarks.createdAt, yearEnd),
+ ),
+ );
+
+ return numBookmarks >= 20;
+ }
+
+ async getWrappedStats(
+ year: number,
+ ): Promise<z.infer<typeof zWrappedStatsResponseSchema>> {
+ const userObj = await this.ctx.db.query.users.findFirst({
+ where: eq(users.id, this.user.id),
+ columns: {
+ timezone: true,
+ },
+ });
+ const userTimezone = userObj?.timezone || "UTC";
+
+ // Define year range for 2025
+ const yearStart = new Date(`${year}-01-01T00:00:00Z`);
+ const yearEnd = new Date(`${year}-12-31T23:59:59Z`);
+
+ const yearFilter = and(
+ eq(bookmarks.userId, this.user.id),
+ gte(bookmarks.createdAt, yearStart),
+ lte(bookmarks.createdAt, yearEnd),
+ );
+
+ const [
+ [{ totalBookmarks }],
+ [{ totalFavorites }],
+ [{ totalArchived }],
+ [{ numTags }],
+ [{ numLists }],
+ [{ numHighlights }],
+ firstBookmarkResult,
+ bookmarksByType,
+ topDomains,
+ topTags,
+ bookmarksBySource,
+ bookmarkTimestamps,
+ ] = await Promise.all([
+ // Total bookmarks in year
+ this.ctx.db
+ .select({ totalBookmarks: count() })
+ .from(bookmarks)
+ .where(yearFilter),
+
+ // Total favorites in year
+ this.ctx.db
+ .select({ totalFavorites: count() })
+ .from(bookmarks)
+ .where(and(yearFilter, eq(bookmarks.favourited, true))),
+
+ // Total archived in year
+ this.ctx.db
+ .select({ totalArchived: count() })
+ .from(bookmarks)
+ .where(and(yearFilter, eq(bookmarks.archived, true))),
+
+ // Total unique tags (created in year)
+ this.ctx.db
+ .select({ numTags: count() })
+ .from(bookmarkTags)
+ .where(
+ and(
+ eq(bookmarkTags.userId, this.user.id),
+ gte(bookmarkTags.createdAt, yearStart),
+ lte(bookmarkTags.createdAt, yearEnd),
+ ),
+ ),
+
+ // Total lists (created in year)
+ this.ctx.db
+ .select({ numLists: count() })
+ .from(bookmarkLists)
+ .where(
+ and(
+ eq(bookmarkLists.userId, this.user.id),
+ gte(bookmarkLists.createdAt, yearStart),
+ lte(bookmarkLists.createdAt, yearEnd),
+ ),
+ ),
+
+ // Total highlights (created in year)
+ this.ctx.db
+ .select({ numHighlights: count() })
+ .from(highlights)
+ .where(
+ and(
+ eq(highlights.userId, this.user.id),
+ gte(highlights.createdAt, yearStart),
+ lte(highlights.createdAt, yearEnd),
+ ),
+ ),
+
+ // First bookmark of the year
+ this.ctx.db
+ .select({
+ id: bookmarks.id,
+ title: bookmarks.title,
+ createdAt: bookmarks.createdAt,
+ type: bookmarks.type,
+ })
+ .from(bookmarks)
+ .where(yearFilter)
+ .orderBy(bookmarks.createdAt)
+ .limit(1),
+
+ // Bookmarks by type
+ this.ctx.db
+ .select({
+ type: bookmarks.type,
+ count: count(),
+ })
+ .from(bookmarks)
+ .where(yearFilter)
+ .groupBy(bookmarks.type),
+
+ // Top 5 domains
+ this.ctx.db
+ .select({
+ domain: sql<string>`CASE
+ WHEN ${bookmarkLinks.url} LIKE 'https://%' THEN
+ CASE
+ WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') > 0 THEN
+ SUBSTR(${bookmarkLinks.url}, 9, INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') - 1)
+ ELSE
+ SUBSTR(${bookmarkLinks.url}, 9)
+ END
+ WHEN ${bookmarkLinks.url} LIKE 'http://%' THEN
+ CASE
+ WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') > 0 THEN
+ SUBSTR(${bookmarkLinks.url}, 8, INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') - 1)
+ ELSE
+ SUBSTR(${bookmarkLinks.url}, 8)
+ END
+ ELSE
+ CASE
+ WHEN INSTR(${bookmarkLinks.url}, '/') > 0 THEN
+ SUBSTR(${bookmarkLinks.url}, 1, INSTR(${bookmarkLinks.url}, '/') - 1)
+ ELSE
+ ${bookmarkLinks.url}
+ END
+ END`,
+ count: count(),
+ })
+ .from(bookmarkLinks)
+ .innerJoin(bookmarks, eq(bookmarks.id, bookmarkLinks.id))
+ .where(yearFilter)
+ .groupBy(
+ sql`CASE
+ WHEN ${bookmarkLinks.url} LIKE 'https://%' THEN
+ CASE
+ WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') > 0 THEN
+ SUBSTR(${bookmarkLinks.url}, 9, INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') - 1)
+ ELSE
+ SUBSTR(${bookmarkLinks.url}, 9)
+ END
+ WHEN ${bookmarkLinks.url} LIKE 'http://%' THEN
+ CASE
+ WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') > 0 THEN
+ SUBSTR(${bookmarkLinks.url}, 8, INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') - 1)
+ ELSE
+ SUBSTR(${bookmarkLinks.url}, 8)
+ END
+ ELSE
+ CASE
+ WHEN INSTR(${bookmarkLinks.url}, '/') > 0 THEN
+ SUBSTR(${bookmarkLinks.url}, 1, INSTR(${bookmarkLinks.url}, '/') - 1)
+ ELSE
+ ${bookmarkLinks.url}
+ END
+ END`,
+ )
+ .orderBy(desc(count()))
+ .limit(5),
+
+ // Top 5 tags (used in bookmarks created this year)
+ this.ctx.db
+ .select({
+ name: bookmarkTags.name,
+ count: count(),
+ })
+ .from(bookmarkTags)
+ .innerJoin(tagsOnBookmarks, eq(tagsOnBookmarks.tagId, bookmarkTags.id))
+ .innerJoin(bookmarks, eq(bookmarks.id, tagsOnBookmarks.bookmarkId))
+ .where(yearFilter)
+ .groupBy(bookmarkTags.name)
+ .orderBy(desc(count()))
+ .limit(5),
+
+ // Bookmarks by source
+ this.ctx.db
+ .select({
+ source: bookmarks.source,
+ count: count(),
+ })
+ .from(bookmarks)
+ .where(yearFilter)
+ .groupBy(bookmarks.source)
+ .orderBy(desc(count())),
+
+ // All bookmark timestamps in the year for activity calculations
+ this.ctx.db
+ .select({
+ createdAt: bookmarks.createdAt,
+ })
+ .from(bookmarks)
+ .where(yearFilter),
+ ]);
+
+ // Process bookmarks by type
+ const bookmarkTypeMap = { link: 0, text: 0, asset: 0 };
+ bookmarksByType.forEach((item) => {
+ if (item.type in bookmarkTypeMap) {
+ bookmarkTypeMap[item.type as keyof typeof bookmarkTypeMap] = item.count;
+ }
+ });
+
+ // Process timestamps with user timezone for hourly/daily activity
+ const hourCounts = Array.from({ length: 24 }, () => 0);
+ const dayCounts = Array.from({ length: 7 }, () => 0);
+ const monthCounts = Array.from({ length: 12 }, () => 0);
+ const dayCounts_full: Record<string, number> = {};
+
+ bookmarkTimestamps.forEach(({ createdAt }) => {
+ if (createdAt) {
+ const date = new Date(createdAt);
+ const userDate = new Date(
+ date.toLocaleString("en-US", { timeZone: userTimezone }),
+ );
+
+ const hour = userDate.getHours();
+ const day = userDate.getDay();
+ const month = userDate.getMonth();
+ const dateKey = userDate.toISOString().split("T")[0];
+
+ hourCounts[hour]++;
+ dayCounts[day]++;
+ monthCounts[month]++;
+ dayCounts_full[dateKey] = (dayCounts_full[dateKey] || 0) + 1;
+ }
+ });
+
+ // Find peak hour and day
+ const peakHour = hourCounts.indexOf(Math.max(...hourCounts));
+ const peakDayOfWeek = dayCounts.indexOf(Math.max(...dayCounts));
+
+ // Find most active day
+ let mostActiveDay: { date: string; count: number } | null = null;
+ if (Object.keys(dayCounts_full).length > 0) {
+ const sortedDays = Object.entries(dayCounts_full).sort(
+ ([, a], [, b]) => b - a,
+ );
+ mostActiveDay = {
+ date: sortedDays[0][0],
+ count: sortedDays[0][1],
+ };
+ }
+
+ // Monthly activity
+ const monthlyActivity = Array.from({ length: 12 }, (_, i) => ({
+ month: i + 1,
+ count: monthCounts[i],
+ }));
+
+ // First bookmark
+ const firstBookmark =
+ firstBookmarkResult.length > 0
+ ? {
+ id: firstBookmarkResult[0].id,
+ title: firstBookmarkResult[0].title,
+ createdAt: firstBookmarkResult[0].createdAt,
+ type: firstBookmarkResult[0].type,
+ }
+ : null;
+
+ return {
+ year,
+ totalBookmarks: totalBookmarks || 0,
+ totalFavorites: totalFavorites || 0,
+ totalArchived: totalArchived || 0,
+ totalHighlights: numHighlights || 0,
+ totalTags: numTags || 0,
+ totalLists: numLists || 0,
+ firstBookmark,
+ mostActiveDay,
+ topDomains: topDomains.filter((d) => d.domain && d.domain.length > 0),
+ topTags,
+ bookmarksByType: bookmarkTypeMap,
+ bookmarksBySource,
+ monthlyActivity,
+ peakHour,
+ peakDayOfWeek,
+ };
+ }
+
asWhoAmI(): z.infer<typeof zWhoAmIResponseSchema> {
return {
id: this.user.id,
name: this.user.name,
email: this.user.email,
+ image: this.user.image,
localUser: this.user.password !== null,
};
}
diff --git a/packages/trpc/models/webhooks.ts b/packages/trpc/models/webhooks.ts
index d2d9c19c..12281ec7 100644
--- a/packages/trpc/models/webhooks.ts
+++ b/packages/trpc/models/webhooks.ts
@@ -1,8 +1,9 @@
import { TRPCError } from "@trpc/server";
-import { and, eq } from "drizzle-orm";
+import { and, count, eq } from "drizzle-orm";
import { z } from "zod";
import { webhooksTable } from "@karakeep/db/schema";
+import serverConfig from "@karakeep/shared/config";
import {
zNewWebhookSchema,
zUpdateWebhookSchema,
@@ -44,6 +45,20 @@ export class Webhook {
ctx: AuthedContext,
input: z.infer<typeof zNewWebhookSchema>,
): Promise<Webhook> {
+ // Check if user has reached the maximum number of webhooks
+ const [webhookCount] = await ctx.db
+ .select({ count: count() })
+ .from(webhooksTable)
+ .where(eq(webhooksTable.userId, ctx.user.id));
+
+ const maxWebhooks = serverConfig.webhook.maxWebhooksPerUser;
+ if (webhookCount.count >= maxWebhooks) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Maximum number of webhooks (${maxWebhooks}) reached`,
+ });
+ }
+
const [result] = await ctx.db
.insert(webhooksTable)
.values({
diff --git a/packages/trpc/package.json b/packages/trpc/package.json
index d9fa12c0..d50a2174 100644
--- a/packages/trpc/package.json
+++ b/packages/trpc/package.json
@@ -17,7 +17,7 @@
"@karakeep/plugins": "workspace:*",
"@karakeep/shared": "workspace:*",
"@karakeep/shared-server": "workspace:*",
- "@trpc/server": "^11.4.3",
+ "@trpc/server": "^11.9.0",
"bcryptjs": "^2.4.3",
"deep-equal": "^2.2.3",
"drizzle-orm": "^0.44.2",
diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts
index bae69130..9e20bb7e 100644
--- a/packages/trpc/routers/_app.ts
+++ b/packages/trpc/routers/_app.ts
@@ -4,6 +4,7 @@ import { apiKeysAppRouter } from "./apiKeys";
import { assetsAppRouter } from "./assets";
import { backupsAppRouter } from "./backups";
import { bookmarksAppRouter } from "./bookmarks";
+import { configAppRouter } from "./config";
import { feedsAppRouter } from "./feeds";
import { highlightsAppRouter } from "./highlights";
import { importSessionsRouter } from "./importSessions";
@@ -35,6 +36,7 @@ export const appRouter = router({
invites: invitesAppRouter,
publicBookmarks: publicBookmarks,
subscriptions: subscriptionsRouter,
+ config: configAppRouter,
});
// export type definition of API
export type AppRouter = typeof appRouter;
diff --git a/packages/trpc/routers/admin.test.ts b/packages/trpc/routers/admin.test.ts
new file mode 100644
index 00000000..2f80d9c0
--- /dev/null
+++ b/packages/trpc/routers/admin.test.ts
@@ -0,0 +1,265 @@
+import { eq } from "drizzle-orm";
+import { assert, beforeEach, describe, expect, test } from "vitest";
+
+import { bookmarkLinks, users } from "@karakeep/db/schema";
+import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
+
+import type { CustomTestContext } from "../testUtils";
+import { buildTestContext, getApiCaller } from "../testUtils";
+
+beforeEach<CustomTestContext>(async (context) => {
+ const testContext = await buildTestContext(true);
+ Object.assign(context, testContext);
+});
+
+describe("Admin Routes", () => {
+ describe("getBookmarkDebugInfo", () => {
+ test<CustomTestContext>("admin can access bookmark debug info for link bookmark", async ({
+ apiCallers,
+ db,
+ }) => {
+ // Create an admin user
+ const adminUser = await db
+ .insert(users)
+ .values({
+ name: "Admin User",
+ email: "admin@test.com",
+ role: "admin",
+ })
+ .returning();
+ const adminApi = getApiCaller(
+ db,
+ adminUser[0].id,
+ adminUser[0].email,
+ "admin",
+ );
+
+ // Create a bookmark as a regular user
+ const bookmark = await apiCallers[0].bookmarks.createBookmark({
+ url: "https://example.com",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Update the bookmark link with some metadata
+ await db
+ .update(bookmarkLinks)
+ .set({
+ crawlStatus: "success",
+ crawlStatusCode: 200,
+ crawledAt: new Date(),
+ htmlContent: "<html><body>Test content</body></html>",
+ title: "Test Title",
+ description: "Test Description",
+ })
+ .where(eq(bookmarkLinks.id, bookmark.id));
+
+ // Admin should be able to access debug info
+ const debugInfo = await adminApi.admin.getBookmarkDebugInfo({
+ bookmarkId: bookmark.id,
+ });
+
+ expect(debugInfo.id).toEqual(bookmark.id);
+ expect(debugInfo.type).toEqual(BookmarkTypes.LINK);
+ expect(debugInfo.linkInfo).toBeDefined();
+ assert(debugInfo.linkInfo);
+ expect(debugInfo.linkInfo.url).toEqual("https://example.com");
+ expect(debugInfo.linkInfo.crawlStatus).toEqual("success");
+ expect(debugInfo.linkInfo.crawlStatusCode).toEqual(200);
+ expect(debugInfo.linkInfo.hasHtmlContent).toEqual(true);
+ expect(debugInfo.linkInfo.htmlContentPreview).toBeDefined();
+ expect(debugInfo.linkInfo.htmlContentPreview).toContain("Test content");
+ });
+
+ test<CustomTestContext>("admin can access bookmark debug info for text bookmark", async ({
+ apiCallers,
+ db,
+ }) => {
+ // Create an admin user
+ const adminUser = await db
+ .insert(users)
+ .values({
+ name: "Admin User",
+ email: "admin@test.com",
+ role: "admin",
+ })
+ .returning();
+ const adminApi = getApiCaller(
+ db,
+ adminUser[0].id,
+ adminUser[0].email,
+ "admin",
+ );
+
+ // Create a text bookmark
+ const bookmark = await apiCallers[0].bookmarks.createBookmark({
+ text: "This is a test text bookmark",
+ type: BookmarkTypes.TEXT,
+ });
+
+ // Admin should be able to access debug info
+ const debugInfo = await adminApi.admin.getBookmarkDebugInfo({
+ bookmarkId: bookmark.id,
+ });
+
+ expect(debugInfo.id).toEqual(bookmark.id);
+ expect(debugInfo.type).toEqual(BookmarkTypes.TEXT);
+ expect(debugInfo.textInfo).toBeDefined();
+ assert(debugInfo.textInfo);
+ expect(debugInfo.textInfo.hasText).toEqual(true);
+ });
+
+ test<CustomTestContext>("admin can see bookmark tags in debug info", async ({
+ apiCallers,
+ db,
+ }) => {
+ // Create an admin user
+ const adminUser = await db
+ .insert(users)
+ .values({
+ name: "Admin User",
+ email: "admin@test.com",
+ role: "admin",
+ })
+ .returning();
+ const adminApi = getApiCaller(
+ db,
+ adminUser[0].id,
+ adminUser[0].email,
+ "admin",
+ );
+
+ // Create a bookmark with tags
+ const bookmark = await apiCallers[0].bookmarks.createBookmark({
+ url: "https://example.com",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Add tags to the bookmark
+ await apiCallers[0].bookmarks.updateTags({
+ bookmarkId: bookmark.id,
+ attach: [{ tagName: "test-tag-1" }, { tagName: "test-tag-2" }],
+ detach: [],
+ });
+
+ // Admin should be able to see tags in debug info
+ const debugInfo = await adminApi.admin.getBookmarkDebugInfo({
+ bookmarkId: bookmark.id,
+ });
+
+ expect(debugInfo.tags).toHaveLength(2);
+ expect(debugInfo.tags.map((t) => t.name).sort()).toEqual([
+ "test-tag-1",
+ "test-tag-2",
+ ]);
+ expect(debugInfo.tags[0].attachedBy).toEqual("human");
+ });
+
+ test<CustomTestContext>("non-admin user cannot access bookmark debug info", async ({
+ apiCallers,
+ }) => {
+ // Create a bookmark
+ const bookmark = await apiCallers[0].bookmarks.createBookmark({
+ url: "https://example.com",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Non-admin user should not be able to access debug info
+ // The admin procedure itself will throw FORBIDDEN
+ await expect(() =>
+ apiCallers[0].admin.getBookmarkDebugInfo({ bookmarkId: bookmark.id }),
+ ).rejects.toThrow(/FORBIDDEN/);
+ });
+
+ test<CustomTestContext>("debug info includes asset URLs with signed tokens", async ({
+ apiCallers,
+ db,
+ }) => {
+ // Create an admin user
+ const adminUser = await db
+ .insert(users)
+ .values({
+ name: "Admin User",
+ email: "admin@test.com",
+ role: "admin",
+ })
+ .returning();
+ const adminApi = getApiCaller(
+ db,
+ adminUser[0].id,
+ adminUser[0].email,
+ "admin",
+ );
+
+ // Create a bookmark
+ const bookmark = await apiCallers[0].bookmarks.createBookmark({
+ url: "https://example.com",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Get debug info
+ const debugInfo = await adminApi.admin.getBookmarkDebugInfo({
+ bookmarkId: bookmark.id,
+ });
+
+ // Check that assets array is present
+ expect(debugInfo.assets).toBeDefined();
+ expect(Array.isArray(debugInfo.assets)).toBe(true);
+
+ // If there are assets, check that they have signed URLs
+ if (debugInfo.assets.length > 0) {
+ const asset = debugInfo.assets[0];
+ expect(asset.url).toBeDefined();
+ expect(asset.url).toContain("/api/public/assets/");
+ expect(asset.url).toContain("token=");
+ }
+ });
+
+ test<CustomTestContext>("debug info truncates HTML content preview", async ({
+ apiCallers,
+ db,
+ }) => {
+ // Create an admin user
+ const adminUser = await db
+ .insert(users)
+ .values({
+ name: "Admin User",
+ email: "admin@test.com",
+ role: "admin",
+ })
+ .returning();
+ const adminApi = getApiCaller(
+ db,
+ adminUser[0].id,
+ adminUser[0].email,
+ "admin",
+ );
+
+ // Create a bookmark
+ const bookmark = await apiCallers[0].bookmarks.createBookmark({
+ url: "https://example.com",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Create a large HTML content
+ const largeContent = "<html><body>" + "x".repeat(2000) + "</body></html>";
+ await db
+ .update(bookmarkLinks)
+ .set({
+ htmlContent: largeContent,
+ })
+ .where(eq(bookmarkLinks.id, bookmark.id));
+
+ // Get debug info
+ const debugInfo = await adminApi.admin.getBookmarkDebugInfo({
+ bookmarkId: bookmark.id,
+ });
+
+ // Check that HTML preview is truncated to 1000 characters
+ assert(debugInfo.linkInfo);
+ expect(debugInfo.linkInfo.htmlContentPreview).toBeDefined();
+ expect(debugInfo.linkInfo.htmlContentPreview!.length).toBeLessThanOrEqual(
+ 1000,
+ );
+ });
+ });
+});
diff --git a/packages/trpc/routers/admin.ts b/packages/trpc/routers/admin.ts
index 463d2ddf..f64e071a 100644
--- a/packages/trpc/routers/admin.ts
+++ b/packages/trpc/routers/admin.ts
@@ -9,7 +9,9 @@ import {
AssetPreprocessingQueue,
FeedQueue,
LinkCrawlerQueue,
+ LowPriorityCrawlerQueue,
OpenAIQueue,
+ QueuePriority,
SearchIndexingQueue,
triggerSearchReindex,
VideoWorkerQueue,
@@ -17,6 +19,7 @@ import {
zAdminMaintenanceTaskSchema,
} from "@karakeep/shared-server";
import serverConfig from "@karakeep/shared/config";
+import logger from "@karakeep/shared/logger";
import { PluginManager, PluginType } from "@karakeep/shared/plugins";
import { getSearchClient } from "@karakeep/shared/search";
import {
@@ -24,9 +27,11 @@ import {
updateUserSchema,
zAdminCreateUserSchema,
} from "@karakeep/shared/types/admin";
+import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
import { generatePasswordSalt, hashPassword } from "../auth";
import { adminProcedure, router } from "../index";
+import { Bookmark } from "../models/bookmarks";
import { User } from "../models/users";
export const adminAppRouter = router({
@@ -86,6 +91,7 @@ export const adminAppRouter = router({
const [
// Crawls
queuedCrawls,
+ queuedLowPriorityCrawls,
[{ value: pendingCrawls }],
[{ value: failedCrawls }],
@@ -114,6 +120,7 @@ export const adminAppRouter = router({
] = await Promise.all([
// Crawls
LinkCrawlerQueue.stats(),
+ LowPriorityCrawlerQueue.stats(),
ctx.db
.select({ value: count() })
.from(bookmarkLinks)
@@ -165,7 +172,11 @@ export const adminAppRouter = router({
return {
crawlStats: {
- queued: queuedCrawls.pending + queuedCrawls.pending_retry,
+ queued:
+ queuedCrawls.pending +
+ queuedCrawls.pending_retry +
+ queuedLowPriorityCrawls.pending +
+ queuedLowPriorityCrawls.pending_retry,
pending: pendingCrawls,
failed: failedCrawls,
},
@@ -201,7 +212,7 @@ export const adminAppRouter = router({
recrawlLinks: adminProcedure
.input(
z.object({
- crawlStatus: z.enum(["success", "failure", "all"]),
+ crawlStatus: z.enum(["success", "failure", "pending", "all"]),
runInference: z.boolean(),
}),
)
@@ -217,10 +228,15 @@ export const adminAppRouter = router({
await Promise.all(
bookmarkIds.map((b) =>
- LinkCrawlerQueue.enqueue({
- bookmarkId: b.id,
- runInference: input.runInference,
- }),
+ LowPriorityCrawlerQueue.enqueue(
+ {
+ bookmarkId: b.id,
+ runInference: input.runInference,
+ },
+ {
+ priority: QueuePriority.Low,
+ },
+ ),
),
);
}),
@@ -233,7 +249,13 @@ export const adminAppRouter = router({
},
});
- await Promise.all(bookmarkIds.map((b) => triggerSearchReindex(b.id)));
+ await Promise.all(
+ bookmarkIds.map((b) =>
+ triggerSearchReindex(b.id, {
+ priority: QueuePriority.Low,
+ }),
+ ),
+ );
}),
reprocessAssetsFixMode: adminProcedure.mutation(async ({ ctx }) => {
const bookmarkIds = await ctx.db.query.bookmarkAssets.findMany({
@@ -244,10 +266,15 @@ export const adminAppRouter = router({
await Promise.all(
bookmarkIds.map((b) =>
- AssetPreprocessingQueue.enqueue({
- bookmarkId: b.id,
- fixMode: true,
- }),
+ AssetPreprocessingQueue.enqueue(
+ {
+ bookmarkId: b.id,
+ fixMode: true,
+ },
+ {
+ priority: QueuePriority.Low,
+ },
+ ),
),
);
}),
@@ -255,7 +282,7 @@ export const adminAppRouter = router({
.input(
z.object({
type: z.enum(["tag", "summarize"]),
- status: z.enum(["success", "failure", "all"]),
+ status: z.enum(["success", "failure", "pending", "all"]),
}),
)
.mutation(async ({ input, ctx }) => {
@@ -277,7 +304,12 @@ export const adminAppRouter = router({
await Promise.all(
bookmarkIds.map((b) =>
- OpenAIQueue.enqueue({ bookmarkId: b.id, type: input.type }),
+ OpenAIQueue.enqueue(
+ { bookmarkId: b.id, type: input.type },
+ {
+ priority: QueuePriority.Low,
+ },
+ ),
),
);
}),
@@ -537,4 +569,194 @@ export const adminAppRouter = router({
queue: queueStatus,
};
}),
+ getBookmarkDebugInfo: adminProcedure
+ .input(z.object({ bookmarkId: z.string() }))
+ .output(
+ z.object({
+ id: z.string(),
+ type: z.enum([
+ BookmarkTypes.LINK,
+ BookmarkTypes.TEXT,
+ BookmarkTypes.ASSET,
+ ]),
+ source: z
+ .enum([
+ "api",
+ "web",
+ "extension",
+ "cli",
+ "mobile",
+ "singlefile",
+ "rss",
+ "import",
+ ])
+ .nullable(),
+ createdAt: z.date(),
+ modifiedAt: z.date().nullable(),
+ title: z.string().nullable(),
+ summary: z.string().nullable(),
+ taggingStatus: z.enum(["pending", "failure", "success"]).nullable(),
+ summarizationStatus: z
+ .enum(["pending", "failure", "success"])
+ .nullable(),
+ userId: z.string(),
+ linkInfo: z
+ .object({
+ url: z.string(),
+ crawlStatus: z.enum(["pending", "failure", "success"]),
+ crawlStatusCode: z.number().nullable(),
+ crawledAt: z.date().nullable(),
+ hasHtmlContent: z.boolean(),
+ hasContentAsset: z.boolean(),
+ htmlContentPreview: z.string().nullable(),
+ })
+ .nullable(),
+ textInfo: z
+ .object({
+ hasText: z.boolean(),
+ sourceUrl: z.string().nullable(),
+ })
+ .nullable(),
+ assetInfo: z
+ .object({
+ assetType: z.enum(["image", "pdf"]),
+ hasContent: z.boolean(),
+ fileName: z.string().nullable(),
+ })
+ .nullable(),
+ tags: z.array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ attachedBy: z.enum(["ai", "human"]),
+ }),
+ ),
+ assets: z.array(
+ z.object({
+ id: z.string(),
+ assetType: z.string(),
+ size: z.number(),
+ url: z.string().nullable(),
+ }),
+ ),
+ }),
+ )
+ .query(async ({ input, ctx }) => {
+ logger.info(
+ `[admin] Admin ${ctx.user.id} accessed debug info for bookmark ${input.bookmarkId}`,
+ );
+
+ return await Bookmark.buildDebugInfo(ctx, input.bookmarkId);
+ }),
+ adminRecrawlBookmark: adminProcedure
+ .input(z.object({ bookmarkId: z.string() }))
+ .mutation(async ({ input, ctx }) => {
+ // Verify bookmark exists and is a link
+ const bookmark = await ctx.db.query.bookmarks.findFirst({
+ where: eq(bookmarks.id, input.bookmarkId),
+ });
+
+ if (!bookmark) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+
+ if (bookmark.type !== BookmarkTypes.LINK) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Only link bookmarks can be recrawled",
+ });
+ }
+
+ await LowPriorityCrawlerQueue.enqueue(
+ {
+ bookmarkId: input.bookmarkId,
+ },
+ {
+ priority: QueuePriority.Low,
+ groupId: "admin",
+ },
+ );
+ }),
+ adminReindexBookmark: adminProcedure
+ .input(z.object({ bookmarkId: z.string() }))
+ .mutation(async ({ input, ctx }) => {
+ // Verify bookmark exists
+ const bookmark = await ctx.db.query.bookmarks.findFirst({
+ where: eq(bookmarks.id, input.bookmarkId),
+ });
+
+ if (!bookmark) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+
+ await triggerSearchReindex(input.bookmarkId, {
+ priority: QueuePriority.Low,
+ groupId: "admin",
+ });
+ }),
+ adminRetagBookmark: adminProcedure
+ .input(z.object({ bookmarkId: z.string() }))
+ .mutation(async ({ input, ctx }) => {
+ // Verify bookmark exists
+ const bookmark = await ctx.db.query.bookmarks.findFirst({
+ where: eq(bookmarks.id, input.bookmarkId),
+ });
+
+ if (!bookmark) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+
+ await OpenAIQueue.enqueue(
+ {
+ bookmarkId: input.bookmarkId,
+ type: "tag",
+ },
+ {
+ priority: QueuePriority.Low,
+ groupId: "admin",
+ },
+ );
+ }),
+ adminResummarizeBookmark: adminProcedure
+ .input(z.object({ bookmarkId: z.string() }))
+ .mutation(async ({ input, ctx }) => {
+ // Verify bookmark exists and is a link
+ const bookmark = await ctx.db.query.bookmarks.findFirst({
+ where: eq(bookmarks.id, input.bookmarkId),
+ });
+
+ if (!bookmark) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+
+ if (bookmark.type !== BookmarkTypes.LINK) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Only link bookmarks can be summarized",
+ });
+ }
+
+ await OpenAIQueue.enqueue(
+ {
+ bookmarkId: input.bookmarkId,
+ type: "summarize",
+ },
+ {
+ priority: QueuePriority.Low,
+ groupId: "admin",
+ },
+ );
+ }),
});
diff --git a/packages/trpc/routers/apiKeys.ts b/packages/trpc/routers/apiKeys.ts
index 763bc23a..90de824a 100644
--- a/packages/trpc/routers/apiKeys.ts
+++ b/packages/trpc/routers/apiKeys.ts
@@ -1,5 +1,5 @@
import { TRPCError } from "@trpc/server";
-import { and, eq } from "drizzle-orm";
+import { and, desc, eq } from "drizzle-orm";
import { z } from "zod";
import { apiKeys } from "@karakeep/db/schema";
@@ -83,6 +83,7 @@ export const apiKeysAppRouter = router({
name: z.string(),
createdAt: z.date(),
keyId: z.string(),
+ lastUsedAt: z.date().nullish(),
}),
),
}),
@@ -94,8 +95,10 @@ export const apiKeysAppRouter = router({
id: true,
name: true,
createdAt: true,
+ lastUsedAt: true,
keyId: true,
},
+ orderBy: desc(apiKeys.createdAt),
});
return { keys: resp };
}),
diff --git a/packages/trpc/routers/assets.ts b/packages/trpc/routers/assets.ts
index 7be85446..c75f1e2e 100644
--- a/packages/trpc/routers/assets.ts
+++ b/packages/trpc/routers/assets.ts
@@ -1,57 +1,20 @@
-import { TRPCError } from "@trpc/server";
-import { and, desc, eq, sql } from "drizzle-orm";
import { z } from "zod";
-import { assets, bookmarks } from "@karakeep/db/schema";
-import { deleteAsset } from "@karakeep/shared/assetdb";
import {
zAssetSchema,
zAssetTypesSchema,
} from "@karakeep/shared/types/bookmarks";
-import { authedProcedure, Context, router } from "../index";
-import {
- isAllowedToAttachAsset,
- isAllowedToDetachAsset,
- mapDBAssetTypeToUserType,
- mapSchemaAssetTypeToDB,
-} from "../lib/attachments";
+import { authedProcedure, router } from "../index";
+import { Asset } from "../models/assets";
import { ensureBookmarkOwnership } from "./bookmarks";
-export const ensureAssetOwnership = async (opts: {
- ctx: Context;
- assetId: string;
-}) => {
- const asset = await opts.ctx.db.query.assets.findFirst({
- where: eq(bookmarks.id, opts.assetId),
- });
- if (!opts.ctx.user) {
- throw new TRPCError({
- code: "UNAUTHORIZED",
- message: "User is not authorized",
- });
- }
- if (!asset) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "Asset not found",
- });
- }
- if (asset.userId != opts.ctx.user.id) {
- throw new TRPCError({
- code: "FORBIDDEN",
- message: "User is not allowed to access resource",
- });
- }
- return asset;
-};
-
export const assetsAppRouter = router({
list: authedProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(20),
- cursor: z.number().nullish(), // page number
+ cursor: z.number().nullish(),
}),
)
.output(
@@ -71,29 +34,10 @@ export const assetsAppRouter = router({
}),
)
.query(async ({ input, ctx }) => {
- const page = input.cursor ?? 1;
- const [results, totalCount] = await Promise.all([
- ctx.db
- .select()
- .from(assets)
- .where(eq(assets.userId, ctx.user.id))
- .orderBy(desc(assets.size))
- .limit(input.limit)
- .offset((page - 1) * input.limit),
- ctx.db
- .select({ count: sql<number>`count(*)` })
- .from(assets)
- .where(eq(assets.userId, ctx.user.id)),
- ]);
-
- return {
- assets: results.map((a) => ({
- ...a,
- assetType: mapDBAssetTypeToUserType(a.assetType),
- })),
- nextCursor: page * input.limit < totalCount[0].count ? page + 1 : null,
- totalCount: totalCount[0].count,
- };
+ return await Asset.list(ctx, {
+ limit: input.limit,
+ cursor: input.cursor ?? null,
+ });
}),
attachAsset: authedProcedure
.input(
@@ -108,29 +52,7 @@ export const assetsAppRouter = router({
.output(zAssetSchema)
.use(ensureBookmarkOwnership)
.mutation(async ({ input, ctx }) => {
- await ensureAssetOwnership({ ctx, assetId: input.asset.id });
- if (!isAllowedToAttachAsset(input.asset.assetType)) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "You can't attach this type of asset",
- });
- }
- const [updatedAsset] = await ctx.db
- .update(assets)
- .set({
- assetType: mapSchemaAssetTypeToDB(input.asset.assetType),
- bookmarkId: input.bookmarkId,
- })
- .where(
- and(eq(assets.id, input.asset.id), eq(assets.userId, ctx.user.id)),
- )
- .returning();
-
- return {
- id: updatedAsset.id,
- assetType: mapDBAssetTypeToUserType(updatedAsset.assetType),
- fileName: updatedAsset.fileName,
- };
+ return await Asset.attachAsset(ctx, input);
}),
replaceAsset: authedProcedure
.input(
@@ -143,41 +65,7 @@ export const assetsAppRouter = router({
.output(z.void())
.use(ensureBookmarkOwnership)
.mutation(async ({ input, ctx }) => {
- await Promise.all([
- ensureAssetOwnership({ ctx, assetId: input.oldAssetId }),
- ensureAssetOwnership({ ctx, assetId: input.newAssetId }),
- ]);
- const [oldAsset] = await ctx.db
- .select()
- .from(assets)
- .where(
- and(eq(assets.id, input.oldAssetId), eq(assets.userId, ctx.user.id)),
- )
- .limit(1);
- if (
- !isAllowedToAttachAsset(mapDBAssetTypeToUserType(oldAsset.assetType))
- ) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "You can't attach this type of asset",
- });
- }
-
- await ctx.db.transaction(async (tx) => {
- await tx.delete(assets).where(eq(assets.id, input.oldAssetId));
- await tx
- .update(assets)
- .set({
- bookmarkId: input.bookmarkId,
- assetType: oldAsset.assetType,
- })
- .where(eq(assets.id, input.newAssetId));
- });
-
- await deleteAsset({
- userId: ctx.user.id,
- assetId: input.oldAssetId,
- }).catch(() => ({}));
+ await Asset.replaceAsset(ctx, input);
}),
detachAsset: authedProcedure
.input(
@@ -189,34 +77,6 @@ export const assetsAppRouter = router({
.output(z.void())
.use(ensureBookmarkOwnership)
.mutation(async ({ input, ctx }) => {
- await ensureAssetOwnership({ ctx, assetId: input.assetId });
- const [oldAsset] = await ctx.db
- .select()
- .from(assets)
- .where(
- and(eq(assets.id, input.assetId), eq(assets.userId, ctx.user.id)),
- );
- if (
- !isAllowedToDetachAsset(mapDBAssetTypeToUserType(oldAsset.assetType))
- ) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "You can't deattach this type of asset",
- });
- }
- const result = await ctx.db
- .delete(assets)
- .where(
- and(
- eq(assets.id, input.assetId),
- eq(assets.bookmarkId, input.bookmarkId),
- ),
- );
- if (result.changes == 0) {
- throw new TRPCError({ code: "NOT_FOUND" });
- }
- await deleteAsset({ userId: ctx.user.id, assetId: input.assetId }).catch(
- () => ({}),
- );
+ await Asset.detachAsset(ctx, input);
}),
});
diff --git a/packages/trpc/routers/bookmarks.test.ts b/packages/trpc/routers/bookmarks.test.ts
index c272e015..aaee5447 100644
--- a/packages/trpc/routers/bookmarks.test.ts
+++ b/packages/trpc/routers/bookmarks.test.ts
@@ -331,6 +331,198 @@ describe("Bookmark Routes", () => {
).rejects.toThrow(/You must provide either a tagId or a tagName/);
});
+ test<CustomTestContext>("update tags - comprehensive edge cases", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0].bookmarks;
+
+ // Create two bookmarks
+ const bookmark1 = await api.createBookmark({
+ url: "https://bookmark1.com",
+ type: BookmarkTypes.LINK,
+ });
+ const bookmark2 = await api.createBookmark({
+ url: "https://bookmark2.com",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Test 1: Attach tags by name to bookmark1 (creates new tags)
+ await api.updateTags({
+ bookmarkId: bookmark1.id,
+ attach: [{ tagName: "existing-tag" }, { tagName: "shared-tag" }],
+ detach: [],
+ });
+
+ let b1 = await api.getBookmark({ bookmarkId: bookmark1.id });
+ expect(b1.tags.map((t) => t.name).sort()).toEqual([
+ "existing-tag",
+ "shared-tag",
+ ]);
+
+ const existingTagId = b1.tags.find((t) => t.name === "existing-tag")!.id;
+ const sharedTagId = b1.tags.find((t) => t.name === "shared-tag")!.id;
+
+ // Test 2: Attach existing tag by ID to bookmark2 (tag already exists in DB from bookmark1)
+ await api.updateTags({
+ bookmarkId: bookmark2.id,
+ attach: [{ tagId: existingTagId }],
+ detach: [],
+ });
+
+ let b2 = await api.getBookmark({ bookmarkId: bookmark2.id });
+ expect(b2.tags.map((t) => t.name)).toEqual(["existing-tag"]);
+
+ // Test 3: Attach existing tag by NAME to bookmark2 (tag already exists in DB)
+ await api.updateTags({
+ bookmarkId: bookmark2.id,
+ attach: [{ tagName: "shared-tag" }],
+ detach: [],
+ });
+
+ b2 = await api.getBookmark({ bookmarkId: bookmark2.id });
+ expect(b2.tags.map((t) => t.name).sort()).toEqual([
+ "existing-tag",
+ "shared-tag",
+ ]);
+
+ // Test 4: Re-attaching the same tag (idempotency) - should be no-op
+ await api.updateTags({
+ bookmarkId: bookmark2.id,
+ attach: [{ tagId: existingTagId }],
+ detach: [],
+ });
+
+ b2 = await api.getBookmark({ bookmarkId: bookmark2.id });
+ expect(b2.tags.map((t) => t.name).sort()).toEqual([
+ "existing-tag",
+ "shared-tag",
+ ]);
+
+ // Test 5: Detach non-existent tag by name (should be no-op)
+ await api.updateTags({
+ bookmarkId: bookmark2.id,
+ attach: [],
+ detach: [{ tagName: "non-existent-tag" }],
+ });
+
+ b2 = await api.getBookmark({ bookmarkId: bookmark2.id });
+ expect(b2.tags.map((t) => t.name).sort()).toEqual([
+ "existing-tag",
+ "shared-tag",
+ ]);
+
+ // Test 6: Mixed attach/detach with pre-existing tags
+ await api.updateTags({
+ bookmarkId: bookmark2.id,
+ attach: [{ tagName: "new-tag" }, { tagId: sharedTagId }], // sharedTagId already attached
+ detach: [{ tagName: "existing-tag" }],
+ });
+
+ b2 = await api.getBookmark({ bookmarkId: bookmark2.id });
+ expect(b2.tags.map((t) => t.name).sort()).toEqual([
+ "new-tag",
+ "shared-tag",
+ ]);
+
+ // Test 7: Detach by ID and re-attach by name in same operation
+ await api.updateTags({
+ bookmarkId: bookmark2.id,
+ attach: [{ tagName: "new-tag" }], // Already exists, should be idempotent
+ detach: [{ tagId: sharedTagId }],
+ });
+
+ b2 = await api.getBookmark({ bookmarkId: bookmark2.id });
+ expect(b2.tags.map((t) => t.name).sort()).toEqual(["new-tag"]);
+
+ // Verify bookmark1 still has its original tags (operations on bookmark2 didn't affect it)
+ b1 = await api.getBookmark({ bookmarkId: bookmark1.id });
+ expect(b1.tags.map((t) => t.name).sort()).toEqual([
+ "existing-tag",
+ "shared-tag",
+ ]);
+
+ // Test 8: Attach same tag multiple times in one operation (deduplication)
+ await api.updateTags({
+ bookmarkId: bookmark1.id,
+ attach: [{ tagName: "duplicate-test" }, { tagName: "duplicate-test" }],
+ detach: [],
+ });
+
+ b1 = await api.getBookmark({ bookmarkId: bookmark1.id });
+ const duplicateTagCount = b1.tags.filter(
+ (t) => t.name === "duplicate-test",
+ ).length;
+ expect(duplicateTagCount).toEqual(1); // Should only be attached once
+ });
+
+ test<CustomTestContext>("updateTags with attachedBy field", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0].bookmarks;
+ const bookmark = await api.createBookmark({
+ url: "https://bookmark.com",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Test 1: Attach tags with different attachedBy values
+ await api.updateTags({
+ bookmarkId: bookmark.id,
+ attach: [
+ { tagName: "ai-tag", attachedBy: "ai" },
+ { tagName: "human-tag", attachedBy: "human" },
+ { tagName: "default-tag" }, // Should default to "human"
+ ],
+ detach: [],
+ });
+
+ let b = await api.getBookmark({ bookmarkId: bookmark.id });
+ expect(b.tags.length).toEqual(3);
+
+ const aiTag = b.tags.find((t) => t.name === "ai-tag");
+ const humanTag = b.tags.find((t) => t.name === "human-tag");
+ const defaultTag = b.tags.find((t) => t.name === "default-tag");
+
+ expect(aiTag?.attachedBy).toEqual("ai");
+ expect(humanTag?.attachedBy).toEqual("human");
+ expect(defaultTag?.attachedBy).toEqual("human");
+
+ // Test 2: Attach existing tag by ID with different attachedBy
+ // First detach the ai-tag
+ await api.updateTags({
+ bookmarkId: bookmark.id,
+ attach: [],
+ detach: [{ tagId: aiTag!.id }],
+ });
+
+ // Re-attach the same tag but as human
+ await api.updateTags({
+ bookmarkId: bookmark.id,
+ attach: [{ tagId: aiTag!.id, attachedBy: "human" }],
+ detach: [],
+ });
+
+ b = await api.getBookmark({ bookmarkId: bookmark.id });
+ const reAttachedTag = b.tags.find((t) => t.id === aiTag!.id);
+ expect(reAttachedTag?.attachedBy).toEqual("human");
+
+ // Test 3: Attach existing tag by name with AI attachedBy
+ const bookmark2 = await api.createBookmark({
+ url: "https://bookmark2.com",
+ type: BookmarkTypes.LINK,
+ });
+
+ await api.updateTags({
+ bookmarkId: bookmark2.id,
+ attach: [{ tagName: "ai-tag", attachedBy: "ai" }],
+ detach: [],
+ });
+
+ const b2 = await api.getBookmark({ bookmarkId: bookmark2.id });
+ const aiTagOnB2 = b2.tags.find((t) => t.name === "ai-tag");
+ expect(aiTagOnB2?.attachedBy).toEqual("ai");
+ expect(aiTagOnB2?.id).toEqual(aiTag!.id); // Should be the same tag
+ });
+
test<CustomTestContext>("update bookmark text", async ({ apiCallers }) => {
const api = apiCallers[0].bookmarks;
const createdBookmark = await api.createBookmark({
diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts
index 15ded2bd..782566cf 100644
--- a/packages/trpc/routers/bookmarks.ts
+++ b/packages/trpc/routers/bookmarks.ts
@@ -1,6 +1,5 @@
import { experimental_trpcMiddleware, TRPCError } from "@trpc/server";
import { and, eq, gt, inArray, lt, or } from "drizzle-orm";
-import invariant from "tiny-invariant";
import { z } from "zod";
import type { ZBookmarkContent } from "@karakeep/shared/types/bookmarks";
@@ -15,11 +14,14 @@ import {
bookmarkTexts,
customPrompts,
tagsOnBookmarks,
+ users,
} from "@karakeep/db/schema";
import {
AssetPreprocessingQueue,
LinkCrawlerQueue,
+ LowPriorityCrawlerQueue,
OpenAIQueue,
+ QueuePriority,
QuotaService,
triggerRuleEngineOnEvent,
triggerSearchReindex,
@@ -28,7 +30,7 @@ import {
import { SUPPORTED_BOOKMARK_ASSET_TYPES } from "@karakeep/shared/assetdb";
import serverConfig from "@karakeep/shared/config";
import { InferenceClientFactory } from "@karakeep/shared/inference";
-import { buildSummaryPrompt } from "@karakeep/shared/prompts";
+import { buildSummaryPrompt } from "@karakeep/shared/prompts.server";
import { EnqueueOptions } from "@karakeep/shared/queueing";
import { FilterQuery, getSearchClient } from "@karakeep/shared/search";
import { parseSearchQuery } from "@karakeep/shared/searchQueryParser";
@@ -49,9 +51,8 @@ import { normalizeTagName } from "@karakeep/shared/utils/tag";
import type { AuthedContext } from "../index";
import { authedProcedure, createRateLimitMiddleware, router } from "../index";
import { getBookmarkIdsFromMatcher } from "../lib/search";
+import { Asset } from "../models/assets";
import { BareBookmark, Bookmark } from "../models/bookmarks";
-import { ImportSession } from "../models/importSessions";
-import { ensureAssetOwnership } from "./assets";
export const ensureBookmarkOwnership = experimental_trpcMiddleware<{
ctx: AuthedContext;
@@ -121,173 +122,173 @@ export const bookmarksAppRouter = router({
// This doesn't 100% protect from duplicates because of races, but it's more than enough for this usecase.
const alreadyExists = await attemptToDedupLink(ctx, input.url);
if (alreadyExists) {
- if (input.importSessionId) {
- const session = await ImportSession.fromId(
- ctx,
- input.importSessionId,
- );
- await session.attachBookmark(alreadyExists.id);
- }
return { ...alreadyExists, alreadyExists: true };
}
}
- // Check user quota
- const quotaResult = await QuotaService.canCreateBookmark(
- ctx.db,
- ctx.user.id,
- );
- if (!quotaResult.result) {
- throw new TRPCError({
- code: "FORBIDDEN",
- message: quotaResult.error,
- });
- }
-
- const bookmark = await ctx.db.transaction(async (tx) => {
- const bookmark = (
- await tx
- .insert(bookmarks)
- .values({
- userId: ctx.user.id,
- title: input.title,
- type: input.type,
- archived: input.archived,
- favourited: input.favourited,
- note: input.note,
- summary: input.summary,
- createdAt: input.createdAt,
- source: input.source,
- })
- .returning()
- )[0];
+ const bookmark = await ctx.db.transaction(
+ async (tx) => {
+ // Check user quota
+ const quotaResult = await QuotaService.canCreateBookmark(
+ tx,
+ ctx.user.id,
+ );
+ if (!quotaResult.result) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: quotaResult.error,
+ });
+ }
+ const bookmark = (
+ await tx
+ .insert(bookmarks)
+ .values({
+ userId: ctx.user.id,
+ title: input.title,
+ type: input.type,
+ archived: input.archived,
+ favourited: input.favourited,
+ note: input.note,
+ summary: input.summary,
+ createdAt: input.createdAt,
+ source: input.source,
+ // Only links currently support summarization. Let's set the status to null for other types for now.
+ summarizationStatus:
+ input.type === BookmarkTypes.LINK ? "pending" : null,
+ })
+ .returning()
+ )[0];
- let content: ZBookmarkContent;
+ let content: ZBookmarkContent;
- switch (input.type) {
- case BookmarkTypes.LINK: {
- const link = (
- await tx
- .insert(bookmarkLinks)
+ switch (input.type) {
+ case BookmarkTypes.LINK: {
+ const link = (
+ await tx
+ .insert(bookmarkLinks)
+ .values({
+ id: bookmark.id,
+ url: input.url.trim(),
+ })
+ .returning()
+ )[0];
+ if (input.precrawledArchiveId) {
+ await Asset.ensureOwnership(ctx, input.precrawledArchiveId);
+ await tx
+ .update(assets)
+ .set({
+ bookmarkId: bookmark.id,
+ assetType: AssetTypes.LINK_PRECRAWLED_ARCHIVE,
+ })
+ .where(
+ and(
+ eq(assets.id, input.precrawledArchiveId),
+ eq(assets.userId, ctx.user.id),
+ ),
+ );
+ }
+ content = {
+ type: BookmarkTypes.LINK,
+ ...link,
+ };
+ break;
+ }
+ case BookmarkTypes.TEXT: {
+ const text = (
+ await tx
+ .insert(bookmarkTexts)
+ .values({
+ id: bookmark.id,
+ text: input.text,
+ sourceUrl: input.sourceUrl,
+ })
+ .returning()
+ )[0];
+ content = {
+ type: BookmarkTypes.TEXT,
+ text: text.text ?? "",
+ sourceUrl: text.sourceUrl,
+ };
+ break;
+ }
+ case BookmarkTypes.ASSET: {
+ const [asset] = await tx
+ .insert(bookmarkAssets)
.values({
id: bookmark.id,
- url: input.url.trim(),
+ assetType: input.assetType,
+ assetId: input.assetId,
+ content: null,
+ metadata: null,
+ fileName: input.fileName ?? null,
+ sourceUrl: null,
})
- .returning()
- )[0];
- if (input.precrawledArchiveId) {
- await ensureAssetOwnership({
- ctx,
- assetId: input.precrawledArchiveId,
- });
+ .returning();
+ const uploadedAsset = await Asset.fromId(ctx, input.assetId);
+ uploadedAsset.ensureOwnership();
+ if (
+ !uploadedAsset.asset.contentType ||
+ !SUPPORTED_BOOKMARK_ASSET_TYPES.has(
+ uploadedAsset.asset.contentType,
+ )
+ ) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Unsupported asset type",
+ });
+ }
await tx
.update(assets)
.set({
bookmarkId: bookmark.id,
- assetType: AssetTypes.LINK_PRECRAWLED_ARCHIVE,
+ assetType: AssetTypes.BOOKMARK_ASSET,
})
.where(
and(
- eq(assets.id, input.precrawledArchiveId),
+ eq(assets.id, input.assetId),
eq(assets.userId, ctx.user.id),
),
);
+ content = {
+ type: BookmarkTypes.ASSET,
+ assetType: asset.assetType,
+ assetId: asset.assetId,
+ };
+ break;
}
- content = {
- type: BookmarkTypes.LINK,
- ...link,
- };
- break;
}
- case BookmarkTypes.TEXT: {
- const text = (
- await tx
- .insert(bookmarkTexts)
- .values({
- id: bookmark.id,
- text: input.text,
- sourceUrl: input.sourceUrl,
- })
- .returning()
- )[0];
- content = {
- type: BookmarkTypes.TEXT,
- text: text.text ?? "",
- sourceUrl: text.sourceUrl,
- };
- break;
- }
- case BookmarkTypes.ASSET: {
- const [asset] = await tx
- .insert(bookmarkAssets)
- .values({
- id: bookmark.id,
- assetType: input.assetType,
- assetId: input.assetId,
- content: null,
- metadata: null,
- fileName: input.fileName ?? null,
- sourceUrl: null,
- })
- .returning();
- const uploadedAsset = await ensureAssetOwnership({
- ctx,
- assetId: input.assetId,
- });
- if (
- !uploadedAsset.contentType ||
- !SUPPORTED_BOOKMARK_ASSET_TYPES.has(uploadedAsset.contentType)
- ) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Unsupported asset type",
- });
- }
- await tx
- .update(assets)
- .set({
- bookmarkId: bookmark.id,
- assetType: AssetTypes.BOOKMARK_ASSET,
- })
- .where(
- and(
- eq(assets.id, input.assetId),
- eq(assets.userId, ctx.user.id),
- ),
- );
- content = {
- type: BookmarkTypes.ASSET,
- assetType: asset.assetType,
- assetId: asset.assetId,
- };
- break;
- }
- }
- return {
- alreadyExists: false,
- tags: [] as ZBookmarkTags[],
- assets: [],
- content,
- ...bookmark,
- };
- });
-
- if (input.importSessionId) {
- const session = await ImportSession.fromId(ctx, input.importSessionId);
- await session.attachBookmark(bookmark.id);
- }
+ return {
+ alreadyExists: false,
+ tags: [] as ZBookmarkTags[],
+ assets: [],
+ content,
+ ...bookmark,
+ };
+ },
+ {
+ behavior: "immediate",
+ },
+ );
const enqueueOpts: EnqueueOptions = {
// The lower the priority number, the sooner the job will be processed
- priority: input.crawlPriority === "low" ? 50 : 0,
+ priority:
+ input.crawlPriority === "low"
+ ? QueuePriority.Low
+ : QueuePriority.Default,
groupId: ctx.user.id,
};
switch (bookmark.content.type) {
case BookmarkTypes.LINK: {
// The crawling job triggers openai when it's done
- await LinkCrawlerQueue.enqueue(
+ // Use a separate queue for low priority crawling to avoid impacting main queue parallelism
+ const crawlerQueue =
+ input.crawlPriority === "low"
+ ? LowPriorityCrawlerQueue
+ : LinkCrawlerQueue;
+ await crawlerQueue.enqueue(
{
bookmarkId: bookmark.id,
},
@@ -317,22 +318,24 @@ export const bookmarksAppRouter = router({
}
}
- await triggerRuleEngineOnEvent(
- bookmark.id,
- [
- {
- type: "bookmarkAdded",
- },
- ],
- enqueueOpts,
- );
- await triggerSearchReindex(bookmark.id, enqueueOpts);
- await triggerWebhook(
- bookmark.id,
- "created",
- /* userId */ undefined,
- enqueueOpts,
- );
+ await Promise.all([
+ triggerRuleEngineOnEvent(
+ bookmark.id,
+ [
+ {
+ type: "bookmarkAdded",
+ },
+ ],
+ enqueueOpts,
+ ),
+ triggerSearchReindex(bookmark.id, enqueueOpts),
+ triggerWebhook(
+ bookmark.id,
+ "created",
+ /* userId */ undefined,
+ enqueueOpts,
+ ),
+ ]);
return bookmark;
}),
@@ -487,13 +490,14 @@ export const bookmarksAppRouter = router({
})),
);
}
- // Trigger re-indexing and webhooks
- await triggerSearchReindex(input.bookmarkId, {
- groupId: ctx.user.id,
- });
- await triggerWebhook(input.bookmarkId, "edited", ctx.user.id, {
- groupId: ctx.user.id,
- });
+ await Promise.all([
+ triggerSearchReindex(input.bookmarkId, {
+ groupId: ctx.user.id,
+ }),
+ triggerWebhook(input.bookmarkId, "edited", ctx.user.id, {
+ groupId: ctx.user.id,
+ }),
+ ]);
return updatedBookmark;
}),
@@ -532,12 +536,14 @@ export const bookmarksAppRouter = router({
),
);
});
- await triggerSearchReindex(input.bookmarkId, {
- groupId: ctx.user.id,
- });
- await triggerWebhook(input.bookmarkId, "edited", ctx.user.id, {
- groupId: ctx.user.id,
- });
+ await Promise.all([
+ triggerSearchReindex(input.bookmarkId, {
+ groupId: ctx.user.id,
+ }),
+ triggerWebhook(input.bookmarkId, "edited", ctx.user.id, {
+ groupId: ctx.user.id,
+ }),
+ ]);
}),
deleteBookmark: authedProcedure
@@ -559,24 +565,20 @@ export const bookmarksAppRouter = router({
z.object({
bookmarkId: z.string(),
archiveFullPage: z.boolean().optional().default(false),
+ storePdf: z.boolean().optional().default(false),
}),
)
.use(ensureBookmarkOwnership)
.mutation(async ({ input, ctx }) => {
- await ctx.db
- .update(bookmarkLinks)
- .set({
- crawlStatus: "pending",
- crawlStatusCode: null,
- })
- .where(eq(bookmarkLinks.id, input.bookmarkId));
- await LinkCrawlerQueue.enqueue(
+ await LowPriorityCrawlerQueue.enqueue(
{
bookmarkId: input.bookmarkId,
archiveFullPage: input.archiveFullPage,
+ storePdf: input.storePdf,
},
{
groupId: ctx.user.id,
+ priority: QueuePriority.Low,
},
);
}),
@@ -711,36 +713,109 @@ export const bookmarksAppRouter = router({
)
.use(ensureBookmarkOwnership)
.mutation(async ({ input, ctx }) => {
- const res = await ctx.db.transaction(async (tx) => {
- // Detaches
- const idsToRemove: string[] = [];
- if (input.detach.length > 0) {
- const namesToRemove: string[] = [];
- input.detach.forEach((detachInfo) => {
- if (detachInfo.tagId) {
- idsToRemove.push(detachInfo.tagId);
- }
- if (detachInfo.tagName) {
- namesToRemove.push(detachInfo.tagName);
- }
- });
+ // Helper function to fetch tag IDs and their names from a list of tag identifiers
+ const fetchTagIdsWithNames = async (
+ tagIdentifiers: { tagId?: string; tagName?: string }[],
+ ): Promise<{ id: string; name: string }[]> => {
+ const tagIds = tagIdentifiers.flatMap((t) =>
+ t.tagId ? [t.tagId] : [],
+ );
+ const tagNames = tagIdentifiers.flatMap((t) =>
+ t.tagName ? [t.tagName] : [],
+ );
- if (namesToRemove.length > 0) {
- (
- await tx.query.bookmarkTags.findMany({
- where: and(
- eq(bookmarkTags.userId, ctx.user.id),
- inArray(bookmarkTags.name, namesToRemove),
- ),
- columns: {
- id: true,
- },
- })
- ).forEach((tag) => {
- idsToRemove.push(tag.id);
- });
+ // Fetch tag IDs in parallel
+ const [byIds, byNames] = await Promise.all([
+ tagIds.length > 0
+ ? ctx.db
+ .select({ id: bookmarkTags.id, name: bookmarkTags.name })
+ .from(bookmarkTags)
+ .where(
+ and(
+ eq(bookmarkTags.userId, ctx.user.id),
+ inArray(bookmarkTags.id, tagIds),
+ ),
+ )
+ : Promise.resolve([]),
+ tagNames.length > 0
+ ? ctx.db
+ .select({ id: bookmarkTags.id, name: bookmarkTags.name })
+ .from(bookmarkTags)
+ .where(
+ and(
+ eq(bookmarkTags.userId, ctx.user.id),
+ inArray(bookmarkTags.name, tagNames),
+ ),
+ )
+ : Promise.resolve([]),
+ ]);
+
+ // Union results and deduplicate by tag ID
+ const seen = new Set<string>();
+ const results: { id: string; name: string }[] = [];
+
+ for (const tag of [...byIds, ...byNames]) {
+ if (!seen.has(tag.id)) {
+ seen.add(tag.id);
+ results.push({ id: tag.id, name: tag.name });
}
+ }
+
+ return results;
+ };
+
+ // Normalize tag names and create new tags outside transaction to reduce transaction duration
+ const normalizedAttachTags = input.attach.map((tag) => ({
+ tagId: tag.tagId,
+ tagName: tag.tagName ? normalizeTagName(tag.tagName) : undefined,
+ attachedBy: tag.attachedBy,
+ }));
+
+ {
+ // Create new tags
+ const toAddTagNames = normalizedAttachTags
+ .flatMap((i) => (i.tagName ? [i.tagName] : []))
+ .filter((n) => n.length > 0); // drop empty results
+
+ if (toAddTagNames.length > 0) {
+ await ctx.db
+ .insert(bookmarkTags)
+ .values(
+ toAddTagNames.map((name) => ({ name, userId: ctx.user.id })),
+ )
+ .onConflictDoNothing();
+ }
+ }
+
+ // Fetch tag IDs for attachment/detachment now that we know that they all exist
+ const [attachTagsWithNames, detachTagsWithNames] = await Promise.all([
+ fetchTagIdsWithNames(normalizedAttachTags),
+ fetchTagIdsWithNames(input.detach),
+ ]);
+
+ // Build the attachedBy map from the fetched results
+ const tagIdToAttachedBy = new Map<string, "ai" | "human">();
+
+ for (const fetchedTag of attachTagsWithNames) {
+ // Find the corresponding input tag
+ const inputTag = normalizedAttachTags.find(
+ (t) =>
+ (t.tagId && t.tagId === fetchedTag.id) ||
+ (t.tagName && t.tagName === fetchedTag.name),
+ );
+
+ if (inputTag) {
+ tagIdToAttachedBy.set(fetchedTag.id, inputTag.attachedBy);
+ }
+ }
+
+ // Extract just the IDs for the transaction
+ const allIdsToAttach = attachTagsWithNames.map((t) => t.id);
+ const idsToRemove = detachTagsWithNames.map((t) => t.id);
+ const res = await ctx.db.transaction(async (tx) => {
+ // Detaches
+ if (idsToRemove.length > 0) {
await tx
.delete(tagsOnBookmarks)
.where(
@@ -751,67 +826,21 @@ export const bookmarksAppRouter = router({
);
}
- if (input.attach.length == 0) {
- return {
- bookmarkId: input.bookmarkId,
- attached: [],
- detached: idsToRemove,
- };
- }
-
- const toAddTagNames = input.attach
- .flatMap((i) => (i.tagName ? [i.tagName] : []))
- .map(normalizeTagName) // strip leading #
- .filter((n) => n.length > 0); // drop empty results
-
- const toAddTagIds = input.attach.flatMap((i) =>
- i.tagId ? [i.tagId] : [],
- );
-
- // New Tags
- if (toAddTagNames.length > 0) {
+ // Attach tags
+ if (allIdsToAttach.length > 0) {
await tx
- .insert(bookmarkTags)
+ .insert(tagsOnBookmarks)
.values(
- toAddTagNames.map((name) => ({ name, userId: ctx.user.id })),
+ allIdsToAttach.map((i) => ({
+ tagId: i,
+ bookmarkId: input.bookmarkId,
+ attachedBy: tagIdToAttachedBy.get(i) ?? "human",
+ })),
)
- .onConflictDoNothing()
- .returning();
+ .onConflictDoNothing();
}
- // If there is nothing to add, the "or" statement will become useless and
- // the query below will simply select all the existing tags for this user and assign them to the bookmark
- invariant(toAddTagNames.length > 0 || toAddTagIds.length > 0);
- const allIds = (
- await tx.query.bookmarkTags.findMany({
- where: and(
- eq(bookmarkTags.userId, ctx.user.id),
- or(
- toAddTagIds.length > 0
- ? inArray(bookmarkTags.id, toAddTagIds)
- : undefined,
- toAddTagNames.length > 0
- ? inArray(bookmarkTags.name, toAddTagNames)
- : undefined,
- ),
- ),
- columns: {
- id: true,
- },
- })
- ).map((t) => t.id);
-
- await tx
- .insert(tagsOnBookmarks)
- .values(
- allIds.map((i) => ({
- tagId: i,
- bookmarkId: input.bookmarkId,
- attachedBy: "human" as const,
- userId: ctx.user.id,
- })),
- )
- .onConflictDoNothing();
+ // Update bookmark modified timestamp
await tx
.update(bookmarks)
.set({ modifiedAt: new Date() })
@@ -824,7 +853,7 @@ export const bookmarksAppRouter = router({
return {
bookmarkId: input.bookmarkId,
- attached: allIds,
+ attached: allIdsToAttach,
detached: idsToRemove,
};
});
@@ -958,8 +987,15 @@ Author: ${bookmark.author ?? ""}
},
});
+ const userSettings = await ctx.db.query.users.findFirst({
+ where: eq(users.id, ctx.user.id),
+ columns: {
+ inferredTagLang: true,
+ },
+ });
+
const summaryPrompt = await buildSummaryPrompt(
- serverConfig.inference.inferredTagLang,
+ userSettings?.inferredTagLang ?? serverConfig.inference.inferredTagLang,
prompts.map((p) => p.text),
bookmarkDetails,
serverConfig.inference.contextLength,
@@ -981,12 +1017,14 @@ Author: ${bookmark.author ?? ""}
summary: summary.response,
})
.where(eq(bookmarks.id, input.bookmarkId));
- await triggerSearchReindex(input.bookmarkId, {
- groupId: ctx.user.id,
- });
- await triggerWebhook(input.bookmarkId, "edited", ctx.user.id, {
- groupId: ctx.user.id,
- });
+ await Promise.all([
+ triggerSearchReindex(input.bookmarkId, {
+ groupId: ctx.user.id,
+ }),
+ triggerWebhook(input.bookmarkId, "edited", ctx.user.id, {
+ groupId: ctx.user.id,
+ }),
+ ]);
return {
bookmarkId: input.bookmarkId,
diff --git a/packages/trpc/routers/config.ts b/packages/trpc/routers/config.ts
new file mode 100644
index 00000000..8d09a2ce
--- /dev/null
+++ b/packages/trpc/routers/config.ts
@@ -0,0 +1,10 @@
+import { clientConfig } from "@karakeep/shared/config";
+import { zClientConfigSchema } from "@karakeep/shared/types/config";
+
+import { publicProcedure, router } from "../index";
+
+export const configAppRouter = router({
+ clientConfig: publicProcedure
+ .output(zClientConfigSchema)
+ .query(() => clientConfig),
+});
diff --git a/packages/trpc/routers/feeds.test.ts b/packages/trpc/routers/feeds.test.ts
new file mode 100644
index 00000000..e80aab0a
--- /dev/null
+++ b/packages/trpc/routers/feeds.test.ts
@@ -0,0 +1,154 @@
+import { beforeEach, describe, expect, test } from "vitest";
+
+import type { CustomTestContext } from "../testUtils";
+import { defaultBeforeEach } from "../testUtils";
+
+beforeEach<CustomTestContext>(defaultBeforeEach(true));
+
+describe("Feed Routes", () => {
+ test<CustomTestContext>("create feed", async ({ apiCallers }) => {
+ const api = apiCallers[0].feeds;
+ const newFeed = await api.create({
+ name: "Test Feed",
+ url: "https://example.com/feed.xml",
+ enabled: true,
+ });
+
+ expect(newFeed).toBeDefined();
+ expect(newFeed.name).toEqual("Test Feed");
+ expect(newFeed.url).toEqual("https://example.com/feed.xml");
+ expect(newFeed.enabled).toBe(true);
+ });
+
+ test<CustomTestContext>("update feed", async ({ apiCallers }) => {
+ const api = apiCallers[0].feeds;
+
+ // First, create a feed to update
+ const createdFeed = await api.create({
+ name: "Test Feed",
+ url: "https://example.com/feed.xml",
+ enabled: true,
+ });
+
+ // Update it
+ const updatedFeed = await api.update({
+ feedId: createdFeed.id,
+ name: "Updated Feed",
+ url: "https://updated-example.com/feed.xml",
+ enabled: false,
+ });
+
+ expect(updatedFeed.name).toEqual("Updated Feed");
+ expect(updatedFeed.url).toEqual("https://updated-example.com/feed.xml");
+ expect(updatedFeed.enabled).toBe(false);
+
+ // Test updating a non-existent feed
+ await expect(() =>
+ api.update({
+ feedId: "non-existent-id",
+ name: "Fail",
+ url: "https://fail.com",
+ enabled: true,
+ }),
+ ).rejects.toThrow(/Feed not found/);
+ });
+
+ test<CustomTestContext>("list feeds", async ({ apiCallers }) => {
+ const api = apiCallers[0].feeds;
+
+ // Create a couple of feeds
+ await api.create({
+ name: "Feed 1",
+ url: "https://example1.com/feed.xml",
+ enabled: true,
+ });
+ await api.create({
+ name: "Feed 2",
+ url: "https://example2.com/feed.xml",
+ enabled: true,
+ });
+
+ const result = await api.list();
+ expect(result.feeds).toBeDefined();
+ expect(result.feeds.length).toBeGreaterThanOrEqual(2);
+ expect(result.feeds.some((f) => f.name === "Feed 1")).toBe(true);
+ expect(result.feeds.some((f) => f.name === "Feed 2")).toBe(true);
+ });
+
+ test<CustomTestContext>("delete feed", async ({ apiCallers }) => {
+ const api = apiCallers[0].feeds;
+
+ // Create a feed to delete
+ const createdFeed = await api.create({
+ name: "Test Feed",
+ url: "https://example.com/feed.xml",
+ enabled: true,
+ });
+
+ // Delete it
+ await api.delete({ feedId: createdFeed.id });
+
+ // Verify it's deleted
+ await expect(() =>
+ api.update({
+ feedId: createdFeed.id,
+ name: "Updated",
+ url: "https://updated.com",
+ enabled: true,
+ }),
+ ).rejects.toThrow(/Feed not found/);
+ });
+
+ test<CustomTestContext>("privacy for feeds", async ({ apiCallers }) => {
+ const user1Feed = await apiCallers[0].feeds.create({
+ name: "User 1 Feed",
+ url: "https://user1-feed.com/feed.xml",
+ enabled: true,
+ });
+ const user2Feed = await apiCallers[1].feeds.create({
+ name: "User 2 Feed",
+ url: "https://user2-feed.com/feed.xml",
+ enabled: true,
+ });
+
+ // User 1 should not access User 2's feed
+ await expect(() =>
+ apiCallers[0].feeds.delete({ feedId: user2Feed.id }),
+ ).rejects.toThrow(/User is not allowed to access resource/);
+ await expect(() =>
+ apiCallers[0].feeds.update({
+ feedId: user2Feed.id,
+ name: "Fail",
+ url: "https://fail.com",
+ enabled: true,
+ }),
+ ).rejects.toThrow(/User is not allowed to access resource/);
+
+ // List should only show the correct user's feeds
+ const user1List = await apiCallers[0].feeds.list();
+ expect(user1List.feeds.some((f) => f.id === user1Feed.id)).toBe(true);
+ expect(user1List.feeds.some((f) => f.id === user2Feed.id)).toBe(false);
+ });
+
+ test<CustomTestContext>("feed limit enforcement", async ({ apiCallers }) => {
+ const api = apiCallers[0].feeds;
+
+ // Create 1000 feeds (the maximum)
+ for (let i = 0; i < 1000; i++) {
+ await api.create({
+ name: `Feed ${i}`,
+ url: `https://example${i}.com/feed.xml`,
+ enabled: true,
+ });
+ }
+
+ // The 1001st feed should fail
+ await expect(() =>
+ api.create({
+ name: "Feed 1001",
+ url: "https://example1001.com/feed.xml",
+ enabled: true,
+ }),
+ ).rejects.toThrow(/Maximum number of RSS feeds \(1000\) reached/);
+ });
+});
diff --git a/packages/trpc/routers/importSessions.test.ts b/packages/trpc/routers/importSessions.test.ts
index 9ef0de6f..f257ad3b 100644
--- a/packages/trpc/routers/importSessions.test.ts
+++ b/packages/trpc/routers/importSessions.test.ts
@@ -1,12 +1,13 @@
-import { eq } from "drizzle-orm";
import { beforeEach, describe, expect, test } from "vitest";
import { z } from "zod";
-import { bookmarks } from "@karakeep/db/schema";
import {
- BookmarkTypes,
- zNewBookmarkRequestSchema,
-} from "@karakeep/shared/types/bookmarks";
+ bookmarkLinks,
+ bookmarks,
+ bookmarkTexts,
+ importStagingBookmarks,
+} from "@karakeep/db/schema";
+import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
import {
zCreateImportSessionRequestSchema,
zDeleteImportSessionRequestSchema,
@@ -20,17 +21,6 @@ import { defaultBeforeEach } from "../testUtils";
beforeEach<CustomTestContext>(defaultBeforeEach(true));
describe("ImportSessions Routes", () => {
- async function createTestBookmark(api: APICallerType, sessionId: string) {
- const newBookmarkInput: z.infer<typeof zNewBookmarkRequestSchema> = {
- type: BookmarkTypes.TEXT,
- text: "Test bookmark text",
- importSessionId: sessionId,
- };
- const createdBookmark =
- await api.bookmarks.createBookmark(newBookmarkInput);
- return createdBookmark.id;
- }
-
async function createTestList(api: APICallerType) {
const newListInput: z.infer<typeof zNewBookmarkListSchema> = {
name: "Test Import List",
@@ -98,8 +88,15 @@ describe("ImportSessions Routes", () => {
const session = await api.importSessions.createImportSession({
name: "Test Import Session",
});
- await createTestBookmark(api, session.id);
- await createTestBookmark(api, session.id);
+
+ // Stage bookmarks using the staging flow
+ await api.importSessions.stageImportedBookmarks({
+ importSessionId: session.id,
+ bookmarks: [
+ { type: "text", content: "Test bookmark 1", tags: [], listIds: [] },
+ { type: "text", content: "Test bookmark 2", tags: [], listIds: [] },
+ ],
+ });
const statsInput: z.infer<typeof zGetImportSessionStatsRequestSchema> = {
importSessionId: session.id,
@@ -110,7 +107,7 @@ describe("ImportSessions Routes", () => {
expect(stats).toMatchObject({
id: session.id,
name: "Test Import Session",
- status: "in_progress",
+ status: "staging",
totalBookmarks: 2,
pendingBookmarks: 2,
completedBookmarks: 0,
@@ -119,31 +116,191 @@ describe("ImportSessions Routes", () => {
});
});
- test<CustomTestContext>("marks text-only imports as completed when tagging succeeds", async ({
+ test<CustomTestContext>("stats reflect crawl and tagging status for completed staging bookmarks", async ({
apiCallers,
db,
}) => {
const api = apiCallers[0];
+
const session = await api.importSessions.createImportSession({
- name: "Text Import Session",
+ name: "Test Import Session",
});
- const bookmarkId = await createTestBookmark(api, session.id);
- await db
- .update(bookmarks)
- .set({ taggingStatus: "success" })
- .where(eq(bookmarks.id, bookmarkId));
+ // Create bookmarks with different crawl/tag statuses
+ const user = (await db.query.users.findFirst())!;
+
+ // 1. Link bookmark: crawl success, tag success -> completed
+ const [completedLinkBookmark] = await db
+ .insert(bookmarks)
+ .values({
+ userId: user.id,
+ type: BookmarkTypes.LINK,
+ taggingStatus: "success",
+ })
+ .returning();
+ await db.insert(bookmarkLinks).values({
+ id: completedLinkBookmark.id,
+ url: "https://example.com/1",
+ crawlStatus: "success",
+ });
+
+ // 2. Link bookmark: crawl pending, tag success -> processing
+ const [crawlPendingBookmark] = await db
+ .insert(bookmarks)
+ .values({
+ userId: user.id,
+ type: BookmarkTypes.LINK,
+ taggingStatus: "success",
+ })
+ .returning();
+ await db.insert(bookmarkLinks).values({
+ id: crawlPendingBookmark.id,
+ url: "https://example.com/2",
+ crawlStatus: "pending",
+ });
+
+ // 3. Text bookmark: tag pending -> processing
+ const [tagPendingBookmark] = await db
+ .insert(bookmarks)
+ .values({
+ userId: user.id,
+ type: BookmarkTypes.TEXT,
+ taggingStatus: "pending",
+ })
+ .returning();
+ await db.insert(bookmarkTexts).values({
+ id: tagPendingBookmark.id,
+ text: "Test text",
+ });
+
+ // 4. Link bookmark: crawl failure -> failed
+ const [crawlFailedBookmark] = await db
+ .insert(bookmarks)
+ .values({
+ userId: user.id,
+ type: BookmarkTypes.LINK,
+ taggingStatus: "success",
+ })
+ .returning();
+ await db.insert(bookmarkLinks).values({
+ id: crawlFailedBookmark.id,
+ url: "https://example.com/3",
+ crawlStatus: "failure",
+ });
+
+ // 5. Text bookmark: tag failure -> failed
+ const [tagFailedBookmark] = await db
+ .insert(bookmarks)
+ .values({
+ userId: user.id,
+ type: BookmarkTypes.TEXT,
+ taggingStatus: "failure",
+ })
+ .returning();
+ await db.insert(bookmarkTexts).values({
+ id: tagFailedBookmark.id,
+ text: "Test text 2",
+ });
+
+ // 6. Text bookmark: tag success (no crawl needed) -> completed
+ const [completedTextBookmark] = await db
+ .insert(bookmarks)
+ .values({
+ userId: user.id,
+ type: BookmarkTypes.TEXT,
+ taggingStatus: "success",
+ })
+ .returning();
+ await db.insert(bookmarkTexts).values({
+ id: completedTextBookmark.id,
+ text: "Test text 3",
+ });
+
+ // Create staging bookmarks in different states
+ // Note: With the new import worker design, items stay in "processing" until
+ // crawl/tag is done. Only then do they move to "completed".
+ await db.insert(importStagingBookmarks).values([
+ // Staging pending -> pendingBookmarks
+ {
+ importSessionId: session.id,
+ type: "text",
+ content: "pending staging",
+ status: "pending",
+ },
+ // Staging processing (no bookmark yet) -> processingBookmarks
+ {
+ importSessionId: session.id,
+ type: "text",
+ content: "processing staging",
+ status: "processing",
+ },
+ // Staging failed -> failedBookmarks
+ {
+ importSessionId: session.id,
+ type: "text",
+ content: "failed staging",
+ status: "failed",
+ },
+ // Staging completed + crawl/tag success -> completedBookmarks
+ {
+ importSessionId: session.id,
+ type: "link",
+ url: "https://example.com/1",
+ status: "completed",
+ resultBookmarkId: completedLinkBookmark.id,
+ },
+ // Staging processing + crawl pending -> processingBookmarks (waiting for crawl)
+ {
+ importSessionId: session.id,
+ type: "link",
+ url: "https://example.com/2",
+ status: "processing",
+ resultBookmarkId: crawlPendingBookmark.id,
+ },
+ // Staging processing + tag pending -> processingBookmarks (waiting for tag)
+ {
+ importSessionId: session.id,
+ type: "text",
+ content: "tag pending",
+ status: "processing",
+ resultBookmarkId: tagPendingBookmark.id,
+ },
+ // Staging completed + crawl failure -> completedBookmarks (failure is terminal)
+ {
+ importSessionId: session.id,
+ type: "link",
+ url: "https://example.com/3",
+ status: "completed",
+ resultBookmarkId: crawlFailedBookmark.id,
+ },
+ // Staging completed + tag failure -> completedBookmarks (failure is terminal)
+ {
+ importSessionId: session.id,
+ type: "text",
+ content: "tag failed",
+ status: "completed",
+ resultBookmarkId: tagFailedBookmark.id,
+ },
+ // Staging completed + tag success (text, no crawl) -> completedBookmarks
+ {
+ importSessionId: session.id,
+ type: "text",
+ content: "completed text",
+ status: "completed",
+ resultBookmarkId: completedTextBookmark.id,
+ },
+ ]);
const stats = await api.importSessions.getImportSessionStats({
importSessionId: session.id,
});
expect(stats).toMatchObject({
- completedBookmarks: 1,
- pendingBookmarks: 0,
- failedBookmarks: 0,
- totalBookmarks: 1,
- status: "completed",
+ totalBookmarks: 9,
+ pendingBookmarks: 1, // staging pending
+ processingBookmarks: 3, // staging processing (no bookmark) + crawl pending + tag pending
+ completedBookmarks: 4, // link success + text success + crawl failure + tag failure
+ failedBookmarks: 1, // staging failed
});
});
@@ -215,7 +372,7 @@ describe("ImportSessions Routes", () => {
).rejects.toThrow("Import session not found");
});
- test<CustomTestContext>("cannot attach other user's bookmark", async ({
+ test<CustomTestContext>("cannot stage other user's session", async ({
apiCallers,
}) => {
const api1 = apiCallers[0];
@@ -228,7 +385,17 @@ describe("ImportSessions Routes", () => {
// User 1 tries to attach User 2's bookmark
await expect(
- createTestBookmark(api2, session.id), // User 2's bookmark
+ api2.importSessions.stageImportedBookmarks({
+ importSessionId: session.id,
+ bookmarks: [
+ {
+ type: "text",
+ content: "Test bookmark",
+ tags: [],
+ listIds: [],
+ },
+ ],
+ }),
).rejects.toThrow("Import session not found");
});
});
diff --git a/packages/trpc/routers/importSessions.ts b/packages/trpc/routers/importSessions.ts
index 4bdc4f29..62263bdd 100644
--- a/packages/trpc/routers/importSessions.ts
+++ b/packages/trpc/routers/importSessions.ts
@@ -1,5 +1,8 @@
+import { experimental_trpcMiddleware } from "@trpc/server";
+import { and, eq, gt } from "drizzle-orm";
import { z } from "zod";
+import { importStagingBookmarks } from "@karakeep/db/schema";
import {
zCreateImportSessionRequestSchema,
zDeleteImportSessionRequestSchema,
@@ -9,9 +12,26 @@ import {
zListImportSessionsResponseSchema,
} from "@karakeep/shared/types/importSessions";
+import type { AuthedContext } from "../index";
import { authedProcedure, router } from "../index";
import { ImportSession } from "../models/importSessions";
+const ensureImportSessionAccess = experimental_trpcMiddleware<{
+ ctx: AuthedContext;
+ input: { importSessionId: string };
+}>().create(async (opts) => {
+ const importSession = await ImportSession.fromId(
+ opts.ctx,
+ opts.input.importSessionId,
+ );
+ return opts.next({
+ ctx: {
+ ...opts.ctx,
+ importSession,
+ },
+ });
+});
+
export const importSessionsRouter = router({
createImportSession: authedProcedure
.input(zCreateImportSessionRequestSchema)
@@ -45,4 +65,93 @@ export const importSessionsRouter = router({
await session.delete();
return { success: true };
}),
+
+ stageImportedBookmarks: authedProcedure
+ .input(
+ z.object({
+ importSessionId: z.string(),
+ bookmarks: z
+ .array(
+ z.object({
+ type: z.enum(["link", "text", "asset"]),
+ url: z.string().optional(),
+ title: z.string().optional(),
+ content: z.string().optional(),
+ note: z.string().optional(),
+ tags: z.array(z.string()).default([]),
+ listIds: z.array(z.string()).default([]),
+ sourceAddedAt: z.date().optional(),
+ }),
+ )
+ .max(50),
+ }),
+ )
+ .use(ensureImportSessionAccess)
+ .mutation(async ({ input, ctx }) => {
+ await ctx.importSession.stageBookmarks(input.bookmarks);
+ }),
+
+ finalizeImportStaging: authedProcedure
+ .input(z.object({ importSessionId: z.string() }))
+ .use(ensureImportSessionAccess)
+ .mutation(async ({ ctx }) => {
+ await ctx.importSession.finalize();
+ }),
+
+ pauseImportSession: authedProcedure
+ .input(z.object({ importSessionId: z.string() }))
+ .use(ensureImportSessionAccess)
+ .mutation(async ({ ctx }) => {
+ await ctx.importSession.pause();
+ }),
+
+ resumeImportSession: authedProcedure
+ .input(z.object({ importSessionId: z.string() }))
+ .use(ensureImportSessionAccess)
+ .mutation(async ({ ctx }) => {
+ await ctx.importSession.resume();
+ }),
+
+ getImportSessionResults: authedProcedure
+ .input(
+ z.object({
+ importSessionId: z.string(),
+ filter: z
+ .enum(["all", "accepted", "rejected", "skipped_duplicate", "pending"])
+ .optional(),
+ cursor: z.string().optional(),
+ limit: z.number().default(50),
+ }),
+ )
+ .use(ensureImportSessionAccess)
+ .query(async ({ ctx, input }) => {
+ const results = await ctx.db
+ .select()
+ .from(importStagingBookmarks)
+ .where(
+ and(
+ eq(
+ importStagingBookmarks.importSessionId,
+ ctx.importSession.session.id,
+ ),
+ input.filter && input.filter !== "all"
+ ? input.filter === "pending"
+ ? eq(importStagingBookmarks.status, "pending")
+ : eq(importStagingBookmarks.result, input.filter)
+ : undefined,
+ input.cursor
+ ? gt(importStagingBookmarks.id, input.cursor)
+ : undefined,
+ ),
+ )
+ .orderBy(importStagingBookmarks.id)
+ .limit(input.limit + 1);
+
+ // Return with pagination info
+ const hasMore = results.length > input.limit;
+ return {
+ items: results.slice(0, input.limit),
+ nextCursor: hasMore ? results[input.limit - 1].id : null,
+ };
+ }),
});
diff --git a/packages/trpc/routers/lists.test.ts b/packages/trpc/routers/lists.test.ts
index 8797b35e..214df32a 100644
--- a/packages/trpc/routers/lists.test.ts
+++ b/packages/trpc/routers/lists.test.ts
@@ -594,3 +594,385 @@ describe("recursive delete", () => {
expect(lists.lists.find((l) => l.id === child.id)).toBeUndefined();
});
});
+
+describe("Nested smart lists", () => {
+ test<CustomTestContext>("smart list can reference another smart list", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0];
+
+ // Create a bookmark that is favourited
+ const bookmark1 = await api.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Favourited bookmark",
+ });
+ await api.bookmarks.updateBookmark({
+ bookmarkId: bookmark1.id,
+ favourited: true,
+ });
+
+ // Create a bookmark that is not favourited
+ const bookmark2 = await api.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Non-favourited bookmark",
+ });
+
+ // Create a smart list that matches favourited bookmarks
+ await api.lists.create({
+ name: "Favourites",
+ type: "smart",
+ query: "is:fav",
+ icon: "⭐",
+ });
+
+ // Create a smart list that references the first smart list
+ const smartListB = await api.lists.create({
+ name: "From Favourites",
+ type: "smart",
+ query: "list:Favourites",
+ icon: "📋",
+ });
+
+ // Get bookmarks from the nested smart list
+ const bookmarksInSmartListB = await api.bookmarks.getBookmarks({
+ listId: smartListB.id,
+ });
+
+ // Should contain the favourited bookmark
+ expect(bookmarksInSmartListB.bookmarks.length).toBe(1);
+ expect(bookmarksInSmartListB.bookmarks[0].id).toBe(bookmark1.id);
+
+ // Verify bookmark2 is not in the nested smart list
+ expect(
+ bookmarksInSmartListB.bookmarks.find((b) => b.id === bookmark2.id),
+ ).toBeUndefined();
+ });
+
+ test<CustomTestContext>("nested smart lists with multiple levels", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0];
+
+ // Create a bookmark that is archived
+ const bookmark = await api.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Archived bookmark",
+ });
+ await api.bookmarks.updateBookmark({
+ bookmarkId: bookmark.id,
+ archived: true,
+ });
+
+ // Create smart list A: matches archived bookmarks
+ await api.lists.create({
+ name: "Archived",
+ type: "smart",
+ query: "is:archived",
+ icon: "đŸ“Ļ",
+ });
+
+ // Create smart list B: references list A
+ await api.lists.create({
+ name: "Level1",
+ type: "smart",
+ query: "list:Archived",
+ icon: "1ī¸âƒŖ",
+ });
+
+ // Create smart list C: references list B (3 levels deep)
+ const smartListC = await api.lists.create({
+ name: "Level2",
+ type: "smart",
+ query: "list:Level1",
+ icon: "2ī¸âƒŖ",
+ });
+
+ // Get bookmarks from the deepest nested smart list
+ const bookmarksInSmartListC = await api.bookmarks.getBookmarks({
+ listId: smartListC.id,
+ });
+
+ // Should contain the archived bookmark
+ expect(bookmarksInSmartListC.bookmarks.length).toBe(1);
+ expect(bookmarksInSmartListC.bookmarks[0].id).toBe(bookmark.id);
+ });
+
+ test<CustomTestContext>("smart list with inverse reference to another smart list", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0];
+
+ // Create two bookmarks
+ const favouritedBookmark = await api.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Favourited bookmark",
+ });
+ await api.bookmarks.updateBookmark({
+ bookmarkId: favouritedBookmark.id,
+ favourited: true,
+ });
+
+ const normalBookmark = await api.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Normal bookmark",
+ });
+
+ // Create a smart list that matches favourited bookmarks
+ await api.lists.create({
+ name: "Favourites",
+ type: "smart",
+ query: "is:fav",
+ icon: "⭐",
+ });
+
+ // Create a smart list with negative reference to Favourites
+ const notInFavourites = await api.lists.create({
+ name: "Not In Favourites",
+ type: "smart",
+ query: "-list:Favourites",
+ icon: "❌",
+ });
+
+ // Get bookmarks from the smart list
+ const bookmarksNotInFav = await api.bookmarks.getBookmarks({
+ listId: notInFavourites.id,
+ });
+
+ // Should contain only the non-favourited bookmark
+ expect(bookmarksNotInFav.bookmarks.length).toBe(1);
+ expect(bookmarksNotInFav.bookmarks[0].id).toBe(normalBookmark.id);
+ });
+
+ test<CustomTestContext>("circular reference between smart lists returns empty", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0];
+
+ // Create a bookmark
+ const bookmark = await api.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Test bookmark",
+ });
+ await api.bookmarks.updateBookmark({
+ bookmarkId: bookmark.id,
+ favourited: true,
+ });
+
+ // Create smart list A that references smart list B
+ const smartListA = await api.lists.create({
+ name: "ListA",
+ type: "smart",
+ query: "list:ListB",
+ icon: "đŸ…°ī¸",
+ });
+
+ // Create smart list B that references smart list A (circular!)
+ await api.lists.create({
+ name: "ListB",
+ type: "smart",
+ query: "list:ListA",
+ icon: "đŸ…ąī¸",
+ });
+
+ // Querying ListA should return empty because of the circular reference
+ const bookmarksInListA = await api.bookmarks.getBookmarks({
+ listId: smartListA.id,
+ });
+
+ // Should be empty due to circular reference detection
+ expect(bookmarksInListA.bookmarks.length).toBe(0);
+ });
+
+ test<CustomTestContext>("self-referencing smart list returns empty", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0];
+
+ // Create a bookmark
+ await api.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Test bookmark",
+ });
+
+ // Create a smart list that references itself
+ const selfRefList = await api.lists.create({
+ name: "SelfRef",
+ type: "smart",
+ query: "list:SelfRef",
+ icon: "🔄",
+ });
+
+ // Querying should return empty because of self-reference
+ const bookmarks = await api.bookmarks.getBookmarks({
+ listId: selfRefList.id,
+ });
+
+ expect(bookmarks.bookmarks.length).toBe(0);
+ });
+
+ test<CustomTestContext>("three-way circular reference returns empty", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0];
+
+ // Create a bookmark
+ await api.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Test bookmark",
+ });
+
+ // Create three smart lists with circular references: A -> B -> C -> A
+ const listA = await api.lists.create({
+ name: "CircularA",
+ type: "smart",
+ query: "list:CircularB",
+ icon: "đŸ…°ī¸",
+ });
+
+ await api.lists.create({
+ name: "CircularB",
+ type: "smart",
+ query: "list:CircularC",
+ icon: "đŸ…ąī¸",
+ });
+
+ await api.lists.create({
+ name: "CircularC",
+ type: "smart",
+ query: "list:CircularA",
+ icon: "ÂŠī¸",
+ });
+
+ // Querying any of them should return empty due to circular reference
+ const bookmarksInListA = await api.bookmarks.getBookmarks({
+ listId: listA.id,
+ });
+
+ expect(bookmarksInListA.bookmarks.length).toBe(0);
+ });
+
+ test<CustomTestContext>("smart list traversal above max visited lists returns empty", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0];
+
+ const bookmark = await api.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Depth test bookmark",
+ });
+
+ const manualList = await api.lists.create({
+ name: "DepthBaseManual",
+ type: "manual",
+ icon: "📋",
+ });
+ await api.lists.addToList({
+ listId: manualList.id,
+ bookmarkId: bookmark.id,
+ });
+
+ const maxVisitedLists = 30;
+ const overLimitChainLength = maxVisitedLists + 1;
+
+ for (let i = overLimitChainLength; i >= 2; i--) {
+ await api.lists.create({
+ name: `DepthL${i}`,
+ type: "smart",
+ query:
+ i === overLimitChainLength
+ ? "list:DepthBaseManual"
+ : `list:DepthL${i + 1}`,
+ icon: "D",
+ });
+ }
+
+ const depthRoot = await api.lists.create({
+ name: "DepthL1",
+ type: "smart",
+ query: "list:DepthL2",
+ icon: "D",
+ });
+
+ const bookmarksInRoot = await api.bookmarks.getBookmarks({
+ listId: depthRoot.id,
+ });
+
+ expect(bookmarksInRoot.bookmarks.length).toBe(0);
+ });
+
+ test<CustomTestContext>("smart list references non-existent list returns empty", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0];
+
+ // Create a bookmark
+ await api.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Test bookmark",
+ });
+
+ // Create a smart list that references a non-existent list
+ const smartList = await api.lists.create({
+ name: "RefNonExistent",
+ type: "smart",
+ query: "list:NonExistentList",
+ icon: "❓",
+ });
+
+ // Should return empty since the referenced list doesn't exist
+ const bookmarks = await api.bookmarks.getBookmarks({
+ listId: smartList.id,
+ });
+
+ expect(bookmarks.bookmarks.length).toBe(0);
+ });
+
+ test<CustomTestContext>("smart list can reference manual list", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0];
+
+ // Create bookmarks
+ const bookmark1 = await api.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Bookmark in manual list",
+ });
+ const bookmark2 = await api.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Bookmark not in list",
+ });
+
+ // Create a manual list and add bookmark1
+ const manualList = await api.lists.create({
+ name: "ManualList",
+ type: "manual",
+ icon: "📋",
+ });
+ await api.lists.addToList({
+ listId: manualList.id,
+ bookmarkId: bookmark1.id,
+ });
+
+ // Create a smart list that references the manual list
+ const smartList = await api.lists.create({
+ name: "SmartRefManual",
+ type: "smart",
+ query: "list:ManualList",
+ icon: "🔗",
+ });
+
+ // Get bookmarks from the smart list
+ const bookmarksInSmartList = await api.bookmarks.getBookmarks({
+ listId: smartList.id,
+ });
+
+ // Should contain only bookmark1
+ expect(bookmarksInSmartList.bookmarks.length).toBe(1);
+ expect(bookmarksInSmartList.bookmarks[0].id).toBe(bookmark1.id);
+
+ // Verify bookmark2 is not in the smart list
+ expect(
+ bookmarksInSmartList.bookmarks.find((b) => b.id === bookmark2.id),
+ ).toBeUndefined();
+ });
+});
diff --git a/packages/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts
index 296679f3..bca3dc53 100644
--- a/packages/trpc/routers/lists.ts
+++ b/packages/trpc/routers/lists.ts
@@ -302,6 +302,7 @@ export const listsAppRouter = router({
id: z.string(),
name: z.string(),
email: z.string().nullable(),
+ image: z.string().nullable(),
}),
}),
),
@@ -310,6 +311,7 @@ export const listsAppRouter = router({
id: z.string(),
name: z.string(),
email: z.string().nullable(),
+ image: z.string().nullable(),
})
.nullable(),
}),
diff --git a/packages/trpc/routers/tags.ts b/packages/trpc/routers/tags.ts
index d4cfbe8c..5713c192 100644
--- a/packages/trpc/routers/tags.ts
+++ b/packages/trpc/routers/tags.ts
@@ -102,6 +102,7 @@ export const tagsAppRouter = router({
.query(async ({ ctx, input }) => {
return await Tag.getAll(ctx, {
nameContains: input.nameContains,
+ ids: input.ids,
attachedBy: input.attachedBy,
sortBy: input.sortBy,
pagination: input.limit
diff --git a/packages/trpc/routers/users.test.ts b/packages/trpc/routers/users.test.ts
index a2f2be9f..d8ec90f9 100644
--- a/packages/trpc/routers/users.test.ts
+++ b/packages/trpc/routers/users.test.ts
@@ -158,6 +158,18 @@ describe("User Routes", () => {
backupsEnabled: false,
backupsFrequency: "weekly",
backupsRetentionDays: 30,
+
+ // Reader settings
+ readerFontFamily: null,
+ readerFontSize: null,
+ readerLineHeight: null,
+
+ // AI Settings
+ autoSummarizationEnabled: null,
+ autoTaggingEnabled: null,
+ curatedTagIds: null,
+ inferredTagLang: null,
+ tagStyle: "titlecase-spaces",
});
// Update settings
@@ -166,6 +178,17 @@ describe("User Routes", () => {
backupsEnabled: true,
backupsFrequency: "daily",
backupsRetentionDays: 7,
+
+ // Reader settings
+ readerFontFamily: "serif",
+ readerFontSize: 12,
+ readerLineHeight: 1.5,
+
+ // AI Settings
+ autoSummarizationEnabled: true,
+ autoTaggingEnabled: true,
+ inferredTagLang: "en",
+ tagStyle: "lowercase-underscores",
});
// Verify updated settings
@@ -177,6 +200,18 @@ describe("User Routes", () => {
backupsEnabled: true,
backupsFrequency: "daily",
backupsRetentionDays: 7,
+
+ // Reader settings
+ readerFontFamily: "serif",
+ readerFontSize: 12,
+ readerLineHeight: 1.5,
+
+ // AI Settings
+ autoSummarizationEnabled: true,
+ autoTaggingEnabled: true,
+ curatedTagIds: null,
+ inferredTagLang: "en",
+ tagStyle: "lowercase-underscores",
});
// Test invalid update (e.g., empty input, if schema enforces it)
@@ -915,6 +950,81 @@ describe("User Routes", () => {
});
});
+ describe("Update Avatar", () => {
+ test<CustomTestContext>("updateAvatar - promotes unknown asset", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Avatar Reject",
+ email: "avatar-reject@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+ const caller = getApiCaller(db, user.id, user.email, user.role || "user");
+
+ await db.insert(assets).values({
+ id: "avatar-asset-2",
+ assetType: AssetTypes.UNKNOWN,
+ userId: user.id,
+ contentType: "image/png",
+ size: 12,
+ fileName: "avatar.png",
+ bookmarkId: null,
+ });
+
+ await caller.users.updateAvatar({ assetId: "avatar-asset-2" });
+
+ const updatedAsset = await db
+ .select()
+ .from(assets)
+ .where(eq(assets.id, "avatar-asset-2"))
+ .then((rows) => rows[0]);
+
+ expect(updatedAsset?.assetType).toBe(AssetTypes.AVATAR);
+ });
+
+ test<CustomTestContext>("updateAvatar - deletes avatar asset", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Avatar Delete",
+ email: "avatar-delete@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+ const caller = getApiCaller(db, user.id, user.email, user.role || "user");
+
+ await db.insert(assets).values({
+ id: "avatar-asset-3",
+ assetType: AssetTypes.UNKNOWN,
+ userId: user.id,
+ contentType: "image/png",
+ size: 12,
+ fileName: "avatar.png",
+ bookmarkId: null,
+ });
+
+ await caller.users.updateAvatar({ assetId: "avatar-asset-3" });
+ await caller.users.updateAvatar({ assetId: null });
+
+ const updatedUser = await db
+ .select()
+ .from(users)
+ .where(eq(users.id, user.id))
+ .then((rows) => rows[0]);
+ const remainingAsset = await db
+ .select()
+ .from(assets)
+ .where(eq(assets.id, "avatar-asset-3"))
+ .then((rows) => rows[0]);
+
+ expect(updatedUser?.image).toBeNull();
+ expect(remainingAsset).toBeUndefined();
+ });
+ });
+
describe("Who Am I", () => {
test<CustomTestContext>("whoami - returns user info", async ({
db,
@@ -1008,6 +1118,7 @@ describe("User Routes", () => {
"resend@test.com",
"Test User",
expect.any(String), // token
+ undefined, // redirectUrl
);
});
diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts
index d3bc06d9..c11a0ffd 100644
--- a/packages/trpc/routers/users.ts
+++ b/packages/trpc/routers/users.ts
@@ -9,7 +9,9 @@ import {
zUserSettingsSchema,
zUserStatsResponseSchema,
zWhoAmIResponseSchema,
+ zWrappedStatsResponseSchema,
} from "@karakeep/shared/types/users";
+import { validateRedirectUrl } from "@karakeep/shared/utils/redirectUrl";
import {
adminProcedure,
@@ -30,7 +32,7 @@ export const usersAppRouter = router({
maxRequests: 3,
}),
)
- .input(zSignUpSchema)
+ .input(zSignUpSchema.and(z.object({ redirectUrl: z.string().optional() })))
.output(
z.object({
id: z.string(),
@@ -64,7 +66,11 @@ export const usersAppRouter = router({
});
}
}
- const user = await User.create(ctx, input);
+ const validatedRedirectUrl = validateRedirectUrl(input.redirectUrl);
+ const user = await User.create(ctx, {
+ ...input,
+ redirectUrl: validatedRedirectUrl,
+ });
return {
id: user.id,
name: user.name,
@@ -136,6 +142,24 @@ export const usersAppRouter = router({
const user = await User.fromCtx(ctx);
return await user.getStats();
}),
+ wrapped: authedProcedure
+ .output(zWrappedStatsResponseSchema)
+ .query(async ({ ctx }) => {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "This endpoint is currently disabled",
+ });
+ const user = await User.fromCtx(ctx);
+ return await user.getWrappedStats(2025);
+ }),
+ hasWrapped: authedProcedure.output(z.boolean()).query(async ({ ctx }) => {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "This endpoint is currently disabled",
+ });
+ const user = await User.fromCtx(ctx);
+ return await user.hasWrapped();
+ }),
settings: authedProcedure
.output(zUserSettingsSchema)
.query(async ({ ctx }) => {
@@ -148,6 +172,16 @@ export const usersAppRouter = router({
const user = await User.fromCtx(ctx);
await user.updateSettings(input);
}),
+ updateAvatar: authedProcedure
+ .input(
+ z.object({
+ assetId: z.string().nullable(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const user = await User.fromCtx(ctx);
+ await user.updateAvatar(input.assetId);
+ }),
verifyEmail: publicProcedure
.use(
createRateLimitMiddleware({
@@ -177,10 +211,16 @@ export const usersAppRouter = router({
.input(
z.object({
email: z.string().email(),
+ redirectUrl: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
- await User.resendVerificationEmail(ctx, input.email);
+ const validatedRedirectUrl = validateRedirectUrl(input.redirectUrl);
+ await User.resendVerificationEmail(
+ ctx,
+ input.email,
+ validatedRedirectUrl,
+ );
return { success: true };
}),
forgotPassword: publicProcedure
diff --git a/packages/trpc/routers/webhooks.test.ts b/packages/trpc/routers/webhooks.test.ts
index 5a136a31..de27b11e 100644
--- a/packages/trpc/routers/webhooks.test.ts
+++ b/packages/trpc/routers/webhooks.test.ts
@@ -125,4 +125,26 @@ describe("Webhook Routes", () => {
false,
);
});
+
+ test<CustomTestContext>("webhook limit enforcement", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0].webhooks;
+
+ // Create 100 webhooks (the maximum)
+ for (let i = 0; i < 100; i++) {
+ await api.create({
+ url: `https://example${i}.com/webhook`,
+ events: ["created"],
+ });
+ }
+
+ // The 101st webhook should fail
+ await expect(() =>
+ api.create({
+ url: "https://example101.com/webhook",
+ events: ["created"],
+ }),
+ ).rejects.toThrow(/Maximum number of webhooks \(100\) reached/);
+ });
});