Przeglądaj źródła

refactor: use next.js middleware for session parsing and move out of custom express server

Nicolas Meienberger 1 rok temu
rodzic
commit
55ceb32fd9

+ 3 - 5
package.json

@@ -11,15 +11,13 @@
     "test:client": "jest --colors --selectProjects client --",
     "test:server": "jest --colors --selectProjects server --",
     "test:vite": "dotenv -e .env.test -- vitest run --coverage",
-    "dev": "npm run db:migrate && nodemon",
+    "dev": "npm run db:migrate && next dev",
     "dev:watcher": "pnpm -r --filter cli dev",
     "db:migrate": "NODE_ENV=development dotenv -e .env -- tsx ./src/server/run-migrations-dev.ts",
-    "start": "NODE_ENV=production node index.js",
     "lint": "next lint",
     "lint:fix": "next lint --fix",
-    "build": "node ./esbuild.js build",
-    "build:server": "node ./esbuild.js build",
-    "build:next": "next build",
+    "build": "next build",
+    "start": "NODE_ENV=production node server.js",
     "start:dev-container": "./.devcontainer/filewatcher.sh && npm run start:dev",
     "start:rc": "docker compose -f docker-compose.rc.yml --env-file .env up --build",
     "start:dev": "npm run prepare && docker compose -f docker-compose.dev.yml up --build",

+ 1 - 1
src/client/components/AppLogo/AppLogo.tsx

@@ -4,7 +4,7 @@ import { getUrl } from '../../core/helpers/url-helpers';
 import styles from './AppLogo.module.scss';
 
 export const AppLogo: React.FC<{ id?: string; size?: number; className?: string; alt?: string }> = ({ id, size = 80, className = '', alt = '' }) => {
-  const logoUrl = id ? `/static/apps/${id}/metadata/logo.jpg` : getUrl('placeholder.png');
+  const logoUrl = id ? `/api/app-image?id=${id}` : getUrl('placeholder.png');
 
   return (
     <div aria-label={alt} className={clsx(styles.dropShadow, className)} style={{ width: size, height: size }}>

+ 1 - 3
src/client/mocks/fixtures/app.fixtures.ts

@@ -9,7 +9,7 @@ const randomCategory = (): AppCategory[] => {
   return [categories[randomIndex] as AppCategory];
 };
 
-export const createApp = (overrides?: Partial<AppInfo>): AppInfo => {
+const createApp = (overrides?: Partial<AppInfo>): AppInfo => {
   const name = faker.lorem.word();
   return {
     id: name.toLowerCase(),
@@ -62,5 +62,3 @@ export const createAppEntity = (params: CreateAppEntityParams): AppWithInfo => {
     ...overrides,
   };
 };
-
-export const createAppsRandomly = (count: number): AppInfo[] => Array.from({ length: count }).map(() => createApp());

+ 2 - 4
src/client/mocks/getTrpcMock.ts

@@ -2,14 +2,12 @@ import { rest } from 'msw';
 import SuperJSON from 'superjson';
 import type { RouterInput, RouterOutput } from '../../server/routers/_app';
 
-export type RpcResponse<Data> = RpcSuccessResponse<Data> | RpcErrorResponse;
-
-export type RpcSuccessResponse<Data> = {
+type RpcSuccessResponse<Data> = {
   id: null;
   result: { type: 'data'; data: Data };
 };
 
-export type RpcErrorResponse = {
+type RpcErrorResponse = {
   error: {
     json: {
       message: string;

+ 1 - 1
src/client/modules/Settings/components/SettingsForm/SettingsForm.tsx

@@ -95,7 +95,7 @@ export const SettingsForm = (props: IProps) => {
 
   const downloadCertificate = (e: React.MouseEvent<HTMLButtonElement>) => {
     e.preventDefault();
-    window.open('/certificate');
+    window.open('/api/certificate');
   };
 
   return (

+ 4 - 3
src/client/utils/page-helpers.ts

@@ -2,9 +2,11 @@ import { GetServerSideProps } from 'next';
 import merge from 'lodash.merge';
 import { getLocaleFromString } from '@/shared/internationalization/locales';
 import { getCookie } from 'cookies-next';
+import TipiCache from '@/server/core/TipiCache/TipiCache';
 
 export const getAuthedPageProps: GetServerSideProps = async (ctx) => {
-  const { userId } = ctx.req.session;
+  const sessionId = ctx.req.headers['x-session-id'];
+  const userId = await TipiCache.get(`session:${sessionId}`);
 
   if (!userId) {
     return {
@@ -21,11 +23,10 @@ export const getAuthedPageProps: GetServerSideProps = async (ctx) => {
 };
 
 export const getMessagesPageProps: GetServerSideProps = async (ctx) => {
-  const { locale: sessionLocale } = ctx.req.session;
   const cookieLocale = getCookie('tipi-locale', { req: ctx.req });
   const browserLocale = ctx.req.headers['accept-language']?.split(',')[0];
 
-  const locale = getLocaleFromString(String(sessionLocale || cookieLocale || browserLocale || 'en'));
+  const locale = getLocaleFromString(String(cookieLocale || browserLocale || 'en'));
 
   const englishMessages = (await import(`../messages/en.json`)).default;
   const messages = (await import(`../messages/${locale}.json`)).default;

+ 33 - 0
src/middleware.ts

@@ -0,0 +1,33 @@
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+
+/**
+ * Middleware to set session ID in request headers
+ * @param {NextRequest} request - Request object
+ */
+export async function middleware(request: NextRequest) {
+  let sessionId = '';
+
+  const cookie = request.cookies.get('tipi.sid')?.value;
+
+  // Check if session ID exists in cookies
+  if (cookie) {
+    sessionId = cookie;
+  }
+
+  const requestHeaders = new Headers(request.headers);
+
+  if (sessionId) {
+    requestHeaders.set('x-session-id', sessionId);
+  }
+
+  const response = NextResponse.next({
+    request: { headers: requestHeaders },
+  });
+
+  if (sessionId) {
+    response.headers.set('x-session-id', sessionId);
+  }
+
+  return response;
+}

+ 27 - 0
src/pages/api/app-image.ts

@@ -0,0 +1,27 @@
+import fs from 'fs';
+import { getConfig } from '@/server/core/TipiConfig/TipiConfig';
+import { NextApiRequest, NextApiResponse } from 'next';
+import path from 'path';
+
+/**
+ * API endpoint to get the image of an app
+ *
+ * @param {NextApiRequest} req - The request
+ * @param {NextApiResponse} res - The response
+ */
+export default function handler(req: NextApiRequest, res: NextApiResponse) {
+  if (typeof req.query.id !== 'string') {
+    res.status(404).send('Not found');
+    return;
+  }
+
+  try {
+    const filePath = path.join(getConfig().rootFolder, 'repos', getConfig().appsRepoId, 'apps', req.query.id, 'metadata', 'logo.jpg');
+    const file = fs.readFileSync(filePath);
+
+    res.setHeader('Content-Type', 'image/jpeg');
+    res.send(file);
+  } catch (e) {
+    res.status(404).send('Not found');
+  }
+}

+ 37 - 0
src/pages/api/certificate.ts

@@ -0,0 +1,37 @@
+import { getConfig } from '@/server/core/TipiConfig/TipiConfig';
+import TipiCache from '@/server/core/TipiCache/TipiCache';
+import { AuthQueries } from '@/server/queries/auth/auth.queries';
+import { db } from '@/server/db';
+
+import { NextApiRequest, NextApiResponse } from 'next';
+import fs from 'fs-extra';
+
+/**
+ * API endpoint to get the self-signed certificate
+ *
+ * @param {NextApiRequest} req - The request
+ * @param {NextApiResponse} res - The response
+ */
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+  const authService = new AuthQueries(db);
+
+  const sessionId = req.headers['x-session-id'];
+  const userId = await TipiCache.get(`session:${sessionId}`);
+  const user = await authService.getUserById(Number(userId));
+
+  if (user?.operator) {
+    const filePath = `${getConfig().rootFolder}/traefik/tls/cert.pem`;
+
+    if (await fs.pathExists(filePath)) {
+      const file = await fs.promises.readFile(filePath);
+
+      res.setHeader('Content-Type', 'application/x-pem-file');
+      res.setHeader('Content-Dispositon', 'attachment; filename=cert.pem');
+      return res.send(file);
+    }
+
+    res.status(404).send('File not found');
+  }
+
+  return res.status(403).send('Forbidden');
+}

+ 15 - 0
src/server/common/session.helpers.ts

@@ -1,5 +1,20 @@
+import { setCookie } from 'cookies-next';
+import { NextApiRequest, NextApiResponse } from 'next';
 import { v4 } from 'uuid';
+import TipiCache from '../core/TipiCache/TipiCache';
+
+const COOKIE_MAX_AGE = 60 * 60 * 24; // 1 day
+const COOKIE_NAME = 'tipi.sid';
 
 export const generateSessionId = (prefix: string) => {
   return `${prefix}-${v4()}`;
 };
+
+export const setSession = async (sessionId: string, userId: string, req: NextApiRequest, res: NextApiResponse) => {
+  setCookie(COOKIE_NAME, sessionId, { req, res, maxAge: COOKIE_MAX_AGE, httpOnly: true, secure: true, sameSite: false });
+
+  const sessionKey = `session:${sessionId}`;
+
+  await TipiCache.set(sessionKey, userId);
+  await TipiCache.set(`session:${userId}:${sessionId}`, sessionKey);
+};

+ 10 - 9
src/server/context.ts

@@ -1,17 +1,12 @@
 import { inferAsyncReturnType } from '@trpc/server';
 import { CreateNextContextOptions } from '@trpc/server/adapters/next';
-import { Locale } from '@/shared/internationalization/locales';
-
-type Session = {
-  userId?: number;
-  locale?: Locale;
-};
+import TipiCache from './core/TipiCache/TipiCache';
 
 type CreateContextOptions = {
-  req: CreateNextContextOptions['req'] & {
-    session?: Session;
-  };
+  req: CreateNextContextOptions['req'];
   res: CreateNextContextOptions['res'];
+  sessionId: string;
+  userId?: number;
 };
 
 /**
@@ -34,9 +29,15 @@ const createContextInner = async (opts: CreateContextOptions) => ({
 export const createContext = async (opts: CreateNextContextOptions) => {
   const { req, res } = opts;
 
+  const sessionId = req.headers['x-session-id'] as string;
+
+  const userId = await TipiCache.get(`session:${sessionId}`);
+
   return createContextInner({
     req,
     res,
+    sessionId,
+    userId: Number(userId) || undefined,
   });
 };
 

+ 0 - 70
src/server/index.ts

@@ -1,70 +0,0 @@
-/* eslint-disable @typescript-eslint/no-var-requires */
-/* eslint-disable global-require */
-import express from 'express';
-import { parse } from 'url';
-
-import type { NextServer } from 'next/dist/server/next';
-import { getConfig, setConfig } from './core/TipiConfig';
-import { Logger } from './core/Logger';
-import { AppServiceClass } from './services/apps/apps.service';
-import { db } from './db';
-import { sessionMiddleware } from './middlewares/session.middleware';
-import { AuthQueries } from './queries/auth/auth.queries';
-
-let conf = {};
-let nextApp: NextServer;
-
-const hostname = 'localhost';
-const port = parseInt(process.env.PORT || '3000', 10);
-const dev = process.env.NODE_ENV !== 'production';
-
-if (!dev) {
-  // eslint-disable-next-line @typescript-eslint/no-var-requires
-  const NextServer = require('next/dist/server/next-server').default;
-  conf = require('./.next/required-server-files.json').config;
-  nextApp = new NextServer({ hostname: 'localhost', dev, port, customServer: true, conf });
-} else {
-  const next = require('next');
-  nextApp = next({ dev, hostname, port });
-}
-
-const handle = nextApp.getRequestHandler();
-
-nextApp.prepare().then(async () => {
-  const authService = new AuthQueries(db);
-  const app = express();
-
-  app.disable('x-powered-by');
-
-  app.use(sessionMiddleware);
-
-  app.use('/static', express.static(`${getConfig().rootFolder}/repos/${getConfig().appsRepoId}/`));
-
-  app.use('/certificate', async (req, res) => {
-    const userId = req.session?.userId;
-    const user = await authService.getUserById(userId as number);
-
-    if (user?.operator) {
-      res.setHeader('Content-Dispositon', 'attachment; filename=cert.pem');
-      return res.sendFile(`${getConfig().rootFolder}/traefik/tls/cert.pem`);
-    }
-
-    return res.status(403).send('Forbidden');
-  });
-
-  app.all('*', (req, res) => {
-    const parsedUrl = parse(req.url, true);
-
-    handle(req, res, parsedUrl);
-  });
-
-  app.listen(port, async () => {
-    const appService = new AppServiceClass(db);
-
-    setConfig('status', 'RUNNING');
-
-    appService.startAllApps();
-
-    Logger.info(`> Server listening at http://localhost:${port} as ${dev ? 'development' : process.env.NODE_ENV}`);
-  });
-});

+ 12 - 12
src/server/routers/auth/auth.router.ts

@@ -6,26 +6,26 @@ import { db } from '../../db';
 const AuthService = new AuthServiceClass(db);
 
 export const authRouter = router({
-  login: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ input, ctx }) => AuthService.login({ ...input }, ctx.req)),
-  logout: protectedProcedure.mutation(async ({ ctx }) => AuthServiceClass.logout(ctx.req)),
-  register: publicProcedure.input(z.object({ username: z.string(), password: z.string(), locale: z.string() })).mutation(async ({ input, ctx }) => AuthService.register({ ...input }, ctx.req)),
-  me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.req.session?.userId)),
+  login: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ input, ctx }) => AuthService.login({ ...input }, ctx.req, ctx.res)),
+  logout: protectedProcedure.mutation(async ({ ctx }) => AuthServiceClass.logout(ctx.sessionId)),
+  register: publicProcedure
+    .input(z.object({ username: z.string(), password: z.string(), locale: z.string() }))
+    .mutation(async ({ input, ctx }) => AuthService.register({ ...input }, ctx.req, ctx.res)),
+  me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.userId)),
   isConfigured: publicProcedure.query(async () => AuthService.isConfigured()),
-  changeLocale: protectedProcedure
-    .input(z.object({ locale: z.string() }))
-    .mutation(async ({ input, ctx }) => AuthService.changeLocale({ userId: Number(ctx.req.session.userId), locale: input.locale })),
+  changeLocale: protectedProcedure.input(z.object({ locale: z.string() })).mutation(async ({ input, ctx }) => AuthService.changeLocale({ userId: Number(ctx.userId), locale: input.locale })),
   // Password
   checkPasswordChangeRequest: publicProcedure.query(AuthServiceClass.checkPasswordChangeRequest),
   changeOperatorPassword: publicProcedure.input(z.object({ newPassword: z.string() })).mutation(({ input }) => AuthService.changeOperatorPassword({ newPassword: input.newPassword })),
   cancelPasswordChangeRequest: publicProcedure.mutation(AuthServiceClass.cancelPasswordChangeRequest),
   changePassword: protectedProcedure
     .input(z.object({ currentPassword: z.string(), newPassword: z.string() }))
-    .mutation(({ input, ctx }) => AuthService.changePassword({ userId: Number(ctx.req.session.userId), ...input })),
+    .mutation(({ input, ctx }) => AuthService.changePassword({ userId: Number(ctx.userId), ...input })),
   // Totp
-  verifyTotp: publicProcedure.input(z.object({ totpSessionId: z.string(), totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.verifyTotp(input, ctx.req)),
-  getTotpUri: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.getTotpUri({ userId: Number(ctx.req.session.userId), password: input.password })),
-  setupTotp: protectedProcedure.input(z.object({ totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.setupTotp({ userId: Number(ctx.req.session.userId), totpCode: input.totpCode })),
-  disableTotp: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.disableTotp({ userId: Number(ctx.req.session.userId), password: input.password })),
+  verifyTotp: publicProcedure.input(z.object({ totpSessionId: z.string(), totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.verifyTotp(input, ctx.req, ctx.res)),
+  getTotpUri: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.getTotpUri({ userId: Number(ctx.userId), password: input.password })),
+  setupTotp: protectedProcedure.input(z.object({ totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.setupTotp({ userId: Number(ctx.userId), totpCode: input.totpCode })),
+  disableTotp: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.disableTotp({ userId: Number(ctx.userId), password: input.password })),
 });
 
 export type AuthRouter = typeof authRouter;

+ 22 - 21
src/server/services/auth/auth.service.ts

@@ -1,12 +1,13 @@
+import { v4 as uuidv4 } from 'uuid';
 import * as argon2 from 'argon2';
 import validator from 'validator';
 import { TotpAuthenticator } from '@/server/utils/totp';
 import { AuthQueries } from '@/server/queries/auth/auth.queries';
-import { Context } from '@/server/context';
 import { TranslatedError } from '@/server/utils/errors';
 import { Locales, getLocaleFromString } from '@/shared/internationalization/locales';
-import { generateSessionId } from '@/server/common/session.helpers';
+import { generateSessionId, setSession } from '@/server/common/session.helpers';
 import { Database } from '@/server/db';
+import { NextApiRequest, NextApiResponse } from 'next';
 import { getConfig } from '../../core/TipiConfig';
 import TipiCache from '../../core/TipiCache';
 import { fileExists, unlinkFile } from '../../common/fs.helpers';
@@ -29,9 +30,10 @@ export class AuthServiceClass {
    * Authenticate user with given username and password
    *
    * @param {UsernamePasswordInput} input - An object containing the user's username and password
-   * @param {Request} req - The Next.js request object
+   * @param {NextApiRequest} req - The Next.js request object
+   * @param {NextApiResponse} res - The Next.js response object
    */
-  public login = async (input: UsernamePasswordInput, req: Context['req']) => {
+  public login = async (input: UsernamePasswordInput, req: NextApiRequest, res: NextApiResponse) => {
     const { password, username } = input;
     const user = await this.queries.getUserByUsername(username);
 
@@ -51,8 +53,8 @@ export class AuthServiceClass {
       return { totpSessionId };
     }
 
-    req.session.userId = user.id;
-    await TipiCache.set(`session:${user.id}:${req.session.id}`, req.session.id);
+    const sessionId = uuidv4();
+    await setSession(sessionId, user.id.toString(), req, res);
 
     return {};
   };
@@ -63,9 +65,10 @@ export class AuthServiceClass {
    * @param {object} params - An object containing the TOTP session ID and the TOTP code
    * @param {string} params.totpSessionId - The TOTP session ID
    * @param {string} params.totpCode - The TOTP code
-   * @param {Request} req - The Next.js request object
+   * @param {NextApiRequest} req - The Next.js request object
+   * @param {NextApiResponse} res - The Next.js response object
    */
-  public verifyTotp = async (params: { totpSessionId: string; totpCode: string }, req: Context['req']) => {
+  public verifyTotp = async (params: { totpSessionId: string; totpCode: string }, req: NextApiRequest, res: NextApiResponse) => {
     const { totpSessionId, totpCode } = params;
     const userId = await TipiCache.get(totpSessionId);
 
@@ -90,7 +93,8 @@ export class AuthServiceClass {
       throw new TranslatedError('server-messages.errors.totp-invalid-code');
     }
 
-    req.session.userId = user.id;
+    const sessionId = uuidv4();
+    await setSession(sessionId, user.id.toString(), req, res);
 
     return true;
   };
@@ -195,9 +199,10 @@ export class AuthServiceClass {
    * Creates a new user with the provided email and password and returns a session token
    *
    * @param {UsernamePasswordInput} input - An object containing the email and password fields
-   * @param {Request} req - The Next.js request object
+   * @param {NextApiRequest} req - The Next.js request object
+   * @param {NextApiResponse} res - The Next.js response object
    */
-  public register = async (input: UsernamePasswordInput, req: Context['req']) => {
+  public register = async (input: UsernamePasswordInput, req: NextApiRequest, res: NextApiResponse) => {
     const operators = await this.queries.getOperators();
 
     if (operators.length > 0) {
@@ -229,8 +234,8 @@ export class AuthServiceClass {
       throw new TranslatedError('server-messages.errors.error-creating-user');
     }
 
-    req.session.userId = newUser.id;
-    await TipiCache.set(`session:${newUser.id}:${req.session.id}`, req.session.id);
+    const sessionId = uuidv4();
+    await setSession(sessionId, newUser.id.toString(), req, res);
 
     return true;
   };
@@ -253,15 +258,11 @@ export class AuthServiceClass {
   /**
    * Logs out the current user by removing the session token
    *
-   * @param  {Request} req - The Next.js request object
+   * @param {string} sessionId - The session token to remove
    * @returns {Promise<boolean>} - Returns true if the session token is removed successfully
    */
-  public static logout = async (req: Context['req']): Promise<boolean> => {
-    if (!req.session) {
-      return true;
-    }
-
-    req.session.destroy(() => {});
+  public static logout = async (sessionId: string): Promise<boolean> => {
+    await TipiCache.del(`session:${sessionId}`);
 
     return true;
   };
@@ -345,7 +346,7 @@ export class AuthServiceClass {
     await Promise.all(
       sessions.map(async (session) => {
         await TipiCache.del(session.key);
-        await TipiCache.del(`tipi:${session.val}`);
+        if (session.val) await TipiCache.del(session.val);
       }),
     );
   };

+ 1 - 7
src/server/trpc.ts

@@ -1,7 +1,6 @@
 import { initTRPC, TRPCError } from '@trpc/server';
 import superjson from 'superjson';
 import { typeToFlattenedError, ZodError } from 'zod';
-import { Locale } from '@/shared/internationalization/locales';
 import { type Context } from './context';
 import { AuthQueries } from './queries/auth/auth.queries';
 import { db } from './db';
@@ -53,19 +52,14 @@ export const publicProcedure = t.procedure;
  * users are logged in
  */
 const isAuthed = t.middleware(async ({ ctx, next }) => {
-  const userId = ctx.req.session?.userId;
+  const { userId } = ctx;
   if (!userId) {
     throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You need to be logged in to perform this action' });
   }
 
   const user = await authQueries.getUserById(userId);
 
-  if (user?.locale) {
-    ctx.req.session.locale = user.locale as Locale;
-  }
-
   if (!user) {
-    ctx.req.session.destroy(() => {});
     throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You need to be logged in to perform this action' });
   }