aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMohamedBassem <me@mbassem.com>2024-02-12 14:52:00 +0000
committerMohamedBassem <me@mbassem.com>2024-02-12 14:55:00 +0000
commit6aacc0c7a86e36c52a3c2c1d26fe58cefcd3bec4 (patch)
treebad306e872d6bfcc2c67f00caa3880c8aa56070f
parent230cafb6dfc8d3bad57d84ef13c3669f5bf5331a (diff)
downloadkarakeep-6aacc0c7a86e36c52a3c2c1d26fe58cefcd3bec4.tar.zst
feature: Add support for managing API keys
-rw-r--r--packages/db/prisma/migrations/20240211184744_add_api_key/migration.sql16
-rw-r--r--packages/db/prisma/schema.prisma13
-rw-r--r--packages/web/app/dashboard/components/Sidebar.tsx15
-rw-r--r--packages/web/app/dashboard/settings/components/AddApiKey.tsx148
-rw-r--r--packages/web/app/dashboard/settings/components/ApiKeySettings.tsx49
-rw-r--r--packages/web/app/dashboard/settings/components/DeleteApiKey.tsx65
-rw-r--r--packages/web/app/dashboard/settings/page.tsx10
-rw-r--r--packages/web/components/ui/dialog.tsx122
-rw-r--r--packages/web/components/ui/table.tsx117
-rw-r--r--packages/web/package.json3
-rw-r--r--packages/web/server/api/routers/_app.ts2
-rw-r--r--packages/web/server/api/routers/apiKeys.ts67
-rw-r--r--packages/web/server/auth.ts75
-rw-r--r--packages/workers/openai.ts8
-rw-r--r--yarn.lock239
15 files changed, 944 insertions, 5 deletions
diff --git a/packages/db/prisma/migrations/20240211184744_add_api_key/migration.sql b/packages/db/prisma/migrations/20240211184744_add_api_key/migration.sql
new file mode 100644
index 00000000..c39bf511
--- /dev/null
+++ b/packages/db/prisma/migrations/20240211184744_add_api_key/migration.sql
@@ -0,0 +1,16 @@
+-- CreateTable
+CREATE TABLE "ApiKey" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "name" TEXT NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "keyId" TEXT NOT NULL,
+ "keyHash" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "ApiKey_keyId_key" ON "ApiKey"("keyId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "ApiKey_name_userId_key" ON "ApiKey"("name", "userId");
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 623a9f13..5c575c97 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -47,6 +47,7 @@ model User {
sessions Session[]
tags BookmarkTags[]
bookmarks Bookmark[]
+ apiKeys ApiKey[]
}
model VerificationToken {
@@ -57,6 +58,18 @@ model VerificationToken {
@@unique([identifier, token])
}
+model ApiKey {
+ id String @id @default(cuid())
+ name String
+ createdAt DateTime @default(now())
+ keyId String @unique
+ keyHash String
+ userId String
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@unique([name, userId])
+}
+
model Bookmark {
id String @id @default(cuid())
createdAt DateTime @default(now())
diff --git a/packages/web/app/dashboard/components/Sidebar.tsx b/packages/web/app/dashboard/components/Sidebar.tsx
index 44892e81..d2ec14a6 100644
--- a/packages/web/app/dashboard/components/Sidebar.tsx
+++ b/packages/web/app/dashboard/components/Sidebar.tsx
@@ -1,5 +1,13 @@
import { Button } from "@/components/ui/button";
-import { Archive, MoreHorizontal, Star, Tag, Home, Brain } from "lucide-react";
+import {
+ Archive,
+ MoreHorizontal,
+ Star,
+ Tag,
+ Home,
+ Brain,
+ Settings,
+} from "lucide-react";
import { redirect } from "next/navigation";
import SidebarItem from "./SidebarItem";
import { getServerAuthSession } from "@/server/auth";
@@ -35,6 +43,11 @@ export default async function Sidebar() {
path="/dashboard/bookmarks/archive"
/>
<SidebarItem logo={<Tag />} name="Tags" path="#" />
+ <SidebarItem
+ logo={<Settings />}
+ name="Settings"
+ path="/dashboard/settings"
+ />
</ul>
</div>
<div className="mt-auto flex justify-between">
diff --git a/packages/web/app/dashboard/settings/components/AddApiKey.tsx b/packages/web/app/dashboard/settings/components/AddApiKey.tsx
new file mode 100644
index 00000000..f4f2894c
--- /dev/null
+++ b/packages/web/app/dashboard/settings/components/AddApiKey.tsx
@@ -0,0 +1,148 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { z } from "zod";
+import { useRouter } from "next/navigation";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm, SubmitErrorHandler } from "react-hook-form";
+import { toast } from "@/components/ui/use-toast";
+import { api } from "@/lib/trpc";
+import { useState } from "react";
+import { Copy } from "lucide-react";
+
+function ApiKeySuccess({ apiKey }: { apiKey: string }) {
+ return (
+ <div>
+ <div className="py-4">
+ Note: please copy the key and store it somewhere safe. Once you close
+ the dialog, you won&apos;t be able to access it again.
+ </div>
+ <div className="flex space-x-2 pt-2">
+ <Input value={apiKey} readOnly />
+ <Button onClick={() => navigator.clipboard.writeText(apiKey)}>
+ <Copy className="size-4" />
+ </Button>
+ </div>
+ </div>
+ );
+}
+
+function AddApiKeyForm({ onSuccess }: { onSuccess: (key: string) => void }) {
+ const formSchema = z.object({
+ name: z.string(),
+ });
+ const router = useRouter();
+
+ const form = useForm<z.infer<typeof formSchema>>({
+ resolver: zodResolver(formSchema),
+ });
+
+ async function onSubmit(value: z.infer<typeof formSchema>) {
+ try {
+ const resp = await api.apiKeys.create.mutate({ name: value.name });
+ onSuccess(resp.key);
+ } catch (e) {
+ toast({ description: "Something went wrong", variant: "destructive" });
+ return;
+ }
+ router.refresh();
+ }
+
+ const onError: SubmitErrorHandler<z.infer<typeof formSchema>> = (errors) => {
+ toast({
+ description: Object.values(errors)
+ .map((v) => v.message)
+ .join("\n"),
+ variant: "destructive",
+ });
+ };
+
+ return (
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit, onError)}
+ className="flex w-full space-x-3 space-y-8 pt-4"
+ >
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => {
+ return (
+ <FormItem className="flex-1">
+ <FormLabel>Name</FormLabel>
+ <FormControl>
+ <Input type="text" placeholder="Name" {...field} />
+ </FormControl>
+ <FormDescription>
+ Give your API key a unique name
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ <Button className="h-full" type="submit">
+ Create
+ </Button>
+ </form>
+ </Form>
+ );
+}
+
+export default function AddApiKey() {
+ const [key, setKey] = useState<string | undefined>(undefined);
+ const [dialogOpen, setDialogOpen] = useState<boolean>(false);
+ return (
+ <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
+ <DialogTrigger asChild>
+ <Button>New API Key</Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>
+ {key ? "Key was successfully created" : "Create API key"}
+ </DialogTitle>
+ <DialogDescription>
+ {key ? (
+ <ApiKeySuccess apiKey={key} />
+ ) : (
+ <AddApiKeyForm onSuccess={setKey} />
+ )}
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="sm:justify-end">
+ <DialogClose asChild>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setKey(undefined)}
+ >
+ Close
+ </Button>
+ </DialogClose>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/packages/web/app/dashboard/settings/components/ApiKeySettings.tsx b/packages/web/app/dashboard/settings/components/ApiKeySettings.tsx
new file mode 100644
index 00000000..1598f25f
--- /dev/null
+++ b/packages/web/app/dashboard/settings/components/ApiKeySettings.tsx
@@ -0,0 +1,49 @@
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { api } from "@/server/api/client";
+import DeleteApiKey from "./DeleteApiKey";
+import AddApiKey from "./AddApiKey";
+
+export default async function ApiKeys() {
+ const keys = await api.apiKeys.list();
+ return (
+ <div className="pt-4">
+ <span className="text-xl">API Keys</span>
+ <hr className="my-2" />
+ <div className="flex flex-col space-y-3">
+ <div className="flex flex-1 justify-end">
+ <AddApiKey />
+ </div>
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>Name</TableHead>
+ <TableHead>Key</TableHead>
+ <TableHead>Created At</TableHead>
+ <TableHead>Action</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {keys.keys.map((k) => (
+ <TableRow key={k.id}>
+ <TableCell>{k.name}</TableCell>
+ <TableCell>**_{k.keyId}_**</TableCell>
+ <TableCell>{k.createdAt.toLocaleString()}</TableCell>
+ <TableCell>
+ <DeleteApiKey name={k.name} id={k.id} />
+ </TableCell>
+ </TableRow>
+ ))}
+ <TableRow></TableRow>
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/web/app/dashboard/settings/components/DeleteApiKey.tsx b/packages/web/app/dashboard/settings/components/DeleteApiKey.tsx
new file mode 100644
index 00000000..715b7a2c
--- /dev/null
+++ b/packages/web/app/dashboard/settings/components/DeleteApiKey.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Trash } from "lucide-react";
+
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { api } from "@/lib/trpc";
+import { useRouter } from "next/navigation";
+import { toast } from "@/components/ui/use-toast";
+
+export default function DeleteApiKey({
+ name,
+ id,
+}: {
+ name: string;
+ id: string;
+}) {
+ const router = useRouter();
+ const deleteKey = async () => {
+ await api.apiKeys.revoke.mutate({ id });
+ toast({
+ description: "Key was successfully deleted",
+ });
+ router.refresh();
+ };
+ return (
+ <Dialog>
+ <DialogTrigger asChild>
+ <Button variant="destructive">
+ <Trash className="size-5" />
+ </Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Delete API Key</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to delete the API key &quot;{name}&quot;? Any
+ service using this API key will lose access.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="sm:justify-end">
+ <DialogClose asChild>
+ <Button type="button" variant="secondary">
+ Close
+ </Button>
+ </DialogClose>
+ <DialogClose asChild>
+ <Button type="button" variant="destructive" onClick={deleteKey}>
+ Delete
+ </Button>
+ </DialogClose>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/packages/web/app/dashboard/settings/page.tsx b/packages/web/app/dashboard/settings/page.tsx
new file mode 100644
index 00000000..e8799583
--- /dev/null
+++ b/packages/web/app/dashboard/settings/page.tsx
@@ -0,0 +1,10 @@
+import { Button } from "@/components/ui/button";
+import ApiKeySettings from "./components/ApiKeySettings";
+export default async function Settings() {
+ return (
+ <div className="m-4 flex flex-col space-y-2 rounded-md border bg-white p-4">
+ <p className="text-2xl">Settings</p>
+ <ApiKeySettings />
+ </div>
+ );
+}
diff --git a/packages/web/components/ui/dialog.tsx b/packages/web/components/ui/dialog.tsx
new file mode 100644
index 00000000..8fe3fe35
--- /dev/null
+++ b/packages/web/components/ui/dialog.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Dialog = DialogPrimitive.Root;
+
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = DialogPrimitive.Portal;
+
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Overlay
+ ref={ref}
+ className={cn(
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
+ className,
+ )}
+ {...props}
+ />
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
+>(({ className, children, ...props }, ref) => (
+ <DialogPortal>
+ <DialogOverlay />
+ <DialogPrimitive.Content
+ ref={ref}
+ className={cn(
+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
+ <X className="size-4" />
+ <span className="sr-only">Close</span>
+ </DialogPrimitive.Close>
+ </DialogPrimitive.Content>
+ </DialogPortal>
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn(
+ "flex flex-col space-y-1.5 text-center sm:text-left",
+ className,
+ )}
+ {...props}
+ />
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn(
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
+ className,
+ )}
+ {...props}
+ />
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Title>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Title
+ ref={ref}
+ className={cn(
+ "text-lg font-semibold leading-none tracking-tight",
+ className,
+ )}
+ {...props}
+ />
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Description>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Description
+ ref={ref}
+ className={cn("text-muted-foreground text-sm", className)}
+ {...props}
+ />
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+};
diff --git a/packages/web/components/ui/table.tsx b/packages/web/components/ui/table.tsx
new file mode 100644
index 00000000..0fa9288e
--- /dev/null
+++ b/packages/web/components/ui/table.tsx
@@ -0,0 +1,117 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Table = React.forwardRef<
+ HTMLTableElement,
+ React.HTMLAttributes<HTMLTableElement>
+>(({ className, ...props }, ref) => (
+ <div className="relative w-full overflow-auto">
+ <table
+ ref={ref}
+ className={cn("w-full caption-bottom text-sm", className)}
+ {...props}
+ />
+ </div>
+));
+Table.displayName = "Table";
+
+const TableHeader = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+ <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
+));
+TableHeader.displayName = "TableHeader";
+
+const TableBody = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+ <tbody
+ ref={ref}
+ className={cn("[&_tr:last-child]:border-0", className)}
+ {...props}
+ />
+));
+TableBody.displayName = "TableBody";
+
+const TableFooter = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+ <tfoot
+ ref={ref}
+ className={cn(
+ "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
+ className,
+ )}
+ {...props}
+ />
+));
+TableFooter.displayName = "TableFooter";
+
+const TableRow = React.forwardRef<
+ HTMLTableRowElement,
+ React.HTMLAttributes<HTMLTableRowElement>
+>(({ className, ...props }, ref) => (
+ <tr
+ ref={ref}
+ className={cn(
+ "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
+ className,
+ )}
+ {...props}
+ />
+));
+TableRow.displayName = "TableRow";
+
+const TableHead = React.forwardRef<
+ HTMLTableCellElement,
+ React.ThHTMLAttributes<HTMLTableCellElement>
+>(({ className, ...props }, ref) => (
+ <th
+ ref={ref}
+ className={cn(
+ "text-muted-foreground h-12 px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0",
+ className,
+ )}
+ {...props}
+ />
+));
+TableHead.displayName = "TableHead";
+
+const TableCell = React.forwardRef<
+ HTMLTableCellElement,
+ React.TdHTMLAttributes<HTMLTableCellElement>
+>(({ className, ...props }, ref) => (
+ <td
+ ref={ref}
+ className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
+ {...props}
+ />
+));
+TableCell.displayName = "TableCell";
+
+const TableCaption = React.forwardRef<
+ HTMLTableCaptionElement,
+ React.HTMLAttributes<HTMLTableCaptionElement>
+>(({ className, ...props }, ref) => (
+ <caption
+ ref={ref}
+ className={cn("text-muted-foreground mt-4 text-sm", className)}
+ {...props}
+ />
+));
+TableCaption.displayName = "TableCaption";
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+};
diff --git a/packages/web/package.json b/packages/web/package.json
index 0601e4f4..d66a3fe7 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -14,6 +14,7 @@
"@next-auth/prisma-adapter": "^1.0.7",
"@next/eslint-plugin-next": "^14.1.0",
"@prisma/client": "^5.9.1",
+ "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2",
@@ -22,6 +23,7 @@
"@trpc/client": "11.0.0-next-beta.274",
"@trpc/next": "11.0.0-next-beta.274",
"@trpc/server": "11.0.0-next-beta.274",
+ "bcrypt": "^5.1.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"install": "^0.13.0",
@@ -38,6 +40,7 @@
"zod": "^3.22.4"
},
"devDependencies": {
+ "@types/bcrypt": "^5.0.2",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
diff --git a/packages/web/server/api/routers/_app.ts b/packages/web/server/api/routers/_app.ts
index a4f9c629..2097b47d 100644
--- a/packages/web/server/api/routers/_app.ts
+++ b/packages/web/server/api/routers/_app.ts
@@ -1,7 +1,9 @@
import { router } from "../trpc";
+import { apiKeysAppRouter } from "./apiKeys";
import { bookmarksAppRouter } from "./bookmarks";
export const appRouter = router({
bookmarks: bookmarksAppRouter,
+ apiKeys: apiKeysAppRouter,
});
// export type definition of API
export type AppRouter = typeof appRouter;
diff --git a/packages/web/server/api/routers/apiKeys.ts b/packages/web/server/api/routers/apiKeys.ts
new file mode 100644
index 00000000..b681d43f
--- /dev/null
+++ b/packages/web/server/api/routers/apiKeys.ts
@@ -0,0 +1,67 @@
+import { generateApiKey } from "@/server/auth";
+import { authedProcedure, router } from "../trpc";
+import { prisma } from "@remember/db";
+import { z } from "zod";
+
+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(),
+ }),
+ )
+ .output(z.object({}))
+ .mutation(async ({ input, ctx }) => {
+ const resp = await prisma.apiKey.delete({
+ where: {
+ id: input.id,
+ userId: ctx.user.id,
+ },
+ });
+ return resp;
+ }),
+ 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 prisma.apiKey.findMany({
+ where: {
+ userId: ctx.user.id,
+ },
+ select: {
+ id: true,
+ name: true,
+ createdAt: true,
+ keyId: true,
+ },
+ });
+ return { keys: resp };
+ }),
+});
diff --git a/packages/web/server/auth.ts b/packages/web/server/auth.ts
index 05d3d296..f78fa8cf 100644
--- a/packages/web/server/auth.ts
+++ b/packages/web/server/auth.ts
@@ -4,6 +4,9 @@ import AuthentikProvider from "next-auth/providers/authentik";
import serverConfig from "@/server/config";
import { prisma } from "@remember/db";
import { DefaultSession } from "next-auth";
+import * as bcrypt from "bcrypt";
+
+import { randomBytes } from "crypto";
declare module "next-auth" {
/**
@@ -37,3 +40,75 @@ 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 prisma.apiKey.create({
+ data: {
+ name: name,
+ userId: userId,
+ keyId: id,
+ keyHash: secretHash,
+ },
+ });
+
+ 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 prisma.apiKey.findUnique({
+ where: {
+ keyId,
+ },
+ include: {
+ 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 {
+ user: apiKey.user,
+ };
+}
diff --git a/packages/workers/openai.ts b/packages/workers/openai.ts
index 999f2827..1adedeba 100644
--- a/packages/workers/openai.ts
+++ b/packages/workers/openai.ts
@@ -1,6 +1,11 @@
import { prisma, BookmarkedLink } from "@remember/db";
import logger from "@remember/shared/logger";
-import { OpenAIQueue, ZOpenAIRequest, queueConnectionDetails, zOpenAIRequestSchema } from "@remember/shared/queues";
+import {
+ OpenAIQueue,
+ ZOpenAIRequest,
+ queueConnectionDetails,
+ zOpenAIRequestSchema,
+} from "@remember/shared/queues";
import { Job } from "bullmq";
import OpenAI from "openai";
import { z } from "zod";
@@ -9,7 +14,6 @@ import { Worker } from "bullmq";
const openAIResponseSchema = z.object({
tags: z.array(z.string()),
});
-
export class OpenAiWorker {
static async build() {
diff --git a/yarn.lock b/yarn.lock
index 32152d0b..c0728732 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -289,6 +289,25 @@ __metadata:
languageName: node
linkType: hard
+"@mapbox/node-pre-gyp@npm:^1.0.11":
+ version: 1.0.11
+ resolution: "@mapbox/node-pre-gyp@npm:1.0.11"
+ dependencies:
+ detect-libc: "npm:^2.0.0"
+ https-proxy-agent: "npm:^5.0.0"
+ make-dir: "npm:^3.1.0"
+ node-fetch: "npm:^2.6.7"
+ nopt: "npm:^5.0.0"
+ npmlog: "npm:^5.0.1"
+ rimraf: "npm:^3.0.2"
+ semver: "npm:^7.3.5"
+ tar: "npm:^6.1.11"
+ bin:
+ node-pre-gyp: bin/node-pre-gyp
+ checksum: 10c0/2b24b93c31beca1c91336fa3b3769fda98e202fb7f9771f0f4062588d36dcc30fcf8118c36aa747fa7f7610d8cf601872bdaaf62ce7822bb08b545d1bbe086cc
+ languageName: node
+ linkType: hard
+
"@metascraper/helpers@npm:^5.43.4":
version: 5.43.4
resolution: "@metascraper/helpers@npm:5.43.4"
@@ -677,6 +696,39 @@ __metadata:
languageName: node
linkType: hard
+"@radix-ui/react-dialog@npm:^1.0.5":
+ version: 1.0.5
+ resolution: "@radix-ui/react-dialog@npm:1.0.5"
+ dependencies:
+ "@babel/runtime": "npm:^7.13.10"
+ "@radix-ui/primitive": "npm:1.0.1"
+ "@radix-ui/react-compose-refs": "npm:1.0.1"
+ "@radix-ui/react-context": "npm:1.0.1"
+ "@radix-ui/react-dismissable-layer": "npm:1.0.5"
+ "@radix-ui/react-focus-guards": "npm:1.0.1"
+ "@radix-ui/react-focus-scope": "npm:1.0.4"
+ "@radix-ui/react-id": "npm:1.0.1"
+ "@radix-ui/react-portal": "npm:1.0.4"
+ "@radix-ui/react-presence": "npm:1.0.1"
+ "@radix-ui/react-primitive": "npm:1.0.3"
+ "@radix-ui/react-slot": "npm:1.0.2"
+ "@radix-ui/react-use-controllable-state": "npm:1.0.1"
+ aria-hidden: "npm:^1.1.1"
+ react-remove-scroll: "npm:2.5.5"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0
+ react-dom: ^16.8 || ^17.0 || ^18.0
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10c0/c5b3069397379e79857a3203f3ead4d12d87736b59899f02a63e620a07dd1e6704e15523926cdf8e39afe1c945a7ff0f2533c5ea5be1e17c3114820300a51133
+ languageName: node
+ linkType: hard
+
"@radix-ui/react-direction@npm:1.0.1":
version: 1.0.1
resolution: "@radix-ui/react-direction@npm:1.0.1"
@@ -1165,6 +1217,7 @@ __metadata:
"@next-auth/prisma-adapter": "npm:^1.0.7"
"@next/eslint-plugin-next": "npm:^14.1.0"
"@prisma/client": "npm:^5.9.1"
+ "@radix-ui/react-dialog": "npm:^1.0.5"
"@radix-ui/react-dropdown-menu": "npm:^2.0.6"
"@radix-ui/react-label": "npm:^2.0.2"
"@radix-ui/react-slot": "npm:^1.0.2"
@@ -1173,9 +1226,11 @@ __metadata:
"@trpc/client": "npm:11.0.0-next-beta.274"
"@trpc/next": "npm:11.0.0-next-beta.274"
"@trpc/server": "npm:11.0.0-next-beta.274"
+ "@types/bcrypt": "npm:^5.0.2"
"@types/react": "npm:^18"
"@types/react-dom": "npm:^18"
autoprefixer: "npm:^10.0.1"
+ bcrypt: "npm:^5.1.1"
class-variance-authority: "npm:^0.7.0"
clsx: "npm:^2.1.0"
install: "npm:^0.13.0"
@@ -1331,6 +1386,15 @@ __metadata:
languageName: node
linkType: hard
+"@types/bcrypt@npm:^5.0.2":
+ version: 5.0.2
+ resolution: "@types/bcrypt@npm:5.0.2"
+ dependencies:
+ "@types/node": "npm:*"
+ checksum: 10c0/dd7f05e183b9b1fc08ec499069febf197ab8e9c720766b5bbb5628395082e248f9a444c60882fe7788361fcadc302e21e055ab9c26a300f100e08791c353e6aa
+ languageName: node
+ linkType: hard
+
"@types/cacheable-request@npm:^6.0.1":
version: 6.0.3
resolution: "@types/cacheable-request@npm:6.0.3"
@@ -1620,6 +1684,13 @@ __metadata:
languageName: node
linkType: hard
+"abbrev@npm:1":
+ version: 1.1.1
+ resolution: "abbrev@npm:1.1.1"
+ checksum: 10c0/3f762677702acb24f65e813070e306c61fafe25d4b2583f9dfc935131f774863f3addd5741572ed576bd69cabe473c5af18e1e108b829cb7b6b4747884f726e6
+ languageName: node
+ linkType: hard
+
"abbrev@npm:^2.0.0":
version: 2.0.0
resolution: "abbrev@npm:2.0.0"
@@ -1661,6 +1732,15 @@ __metadata:
languageName: node
linkType: hard
+"agent-base@npm:6":
+ version: 6.0.2
+ resolution: "agent-base@npm:6.0.2"
+ dependencies:
+ debug: "npm:4"
+ checksum: 10c0/dc4f757e40b5f3e3d674bc9beb4f1048f4ee83af189bae39be99f57bf1f48dde166a8b0a5342a84b5944ee8e6ed1e5a9d801858f4ad44764e84957122fe46261
+ languageName: node
+ linkType: hard
+
"agent-base@npm:^7.0.2, agent-base@npm:^7.1.0":
version: 7.1.0
resolution: "agent-base@npm:7.1.0"
@@ -1757,6 +1837,23 @@ __metadata:
languageName: node
linkType: hard
+"aproba@npm:^1.0.3 || ^2.0.0":
+ version: 2.0.0
+ resolution: "aproba@npm:2.0.0"
+ checksum: 10c0/d06e26384a8f6245d8c8896e138c0388824e259a329e0c9f196b4fa533c82502a6fd449586e3604950a0c42921832a458bb3aa0aa9f0ba449cfd4f50fd0d09b5
+ languageName: node
+ linkType: hard
+
+"are-we-there-yet@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "are-we-there-yet@npm:2.0.0"
+ dependencies:
+ delegates: "npm:^1.0.0"
+ readable-stream: "npm:^3.6.0"
+ checksum: 10c0/375f753c10329153c8d66dc95e8f8b6c7cc2aa66e05cb0960bd69092b10dae22900cacc7d653ad11d26b3ecbdbfe1e8bfb6ccf0265ba8077a7d979970f16b99c
+ languageName: node
+ linkType: hard
+
"arg@npm:^4.1.0":
version: 4.1.3
resolution: "arg@npm:4.1.3"
@@ -2041,6 +2138,16 @@ __metadata:
languageName: node
linkType: hard
+"bcrypt@npm:^5.1.1":
+ version: 5.1.1
+ resolution: "bcrypt@npm:5.1.1"
+ dependencies:
+ "@mapbox/node-pre-gyp": "npm:^1.0.11"
+ node-addon-api: "npm:^5.0.0"
+ checksum: 10c0/743231158c866bddc46f25eb8e9617fe38bc1a6f5f3052aba35e361d349b7f8fb80e96b45c48a4c23c45c29967ccd11c81cf31166454fc0ab019801c336cab40
+ languageName: node
+ linkType: hard
+
"binary-extensions@npm:^2.0.0":
version: 2.2.0
resolution: "binary-extensions@npm:2.2.0"
@@ -2447,6 +2554,15 @@ __metadata:
languageName: node
linkType: hard
+"color-support@npm:^1.1.2":
+ version: 1.1.3
+ resolution: "color-support@npm:1.1.3"
+ bin:
+ color-support: bin.js
+ checksum: 10c0/8ffeaa270a784dc382f62d9be0a98581db43e11eee301af14734a6d089bd456478b1a8b3e7db7ca7dc5b18a75f828f775c44074020b51c05fc00e6d0992b1cc6
+ languageName: node
+ linkType: hard
+
"color@npm:^3.1.3":
version: 3.2.1
resolution: "color@npm:3.2.1"
@@ -2497,6 +2613,13 @@ __metadata:
languageName: node
linkType: hard
+"console-control-strings@npm:^1.0.0, console-control-strings@npm:^1.1.0":
+ version: 1.1.0
+ resolution: "console-control-strings@npm:1.1.0"
+ checksum: 10c0/7ab51d30b52d461412cd467721bb82afe695da78fff8f29fe6f6b9cbaac9a2328e27a22a966014df9532100f6dd85370460be8130b9c677891ba36d96a343f50
+ languageName: node
+ linkType: hard
+
"cookie@npm:^0.5.0":
version: 0.5.0
resolution: "cookie@npm:0.5.0"
@@ -2739,6 +2862,13 @@ __metadata:
languageName: node
linkType: hard
+"delegates@npm:^1.0.0":
+ version: 1.0.0
+ resolution: "delegates@npm:1.0.0"
+ checksum: 10c0/ba05874b91148e1db4bf254750c042bf2215febd23a6d3cda2e64896aef79745fbd4b9996488bd3cafb39ce19dbce0fd6e3b6665275638befffe1c9b312b91b5
+ languageName: node
+ linkType: hard
+
"denque@npm:^2.1.0":
version: 2.1.0
resolution: "denque@npm:2.1.0"
@@ -2753,6 +2883,13 @@ __metadata:
languageName: node
linkType: hard
+"detect-libc@npm:^2.0.0":
+ version: 2.0.2
+ resolution: "detect-libc@npm:2.0.2"
+ checksum: 10c0/a9f4ffcd2701525c589617d98afe5a5d0676c8ea82bcc4ed6f3747241b79f781d36437c59a5e855254c864d36a3e9f8276568b6b531c28d6e53b093a15703f11
+ languageName: node
+ linkType: hard
+
"detect-node-es@npm:^1.1.0":
version: 1.1.0
resolution: "detect-node-es@npm:1.1.0"
@@ -3735,6 +3872,23 @@ __metadata:
languageName: node
linkType: hard
+"gauge@npm:^3.0.0":
+ version: 3.0.2
+ resolution: "gauge@npm:3.0.2"
+ dependencies:
+ aproba: "npm:^1.0.3 || ^2.0.0"
+ color-support: "npm:^1.1.2"
+ console-control-strings: "npm:^1.0.0"
+ has-unicode: "npm:^2.0.1"
+ object-assign: "npm:^4.1.1"
+ signal-exit: "npm:^3.0.0"
+ string-width: "npm:^4.2.3"
+ strip-ansi: "npm:^6.0.1"
+ wide-align: "npm:^1.1.2"
+ checksum: 10c0/75230ccaf216471e31025c7d5fcea1629596ca20792de50c596eb18ffb14d8404f927cd55535aab2eeecd18d1e11bd6f23ec3c2e9878d2dda1dc74bccc34b913
+ languageName: node
+ linkType: hard
+
"get-caller-file@npm:^2.0.5":
version: 2.0.5
resolution: "get-caller-file@npm:2.0.5"
@@ -3990,6 +4144,13 @@ __metadata:
languageName: node
linkType: hard
+"has-unicode@npm:^2.0.1":
+ version: 2.0.1
+ resolution: "has-unicode@npm:2.0.1"
+ checksum: 10c0/ebdb2f4895c26bb08a8a100b62d362e49b2190bcfd84b76bc4be1a3bd4d254ec52d0dd9f2fbcc093fc5eb878b20c52146f9dfd33e2686ed28982187be593b47c
+ languageName: node
+ linkType: hard
+
"has-values@npm:~2.0.1":
version: 2.0.1
resolution: "has-values@npm:2.0.1"
@@ -4056,6 +4217,16 @@ __metadata:
languageName: node
linkType: hard
+"https-proxy-agent@npm:^5.0.0":
+ version: 5.0.1
+ resolution: "https-proxy-agent@npm:5.0.1"
+ dependencies:
+ agent-base: "npm:6"
+ debug: "npm:4"
+ checksum: 10c0/6dd639f03434003577c62b27cafdb864784ef19b2de430d8ae2a1d45e31c4fd60719e5637b44db1a88a046934307da7089e03d6089ec3ddacc1189d8de8897d1
+ languageName: node
+ linkType: hard
+
"https-proxy-agent@npm:^7.0.1, https-proxy-agent@npm:^7.0.2":
version: 7.0.2
resolution: "https-proxy-agent@npm:7.0.2"
@@ -4956,6 +5127,15 @@ __metadata:
languageName: node
linkType: hard
+"make-dir@npm:^3.1.0":
+ version: 3.1.0
+ resolution: "make-dir@npm:3.1.0"
+ dependencies:
+ semver: "npm:^6.0.0"
+ checksum: 10c0/56aaafefc49c2dfef02c5c95f9b196c4eb6988040cf2c712185c7fe5c99b4091591a7fc4d4eafaaefa70ff763a26f6ab8c3ff60b9e75ea19876f49b18667ecaa
+ languageName: node
+ linkType: hard
+
"make-error@npm:^1.1.1":
version: 1.3.6
resolution: "make-error@npm:1.3.6"
@@ -5523,6 +5703,15 @@ __metadata:
languageName: node
linkType: hard
+"node-addon-api@npm:^5.0.0":
+ version: 5.1.0
+ resolution: "node-addon-api@npm:5.1.0"
+ dependencies:
+ node-gyp: "npm:latest"
+ checksum: 10c0/0eb269786124ba6fad9df8007a149e03c199b3e5a3038125dfb3e747c2d5113d406a4e33f4de1ea600aa2339be1f137d55eba1a73ee34e5fff06c52a5c296d1d
+ languageName: node
+ linkType: hard
+
"node-domexception@npm:1.0.0":
version: 1.0.0
resolution: "node-domexception@npm:1.0.0"
@@ -5582,6 +5771,17 @@ __metadata:
languageName: node
linkType: hard
+"nopt@npm:^5.0.0":
+ version: 5.0.0
+ resolution: "nopt@npm:5.0.0"
+ dependencies:
+ abbrev: "npm:1"
+ bin:
+ nopt: bin/nopt.js
+ checksum: 10c0/fc5c4f07155cb455bf5fc3dd149fac421c1a40fd83c6bfe83aa82b52f02c17c5e88301321318adaa27611c8a6811423d51d29deaceab5fa158b585a61a551061
+ languageName: node
+ linkType: hard
+
"nopt@npm:^7.0.0":
version: 7.2.0
resolution: "nopt@npm:7.2.0"
@@ -5614,6 +5814,18 @@ __metadata:
languageName: node
linkType: hard
+"npmlog@npm:^5.0.1":
+ version: 5.0.1
+ resolution: "npmlog@npm:5.0.1"
+ dependencies:
+ are-we-there-yet: "npm:^2.0.0"
+ console-control-strings: "npm:^1.1.0"
+ gauge: "npm:^3.0.0"
+ set-blocking: "npm:^2.0.0"
+ checksum: 10c0/489ba519031013001135c463406f55491a17fc7da295c18a04937fe3a4d523fd65e88dd418a28b967ab743d913fdeba1e29838ce0ad8c75557057c481f7d49fa
+ languageName: node
+ linkType: hard
+
"nth-check@npm:^2.0.1":
version: 2.1.1
resolution: "nth-check@npm:2.1.1"
@@ -6855,7 +7067,7 @@ __metadata:
languageName: node
linkType: hard
-"semver@npm:^6.3.1":
+"semver@npm:^6.0.0, semver@npm:^6.3.1":
version: 6.3.1
resolution: "semver@npm:6.3.1"
bin:
@@ -6882,6 +7094,13 @@ __metadata:
languageName: node
linkType: hard
+"set-blocking@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "set-blocking@npm:2.0.0"
+ checksum: 10c0/9f8c1b2d800800d0b589de1477c753492de5c1548d4ade52f57f1d1f5e04af5481554d75ce5e5c43d4004b80a3eb714398d6907027dc0534177b7539119f4454
+ languageName: node
+ linkType: hard
+
"set-function-length@npm:^1.2.0":
version: 1.2.1
resolution: "set-function-length@npm:1.2.1"
@@ -6947,6 +7166,13 @@ __metadata:
languageName: node
linkType: hard
+"signal-exit@npm:^3.0.0":
+ version: 3.0.7
+ resolution: "signal-exit@npm:3.0.7"
+ checksum: 10c0/25d272fa73e146048565e08f3309d5b942c1979a6f4a58a8c59d5fa299728e9c2fcd1a759ec870863b1fd38653670240cd420dad2ad9330c71f36608a6a1c912
+ languageName: node
+ linkType: hard
+
"signal-exit@npm:^4.0.1":
version: 4.1.0
resolution: "signal-exit@npm:4.1.0"
@@ -7063,7 +7289,7 @@ __metadata:
languageName: node
linkType: hard
-"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3":
+"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3":
version: 4.2.3
resolution: "string-width@npm:4.2.3"
dependencies:
@@ -7958,6 +8184,15 @@ __metadata:
languageName: node
linkType: hard
+"wide-align@npm:^1.1.2":
+ version: 1.1.5
+ resolution: "wide-align@npm:1.1.5"
+ dependencies:
+ string-width: "npm:^1.0.2 || 2 || 3 || 4"
+ checksum: 10c0/1d9c2a3e36dfb09832f38e2e699c367ef190f96b82c71f809bc0822c306f5379df87bab47bed27ea99106d86447e50eb972d3c516c2f95782807a9d082fbea95
+ languageName: node
+ linkType: hard
+
"winston-transport@npm:^4.5.0":
version: 4.7.0
resolution: "winston-transport@npm:4.7.0"