aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/models
diff options
context:
space:
mode:
Diffstat (limited to 'packages/trpc/models')
-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.ts174
-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
9 files changed, 1194 insertions, 219 deletions
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;
-
- if (isCrawlSuccessfulOrNotRequired && isTagSuccessfulOrUnknown) {
- stats.completedBookmarks += count;
- } else {
- // Fallback to pending to avoid leaving imports unclassified
- stats.pendingBookmarks += count;
+ statusCounts.forEach(({ status, count: itemCount }) => {
+ stats.totalBookmarks += itemCount;
+
+ 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({