Skip to main content
Glama

Karakeep MCP server

by karakeep-app
users.ts20.5 kB
import { randomBytes } from "crypto"; import { TRPCError } from "@trpc/server"; import { and, count, desc, eq, gte, sql } from "drizzle-orm"; import invariant from "tiny-invariant"; import { z } from "zod"; import { SqliteError } from "@karakeep/db"; import { assets, bookmarkLinks, bookmarkLists, bookmarks, bookmarkTags, highlights, passwordResetTokens, tagsOnBookmarks, users, verificationTokens, } from "@karakeep/db/schema"; import { deleteUserAssets } from "@karakeep/shared/assetdb"; import serverConfig from "@karakeep/shared/config"; import { zResetPasswordSchema, zSignUpSchema, zUpdateUserSettingsSchema, zUserSettingsSchema, zUserStatsResponseSchema, zWhoAmIResponseSchema, } from "@karakeep/shared/types/users"; import { AuthedContext, Context } from ".."; import { generatePasswordSalt, hashPassword, validatePassword } from "../auth"; import { sendPasswordResetEmail, sendVerificationEmail } from "../email"; import { PrivacyAware } from "./privacy"; export class User implements PrivacyAware { constructor( protected ctx: AuthedContext, public user: typeof users.$inferSelect, ) {} static async fromId_DANGEROUS(ctx: AuthedContext, id: string): Promise<User> { const user = await ctx.db.query.users.findFirst({ where: eq(users.id, id), }); if (!user) { throw new TRPCError({ code: "NOT_FOUND", message: "User not found", }); } return new User(ctx, user); } static async fromCtx(ctx: AuthedContext): Promise<User> { return this.fromId_DANGEROUS(ctx, ctx.user.id); } static async create( ctx: Context, input: z.infer<typeof zSignUpSchema>, role?: "user" | "admin", ) { const salt = generatePasswordSalt(); const user = await User.createRaw(ctx.db, { name: input.name, email: input.email, password: await hashPassword(input.password, salt), salt, role, }); if (serverConfig.auth.emailVerificationRequired) { const token = await User.genEmailVerificationToken(ctx.db, input.email); try { await sendVerificationEmail(input.email, input.name, token); } catch (error) { console.error("Failed to send verification email:", error); } } return user; } static async createRaw( db: Context["db"], input: { name: string; email: string; password?: string; salt?: string; role?: "user" | "admin"; emailVerified?: Date | null; }, ) { return await db.transaction(async (trx) => { let userRole = input.role; if (!userRole) { const [{ count: userCount }] = await trx .select({ count: count() }) .from(users); userRole = userCount === 0 ? "admin" : "user"; } try { const [result] = await trx .insert(users) .values({ name: input.name, email: input.email, password: input.password, salt: input.salt, role: userRole, emailVerified: input.emailVerified, bookmarkQuota: serverConfig.quotas.free.bookmarkLimit, storageQuota: serverConfig.quotas.free.assetSizeBytes, }) .returning(); return result; } catch (e) { if (e instanceof SqliteError) { if (e.code === "SQLITE_CONSTRAINT_UNIQUE") { throw new TRPCError({ code: "BAD_REQUEST", message: "Email is already taken", }); } } throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Something went wrong", }); } }); } static async getAll(ctx: AuthedContext): Promise<User[]> { const dbUsers = await ctx.db.select().from(users); return dbUsers.map((u) => new User(ctx, u)); } static async genEmailVerificationToken( db: Context["db"], email: string, ): Promise<string> { const token = randomBytes(10).toString("hex"); const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours await db.insert(verificationTokens).values({ identifier: email, token, expires, }); return token; } static async verifyEmailToken( db: Context["db"], email: string, token: string, ): Promise<boolean> { const verificationToken = await db.query.verificationTokens.findFirst({ where: (vt, { and, eq }) => and(eq(vt.identifier, email), eq(vt.token, token)), }); if (!verificationToken) { return false; } if (verificationToken.expires < new Date()) { await db .delete(verificationTokens) .where( and( eq(verificationTokens.identifier, email), eq(verificationTokens.token, token), ), ); return false; } await db .delete(verificationTokens) .where( and( eq(verificationTokens.identifier, email), eq(verificationTokens.token, token), ), ); return true; } static async verifyEmail( ctx: Context, email: string, token: string, ): Promise<void> { const isValid = await User.verifyEmailToken(ctx.db, email, token); if (!isValid) { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid or expired verification token", }); } const result = await ctx.db .update(users) .set({ emailVerified: new Date() }) .where(eq(users.email, email)); if (result.changes === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "User not found", }); } } static async resendVerificationEmail( ctx: Context, email: string, ): Promise<void> { if ( !serverConfig.auth.emailVerificationRequired || !serverConfig.email.smtp ) { throw new TRPCError({ code: "BAD_REQUEST", message: "Email verification is not enabled", }); } const user = await ctx.db.query.users.findFirst({ where: eq(users.email, email), }); if (!user) { return; // Don't reveal if user exists or not for security } if (user.emailVerified) { throw new TRPCError({ code: "BAD_REQUEST", message: "Email is already verified", }); } const token = await User.genEmailVerificationToken(ctx.db, email); try { await sendVerificationEmail(email, user.name, token); } catch (error) { console.error("Failed to send verification email:", error); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to send verification email", }); } } static async forgotPassword(ctx: Context, email: string): Promise<void> { if (!serverConfig.email.smtp) { throw new TRPCError({ code: "BAD_REQUEST", message: "Email service is not configured", }); } const user = await ctx.db.query.users.findFirst({ where: eq(users.email, email), }); if (!user || !user.password) { return; // Don't reveal if user exists or not for security } try { const token = randomBytes(32).toString("hex"); const expires = new Date(Date.now() + 60 * 60 * 1000); // 1 hour await ctx.db.insert(passwordResetTokens).values({ userId: user.id, token, expires, }); await sendPasswordResetEmail(email, user.name, token); } catch (error) { console.error("Failed to send password reset email:", error); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to send password reset email", }); } } static async resetPassword( ctx: Context, input: z.infer<typeof zResetPasswordSchema>, ): Promise<void> { const resetToken = await ctx.db.query.passwordResetTokens.findFirst({ where: eq(passwordResetTokens.token, input.token), with: { user: { columns: { id: true, }, }, }, }); if (!resetToken) { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid or expired reset token", }); } if (resetToken.expires < new Date()) { await ctx.db .delete(passwordResetTokens) .where(eq(passwordResetTokens.token, input.token)); throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid or expired reset token", }); } if (!resetToken.user) { throw new TRPCError({ code: "NOT_FOUND", message: "User not found", }); } const newSalt = generatePasswordSalt(); const hashedPassword = await hashPassword(input.newPassword, newSalt); await ctx.db .update(users) .set({ password: hashedPassword, salt: newSalt, }) .where(eq(users.id, resetToken.user.id)); await ctx.db .delete(passwordResetTokens) .where(eq(passwordResetTokens.token, input.token)); } ensureCanAccess(ctx: AuthedContext): void { if (this.user.id !== ctx.user.id) { throw new TRPCError({ code: "FORBIDDEN", message: "User is not allowed to access resource", }); } } private static async deleteInternal(db: Context["db"], userId: string) { const res = await db.delete(users).where(eq(users.id, userId)); if (res.changes === 0) { throw new TRPCError({ code: "NOT_FOUND" }); } await deleteUserAssets({ userId: userId }); } static async deleteAsAdmin( adminCtx: AuthedContext, userId: string, ): Promise<void> { invariant(adminCtx.user.role === "admin", "Only admins can delete users"); await this.deleteInternal(adminCtx.db, userId); } async deleteAccount(password?: string): Promise<void> { invariant(this.ctx.user.email, "A user always has an email specified"); if (this.user.password) { if (!password) { throw new TRPCError({ code: "BAD_REQUEST", message: "Password is required for local accounts", }); } try { await validatePassword(this.ctx.user.email, password, this.ctx.db); } catch { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid password", }); } } await User.deleteInternal(this.ctx.db, this.user.id); } async changePassword( currentPassword: string, newPassword: string, ): Promise<void> { invariant(this.ctx.user.email, "A user always has an email specified"); try { const user = await validatePassword( this.ctx.user.email, currentPassword, this.ctx.db, ); invariant(user.id === this.ctx.user.id); } catch { throw new TRPCError({ code: "UNAUTHORIZED" }); } const newSalt = generatePasswordSalt(); await this.ctx.db .update(users) .set({ password: await hashPassword(newPassword, newSalt), salt: newSalt, }) .where(eq(users.id, this.user.id)); } async getSettings(): Promise<z.infer<typeof zUserSettingsSchema>> { const settings = await this.ctx.db.query.users.findFirst({ where: eq(users.id, this.user.id), columns: { bookmarkClickAction: true, archiveDisplayBehaviour: true, timezone: true, }, }); if (!settings) { throw new TRPCError({ code: "NOT_FOUND", message: "User settings not found", }); } return { bookmarkClickAction: settings.bookmarkClickAction, archiveDisplayBehaviour: settings.archiveDisplayBehaviour, timezone: settings.timezone || "UTC", }; } async updateSettings( input: z.infer<typeof zUpdateUserSettingsSchema>, ): Promise<void> { if (Object.keys(input).length === 0) { throw new TRPCError({ code: "BAD_REQUEST", message: "No settings provided", }); } await this.ctx.db .update(users) .set({ bookmarkClickAction: input.bookmarkClickAction, archiveDisplayBehaviour: input.archiveDisplayBehaviour, timezone: input.timezone, }) .where(eq(users.id, this.user.id)); } async getStats(): Promise<z.infer<typeof zUserStatsResponseSchema>> { const userObj = await this.ctx.db.query.users.findFirst({ where: eq(users.id, this.user.id), columns: { timezone: true, }, }); const userTimezone = userObj?.timezone || "UTC"; const now = new Date(); const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const yearAgo = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000); const [ [{ numBookmarks }], [{ numFavorites }], [{ numArchived }], [{ numTags }], [{ numLists }], [{ numHighlights }], bookmarksByType, topDomains, [{ totalAssetSize }], assetsByType, [{ thisWeek }], [{ thisMonth }], [{ thisYear }], bookmarkTimestamps, tagUsage, ] = await Promise.all([ // Basic counts this.ctx.db .select({ numBookmarks: count() }) .from(bookmarks) .where(eq(bookmarks.userId, this.user.id)), this.ctx.db .select({ numFavorites: count() }) .from(bookmarks) .where( and( eq(bookmarks.userId, this.user.id), eq(bookmarks.favourited, true), ), ), this.ctx.db .select({ numArchived: count() }) .from(bookmarks) .where( and(eq(bookmarks.userId, this.user.id), eq(bookmarks.archived, true)), ), this.ctx.db .select({ numTags: count() }) .from(bookmarkTags) .where(eq(bookmarkTags.userId, this.user.id)), this.ctx.db .select({ numLists: count() }) .from(bookmarkLists) .where(eq(bookmarkLists.userId, this.user.id)), this.ctx.db .select({ numHighlights: count() }) .from(highlights) .where(eq(highlights.userId, this.user.id)), // Bookmarks by type this.ctx.db .select({ type: bookmarks.type, count: count(), }) .from(bookmarks) .where(eq(bookmarks.userId, this.user.id)) .groupBy(bookmarks.type), // Top 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(eq(bookmarks.userId, this.user.id)) .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(10), // Total asset size this.ctx.db .select({ totalAssetSize: sql<number>`COALESCE(SUM(${assets.size}), 0)`, }) .from(assets) .where(eq(assets.userId, this.user.id)), // Assets by type this.ctx.db .select({ type: assets.assetType, count: count(), totalSize: sql<number>`COALESCE(SUM(${assets.size}), 0)`, }) .from(assets) .where(eq(assets.userId, this.user.id)) .groupBy(assets.assetType), // Activity stats this.ctx.db .select({ thisWeek: count() }) .from(bookmarks) .where( and( eq(bookmarks.userId, this.user.id), gte(bookmarks.createdAt, weekAgo), ), ), this.ctx.db .select({ thisMonth: count() }) .from(bookmarks) .where( and( eq(bookmarks.userId, this.user.id), gte(bookmarks.createdAt, monthAgo), ), ), this.ctx.db .select({ thisYear: count() }) .from(bookmarks) .where( and( eq(bookmarks.userId, this.user.id), gte(bookmarks.createdAt, yearAgo), ), ), // Get all bookmark timestamps for timezone conversion this.ctx.db .select({ createdAt: bookmarks.createdAt, }) .from(bookmarks) .where(eq(bookmarks.userId, this.user.id)), // Tag usage this.ctx.db .select({ name: bookmarkTags.name, count: count(), }) .from(bookmarkTags) .innerJoin(tagsOnBookmarks, eq(tagsOnBookmarks.tagId, bookmarkTags.id)) .where(eq(bookmarkTags.userId, this.user.id)) .groupBy(bookmarkTags.name) .orderBy(desc(count())) .limit(10), ]); // 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 const hourCounts = Array.from({ length: 24 }, () => 0); const dayCounts = Array.from({ length: 7 }, () => 0); 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(); hourCounts[hour]++; dayCounts[day]++; } }); const hourlyActivity = Array.from({ length: 24 }, (_, i) => ({ hour: i, count: hourCounts[i], })); const dailyActivity = Array.from({ length: 7 }, (_, i) => ({ day: i, count: dayCounts[i], })); return { numBookmarks, numFavorites, numArchived, numTags, numLists, numHighlights, bookmarksByType: bookmarkTypeMap, topDomains: topDomains.filter((d) => d.domain && d.domain.length > 0), totalAssetSize: totalAssetSize || 0, assetsByType, bookmarkingActivity: { thisWeek: thisWeek || 0, thisMonth: thisMonth || 0, thisYear: thisYear || 0, byHour: hourlyActivity, byDayOfWeek: dailyActivity, }, tagUsage, }; } asWhoAmI(): z.infer<typeof zWhoAmIResponseSchema> { return { id: this.user.id, name: this.user.name, email: this.user.email, localUser: this.user.password !== null, }; } asPublicUser() { const { password, salt: _salt, ...rest } = this.user; return { ...rest, localUser: password !== null, }; } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/karakeep-app/karakeep'

If you have feedback or need assistance with the MCP directory API, please join our Discord server