diff options
Diffstat (limited to 'packages/web')
32 files changed, 24 insertions, 1528 deletions
diff --git a/packages/web/app/api/trpc/[trpc]/route.ts b/packages/web/app/api/trpc/[trpc]/route.ts index 7d56cadc..b6753101 100644 --- a/packages/web/app/api/trpc/[trpc]/route.ts +++ b/packages/web/app/api/trpc/[trpc]/route.ts @@ -1,7 +1,7 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; -import { appRouter } from "@/server/api/routers/_app"; +import { appRouter } from "@hoarder/trpc/routers/_app"; import { createContext } from "@/server/api/client"; -import { authenticateApiKey } from "@/server/auth"; +import { authenticateApiKey } from "@hoarder/trpc/auth"; import { db } from "@hoarder/db"; const handler = (req: Request) => diff --git a/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx b/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx index 584e8708..4f08ebee 100644 --- a/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx +++ b/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx @@ -2,7 +2,7 @@ import { useToast } from "@/components/ui/use-toast"; import { api } from "@/lib/trpc"; -import { ZBookmark, ZBookmarkedLink } from "@/lib/types/api/bookmarks"; +import { ZBookmark, ZBookmarkedLink } from "@hoarder/trpc/types/bookmarks"; import { Button } from "@/components/ui/button"; import { DropdownMenu, diff --git a/packages/web/app/dashboard/bookmarks/components/BookmarkedTextEditor.tsx b/packages/web/app/dashboard/bookmarks/components/BookmarkedTextEditor.tsx index c449fae3..a5b58f1a 100644 --- a/packages/web/app/dashboard/bookmarks/components/BookmarkedTextEditor.tsx +++ b/packages/web/app/dashboard/bookmarks/components/BookmarkedTextEditor.tsx @@ -1,4 +1,4 @@ -import { ZBookmark } from "@/lib/types/api/bookmarks"; +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; import { Dialog, DialogClose, diff --git a/packages/web/app/dashboard/bookmarks/components/Bookmarks.tsx b/packages/web/app/dashboard/bookmarks/components/Bookmarks.tsx index 62b93dc8..1ad3670c 100644 --- a/packages/web/app/dashboard/bookmarks/components/Bookmarks.tsx +++ b/packages/web/app/dashboard/bookmarks/components/Bookmarks.tsx @@ -1,6 +1,6 @@ import { redirect } from "next/navigation"; import BookmarksGrid from "./BookmarksGrid"; -import { ZGetBookmarksRequest } from "@/lib/types/api/bookmarks"; +import { ZGetBookmarksRequest } from "@hoarder/trpc/types/bookmarks"; import { api } from "@/server/api/client"; import { getServerAuthSession } from "@/server/auth"; diff --git a/packages/web/app/dashboard/bookmarks/components/BookmarksGrid.tsx b/packages/web/app/dashboard/bookmarks/components/BookmarksGrid.tsx index 554d20a0..4d5b6b0a 100644 --- a/packages/web/app/dashboard/bookmarks/components/BookmarksGrid.tsx +++ b/packages/web/app/dashboard/bookmarks/components/BookmarksGrid.tsx @@ -1,7 +1,7 @@ "use client"; import LinkCard from "./LinkCard"; -import { ZBookmark, ZGetBookmarksRequest } from "@/lib/types/api/bookmarks"; +import { ZBookmark, ZGetBookmarksRequest } from "@hoarder/trpc/types/bookmarks"; import { api } from "@/lib/trpc"; import TextCard from "./TextCard"; import { Slot } from "@radix-ui/react-slot"; diff --git a/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx b/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx index 5af11aa3..76d3f1b8 100644 --- a/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx +++ b/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx @@ -8,7 +8,7 @@ import { ImageCardFooter, ImageCardTitle, } from "@/components/ui/imageCard"; -import { ZBookmark } from "@/lib/types/api/bookmarks"; +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; import Link from "next/link"; import BookmarkOptions from "./BookmarkOptions"; import { api } from "@/lib/trpc"; diff --git a/packages/web/app/dashboard/bookmarks/components/TagList.tsx b/packages/web/app/dashboard/bookmarks/components/TagList.tsx index 82d9f376..6c9d2d22 100644 --- a/packages/web/app/dashboard/bookmarks/components/TagList.tsx +++ b/packages/web/app/dashboard/bookmarks/components/TagList.tsx @@ -1,7 +1,7 @@ import { badgeVariants } from "@/components/ui/badge"; import Link from "next/link"; import { Skeleton } from "@/components/ui/skeleton"; -import { ZBookmark } from "@/lib/types/api/bookmarks"; +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; import { cn } from "@/lib/utils"; export default function TagList({ diff --git a/packages/web/app/dashboard/bookmarks/components/TagModal.tsx b/packages/web/app/dashboard/bookmarks/components/TagModal.tsx index 703c4221..8c09d00e 100644 --- a/packages/web/app/dashboard/bookmarks/components/TagModal.tsx +++ b/packages/web/app/dashboard/bookmarks/components/TagModal.tsx @@ -11,8 +11,8 @@ import { import { Input } from "@/components/ui/input"; import { toast } from "@/components/ui/use-toast"; import { api } from "@/lib/trpc"; -import { ZBookmark } from "@/lib/types/api/bookmarks"; -import { ZAttachedByEnum } from "@/lib/types/api/tags"; +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import { ZAttachedByEnum } from "@hoarder/trpc/types/tags"; import { cn } from "@/lib/utils"; import { Sparkles, X } from "lucide-react"; import { useState, KeyboardEvent, useEffect } from "react"; diff --git a/packages/web/app/dashboard/bookmarks/components/TextCard.tsx b/packages/web/app/dashboard/bookmarks/components/TextCard.tsx index 029800ac..5e0ba3f9 100644 --- a/packages/web/app/dashboard/bookmarks/components/TextCard.tsx +++ b/packages/web/app/dashboard/bookmarks/components/TextCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { ZBookmark } from "@/lib/types/api/bookmarks"; +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; import BookmarkOptions from "./BookmarkOptions"; import { api } from "@/lib/trpc"; import { Maximize2, Star } from "lucide-react"; diff --git a/packages/web/app/dashboard/components/AllLists.tsx b/packages/web/app/dashboard/components/AllLists.tsx index 8903c82a..a77252d0 100644 --- a/packages/web/app/dashboard/components/AllLists.tsx +++ b/packages/web/app/dashboard/components/AllLists.tsx @@ -5,7 +5,7 @@ import SidebarItem from "./SidebarItem"; import NewListModal, { useNewListModal } from "./NewListModal"; import { Plus } from "lucide-react"; import Link from "next/link"; -import { ZBookmarkList } from "@/lib/types/api/lists"; +import { ZBookmarkList } from "@hoarder/trpc/types/lists"; export default function AllLists({ initialData, diff --git a/packages/web/app/dashboard/lists/[listId]/components/DeleteListButton.tsx b/packages/web/app/dashboard/lists/[listId]/components/DeleteListButton.tsx index 32a7facf..5303b217 100644 --- a/packages/web/app/dashboard/lists/[listId]/components/DeleteListButton.tsx +++ b/packages/web/app/dashboard/lists/[listId]/components/DeleteListButton.tsx @@ -16,7 +16,7 @@ import { toast } from "@/components/ui/use-toast"; import { api } from "@/lib/trpc"; import { ActionButton } from "@/components/ui/action-button"; import { useState } from "react"; -import { ZBookmarkList } from "@/lib/types/api/lists"; +import { ZBookmarkList } from "@hoarder/trpc/types/lists"; export default function DeleteListButton({ list }: { list: ZBookmarkList }) { const [isDialogOpen, setDialogOpen] = useState(false); diff --git a/packages/web/app/dashboard/lists/[listId]/components/ListView.tsx b/packages/web/app/dashboard/lists/[listId]/components/ListView.tsx index 6489e9f0..979b522f 100644 --- a/packages/web/app/dashboard/lists/[listId]/components/ListView.tsx +++ b/packages/web/app/dashboard/lists/[listId]/components/ListView.tsx @@ -1,8 +1,8 @@ "use client"; import BookmarksGrid from "@/app/dashboard/bookmarks/components/BookmarksGrid"; -import { ZBookmark } from "@/lib/types/api/bookmarks"; -import { ZBookmarkListWithBookmarks } from "@/lib/types/api/lists"; +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import { ZBookmarkListWithBookmarks } from "@hoarder/trpc/types/lists"; import { api } from "@/lib/trpc"; export default function ListView({ diff --git a/packages/web/app/dashboard/lists/components/AllListsView.tsx b/packages/web/app/dashboard/lists/components/AllListsView.tsx index d81f5fca..0e2f898b 100644 --- a/packages/web/app/dashboard/lists/components/AllListsView.tsx +++ b/packages/web/app/dashboard/lists/components/AllListsView.tsx @@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button"; import { api } from "@/lib/trpc"; -import { ZBookmarkList } from "@/lib/types/api/lists"; +import { ZBookmarkList } from "@hoarder/trpc/types/lists"; import { keepPreviousData } from "@tanstack/react-query"; import { Plus } from "lucide-react"; import Link from "next/link"; diff --git a/packages/web/app/signin/components/CredentialsForm.tsx b/packages/web/app/signin/components/CredentialsForm.tsx index f47708f6..5296e163 100644 --- a/packages/web/app/signin/components/CredentialsForm.tsx +++ b/packages/web/app/signin/components/CredentialsForm.tsx @@ -13,7 +13,7 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { ActionButton } from "@/components/ui/action-button"; -import { zSignUpSchema } from "@/lib/types/api/users"; +import { zSignUpSchema } from "@hoarder/trpc/types/users"; import { signIn } from "next-auth/react"; import { useState } from "react"; import { api } from "@/lib/trpc"; diff --git a/packages/web/lib/testUtils.ts b/packages/web/lib/testUtils.ts deleted file mode 100644 index bad78463..00000000 --- a/packages/web/lib/testUtils.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { users } from "@hoarder/db/schema"; -import { getInMemoryDB } from "@hoarder/db/drizzle"; -import { appRouter } from "@/server/api/routers/_app"; -import { createCallerFactory } from "@/server/api/trpc"; - -export function getTestDB() { - return getInMemoryDB(true); -} - -export type TestDB = ReturnType<typeof getTestDB>; - -export async function seedUsers(db: TestDB) { - return await db - .insert(users) - .values([ - { - name: "Test User 1", - email: "test1@test.com", - }, - { - name: "Test User 2", - email: "test2@test.com", - }, - ]) - .returning(); -} - -export function getApiCaller(db: TestDB, userId?: string) { - const createCaller = createCallerFactory(appRouter); - return createCaller({ - user: userId - ? { - id: userId, - role: "user", - } - : null, - db, - }); -} - -export type APICallerType = ReturnType<typeof getApiCaller>; - -export interface CustomTestContext { - apiCallers: APICallerType[]; - unauthedAPICaller: APICallerType; - db: TestDB; -} - -export async function buildTestContext( - seedDB: boolean, -): Promise<CustomTestContext> { - const db = getTestDB(); - let users: Awaited<ReturnType<typeof seedUsers>> = []; - if (seedDB) { - users = await seedUsers(db); - } - const callers = users.map((u) => getApiCaller(db, u.id)); - - return { - apiCallers: callers, - unauthedAPICaller: getApiCaller(db), - db, - }; -} - -export function defaultBeforeEach(seedDB: boolean = true) { - return async (context: object) => { - Object.assign(context, await buildTestContext(seedDB)); - }; -} diff --git a/packages/web/lib/trpc.tsx b/packages/web/lib/trpc.tsx index aa246047..79a2a9fe 100644 --- a/packages/web/lib/trpc.tsx +++ b/packages/web/lib/trpc.tsx @@ -1,5 +1,5 @@ "use client"; -import type { AppRouter } from "@/server/api/routers/_app"; +import type { AppRouter } from "@hoarder/trpc/routers/_app"; import { createTRPCReact } from "@trpc/react-query"; export const api = createTRPCReact<AppRouter>(); diff --git a/packages/web/lib/types/api/bookmarks.ts b/packages/web/lib/types/api/bookmarks.ts deleted file mode 100644 index 5fabc7ca..00000000 --- a/packages/web/lib/types/api/bookmarks.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { z } from "zod"; -import { zBookmarkTagSchema } from "@/lib/types/api/tags"; - -export const zBookmarkedLinkSchema = z.object({ - type: z.literal("link"), - url: z.string().url(), - title: z.string().nullish(), - description: z.string().nullish(), - imageUrl: z.string().url().nullish(), - favicon: z.string().url().nullish(), - htmlContent: z.string().nullish(), - crawledAt: z.date().nullish(), -}); -export type ZBookmarkedLink = z.infer<typeof zBookmarkedLinkSchema>; - -export const zBookmarkedTextSchema = z.object({ - type: z.literal("text"), - text: z.string().max(2000), -}); -export type ZBookmarkedText = z.infer<typeof zBookmarkedTextSchema>; - -export const zBookmarkContentSchema = z.discriminatedUnion("type", [ - zBookmarkedLinkSchema, - zBookmarkedTextSchema, -]); -export type ZBookmarkContent = z.infer<typeof zBookmarkContentSchema>; - -export const zBareBookmarkSchema = z.object({ - id: z.string(), - createdAt: z.date(), - archived: z.boolean(), - favourited: z.boolean(), - taggingStatus: z.enum(["success", "failure", "pending"]).nullable(), -}); - -export const zBookmarkSchema = zBareBookmarkSchema.merge( - z.object({ - tags: z.array(zBookmarkTagSchema), - content: zBookmarkContentSchema, - }), -); -export type ZBookmark = z.infer<typeof zBookmarkSchema>; - -// POST /v1/bookmarks -export const zNewBookmarkRequestSchema = zBookmarkContentSchema; -export type ZNewBookmarkRequest = z.infer<typeof zNewBookmarkRequestSchema>; - -// GET /v1/bookmarks - -export const zGetBookmarksRequestSchema = z.object({ - ids: z.array(z.string()).optional(), - archived: z.boolean().optional(), - favourited: z.boolean().optional(), -}); -export type ZGetBookmarksRequest = z.infer<typeof zGetBookmarksRequestSchema>; - -export const zGetBookmarksResponseSchema = z.object({ - bookmarks: z.array(zBookmarkSchema), -}); -export type ZGetBookmarksResponse = z.infer<typeof zGetBookmarksResponseSchema>; - -// PATCH /v1/bookmarks/[bookmarkId] -export const zUpdateBookmarksRequestSchema = z.object({ - bookmarkId: z.string(), - archived: z.boolean().optional(), - favourited: z.boolean().optional(), -}); -export type ZUpdateBookmarksRequest = z.infer< - typeof zUpdateBookmarksRequestSchema ->; diff --git a/packages/web/lib/types/api/lists.ts b/packages/web/lib/types/api/lists.ts deleted file mode 100644 index 4b0ccaca..00000000 --- a/packages/web/lib/types/api/lists.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { z } from "zod"; - -export const zBookmarkListSchema = z.object({ - id: z.string(), - name: z.string(), - icon: z.string(), -}); - -export const zBookmarkListWithBookmarksSchema = zBookmarkListSchema.merge( - z.object({ - bookmarks: z.array(z.string()), - }), -); - -export type ZBookmarkList = z.infer<typeof zBookmarkListSchema>; -export type ZBookmarkListWithBookmarks = z.infer< - typeof zBookmarkListWithBookmarksSchema ->; diff --git a/packages/web/lib/types/api/tags.ts b/packages/web/lib/types/api/tags.ts deleted file mode 100644 index 7a99dad4..00000000 --- a/packages/web/lib/types/api/tags.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { z } from "zod"; - -export const zAttachedByEnumSchema = z.enum(["ai", "human"]); -export type ZAttachedByEnum = z.infer<typeof zAttachedByEnumSchema>; -export const zBookmarkTagSchema = z.object({ - id: z.string(), - name: z.string(), - attachedBy: zAttachedByEnumSchema, -}); -export type ZBookmarkTags = z.infer<typeof zBookmarkTagSchema>; diff --git a/packages/web/lib/types/api/users.ts b/packages/web/lib/types/api/users.ts deleted file mode 100644 index c2fe182a..00000000 --- a/packages/web/lib/types/api/users.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { z } from "zod"; - -export const zSignUpSchema = z - .object({ - name: z.string().min(1, { message: "Name can't be empty" }), - email: z.string().email(), - password: z.string().min(8), - confirmPassword: z.string(), - }) - .refine((data) => data.password === data.confirmPassword, { - message: "Passwords don't match", - path: ["confirmPassword"], - }); diff --git a/packages/web/package.json b/packages/web/package.json index 5367d189..e0c9d407 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -8,7 +8,8 @@ "build": "next build", "start": "next start", "lint": "next lint", - "test": "vitest" + "test": "vitest", + "typecheck": "tsc --noEmit" }, "dependencies": { "@auth/drizzle-adapter": "^0.8.0", @@ -16,6 +17,7 @@ "@emoji-mart/react": "^1.1.1", "@hoarder/db": "0.1.0", "@hoarder/shared": "0.1.0", + "@hoarder/trpc": "0.1.0", "@hookform/resolvers": "^3.3.4", "@next/eslint-plugin-next": "^14.1.1", "@radix-ui/react-dialog": "^1.0.5", @@ -34,7 +36,6 @@ "@trpc/next": "11.0.0-next-beta.304", "@trpc/react-query": "^11.0.0-next-beta.304", "@trpc/server": "11.0.0-next-beta.304", - "bcrypt": "^5.1.1", "better-sqlite3": "^9.4.3", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", @@ -60,7 +61,6 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.10", - "@types/bcrypt": "^5.0.2", "@types/emoji-mart": "^3.0.14", "@types/react": "^18", "@types/react-dom": "^18", 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; -} |
