aboutsummaryrefslogtreecommitdiffstats
path: root/packages/web
diff options
context:
space:
mode:
Diffstat (limited to 'packages/web')
-rw-r--r--packages/web/app/api/trpc/[trpc]/route.ts4
-rw-r--r--packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx2
-rw-r--r--packages/web/app/dashboard/bookmarks/components/BookmarkedTextEditor.tsx2
-rw-r--r--packages/web/app/dashboard/bookmarks/components/Bookmarks.tsx2
-rw-r--r--packages/web/app/dashboard/bookmarks/components/BookmarksGrid.tsx2
-rw-r--r--packages/web/app/dashboard/bookmarks/components/LinkCard.tsx2
-rw-r--r--packages/web/app/dashboard/bookmarks/components/TagList.tsx2
-rw-r--r--packages/web/app/dashboard/bookmarks/components/TagModal.tsx4
-rw-r--r--packages/web/app/dashboard/bookmarks/components/TextCard.tsx2
-rw-r--r--packages/web/app/dashboard/components/AllLists.tsx2
-rw-r--r--packages/web/app/dashboard/lists/[listId]/components/DeleteListButton.tsx2
-rw-r--r--packages/web/app/dashboard/lists/[listId]/components/ListView.tsx4
-rw-r--r--packages/web/app/dashboard/lists/components/AllListsView.tsx2
-rw-r--r--packages/web/app/signin/components/CredentialsForm.tsx2
-rw-r--r--packages/web/lib/testUtils.ts70
-rw-r--r--packages/web/lib/trpc.tsx2
-rw-r--r--packages/web/lib/types/api/bookmarks.ts70
-rw-r--r--packages/web/lib/types/api/lists.ts18
-rw-r--r--packages/web/lib/types/api/tags.ts10
-rw-r--r--packages/web/lib/types/api/users.ts13
-rw-r--r--packages/web/package.json6
-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
32 files changed, 24 insertions, 1528 deletions
diff --git a/packages/web/app/api/trpc/[trpc]/route.ts b/packages/web/app/api/trpc/[trpc]/route.ts
index 7d56cadc..b6753101 100644
--- a/packages/web/app/api/trpc/[trpc]/route.ts
+++ b/packages/web/app/api/trpc/[trpc]/route.ts
@@ -1,7 +1,7 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
-import { appRouter } from "@/server/api/routers/_app";
+import { appRouter } from "@hoarder/trpc/routers/_app";
import { createContext } from "@/server/api/client";
-import { authenticateApiKey } from "@/server/auth";
+import { authenticateApiKey } from "@hoarder/trpc/auth";
import { db } from "@hoarder/db";
const handler = (req: Request) =>
diff --git a/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx b/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx
index 584e8708..4f08ebee 100644
--- a/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx
+++ b/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx
@@ -2,7 +2,7 @@
import { useToast } from "@/components/ui/use-toast";
import { api } from "@/lib/trpc";
-import { ZBookmark, ZBookmarkedLink } from "@/lib/types/api/bookmarks";
+import { ZBookmark, ZBookmarkedLink } from "@hoarder/trpc/types/bookmarks";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
diff --git a/packages/web/app/dashboard/bookmarks/components/BookmarkedTextEditor.tsx b/packages/web/app/dashboard/bookmarks/components/BookmarkedTextEditor.tsx
index c449fae3..a5b58f1a 100644
--- a/packages/web/app/dashboard/bookmarks/components/BookmarkedTextEditor.tsx
+++ b/packages/web/app/dashboard/bookmarks/components/BookmarkedTextEditor.tsx
@@ -1,4 +1,4 @@
-import { ZBookmark } from "@/lib/types/api/bookmarks";
+import { ZBookmark } from "@hoarder/trpc/types/bookmarks";
import {
Dialog,
DialogClose,
diff --git a/packages/web/app/dashboard/bookmarks/components/Bookmarks.tsx b/packages/web/app/dashboard/bookmarks/components/Bookmarks.tsx
index 62b93dc8..1ad3670c 100644
--- a/packages/web/app/dashboard/bookmarks/components/Bookmarks.tsx
+++ b/packages/web/app/dashboard/bookmarks/components/Bookmarks.tsx
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import BookmarksGrid from "./BookmarksGrid";
-import { ZGetBookmarksRequest } from "@/lib/types/api/bookmarks";
+import { ZGetBookmarksRequest } from "@hoarder/trpc/types/bookmarks";
import { api } from "@/server/api/client";
import { getServerAuthSession } from "@/server/auth";
diff --git a/packages/web/app/dashboard/bookmarks/components/BookmarksGrid.tsx b/packages/web/app/dashboard/bookmarks/components/BookmarksGrid.tsx
index 554d20a0..4d5b6b0a 100644
--- a/packages/web/app/dashboard/bookmarks/components/BookmarksGrid.tsx
+++ b/packages/web/app/dashboard/bookmarks/components/BookmarksGrid.tsx
@@ -1,7 +1,7 @@
"use client";
import LinkCard from "./LinkCard";
-import { ZBookmark, ZGetBookmarksRequest } from "@/lib/types/api/bookmarks";
+import { ZBookmark, ZGetBookmarksRequest } from "@hoarder/trpc/types/bookmarks";
import { api } from "@/lib/trpc";
import TextCard from "./TextCard";
import { Slot } from "@radix-ui/react-slot";
diff --git a/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx b/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx
index 5af11aa3..76d3f1b8 100644
--- a/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx
+++ b/packages/web/app/dashboard/bookmarks/components/LinkCard.tsx
@@ -8,7 +8,7 @@ import {
ImageCardFooter,
ImageCardTitle,
} from "@/components/ui/imageCard";
-import { ZBookmark } from "@/lib/types/api/bookmarks";
+import { ZBookmark } from "@hoarder/trpc/types/bookmarks";
import Link from "next/link";
import BookmarkOptions from "./BookmarkOptions";
import { api } from "@/lib/trpc";
diff --git a/packages/web/app/dashboard/bookmarks/components/TagList.tsx b/packages/web/app/dashboard/bookmarks/components/TagList.tsx
index 82d9f376..6c9d2d22 100644
--- a/packages/web/app/dashboard/bookmarks/components/TagList.tsx
+++ b/packages/web/app/dashboard/bookmarks/components/TagList.tsx
@@ -1,7 +1,7 @@
import { badgeVariants } from "@/components/ui/badge";
import Link from "next/link";
import { Skeleton } from "@/components/ui/skeleton";
-import { ZBookmark } from "@/lib/types/api/bookmarks";
+import { ZBookmark } from "@hoarder/trpc/types/bookmarks";
import { cn } from "@/lib/utils";
export default function TagList({
diff --git a/packages/web/app/dashboard/bookmarks/components/TagModal.tsx b/packages/web/app/dashboard/bookmarks/components/TagModal.tsx
index 703c4221..8c09d00e 100644
--- a/packages/web/app/dashboard/bookmarks/components/TagModal.tsx
+++ b/packages/web/app/dashboard/bookmarks/components/TagModal.tsx
@@ -11,8 +11,8 @@ import {
import { Input } from "@/components/ui/input";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/lib/trpc";
-import { ZBookmark } from "@/lib/types/api/bookmarks";
-import { ZAttachedByEnum } from "@/lib/types/api/tags";
+import { ZBookmark } from "@hoarder/trpc/types/bookmarks";
+import { ZAttachedByEnum } from "@hoarder/trpc/types/tags";
import { cn } from "@/lib/utils";
import { Sparkles, X } from "lucide-react";
import { useState, KeyboardEvent, useEffect } from "react";
diff --git a/packages/web/app/dashboard/bookmarks/components/TextCard.tsx b/packages/web/app/dashboard/bookmarks/components/TextCard.tsx
index 029800ac..5e0ba3f9 100644
--- a/packages/web/app/dashboard/bookmarks/components/TextCard.tsx
+++ b/packages/web/app/dashboard/bookmarks/components/TextCard.tsx
@@ -1,6 +1,6 @@
"use client";
-import { ZBookmark } from "@/lib/types/api/bookmarks";
+import { ZBookmark } from "@hoarder/trpc/types/bookmarks";
import BookmarkOptions from "./BookmarkOptions";
import { api } from "@/lib/trpc";
import { Maximize2, Star } from "lucide-react";
diff --git a/packages/web/app/dashboard/components/AllLists.tsx b/packages/web/app/dashboard/components/AllLists.tsx
index 8903c82a..a77252d0 100644
--- a/packages/web/app/dashboard/components/AllLists.tsx
+++ b/packages/web/app/dashboard/components/AllLists.tsx
@@ -5,7 +5,7 @@ import SidebarItem from "./SidebarItem";
import NewListModal, { useNewListModal } from "./NewListModal";
import { Plus } from "lucide-react";
import Link from "next/link";
-import { ZBookmarkList } from "@/lib/types/api/lists";
+import { ZBookmarkList } from "@hoarder/trpc/types/lists";
export default function AllLists({
initialData,
diff --git a/packages/web/app/dashboard/lists/[listId]/components/DeleteListButton.tsx b/packages/web/app/dashboard/lists/[listId]/components/DeleteListButton.tsx
index 32a7facf..5303b217 100644
--- a/packages/web/app/dashboard/lists/[listId]/components/DeleteListButton.tsx
+++ b/packages/web/app/dashboard/lists/[listId]/components/DeleteListButton.tsx
@@ -16,7 +16,7 @@ import { toast } from "@/components/ui/use-toast";
import { api } from "@/lib/trpc";
import { ActionButton } from "@/components/ui/action-button";
import { useState } from "react";
-import { ZBookmarkList } from "@/lib/types/api/lists";
+import { ZBookmarkList } from "@hoarder/trpc/types/lists";
export default function DeleteListButton({ list }: { list: ZBookmarkList }) {
const [isDialogOpen, setDialogOpen] = useState(false);
diff --git a/packages/web/app/dashboard/lists/[listId]/components/ListView.tsx b/packages/web/app/dashboard/lists/[listId]/components/ListView.tsx
index 6489e9f0..979b522f 100644
--- a/packages/web/app/dashboard/lists/[listId]/components/ListView.tsx
+++ b/packages/web/app/dashboard/lists/[listId]/components/ListView.tsx
@@ -1,8 +1,8 @@
"use client";
import BookmarksGrid from "@/app/dashboard/bookmarks/components/BookmarksGrid";
-import { ZBookmark } from "@/lib/types/api/bookmarks";
-import { ZBookmarkListWithBookmarks } from "@/lib/types/api/lists";
+import { ZBookmark } from "@hoarder/trpc/types/bookmarks";
+import { ZBookmarkListWithBookmarks } from "@hoarder/trpc/types/lists";
import { api } from "@/lib/trpc";
export default function ListView({
diff --git a/packages/web/app/dashboard/lists/components/AllListsView.tsx b/packages/web/app/dashboard/lists/components/AllListsView.tsx
index d81f5fca..0e2f898b 100644
--- a/packages/web/app/dashboard/lists/components/AllListsView.tsx
+++ b/packages/web/app/dashboard/lists/components/AllListsView.tsx
@@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button";
import { api } from "@/lib/trpc";
-import { ZBookmarkList } from "@/lib/types/api/lists";
+import { ZBookmarkList } from "@hoarder/trpc/types/lists";
import { keepPreviousData } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import Link from "next/link";
diff --git a/packages/web/app/signin/components/CredentialsForm.tsx b/packages/web/app/signin/components/CredentialsForm.tsx
index f47708f6..5296e163 100644
--- a/packages/web/app/signin/components/CredentialsForm.tsx
+++ b/packages/web/app/signin/components/CredentialsForm.tsx
@@ -13,7 +13,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { ActionButton } from "@/components/ui/action-button";
-import { zSignUpSchema } from "@/lib/types/api/users";
+import { zSignUpSchema } from "@hoarder/trpc/types/users";
import { signIn } from "next-auth/react";
import { useState } from "react";
import { api } from "@/lib/trpc";
diff --git a/packages/web/lib/testUtils.ts b/packages/web/lib/testUtils.ts
deleted file mode 100644
index bad78463..00000000
--- a/packages/web/lib/testUtils.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { users } from "@hoarder/db/schema";
-import { getInMemoryDB } from "@hoarder/db/drizzle";
-import { appRouter } from "@/server/api/routers/_app";
-import { createCallerFactory } from "@/server/api/trpc";
-
-export function getTestDB() {
- return getInMemoryDB(true);
-}
-
-export type TestDB = ReturnType<typeof getTestDB>;
-
-export async function seedUsers(db: TestDB) {
- return await db
- .insert(users)
- .values([
- {
- name: "Test User 1",
- email: "test1@test.com",
- },
- {
- name: "Test User 2",
- email: "test2@test.com",
- },
- ])
- .returning();
-}
-
-export function getApiCaller(db: TestDB, userId?: string) {
- const createCaller = createCallerFactory(appRouter);
- return createCaller({
- user: userId
- ? {
- id: userId,
- role: "user",
- }
- : null,
- db,
- });
-}
-
-export type APICallerType = ReturnType<typeof getApiCaller>;
-
-export interface CustomTestContext {
- apiCallers: APICallerType[];
- unauthedAPICaller: APICallerType;
- db: TestDB;
-}
-
-export async function buildTestContext(
- seedDB: boolean,
-): Promise<CustomTestContext> {
- const db = getTestDB();
- let users: Awaited<ReturnType<typeof seedUsers>> = [];
- if (seedDB) {
- users = await seedUsers(db);
- }
- const callers = users.map((u) => getApiCaller(db, u.id));
-
- return {
- apiCallers: callers,
- unauthedAPICaller: getApiCaller(db),
- db,
- };
-}
-
-export function defaultBeforeEach(seedDB: boolean = true) {
- return async (context: object) => {
- Object.assign(context, await buildTestContext(seedDB));
- };
-}
diff --git a/packages/web/lib/trpc.tsx b/packages/web/lib/trpc.tsx
index aa246047..79a2a9fe 100644
--- a/packages/web/lib/trpc.tsx
+++ b/packages/web/lib/trpc.tsx
@@ -1,5 +1,5 @@
"use client";
-import type { AppRouter } from "@/server/api/routers/_app";
+import type { AppRouter } from "@hoarder/trpc/routers/_app";
import { createTRPCReact } from "@trpc/react-query";
export const api = createTRPCReact<AppRouter>();
diff --git a/packages/web/lib/types/api/bookmarks.ts b/packages/web/lib/types/api/bookmarks.ts
deleted file mode 100644
index 5fabc7ca..00000000
--- a/packages/web/lib/types/api/bookmarks.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { z } from "zod";
-import { zBookmarkTagSchema } from "@/lib/types/api/tags";
-
-export const zBookmarkedLinkSchema = z.object({
- type: z.literal("link"),
- url: z.string().url(),
- title: z.string().nullish(),
- description: z.string().nullish(),
- imageUrl: z.string().url().nullish(),
- favicon: z.string().url().nullish(),
- htmlContent: z.string().nullish(),
- crawledAt: z.date().nullish(),
-});
-export type ZBookmarkedLink = z.infer<typeof zBookmarkedLinkSchema>;
-
-export const zBookmarkedTextSchema = z.object({
- type: z.literal("text"),
- text: z.string().max(2000),
-});
-export type ZBookmarkedText = z.infer<typeof zBookmarkedTextSchema>;
-
-export const zBookmarkContentSchema = z.discriminatedUnion("type", [
- zBookmarkedLinkSchema,
- zBookmarkedTextSchema,
-]);
-export type ZBookmarkContent = z.infer<typeof zBookmarkContentSchema>;
-
-export const zBareBookmarkSchema = z.object({
- id: z.string(),
- createdAt: z.date(),
- archived: z.boolean(),
- favourited: z.boolean(),
- taggingStatus: z.enum(["success", "failure", "pending"]).nullable(),
-});
-
-export const zBookmarkSchema = zBareBookmarkSchema.merge(
- z.object({
- tags: z.array(zBookmarkTagSchema),
- content: zBookmarkContentSchema,
- }),
-);
-export type ZBookmark = z.infer<typeof zBookmarkSchema>;
-
-// POST /v1/bookmarks
-export const zNewBookmarkRequestSchema = zBookmarkContentSchema;
-export type ZNewBookmarkRequest = z.infer<typeof zNewBookmarkRequestSchema>;
-
-// GET /v1/bookmarks
-
-export const zGetBookmarksRequestSchema = z.object({
- ids: z.array(z.string()).optional(),
- archived: z.boolean().optional(),
- favourited: z.boolean().optional(),
-});
-export type ZGetBookmarksRequest = z.infer<typeof zGetBookmarksRequestSchema>;
-
-export const zGetBookmarksResponseSchema = z.object({
- bookmarks: z.array(zBookmarkSchema),
-});
-export type ZGetBookmarksResponse = z.infer<typeof zGetBookmarksResponseSchema>;
-
-// PATCH /v1/bookmarks/[bookmarkId]
-export const zUpdateBookmarksRequestSchema = z.object({
- bookmarkId: z.string(),
- archived: z.boolean().optional(),
- favourited: z.boolean().optional(),
-});
-export type ZUpdateBookmarksRequest = z.infer<
- typeof zUpdateBookmarksRequestSchema
->;
diff --git a/packages/web/lib/types/api/lists.ts b/packages/web/lib/types/api/lists.ts
deleted file mode 100644
index 4b0ccaca..00000000
--- a/packages/web/lib/types/api/lists.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { z } from "zod";
-
-export const zBookmarkListSchema = z.object({
- id: z.string(),
- name: z.string(),
- icon: z.string(),
-});
-
-export const zBookmarkListWithBookmarksSchema = zBookmarkListSchema.merge(
- z.object({
- bookmarks: z.array(z.string()),
- }),
-);
-
-export type ZBookmarkList = z.infer<typeof zBookmarkListSchema>;
-export type ZBookmarkListWithBookmarks = z.infer<
- typeof zBookmarkListWithBookmarksSchema
->;
diff --git a/packages/web/lib/types/api/tags.ts b/packages/web/lib/types/api/tags.ts
deleted file mode 100644
index 7a99dad4..00000000
--- a/packages/web/lib/types/api/tags.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { z } from "zod";
-
-export const zAttachedByEnumSchema = z.enum(["ai", "human"]);
-export type ZAttachedByEnum = z.infer<typeof zAttachedByEnumSchema>;
-export const zBookmarkTagSchema = z.object({
- id: z.string(),
- name: z.string(),
- attachedBy: zAttachedByEnumSchema,
-});
-export type ZBookmarkTags = z.infer<typeof zBookmarkTagSchema>;
diff --git a/packages/web/lib/types/api/users.ts b/packages/web/lib/types/api/users.ts
deleted file mode 100644
index c2fe182a..00000000
--- a/packages/web/lib/types/api/users.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { z } from "zod";
-
-export const zSignUpSchema = z
- .object({
- name: z.string().min(1, { message: "Name can't be empty" }),
- email: z.string().email(),
- password: z.string().min(8),
- confirmPassword: z.string(),
- })
- .refine((data) => data.password === data.confirmPassword, {
- message: "Passwords don't match",
- path: ["confirmPassword"],
- });
diff --git a/packages/web/package.json b/packages/web/package.json
index 5367d189..e0c9d407 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -8,7 +8,8 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
- "test": "vitest"
+ "test": "vitest",
+ "typecheck": "tsc --noEmit"
},
"dependencies": {
"@auth/drizzle-adapter": "^0.8.0",
@@ -16,6 +17,7 @@
"@emoji-mart/react": "^1.1.1",
"@hoarder/db": "0.1.0",
"@hoarder/shared": "0.1.0",
+ "@hoarder/trpc": "0.1.0",
"@hookform/resolvers": "^3.3.4",
"@next/eslint-plugin-next": "^14.1.1",
"@radix-ui/react-dialog": "^1.0.5",
@@ -34,7 +36,6 @@
"@trpc/next": "11.0.0-next-beta.304",
"@trpc/react-query": "^11.0.0-next-beta.304",
"@trpc/server": "11.0.0-next-beta.304",
- "bcrypt": "^5.1.1",
"better-sqlite3": "^9.4.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
@@ -60,7 +61,6 @@
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.10",
- "@types/bcrypt": "^5.0.2",
"@types/emoji-mart": "^3.0.14",
"@types/react": "^18",
"@types/react-dom": "^18",
diff --git a/packages/web/server/api/client.ts b/packages/web/server/api/client.ts
index 130f4f87..88ea7a0e 100644
--- a/packages/web/server/api/client.ts
+++ b/packages/web/server/api/client.ts
@@ -1,6 +1,6 @@
-import { appRouter } from "./routers/_app";
+import { appRouter } from "@hoarder/trpc/routers/_app";
import { getServerAuthSession } from "@/server/auth";
-import { Context, createCallerFactory } from "./trpc";
+import { Context, createCallerFactory } from "@hoarder/trpc";
import { db } from "@hoarder/db";
export const createContext = async (database?: typeof db): Promise<Context> => {
diff --git a/packages/web/server/api/routers/_app.ts b/packages/web/server/api/routers/_app.ts
deleted file mode 100644
index 43ab6f5d..00000000
--- a/packages/web/server/api/routers/_app.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { router } from "../trpc";
-import { adminAppRouter } from "./admin";
-import { apiKeysAppRouter } from "./apiKeys";
-import { bookmarksAppRouter } from "./bookmarks";
-import { listsAppRouter } from "./lists";
-import { usersAppRouter } from "./users";
-export const appRouter = router({
- bookmarks: bookmarksAppRouter,
- apiKeys: apiKeysAppRouter,
- users: usersAppRouter,
- lists: listsAppRouter,
- admin: adminAppRouter,
-});
-// export type definition of API
-export type AppRouter = typeof appRouter;
diff --git a/packages/web/server/api/routers/admin.ts b/packages/web/server/api/routers/admin.ts
deleted file mode 100644
index c3f6235a..00000000
--- a/packages/web/server/api/routers/admin.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { adminProcedure, router } from "../trpc";
-import { z } from "zod";
-import { count } from "drizzle-orm";
-import { bookmarks, users } from "@hoarder/db/schema";
-import {
- LinkCrawlerQueue,
- OpenAIQueue,
- SearchIndexingQueue,
-} from "@hoarder/shared/queues";
-
-export const adminAppRouter = router({
- stats: adminProcedure
- .output(
- z.object({
- numUsers: z.number(),
- numBookmarks: z.number(),
- pendingCrawls: z.number(),
- pendingIndexing: z.number(),
- pendingOpenai: z.number(),
- }),
- )
- .query(async ({ ctx }) => {
- const [
- [{ value: numUsers }],
- [{ value: numBookmarks }],
- pendingCrawls,
- pendingIndexing,
- pendingOpenai,
- ] = await Promise.all([
- ctx.db.select({ value: count() }).from(users),
- ctx.db.select({ value: count() }).from(bookmarks),
- LinkCrawlerQueue.getWaitingCount(),
- SearchIndexingQueue.getWaitingCount(),
- OpenAIQueue.getWaitingCount(),
- ]);
-
- return {
- numUsers,
- numBookmarks,
- pendingCrawls,
- pendingIndexing,
- pendingOpenai,
- };
- }),
- recrawlAllLinks: adminProcedure.mutation(async ({ ctx }) => {
- const bookmarkIds = await ctx.db.query.bookmarkLinks.findMany({
- columns: {
- id: true,
- },
- });
-
- await Promise.all(
- bookmarkIds.map((b) =>
- LinkCrawlerQueue.add("crawl", {
- bookmarkId: b.id,
- }),
- ),
- );
- }),
-
- reindexAllBookmarks: adminProcedure.mutation(async ({ ctx }) => {
- const bookmarkIds = await ctx.db.query.bookmarks.findMany({
- columns: {
- id: true,
- },
- });
-
- await Promise.all(
- bookmarkIds.map((b) =>
- SearchIndexingQueue.add("search_indexing", {
- bookmarkId: b.id,
- type: "index",
- }),
- ),
- );
- }),
-});
diff --git a/packages/web/server/api/routers/apiKeys.ts b/packages/web/server/api/routers/apiKeys.ts
deleted file mode 100644
index 9eb36974..00000000
--- a/packages/web/server/api/routers/apiKeys.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { generateApiKey } from "@/server/auth";
-import { authedProcedure, router } from "../trpc";
-import { z } from "zod";
-import { apiKeys } from "@hoarder/db/schema";
-import { eq, and } from "drizzle-orm";
-
-export const apiKeysAppRouter = router({
- create: authedProcedure
- .input(
- z.object({
- name: z.string(),
- }),
- )
- .output(
- z.object({
- id: z.string(),
- name: z.string(),
- key: z.string(),
- createdAt: z.date(),
- }),
- )
- .mutation(async ({ input, ctx }) => {
- return await generateApiKey(input.name, ctx.user.id);
- }),
- revoke: authedProcedure
- .input(
- z.object({
- id: z.string(),
- }),
- )
- .mutation(async ({ input, ctx }) => {
- await ctx.db
- .delete(apiKeys)
- .where(and(eq(apiKeys.id, input.id), eq(apiKeys.userId, ctx.user.id)));
- }),
- list: authedProcedure
- .output(
- z.object({
- keys: z.array(
- z.object({
- id: z.string(),
- name: z.string(),
- createdAt: z.date(),
- keyId: z.string(),
- }),
- ),
- }),
- )
- .query(async ({ ctx }) => {
- const resp = await ctx.db.query.apiKeys.findMany({
- where: eq(apiKeys.userId, ctx.user.id),
- columns: {
- id: true,
- name: true,
- createdAt: true,
- keyId: true,
- },
- });
- return { keys: resp };
- }),
-});
diff --git a/packages/web/server/api/routers/bookmarks.test.ts b/packages/web/server/api/routers/bookmarks.test.ts
deleted file mode 100644
index 626a7250..00000000
--- a/packages/web/server/api/routers/bookmarks.test.ts
+++ /dev/null
@@ -1,200 +0,0 @@
-import { CustomTestContext, defaultBeforeEach } from "@/lib/testUtils";
-import { expect, describe, test, beforeEach, assert } from "vitest";
-
-beforeEach<CustomTestContext>(defaultBeforeEach(true));
-
-describe("Bookmark Routes", () => {
- test<CustomTestContext>("create bookmark", async ({ apiCallers }) => {
- const api = apiCallers[0].bookmarks;
- const bookmark = await api.createBookmark({
- url: "https://google.com",
- type: "link",
- });
-
- const res = await api.getBookmark({ bookmarkId: bookmark.id });
- assert(res.content.type == "link");
- expect(res.content.url).toEqual("https://google.com");
- expect(res.favourited).toEqual(false);
- expect(res.archived).toEqual(false);
- expect(res.content.type).toEqual("link");
- });
-
- test<CustomTestContext>("delete bookmark", async ({ apiCallers }) => {
- const api = apiCallers[0].bookmarks;
-
- // Create the bookmark
- const bookmark = await api.createBookmark({
- url: "https://google.com",
- type: "link",
- });
-
- // It should exist
- await api.getBookmark({ bookmarkId: bookmark.id });
-
- // Delete it
- await api.deleteBookmark({ bookmarkId: bookmark.id });
-
- // It shouldn't be there anymore
- await expect(() =>
- api.getBookmark({ bookmarkId: bookmark.id }),
- ).rejects.toThrow(/Bookmark not found/);
- });
-
- test<CustomTestContext>("update bookmark", async ({ apiCallers }) => {
- const api = apiCallers[0].bookmarks;
-
- // Create the bookmark
- const bookmark = await api.createBookmark({
- url: "https://google.com",
- type: "link",
- });
-
- await api.updateBookmark({
- bookmarkId: bookmark.id,
- archived: true,
- favourited: true,
- });
-
- const res = await api.getBookmark({ bookmarkId: bookmark.id });
- expect(res.archived).toBeTruthy();
- expect(res.favourited).toBeTruthy();
- });
-
- test<CustomTestContext>("list bookmarks", async ({ apiCallers }) => {
- const api = apiCallers[0].bookmarks;
- const emptyBookmarks = await api.getBookmarks({});
- expect(emptyBookmarks.bookmarks.length).toEqual(0);
-
- const bookmark1 = await api.createBookmark({
- url: "https://google.com",
- type: "link",
- });
-
- const bookmark2 = await api.createBookmark({
- url: "https://google2.com",
- type: "link",
- });
-
- {
- const bookmarks = await api.getBookmarks({});
- expect(bookmarks.bookmarks.length).toEqual(2);
- }
-
- // Archive and favourite bookmark1
- await api.updateBookmark({
- bookmarkId: bookmark1.id,
- archived: true,
- favourited: true,
- });
-
- {
- const bookmarks = await api.getBookmarks({ archived: false });
- expect(bookmarks.bookmarks.length).toEqual(1);
- expect(bookmarks.bookmarks[0].id).toEqual(bookmark2.id);
- }
-
- {
- const bookmarks = await api.getBookmarks({ favourited: true });
- expect(bookmarks.bookmarks.length).toEqual(1);
- expect(bookmarks.bookmarks[0].id).toEqual(bookmark1.id);
- }
-
- {
- const bookmarks = await api.getBookmarks({ archived: true });
- expect(bookmarks.bookmarks.length).toEqual(1);
- expect(bookmarks.bookmarks[0].id).toEqual(bookmark1.id);
- }
-
- {
- const bookmarks = await api.getBookmarks({ ids: [bookmark1.id] });
- expect(bookmarks.bookmarks.length).toEqual(1);
- expect(bookmarks.bookmarks[0].id).toEqual(bookmark1.id);
- }
- });
-
- test<CustomTestContext>("update tags", async ({ apiCallers }) => {
- const api = apiCallers[0].bookmarks;
- let bookmark = await api.createBookmark({
- url: "https://google.com",
- type: "link",
- });
-
- await api.updateTags({
- bookmarkId: bookmark.id,
- attach: [{ tag: "tag1" }, { tag: "tag2" }],
- detach: [],
- });
-
- bookmark = await api.getBookmark({ bookmarkId: bookmark.id });
- expect(bookmark.tags.map((t) => t.name).sort()).toEqual(["tag1", "tag2"]);
-
- const tag1Id = bookmark.tags.filter((t) => t.name == "tag1")[0].id;
-
- await api.updateTags({
- bookmarkId: bookmark.id,
- attach: [{ tag: "tag3" }],
- detach: [{ tagId: tag1Id }],
- });
-
- bookmark = await api.getBookmark({ bookmarkId: bookmark.id });
- expect(bookmark.tags.map((t) => t.name).sort()).toEqual(["tag2", "tag3"]);
- });
-
- test<CustomTestContext>("update bookmark text", async ({ apiCallers }) => {
- const api = apiCallers[0].bookmarks;
- let bookmark = await api.createBookmark({
- text: "HELLO WORLD",
- type: "text",
- });
-
- await api.updateBookmarkText({
- bookmarkId: bookmark.id,
- text: "WORLD HELLO",
- });
-
- bookmark = await api.getBookmark({ bookmarkId: bookmark.id });
- assert(bookmark.content.type == "text");
- expect(bookmark.content.text).toEqual("WORLD HELLO");
- });
-
- test<CustomTestContext>("privacy", async ({ apiCallers }) => {
- const user1Bookmark = await apiCallers[0].bookmarks.createBookmark({
- type: "link",
- url: "https://google.com",
- });
- const user2Bookmark = await apiCallers[1].bookmarks.createBookmark({
- type: "link",
- url: "https://google.com",
- });
-
- // All interactions with the wrong user should fail
- await expect(() =>
- apiCallers[0].bookmarks.deleteBookmark({ bookmarkId: user2Bookmark.id }),
- ).rejects.toThrow(/User is not allowed to access resource/);
- await expect(() =>
- apiCallers[0].bookmarks.getBookmark({ bookmarkId: user2Bookmark.id }),
- ).rejects.toThrow(/User is not allowed to access resource/);
- await expect(() =>
- apiCallers[0].bookmarks.updateBookmark({ bookmarkId: user2Bookmark.id }),
- ).rejects.toThrow(/User is not allowed to access resource/);
- await expect(() =>
- apiCallers[0].bookmarks.updateTags({
- bookmarkId: user2Bookmark.id,
- attach: [],
- detach: [],
- }),
- ).rejects.toThrow(/User is not allowed to access resource/);
-
- // Get bookmarks should only show the correct one
- expect(
- (await apiCallers[0].bookmarks.getBookmarks({})).bookmarks.map(
- (b) => b.id,
- ),
- ).toEqual([user1Bookmark.id]);
- expect(
- (await apiCallers[1].bookmarks.getBookmarks({})).bookmarks.map(
- (b) => b.id,
- ),
- ).toEqual([user2Bookmark.id]);
- });
-});
diff --git a/packages/web/server/api/routers/bookmarks.ts b/packages/web/server/api/routers/bookmarks.ts
deleted file mode 100644
index 73818508..00000000
--- a/packages/web/server/api/routers/bookmarks.ts
+++ /dev/null
@@ -1,454 +0,0 @@
-import { z } from "zod";
-import { Context, authedProcedure, router } from "../trpc";
-import { getSearchIdxClient } from "@hoarder/shared/search";
-import {
- ZBookmark,
- ZBookmarkContent,
- zBareBookmarkSchema,
- zBookmarkSchema,
- zGetBookmarksRequestSchema,
- zGetBookmarksResponseSchema,
- zNewBookmarkRequestSchema,
- zUpdateBookmarksRequestSchema,
-} from "@/lib/types/api/bookmarks";
-import {
- bookmarkLinks,
- bookmarkTags,
- bookmarkTexts,
- bookmarks,
- tagsOnBookmarks,
-} from "@hoarder/db/schema";
-import {
- LinkCrawlerQueue,
- OpenAIQueue,
- SearchIndexingQueue,
-} from "@hoarder/shared/queues";
-import { TRPCError, experimental_trpcMiddleware } from "@trpc/server";
-import { and, desc, eq, inArray } from "drizzle-orm";
-import { ZBookmarkTags } from "@/lib/types/api/tags";
-
-import { db as DONT_USE_db } from "@hoarder/db";
-
-const ensureBookmarkOwnership = experimental_trpcMiddleware<{
- ctx: Context;
- input: { bookmarkId: string };
-}>().create(async (opts) => {
- const bookmark = await opts.ctx.db.query.bookmarks.findFirst({
- where: eq(bookmarks.id, opts.input.bookmarkId),
- columns: {
- userId: true,
- },
- });
- if (!opts.ctx.user) {
- throw new TRPCError({
- code: "UNAUTHORIZED",
- message: "User is not authorized",
- });
- }
- if (!bookmark) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "Bookmark not found",
- });
- }
- if (bookmark.userId != opts.ctx.user.id) {
- throw new TRPCError({
- code: "FORBIDDEN",
- message: "User is not allowed to access resource",
- });
- }
-
- return opts.next();
-});
-
-async function dummyDrizzleReturnType() {
- const x = await DONT_USE_db.query.bookmarks.findFirst({
- with: {
- tagsOnBookmarks: {
- with: {
- tag: true,
- },
- },
- link: true,
- text: true,
- },
- });
- if (!x) {
- throw new Error();
- }
- return x;
-}
-
-function toZodSchema(
- bookmark: Awaited<ReturnType<typeof dummyDrizzleReturnType>>,
-): ZBookmark {
- const { tagsOnBookmarks, link, text, ...rest } = bookmark;
-
- let content: ZBookmarkContent;
- if (link) {
- content = { type: "link", ...link };
- } else if (text) {
- content = { type: "text", text: text.text || "" };
- } else {
- throw new Error("Unknown content type");
- }
-
- return {
- tags: tagsOnBookmarks.map((t) => ({
- attachedBy: t.attachedBy,
- ...t.tag,
- })),
- content,
- ...rest,
- };
-}
-
-export const bookmarksAppRouter = router({
- createBookmark: authedProcedure
- .input(zNewBookmarkRequestSchema)
- .output(zBookmarkSchema)
- .mutation(async ({ input, ctx }) => {
- const bookmark = await ctx.db.transaction(
- async (tx): Promise<ZBookmark> => {
- const bookmark = (
- await tx
- .insert(bookmarks)
- .values({
- userId: ctx.user.id,
- })
- .returning()
- )[0];
-
- let content: ZBookmarkContent;
-
- switch (input.type) {
- case "link": {
- const link = (
- await tx
- .insert(bookmarkLinks)
- .values({
- id: bookmark.id,
- url: input.url,
- })
- .returning()
- )[0];
- content = {
- type: "link",
- ...link,
- };
- break;
- }
- case "text": {
- const text = (
- await tx
- .insert(bookmarkTexts)
- .values({ id: bookmark.id, text: input.text })
- .returning()
- )[0];
- content = {
- type: "text",
- text: text.text || "",
- };
- break;
- }
- }
-
- return {
- tags: [] as ZBookmarkTags[],
- content,
- ...bookmark,
- };
- },
- );
-
- // Enqueue crawling request
- switch (bookmark.content.type) {
- case "link": {
- // The crawling job triggers openai when it's done
- await LinkCrawlerQueue.add("crawl", {
- bookmarkId: bookmark.id,
- });
- break;
- }
- case "text": {
- await OpenAIQueue.add("openai", {
- bookmarkId: bookmark.id,
- });
- break;
- }
- }
- SearchIndexingQueue.add("search_indexing", {
- bookmarkId: bookmark.id,
- type: "index",
- });
- return bookmark;
- }),
-
- updateBookmark: authedProcedure
- .input(zUpdateBookmarksRequestSchema)
- .output(zBareBookmarkSchema)
- .use(ensureBookmarkOwnership)
- .mutation(async ({ input, ctx }) => {
- const res = await ctx.db
- .update(bookmarks)
- .set({
- archived: input.archived,
- favourited: input.favourited,
- })
- .where(
- and(
- eq(bookmarks.userId, ctx.user.id),
- eq(bookmarks.id, input.bookmarkId),
- ),
- )
- .returning();
- if (res.length == 0) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "Bookmark not found",
- });
- }
- return res[0];
- }),
-
- updateBookmarkText: authedProcedure
- .input(
- z.object({
- bookmarkId: z.string(),
- text: z.string().max(2000),
- }),
- )
- .use(ensureBookmarkOwnership)
- .mutation(async ({ input, ctx }) => {
- const res = await ctx.db
- .update(bookmarkTexts)
- .set({
- text: input.text,
- })
- .where(and(eq(bookmarkTexts.id, input.bookmarkId)))
- .returning();
- if (res.length == 0) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "Bookmark not found",
- });
- }
- SearchIndexingQueue.add("search_indexing", {
- bookmarkId: input.bookmarkId,
- type: "index",
- });
- }),
-
- deleteBookmark: authedProcedure
- .input(z.object({ bookmarkId: z.string() }))
- .use(ensureBookmarkOwnership)
- .mutation(async ({ input, ctx }) => {
- await ctx.db
- .delete(bookmarks)
- .where(
- and(
- eq(bookmarks.userId, ctx.user.id),
- eq(bookmarks.id, input.bookmarkId),
- ),
- );
- SearchIndexingQueue.add("search_indexing", {
- bookmarkId: input.bookmarkId,
- type: "delete",
- });
- }),
- recrawlBookmark: authedProcedure
- .input(z.object({ bookmarkId: z.string() }))
- .use(ensureBookmarkOwnership)
- .mutation(async ({ input }) => {
- await LinkCrawlerQueue.add("crawl", {
- bookmarkId: input.bookmarkId,
- });
- }),
- getBookmark: authedProcedure
- .input(
- z.object({
- bookmarkId: z.string(),
- }),
- )
- .output(zBookmarkSchema)
- .use(ensureBookmarkOwnership)
- .query(async ({ input, ctx }) => {
- const bookmark = await ctx.db.query.bookmarks.findFirst({
- where: and(
- eq(bookmarks.userId, ctx.user.id),
- eq(bookmarks.id, input.bookmarkId),
- ),
- with: {
- tagsOnBookmarks: {
- with: {
- tag: true,
- },
- },
- link: true,
- text: true,
- },
- });
- if (!bookmark) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "Bookmark not found",
- });
- }
-
- return toZodSchema(bookmark);
- }),
- searchBookmarks: authedProcedure
- .input(
- z.object({
- text: z.string(),
- }),
- )
- .output(zGetBookmarksResponseSchema)
- .query(async ({ input, ctx }) => {
- const client = await getSearchIdxClient();
- if (!client) {
- throw new TRPCError({
- code: "INTERNAL_SERVER_ERROR",
- message: "Search functionality is not configured",
- });
- }
- const resp = await client.search(input.text, {
- filter: [`userId = '${ctx.user.id}'`],
- });
-
- if (resp.hits.length == 0) {
- return { bookmarks: [] };
- }
- const results = await ctx.db.query.bookmarks.findMany({
- where: and(
- eq(bookmarks.userId, ctx.user.id),
- inArray(
- bookmarks.id,
- resp.hits.map((h) => h.id),
- ),
- ),
- with: {
- tagsOnBookmarks: {
- with: {
- tag: true,
- },
- },
- link: true,
- text: true,
- },
- });
-
- return { bookmarks: results.map(toZodSchema) };
- }),
- getBookmarks: authedProcedure
- .input(zGetBookmarksRequestSchema)
- .output(zGetBookmarksResponseSchema)
- .query(async ({ input, ctx }) => {
- if (input.ids && input.ids.length == 0) {
- return { bookmarks: [] };
- }
- const results = await ctx.db.query.bookmarks.findMany({
- where: and(
- eq(bookmarks.userId, ctx.user.id),
- input.archived !== undefined
- ? eq(bookmarks.archived, input.archived)
- : undefined,
- input.favourited !== undefined
- ? eq(bookmarks.favourited, input.favourited)
- : undefined,
- input.ids ? inArray(bookmarks.id, input.ids) : undefined,
- ),
- orderBy: [desc(bookmarks.createdAt)],
- with: {
- tagsOnBookmarks: {
- with: {
- tag: true,
- },
- },
- link: true,
- text: true,
- },
- });
-
- return { bookmarks: results.map(toZodSchema) };
- }),
-
- updateTags: authedProcedure
- .input(
- z.object({
- bookmarkId: z.string(),
- attach: z.array(
- z.object({
- tagId: z.string().optional(), // If the tag already exists and we know its id
- tag: z.string(),
- }),
- ),
- // Detach by tag ids
- detach: z.array(z.object({ tagId: z.string() })),
- }),
- )
- .use(ensureBookmarkOwnership)
- .mutation(async ({ input, ctx }) => {
- await ctx.db.transaction(async (tx) => {
- // Detaches
- if (input.detach.length > 0) {
- await tx.delete(tagsOnBookmarks).where(
- and(
- eq(tagsOnBookmarks.bookmarkId, input.bookmarkId),
- inArray(
- tagsOnBookmarks.tagId,
- input.detach.map((t) => t.tagId),
- ),
- ),
- );
- }
-
- if (input.attach.length == 0) {
- return;
- }
-
- // New Tags
- const toBeCreatedTags = input.attach
- .filter((i) => i.tagId === undefined)
- .map((i) => ({
- name: i.tag,
- userId: ctx.user.id,
- }));
-
- if (toBeCreatedTags.length > 0) {
- await tx
- .insert(bookmarkTags)
- .values(toBeCreatedTags)
- .onConflictDoNothing()
- .returning();
- }
-
- const allIds = (
- await tx.query.bookmarkTags.findMany({
- where: and(
- eq(bookmarkTags.userId, ctx.user.id),
- inArray(
- bookmarkTags.name,
- input.attach.map((t) => t.tag),
- ),
- ),
- columns: {
- id: true,
- },
- })
- ).map((t) => t.id);
-
- await tx
- .insert(tagsOnBookmarks)
- .values(
- allIds.map((i) => ({
- tagId: i as string,
- bookmarkId: input.bookmarkId,
- attachedBy: "human" as const,
- userId: ctx.user.id,
- })),
- )
- .onConflictDoNothing();
- });
- }),
-});
diff --git a/packages/web/server/api/routers/lists.ts b/packages/web/server/api/routers/lists.ts
deleted file mode 100644
index 7bf5eed5..00000000
--- a/packages/web/server/api/routers/lists.ts
+++ /dev/null
@@ -1,173 +0,0 @@
-import { Context, authedProcedure, router } from "../trpc";
-import { SqliteError } from "@hoarder/db";
-import { z } from "zod";
-import { TRPCError, experimental_trpcMiddleware } from "@trpc/server";
-import { bookmarkLists, bookmarksInLists } from "@hoarder/db/schema";
-import { and, eq } from "drizzle-orm";
-import { zBookmarkListSchema } from "@/lib/types/api/lists";
-
-const ensureListOwnership = experimental_trpcMiddleware<{
- ctx: Context;
- input: { listId: string };
-}>().create(async (opts) => {
- const list = await opts.ctx.db.query.bookmarkLists.findFirst({
- where: eq(bookmarkLists.id, opts.input.listId),
- columns: {
- userId: true,
- },
- });
- if (!opts.ctx.user) {
- throw new TRPCError({
- code: "UNAUTHORIZED",
- message: "User is not authorized",
- });
- }
- if (!list) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "List not found",
- });
- }
- if (list.userId != opts.ctx.user.id) {
- throw new TRPCError({
- code: "FORBIDDEN",
- message: "User is not allowed to access resource",
- });
- }
-
- return opts.next();
-});
-
-export const listsAppRouter = router({
- create: authedProcedure
- .input(
- z.object({
- name: z.string().min(1).max(20),
- icon: z.string(),
- }),
- )
- .output(zBookmarkListSchema)
- .mutation(async ({ input, ctx }) => {
- try {
- const result = await ctx.db
- .insert(bookmarkLists)
- .values({
- name: input.name,
- icon: input.icon,
- userId: ctx.user.id,
- })
- .returning();
- return result[0];
- } catch (e) {
- if (e instanceof SqliteError) {
- if (e.code == "SQLITE_CONSTRAINT_UNIQUE") {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "List already exists",
- });
- }
- }
- throw new TRPCError({
- code: "INTERNAL_SERVER_ERROR",
- message: "Something went wrong",
- });
- }
- }),
- delete: authedProcedure
- .input(
- z.object({
- listId: z.string(),
- }),
- )
- .use(ensureListOwnership)
- .mutation(async ({ input, ctx }) => {
- const res = await ctx.db
- .delete(bookmarkLists)
- .where(
- and(
- eq(bookmarkLists.id, input.listId),
- eq(bookmarkLists.userId, ctx.user.id),
- ),
- );
- if (res.changes == 0) {
- throw new TRPCError({ code: "NOT_FOUND" });
- }
- }),
- addToList: authedProcedure
- .input(
- z.object({
- listId: z.string(),
- bookmarkId: z.string(),
- }),
- )
- .use(ensureListOwnership)
- .mutation(async ({ input, ctx }) => {
- try {
- await ctx.db.insert(bookmarksInLists).values({
- listId: input.listId,
- bookmarkId: input.bookmarkId,
- });
- } catch (e) {
- if (e instanceof SqliteError) {
- if (e.code == "SQLITE_CONSTRAINT_PRIMARYKEY") {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Bookmark already in the list",
- });
- }
- }
- throw new TRPCError({
- code: "INTERNAL_SERVER_ERROR",
- message: "Something went wrong",
- });
- }
- }),
- get: authedProcedure
- .input(
- z.object({
- listId: z.string(),
- }),
- )
- .output(
- zBookmarkListSchema.merge(
- z.object({
- bookmarks: z.array(z.string()),
- }),
- ),
- )
- .use(ensureListOwnership)
- .query(async ({ input, ctx }) => {
- const res = await ctx.db.query.bookmarkLists.findFirst({
- where: and(
- eq(bookmarkLists.id, input.listId),
- eq(bookmarkLists.userId, ctx.user.id),
- ),
- with: {
- bookmarksInLists: true,
- },
- });
- if (!res) {
- throw new TRPCError({ code: "NOT_FOUND" });
- }
-
- return {
- id: res.id,
- name: res.name,
- icon: res.icon,
- bookmarks: res.bookmarksInLists.map((b) => b.bookmarkId),
- };
- }),
- list: authedProcedure
- .output(
- z.object({
- lists: z.array(zBookmarkListSchema),
- }),
- )
- .query(async ({ ctx }) => {
- const lists = await ctx.db.query.bookmarkLists.findMany({
- where: and(eq(bookmarkLists.userId, ctx.user.id)),
- });
-
- return { lists };
- }),
-});
diff --git a/packages/web/server/api/routers/users.test.ts b/packages/web/server/api/routers/users.test.ts
deleted file mode 100644
index 1ee04f99..00000000
--- a/packages/web/server/api/routers/users.test.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import {
- CustomTestContext,
- defaultBeforeEach,
- getApiCaller,
-} from "@/lib/testUtils";
-import { expect, describe, test, beforeEach, assert } from "vitest";
-
-beforeEach<CustomTestContext>(defaultBeforeEach(false));
-
-describe("User Routes", () => {
- test<CustomTestContext>("create user", async ({ unauthedAPICaller }) => {
- const user = await unauthedAPICaller.users.create({
- name: "Test User",
- email: "test123@test.com",
- password: "pass1234",
- confirmPassword: "pass1234",
- });
-
- expect(user.name).toEqual("Test User");
- expect(user.email).toEqual("test123@test.com");
- });
-
- test<CustomTestContext>("first user is admin", async ({
- unauthedAPICaller,
- }) => {
- const user1 = await unauthedAPICaller.users.create({
- name: "Test User",
- email: "test123@test.com",
- password: "pass1234",
- confirmPassword: "pass1234",
- });
-
- const user2 = await unauthedAPICaller.users.create({
- name: "Test User",
- email: "test124@test.com",
- password: "pass1234",
- confirmPassword: "pass1234",
- });
-
- expect(user1.role).toEqual("admin");
- expect(user2.role).toEqual("user");
- });
-
- test<CustomTestContext>("unique emails", async ({ unauthedAPICaller }) => {
- await unauthedAPICaller.users.create({
- name: "Test User",
- email: "test123@test.com",
- password: "pass1234",
- confirmPassword: "pass1234",
- });
-
- await expect(() =>
- unauthedAPICaller.users.create({
- name: "Test User",
- email: "test123@test.com",
- password: "pass1234",
- confirmPassword: "pass1234",
- }),
- ).rejects.toThrow(/Email is already taken/);
- });
-
- test<CustomTestContext>("privacy checks", async ({
- db,
- unauthedAPICaller,
- }) => {
- const adminUser = await unauthedAPICaller.users.create({
- name: "Test User",
- email: "test123@test.com",
- password: "pass1234",
- confirmPassword: "pass1234",
- });
- const [user1, user2] = await Promise.all(
- ["test1234@test.com", "test12345@test.com"].map((e) =>
- unauthedAPICaller.users.create({
- name: "Test User",
- email: e,
- password: "pass1234",
- confirmPassword: "pass1234",
- }),
- ),
- );
-
- assert(adminUser.role == "admin");
- assert(user1.role == "user");
- assert(user2.role == "user");
-
- const user2Caller = getApiCaller(db, user2.id);
-
- // A normal user can't delete other users
- await expect(() =>
- user2Caller.users.delete({
- userId: user1.id,
- }),
- ).rejects.toThrow(/FORBIDDEN/);
-
- // A normal user can't list all users
- await expect(() => user2Caller.users.list()).rejects.toThrow(/FORBIDDEN/);
- });
-});
diff --git a/packages/web/server/api/routers/users.ts b/packages/web/server/api/routers/users.ts
deleted file mode 100644
index 32d10860..00000000
--- a/packages/web/server/api/routers/users.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { zSignUpSchema } from "@/lib/types/api/users";
-import { adminProcedure, publicProcedure, router } from "../trpc";
-import { SqliteError } from "@hoarder/db";
-import { z } from "zod";
-import { hashPassword } from "@/server/auth";
-import { TRPCError } from "@trpc/server";
-import { users } from "@hoarder/db/schema";
-import { count, eq } from "drizzle-orm";
-
-export const usersAppRouter = router({
- create: publicProcedure
- .input(zSignUpSchema)
- .output(
- z.object({
- id: z.string(),
- name: z.string(),
- email: z.string(),
- role: z.enum(["user", "admin"]).nullable(),
- }),
- )
- .mutation(async ({ input, ctx }) => {
- // TODO: This is racy, but that's probably fine.
- const [{ count: userCount }] = await ctx.db
- .select({ count: count() })
- .from(users);
- try {
- const result = await ctx.db
- .insert(users)
- .values({
- name: input.name,
- email: input.email,
- password: await hashPassword(input.password),
- role: userCount == 0 ? "admin" : "user",
- })
- .returning({
- id: users.id,
- name: users.name,
- email: users.email,
- role: users.role,
- });
- return result[0];
- } catch (e) {
- if (e instanceof SqliteError) {
- if (e.code == "SQLITE_CONSTRAINT_UNIQUE") {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Email is already taken",
- });
- }
- }
- throw new TRPCError({
- code: "INTERNAL_SERVER_ERROR",
- message: "Something went wrong",
- });
- }
- }),
- list: adminProcedure
- .output(
- z.object({
- users: z.array(
- z.object({
- id: z.string(),
- name: z.string(),
- email: z.string(),
- role: z.enum(["user", "admin"]).nullable(),
- }),
- ),
- }),
- )
- .query(async ({ ctx }) => {
- const users = await ctx.db.query.users.findMany({
- columns: {
- id: true,
- name: true,
- email: true,
- role: true,
- },
- });
- return { users };
- }),
- delete: adminProcedure
- .input(
- z.object({
- userId: z.string(),
- }),
- )
- .mutation(async ({ input, ctx }) => {
- const res = await ctx.db.delete(users).where(eq(users.id, input.userId));
- if (res.changes == 0) {
- throw new TRPCError({ code: "NOT_FOUND" });
- }
- }),
-});
diff --git a/packages/web/server/api/trpc.ts b/packages/web/server/api/trpc.ts
deleted file mode 100644
index 0ba09e94..00000000
--- a/packages/web/server/api/trpc.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { db } from "@hoarder/db";
-import serverConfig from "@hoarder/shared/config";
-import { TRPCError, initTRPC } from "@trpc/server";
-import { User } from "next-auth";
-import superjson from "superjson";
-
-export type Context = {
- user: User | null;
- db: typeof db;
-};
-
-// Avoid exporting the entire t-object
-// since it's not very descriptive.
-// For instance, the use of a t variable
-// is common in i18n libraries.
-const t = initTRPC.context<Context>().create({
- transformer: superjson,
-});
-export const createCallerFactory = t.createCallerFactory;
-// Base router and procedure helpers
-export const router = t.router;
-export const procedure = t.procedure.use(function isDemoMode(opts) {
- if (serverConfig.demoMode && opts.type == "mutation") {
- throw new TRPCError({
- message: "Mutations are not allowed in demo mode",
- code: "FORBIDDEN",
- });
- }
- return opts.next();
-});
-export const publicProcedure = procedure;
-
-export const authedProcedure = procedure.use(function isAuthed(opts) {
- const user = opts.ctx.user;
-
- if (!user || !user.id) {
- throw new TRPCError({ code: "UNAUTHORIZED" });
- }
-
- return opts.next({
- ctx: {
- user,
- },
- });
-});
-
-export const adminProcedure = authedProcedure.use(function isAdmin(opts) {
- const user = opts.ctx.user;
- if (user.role != "admin") {
- throw new TRPCError({ code: "FORBIDDEN" });
- }
- return opts.next(opts);
-});
diff --git a/packages/web/server/auth.ts b/packages/web/server/auth.ts
index 1810c87d..950443b9 100644
--- a/packages/web/server/auth.ts
+++ b/packages/web/server/auth.ts
@@ -2,15 +2,13 @@ import NextAuth, { NextAuthOptions, getServerSession } from "next-auth";
import type { Adapter } from "next-auth/adapters";
import AuthentikProvider from "next-auth/providers/authentik";
import serverConfig from "@hoarder/shared/config";
+import { validatePassword } from "@hoarder/trpc/auth";
import { db } from "@hoarder/db";
import { DefaultSession } from "next-auth";
-import * as bcrypt from "bcrypt";
import CredentialsProvider from "next-auth/providers/credentials";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
-import { randomBytes } from "crypto";
import { Provider } from "next-auth/providers/index";
-import { apiKeys } from "@hoarder/db/schema";
declare module "next-auth/jwt" {
export interface JWT {
@@ -96,99 +94,3 @@ export const authOptions: NextAuthOptions = {
export const authHandler = NextAuth(authOptions);
export const getServerAuthSession = () => getServerSession(authOptions);
-
-// API Keys
-
-const BCRYPT_SALT_ROUNDS = 10;
-const API_KEY_PREFIX = "ak1";
-
-export async function generateApiKey(name: string, userId: string) {
- const id = randomBytes(10).toString("hex");
- const secret = randomBytes(10).toString("hex");
- const secretHash = await bcrypt.hash(secret, BCRYPT_SALT_ROUNDS);
-
- const plain = `${API_KEY_PREFIX}_${id}_${secret}`;
-
- const key = (
- await db
- .insert(apiKeys)
- .values({
- name: name,
- userId: userId,
- keyId: id,
- keyHash: secretHash,
- })
- .returning()
- )[0];
-
- return {
- id: key.id,
- name: key.name,
- createdAt: key.createdAt,
- key: plain,
- };
-}
-
-function parseApiKey(plain: string) {
- const parts = plain.split("_");
- if (parts.length != 3) {
- throw new Error(
- `Malformd API key. API keys should have 3 segments, found ${parts.length} instead.`,
- );
- }
- if (parts[0] !== API_KEY_PREFIX) {
- throw new Error(`Malformd API key. Got unexpected key prefix.`);
- }
- return {
- keyId: parts[1],
- keySecret: parts[2],
- };
-}
-
-export async function authenticateApiKey(key: string) {
- const { keyId, keySecret } = parseApiKey(key);
- const apiKey = await db.query.apiKeys.findFirst({
- where: (k, { eq }) => eq(k.keyId, keyId),
- with: {
- user: true,
- },
- });
-
- if (!apiKey) {
- throw new Error("API key not found");
- }
-
- const hash = apiKey.keyHash;
-
- const validation = await bcrypt.compare(keySecret, hash);
- if (!validation) {
- throw new Error("Invalid API Key");
- }
-
- return apiKey.user;
-}
-
-export async function hashPassword(password: string) {
- return bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
-}
-
-export async function validatePassword(email: string, password: string) {
- const user = await db.query.users.findFirst({
- where: (u, { eq }) => eq(u.email, email),
- });
-
- if (!user) {
- throw new Error("User not found");
- }
-
- if (!user.password) {
- throw new Error("This user doesn't have a password defined");
- }
-
- const validation = await bcrypt.compare(password, user.password);
- if (!validation) {
- throw new Error("Wrong password");
- }
-
- return user;
-}