diff options
| author | MohamedBassem <me@mbassem.com> | 2024-03-05 13:11:06 +0000 |
|---|---|---|
| committer | MohamedBassem <me@mbassem.com> | 2024-03-05 13:11:06 +0000 |
| commit | 8a46ecb7373d6c5e7300861169ea51a7917cd2b4 (patch) | |
| tree | 4ad318c3b5fc8b7a74cba6d0e37b6ade24db829a /packages/web/server | |
| parent | 224aa38d5976523f213e2860b6addc7630d472ba (diff) | |
| download | karakeep-8a46ecb7373d6c5e7300861169ea51a7917cd2b4.tar.zst | |
refactor: Extract trpc logic into its package
Diffstat (limited to 'packages/web/server')
| -rw-r--r-- | packages/web/server/api/client.ts | 4 | ||||
| -rw-r--r-- | packages/web/server/api/routers/_app.ts | 15 | ||||
| -rw-r--r-- | packages/web/server/api/routers/admin.ts | 77 | ||||
| -rw-r--r-- | packages/web/server/api/routers/apiKeys.ts | 61 | ||||
| -rw-r--r-- | packages/web/server/api/routers/bookmarks.test.ts | 200 | ||||
| -rw-r--r-- | packages/web/server/api/routers/bookmarks.ts | 454 | ||||
| -rw-r--r-- | packages/web/server/api/routers/lists.ts | 173 | ||||
| -rw-r--r-- | packages/web/server/api/routers/users.test.ts | 99 | ||||
| -rw-r--r-- | packages/web/server/api/routers/users.ts | 93 | ||||
| -rw-r--r-- | packages/web/server/api/trpc.ts | 53 | ||||
| -rw-r--r-- | packages/web/server/auth.ts | 100 |
11 files changed, 3 insertions, 1326 deletions
diff --git a/packages/web/server/api/client.ts b/packages/web/server/api/client.ts index 130f4f87..88ea7a0e 100644 --- a/packages/web/server/api/client.ts +++ b/packages/web/server/api/client.ts @@ -1,6 +1,6 @@ -import { appRouter } from "./routers/_app"; +import { appRouter } from "@hoarder/trpc/routers/_app"; import { getServerAuthSession } from "@/server/auth"; -import { Context, createCallerFactory } from "./trpc"; +import { Context, createCallerFactory } from "@hoarder/trpc"; import { db } from "@hoarder/db"; export const createContext = async (database?: typeof db): Promise<Context> => { diff --git a/packages/web/server/api/routers/_app.ts b/packages/web/server/api/routers/_app.ts deleted file mode 100644 index 43ab6f5d..00000000 --- a/packages/web/server/api/routers/_app.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { router } from "../trpc"; -import { adminAppRouter } from "./admin"; -import { apiKeysAppRouter } from "./apiKeys"; -import { bookmarksAppRouter } from "./bookmarks"; -import { listsAppRouter } from "./lists"; -import { usersAppRouter } from "./users"; -export const appRouter = router({ - bookmarks: bookmarksAppRouter, - apiKeys: apiKeysAppRouter, - users: usersAppRouter, - lists: listsAppRouter, - admin: adminAppRouter, -}); -// export type definition of API -export type AppRouter = typeof appRouter; diff --git a/packages/web/server/api/routers/admin.ts b/packages/web/server/api/routers/admin.ts deleted file mode 100644 index c3f6235a..00000000 --- a/packages/web/server/api/routers/admin.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { adminProcedure, router } from "../trpc"; -import { z } from "zod"; -import { count } from "drizzle-orm"; -import { bookmarks, users } from "@hoarder/db/schema"; -import { - LinkCrawlerQueue, - OpenAIQueue, - SearchIndexingQueue, -} from "@hoarder/shared/queues"; - -export const adminAppRouter = router({ - stats: adminProcedure - .output( - z.object({ - numUsers: z.number(), - numBookmarks: z.number(), - pendingCrawls: z.number(), - pendingIndexing: z.number(), - pendingOpenai: z.number(), - }), - ) - .query(async ({ ctx }) => { - const [ - [{ value: numUsers }], - [{ value: numBookmarks }], - pendingCrawls, - pendingIndexing, - pendingOpenai, - ] = await Promise.all([ - ctx.db.select({ value: count() }).from(users), - ctx.db.select({ value: count() }).from(bookmarks), - LinkCrawlerQueue.getWaitingCount(), - SearchIndexingQueue.getWaitingCount(), - OpenAIQueue.getWaitingCount(), - ]); - - return { - numUsers, - numBookmarks, - pendingCrawls, - pendingIndexing, - pendingOpenai, - }; - }), - recrawlAllLinks: adminProcedure.mutation(async ({ ctx }) => { - const bookmarkIds = await ctx.db.query.bookmarkLinks.findMany({ - columns: { - id: true, - }, - }); - - await Promise.all( - bookmarkIds.map((b) => - LinkCrawlerQueue.add("crawl", { - bookmarkId: b.id, - }), - ), - ); - }), - - reindexAllBookmarks: adminProcedure.mutation(async ({ ctx }) => { - const bookmarkIds = await ctx.db.query.bookmarks.findMany({ - columns: { - id: true, - }, - }); - - await Promise.all( - bookmarkIds.map((b) => - SearchIndexingQueue.add("search_indexing", { - bookmarkId: b.id, - type: "index", - }), - ), - ); - }), -}); diff --git a/packages/web/server/api/routers/apiKeys.ts b/packages/web/server/api/routers/apiKeys.ts deleted file mode 100644 index 9eb36974..00000000 --- a/packages/web/server/api/routers/apiKeys.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { generateApiKey } from "@/server/auth"; -import { authedProcedure, router } from "../trpc"; -import { z } from "zod"; -import { apiKeys } from "@hoarder/db/schema"; -import { eq, and } from "drizzle-orm"; - -export const apiKeysAppRouter = router({ - create: authedProcedure - .input( - z.object({ - name: z.string(), - }), - ) - .output( - z.object({ - id: z.string(), - name: z.string(), - key: z.string(), - createdAt: z.date(), - }), - ) - .mutation(async ({ input, ctx }) => { - return await generateApiKey(input.name, ctx.user.id); - }), - revoke: authedProcedure - .input( - z.object({ - id: z.string(), - }), - ) - .mutation(async ({ input, ctx }) => { - await ctx.db - .delete(apiKeys) - .where(and(eq(apiKeys.id, input.id), eq(apiKeys.userId, ctx.user.id))); - }), - list: authedProcedure - .output( - z.object({ - keys: z.array( - z.object({ - id: z.string(), - name: z.string(), - createdAt: z.date(), - keyId: z.string(), - }), - ), - }), - ) - .query(async ({ ctx }) => { - const resp = await ctx.db.query.apiKeys.findMany({ - where: eq(apiKeys.userId, ctx.user.id), - columns: { - id: true, - name: true, - createdAt: true, - keyId: true, - }, - }); - return { keys: resp }; - }), -}); diff --git a/packages/web/server/api/routers/bookmarks.test.ts b/packages/web/server/api/routers/bookmarks.test.ts deleted file mode 100644 index 626a7250..00000000 --- a/packages/web/server/api/routers/bookmarks.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { CustomTestContext, defaultBeforeEach } from "@/lib/testUtils"; -import { expect, describe, test, beforeEach, assert } from "vitest"; - -beforeEach<CustomTestContext>(defaultBeforeEach(true)); - -describe("Bookmark Routes", () => { - test<CustomTestContext>("create bookmark", async ({ apiCallers }) => { - const api = apiCallers[0].bookmarks; - const bookmark = await api.createBookmark({ - url: "https://google.com", - type: "link", - }); - - const res = await api.getBookmark({ bookmarkId: bookmark.id }); - assert(res.content.type == "link"); - expect(res.content.url).toEqual("https://google.com"); - expect(res.favourited).toEqual(false); - expect(res.archived).toEqual(false); - expect(res.content.type).toEqual("link"); - }); - - test<CustomTestContext>("delete bookmark", async ({ apiCallers }) => { - const api = apiCallers[0].bookmarks; - - // Create the bookmark - const bookmark = await api.createBookmark({ - url: "https://google.com", - type: "link", - }); - - // It should exist - await api.getBookmark({ bookmarkId: bookmark.id }); - - // Delete it - await api.deleteBookmark({ bookmarkId: bookmark.id }); - - // It shouldn't be there anymore - await expect(() => - api.getBookmark({ bookmarkId: bookmark.id }), - ).rejects.toThrow(/Bookmark not found/); - }); - - test<CustomTestContext>("update bookmark", async ({ apiCallers }) => { - const api = apiCallers[0].bookmarks; - - // Create the bookmark - const bookmark = await api.createBookmark({ - url: "https://google.com", - type: "link", - }); - - await api.updateBookmark({ - bookmarkId: bookmark.id, - archived: true, - favourited: true, - }); - - const res = await api.getBookmark({ bookmarkId: bookmark.id }); - expect(res.archived).toBeTruthy(); - expect(res.favourited).toBeTruthy(); - }); - - test<CustomTestContext>("list bookmarks", async ({ apiCallers }) => { - const api = apiCallers[0].bookmarks; - const emptyBookmarks = await api.getBookmarks({}); - expect(emptyBookmarks.bookmarks.length).toEqual(0); - - const bookmark1 = await api.createBookmark({ - url: "https://google.com", - type: "link", - }); - - const bookmark2 = await api.createBookmark({ - url: "https://google2.com", - type: "link", - }); - - { - const bookmarks = await api.getBookmarks({}); - expect(bookmarks.bookmarks.length).toEqual(2); - } - - // Archive and favourite bookmark1 - await api.updateBookmark({ - bookmarkId: bookmark1.id, - archived: true, - favourited: true, - }); - - { - const bookmarks = await api.getBookmarks({ archived: false }); - expect(bookmarks.bookmarks.length).toEqual(1); - expect(bookmarks.bookmarks[0].id).toEqual(bookmark2.id); - } - - { - const bookmarks = await api.getBookmarks({ favourited: true }); - expect(bookmarks.bookmarks.length).toEqual(1); - expect(bookmarks.bookmarks[0].id).toEqual(bookmark1.id); - } - - { - const bookmarks = await api.getBookmarks({ archived: true }); - expect(bookmarks.bookmarks.length).toEqual(1); - expect(bookmarks.bookmarks[0].id).toEqual(bookmark1.id); - } - - { - const bookmarks = await api.getBookmarks({ ids: [bookmark1.id] }); - expect(bookmarks.bookmarks.length).toEqual(1); - expect(bookmarks.bookmarks[0].id).toEqual(bookmark1.id); - } - }); - - test<CustomTestContext>("update tags", async ({ apiCallers }) => { - const api = apiCallers[0].bookmarks; - let bookmark = await api.createBookmark({ - url: "https://google.com", - type: "link", - }); - - await api.updateTags({ - bookmarkId: bookmark.id, - attach: [{ tag: "tag1" }, { tag: "tag2" }], - detach: [], - }); - - bookmark = await api.getBookmark({ bookmarkId: bookmark.id }); - expect(bookmark.tags.map((t) => t.name).sort()).toEqual(["tag1", "tag2"]); - - const tag1Id = bookmark.tags.filter((t) => t.name == "tag1")[0].id; - - await api.updateTags({ - bookmarkId: bookmark.id, - attach: [{ tag: "tag3" }], - detach: [{ tagId: tag1Id }], - }); - - bookmark = await api.getBookmark({ bookmarkId: bookmark.id }); - expect(bookmark.tags.map((t) => t.name).sort()).toEqual(["tag2", "tag3"]); - }); - - test<CustomTestContext>("update bookmark text", async ({ apiCallers }) => { - const api = apiCallers[0].bookmarks; - let bookmark = await api.createBookmark({ - text: "HELLO WORLD", - type: "text", - }); - - await api.updateBookmarkText({ - bookmarkId: bookmark.id, - text: "WORLD HELLO", - }); - - bookmark = await api.getBookmark({ bookmarkId: bookmark.id }); - assert(bookmark.content.type == "text"); - expect(bookmark.content.text).toEqual("WORLD HELLO"); - }); - - test<CustomTestContext>("privacy", async ({ apiCallers }) => { - const user1Bookmark = await apiCallers[0].bookmarks.createBookmark({ - type: "link", - url: "https://google.com", - }); - const user2Bookmark = await apiCallers[1].bookmarks.createBookmark({ - type: "link", - url: "https://google.com", - }); - - // All interactions with the wrong user should fail - await expect(() => - apiCallers[0].bookmarks.deleteBookmark({ bookmarkId: user2Bookmark.id }), - ).rejects.toThrow(/User is not allowed to access resource/); - await expect(() => - apiCallers[0].bookmarks.getBookmark({ bookmarkId: user2Bookmark.id }), - ).rejects.toThrow(/User is not allowed to access resource/); - await expect(() => - apiCallers[0].bookmarks.updateBookmark({ bookmarkId: user2Bookmark.id }), - ).rejects.toThrow(/User is not allowed to access resource/); - await expect(() => - apiCallers[0].bookmarks.updateTags({ - bookmarkId: user2Bookmark.id, - attach: [], - detach: [], - }), - ).rejects.toThrow(/User is not allowed to access resource/); - - // Get bookmarks should only show the correct one - expect( - (await apiCallers[0].bookmarks.getBookmarks({})).bookmarks.map( - (b) => b.id, - ), - ).toEqual([user1Bookmark.id]); - expect( - (await apiCallers[1].bookmarks.getBookmarks({})).bookmarks.map( - (b) => b.id, - ), - ).toEqual([user2Bookmark.id]); - }); -}); diff --git a/packages/web/server/api/routers/bookmarks.ts b/packages/web/server/api/routers/bookmarks.ts deleted file mode 100644 index 73818508..00000000 --- a/packages/web/server/api/routers/bookmarks.ts +++ /dev/null @@ -1,454 +0,0 @@ -import { z } from "zod"; -import { Context, authedProcedure, router } from "../trpc"; -import { getSearchIdxClient } from "@hoarder/shared/search"; -import { - ZBookmark, - ZBookmarkContent, - zBareBookmarkSchema, - zBookmarkSchema, - zGetBookmarksRequestSchema, - zGetBookmarksResponseSchema, - zNewBookmarkRequestSchema, - zUpdateBookmarksRequestSchema, -} from "@/lib/types/api/bookmarks"; -import { - bookmarkLinks, - bookmarkTags, - bookmarkTexts, - bookmarks, - tagsOnBookmarks, -} from "@hoarder/db/schema"; -import { - LinkCrawlerQueue, - OpenAIQueue, - SearchIndexingQueue, -} from "@hoarder/shared/queues"; -import { TRPCError, experimental_trpcMiddleware } from "@trpc/server"; -import { and, desc, eq, inArray } from "drizzle-orm"; -import { ZBookmarkTags } from "@/lib/types/api/tags"; - -import { db as DONT_USE_db } from "@hoarder/db"; - -const ensureBookmarkOwnership = experimental_trpcMiddleware<{ - ctx: Context; - input: { bookmarkId: string }; -}>().create(async (opts) => { - const bookmark = await opts.ctx.db.query.bookmarks.findFirst({ - where: eq(bookmarks.id, opts.input.bookmarkId), - columns: { - userId: true, - }, - }); - if (!opts.ctx.user) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "User is not authorized", - }); - } - if (!bookmark) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Bookmark not found", - }); - } - if (bookmark.userId != opts.ctx.user.id) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "User is not allowed to access resource", - }); - } - - return opts.next(); -}); - -async function dummyDrizzleReturnType() { - const x = await DONT_USE_db.query.bookmarks.findFirst({ - with: { - tagsOnBookmarks: { - with: { - tag: true, - }, - }, - link: true, - text: true, - }, - }); - if (!x) { - throw new Error(); - } - return x; -} - -function toZodSchema( - bookmark: Awaited<ReturnType<typeof dummyDrizzleReturnType>>, -): ZBookmark { - const { tagsOnBookmarks, link, text, ...rest } = bookmark; - - let content: ZBookmarkContent; - if (link) { - content = { type: "link", ...link }; - } else if (text) { - content = { type: "text", text: text.text || "" }; - } else { - throw new Error("Unknown content type"); - } - - return { - tags: tagsOnBookmarks.map((t) => ({ - attachedBy: t.attachedBy, - ...t.tag, - })), - content, - ...rest, - }; -} - -export const bookmarksAppRouter = router({ - createBookmark: authedProcedure - .input(zNewBookmarkRequestSchema) - .output(zBookmarkSchema) - .mutation(async ({ input, ctx }) => { - const bookmark = await ctx.db.transaction( - async (tx): Promise<ZBookmark> => { - const bookmark = ( - await tx - .insert(bookmarks) - .values({ - userId: ctx.user.id, - }) - .returning() - )[0]; - - let content: ZBookmarkContent; - - switch (input.type) { - case "link": { - const link = ( - await tx - .insert(bookmarkLinks) - .values({ - id: bookmark.id, - url: input.url, - }) - .returning() - )[0]; - content = { - type: "link", - ...link, - }; - break; - } - case "text": { - const text = ( - await tx - .insert(bookmarkTexts) - .values({ id: bookmark.id, text: input.text }) - .returning() - )[0]; - content = { - type: "text", - text: text.text || "", - }; - break; - } - } - - return { - tags: [] as ZBookmarkTags[], - content, - ...bookmark, - }; - }, - ); - - // Enqueue crawling request - switch (bookmark.content.type) { - case "link": { - // The crawling job triggers openai when it's done - await LinkCrawlerQueue.add("crawl", { - bookmarkId: bookmark.id, - }); - break; - } - case "text": { - await OpenAIQueue.add("openai", { - bookmarkId: bookmark.id, - }); - break; - } - } - SearchIndexingQueue.add("search_indexing", { - bookmarkId: bookmark.id, - type: "index", - }); - return bookmark; - }), - - updateBookmark: authedProcedure - .input(zUpdateBookmarksRequestSchema) - .output(zBareBookmarkSchema) - .use(ensureBookmarkOwnership) - .mutation(async ({ input, ctx }) => { - const res = await ctx.db - .update(bookmarks) - .set({ - archived: input.archived, - favourited: input.favourited, - }) - .where( - and( - eq(bookmarks.userId, ctx.user.id), - eq(bookmarks.id, input.bookmarkId), - ), - ) - .returning(); - if (res.length == 0) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Bookmark not found", - }); - } - return res[0]; - }), - - updateBookmarkText: authedProcedure - .input( - z.object({ - bookmarkId: z.string(), - text: z.string().max(2000), - }), - ) - .use(ensureBookmarkOwnership) - .mutation(async ({ input, ctx }) => { - const res = await ctx.db - .update(bookmarkTexts) - .set({ - text: input.text, - }) - .where(and(eq(bookmarkTexts.id, input.bookmarkId))) - .returning(); - if (res.length == 0) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Bookmark not found", - }); - } - SearchIndexingQueue.add("search_indexing", { - bookmarkId: input.bookmarkId, - type: "index", - }); - }), - - deleteBookmark: authedProcedure - .input(z.object({ bookmarkId: z.string() })) - .use(ensureBookmarkOwnership) - .mutation(async ({ input, ctx }) => { - await ctx.db - .delete(bookmarks) - .where( - and( - eq(bookmarks.userId, ctx.user.id), - eq(bookmarks.id, input.bookmarkId), - ), - ); - SearchIndexingQueue.add("search_indexing", { - bookmarkId: input.bookmarkId, - type: "delete", - }); - }), - recrawlBookmark: authedProcedure - .input(z.object({ bookmarkId: z.string() })) - .use(ensureBookmarkOwnership) - .mutation(async ({ input }) => { - await LinkCrawlerQueue.add("crawl", { - bookmarkId: input.bookmarkId, - }); - }), - getBookmark: authedProcedure - .input( - z.object({ - bookmarkId: z.string(), - }), - ) - .output(zBookmarkSchema) - .use(ensureBookmarkOwnership) - .query(async ({ input, ctx }) => { - const bookmark = await ctx.db.query.bookmarks.findFirst({ - where: and( - eq(bookmarks.userId, ctx.user.id), - eq(bookmarks.id, input.bookmarkId), - ), - with: { - tagsOnBookmarks: { - with: { - tag: true, - }, - }, - link: true, - text: true, - }, - }); - if (!bookmark) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Bookmark not found", - }); - } - - return toZodSchema(bookmark); - }), - searchBookmarks: authedProcedure - .input( - z.object({ - text: z.string(), - }), - ) - .output(zGetBookmarksResponseSchema) - .query(async ({ input, ctx }) => { - const client = await getSearchIdxClient(); - if (!client) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Search functionality is not configured", - }); - } - const resp = await client.search(input.text, { - filter: [`userId = '${ctx.user.id}'`], - }); - - if (resp.hits.length == 0) { - return { bookmarks: [] }; - } - const results = await ctx.db.query.bookmarks.findMany({ - where: and( - eq(bookmarks.userId, ctx.user.id), - inArray( - bookmarks.id, - resp.hits.map((h) => h.id), - ), - ), - with: { - tagsOnBookmarks: { - with: { - tag: true, - }, - }, - link: true, - text: true, - }, - }); - - return { bookmarks: results.map(toZodSchema) }; - }), - getBookmarks: authedProcedure - .input(zGetBookmarksRequestSchema) - .output(zGetBookmarksResponseSchema) - .query(async ({ input, ctx }) => { - if (input.ids && input.ids.length == 0) { - return { bookmarks: [] }; - } - const results = await ctx.db.query.bookmarks.findMany({ - where: and( - eq(bookmarks.userId, ctx.user.id), - input.archived !== undefined - ? eq(bookmarks.archived, input.archived) - : undefined, - input.favourited !== undefined - ? eq(bookmarks.favourited, input.favourited) - : undefined, - input.ids ? inArray(bookmarks.id, input.ids) : undefined, - ), - orderBy: [desc(bookmarks.createdAt)], - with: { - tagsOnBookmarks: { - with: { - tag: true, - }, - }, - link: true, - text: true, - }, - }); - - return { bookmarks: results.map(toZodSchema) }; - }), - - updateTags: authedProcedure - .input( - z.object({ - bookmarkId: z.string(), - attach: z.array( - z.object({ - tagId: z.string().optional(), // If the tag already exists and we know its id - tag: z.string(), - }), - ), - // Detach by tag ids - detach: z.array(z.object({ tagId: z.string() })), - }), - ) - .use(ensureBookmarkOwnership) - .mutation(async ({ input, ctx }) => { - await ctx.db.transaction(async (tx) => { - // Detaches - if (input.detach.length > 0) { - await tx.delete(tagsOnBookmarks).where( - and( - eq(tagsOnBookmarks.bookmarkId, input.bookmarkId), - inArray( - tagsOnBookmarks.tagId, - input.detach.map((t) => t.tagId), - ), - ), - ); - } - - if (input.attach.length == 0) { - return; - } - - // New Tags - const toBeCreatedTags = input.attach - .filter((i) => i.tagId === undefined) - .map((i) => ({ - name: i.tag, - userId: ctx.user.id, - })); - - if (toBeCreatedTags.length > 0) { - await tx - .insert(bookmarkTags) - .values(toBeCreatedTags) - .onConflictDoNothing() - .returning(); - } - - const allIds = ( - await tx.query.bookmarkTags.findMany({ - where: and( - eq(bookmarkTags.userId, ctx.user.id), - inArray( - bookmarkTags.name, - input.attach.map((t) => t.tag), - ), - ), - columns: { - id: true, - }, - }) - ).map((t) => t.id); - - await tx - .insert(tagsOnBookmarks) - .values( - allIds.map((i) => ({ - tagId: i as string, - bookmarkId: input.bookmarkId, - attachedBy: "human" as const, - userId: ctx.user.id, - })), - ) - .onConflictDoNothing(); - }); - }), -}); diff --git a/packages/web/server/api/routers/lists.ts b/packages/web/server/api/routers/lists.ts deleted file mode 100644 index 7bf5eed5..00000000 --- a/packages/web/server/api/routers/lists.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Context, authedProcedure, router } from "../trpc"; -import { SqliteError } from "@hoarder/db"; -import { z } from "zod"; -import { TRPCError, experimental_trpcMiddleware } from "@trpc/server"; -import { bookmarkLists, bookmarksInLists } from "@hoarder/db/schema"; -import { and, eq } from "drizzle-orm"; -import { zBookmarkListSchema } from "@/lib/types/api/lists"; - -const ensureListOwnership = experimental_trpcMiddleware<{ - ctx: Context; - input: { listId: string }; -}>().create(async (opts) => { - const list = await opts.ctx.db.query.bookmarkLists.findFirst({ - where: eq(bookmarkLists.id, opts.input.listId), - columns: { - userId: true, - }, - }); - if (!opts.ctx.user) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "User is not authorized", - }); - } - if (!list) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "List not found", - }); - } - if (list.userId != opts.ctx.user.id) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "User is not allowed to access resource", - }); - } - - return opts.next(); -}); - -export const listsAppRouter = router({ - create: authedProcedure - .input( - z.object({ - name: z.string().min(1).max(20), - icon: z.string(), - }), - ) - .output(zBookmarkListSchema) - .mutation(async ({ input, ctx }) => { - try { - const result = await ctx.db - .insert(bookmarkLists) - .values({ - name: input.name, - icon: input.icon, - userId: ctx.user.id, - }) - .returning(); - return result[0]; - } catch (e) { - if (e instanceof SqliteError) { - if (e.code == "SQLITE_CONSTRAINT_UNIQUE") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "List already exists", - }); - } - } - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Something went wrong", - }); - } - }), - delete: authedProcedure - .input( - z.object({ - listId: z.string(), - }), - ) - .use(ensureListOwnership) - .mutation(async ({ input, ctx }) => { - const res = await ctx.db - .delete(bookmarkLists) - .where( - and( - eq(bookmarkLists.id, input.listId), - eq(bookmarkLists.userId, ctx.user.id), - ), - ); - if (res.changes == 0) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - }), - addToList: authedProcedure - .input( - z.object({ - listId: z.string(), - bookmarkId: z.string(), - }), - ) - .use(ensureListOwnership) - .mutation(async ({ input, ctx }) => { - try { - await ctx.db.insert(bookmarksInLists).values({ - listId: input.listId, - bookmarkId: input.bookmarkId, - }); - } catch (e) { - if (e instanceof SqliteError) { - if (e.code == "SQLITE_CONSTRAINT_PRIMARYKEY") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Bookmark already in the list", - }); - } - } - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Something went wrong", - }); - } - }), - get: authedProcedure - .input( - z.object({ - listId: z.string(), - }), - ) - .output( - zBookmarkListSchema.merge( - z.object({ - bookmarks: z.array(z.string()), - }), - ), - ) - .use(ensureListOwnership) - .query(async ({ input, ctx }) => { - const res = await ctx.db.query.bookmarkLists.findFirst({ - where: and( - eq(bookmarkLists.id, input.listId), - eq(bookmarkLists.userId, ctx.user.id), - ), - with: { - bookmarksInLists: true, - }, - }); - if (!res) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - return { - id: res.id, - name: res.name, - icon: res.icon, - bookmarks: res.bookmarksInLists.map((b) => b.bookmarkId), - }; - }), - list: authedProcedure - .output( - z.object({ - lists: z.array(zBookmarkListSchema), - }), - ) - .query(async ({ ctx }) => { - const lists = await ctx.db.query.bookmarkLists.findMany({ - where: and(eq(bookmarkLists.userId, ctx.user.id)), - }); - - return { lists }; - }), -}); diff --git a/packages/web/server/api/routers/users.test.ts b/packages/web/server/api/routers/users.test.ts deleted file mode 100644 index 1ee04f99..00000000 --- a/packages/web/server/api/routers/users.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { - CustomTestContext, - defaultBeforeEach, - getApiCaller, -} from "@/lib/testUtils"; -import { expect, describe, test, beforeEach, assert } from "vitest"; - -beforeEach<CustomTestContext>(defaultBeforeEach(false)); - -describe("User Routes", () => { - test<CustomTestContext>("create user", async ({ unauthedAPICaller }) => { - const user = await unauthedAPICaller.users.create({ - name: "Test User", - email: "test123@test.com", - password: "pass1234", - confirmPassword: "pass1234", - }); - - expect(user.name).toEqual("Test User"); - expect(user.email).toEqual("test123@test.com"); - }); - - test<CustomTestContext>("first user is admin", async ({ - unauthedAPICaller, - }) => { - const user1 = await unauthedAPICaller.users.create({ - name: "Test User", - email: "test123@test.com", - password: "pass1234", - confirmPassword: "pass1234", - }); - - const user2 = await unauthedAPICaller.users.create({ - name: "Test User", - email: "test124@test.com", - password: "pass1234", - confirmPassword: "pass1234", - }); - - expect(user1.role).toEqual("admin"); - expect(user2.role).toEqual("user"); - }); - - test<CustomTestContext>("unique emails", async ({ unauthedAPICaller }) => { - await unauthedAPICaller.users.create({ - name: "Test User", - email: "test123@test.com", - password: "pass1234", - confirmPassword: "pass1234", - }); - - await expect(() => - unauthedAPICaller.users.create({ - name: "Test User", - email: "test123@test.com", - password: "pass1234", - confirmPassword: "pass1234", - }), - ).rejects.toThrow(/Email is already taken/); - }); - - test<CustomTestContext>("privacy checks", async ({ - db, - unauthedAPICaller, - }) => { - const adminUser = await unauthedAPICaller.users.create({ - name: "Test User", - email: "test123@test.com", - password: "pass1234", - confirmPassword: "pass1234", - }); - const [user1, user2] = await Promise.all( - ["test1234@test.com", "test12345@test.com"].map((e) => - unauthedAPICaller.users.create({ - name: "Test User", - email: e, - password: "pass1234", - confirmPassword: "pass1234", - }), - ), - ); - - assert(adminUser.role == "admin"); - assert(user1.role == "user"); - assert(user2.role == "user"); - - const user2Caller = getApiCaller(db, user2.id); - - // A normal user can't delete other users - await expect(() => - user2Caller.users.delete({ - userId: user1.id, - }), - ).rejects.toThrow(/FORBIDDEN/); - - // A normal user can't list all users - await expect(() => user2Caller.users.list()).rejects.toThrow(/FORBIDDEN/); - }); -}); diff --git a/packages/web/server/api/routers/users.ts b/packages/web/server/api/routers/users.ts deleted file mode 100644 index 32d10860..00000000 --- a/packages/web/server/api/routers/users.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { zSignUpSchema } from "@/lib/types/api/users"; -import { adminProcedure, publicProcedure, router } from "../trpc"; -import { SqliteError } from "@hoarder/db"; -import { z } from "zod"; -import { hashPassword } from "@/server/auth"; -import { TRPCError } from "@trpc/server"; -import { users } from "@hoarder/db/schema"; -import { count, eq } from "drizzle-orm"; - -export const usersAppRouter = router({ - create: publicProcedure - .input(zSignUpSchema) - .output( - z.object({ - id: z.string(), - name: z.string(), - email: z.string(), - role: z.enum(["user", "admin"]).nullable(), - }), - ) - .mutation(async ({ input, ctx }) => { - // TODO: This is racy, but that's probably fine. - const [{ count: userCount }] = await ctx.db - .select({ count: count() }) - .from(users); - try { - const result = await ctx.db - .insert(users) - .values({ - name: input.name, - email: input.email, - password: await hashPassword(input.password), - role: userCount == 0 ? "admin" : "user", - }) - .returning({ - id: users.id, - name: users.name, - email: users.email, - role: users.role, - }); - return result[0]; - } 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", - }); - } - }), - list: adminProcedure - .output( - z.object({ - users: z.array( - z.object({ - id: z.string(), - name: z.string(), - email: z.string(), - role: z.enum(["user", "admin"]).nullable(), - }), - ), - }), - ) - .query(async ({ ctx }) => { - const users = await ctx.db.query.users.findMany({ - columns: { - id: true, - name: true, - email: true, - role: true, - }, - }); - return { users }; - }), - delete: adminProcedure - .input( - z.object({ - userId: z.string(), - }), - ) - .mutation(async ({ input, ctx }) => { - const res = await ctx.db.delete(users).where(eq(users.id, input.userId)); - if (res.changes == 0) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - }), -}); diff --git a/packages/web/server/api/trpc.ts b/packages/web/server/api/trpc.ts deleted file mode 100644 index 0ba09e94..00000000 --- a/packages/web/server/api/trpc.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { db } from "@hoarder/db"; -import serverConfig from "@hoarder/shared/config"; -import { TRPCError, initTRPC } from "@trpc/server"; -import { User } from "next-auth"; -import superjson from "superjson"; - -export type Context = { - user: User | null; - db: typeof db; -}; - -// Avoid exporting the entire t-object -// since it's not very descriptive. -// For instance, the use of a t variable -// is common in i18n libraries. -const t = initTRPC.context<Context>().create({ - transformer: superjson, -}); -export const createCallerFactory = t.createCallerFactory; -// Base router and procedure helpers -export const router = t.router; -export const procedure = t.procedure.use(function isDemoMode(opts) { - if (serverConfig.demoMode && opts.type == "mutation") { - throw new TRPCError({ - message: "Mutations are not allowed in demo mode", - code: "FORBIDDEN", - }); - } - return opts.next(); -}); -export const publicProcedure = procedure; - -export const authedProcedure = procedure.use(function isAuthed(opts) { - const user = opts.ctx.user; - - if (!user || !user.id) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - return opts.next({ - ctx: { - user, - }, - }); -}); - -export const adminProcedure = authedProcedure.use(function isAdmin(opts) { - const user = opts.ctx.user; - if (user.role != "admin") { - throw new TRPCError({ code: "FORBIDDEN" }); - } - return opts.next(opts); -}); diff --git a/packages/web/server/auth.ts b/packages/web/server/auth.ts index 1810c87d..950443b9 100644 --- a/packages/web/server/auth.ts +++ b/packages/web/server/auth.ts @@ -2,15 +2,13 @@ import NextAuth, { NextAuthOptions, getServerSession } from "next-auth"; import type { Adapter } from "next-auth/adapters"; import AuthentikProvider from "next-auth/providers/authentik"; import serverConfig from "@hoarder/shared/config"; +import { validatePassword } from "@hoarder/trpc/auth"; import { db } from "@hoarder/db"; import { DefaultSession } from "next-auth"; -import * as bcrypt from "bcrypt"; import CredentialsProvider from "next-auth/providers/credentials"; import { DrizzleAdapter } from "@auth/drizzle-adapter"; -import { randomBytes } from "crypto"; import { Provider } from "next-auth/providers/index"; -import { apiKeys } from "@hoarder/db/schema"; declare module "next-auth/jwt" { export interface JWT { @@ -96,99 +94,3 @@ export const authOptions: NextAuthOptions = { export const authHandler = NextAuth(authOptions); export const getServerAuthSession = () => getServerSession(authOptions); - -// API Keys - -const BCRYPT_SALT_ROUNDS = 10; -const API_KEY_PREFIX = "ak1"; - -export async function generateApiKey(name: string, userId: string) { - const id = randomBytes(10).toString("hex"); - const secret = randomBytes(10).toString("hex"); - const secretHash = await bcrypt.hash(secret, BCRYPT_SALT_ROUNDS); - - const plain = `${API_KEY_PREFIX}_${id}_${secret}`; - - const key = ( - await db - .insert(apiKeys) - .values({ - name: name, - userId: userId, - keyId: id, - keyHash: secretHash, - }) - .returning() - )[0]; - - return { - id: key.id, - name: key.name, - createdAt: key.createdAt, - key: plain, - }; -} - -function parseApiKey(plain: string) { - const parts = plain.split("_"); - if (parts.length != 3) { - throw new Error( - `Malformd API key. API keys should have 3 segments, found ${parts.length} instead.`, - ); - } - if (parts[0] !== API_KEY_PREFIX) { - throw new Error(`Malformd API key. Got unexpected key prefix.`); - } - return { - keyId: parts[1], - keySecret: parts[2], - }; -} - -export async function authenticateApiKey(key: string) { - const { keyId, keySecret } = parseApiKey(key); - const apiKey = await db.query.apiKeys.findFirst({ - where: (k, { eq }) => eq(k.keyId, keyId), - with: { - user: true, - }, - }); - - if (!apiKey) { - throw new Error("API key not found"); - } - - const hash = apiKey.keyHash; - - const validation = await bcrypt.compare(keySecret, hash); - if (!validation) { - throw new Error("Invalid API Key"); - } - - return apiKey.user; -} - -export async function hashPassword(password: string) { - return bcrypt.hash(password, BCRYPT_SALT_ROUNDS); -} - -export async function validatePassword(email: string, password: string) { - const user = await db.query.users.findFirst({ - where: (u, { eq }) => eq(u.email, email), - }); - - if (!user) { - throw new Error("User not found"); - } - - if (!user.password) { - throw new Error("This user doesn't have a password defined"); - } - - const validation = await bcrypt.compare(password, user.password); - if (!validation) { - throw new Error("Wrong password"); - } - - return user; -} |
