diff options
| author | MohamedBassem <me@mbassem.com> | 2024-02-12 14:52:00 +0000 |
|---|---|---|
| committer | MohamedBassem <me@mbassem.com> | 2024-02-12 14:55:00 +0000 |
| commit | 6aacc0c7a86e36c52a3c2c1d26fe58cefcd3bec4 (patch) | |
| tree | bad306e872d6bfcc2c67f00caa3880c8aa56070f | |
| parent | 230cafb6dfc8d3bad57d84ef13c3669f5bf5331a (diff) | |
| download | karakeep-6aacc0c7a86e36c52a3c2c1d26fe58cefcd3bec4.tar.zst | |
feature: Add support for managing API keys
| -rw-r--r-- | packages/db/prisma/migrations/20240211184744_add_api_key/migration.sql | 16 | ||||
| -rw-r--r-- | packages/db/prisma/schema.prisma | 13 | ||||
| -rw-r--r-- | packages/web/app/dashboard/components/Sidebar.tsx | 15 | ||||
| -rw-r--r-- | packages/web/app/dashboard/settings/components/AddApiKey.tsx | 148 | ||||
| -rw-r--r-- | packages/web/app/dashboard/settings/components/ApiKeySettings.tsx | 49 | ||||
| -rw-r--r-- | packages/web/app/dashboard/settings/components/DeleteApiKey.tsx | 65 | ||||
| -rw-r--r-- | packages/web/app/dashboard/settings/page.tsx | 10 | ||||
| -rw-r--r-- | packages/web/components/ui/dialog.tsx | 122 | ||||
| -rw-r--r-- | packages/web/components/ui/table.tsx | 117 | ||||
| -rw-r--r-- | packages/web/package.json | 3 | ||||
| -rw-r--r-- | packages/web/server/api/routers/_app.ts | 2 | ||||
| -rw-r--r-- | packages/web/server/api/routers/apiKeys.ts | 67 | ||||
| -rw-r--r-- | packages/web/server/auth.ts | 75 | ||||
| -rw-r--r-- | packages/workers/openai.ts | 8 | ||||
| -rw-r--r-- | yarn.lock | 239 |
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'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 "{name}"? 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() { @@ -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" |
