From 47168930dc13127ab809d147d2eb8470bedd3de8 Mon Sep 17 00:00:00 2001 From: BennyKok Date: Mon, 22 Jan 2024 14:11:14 +0800 Subject: [PATCH] feat: add new auth_request flow for logging in with comfy deploy --- web-plugin/index.js | 109 ++- web/bun.lockb | Bin 498075 -> 498007 bytes web/drizzle/0033_awesome_human_fly.sql | 8 + web/drizzle/0034_even_lady_ursula.sql | 1 + web/drizzle/meta/0033_snapshot.json | 824 +++++++++++++++++ web/drizzle/meta/0034_snapshot.json | 830 ++++++++++++++++++ web/drizzle/meta/_journal.json | 14 + web/migrate.mts | 16 +- web/package.json | 1 + web/src/app/(app)/api/[[...routes]]/route.ts | 25 +- web/src/app/(app)/api/file-upload/route.ts | 7 +- web/src/app/(app)/api/upload/route.ts | 84 +- .../(app)/auth-request/[request_id]/page.tsx | 54 ++ web/src/components/ButtonActionLoader.tsx | 133 ++- web/src/db/db.ts | 8 +- web/src/db/schema.ts | 136 +-- web/src/routes/newId.ts | 13 + web/src/routes/registerCreateRunRoute.ts | 6 +- web/src/routes/registerGetAuthResponse.ts | 150 ++++ web/src/routes/registerUploadRoute.ts | 19 +- web/src/routes/registerWorkflowUploadRoute.ts | 164 ++++ web/src/server/APIKeyBodyRequest.ts | 5 +- web/src/server/createNewWorkflow.ts | 42 +- web/src/server/curdApiKeys.ts | 29 +- web/src/server/getOrgOrUserDisplayName.tsx | 18 + 25 files changed, 2424 insertions(+), 272 deletions(-) create mode 100644 web/drizzle/0033_awesome_human_fly.sql create mode 100644 web/drizzle/0034_even_lady_ursula.sql create mode 100644 web/drizzle/meta/0033_snapshot.json create mode 100644 web/drizzle/meta/0034_snapshot.json create mode 100644 web/src/app/(app)/auth-request/[request_id]/page.tsx create mode 100644 web/src/routes/newId.ts create mode 100644 web/src/routes/registerGetAuthResponse.ts create mode 100644 web/src/routes/registerWorkflowUploadRoute.ts create mode 100644 web/src/server/getOrgOrUserDisplayName.tsx diff --git a/web-plugin/index.js b/web-plugin/index.js index 60726c8..47e71d3 100644 --- a/web-plugin/index.js +++ b/web-plugin/index.js @@ -120,7 +120,7 @@ function addButton() { const graph = app.graph; const snapshot = await fetch("/snapshot/get_current").then((x) => x.json()); - console.log(snapshot); + // console.log(snapshot); if (!snapshot) { showError( @@ -154,7 +154,7 @@ function addButton() { const deployMetaNode = deployMeta[0]; - console.log(deployMetaNode); + // console.log(deployMetaNode); const workflow_name = deployMetaNode.widgets[0].value; const workflow_id = deployMetaNode.widgets[1].value; @@ -168,20 +168,23 @@ function addButton() { // const endpoint = localStorage.getItem("endpoint") ?? ""; // const apiKey = localStorage.getItem("apiKey"); - const { endpoint, apiKey } = getData(); + const { endpoint, apiKey, displayName } = getData(); if (!endpoint || !apiKey || apiKey === "" || endpoint === "") { configDialog.show(); return; } - const ok = await confirmDialog.confirm("Confirm deployment", "A new version will be deployed, are you conform?") + const ok = await confirmDialog.confirm( + "Confirm deployment -> " + displayName, + "A new version will be deployed, are you conform?", + ); if (!ok) return; title.innerText = "Deploying..."; title.style.color = "orange"; - console.log(prompt); + // console.log(prompt); // TODO trim the ending / from endpoint is there is if (endpoint.endsWith("/")) { @@ -191,15 +194,17 @@ function addButton() { const apiRoute = endpoint + "/api/upload"; // const userId = apiKey try { + const body = { + workflow_name, + workflow_id, + workflow: prompt.workflow, + workflow_api: prompt.output, + snapshot: snapshot, + }; + console.log(body); let data = await fetch(apiRoute, { method: "POST", - body: JSON.stringify({ - workflow_name, - workflow_id, - workflow: prompt.workflow, - workflow_api: prompt.output, - snapshot: snapshot, - }), + body: JSON.stringify(body), headers: { "Content-Type": "application/json", Authorization: "Bearer " + apiKey, @@ -301,6 +306,17 @@ export class InfoDialog extends ComfyDialog { this.element.style.display = "flex"; this.element.style.zIndex = 1001; } + + showMessage(title, message) { + this.show(` +
+

${title}

+ +
+ `); + } } export class InputDialog extends InfoDialog { @@ -367,7 +383,6 @@ export class InputDialog extends InfoDialog { } } - export class ConfirmDialog extends InfoDialog { callback = undefined; @@ -456,6 +471,8 @@ function getData(environment) { export class ConfigDialog extends ComfyDialog { container = null; + poll = null; + timeout = null; constructor() { super(); @@ -498,17 +515,22 @@ export class ConfigDialog extends ComfyDialog { close() { this.element.style.display = "none"; + clearInterval(this.poll); + clearTimeout(this.timeout); } - save() { + save(api_key, displayName) { + if (!displayName) displayName = getData().displayName; + const deployOption = this.container.querySelector("#deployOption").value; localStorage.setItem("comfy_deploy_env", deployOption); const endpoint = this.container.querySelector("#endpoint").value; - const apiKey = this.container.querySelector("#apiKey").value; + const apiKey = api_key ?? this.container.querySelector("#apiKey").value; const data = { endpoint, apiKey, + displayName, }; localStorage.setItem( "comfy_deploy_env_data_" + deployOption, @@ -527,12 +549,8 @@ export class ConfigDialog extends ComfyDialog {

Comfy Deploy Config

`; + const button = this.container.querySelector("#loginButton"); + button.onclick = () => { + const uuid = + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + window.open(data.endpoint + "/auth-request/" + uuid, "_blank"); + + this.timeout = setTimeout(() => { + clearInterval(poll); + infoDialog.showMessage( + "Timeout", + "Wait too long for the response, please try re-login", + ); + }, 30000); // Stop polling after 30 seconds + + this.poll = setInterval(() => { + fetch(data.endpoint + "/api/auth-response/" + uuid) + .then((response) => response.json()) + .then((json) => { + if (json.api_key) { + this.save(json.api_key, json.name); + this.container.querySelector("#apiKey").value = json.api_key; + infoDialog.show(); + clearInterval(this.poll); + clearTimeout(this.timeout); + infoDialog.showMessage( + "Authenticated", + "You will be able to upload workflow to " + json.name, + ); + } + }) + .catch((error) => { + console.error("Error:", error); + clearInterval(this.poll); + clearTimeout(this.timeout); + infoDialog.showMessage("Error", error); + }); + }, 2000); + }; + const apiKeyInput = this.container.querySelector("#apiKey"); - apiKeyInput.addEventListener("paste", function (e) { + apiKeyInput.addEventListener("paste", (e) => { e.stopPropagation(); }); diff --git a/web/bun.lockb b/web/bun.lockb index c5cc14e81f695aabf0c4d68e96314844fb7b95d8..650dd43bf9f70e00173efe1b412186dd13171118 100755 GIT binary patch delta 63105 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 setTimeout(res, 1000)); + await new Promise((res) => setTimeout(res, 1000)); } } diff --git a/web/package.json b/web/package.json index 588cbb8..faf351d 100644 --- a/web/package.json +++ b/web/package.json @@ -72,6 +72,7 @@ "mdx-annotations": "^0.1.4", "million": "latest", "mitata": "^0.1.6", + "ms": "^2.1.3", "nanoid": "^5.0.4", "next": "14.1", "next-plausible": "^3.12.0", diff --git a/web/src/app/(app)/api/[[...routes]]/route.ts b/web/src/app/(app)/api/[[...routes]]/route.ts index ea42f63..e8c7cdd 100644 --- a/web/src/app/(app)/api/[[...routes]]/route.ts +++ b/web/src/app/(app)/api/[[...routes]]/route.ts @@ -1,4 +1,3 @@ -import { app } from "../../../../routes/app"; import { registerCreateRunRoute } from "@/routes/registerCreateRunRoute"; import { registerGetOutputRoute } from "@/routes/registerGetOutputRoute"; import { registerUploadRoute } from "@/routes/registerUploadRoute"; @@ -6,6 +5,9 @@ import { isKeyRevoked } from "@/server/curdApiKeys"; import { parseJWT } from "@/server/parseJWT"; import type { Context, Next } from "hono"; import { handle } from "hono/vercel"; +import { app } from "../../../../routes/app"; +import { registerWorkflowUploadRoute } from "@/routes/registerWorkflowUploadRoute"; +import { registerGetAuthResponse } from "@/routes/registerGetAuthResponse"; export const dynamic = "force-dynamic"; export const maxDuration = 300; // 5 minutes @@ -21,7 +23,10 @@ async function checkAuth(c: Context, next: Next) { const userData = token ? parseJWT(token) : undefined; if (!userData || token === undefined) { return c.text("Invalid or expired token", 401); - } else { + } + + // If the key has expiration, this is a temporary key and not in our db, so we can skip checking + if (userData.exp === undefined) { const revokedKey = await isKeyRevoked(token); if (revokedKey) return c.text("Revoked token", 401); } @@ -31,18 +36,20 @@ async function checkAuth(c: Context, next: Next) { await next(); } -app.use("/run", async (c, next) => { - return checkAuth(c, next); -}); - -app.use("/upload-url", async (c, next) => { - return checkAuth(c, next); -}); +app.use("/run", checkAuth); +app.use("/upload-url", checkAuth); +app.use("/upload-workflow", checkAuth); +// create run endpoint registerCreateRunRoute(app); registerGetOutputRoute(app); + +// file upload endpoint registerUploadRoute(app); +registerWorkflowUploadRoute(app); +registerGetAuthResponse(app); + // The OpenAPI documentation will be available at /doc app.doc("/doc", { openapi: "3.0.0", diff --git a/web/src/app/(app)/api/file-upload/route.ts b/web/src/app/(app)/api/file-upload/route.ts index 8f5ba7b..f132181 100644 --- a/web/src/app/(app)/api/file-upload/route.ts +++ b/web/src/app/(app)/api/file-upload/route.ts @@ -1,11 +1,12 @@ -import { parseDataSafe } from "../../../../lib/parseDataSafe"; import { handleResourceUpload } from "@/server/resource"; import { NextResponse } from "next/server"; import { z } from "zod"; +import { parseDataSafe } from "../../../../lib/parseDataSafe"; const Request = z.object({ file_name: z.string(), run_id: z.string(), + type: z.string(), }); @@ -29,7 +30,7 @@ export async function GET(request: Request) { { url: uploadUrl, }, - { status: 200 } + { status: 200 }, ); } catch (error: unknown) { const errorMessage = @@ -38,7 +39,7 @@ export async function GET(request: Request) { { error: errorMessage, }, - { status: 500 } + { status: 500 }, ); } } diff --git a/web/src/app/(app)/api/upload/route.ts b/web/src/app/(app)/api/upload/route.ts index 932169b..2a98b40 100644 --- a/web/src/app/(app)/api/upload/route.ts +++ b/web/src/app/(app)/api/upload/route.ts @@ -1,17 +1,14 @@ -import { createNewWorkflow } from "../../../../server/createNewWorkflow"; -import { parseJWT } from "../../../../server/parseJWT"; -import { db } from "@/db/db"; -import { - snapshotType, - workflowAPIType, - workflowTable, - workflowType, - workflowVersionTable, -} from "@/db/schema"; +import { snapshotType, workflowAPIType, workflowType } from "@/db/schema"; import { parseDataSafe } from "@/lib/parseDataSafe"; -import { eq, sql } from "drizzle-orm"; import { NextResponse } from "next/server"; import { z } from "zod"; +import { + createNewWorkflow, + createNewWorkflowVersion, +} from "../../../../server/createNewWorkflow"; +import { parseJWT } from "../../../../server/parseJWT"; + +// This is will be deprecated const corsHeaders = { "Access-Control-Allow-Origin": "*", @@ -55,7 +52,7 @@ export async function POST(request: Request) { const [data, error] = await parseDataSafe( UploadRequest, request, - corsHeaders + corsHeaders, ); if (!data || error) return error; @@ -75,7 +72,7 @@ export async function POST(request: Request) { // Case 1 new workflow try { - if ((!workflow_id || workflow_id.length == 0) && workflow_name) { + if ((!workflow_id || workflow_id.length === 0) && workflow_name) { // Create a new parent workflow const { workflow_id: _workflow_id, version: _version } = await createNewWorkflow({ @@ -91,56 +88,17 @@ export async function POST(request: Request) { workflow_id = _workflow_id; version = _version; - - // const workflow_parent = await db - // .insert(workflowTable) - // .values({ - // user_id, - // name: workflow_name, - // org_id: org_id, - // }) - // .returning(); - - // workflow_id = workflow_parent[0].id; - - // // Create a new version - // const data = await db - // .insert(workflowVersionTable) - // .values({ - // workflow_id: workflow_id, - // workflow, - // workflow_api, - // version: 1, - // snapshot: snapshot, - // }) - // .returning(); - // version = data[0].version; } else if (workflow_id) { // Case 2 update workflow - const data = await db - .insert(workflowVersionTable) - .values({ - workflow_id, - workflow: workflow, + const { version: _version } = await createNewWorkflowVersion({ + workflow_id: workflow_id, + workflowData: { + workflow, workflow_api, - // version: sql`${workflowVersionTable.version} + 1`, - snapshot: snapshot, - version: sql`( - SELECT COALESCE(MAX(version), 0) + 1 - FROM ${workflowVersionTable} - WHERE workflow_id = ${workflow_id} - )`, - }) - .returning(); - version = data[0].version; - - await db - .update(workflowTable) - .set({ - updated_at: new Date(), - }) - .where(eq(workflowTable.id, workflow_id)) - .returning(); + snapshot, + }, + }); + version = _version; } else { return NextResponse.json( { @@ -150,7 +108,7 @@ export async function POST(request: Request) { status: 500, statusText: "Invalid request", headers: corsHeaders, - } + }, ); } } catch (error: any) { @@ -162,7 +120,7 @@ export async function POST(request: Request) { status: 500, statusText: "Invalid request", headers: corsHeaders, - } + }, ); } @@ -174,6 +132,6 @@ export async function POST(request: Request) { { status: 200, headers: corsHeaders, - } + }, ); } diff --git a/web/src/app/(app)/auth-request/[request_id]/page.tsx b/web/src/app/(app)/auth-request/[request_id]/page.tsx new file mode 100644 index 0000000..a419b96 --- /dev/null +++ b/web/src/app/(app)/auth-request/[request_id]/page.tsx @@ -0,0 +1,54 @@ +import { ButtonAction } from "@/components/ButtonActionLoader"; +import { Button } from "@/components/ui/button"; +import { createAuthRequest } from "@/server/curdApiKeys"; +import { auth } from "@clerk/nextjs"; +import { redirect } from "next/navigation"; +import { getOrgOrUserDisplayName } from "../../../../server/getOrgOrUserDisplayName"; +import { db } from "@/db/db"; +import { eq } from "drizzle-orm"; +import { authRequestsTable } from "@/db/schema"; + +export default async function Home({ + params, +}: { + params: { request_id: string }; +}) { + const { userId, orgId } = await auth(); + + if (!userId) redirect("/"); + + if (!params.request_id) + return ( +
+ No valid request_id +
+ ); + + const existingResult = await db.query.authRequestsTable.findFirst({ + where: eq(authRequestsTable.request_id, params.request_id), + }); + + if (existingResult?.api_hash) { + return ( +
+ Request already consumed. +
+ ); + } + + const userName = await getOrgOrUserDisplayName(orgId, userId); + + return ( +
+
Grant API Access to {userName}
+ +
+ ); +} diff --git a/web/src/components/ButtonActionLoader.tsx b/web/src/components/ButtonActionLoader.tsx index 568e87d..5edb68e 100644 --- a/web/src/components/ButtonActionLoader.tsx +++ b/web/src/components/ButtonActionLoader.tsx @@ -4,10 +4,10 @@ import { LoadingIcon } from "@/components/LoadingIcon"; import { callServerPromise } from "@/components/callServerPromise"; import { Button } from "@/components/ui/button"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useAuth, useClerk } from "@clerk/nextjs"; import { MoreVertical } from "lucide-react"; @@ -15,80 +15,79 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; export function ButtonAction({ - action, - children, - routerAction = "back", - ...rest + action, + children, + routerAction = "back", + ...rest }: { - action: () => Promise; - routerAction?: "refresh" | "back"; - children: React.ReactNode; + action: () => Promise; + routerAction?: "refresh" | "back" | "do-nothing"; + children: React.ReactNode; }) { - const [pending, setPending] = useState(false); - const router = useRouter(); + const [pending, setPending] = useState(false); + const router = useRouter(); - return ( - - ); + } else if (routerAction === "refresh") router.refresh(); + }} + {...rest} + > + {children} {pending && } + + ); } export function ButtonActionMenu(props: { - title?: string; - actions: { - title: string; - action: () => Promise; - }[]; + title?: string; + actions: { + title: string; + action: () => Promise; + }[]; }) { - const user = useAuth(); - const [isLoading, setIsLoading] = useState(false); - const clerk = useClerk(); + const user = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const clerk = useClerk(); - return ( - - - - - - {props.actions.map((action) => ( - { - if (!user.isSignedIn) { - clerk.openSignIn({ - redirectUrl: window.location.href, - }); - return; - } + return ( + + + + + + {props.actions.map((action) => ( + { + if (!user.isSignedIn) { + clerk.openSignIn({ + redirectUrl: window.location.href, + }); + return; + } - setIsLoading(true); - await callServerPromise(action.action()); - setIsLoading(false); - }} - > - {action.title} - - ))} - - - ); + setIsLoading(true); + await callServerPromise(action.action()); + setIsLoading(false); + }} + > + {action.title} + + ))} + + + ); } diff --git a/web/src/db/db.ts b/web/src/db/db.ts index 33aa553..2cd8d5d 100644 --- a/web/src/db/db.ts +++ b/web/src/db/db.ts @@ -1,6 +1,6 @@ -import * as schema from "./schema"; -import { neonConfig, Pool } from "@neondatabase/serverless"; +import { Pool, neonConfig } from "@neondatabase/serverless"; import { drizzle as neonDrizzle } from "drizzle-orm/neon-serverless"; +import * as schema from "./schema"; const isDevContainer = process.env.REMOTE_CONTAINERS !== undefined; @@ -9,7 +9,7 @@ if (process.env.VERCEL_ENV !== "production") { // Set the WebSocket proxy to work with the local instance if (isDevContainer) { // Running inside a VS Code devcontainer - neonConfig.wsProxy = (host) => `host.docker.internal:5481/v1`; + neonConfig.wsProxy = (host) => "host.docker.internal:5481/v1"; } else { // Not running inside a VS Code devcontainer neonConfig.wsProxy = (host) => `${host}:5481/v1`; @@ -26,5 +26,5 @@ export const db = neonDrizzle( }), { schema, - } + }, ); diff --git a/web/src/db/schema.ts b/web/src/db/schema.ts index 20372ff..02bbedf 100644 --- a/web/src/db/schema.ts +++ b/web/src/db/schema.ts @@ -1,13 +1,13 @@ import { type InferSelectModel, relations } from "drizzle-orm"; import { - boolean, - integer, - jsonb, - pgEnum, - pgSchema, - text, - timestamp, - uuid, + boolean, + integer, + jsonb, + pgEnum, + pgSchema, + text, + timestamp, + uuid, } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; @@ -87,7 +87,7 @@ export const workflowVersionRelations = relations( fields: [workflowVersionTable.workflow_id], references: [workflowTable.id], }), - }) + }), ); export const workflowRunStatus = pgEnum("workflow_run_status", [ @@ -130,30 +130,30 @@ export const machinesStatus = pgEnum("machine_status", [ // We still want to keep the workflow run record. export const workflowRunsTable = dbSchema.table("workflow_runs", { - id: uuid("id").primaryKey().defaultRandom().notNull(), - // when workflow version deleted, still want to keep this record - workflow_version_id: uuid("workflow_version_id").references( - () => workflowVersionTable.id, - { - onDelete: "set null", - }, - ), - workflow_inputs: - jsonb("workflow_inputs").$type>(), - workflow_id: uuid("workflow_id") - .notNull() - .references(() => workflowTable.id, { - onDelete: "cascade", - }), - // when machine deleted, still want to keep this record - machine_id: uuid("machine_id").references(() => machinesTable.id, { - onDelete: "set null", - }), - origin: workflowRunOrigin("origin").notNull().default("api"), - status: workflowRunStatus("status").notNull().default("not-started"), - ended_at: timestamp("ended_at"), - created_at: timestamp("created_at").defaultNow().notNull(), - started_at: timestamp("started_at"), + id: uuid("id").primaryKey().defaultRandom().notNull(), + // when workflow version deleted, still want to keep this record + workflow_version_id: uuid("workflow_version_id").references( + () => workflowVersionTable.id, + { + onDelete: "set null", + }, + ), + workflow_inputs: + jsonb("workflow_inputs").$type>(), + workflow_id: uuid("workflow_id") + .notNull() + .references(() => workflowTable.id, { + onDelete: "cascade", + }), + // when machine deleted, still want to keep this record + machine_id: uuid("machine_id").references(() => machinesTable.id, { + onDelete: "set null", + }), + origin: workflowRunOrigin("origin").notNull().default("api"), + status: workflowRunStatus("status").notNull().default("not-started"), + ended_at: timestamp("ended_at"), + created_at: timestamp("created_at").defaultNow().notNull(), + started_at: timestamp("started_at"), }); export const workflowRunRelations = relations( @@ -172,7 +172,7 @@ export const workflowRunRelations = relations( fields: [workflowRunsTable.workflow_id], references: [workflowTable.id], }), - }) + }), ); // We still want to keep the workflow run record. @@ -196,7 +196,7 @@ export const workflowOutputRelations = relations( fields: [workflowRunOutputs.run_id], references: [workflowRunsTable.id], }), - }) + }), ); // when user delete, also delete all the workflow versions @@ -229,7 +229,7 @@ export const snapshotType = z.object({ z.object({ hash: z.string(), disabled: z.boolean(), - }) + }), ), file_custom_nodes: z.array(z.any()), }); @@ -244,7 +244,7 @@ export const showcaseMedia = z.array( z.object({ url: z.string(), isCover: z.boolean().default(false), - }) + }), ); export const showcaseMediaNullable = z @@ -252,36 +252,36 @@ export const showcaseMediaNullable = z z.object({ url: z.string(), isCover: z.boolean().default(false), - }) + }), ) .nullable(); export const deploymentsTable = dbSchema.table("deployments", { - id: uuid("id").primaryKey().defaultRandom().notNull(), - user_id: text("user_id") - .references(() => usersTable.id, { - onDelete: "cascade", - }) - .notNull(), - org_id: text("org_id"), - workflow_version_id: uuid("workflow_version_id") - .notNull() - .references(() => workflowVersionTable.id), - workflow_id: uuid("workflow_id") - .notNull() - .references(() => workflowTable.id, { - onDelete: "cascade", - }), - machine_id: uuid("machine_id") - .notNull() - .references(() => machinesTable.id), - share_slug: text("share_slug").unique(), - description: text("description"), - showcase_media: - jsonb("showcase_media").$type>(), - environment: deploymentEnvironment("environment").notNull(), - created_at: timestamp("created_at").defaultNow().notNull(), - updated_at: timestamp("updated_at").defaultNow().notNull(), + id: uuid("id").primaryKey().defaultRandom().notNull(), + user_id: text("user_id") + .references(() => usersTable.id, { + onDelete: "cascade", + }) + .notNull(), + org_id: text("org_id"), + workflow_version_id: uuid("workflow_version_id") + .notNull() + .references(() => workflowVersionTable.id), + workflow_id: uuid("workflow_id") + .notNull() + .references(() => workflowTable.id, { + onDelete: "cascade", + }), + machine_id: uuid("machine_id") + .notNull() + .references(() => machinesTable.id), + share_slug: text("share_slug").unique(), + description: text("description"), + showcase_media: + jsonb("showcase_media").$type>(), + environment: deploymentEnvironment("environment").notNull(), + created_at: timestamp("created_at").defaultNow().notNull(), + updated_at: timestamp("updated_at").defaultNow().notNull(), }); export const publicShareDeployment = z.object({ @@ -331,6 +331,16 @@ export const apiKeyTable = dbSchema.table("api_keys", { updated_at: timestamp("updated_at").defaultNow().notNull(), }); +export const authRequestsTable = dbSchema.table("auth_requests", { + request_id: text("request_id").primaryKey().notNull(), + user_id: text("user_id"), + org_id: text("org_id"), + api_hash: text("api_hash"), + created_at: timestamp("created_at").defaultNow().notNull(), + expired_date: timestamp("expired_date"), + updated_at: timestamp("updated_at").defaultNow().notNull(), +}); + export type UserType = InferSelectModel; export type WorkflowType = InferSelectModel; export type MachineType = InferSelectModel; diff --git a/web/src/routes/newId.ts b/web/src/routes/newId.ts new file mode 100644 index 0000000..6c3d144 --- /dev/null +++ b/web/src/routes/newId.ts @@ -0,0 +1,13 @@ +import { customAlphabet } from "nanoid"; + +export const nanoid = customAlphabet( + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz", +); +const prefixes = { + img: "img", + vid: "vid", +} as const; + +export function newId(prefix: keyof typeof prefixes): string { + return [prefixes[prefix], nanoid(16)].join("_"); +} diff --git a/web/src/routes/registerCreateRunRoute.ts b/web/src/routes/registerCreateRunRoute.ts index 376c8d4..98143eb 100644 --- a/web/src/routes/registerCreateRunRoute.ts +++ b/web/src/routes/registerCreateRunRoute.ts @@ -1,10 +1,10 @@ -import { createRun } from "../server/createRun"; import { db } from "@/db/db"; import { deploymentsTable } from "@/db/schema"; import type { App } from "@/routes/app"; import { authError } from "@/routes/authError"; -import { z, createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { eq } from "drizzle-orm"; +import { createRun } from "../server/createRun"; const createRunRoute = createRoute({ method: "post", @@ -99,7 +99,7 @@ export const registerCreateRunRoute = (app: App) => { }, { status: 500, - } + }, ); } }); diff --git a/web/src/routes/registerGetAuthResponse.ts b/web/src/routes/registerGetAuthResponse.ts new file mode 100644 index 0000000..196dfbb --- /dev/null +++ b/web/src/routes/registerGetAuthResponse.ts @@ -0,0 +1,150 @@ +import { db } from "@/db/db"; +import { authRequestsTable } from "@/db/schema"; +import type { App } from "@/routes/app"; +import { authError } from "@/routes/authError"; +import { z, createRoute } from "@hono/zod-openapi"; +import { eq } from "drizzle-orm"; +import jwt from "jsonwebtoken"; +import crypto from "crypto"; +import { getOrgOrUserDisplayName } from "@/server/getOrgOrUserDisplayName"; +import ms from "ms"; + +const route = createRoute({ + method: "get", + path: "/auth-response/:request_id", + tags: ["comfyui"], + summary: "Get an API Key with code", + description: + "This endpoints is specifically built for ComfyUI workflow upload.", + request: { + params: z.object({ + request_id: z.string(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + api_key: z.string(), + name: z.string(), + }), + }, + }, + description: "The returned API Key", + }, + 201: { + content: { + "application/json": { + schema: z.object({ + message: z.string(), + }), + }, + }, + description: "The API key is not yet ready", + }, + 500: { + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + description: "Error when fetching the API Key with code", + }, + ...authError, + }, +}); + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", +}; + +export const registerGetAuthResponse = (app: App) => { + return app.openapi(route, async (c) => { + const { request_id } = c.req.valid("param"); + + try { + const result = await db.query.authRequestsTable.findFirst({ + where: eq(authRequestsTable.request_id, request_id), + }); + + if (result?.api_hash) { + return c.json( + { + message: "Already used.", + }, + { + status: 201, + headers: corsHeaders, + }, + ); + } + + if (result && result.user_id) { + const expireTime = "1w"; + const token = jwt.sign( + { user_id: result.user_id, org_id: result.org_id }, + process.env.JWT_SECRET!, + { + expiresIn: expireTime, + }, + ); + + const hash = crypto.createHash("sha256").update(token).digest("hex"); + + const now = new Date(); + const expiryDate = new Date(now.getTime() + ms(expireTime)); + + await db + .update(authRequestsTable) + .set({ + api_hash: hash, + expired_date: expiryDate, + }) + .where(eq(authRequestsTable.request_id, request_id)); + + const userName = await getOrgOrUserDisplayName( + result.org_id, + result.user_id, + ); + + return c.json( + { + api_key: token, + name: userName, + }, + { + status: 200, + headers: corsHeaders, + }, + ); + } + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return c.json( + { + error: errorMessage, + }, + { + statusText: "Invalid request", + status: 500, + headers: corsHeaders, + }, + ); + } + return c.json( + { + message: "Not ready yet.", + }, + { + status: 201, + headers: corsHeaders, + }, + ); + }); +}; diff --git a/web/src/routes/registerUploadRoute.ts b/web/src/routes/registerUploadRoute.ts index b58d76b..d318b66 100644 --- a/web/src/routes/registerUploadRoute.ts +++ b/web/src/routes/registerUploadRoute.ts @@ -3,20 +3,7 @@ import { authError } from "@/routes/authError"; import { getFileDownloadUrl } from "@/server/getFileDownloadUrl"; import { handleResourceUpload } from "@/server/resource"; import { z, createRoute } from "@hono/zod-openapi"; -import { customAlphabet } from "nanoid"; - -export const nanoid = customAlphabet( - "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" -); - -const prefixes = { - img: "img", - vid: "vid", -} as const; - -export function newId(prefix: keyof typeof prefixes): string { - return [prefixes[prefix], nanoid(16)].join("_"); -} +import { newId } from "./newId"; const uploadUrlRoute = createRoute({ method: "get", @@ -96,7 +83,7 @@ export const registerUploadRoute = (app: App) => { file_id: id, download_url: await getFileDownloadUrl(filePath), }, - 200 + 200, ); } catch (error: unknown) { const errorMessage = @@ -107,7 +94,7 @@ export const registerUploadRoute = (app: App) => { }, { status: 500, - } + }, ); } }); diff --git a/web/src/routes/registerWorkflowUploadRoute.ts b/web/src/routes/registerWorkflowUploadRoute.ts new file mode 100644 index 0000000..b6fe96d --- /dev/null +++ b/web/src/routes/registerWorkflowUploadRoute.ts @@ -0,0 +1,164 @@ +import { snapshotType, workflowAPIType, workflowType } from "@/db/schema"; +import type { App } from "@/routes/app"; +import { authError } from "@/routes/authError"; +import { + createNewWorkflow, + createNewWorkflowVersion, +} from "@/server/createNewWorkflow"; +import { z, createRoute } from "@hono/zod-openapi"; + +const route = createRoute({ + method: "post", + path: "/upload-workflow", + tags: ["comfyui"], + summary: "Upload workflow from ComfyUI", + description: + "This endpoints is specifically built for ComfyUI workflow upload.", + request: { + body: { + content: { + "application/json": { + schema: z.object({ + workflow_id: z.string().optional(), + workflow_name: z.string().min(1).optional(), + workflow: workflowType, + workflow_api: workflowAPIType, + snapshot: snapshotType, + }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + workflow_id: z.string(), + version: z.string(), + }), + }, + }, + description: "Retrieve the output", + }, + 500: { + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + description: "Error when uploading the workflow", + }, + ...authError, + }, +}); + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", +}; + +export const registerWorkflowUploadRoute = (app: App) => { + app.openapi(route, async (c) => { + const { + // user_id, + workflow, + workflow_api, + workflow_id: _workflow_id, + workflow_name, + snapshot, + } = c.req.valid("json"); + const { org_id, user_id } = c.get("apiKeyTokenData")!; + + if (!user_id) + return c.json( + { + error: "Invalid user_id", + }, + { + headers: corsHeaders, + status: 500, + }, + ); + + let workflow_id = _workflow_id; + + let version = -1; + + try { + if ((!workflow_id || workflow_id.length === 0) && workflow_name) { + // Create a new parent workflow + const { workflow_id: _workflow_id, version: _version } = + await createNewWorkflow({ + user_id: user_id, + org_id: org_id, + workflow_name: workflow_name, + workflowData: { + workflow, + workflow_api, + snapshot, + }, + }); + + workflow_id = _workflow_id; + version = _version; + } else if (workflow_id) { + // Case 2 update workflow + const { version: _version } = await createNewWorkflowVersion({ + workflow_id: workflow_id, + workflowData: { + workflow, + workflow_api, + snapshot, + }, + }); + version = _version; + } else { + return c.json( + { + error: "Invalid request, missing either workflow_id or name", + }, + { + status: 500, + statusText: "Invalid request", + headers: corsHeaders, + }, + ); + } + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return c.json( + { + error: errorMessage, + }, + { + statusText: "Invalid request", + status: 500, + headers: corsHeaders, + }, + ); + } + + return c.json( + { + workflow_id: workflow_id, + version: version, + }, + { + status: 200, + headers: corsHeaders, + }, + ); + }); + + app.route("/upload-workflow").options(async (c) => { + return new Response(null, { + status: 204, + headers: corsHeaders, + }); + }); +}; diff --git a/web/src/server/APIKeyBodyRequest.ts b/web/src/server/APIKeyBodyRequest.ts index e7b92bc..3f71a00 100644 --- a/web/src/server/APIKeyBodyRequest.ts +++ b/web/src/server/APIKeyBodyRequest.ts @@ -1,9 +1,10 @@ import { z } from "zod"; export const APIKeyBodyRequest = z.object({ - user_id: z.string().optional(), - org_id: z.string().optional(), + user_id: z.string().optional().nullable(), + org_id: z.string().optional().nullable(), iat: z.number(), + exp: z.number().optional(), }); export type APIKeyUserType = z.infer; diff --git a/web/src/server/createNewWorkflow.ts b/web/src/server/createNewWorkflow.ts index 3636ff9..faa00d7 100644 --- a/web/src/server/createNewWorkflow.ts +++ b/web/src/server/createNewWorkflow.ts @@ -1,6 +1,46 @@ import { db } from "@/db/db"; import type { WorkflowVersionType } from "@/db/schema"; import { workflowTable, workflowVersionTable } from "@/db/schema"; +import { eq, sql } from "drizzle-orm"; + +export async function createNewWorkflowVersion({ + workflow_id, + workflowData, +}: { + workflow_id: string; + workflowData: Pick< + WorkflowVersionType, + "workflow" | "workflow_api" | "snapshot" + >; +}) { + // Add a new version + const data = await db + .insert(workflowVersionTable) + .values({ + workflow_id, + ...workflowData, + version: sql`( + SELECT COALESCE(MAX(version), 0) + 1 + FROM ${workflowVersionTable} + WHERE workflow_id = ${workflow_id} + )`, + }) + .returning(); + const version = data[0].version; + + // Touch up the last updated time + await db + .update(workflowTable) + .set({ + updated_at: new Date(), + }) + .where(eq(workflowTable.id, workflow_id)) + .returning(); + + return { + version, + }; +} export async function createNewWorkflow({ workflow_name, @@ -10,7 +50,7 @@ export async function createNewWorkflow({ }: { workflow_name: string; user_id: string; - org_id?: string; + org_id?: string | null; workflowData: Pick< WorkflowVersionType, "workflow" | "workflow_api" | "snapshot" diff --git a/web/src/server/curdApiKeys.ts b/web/src/server/curdApiKeys.ts index 252bddd..b15b625 100644 --- a/web/src/server/curdApiKeys.ts +++ b/web/src/server/curdApiKeys.ts @@ -1,23 +1,28 @@ "use server"; import { db } from "@/db/db"; -import { apiKeyTable } from "@/db/schema"; +import { apiKeyTable, authRequestsTable } from "@/db/schema"; +import { withServerPromise } from "@/server/withServerPromise"; import { auth } from "@clerk/nextjs"; import { and, desc, eq, isNull } from "drizzle-orm"; import jwt from "jsonwebtoken"; import { revalidatePath } from "next/cache"; -// export const nanoid = customAlphabet( -// "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" -// ); +export const createAuthRequest = withServerPromise( + async (request_id: string) => { + const { userId, orgId } = auth(); -// const prefixes = { -// cd: "cd", -// } as const; + const result = await db.insert(authRequestsTable).values({ + request_id: request_id, + user_id: userId, + org_id: orgId, + }); -// function newId(prefix: keyof typeof prefixes): string { -// return [prefixes[prefix], nanoid(16)].join("_"); -// } + return { + message: "Auth request created, you may now return to your application.", + }; + }, +); export async function addNewAPIKey(name: string) { const { userId, orgId } = auth(); @@ -29,7 +34,7 @@ export async function addNewAPIKey(name: string) { if (orgId) { token = jwt.sign( { user_id: userId, org_id: orgId }, - process.env.JWT_SECRET! + process.env.JWT_SECRET!, ); } else { token = jwt.sign({ user_id: userId }, process.env.JWT_SECRET!); @@ -93,7 +98,7 @@ export async function getAPIKeys() { where: and( eq(apiKeyTable.user_id, userId), isNull(apiKeyTable.org_id), - eq(apiKeyTable.revoked, false) + eq(apiKeyTable.revoked, false), ), orderBy: desc(apiKeyTable.created_at), }); diff --git a/web/src/server/getOrgOrUserDisplayName.tsx b/web/src/server/getOrgOrUserDisplayName.tsx new file mode 100644 index 0000000..562b2c5 --- /dev/null +++ b/web/src/server/getOrgOrUserDisplayName.tsx @@ -0,0 +1,18 @@ +import { db } from "@/db/db"; +import { usersTable } from "@/db/schema"; +import { clerkClient } from "@clerk/nextjs"; +import { eq } from "drizzle-orm"; + +export async function getOrgOrUserDisplayName( + orgId: string | undefined | null, + userId: string, +) { + return orgId + ? await clerkClient.organizations + .getOrganization({ + organizationId: orgId, + }) + .then((x) => x.name) + : (await db.select().from(usersTable).where(eq(usersTable.id, userId)))[0] + .name; +}