From 1954a50596ddc3e176beb791892c17f2272f46ab Mon Sep 17 00:00:00 2001 From: "Rahul R." Date: Wed, 6 Nov 2024 13:21:28 +0530 Subject: [PATCH 1/5] fix: resolve critical privilege escalation vulnerability --- .gitignore | 2 + api/package.json | 1 + api/src/app.module.ts | 139 +++++++++++++++++++------------ api/src/main.ts | 109 +++++++++++++----------- api/src/services/jwt.strategy.ts | 79 ++++++++++-------- 5 files changed, 192 insertions(+), 138 deletions(-) diff --git a/.gitignore b/.gitignore index 6ea2fd35..e3d1ab3d 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ yarn-error.log /**/.cache .vscode/ + +.env diff --git a/api/package.json b/api/package.json index b8695704..87637f69 100644 --- a/api/package.json +++ b/api/package.json @@ -44,6 +44,7 @@ "class-validator": "^0.14.1", "csv": "^5.5.3", "csv-stringify": "^6.0.5", + "dotenv": "^16.0.3", "ejs": "^3.1.10", "generate-password": "^1.6.1", "gettext-parser": "^4.0.4", diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 441e526d..d8042cd6 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -42,69 +42,102 @@ import { UserService } from './services/user.service'; import ProjectStatsController from './controllers/project-stats.controller'; @Module({ - imports: [ - PassportModule.register({ defaultStrategy: 'jwt' }), - JwtModule.register({ - secretOrPrivateKey: config.secret, - signOptions: { - expiresIn: config.authTokenExpires, - }, - }), - TypeOrmModule.forRoot(config.db.default), - TypeOrmModule.forFeature([User, Invite, ProjectUser, Project, Term, Locale, ProjectLocale, Translation, ProjectClient, Plan, Label]), - HttpModule, - ], - controllers: [ - HealthController, - AuthController, - UserController, - ProjectController, - ProjectStatsController, - ProjectPlanController, - ProjectUserController, - ProjectInviteController, - TermController, - TranslationController, - ImportController, - ProjectClientController, - ProjectLabelController, - ExportsController, - LocaleController, - IndexController, - ], - providers: [UserService, AuthService, MailService, JwtStrategy, AuthorizationService], + imports: [ + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.register({ + secret: config.secret, + signOptions: { + expiresIn: config.authTokenExpires, + } + }), + TypeOrmModule.forRoot(config.db.default), + TypeOrmModule.forFeature([ + User, + Invite, + ProjectUser, + Project, + Term, + Locale, + ProjectLocale, + Translation, + ProjectClient, + Plan, + Label + ]), + HttpModule, + ], + controllers: [ + HealthController, + AuthController, + UserController, + ProjectController, + ProjectStatsController, + ProjectPlanController, + ProjectUserController, + ProjectInviteController, + TermController, + TranslationController, + ImportController, + ProjectClientController, + ProjectLabelController, + ExportsController, + LocaleController, + IndexController, + ], + providers: [ + UserService, + AuthService, + MailService, + JwtStrategy, + AuthorizationService + ], }) export class AppModule { - configure(consumer: MiddlewareConsumer) { - if (config.accessLogsEnabled) { - MorganMiddleware.configure('short'); - consumer.apply(MorganMiddleware).forRoutes('*'); + /** + * Configures middleware for the application, applying the Morgan logging middleware + * conditionally based on the `accessLogsEnabled` configuration. + * + * @param {MiddlewareConsumer} consumer - The `MiddlewareConsumer` instance used to apply middleware to routes. + * @returns {void} - This function does not return a value. + */ + configure(consumer: MiddlewareConsumer): void { + if (config.accessLogsEnabled) { + MorganMiddleware.configure('short'); + consumer.apply(MorganMiddleware).forRoutes('*'); + } } - } } -export const addPipesAndFilters = (app: NestExpressApplication) => { - app.disable('x-powered-by'); +/** + * Configures global pipes, filters, CORS, static assets, and view settings for the given NestExpress application instance. + * This setup is used to ensure consistent security, validation, and resource serving behavior across the application. + * + * @param {NestExpressApplication} app - The NestJS application instance to apply the configurations to. + * @returns {void} - This function does not return a value. + */ +export const addPipesAndFilters = (app: NestExpressApplication): void => { + app.disable('x-powered-by'); - app.set('etag', false); + app.set('etag', false); - if (config.corsEnabled) { - app.enableCors({ origin: '*' }); - } + if (config.corsEnabled) { + app.enableCors({ origin: '*' }); + } - app.useGlobalFilters(new CustomExceptionFilter()); + app.useGlobalFilters(new CustomExceptionFilter()); - app.useGlobalPipes( - new ValidationPipe({ - transform: false, - disableErrorMessages: true, - whitelist: true, - }), - ); + app.useGlobalPipes( + new ValidationPipe({ + transform: false, + disableErrorMessages: true, + whitelist: true, + }), + ); - app.useStaticAssets(config.publicDir, { index: false, redirect: false }); + app.useStaticAssets(config.publicDir, { index: false, redirect: false }); - app.setBaseViewsDir('src/templates'); + app.setBaseViewsDir('src/templates'); - app.engine('html', renderFile); + app.engine('html', renderFile); }; + diff --git a/api/src/main.ts b/api/src/main.ts index d5a359f7..f1c03f36 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -1,74 +1,83 @@ import { NestFactory } from '@nestjs/core'; import { Connection } from 'typeorm'; -import { addPipesAndFilters, AppModule } from './app.module'; -import { config } from './config'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { ExpressAdapter, NestExpressApplication } from '@nestjs/platform-express'; +import { join } from 'path'; + +// see https://github.com/motdotla/dotenv#how-do-i-use-dotenv-with-import +import * as dotenv from 'dotenv'; +dotenv.config({ path: join(__dirname, '../../.env') }); +import { addPipesAndFilters, AppModule } from './app.module'; +import { config } from './config'; import { version } from '../package.json'; interface Closable { - close(): Promise; + close(): Promise; } const closables: Closable[] = []; process.on('SIGINT', async () => { - console.log('Shutting down...'); - for (const closable of closables) { - await closable.close(); - } - process.exit(1); + console.log('Shutting down...'); + try { + await Promise.all(closables.map((closable) => closable.close())); + console.log('All resources closed successfully.'); + } catch (error) { + console.error('Error while shutting down:', error); + } finally { + process.exit(1); + } }); async function bootstrap() { - const app = await NestFactory.create(AppModule, new ExpressAdapter()); - addPipesAndFilters(app); - closables.push(app); + const app = await NestFactory.create(AppModule, new ExpressAdapter()); + addPipesAndFilters(app); + closables.push(app); - // Run migrations - if (config.autoMigrate) { - console.log('Running DB migrations if necessary'); - const connection = app.get(Connection); - await connection.runMigrations(); - console.log('DB migrations up to date'); - } + // Run migrations + if (config.autoMigrate) { + console.log('Running DB migrations if necessary'); + const connection = app.get(Connection); + await connection.runMigrations(); + console.log('DB migrations up to date'); + } - const port = config.port; - const host = '0.0.0.0'; + const port = config.port; + const host = '0.0.0.0'; - // Setup swagger - { - const options = new DocumentBuilder() - .setTitle('Traduora API') - .setDescription( - `

Documentation for the traduora REST API

` + - `

Official website: https://traduora.co
` + - `Additional documentation: https://docs.traduora.co
` + - `Source code: https://github.com/ever-co/ever-traduora

`, - ) - .setVersion(version) - .setBasePath('/') - .addOAuth2({ - type: 'oauth2', - flows: { - password: { - authorizationUrl: '/api/v1/auth/token', - tokenUrl: '/api/v1/auth/token', - scopes: [], - }, - }, - }) - .build(); + // Setup swagger + { + const options = new DocumentBuilder() + .setTitle('Traduora API') + .setDescription( + `

Documentation for the traduora REST API

` + + `

Official website: https://traduora.co
` + + `Additional documentation: https://docs.traduora.co
` + + `Source code: https://github.com/ever-co/ever-traduora

`, + ) + .setVersion(version) + .setBasePath('/') + .addOAuth2({ + type: 'oauth2', + flows: { + password: { + authorizationUrl: '/api/v1/auth/token', + tokenUrl: '/api/v1/auth/token', + scopes: [], + }, + }, + }) + .build(); - const document = SwaggerModule.createDocument(app, options); - SwaggerModule.setup('api/v1/swagger', app, document, { customSiteTitle: 'Traduora API v1 docs' }); - console.log(`Swagger UI available at http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/api/v1/swagger`); - } + const document = SwaggerModule.createDocument(app, options); + SwaggerModule.setup('api/v1/swagger', app, document, { customSiteTitle: 'Traduora API v1 docs' }); + console.log(`Swagger UI available at http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/api/v1/swagger`); + } - await app.listen(port, host, () => { - console.log(`Listening at http://${host}:${port}`); - }); + await app.listen(port, host, () => { + console.log(`Listening at http://${host}:${port}`); + }); } bootstrap(); diff --git a/api/src/services/jwt.strategy.ts b/api/src/services/jwt.strategy.ts index 73736348..d2f4b460 100644 --- a/api/src/services/jwt.strategy.ts +++ b/api/src/services/jwt.strategy.ts @@ -10,42 +10,51 @@ import { User } from '../entity/user.entity'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { - constructor( - @InjectRepository(User) private readonly userRepository: Repository, - @InjectRepository(ProjectClient) private readonly projectClientRepository: Repository, - ) { - super({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - secretOrKey: config.secret, - }); - } - - /** - * - * @param payload - * @returns - */ - async validate(payload: JwtPayload) { - let user: User | ProjectClient; - switch (payload.type) { - case 'user': - user = await this.userRepository.findOne({ - where: { id: payload.sub }, - select: ['id', 'name', 'email', 'numProjectsCreated'], - }); - break; - case 'client': - user = await this.projectClientRepository.findOne({ - where: { id: payload.sub }, - select: ['id'], + constructor( + @InjectRepository(User) private readonly userRepository: Repository, + @InjectRepository(ProjectClient) private readonly projectClientRepository: Repository, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: config.secret, }); - break; - default: - break; } - if (!user) { - throw new UnauthorizedException(); + + /** + * Validates a JWT payload to authenticate a user or project client based on the payload type. + * Retrieves the associated `User` or `ProjectClient` entity from the database and returns it if found. + * Throws an `UnauthorizedException` if no corresponding entity is found for the given payload. + * + * @param {JwtPayload} payload - The JWT payload containing the subject ID and type (user or client). + * @returns {Promise} - Returns a promise that resolves to a `User` or `ProjectClient` entity. + * @throws {UnauthorizedException} - If no user or client is found for the provided payload. + */ + async validate(payload: JwtPayload): Promise { + let user: User | ProjectClient | null = null; + + switch (payload.type) { + case 'user': + user = await this.userRepository.findOne({ + where: { id: payload.sub }, + select: ['id', 'name', 'email', 'numProjectsCreated'], + }); + break; + + case 'client': + user = await this.projectClientRepository.findOne({ + where: { id: payload.sub }, + select: ['id'], + }); + break; + + default: + break; + } + + if (!user) { + throw new UnauthorizedException(); + } + + return user; } - return user; - } } From 92c4a03dfdd04ecabaefa234f6749609533b47a0 Mon Sep 17 00:00:00 2001 From: "Rahul R." Date: Wed, 6 Nov 2024 18:58:33 +0530 Subject: [PATCH 2/5] feat(logging): add detailed warning and info logs for missing environment variables --- .env.sample | 49 +++++++++++++++++++++++++++++++++++++ api/src/config.ts | 3 --- api/src/env.logger.ts | 40 ++++++++++++++++++++++++++++++ api/src/main.ts | 44 ++++++++++++++------------------- api/src/shutdown.handler.ts | 31 +++++++++++++++++++++++ api/src/types.ts | 10 ++++++++ 6 files changed, 149 insertions(+), 28 deletions(-) create mode 100644 .env.sample create mode 100644 api/src/env.logger.ts create mode 100644 api/src/shutdown.handler.ts create mode 100644 api/src/types.ts diff --git a/.env.sample b/.env.sample new file mode 100644 index 00000000..2f204ef9 --- /dev/null +++ b/.env.sample @@ -0,0 +1,49 @@ +# Environment +NODE_ENV=development +TR_PORT=3000 +TR_SECRET=supersecretkey +TR_VIRTUAL_HOST=http://localhost:8080 +TR_PUBLIC_DIR=/path/to/public +TR_TEMPLATES_DIR=/path/to/templates + +# CORS and Logging +TR_CORS_ENABLED=true +TR_ACCESS_LOGS_ENABLED=true + +# Authentication +TR_AUTH_TOKEN_EXPIRES=86400 +TR_SIGNUPS_ENABLED=true + +# Database Configuration +TR_DB_TYPE=mysql +TR_DB_HOST=localhost +TR_DB_PORT=3306 +TR_DB_USER=myuser +TR_DB_PASSWORD=mypassword +TR_DB_DATABASE=mydatabase + +# Database Migration +TR_DB_AUTOMIGRATE=true + +# Project Limits +TR_MAX_PROJECTS_PER_USER=100 +TR_DEFAULT_PROJECT_PLAN=open-source + +# Import Settings +TR_IMPORT_MAX_NESTED_LEVELS=5 + +# Google OAuth Provider +TR_AUTH_GOOGLE_ENABLED=false +TR_AUTH_GOOGLE_CLIENT_SECRET=your-google-client-secret +TR_AUTH_GOOGLE_CLIENT_ID=your-google-client-id +TR_AUTH_GOOGLE_REDIRECT_URL=http://localhost:3000/auth/google/callback + +# Mail Configuration +TR_MAIL_DEBUG=true +TR_MAIL_SENDER=no-reply@myapp.com +TR_MAIL_HOST=smtp.mailtrap.io +TR_MAIL_PORT=587 +TR_MAIL_SECURE=false +TR_MAIL_REJECT_SELF_SIGNED=true +TR_MAIL_USER=myuser +TR_MAIL_PASSWORD=mypassword diff --git a/api/src/config.ts b/api/src/config.ts index 4cb31909..5d41b61e 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -1,13 +1,10 @@ import { TypeOrmModuleOptions } from '@nestjs/typeorm'; import { DefaultNamingStrategy } from 'typeorm'; - import { join } from 'path'; import * as process from 'process'; - import { SnakeNamingStrategy } from './utils/snake-naming-strategy'; const env = process.env; - const getBoolOrDefault = (value: string, defaultValue: boolean) => (value ? value === 'true' : defaultValue); export const config = { diff --git a/api/src/env.logger.ts b/api/src/env.logger.ts new file mode 100644 index 00000000..19b1d9e8 --- /dev/null +++ b/api/src/env.logger.ts @@ -0,0 +1,40 @@ +import { join } from 'path'; +// see https://github.com/motdotla/dotenv#how-do-i-use-dotenv-with-import +import * as dotenv from 'dotenv'; +dotenv.config({ path: join(__dirname, '../../.env') }); + +const env = process.env; + +/** + * Checks for the presence of critical environment variables and logs warnings if any are missing. + * This function logs a warning if `TR_SECRET` is not set, indicating that a default value will be used. + * This helps ensure that necessary environment variables are defined for security and configuration purposes. + */ +export function checkEnvVariables() { + // Check TR_SECRET + if (!env.TR_SECRET) { + console.warn('TR_SECRET not set. Default value will be used. Please make sure you set it to your own value for security reasons in production!'); + } + + // Check Google Auth Configuration + if (env.TR_AUTH_GOOGLE_ENABLED === 'true') { + const missingVars = []; + + if (!env.TR_AUTH_GOOGLE_CLIENT_SECRET) { + missingVars.push('TR_AUTH_GOOGLE_CLIENT_SECRET'); + } + if (!env.TR_AUTH_GOOGLE_CLIENT_ID) { + missingVars.push('TR_AUTH_GOOGLE_CLIENT_ID'); + } + if (!env.TR_AUTH_GOOGLE_REDIRECT_URL) { + missingVars.push('TR_AUTH_GOOGLE_REDIRECT_URL'); + } + + if (missingVars.length > 0) { + console.warn(`You enabled Google Auth with TR_AUTH_GOOGLE_ENABLED = true, but did not set one or more required environment variables: ${missingVars.join(', ')}.`); + } + } else { + console.info(`Google Auth is disabled. To enable, set TR_AUTH_GOOGLE_ENABLED = true and define the required environment variables: TR_AUTH_GOOGLE_CLIENT_SECRET, TR_AUTH_GOOGLE_CLIENT_ID, and TR_AUTH_GOOGLE_REDIRECT_URL.`) + } +} + diff --git a/api/src/main.ts b/api/src/main.ts index f1c03f36..33ce3a9b 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -2,34 +2,19 @@ import { NestFactory } from '@nestjs/core'; import { Connection } from 'typeorm'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { ExpressAdapter, NestExpressApplication } from '@nestjs/platform-express'; -import { join } from 'path'; - -// see https://github.com/motdotla/dotenv#how-do-i-use-dotenv-with-import -import * as dotenv from 'dotenv'; -dotenv.config({ path: join(__dirname, '../../.env') }); - -import { addPipesAndFilters, AppModule } from './app.module'; -import { config } from './config'; import { version } from '../package.json'; - -interface Closable { - close(): Promise; -} +import { setupShutdownHandler } from './shutdown.handler'; +import { checkEnvVariables } from './env.logger'; +import { Closable } from './types'; +import { config } from './config'; +import { addPipesAndFilters, AppModule } from './app.module'; const closables: Closable[] = []; -process.on('SIGINT', async () => { - console.log('Shutting down...'); - try { - await Promise.all(closables.map((closable) => closable.close())); - console.log('All resources closed successfully.'); - } catch (error) { - console.error('Error while shutting down:', error); - } finally { - process.exit(1); - } -}); - +/** + * Bootstraps the application by creating an instance of the AppModule and configuring the necessary components. + * This function also sets up the necessary shutdown handler for graceful application termination. + */ async function bootstrap() { const app = await NestFactory.create(AppModule, new ExpressAdapter()); addPipesAndFilters(app); @@ -80,4 +65,13 @@ async function bootstrap() { }); } -bootstrap(); +// Initialize Check Env Variables +checkEnvVariables() + +// Bootstrap the application +bootstrap().then(() => { + // Initialize the shutdown handler + setupShutdownHandler(closables); +}); + + diff --git a/api/src/shutdown.handler.ts b/api/src/shutdown.handler.ts new file mode 100644 index 00000000..600d5ea1 --- /dev/null +++ b/api/src/shutdown.handler.ts @@ -0,0 +1,31 @@ +import { Closable } from "types"; +import * as process from 'process'; + +/** + * Sets up a shutdown handler for graceful application termination. + * + * This function listens for the SIGINT signal (typically triggered by pressing Ctrl+C) + * and ensures that all provided resources are closed gracefully before the application exits. + * + * @param closables - An array of objects implementing the Closable interface, each containing a `close` method that returns a Promise. + * This method is expected to clean up or release resources associated with each object. + */ +export function setupShutdownHandler(closables: Closable[]) { + process.on('SIGINT', async () => { + console.log('Shutdown signal received. Initiating graceful shutdown...'); + console.log(`Total resources to close: ${closables.length}`); + + try { + for (const [index, closable] of closables.entries()) { + await closable.close(); + console.log(`Resource ${index + 1} closed successfully.`); + } + } catch (error) { + console.error('Error occurred while closing resources:', error); + } finally { + process.exit(1); + } + }); +} + + diff --git a/api/src/types.ts b/api/src/types.ts new file mode 100644 index 00000000..895b75bc --- /dev/null +++ b/api/src/types.ts @@ -0,0 +1,10 @@ +/** + * Defines a contract for objects that can be closed gracefully. + * + * Any class implementing this interface should provide a `close` method + * that performs cleanup or resource release and returns a Promise that + * resolves when the operation is complete. + */ +export interface Closable { + close(): Promise; +} From 9c7f235c285e521efbc52c61f8398da683458d0a Mon Sep 17 00:00:00 2001 From: "Rahul R." Date: Wed, 6 Nov 2024 19:04:04 +0530 Subject: [PATCH 3/5] style: format code with Prettier to fix code style issues --- api/src/app.module.ts | 137 +++++++++++++------------------ api/src/env.logger.ts | 47 ++++++----- api/src/main.ts | 92 ++++++++++----------- api/src/services/jwt.strategy.ts | 82 +++++++++--------- api/src/shutdown.handler.ts | 32 ++++---- api/src/types.ts | 2 +- 6 files changed, 186 insertions(+), 206 deletions(-) diff --git a/api/src/app.module.ts b/api/src/app.module.ts index d8042cd6..b59b8879 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -42,70 +42,52 @@ import { UserService } from './services/user.service'; import ProjectStatsController from './controllers/project-stats.controller'; @Module({ - imports: [ - PassportModule.register({ defaultStrategy: 'jwt' }), - JwtModule.register({ - secret: config.secret, - signOptions: { - expiresIn: config.authTokenExpires, - } - }), - TypeOrmModule.forRoot(config.db.default), - TypeOrmModule.forFeature([ - User, - Invite, - ProjectUser, - Project, - Term, - Locale, - ProjectLocale, - Translation, - ProjectClient, - Plan, - Label - ]), - HttpModule, - ], - controllers: [ - HealthController, - AuthController, - UserController, - ProjectController, - ProjectStatsController, - ProjectPlanController, - ProjectUserController, - ProjectInviteController, - TermController, - TranslationController, - ImportController, - ProjectClientController, - ProjectLabelController, - ExportsController, - LocaleController, - IndexController, - ], - providers: [ - UserService, - AuthService, - MailService, - JwtStrategy, - AuthorizationService - ], + imports: [ + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.register({ + secret: config.secret, + signOptions: { + expiresIn: config.authTokenExpires, + }, + }), + TypeOrmModule.forRoot(config.db.default), + TypeOrmModule.forFeature([User, Invite, ProjectUser, Project, Term, Locale, ProjectLocale, Translation, ProjectClient, Plan, Label]), + HttpModule, + ], + controllers: [ + HealthController, + AuthController, + UserController, + ProjectController, + ProjectStatsController, + ProjectPlanController, + ProjectUserController, + ProjectInviteController, + TermController, + TranslationController, + ImportController, + ProjectClientController, + ProjectLabelController, + ExportsController, + LocaleController, + IndexController, + ], + providers: [UserService, AuthService, MailService, JwtStrategy, AuthorizationService], }) export class AppModule { - /** - * Configures middleware for the application, applying the Morgan logging middleware - * conditionally based on the `accessLogsEnabled` configuration. - * - * @param {MiddlewareConsumer} consumer - The `MiddlewareConsumer` instance used to apply middleware to routes. - * @returns {void} - This function does not return a value. - */ - configure(consumer: MiddlewareConsumer): void { - if (config.accessLogsEnabled) { - MorganMiddleware.configure('short'); - consumer.apply(MorganMiddleware).forRoutes('*'); - } + /** + * Configures middleware for the application, applying the Morgan logging middleware + * conditionally based on the `accessLogsEnabled` configuration. + * + * @param {MiddlewareConsumer} consumer - The `MiddlewareConsumer` instance used to apply middleware to routes. + * @returns {void} - This function does not return a value. + */ + configure(consumer: MiddlewareConsumer): void { + if (config.accessLogsEnabled) { + MorganMiddleware.configure('short'); + consumer.apply(MorganMiddleware).forRoutes('*'); } + } } /** @@ -116,28 +98,27 @@ export class AppModule { * @returns {void} - This function does not return a value. */ export const addPipesAndFilters = (app: NestExpressApplication): void => { - app.disable('x-powered-by'); + app.disable('x-powered-by'); - app.set('etag', false); + app.set('etag', false); - if (config.corsEnabled) { - app.enableCors({ origin: '*' }); - } + if (config.corsEnabled) { + app.enableCors({ origin: '*' }); + } - app.useGlobalFilters(new CustomExceptionFilter()); + app.useGlobalFilters(new CustomExceptionFilter()); - app.useGlobalPipes( - new ValidationPipe({ - transform: false, - disableErrorMessages: true, - whitelist: true, - }), - ); + app.useGlobalPipes( + new ValidationPipe({ + transform: false, + disableErrorMessages: true, + whitelist: true, + }), + ); - app.useStaticAssets(config.publicDir, { index: false, redirect: false }); + app.useStaticAssets(config.publicDir, { index: false, redirect: false }); - app.setBaseViewsDir('src/templates'); + app.setBaseViewsDir('src/templates'); - app.engine('html', renderFile); + app.engine('html', renderFile); }; - diff --git a/api/src/env.logger.ts b/api/src/env.logger.ts index 19b1d9e8..7953f005 100644 --- a/api/src/env.logger.ts +++ b/api/src/env.logger.ts @@ -11,30 +11,33 @@ const env = process.env; * This helps ensure that necessary environment variables are defined for security and configuration purposes. */ export function checkEnvVariables() { - // Check TR_SECRET - if (!env.TR_SECRET) { - console.warn('TR_SECRET not set. Default value will be used. Please make sure you set it to your own value for security reasons in production!'); - } + // Check TR_SECRET + if (!env.TR_SECRET) { + console.warn('TR_SECRET not set. Default value will be used. Please make sure you set it to your own value for security reasons in production!'); + } - // Check Google Auth Configuration - if (env.TR_AUTH_GOOGLE_ENABLED === 'true') { - const missingVars = []; + // Check Google Auth Configuration + if (env.TR_AUTH_GOOGLE_ENABLED === 'true') { + const missingVars = []; - if (!env.TR_AUTH_GOOGLE_CLIENT_SECRET) { - missingVars.push('TR_AUTH_GOOGLE_CLIENT_SECRET'); - } - if (!env.TR_AUTH_GOOGLE_CLIENT_ID) { - missingVars.push('TR_AUTH_GOOGLE_CLIENT_ID'); - } - if (!env.TR_AUTH_GOOGLE_REDIRECT_URL) { - missingVars.push('TR_AUTH_GOOGLE_REDIRECT_URL'); - } + if (!env.TR_AUTH_GOOGLE_CLIENT_SECRET) { + missingVars.push('TR_AUTH_GOOGLE_CLIENT_SECRET'); + } + if (!env.TR_AUTH_GOOGLE_CLIENT_ID) { + missingVars.push('TR_AUTH_GOOGLE_CLIENT_ID'); + } + if (!env.TR_AUTH_GOOGLE_REDIRECT_URL) { + missingVars.push('TR_AUTH_GOOGLE_REDIRECT_URL'); + } - if (missingVars.length > 0) { - console.warn(`You enabled Google Auth with TR_AUTH_GOOGLE_ENABLED = true, but did not set one or more required environment variables: ${missingVars.join(', ')}.`); - } - } else { - console.info(`Google Auth is disabled. To enable, set TR_AUTH_GOOGLE_ENABLED = true and define the required environment variables: TR_AUTH_GOOGLE_CLIENT_SECRET, TR_AUTH_GOOGLE_CLIENT_ID, and TR_AUTH_GOOGLE_REDIRECT_URL.`) + if (missingVars.length > 0) { + console.warn( + `You enabled Google Auth with TR_AUTH_GOOGLE_ENABLED = true, but did not set one or more required environment variables: ${missingVars.join(', ')}.`, + ); } + } else { + console.info( + `Google Auth is disabled. To enable, set TR_AUTH_GOOGLE_ENABLED = true and define the required environment variables: TR_AUTH_GOOGLE_CLIENT_SECRET, TR_AUTH_GOOGLE_CLIENT_ID, and TR_AUTH_GOOGLE_REDIRECT_URL.`, + ); + } } - diff --git a/api/src/main.ts b/api/src/main.ts index 33ce3a9b..646f6e00 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -16,62 +16,60 @@ const closables: Closable[] = []; * This function also sets up the necessary shutdown handler for graceful application termination. */ async function bootstrap() { - const app = await NestFactory.create(AppModule, new ExpressAdapter()); - addPipesAndFilters(app); - closables.push(app); + const app = await NestFactory.create(AppModule, new ExpressAdapter()); + addPipesAndFilters(app); + closables.push(app); - // Run migrations - if (config.autoMigrate) { - console.log('Running DB migrations if necessary'); - const connection = app.get(Connection); - await connection.runMigrations(); - console.log('DB migrations up to date'); - } + // Run migrations + if (config.autoMigrate) { + console.log('Running DB migrations if necessary'); + const connection = app.get(Connection); + await connection.runMigrations(); + console.log('DB migrations up to date'); + } - const port = config.port; - const host = '0.0.0.0'; + const port = config.port; + const host = '0.0.0.0'; - // Setup swagger - { - const options = new DocumentBuilder() - .setTitle('Traduora API') - .setDescription( - `

Documentation for the traduora REST API

` + - `

Official website: https://traduora.co
` + - `Additional documentation: https://docs.traduora.co
` + - `Source code: https://github.com/ever-co/ever-traduora

`, - ) - .setVersion(version) - .setBasePath('/') - .addOAuth2({ - type: 'oauth2', - flows: { - password: { - authorizationUrl: '/api/v1/auth/token', - tokenUrl: '/api/v1/auth/token', - scopes: [], - }, - }, - }) - .build(); + // Setup swagger + { + const options = new DocumentBuilder() + .setTitle('Traduora API') + .setDescription( + `

Documentation for the traduora REST API

` + + `

Official website: https://traduora.co
` + + `Additional documentation: https://docs.traduora.co
` + + `Source code: https://github.com/ever-co/ever-traduora

`, + ) + .setVersion(version) + .setBasePath('/') + .addOAuth2({ + type: 'oauth2', + flows: { + password: { + authorizationUrl: '/api/v1/auth/token', + tokenUrl: '/api/v1/auth/token', + scopes: [], + }, + }, + }) + .build(); - const document = SwaggerModule.createDocument(app, options); - SwaggerModule.setup('api/v1/swagger', app, document, { customSiteTitle: 'Traduora API v1 docs' }); - console.log(`Swagger UI available at http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/api/v1/swagger`); - } + const document = SwaggerModule.createDocument(app, options); + SwaggerModule.setup('api/v1/swagger', app, document, { customSiteTitle: 'Traduora API v1 docs' }); + console.log(`Swagger UI available at http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/api/v1/swagger`); + } - await app.listen(port, host, () => { - console.log(`Listening at http://${host}:${port}`); - }); + await app.listen(port, host, () => { + console.log(`Listening at http://${host}:${port}`); + }); } // Initialize Check Env Variables -checkEnvVariables() +checkEnvVariables(); // Bootstrap the application bootstrap().then(() => { - // Initialize the shutdown handler - setupShutdownHandler(closables); + // Initialize the shutdown handler + setupShutdownHandler(closables); }); - - diff --git a/api/src/services/jwt.strategy.ts b/api/src/services/jwt.strategy.ts index d2f4b460..1c8bd2b7 100644 --- a/api/src/services/jwt.strategy.ts +++ b/api/src/services/jwt.strategy.ts @@ -10,51 +10,51 @@ import { User } from '../entity/user.entity'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { - constructor( - @InjectRepository(User) private readonly userRepository: Repository, - @InjectRepository(ProjectClient) private readonly projectClientRepository: Repository, - ) { - super({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - secretOrKey: config.secret, - }); - } + constructor( + @InjectRepository(User) private readonly userRepository: Repository, + @InjectRepository(ProjectClient) private readonly projectClientRepository: Repository, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: config.secret, + }); + } - /** - * Validates a JWT payload to authenticate a user or project client based on the payload type. - * Retrieves the associated `User` or `ProjectClient` entity from the database and returns it if found. - * Throws an `UnauthorizedException` if no corresponding entity is found for the given payload. - * - * @param {JwtPayload} payload - The JWT payload containing the subject ID and type (user or client). - * @returns {Promise} - Returns a promise that resolves to a `User` or `ProjectClient` entity. - * @throws {UnauthorizedException} - If no user or client is found for the provided payload. - */ - async validate(payload: JwtPayload): Promise { - let user: User | ProjectClient | null = null; + /** + * Validates a JWT payload to authenticate a user or project client based on the payload type. + * Retrieves the associated `User` or `ProjectClient` entity from the database and returns it if found. + * Throws an `UnauthorizedException` if no corresponding entity is found for the given payload. + * + * @param {JwtPayload} payload - The JWT payload containing the subject ID and type (user or client). + * @returns {Promise} - Returns a promise that resolves to a `User` or `ProjectClient` entity. + * @throws {UnauthorizedException} - If no user or client is found for the provided payload. + */ + async validate(payload: JwtPayload): Promise { + let user: User | ProjectClient | null = null; - switch (payload.type) { - case 'user': - user = await this.userRepository.findOne({ - where: { id: payload.sub }, - select: ['id', 'name', 'email', 'numProjectsCreated'], - }); - break; - - case 'client': - user = await this.projectClientRepository.findOne({ - where: { id: payload.sub }, - select: ['id'], - }); - break; + switch (payload.type) { + case 'user': + user = await this.userRepository.findOne({ + where: { id: payload.sub }, + select: ['id', 'name', 'email', 'numProjectsCreated'], + }); + break; - default: - break; - } + case 'client': + user = await this.projectClientRepository.findOne({ + where: { id: payload.sub }, + select: ['id'], + }); + break; - if (!user) { - throw new UnauthorizedException(); - } + default: + break; + } - return user; + if (!user) { + throw new UnauthorizedException(); } + + return user; + } } diff --git a/api/src/shutdown.handler.ts b/api/src/shutdown.handler.ts index 600d5ea1..b53d903c 100644 --- a/api/src/shutdown.handler.ts +++ b/api/src/shutdown.handler.ts @@ -1,4 +1,4 @@ -import { Closable } from "types"; +import { Closable } from 'types'; import * as process from 'process'; /** @@ -11,21 +11,19 @@ import * as process from 'process'; * This method is expected to clean up or release resources associated with each object. */ export function setupShutdownHandler(closables: Closable[]) { - process.on('SIGINT', async () => { - console.log('Shutdown signal received. Initiating graceful shutdown...'); - console.log(`Total resources to close: ${closables.length}`); + process.on('SIGINT', async () => { + console.log('Shutdown signal received. Initiating graceful shutdown...'); + console.log(`Total resources to close: ${closables.length}`); - try { - for (const [index, closable] of closables.entries()) { - await closable.close(); - console.log(`Resource ${index + 1} closed successfully.`); - } - } catch (error) { - console.error('Error occurred while closing resources:', error); - } finally { - process.exit(1); - } - }); + try { + for (const [index, closable] of closables.entries()) { + await closable.close(); + console.log(`Resource ${index + 1} closed successfully.`); + } + } catch (error) { + console.error('Error occurred while closing resources:', error); + } finally { + process.exit(1); + } + }); } - - diff --git a/api/src/types.ts b/api/src/types.ts index 895b75bc..7765e1e8 100644 --- a/api/src/types.ts +++ b/api/src/types.ts @@ -6,5 +6,5 @@ * resolves when the operation is complete. */ export interface Closable { - close(): Promise; + close(): Promise; } From 8a433e2e4de552040aaba37ac38fe7686bb2db40 Mon Sep 17 00:00:00 2001 From: "Rahul R." Date: Wed, 6 Nov 2024 19:14:31 +0530 Subject: [PATCH 4/5] chore: bump version --- api/package.json | 2 +- docs-website/package.json | 2 +- docs-website/static/docs/api/v1/swagger.json | 2 +- package.json | 2 +- webapp/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api/package.json b/api/package.json index 87637f69..dd4099ba 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@ever-traduora/api", - "version": "0.20.0", + "version": "0.20.1", "license": "AGPL-3.0-only", "homepage": "https://traduora.co", "repository": { diff --git a/docs-website/package.json b/docs-website/package.json index 50485599..1643022a 100644 --- a/docs-website/package.json +++ b/docs-website/package.json @@ -1,6 +1,6 @@ { "name": "@ever-traduora/docs", - "version": "0.20.0", + "version": "0.20.1", "license": "AGPL-3.0-only", "homepage": "https://traduora.co", "repository": { diff --git a/docs-website/static/docs/api/v1/swagger.json b/docs-website/static/docs/api/v1/swagger.json index c090515c..2b8a2ef3 100644 --- a/docs-website/static/docs/api/v1/swagger.json +++ b/docs-website/static/docs/api/v1/swagger.json @@ -2,7 +2,7 @@ "swagger": "2.0", "info": { "description": "Documentation for the traduora REST API\n\nOfficial website: https://traduora.co\nAdditional documentation: https://docs.traduora.co\nSource code: https://github.com/ever-co/ever-traduora", - "version": "0.20.0", + "version": "0.20.1", "title": "Ever Traduora API" }, "basePath": "/", diff --git a/package.json b/package.json index 8c422427..f1c8a4e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ever-traduora", - "version": "0.20.0", + "version": "0.20.1", "description": "EverĀ® Traduora - Open Translation Management Platform", "license": "AGPL-3.0-only", "homepage": "https://traduora.co", diff --git a/webapp/package.json b/webapp/package.json index 823bceae..08f25cb5 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -1,6 +1,6 @@ { "name": "@ever-traduora/webapp", - "version": "0.20.0", + "version": "0.20.1", "license": "AGPL-3.0-only", "homepage": "https://traduora.co", "repository": { From 047924e71016722294a2671059ccb239478b9490 Mon Sep 17 00:00:00 2001 From: Duleep Kodithuwakku Date: Sun, 10 Nov 2024 15:51:19 +0100 Subject: [PATCH 5/5] fix: resolve column name handling for postgres (#437) * fix: adjust alias to work with postgres * fix: resolve column name handling for postgres * fix: resolve lint issues * fix: re-adjust query --- api/src/controllers/exports.controller.ts | 9 +++++---- api/src/controllers/project-stats.controller.ts | 9 +++++---- api/src/utils/alias-helper.ts | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 8 deletions(-) create mode 100644 api/src/utils/alias-helper.ts diff --git a/api/src/controllers/exports.controller.ts b/api/src/controllers/exports.controller.ts index 55c9e34e..733e9038 100644 --- a/api/src/controllers/exports.controller.ts +++ b/api/src/controllers/exports.controller.ts @@ -23,6 +23,7 @@ import { ApiOAuth2, ApiTags, ApiOperation, ApiProduces, ApiResponse } from '@nes import { androidXmlExporter } from '../formatters/android-xml'; import { resXExporter } from '../formatters/resx'; import { merge } from 'lodash'; +import { resolveColumnName } from '../utils/alias-helper'; @Controller('api/v1/projects/:projectId/exports') export class ExportsController { @@ -69,10 +70,10 @@ export class ExportsController { const queryBuilder = this.termRepo .createQueryBuilder('term') - .leftJoinAndSelect('term.translations', 'translation', 'translation.projectLocaleId = :projectLocaleId', { + .leftJoinAndSelect('term.translations', 'translation', `translation.${resolveColumnName('projectLocaleId')} = :projectLocaleId`, { projectLocaleId: projectLocale.id, }) - .where('term.projectId = :projectId', { projectId }) + .where(`term.${resolveColumnName('projectId')} = :projectId`, { projectId }) .orderBy('term.value', 'ASC'); if (query.untranslated) { @@ -112,10 +113,10 @@ export class ExportsController { if (fallbackProjectLocale) { const fallbackTermsWithTranslations = await this.termRepo .createQueryBuilder('term') - .leftJoinAndSelect('term.translations', 'translation', 'translation.projectLocaleId = :projectLocaleId', { + .leftJoinAndSelect('term.translations', 'translation', `translation.${resolveColumnName('projectLocaleId')} = :projectLocaleId`, { projectLocaleId: fallbackProjectLocale.id, }) - .where('term.projectId = :projectId', { projectId }) + .where(`term.${resolveColumnName('projectId')} = :projectId`, { projectId }) .orderBy('term.value', 'ASC') .getMany(); diff --git a/api/src/controllers/project-stats.controller.ts b/api/src/controllers/project-stats.controller.ts index 8a651ba9..9ba34e4f 100644 --- a/api/src/controllers/project-stats.controller.ts +++ b/api/src/controllers/project-stats.controller.ts @@ -12,6 +12,7 @@ import { Project } from '../entity/project.entity'; import { Term } from '../entity/term.entity'; import { Translation } from '../entity/translation.entity'; import AuthorizationService from '../services/authorization.service'; +import { resolveColumnName } from '../utils/alias-helper'; @Controller('api/v1/projects/:projectId/stats') @UseGuards(AuthGuard()) @@ -45,11 +46,11 @@ export default class ProjectStatsController { const termCount = membership.project.termsCount; const translatedByLocale = await this.projectLocaleRepo - .createQueryBuilder('projectLocale') - .leftJoin('projectLocale.translations', 'translations') - .select('projectLocale.localeCode', 'localeCode') + .createQueryBuilder(resolveColumnName('projectLocale')) + .leftJoin(`${resolveColumnName('projectLocale')}.translations`, 'translations') + .select(`${resolveColumnName('projectLocale')}.${resolveColumnName('localeCode')}`, 'localeCode') .addSelect('count(*)', 'translated') - .groupBy('localeCode') + .groupBy(resolveColumnName('localeCode')) .whereInIds(locales.map(l => l.id)) .andWhere("translations.value <> ''") .execute(); diff --git a/api/src/utils/alias-helper.ts b/api/src/utils/alias-helper.ts new file mode 100644 index 00000000..5174b06c --- /dev/null +++ b/api/src/utils/alias-helper.ts @@ -0,0 +1,17 @@ +import { snakeCase } from 'typeorm/util/StringUtils'; +import { config } from '../config'; + +// TypeORM fails to properly quote camelCase aliases with PostgreSQL +// https://github.com/typeorm/typeorm/issues/10961 +export const resolveColumnName = (columnName: string): string => { + if (!columnName) { + throw new Error('Column name cannot be empty'); + } + + // convert only for postgres until typeorm has a fix + if (config.db.default.type === 'postgres') { + return snakeCase(columnName); + } + + return columnName; +};