From 058a8c4776d2cd73653f9c40525847576e7b0a2b Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Mon, 25 Mar 2024 18:57:59 +0100 Subject: [PATCH] feat: add actual user for trpc wss-dev-server (#261) * feat: add actual user for trpc wss-dev-server #233 * chore: address pull request feedback * fix: deepsource issue --- apps/nestjs/src/app.controller.spec.ts | 4 +- packages/api/package.json | 6 ++- packages/api/src/router/board.ts | 28 +++++++------- packages/api/src/router/integration.ts | 18 ++++++--- packages/api/src/trpc.ts | 5 +-- packages/api/src/wssDevServer.ts | 53 ++++++++++++++++---------- packages/auth/index.ts | 1 + packages/auth/session.ts | 38 ++++++++++++++++++ packages/common/src/cookie.ts | 17 +++++++++ packages/common/src/index.ts | 1 + packages/db/schema/mysql.ts | 24 ++++++++---- packages/db/schema/sqlite.ts | 23 ++++++++--- packages/spotlight/src/chip-group.tsx | 20 +++++----- pnpm-lock.yaml | 3 ++ tooling/eslint/base.js | 2 +- 15 files changed, 174 insertions(+), 69 deletions(-) create mode 100644 packages/common/src/cookie.ts diff --git a/apps/nestjs/src/app.controller.spec.ts b/apps/nestjs/src/app.controller.spec.ts index 3b49f8326..45b8d0fb5 100644 --- a/apps/nestjs/src/app.controller.spec.ts +++ b/apps/nestjs/src/app.controller.spec.ts @@ -29,10 +29,10 @@ describe("AppController", () => { .mockReturnValueOnce(Promise.resolve("ABC")); // act - const a = await appController.getHello(); + const app = await appController.getHello(); // assert - expect(a).toBe("ABC"); + expect(app).toBe("ABC"); expect(appService.getHello).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/api/package.json b/packages/api/package.json index 8eab38f3f..c107baa83 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -12,14 +12,16 @@ "license": "MIT", "type": "module", "scripts": { - "dev": "pnpm tsx ./src/wssDevServer.ts", + "dev": "pnpm with-env tsx ./src/wssDevServer.ts", "clean": "rm -rf .turbo node_modules", "lint": "eslint .", "format": "prettier --check . --ignore-path ../../.gitignore", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "with-env": "dotenv -e ../../.env --" }, "dependencies": { "@homarr/auth": "workspace:^0.1.0", + "@homarr/common": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0", "@homarr/log": "workspace:^", diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 871d2660f..4cd92665d 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -155,9 +155,9 @@ export const boardRouter = createTRPCRouter({ save: publicProcedure .input(validation.board.save) .mutation(async ({ input, ctx }) => { - await ctx.db.transaction(async (tx) => { + await ctx.db.transaction(async (transaction) => { const dbBoard = await getFullBoardWithWhere( - tx, + transaction, eq(boards.id, input.id), ); @@ -167,7 +167,7 @@ export const boardRouter = createTRPCRouter({ ); if (addedSections.length > 0) { - await tx.insert(sections).values( + await transaction.insert(sections).values( addedSections.map((section) => ({ id: section.id, kind: section.kind, @@ -188,7 +188,7 @@ export const boardRouter = createTRPCRouter({ const addedItems = filterAddedItems(inputItems, dbItems); if (addedItems.length > 0) { - await tx.insert(items).values( + await transaction.insert(items).values( addedItems.map((item) => ({ id: item.id, kind: item.kind, @@ -226,7 +226,7 @@ export const boardRouter = createTRPCRouter({ ); if (addedIntegrationRelations.length > 0) { - await tx.insert(integrationItems).values( + await transaction.insert(integrationItems).values( addedIntegrationRelations.map((relation) => ({ itemId: relation.itemId, integrationId: relation.integrationId, @@ -237,7 +237,7 @@ export const boardRouter = createTRPCRouter({ const updatedItems = filterUpdatedItems(inputItems, dbItems); for (const item of updatedItems) { - await tx + await transaction .update(items) .set({ kind: item.kind, @@ -260,7 +260,7 @@ export const boardRouter = createTRPCRouter({ const prev = dbBoard.sections.find( (dbSection) => dbSection.id === section.id, ); - await tx + await transaction .update(sections) .set({ position: section.position, @@ -282,7 +282,7 @@ export const boardRouter = createTRPCRouter({ ); for (const relation of removedIntegrationRelations) { - await tx + await transaction .delete(integrationItems) .where( and( @@ -296,7 +296,7 @@ export const boardRouter = createTRPCRouter({ const itemIds = removedItems.map((item) => item.id); if (itemIds.length > 0) { - await tx.delete(items).where(inArray(items.id, itemIds)); + await transaction.delete(items).where(inArray(items.id, itemIds)); } const removedSections = filterRemovedItems( @@ -306,7 +306,9 @@ export const boardRouter = createTRPCRouter({ const sectionIds = removedSections.map((section) => section.id); if (sectionIds.length > 0) { - await tx.delete(sections).where(inArray(sections.id, sectionIds)); + await transaction + .delete(sections) + .where(inArray(sections.id, sectionIds)); } }); }), @@ -340,14 +342,14 @@ export const boardRouter = createTRPCRouter({ savePermissions: publicProcedure .input(validation.board.savePermissions) .mutation(async ({ input, ctx }) => { - await ctx.db.transaction(async (tx) => { - await tx + await ctx.db.transaction(async (transaction) => { + await transaction .delete(boardPermissions) .where(eq(boardPermissions.boardId, input.id)); if (input.permissions.length === 0) { return; } - await tx.insert(boardPermissions).values( + await transaction.insert(boardPermissions).values( input.permissions.map((permission) => ({ userId: permission.user.id, permission: permission.permission, diff --git a/packages/api/src/router/integration.ts b/packages/api/src/router/integration.ts index b1e523fb5..28c4415fb 100644 --- a/packages/api/src/router/integration.ts +++ b/packages/api/src/router/integration.ts @@ -245,19 +245,27 @@ const key = Buffer.from( //Encrypting text export function encryptSecret(text: string): `${string}.${string}` { - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), iv); + const initializationVector = crypto.randomBytes(16); + const cipher = crypto.createCipheriv( + algorithm, + Buffer.from(key), + initializationVector, + ); let encrypted = cipher.update(text); encrypted = Buffer.concat([encrypted, cipher.final()]); - return `${encrypted.toString("hex")}.${iv.toString("hex")}`; + return `${encrypted.toString("hex")}.${initializationVector.toString("hex")}`; } // Decrypting text function decryptSecret(value: `${string}.${string}`) { const [data, dataIv] = value.split(".") as [string, string]; - const iv = Buffer.from(dataIv, "hex"); + const initializationVector = Buffer.from(dataIv, "hex"); const encryptedText = Buffer.from(data, "hex"); - const decipher = crypto.createDecipheriv(algorithm, Buffer.from(key), iv); + const decipher = crypto.createDecipheriv( + algorithm, + Buffer.from(key), + initializationVector, + ); let decrypted = decipher.update(encryptedText); decrypted = Buffer.concat([decrypted, decipher.final()]); return decrypted.toString(); diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 08a2ede92..f4cd583c7 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -10,7 +10,6 @@ import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import type { Session } from "@homarr/auth"; -import { auth } from "@homarr/auth"; import { db } from "@homarr/db"; import { logger } from "@homarr/log"; import { ZodError } from "@homarr/validation"; @@ -27,11 +26,11 @@ import { ZodError } from "@homarr/validation"; * * @see https://trpc.io/docs/server/context */ -export const createTRPCContext = async (opts: { +export const createTRPCContext = (opts: { headers: Headers; session: Session | null; }) => { - const session = opts.session ?? (await auth()); + const session = opts.session; const source = opts.headers.get("x-trpc-source") ?? "unknown"; logger.info( diff --git a/packages/api/src/wssDevServer.ts b/packages/api/src/wssDevServer.ts index 1f09640a0..39a434a69 100644 --- a/packages/api/src/wssDevServer.ts +++ b/packages/api/src/wssDevServer.ts @@ -1,6 +1,9 @@ import { applyWSSHandler } from "@trpc/server/adapters/ws"; import { WebSocketServer } from "ws"; +import { getSessionFromToken, sessionTokenCookieName } from "@homarr/auth"; +import { parseCookies } from "@homarr/common"; +import { db } from "@homarr/db"; import { logger } from "@homarr/log"; import { appRouter } from "./root"; @@ -12,33 +15,41 @@ const wss = new WebSocketServer({ const handler = applyWSSHandler({ wss, router: appRouter, - createContext: ({ req }) => { - return createTRPCContext({ - headers: { - ...req.headers, - get(key: string) { - const item = req.headers[key]; - return typeof item === "string" ? item ?? null : item?.at(0) ?? null; - }, - } as Headers, - session: { - // TODO: replace with actual session - user: { - id: "1", - name: "Test User", - email: "", - }, - expires: new Date().toISOString(), - }, - }); + createContext: async ({ req }) => { + try { + const headers = Object.entries(req.headers).map( + ([key, value]) => + [key, typeof value === "string" ? value : value?.[0]] as [ + string, + string, + ], + ); + const nextHeaders = new Headers(headers); + + const store = parseCookies(nextHeaders.get("cookie") ?? ""); + const sessionToken = store[sessionTokenCookieName]; + + const session = await getSessionFromToken(db, sessionToken); + + return createTRPCContext({ + headers: nextHeaders, + session, + }); + } catch (error) { + logger.error(error); + return createTRPCContext({ + headers: new Headers(), + session: null, + }); + } }, }); -wss.on("connection", (ws, incomingMessage) => { +wss.on("connection", (websocket, incomingMessage) => { logger.info( `➕ Connection (${wss.clients.size}) ${incomingMessage.method} ${incomingMessage.url}`, ); - ws.once("close", (code, reason) => { + websocket.once("close", (code, reason) => { logger.info( `➖ Connection (${wss.clients.size}) ${code} ${reason.toString()}`, ); diff --git a/packages/auth/index.ts b/packages/auth/index.ts index 407e7fa2a..acefe9ee8 100644 --- a/packages/auth/index.ts +++ b/packages/auth/index.ts @@ -17,3 +17,4 @@ export * from "./security"; export const createHandlers = (isCredentialsRequest: boolean) => createConfiguration(isCredentialsRequest); export const { auth } = createConfiguration(false); +export { getSessionFromToken, sessionTokenCookieName } from "./session"; diff --git a/packages/auth/session.ts b/packages/auth/session.ts index 356401439..6c04ddf1c 100644 --- a/packages/auth/session.ts +++ b/packages/auth/session.ts @@ -1,4 +1,7 @@ import { randomUUID } from "crypto"; +import type { Session } from "next-auth"; + +import type { Database } from "@homarr/db"; export const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days export const sessionTokenCookieName = "next-auth.session-token"; @@ -10,3 +13,38 @@ export const expireDateAfter = (seconds: number) => { export const generateSessionToken = () => { return randomUUID(); }; + +export const getSessionFromToken = async ( + db: Database, + token: string | undefined, +): Promise => { + if (!token) { + return null; + } + + const session = await db.query.sessions.findFirst({ + where: ({ sessionToken }, { eq }) => eq(sessionToken, token), + columns: { + expires: true, + }, + with: { + user: { + columns: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + }); + + if (!session) { + return null; + } + + return { + user: session.user, + expires: session.expires.toISOString(), + }; +}; diff --git a/packages/common/src/cookie.ts b/packages/common/src/cookie.ts new file mode 100644 index 000000000..699dd9252 --- /dev/null +++ b/packages/common/src/cookie.ts @@ -0,0 +1,17 @@ +export function parseCookies(cookieString: string) { + const list: Record = {}; + const cookieHeader = cookieString; + if (!cookieHeader) return list; + + cookieHeader.split(";").forEach(function (cookie) { + const items = cookie.split("="); + let name = items.shift(); + name = name?.trim(); + if (!name) return; + const value = items.join("=").trim(); + if (!value) return; + list[name] = decodeURIComponent(value); + }); + + return list; +} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index e136ba1f5..0b71e6748 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,2 +1,3 @@ export * from "./object"; export * from "./string"; +export * from "./cookie"; diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts index 66f75e98e..7ef925b15 100644 --- a/packages/db/schema/mysql.ts +++ b/packages/db/schema/mysql.ts @@ -85,8 +85,10 @@ export const verificationTokens = mysqlTable( token: varchar("token", { length: 512 }).notNull(), expires: timestamp("expires").notNull(), }, - (vt) => ({ - compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }), + (verificationToken) => ({ + compoundKey: primaryKey({ + columns: [verificationToken.identifier, verificationToken.token], + }), }), ); @@ -115,12 +117,14 @@ export const integrationSecrets = mysqlTable( .notNull() .references(() => integrations.id, { onDelete: "cascade" }), }, - (is) => ({ + (integrationSecret) => ({ compoundKey: primaryKey({ - columns: [is.integrationId, is.kind], + columns: [integrationSecret.integrationId, integrationSecret.kind], }), - kindIdx: index("integration_secret__kind_idx").on(is.kind), - updatedAtIdx: index("integration_secret__updated_at_idx").on(is.updatedAt), + kindIdx: index("integration_secret__kind_idx").on(integrationSecret.kind), + updatedAtIdx: index("integration_secret__updated_at_idx").on( + integrationSecret.updatedAt, + ), }), ); @@ -230,11 +234,17 @@ export const accountRelations = relations(accounts, ({ one }) => ({ export const userRelations = relations(users, ({ many }) => ({ accounts: many(accounts), - boards: many(boards), boardPermissions: many(boardPermissions), })); +export const sessionRelations = relations(sessions, ({ one }) => ({ + user: one(users, { + fields: [sessions.userId], + references: [users.id], + }), +})); + export const boardPermissionRelations = relations( boardPermissions, ({ one }) => ({ diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts index cbf21a12e..89439e1e1 100644 --- a/packages/db/schema/sqlite.ts +++ b/packages/db/schema/sqlite.ts @@ -82,8 +82,10 @@ export const verificationTokens = sqliteTable( token: text("token").notNull(), expires: integer("expires", { mode: "timestamp_ms" }).notNull(), }, - (vt) => ({ - compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }), + (verificationToken) => ({ + compoundKey: primaryKey({ + columns: [verificationToken.identifier, verificationToken.token], + }), }), ); @@ -110,12 +112,14 @@ export const integrationSecrets = sqliteTable( .notNull() .references(() => integrations.id, { onDelete: "cascade" }), }, - (is) => ({ + (integrationSecret) => ({ compoundKey: primaryKey({ - columns: [is.integrationId, is.kind], + columns: [integrationSecret.integrationId, integrationSecret.kind], }), - kindIdx: index("integration_secret__kind_idx").on(is.kind), - updatedAtIdx: index("integration_secret__updated_at_idx").on(is.updatedAt), + kindIdx: index("integration_secret__kind_idx").on(integrationSecret.kind), + updatedAtIdx: index("integration_secret__updated_at_idx").on( + integrationSecret.updatedAt, + ), }), ); @@ -229,6 +233,13 @@ export const userRelations = relations(users, ({ many }) => ({ boardPermissions: many(boardPermissions), })); +export const sessionRelations = relations(sessions, ({ one }) => ({ + user: one(users, { + fields: [sessions.userId], + references: [users.id], + }), +})); + export const boardPermissionRelations = relations( boardPermissions, ({ one }) => ({ diff --git a/packages/spotlight/src/chip-group.tsx b/packages/spotlight/src/chip-group.tsx index 73f86ffae..3c3d83dcf 100644 --- a/packages/spotlight/src/chip-group.tsx +++ b/packages/spotlight/src/chip-group.tsx @@ -9,26 +9,28 @@ import { } from "./spotlight-store"; import type { SpotlightActionGroup } from "./type"; -const disableArrowUpAndDown = (e: React.KeyboardEvent) => { - if (e.key === "ArrowDown") { +const disableArrowUpAndDown = ( + event: React.KeyboardEvent, +) => { + if (event.key === "ArrowDown") { selectNextAction(spotlightStore); - e.preventDefault(); - } else if (e.key === "ArrowUp") { + event.preventDefault(); + } else if (event.key === "ArrowUp") { selectPreviousAction(spotlightStore); - e.preventDefault(); - } else if (e.key === "Enter") { + event.preventDefault(); + } else if (event.key === "Enter") { triggerSelectedAction(spotlightStore); } }; -const focusActiveByDefault = (e: React.FocusEvent) => { - const relatedTarget = e.relatedTarget; +const focusActiveByDefault = (event: React.FocusEvent) => { + const relatedTarget = event.relatedTarget; const isPreviousTargetRadio = relatedTarget && "type" in relatedTarget && relatedTarget.type === "radio"; if (isPreviousTargetRadio) return; - const group = e.currentTarget.parentElement?.parentElement; + const group = event.currentTarget.parentElement?.parentElement; if (!group) return; const label = group.querySelector("label[data-checked]"); if (!label) return; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fab0e5bc..14a6f17fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -307,6 +307,9 @@ importers: '@homarr/auth': specifier: workspace:^0.1.0 version: link:../auth + '@homarr/common': + specifier: workspace:^0.1.0 + version: link:../common '@homarr/db': specifier: workspace:^0.1.0 version: link:../db diff --git a/tooling/eslint/base.js b/tooling/eslint/base.js index 867d7cd45..8a222151c 100644 --- a/tooling/eslint/base.js +++ b/tooling/eslint/base.js @@ -21,7 +21,7 @@ const config = { "warn", { min: 3, - exceptions: ["_", "i", "z", "t", "id"], // _ for unused variables, i for index, z for zod, t for translation + exceptions: ["_", "i", "z", "t", "id", "db"], // _ for unused variables, i for index, z for zod, t for translation properties: "never", // This allows for example the use of as sm and md would be too short }, ],