From 8a46ecb7373d6c5e7300861169ea51a7917cd2b4 Mon Sep 17 00:00:00 2001 From: MohamedBassem Date: Tue, 5 Mar 2024 13:11:06 +0000 Subject: refactor: Extract trpc logic into its package --- packages/trpc/auth.ts | 99 +++++ packages/trpc/index.ts | 57 +++ packages/trpc/package.json | 28 ++ packages/trpc/routers/_app.ts | 15 + packages/trpc/routers/admin.ts | 77 ++++ packages/trpc/routers/apiKeys.ts | 61 +++ packages/trpc/routers/bookmarks.test.ts | 200 +++++++++ packages/trpc/routers/bookmarks.ts | 454 +++++++++++++++++++++ packages/trpc/routers/lists.ts | 173 ++++++++ packages/trpc/routers/users.test.ts | 99 +++++ packages/trpc/routers/users.ts | 93 +++++ packages/trpc/testUtils.ts | 70 ++++ packages/trpc/tsconfig.json | 13 + packages/trpc/types/bookmarks.ts | 70 ++++ packages/trpc/types/lists.ts | 18 + packages/trpc/types/tags.ts | 10 + packages/trpc/types/users.ts | 13 + packages/trpc/vitest.config.ts | 14 + packages/web/app/api/trpc/[trpc]/route.ts | 4 +- .../bookmarks/components/BookmarkOptions.tsx | 2 +- .../bookmarks/components/BookmarkedTextEditor.tsx | 2 +- .../dashboard/bookmarks/components/Bookmarks.tsx | 2 +- .../bookmarks/components/BookmarksGrid.tsx | 2 +- .../dashboard/bookmarks/components/LinkCard.tsx | 2 +- .../app/dashboard/bookmarks/components/TagList.tsx | 2 +- .../dashboard/bookmarks/components/TagModal.tsx | 4 +- .../dashboard/bookmarks/components/TextCard.tsx | 2 +- packages/web/app/dashboard/components/AllLists.tsx | 2 +- .../lists/[listId]/components/DeleteListButton.tsx | 2 +- .../lists/[listId]/components/ListView.tsx | 4 +- .../dashboard/lists/components/AllListsView.tsx | 2 +- .../web/app/signin/components/CredentialsForm.tsx | 2 +- packages/web/lib/testUtils.ts | 70 ---- packages/web/lib/trpc.tsx | 2 +- packages/web/lib/types/api/bookmarks.ts | 70 ---- packages/web/lib/types/api/lists.ts | 18 - packages/web/lib/types/api/tags.ts | 10 - packages/web/lib/types/api/users.ts | 13 - packages/web/package.json | 6 +- packages/web/server/api/client.ts | 4 +- packages/web/server/api/routers/_app.ts | 15 - packages/web/server/api/routers/admin.ts | 77 ---- packages/web/server/api/routers/apiKeys.ts | 61 --- packages/web/server/api/routers/bookmarks.test.ts | 200 --------- packages/web/server/api/routers/bookmarks.ts | 454 --------------------- packages/web/server/api/routers/lists.ts | 173 -------- packages/web/server/api/routers/users.test.ts | 99 ----- packages/web/server/api/routers/users.ts | 93 ----- packages/web/server/api/trpc.ts | 53 --- packages/web/server/auth.ts | 100 +---- pnpm-lock.yaml | 213 +++++++++- 51 files changed, 1793 insertions(+), 1536 deletions(-) create mode 100644 packages/trpc/auth.ts create mode 100644 packages/trpc/index.ts create mode 100644 packages/trpc/package.json create mode 100644 packages/trpc/routers/_app.ts create mode 100644 packages/trpc/routers/admin.ts create mode 100644 packages/trpc/routers/apiKeys.ts create mode 100644 packages/trpc/routers/bookmarks.test.ts create mode 100644 packages/trpc/routers/bookmarks.ts create mode 100644 packages/trpc/routers/lists.ts create mode 100644 packages/trpc/routers/users.test.ts create mode 100644 packages/trpc/routers/users.ts create mode 100644 packages/trpc/testUtils.ts create mode 100644 packages/trpc/tsconfig.json create mode 100644 packages/trpc/types/bookmarks.ts create mode 100644 packages/trpc/types/lists.ts create mode 100644 packages/trpc/types/tags.ts create mode 100644 packages/trpc/types/users.ts create mode 100644 packages/trpc/vitest.config.ts delete mode 100644 packages/web/lib/testUtils.ts delete mode 100644 packages/web/lib/types/api/bookmarks.ts delete mode 100644 packages/web/lib/types/api/lists.ts delete mode 100644 packages/web/lib/types/api/tags.ts delete mode 100644 packages/web/lib/types/api/users.ts delete mode 100644 packages/web/server/api/routers/_app.ts delete mode 100644 packages/web/server/api/routers/admin.ts delete mode 100644 packages/web/server/api/routers/apiKeys.ts delete mode 100644 packages/web/server/api/routers/bookmarks.test.ts delete mode 100644 packages/web/server/api/routers/bookmarks.ts delete mode 100644 packages/web/server/api/routers/lists.ts delete mode 100644 packages/web/server/api/routers/users.test.ts delete mode 100644 packages/web/server/api/routers/users.ts delete mode 100644 packages/web/server/api/trpc.ts diff --git a/packages/trpc/auth.ts b/packages/trpc/auth.ts new file mode 100644 index 00000000..6854303b --- /dev/null +++ b/packages/trpc/auth.ts @@ -0,0 +1,99 @@ +import { randomBytes } from "crypto"; +import { apiKeys } from "@hoarder/db/schema"; +import * as bcrypt from "bcrypt"; +import { db } from "@hoarder/db"; + +// 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; +} diff --git a/packages/trpc/index.ts b/packages/trpc/index.ts new file mode 100644 index 00000000..a32eb871 --- /dev/null +++ b/packages/trpc/index.ts @@ -0,0 +1,57 @@ +import { db } from "@hoarder/db"; +import serverConfig from "@hoarder/shared/config"; +import { TRPCError, initTRPC } from "@trpc/server"; +import superjson from "superjson"; + +type User = { + id: string; + role: "admin" | "user" | null; +}; + +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().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/trpc/package.json b/packages/trpc/package.json new file mode 100644 index 00000000..1e33eff0 --- /dev/null +++ b/packages/trpc/package.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@hoarder/trpc", + "version": "0.1.0", + "private": true, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest" + }, + "dependencies": { + "@hoarder/db": "workspace:*", + "@hoarder/shared": "workspace:*", + "@trpc/server": "11.0.0-next-beta.304", + "bcrypt": "^5.1.1", + "drizzle-orm": "^0.29.4", + "superjson": "^2.2.1", + "zod": "^3.22.4" + }, + "devDependencies": { + "@tsconfig/node21": "^21.0.1", + "@types/bcrypt": "^5.0.2", + "aws-sdk": "^2.1570.0", + "mock-aws-s3": "^4.0.2", + "nock": "^13.5.4", + "vite-tsconfig-paths": "^4.3.1", + "vitest": "^1.3.1" + } +} diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts new file mode 100644 index 00000000..6e5dd91d --- /dev/null +++ b/packages/trpc/routers/_app.ts @@ -0,0 +1,15 @@ +import { router } from "../index"; +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/trpc/routers/admin.ts b/packages/trpc/routers/admin.ts new file mode 100644 index 00000000..8a7b592d --- /dev/null +++ b/packages/trpc/routers/admin.ts @@ -0,0 +1,77 @@ +import { adminProcedure, router } from "../index"; +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/trpc/routers/apiKeys.ts b/packages/trpc/routers/apiKeys.ts new file mode 100644 index 00000000..d13f87fb --- /dev/null +++ b/packages/trpc/routers/apiKeys.ts @@ -0,0 +1,61 @@ +import { generateApiKey } from "../auth"; +import { authedProcedure, router } from "../index"; +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/trpc/routers/bookmarks.test.ts b/packages/trpc/routers/bookmarks.test.ts new file mode 100644 index 00000000..724a9998 --- /dev/null +++ b/packages/trpc/routers/bookmarks.test.ts @@ -0,0 +1,200 @@ +import { CustomTestContext, defaultBeforeEach } from "../testUtils"; +import { expect, describe, test, beforeEach, assert } from "vitest"; + +beforeEach(defaultBeforeEach(true)); + +describe("Bookmark Routes", () => { + test("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("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("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("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("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("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("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/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts new file mode 100644 index 00000000..ea7ffef8 --- /dev/null +++ b/packages/trpc/routers/bookmarks.ts @@ -0,0 +1,454 @@ +import { z } from "zod"; +import { Context, authedProcedure, router } from "../index"; +import { getSearchIdxClient } from "@hoarder/shared/search"; +import { + ZBookmark, + ZBookmarkContent, + zBareBookmarkSchema, + zBookmarkSchema, + zGetBookmarksRequestSchema, + zGetBookmarksResponseSchema, + zNewBookmarkRequestSchema, + zUpdateBookmarksRequestSchema, +} from "../types/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 "../types/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>, +): 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 => { + 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/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts new file mode 100644 index 00000000..fa97929d --- /dev/null +++ b/packages/trpc/routers/lists.ts @@ -0,0 +1,173 @@ +import { Context, authedProcedure, router } from "../index"; +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 "../types/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/trpc/routers/users.test.ts b/packages/trpc/routers/users.test.ts new file mode 100644 index 00000000..87814407 --- /dev/null +++ b/packages/trpc/routers/users.test.ts @@ -0,0 +1,99 @@ +import { + CustomTestContext, + defaultBeforeEach, + getApiCaller, +} from "../testUtils"; +import { expect, describe, test, beforeEach, assert } from "vitest"; + +beforeEach(defaultBeforeEach(false)); + +describe("User Routes", () => { + test("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("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("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("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/trpc/routers/users.ts b/packages/trpc/routers/users.ts new file mode 100644 index 00000000..b5334f99 --- /dev/null +++ b/packages/trpc/routers/users.ts @@ -0,0 +1,93 @@ +import { zSignUpSchema } from "../types/users"; +import { adminProcedure, publicProcedure, router } from "../index"; +import { SqliteError } from "@hoarder/db"; +import { z } from "zod"; +import { hashPassword } from "../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/trpc/testUtils.ts b/packages/trpc/testUtils.ts new file mode 100644 index 00000000..d5f24def --- /dev/null +++ b/packages/trpc/testUtils.ts @@ -0,0 +1,70 @@ +import { users } from "@hoarder/db/schema"; +import { getInMemoryDB } from "@hoarder/db/drizzle"; +import { appRouter } from "./routers/_app"; +import { createCallerFactory } from "./index"; + +export function getTestDB() { + return getInMemoryDB(true); +} + +export type TestDB = ReturnType; + +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; + +export interface CustomTestContext { + apiCallers: APICallerType[]; + unauthedAPICaller: APICallerType; + db: TestDB; +} + +export async function buildTestContext( + seedDB: boolean, +): Promise { + const db = getTestDB(); + let users: Awaited> = []; + 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/trpc/tsconfig.json b/packages/trpc/tsconfig.json new file mode 100644 index 00000000..bf020b01 --- /dev/null +++ b/packages/trpc/tsconfig.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/node21/tsconfig.json", + "include": ["**/*.ts"], + "exclude": ["node_modules"], + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "node", + "baseUrl": "./", + "esModuleInterop": true + } +} + diff --git a/packages/trpc/types/bookmarks.ts b/packages/trpc/types/bookmarks.ts new file mode 100644 index 00000000..b61ab0e0 --- /dev/null +++ b/packages/trpc/types/bookmarks.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; +import { zBookmarkTagSchema } from "./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; + +export const zBookmarkedTextSchema = z.object({ + type: z.literal("text"), + text: z.string().max(2000), +}); +export type ZBookmarkedText = z.infer; + +export const zBookmarkContentSchema = z.discriminatedUnion("type", [ + zBookmarkedLinkSchema, + zBookmarkedTextSchema, +]); +export type ZBookmarkContent = z.infer; + +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; + +// POST /v1/bookmarks +export const zNewBookmarkRequestSchema = zBookmarkContentSchema; +export type ZNewBookmarkRequest = z.infer; + +// 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; + +export const zGetBookmarksResponseSchema = z.object({ + bookmarks: z.array(zBookmarkSchema), +}); +export type ZGetBookmarksResponse = z.infer; + +// 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/trpc/types/lists.ts b/packages/trpc/types/lists.ts new file mode 100644 index 00000000..4b0ccaca --- /dev/null +++ b/packages/trpc/types/lists.ts @@ -0,0 +1,18 @@ +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; +export type ZBookmarkListWithBookmarks = z.infer< + typeof zBookmarkListWithBookmarksSchema +>; diff --git a/packages/trpc/types/tags.ts b/packages/trpc/types/tags.ts new file mode 100644 index 00000000..7a99dad4 --- /dev/null +++ b/packages/trpc/types/tags.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const zAttachedByEnumSchema = z.enum(["ai", "human"]); +export type ZAttachedByEnum = z.infer; +export const zBookmarkTagSchema = z.object({ + id: z.string(), + name: z.string(), + attachedBy: zAttachedByEnumSchema, +}); +export type ZBookmarkTags = z.infer; diff --git a/packages/trpc/types/users.ts b/packages/trpc/types/users.ts new file mode 100644 index 00000000..c2fe182a --- /dev/null +++ b/packages/trpc/types/users.ts @@ -0,0 +1,13 @@ +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/trpc/vitest.config.ts b/packages/trpc/vitest.config.ts new file mode 100644 index 00000000..c3d02f71 --- /dev/null +++ b/packages/trpc/vitest.config.ts @@ -0,0 +1,14 @@ +/// + +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + alias: { + "@/*": "./*", + }, + }, +}); 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; - -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; - -export interface CustomTestContext { - apiCallers: APICallerType[]; - unauthedAPICaller: APICallerType; - db: TestDB; -} - -export async function buildTestContext( - seedDB: boolean, -): Promise { - const db = getTestDB(); - let users: Awaited> = []; - 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(); 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; - -export const zBookmarkedTextSchema = z.object({ - type: z.literal("text"), - text: z.string().max(2000), -}); -export type ZBookmarkedText = z.infer; - -export const zBookmarkContentSchema = z.discriminatedUnion("type", [ - zBookmarkedLinkSchema, - zBookmarkedTextSchema, -]); -export type ZBookmarkContent = z.infer; - -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; - -// POST /v1/bookmarks -export const zNewBookmarkRequestSchema = zBookmarkContentSchema; -export type ZNewBookmarkRequest = z.infer; - -// 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; - -export const zGetBookmarksResponseSchema = z.object({ - bookmarks: z.array(zBookmarkSchema), -}); -export type ZGetBookmarksResponse = z.infer; - -// 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; -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; -export const zBookmarkTagSchema = z.object({ - id: z.string(), - name: z.string(), - attachedBy: zAttachedByEnumSchema, -}); -export type ZBookmarkTags = z.infer; 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 => { 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(defaultBeforeEach(true)); - -describe("Bookmark Routes", () => { - test("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("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("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("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("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("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("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>, -): 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 => { - 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(defaultBeforeEach(false)); - -describe("User Routes", () => { - test("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("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("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("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().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; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f7a22a6..dc333de5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -177,6 +177,52 @@ importers: specifier: ^3.22.4 version: 3.22.4 + packages/trpc: + dependencies: + '@hoarder/db': + specifier: workspace:* + version: link:../db + '@hoarder/shared': + specifier: workspace:* + version: link:../shared + '@trpc/server': + specifier: 11.0.0-next-beta.304 + version: 11.0.0-next-beta.304 + bcrypt: + specifier: ^5.1.1 + version: 5.1.1 + drizzle-orm: + specifier: ^0.29.4 + version: 0.29.4(@types/react@18.2.58)(better-sqlite3@9.4.3)(react@18.2.0) + superjson: + specifier: ^2.2.1 + version: 2.2.1 + zod: + specifier: ^3.22.4 + version: 3.22.4 + devDependencies: + '@tsconfig/node21': + specifier: ^21.0.1 + version: 21.0.1 + '@types/bcrypt': + specifier: ^5.0.2 + version: 5.0.2 + aws-sdk: + specifier: ^2.1570.0 + version: 2.1570.0 + mock-aws-s3: + specifier: ^4.0.2 + version: 4.0.2 + nock: + specifier: ^13.5.4 + version: 13.5.4 + vite-tsconfig-paths: + specifier: ^4.3.1 + version: 4.3.1(typescript@5.3.3) + vitest: + specifier: ^1.3.1 + version: 1.3.1(@types/node@20.11.20) + packages/web: dependencies: '@auth/drizzle-adapter': @@ -194,6 +240,9 @@ importers: '@hoarder/shared': specifier: 0.1.0 version: link:../shared + '@hoarder/trpc': + specifier: 0.1.0 + version: link:../trpc '@hookform/resolvers': specifier: ^3.3.4 version: 3.3.4(react-hook-form@7.50.1) @@ -248,9 +297,6 @@ importers: '@trpc/server': specifier: 11.0.0-next-beta.304 version: 11.0.0-next-beta.304 - bcrypt: - specifier: ^5.1.1 - version: 5.1.1 better-sqlite3: specifier: ^9.4.3 version: 9.4.3 @@ -321,9 +367,6 @@ importers: '@tailwindcss/typography': specifier: ^0.5.10 version: 0.5.10(tailwindcss@3.4.1) - '@types/bcrypt': - specifier: ^5.0.2 - version: 5.0.2 '@types/emoji-mart': specifier: ^3.0.14 version: 3.0.14 @@ -4804,6 +4847,22 @@ packages: dependencies: possible-typed-array-names: 1.0.0 + /aws-sdk@2.1570.0: + resolution: {integrity: sha512-WySdibC3YOPCFcXNSevX7cGp6Nc0Ksv7m6aaz6YoqSrmSn7mZhkWaVXqfd14nsjJuyEbEgX+gAiZaahyvkUYJw==} + engines: {node: '>= 10.0.0'} + dependencies: + buffer: 4.9.2 + events: 1.1.1 + ieee754: 1.1.13 + jmespath: 0.16.0 + querystring: 0.2.0 + sax: 1.2.1 + url: 0.10.3 + util: 0.12.5 + uuid: 8.0.0 + xml2js: 0.6.2 + dev: true + /axe-core@4.7.0: resolution: {integrity: sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==} engines: {node: '>=4'} @@ -4914,7 +4973,6 @@ packages: /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - dev: false /basic-ftp@5.0.4: resolution: {integrity: sha512-8PzkB0arJFV4jJWSGOYR+OEic6aeKMu/osRhBULN6RY0ykby6LKhbmuQ5ublvaas5BOwboah5D87nrHyuh8PPA==} @@ -4963,6 +5021,10 @@ packages: readable-stream: 3.6.2 dev: false + /bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + dev: true + /boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -5000,6 +5062,14 @@ packages: /buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + /buffer@4.9.2: + resolution: {integrity: sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==} + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + isarray: 1.0.0 + dev: true + /buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} dependencies: @@ -6647,6 +6717,11 @@ packages: engines: {node: '>=6'} dev: false + /events@1.1.1: + resolution: {integrity: sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==} + engines: {node: '>=0.4.x'} + dev: true + /events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -6879,6 +6954,15 @@ packages: universalify: 2.0.1 dev: false + /fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + dev: true + /fs-extra@9.1.0: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} @@ -7311,9 +7395,12 @@ packages: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} dev: false + /ieee754@1.1.13: + resolution: {integrity: sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==} + dev: true + /ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - dev: false /ignore@5.3.1: resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} @@ -7426,6 +7513,14 @@ packages: is-decimal: 2.0.1 dev: false + /is-arguments@1.1.1: + resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + dev: true + /is-array-buffer@3.0.4: resolution: {integrity: sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==} engines: {node: '>= 0.4'} @@ -7677,6 +7772,10 @@ packages: resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} engines: {node: '>=12.13'} + /isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + dev: true + /isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -7751,6 +7850,11 @@ packages: resolution: {integrity: sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==} hasBin: true + /jmespath@0.16.0: + resolution: {integrity: sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==} + engines: {node: '>= 0.6.0'} + dev: true + /jose@4.15.4: resolution: {integrity: sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==} dev: false @@ -7859,6 +7963,10 @@ packages: /json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + /json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + dev: true + /json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -7876,6 +7984,12 @@ packages: resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==} dev: true + /jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + optionalDependencies: + graceful-fs: 4.2.11 + dev: true + /jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} dependencies: @@ -8791,6 +8905,15 @@ packages: ufo: 1.4.0 dev: true + /mock-aws-s3@4.0.2: + resolution: {integrity: sha512-J6g3MMCuKHeuqVEOgvQfRGIfVmg6KKrED48Bux/L9rTY3NPK9TFRh/9bCf5AuzjJm9PIlwhDEO99tD8+smnTyQ==} + engines: {node: '>=10.0.0'} + dependencies: + bluebird: 3.7.2 + fs-extra: 7.0.1 + underscore: 1.12.1 + dev: true + /ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} dev: true @@ -8950,6 +9073,17 @@ packages: - babel-plugin-macros dev: false + /nock@13.5.4: + resolution: {integrity: sha512-yAyTfdeNJGGBFxWdzSKCBYxs5FxLbCg5X5Q4ets974hcQzG1+qCxvIyOo4j2Ry6MUlhWVMX4OoYDefAIIwupjw==} + engines: {node: '>= 10.13'} + dependencies: + debug: 4.3.4 + json-stringify-safe: 5.0.1 + propagate: 2.0.1 + transitivePeerDependencies: + - supports-color + dev: true + /node-abi@3.56.0: resolution: {integrity: sha512-fZjdhDOeRcaS+rcpve7XuwHBmktS1nS1gzgghwKUQQ8nTy2FdSDr6ZT8k6YhvlJeHmmQMYiT/IH9hfco5zeW2Q==} engines: {node: '>=10'} @@ -9660,6 +9794,11 @@ packages: object-assign: 4.1.1 react-is: 16.13.1 + /propagate@2.0.1: + resolution: {integrity: sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==} + engines: {node: '>= 8'} + dev: true + /property-information@6.4.1: resolution: {integrity: sha512-OHYtXfu5aI2sS2LWFSN5rgJjrQ4pCy8i1jubJLe2QvMF8JJ++HXTUIVWFLfXJoaOfvYYjk2SN8J2wFUWIGXT4w==} dev: false @@ -9700,6 +9839,10 @@ packages: engines: {node: '>=0.10'} dev: false + /punycode@1.3.2: + resolution: {integrity: sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==} + dev: true + /punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -9875,6 +10018,12 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} dev: true + /querystring@0.2.0: + resolution: {integrity: sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==} + engines: {node: '>=0.4.x'} + deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead. + dev: true + /querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} dev: false @@ -10342,6 +10491,10 @@ packages: requiresBuild: true dev: false + /sax@1.2.1: + resolution: {integrity: sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==} + dev: true + /saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -11238,6 +11391,10 @@ packages: through: 2.3.8 dev: false + /underscore@1.12.1: + resolution: {integrity: sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==} + dev: true + /undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} @@ -11337,6 +11494,11 @@ packages: unist-util-visit-parents: 6.0.1 dev: false + /universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + dev: true + /universalify@0.2.0: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'} @@ -11387,6 +11549,13 @@ packages: tlds: 1.250.0 dev: false + /url@0.10.3: + resolution: {integrity: sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==} + dependencies: + punycode: 1.3.2 + querystring: 0.2.0 + dev: true + /urlpattern-polyfill@10.0.0: resolution: {integrity: sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==} dev: false @@ -11441,6 +11610,21 @@ packages: /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + /util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + dependencies: + inherits: 2.0.4 + is-arguments: 1.1.1 + is-generator-function: 1.0.10 + is-typed-array: 1.1.13 + which-typed-array: 1.1.14 + dev: true + + /uuid@8.0.0: + resolution: {integrity: sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==} + hasBin: true + dev: true + /uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -12042,6 +12226,19 @@ packages: engines: {node: '>=18'} dev: false + /xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + dependencies: + sax: 1.2.1 + xmlbuilder: 11.0.1 + dev: true + + /xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + dev: true + /xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} dev: false -- cgit v1.2.3-70-g09d2