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
This commit is contained in:
Meier Lukas
2024-03-25 18:57:59 +01:00
committed by GitHub
parent 87808c1349
commit 058a8c4776
15 changed files with 174 additions and 69 deletions

View File

@@ -29,10 +29,10 @@ describe("AppController", () => {
.mockReturnValueOnce(Promise.resolve("ABC")); .mockReturnValueOnce(Promise.resolve("ABC"));
// act // act
const a = await appController.getHello(); const app = await appController.getHello();
// assert // assert
expect(a).toBe("ABC"); expect(app).toBe("ABC");
expect(appService.getHello).toHaveBeenCalledTimes(1); expect(appService.getHello).toHaveBeenCalledTimes(1);
}); });
}); });

View File

@@ -12,14 +12,16 @@
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "pnpm tsx ./src/wssDevServer.ts", "dev": "pnpm with-env tsx ./src/wssDevServer.ts",
"clean": "rm -rf .turbo node_modules", "clean": "rm -rf .turbo node_modules",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --check . --ignore-path ../../.gitignore", "format": "prettier --check . --ignore-path ../../.gitignore",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --"
}, },
"dependencies": { "dependencies": {
"@homarr/auth": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/log": "workspace:^", "@homarr/log": "workspace:^",

View File

@@ -155,9 +155,9 @@ export const boardRouter = createTRPCRouter({
save: publicProcedure save: publicProcedure
.input(validation.board.save) .input(validation.board.save)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await ctx.db.transaction(async (tx) => { await ctx.db.transaction(async (transaction) => {
const dbBoard = await getFullBoardWithWhere( const dbBoard = await getFullBoardWithWhere(
tx, transaction,
eq(boards.id, input.id), eq(boards.id, input.id),
); );
@@ -167,7 +167,7 @@ export const boardRouter = createTRPCRouter({
); );
if (addedSections.length > 0) { if (addedSections.length > 0) {
await tx.insert(sections).values( await transaction.insert(sections).values(
addedSections.map((section) => ({ addedSections.map((section) => ({
id: section.id, id: section.id,
kind: section.kind, kind: section.kind,
@@ -188,7 +188,7 @@ export const boardRouter = createTRPCRouter({
const addedItems = filterAddedItems(inputItems, dbItems); const addedItems = filterAddedItems(inputItems, dbItems);
if (addedItems.length > 0) { if (addedItems.length > 0) {
await tx.insert(items).values( await transaction.insert(items).values(
addedItems.map((item) => ({ addedItems.map((item) => ({
id: item.id, id: item.id,
kind: item.kind, kind: item.kind,
@@ -226,7 +226,7 @@ export const boardRouter = createTRPCRouter({
); );
if (addedIntegrationRelations.length > 0) { if (addedIntegrationRelations.length > 0) {
await tx.insert(integrationItems).values( await transaction.insert(integrationItems).values(
addedIntegrationRelations.map((relation) => ({ addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId, itemId: relation.itemId,
integrationId: relation.integrationId, integrationId: relation.integrationId,
@@ -237,7 +237,7 @@ export const boardRouter = createTRPCRouter({
const updatedItems = filterUpdatedItems(inputItems, dbItems); const updatedItems = filterUpdatedItems(inputItems, dbItems);
for (const item of updatedItems) { for (const item of updatedItems) {
await tx await transaction
.update(items) .update(items)
.set({ .set({
kind: item.kind, kind: item.kind,
@@ -260,7 +260,7 @@ export const boardRouter = createTRPCRouter({
const prev = dbBoard.sections.find( const prev = dbBoard.sections.find(
(dbSection) => dbSection.id === section.id, (dbSection) => dbSection.id === section.id,
); );
await tx await transaction
.update(sections) .update(sections)
.set({ .set({
position: section.position, position: section.position,
@@ -282,7 +282,7 @@ export const boardRouter = createTRPCRouter({
); );
for (const relation of removedIntegrationRelations) { for (const relation of removedIntegrationRelations) {
await tx await transaction
.delete(integrationItems) .delete(integrationItems)
.where( .where(
and( and(
@@ -296,7 +296,7 @@ export const boardRouter = createTRPCRouter({
const itemIds = removedItems.map((item) => item.id); const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) { 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( const removedSections = filterRemovedItems(
@@ -306,7 +306,9 @@ export const boardRouter = createTRPCRouter({
const sectionIds = removedSections.map((section) => section.id); const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) { 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 savePermissions: publicProcedure
.input(validation.board.savePermissions) .input(validation.board.savePermissions)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await ctx.db.transaction(async (tx) => { await ctx.db.transaction(async (transaction) => {
await tx await transaction
.delete(boardPermissions) .delete(boardPermissions)
.where(eq(boardPermissions.boardId, input.id)); .where(eq(boardPermissions.boardId, input.id));
if (input.permissions.length === 0) { if (input.permissions.length === 0) {
return; return;
} }
await tx.insert(boardPermissions).values( await transaction.insert(boardPermissions).values(
input.permissions.map((permission) => ({ input.permissions.map((permission) => ({
userId: permission.user.id, userId: permission.user.id,
permission: permission.permission, permission: permission.permission,

View File

@@ -245,19 +245,27 @@ const key = Buffer.from(
//Encrypting text //Encrypting text
export function encryptSecret(text: string): `${string}.${string}` { export function encryptSecret(text: string): `${string}.${string}` {
const iv = crypto.randomBytes(16); const initializationVector = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), iv); const cipher = crypto.createCipheriv(
algorithm,
Buffer.from(key),
initializationVector,
);
let encrypted = cipher.update(text); let encrypted = cipher.update(text);
encrypted = Buffer.concat([encrypted, cipher.final()]); encrypted = Buffer.concat([encrypted, cipher.final()]);
return `${encrypted.toString("hex")}.${iv.toString("hex")}`; return `${encrypted.toString("hex")}.${initializationVector.toString("hex")}`;
} }
// Decrypting text // Decrypting text
function decryptSecret(value: `${string}.${string}`) { function decryptSecret(value: `${string}.${string}`) {
const [data, dataIv] = value.split(".") as [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 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); let decrypted = decipher.update(encryptedText);
decrypted = Buffer.concat([decrypted, decipher.final()]); decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString(); return decrypted.toString();

View File

@@ -10,7 +10,6 @@ import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson"; import superjson from "superjson";
import type { Session } from "@homarr/auth"; import type { Session } from "@homarr/auth";
import { auth } from "@homarr/auth";
import { db } from "@homarr/db"; import { db } from "@homarr/db";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import { ZodError } from "@homarr/validation"; import { ZodError } from "@homarr/validation";
@@ -27,11 +26,11 @@ import { ZodError } from "@homarr/validation";
* *
* @see https://trpc.io/docs/server/context * @see https://trpc.io/docs/server/context
*/ */
export const createTRPCContext = async (opts: { export const createTRPCContext = (opts: {
headers: Headers; headers: Headers;
session: Session | null; session: Session | null;
}) => { }) => {
const session = opts.session ?? (await auth()); const session = opts.session;
const source = opts.headers.get("x-trpc-source") ?? "unknown"; const source = opts.headers.get("x-trpc-source") ?? "unknown";
logger.info( logger.info(

View File

@@ -1,6 +1,9 @@
import { applyWSSHandler } from "@trpc/server/adapters/ws"; import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { WebSocketServer } from "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 { logger } from "@homarr/log";
import { appRouter } from "./root"; import { appRouter } from "./root";
@@ -12,33 +15,41 @@ const wss = new WebSocketServer({
const handler = applyWSSHandler({ const handler = applyWSSHandler({
wss, wss,
router: appRouter, router: appRouter,
createContext: ({ req }) => { createContext: async ({ req }) => {
return createTRPCContext({ try {
headers: { const headers = Object.entries(req.headers).map(
...req.headers, ([key, value]) =>
get(key: string) { [key, typeof value === "string" ? value : value?.[0]] as [
const item = req.headers[key]; string,
return typeof item === "string" ? item ?? null : item?.at(0) ?? null; string,
}, ],
} as Headers, );
session: { const nextHeaders = new Headers(headers);
// TODO: replace with actual session
user: { const store = parseCookies(nextHeaders.get("cookie") ?? "");
id: "1", const sessionToken = store[sessionTokenCookieName];
name: "Test User",
email: "", const session = await getSessionFromToken(db, sessionToken);
},
expires: new Date().toISOString(), 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( logger.info(
` Connection (${wss.clients.size}) ${incomingMessage.method} ${incomingMessage.url}`, ` Connection (${wss.clients.size}) ${incomingMessage.method} ${incomingMessage.url}`,
); );
ws.once("close", (code, reason) => { websocket.once("close", (code, reason) => {
logger.info( logger.info(
` Connection (${wss.clients.size}) ${code} ${reason.toString()}`, ` Connection (${wss.clients.size}) ${code} ${reason.toString()}`,
); );

View File

@@ -17,3 +17,4 @@ export * from "./security";
export const createHandlers = (isCredentialsRequest: boolean) => export const createHandlers = (isCredentialsRequest: boolean) =>
createConfiguration(isCredentialsRequest); createConfiguration(isCredentialsRequest);
export const { auth } = createConfiguration(false); export const { auth } = createConfiguration(false);
export { getSessionFromToken, sessionTokenCookieName } from "./session";

View File

@@ -1,4 +1,7 @@
import { randomUUID } from "crypto"; 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 sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days
export const sessionTokenCookieName = "next-auth.session-token"; export const sessionTokenCookieName = "next-auth.session-token";
@@ -10,3 +13,38 @@ export const expireDateAfter = (seconds: number) => {
export const generateSessionToken = () => { export const generateSessionToken = () => {
return randomUUID(); return randomUUID();
}; };
export const getSessionFromToken = async (
db: Database,
token: string | undefined,
): Promise<Session | null> => {
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(),
};
};

View File

@@ -0,0 +1,17 @@
export function parseCookies(cookieString: string) {
const list: Record<string, string> = {};
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;
}

View File

@@ -1,2 +1,3 @@
export * from "./object"; export * from "./object";
export * from "./string"; export * from "./string";
export * from "./cookie";

View File

@@ -85,8 +85,10 @@ export const verificationTokens = mysqlTable(
token: varchar("token", { length: 512 }).notNull(), token: varchar("token", { length: 512 }).notNull(),
expires: timestamp("expires").notNull(), expires: timestamp("expires").notNull(),
}, },
(vt) => ({ (verificationToken) => ({
compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }), compoundKey: primaryKey({
columns: [verificationToken.identifier, verificationToken.token],
}),
}), }),
); );
@@ -115,12 +117,14 @@ export const integrationSecrets = mysqlTable(
.notNull() .notNull()
.references(() => integrations.id, { onDelete: "cascade" }), .references(() => integrations.id, { onDelete: "cascade" }),
}, },
(is) => ({ (integrationSecret) => ({
compoundKey: primaryKey({ compoundKey: primaryKey({
columns: [is.integrationId, is.kind], columns: [integrationSecret.integrationId, integrationSecret.kind],
}), }),
kindIdx: index("integration_secret__kind_idx").on(is.kind), kindIdx: index("integration_secret__kind_idx").on(integrationSecret.kind),
updatedAtIdx: index("integration_secret__updated_at_idx").on(is.updatedAt), 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 }) => ({ export const userRelations = relations(users, ({ many }) => ({
accounts: many(accounts), accounts: many(accounts),
boards: many(boards), boards: many(boards),
boardPermissions: many(boardPermissions), boardPermissions: many(boardPermissions),
})); }));
export const sessionRelations = relations(sessions, ({ one }) => ({
user: one(users, {
fields: [sessions.userId],
references: [users.id],
}),
}));
export const boardPermissionRelations = relations( export const boardPermissionRelations = relations(
boardPermissions, boardPermissions,
({ one }) => ({ ({ one }) => ({

View File

@@ -82,8 +82,10 @@ export const verificationTokens = sqliteTable(
token: text("token").notNull(), token: text("token").notNull(),
expires: integer("expires", { mode: "timestamp_ms" }).notNull(), expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
}, },
(vt) => ({ (verificationToken) => ({
compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }), compoundKey: primaryKey({
columns: [verificationToken.identifier, verificationToken.token],
}),
}), }),
); );
@@ -110,12 +112,14 @@ export const integrationSecrets = sqliteTable(
.notNull() .notNull()
.references(() => integrations.id, { onDelete: "cascade" }), .references(() => integrations.id, { onDelete: "cascade" }),
}, },
(is) => ({ (integrationSecret) => ({
compoundKey: primaryKey({ compoundKey: primaryKey({
columns: [is.integrationId, is.kind], columns: [integrationSecret.integrationId, integrationSecret.kind],
}), }),
kindIdx: index("integration_secret__kind_idx").on(is.kind), kindIdx: index("integration_secret__kind_idx").on(integrationSecret.kind),
updatedAtIdx: index("integration_secret__updated_at_idx").on(is.updatedAt), updatedAtIdx: index("integration_secret__updated_at_idx").on(
integrationSecret.updatedAt,
),
}), }),
); );
@@ -229,6 +233,13 @@ export const userRelations = relations(users, ({ many }) => ({
boardPermissions: many(boardPermissions), boardPermissions: many(boardPermissions),
})); }));
export const sessionRelations = relations(sessions, ({ one }) => ({
user: one(users, {
fields: [sessions.userId],
references: [users.id],
}),
}));
export const boardPermissionRelations = relations( export const boardPermissionRelations = relations(
boardPermissions, boardPermissions,
({ one }) => ({ ({ one }) => ({

View File

@@ -9,26 +9,28 @@ import {
} from "./spotlight-store"; } from "./spotlight-store";
import type { SpotlightActionGroup } from "./type"; import type { SpotlightActionGroup } from "./type";
const disableArrowUpAndDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const disableArrowUpAndDown = (
if (e.key === "ArrowDown") { event: React.KeyboardEvent<HTMLInputElement>,
) => {
if (event.key === "ArrowDown") {
selectNextAction(spotlightStore); selectNextAction(spotlightStore);
e.preventDefault(); event.preventDefault();
} else if (e.key === "ArrowUp") { } else if (event.key === "ArrowUp") {
selectPreviousAction(spotlightStore); selectPreviousAction(spotlightStore);
e.preventDefault(); event.preventDefault();
} else if (e.key === "Enter") { } else if (event.key === "Enter") {
triggerSelectedAction(spotlightStore); triggerSelectedAction(spotlightStore);
} }
}; };
const focusActiveByDefault = (e: React.FocusEvent<HTMLInputElement>) => { const focusActiveByDefault = (event: React.FocusEvent<HTMLInputElement>) => {
const relatedTarget = e.relatedTarget; const relatedTarget = event.relatedTarget;
const isPreviousTargetRadio = const isPreviousTargetRadio =
relatedTarget && "type" in relatedTarget && relatedTarget.type === "radio"; relatedTarget && "type" in relatedTarget && relatedTarget.type === "radio";
if (isPreviousTargetRadio) return; if (isPreviousTargetRadio) return;
const group = e.currentTarget.parentElement?.parentElement; const group = event.currentTarget.parentElement?.parentElement;
if (!group) return; if (!group) return;
const label = group.querySelector<HTMLLabelElement>("label[data-checked]"); const label = group.querySelector<HTMLLabelElement>("label[data-checked]");
if (!label) return; if (!label) return;

3
pnpm-lock.yaml generated
View File

@@ -307,6 +307,9 @@ importers:
'@homarr/auth': '@homarr/auth':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../auth version: link:../auth
'@homarr/common':
specifier: workspace:^0.1.0
version: link:../common
'@homarr/db': '@homarr/db':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../db version: link:../db

View File

@@ -21,7 +21,7 @@ const config = {
"warn", "warn",
{ {
min: 3, 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 <Grid.Col span={{ sm: 12, md: 6 }}> as sm and md would be too short properties: "never", // This allows for example the use of <Grid.Col span={{ sm: 12, md: 6 }}> as sm and md would be too short
}, },
], ],