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