diff options
Diffstat (limited to 'packages/trpc/models')
| -rw-r--r-- | packages/trpc/models/assets.ts | 282 | ||||
| -rw-r--r-- | packages/trpc/models/bookmarks.ts | 406 | ||||
| -rw-r--r-- | packages/trpc/models/feeds.ts | 17 | ||||
| -rw-r--r-- | packages/trpc/models/importSessions.ts | 174 | ||||
| -rw-r--r-- | packages/trpc/models/listInvitations.ts | 1 | ||||
| -rw-r--r-- | packages/trpc/models/lists.ts | 31 | ||||
| -rw-r--r-- | packages/trpc/models/tags.ts | 4 | ||||
| -rw-r--r-- | packages/trpc/models/users.ts | 481 | ||||
| -rw-r--r-- | packages/trpc/models/webhooks.ts | 17 |
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({ |
