Skip to content

Commit

Permalink
Merge pull request #433 from ever-co/develop
Browse files Browse the repository at this point in the history
Release
  • Loading branch information
evereq authored Nov 10, 2024
2 parents 4aa0d4b + 047924e commit 577794e
Show file tree
Hide file tree
Showing 17 changed files with 213 additions and 39 deletions.
49 changes: 49 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -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=[email protected]
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,5 @@ yarn-error.log
/**/.cache

.vscode/

.env
3 changes: 2 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
20 changes: 17 additions & 3 deletions api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -75,15 +75,29 @@ 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('*');
}
}
}

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);
Expand Down
3 changes: 0 additions & 3 deletions api/src/config.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
9 changes: 5 additions & 4 deletions api/src/controllers/exports.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();

Expand Down
9 changes: 5 additions & 4 deletions api/src/controllers/project-stats.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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();
Expand Down
43 changes: 43 additions & 0 deletions api/src/env.logger.ts
Original file line number Diff line number Diff line change
@@ -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.`,
);
}
}
33 changes: 17 additions & 16 deletions api/src/main.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}
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<NestExpressApplication>(AppModule, new ExpressAdapter());
addPipesAndFilters(app);
Expand Down Expand Up @@ -71,4 +65,11 @@ async function bootstrap() {
});
}

bootstrap();
// Initialize Check Env Variables
checkEnvVariables();

// Bootstrap the application
bootstrap().then(() => {
// Initialize the shutdown handler
setupShutdownHandler(closables);
});
17 changes: 13 additions & 4 deletions api/src/services/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,31 +21,40 @@ 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<User | ProjectClient>} - 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<User | ProjectClient> {
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;
}
}
29 changes: 29 additions & 0 deletions api/src/shutdown.handler.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
}
10 changes: 10 additions & 0 deletions api/src/types.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}
17 changes: 17 additions & 0 deletions api/src/utils/alias-helper.ts
Original file line number Diff line number Diff line change
@@ -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;
};
2 changes: 1 addition & 1 deletion docs-website/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion docs-website/static/docs/api/v1/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "/",
Expand Down
Loading

0 comments on commit 577794e

Please sign in to comment.