diff options
Diffstat (limited to 'packages')
| -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 |
14 files changed, 707 insertions, 3 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() { |
