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/.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..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": { @@ -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..b59b8879 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -45,7 +45,7 @@ import ProjectStatsController from './controllers/project-stats.controller'; imports: [ PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.register({ - secretOrPrivateKey: config.secret, + secret: config.secret, signOptions: { expiresIn: config.authTokenExpires, }, @@ -75,7 +75,14 @@ import ProjectStatsController from './controllers/project-stats.controller'; providers: [UserService, AuthService, MailService, JwtStrategy, AuthorizationService], }) export class AppModule { - configure(consumer: MiddlewareConsumer) { + /** + * 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('*'); @@ -83,7 +90,14 @@ export class AppModule { } } -export const addPipesAndFilters = (app: NestExpressApplication) => { +/** + * 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); 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/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/env.logger.ts b/api/src/env.logger.ts new file mode 100644 index 00000000..7953f005 --- /dev/null +++ b/api/src/env.logger.ts @@ -0,0 +1,43 @@ +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 d5a359f7..646f6e00 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -1,26 +1,20 @@ 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 { 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...'); - for (const closable of closables) { - await closable.close(); - } - 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); @@ -71,4 +65,11 @@ 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/services/jwt.strategy.ts b/api/src/services/jwt.strategy.ts index 73736348..1c8bd2b7 100644 --- a/api/src/services/jwt.strategy.ts +++ b/api/src/services/jwt.strategy.ts @@ -21,12 +21,17 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { } /** + * 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 payload - * @returns + * @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) { - let user: User | ProjectClient; + async validate(payload: JwtPayload): Promise { + let user: User | ProjectClient | null = null; + switch (payload.type) { case 'user': user = await this.userRepository.findOne({ @@ -34,18 +39,22 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { 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; } } diff --git a/api/src/shutdown.handler.ts b/api/src/shutdown.handler.ts new file mode 100644 index 00000000..b53d903c --- /dev/null +++ b/api/src/shutdown.handler.ts @@ -0,0 +1,29 @@ +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..7765e1e8 --- /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; +} 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; +}; 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": {