Skip to content
This repository has been archived by the owner on Sep 18, 2024. It is now read-only.

Commit

Permalink
feat(config): improve setup and variable checking using zod
Browse files Browse the repository at this point in the history
Removed pre-configured config values and use zod to parse the `.env` file and analyze for any missed/mistyped config values.
  • Loading branch information
Juknum authored Oct 31, 2023
1 parent 8501ac1 commit a24a78d
Show file tree
Hide file tree
Showing 28 changed files with 314 additions and 188 deletions.
64 changes: 44 additions & 20 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,46 +1,70 @@
API_PORT = 5000
#######################
# Default config file #
#######################

#* If a parameter is noted as a LIST, it means that you can separate
#* multiple values with a ';', DO NOT add a ';' at the end of the line,
#* only between values

#! ALL time value are expressed in milliseconds

API_PORT = '4000'
API_URL = 'http://localhost:4000'
DEBUG = 'true'

# Allowed URLs for CORS 'Access-Control-Allow-Origin' header
CORS_ORIGIN_WHITELIST = 'https://ae.utbm.fr;'
DEBUG = true
# -> LIST
# -> Accepted values: URLs or '*'
CORS_ORIGIN_WHITELIST = 'https://ae.utbm.fr'

# Postgres configuration
# You can avoid user password when developing on local machine
POSTGRES_DB = 'ae_test'
POSTGRES_HOST = '127.0.0.1'
POSTGRES_PORT = '5432'
POSTGRES_USER = 'postgres'
POSTGRES_PASSWORD = 'postgres'
POSTGRES_PASSWORD = 'postgres' # optional

# JWT configuration
# Note: changing the key will invalidate all existing tokens
JWT_KEY = 'secret_jwt_key'
JWT_EXPIRATION_TIME = 604800000 # 1 week in milliseconds
# Expiration time in milliseconds
JWT_EXPIRATION_TIME = '604800000' # 1 week

# Base directory for generic uploaded files
FILES_BASE_DIR = './public'

# Users configuration
# Users files root directory
USERS_BASE_PATH = './public/users'
# Delay before allowing the user to update its profile picture
USERS_PICTURES_DELAY = 604800000 # 1 week in milliseconds
USERS_PICTURES_PATH = './public/users/pictures'
USERS_BANNERS_PATH = './public/users/banners'
USERS_PATH = './public/users'
USERS_PICTURES_DELAY = '604800000' # 1 week
# Delay before deleting unverified users
USERS_VERIFICATION_DELAY = '604800000' # 1 week

# Promotion configuration
PROMOTION_LOGO_PATH = './public/promotions'
# Promotion files root directory
PROMOTION_BASE_PATH = './public/promotions'

# Emailing configuration
# Note: use your own UTBM email account when developing on local machine
EMAIL_HOST = 'smtp.domain.com'
EMAIL_PORT = 465
EMAIL_SECURE = true
EMAIL_PORT = '465'
EMAIL_SECURE = 'true'
EMAIL_AUTH_USER = '[email protected]'
EMAIL_AUTH_PASS = 'azertyuiop'
EMAIL_ENABLED = true
EMAIL_ENABLED = 'false'

# Whitelisted emails/host can be used to register
WHITELISTED_HOSTS =
# Whitelisted hosts can be used to register
# -> Bypass email validation
# -> LIST
WHITELISTED_HOSTS = '@gmail.com'
# Whitelisted emails can be used to register, despite their host
# being blacklisted
# -> Bypass email validation
# -> LIST
WHITELISTED_EMAILS = '[email protected];[email protected]'
# Blacklisted emails/host cannot be used to register
BLACKLISTED_HOSTS = '@utbm.fr;'
BLACKLISTED_EMAILS =

# Blacklisted hosts/emails cannot be used to register,
# unless they are whitelisted in WHITELISTED_HOSTS or WHITELISTED_EMAILS
# -> LISTs
BLACKLISTED_HOSTS = '@utbm.fr'
BLACKLISTED_EMAILS = '[email protected]'
13 changes: 4 additions & 9 deletions .github/workflows/testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
- name: Setup PostgresSQL
uses: ikalnytskyi/action-setup-postgres@v4
with:
# Should match .env.example values
database: ae_test
port: 5432
username: postgres
Expand All @@ -41,20 +42,14 @@ jobs:
- name: Install dependencies
run: pnpm install

- name: Setup environment variables
run: pnpm workflow:setup_env

- name: Setup database for tests
run: pnpm workflow:init_db

- name: Run tests
run: pnpm workflow:test
env:
POSTGRES_DB: ae_test
POSTGRES_HOST: '127.0.0.1'
POSTGRES_PORT: 5432
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
DEBUG: true
EMAIL_ENABLED: false
JWT_KEY: jwt_key

- name: Export coverage results
uses: actions/upload-artifact@v3
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"start:prod": "pnpm db:migrate && node dist/src/main.js",
"workflow:lint": "eslint \"{src,tests}/**/*.ts\" --max-warnings=0",
"workflow:test": "jest --config jest.config.ts --forceExit",
"workflow:init_db": "pnpm ts-node tests/database.setup.ts"
"workflow:init_db": "pnpm ts-node tests/database.setup.ts",
"workflow:setup_env": "pnpm ts-node tests/env.setup.ts"
},
"dependencies": {
"@ae_utbm/typings": "file:src/exported",
Expand Down Expand Up @@ -74,7 +75,7 @@
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-prettier": "^4.2.1",
"jest": "29.6.1",
"jest": "^29.7.0",
"jest-extended": "^4.0.2",
"prettier": "^2.8.8",
"source-map-support": "^0.5.21",
Expand Down
20 changes: 10 additions & 10 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 0 additions & 7 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { join, sep } from 'path';

import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { AcceptLanguageResolver, I18nModule } from 'nestjs-i18n';
Expand All @@ -18,15 +17,9 @@ import { RolesModule } from '@modules/roles/roles.module';
import { TranslateModule } from '@modules/translate/translate.module';
import { UsersModule } from '@modules/users/users.module';

import env from './env';

@Module({
imports: [
AuthModule,
ConfigModule.forRoot({
isGlobal: true,
load: [env],
}),
EmailsModule,
FilesModule,
I18nModule.forRoot({
Expand Down
135 changes: 89 additions & 46 deletions src/env.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,90 @@
/* istanbul ignore file */
import { join } from 'path';

export type Config = typeof config;

const config = () => ({
production: process.env['DEBUG'] !== 'true',
api_url:
process.env['DEBUG'] === 'true'
? `http://localhost:${parseInt(process.env['API_PORT'], 10) || 3000}`
: 'https://ae.utbm.fr/api',
port: parseInt(process.env['API_PORT'], 10) || 3000,
cors: process.env['DEBUG'] === 'true' ? ['*'] : process.env['CORS_ORIGIN_WHITELIST']?.split(';'),
auth: {
jwtKey: process.env['JWT_KEY'],
jwtExpirationTime: parseInt(process.env['JWT_EXPIRATION_TIME'], 10) || 60 * 60 * 24 * 7 * 1000, // 1 week
},
files: {
baseDir: join(process.cwd(), process.env['FILES_BASE_DIR'] || './public'),
users: join(process.cwd(), process.env['USERS_PATH'] || './public/users'),
promotions: join(process.cwd(), process.env['PROMOTIONS_LOGO_PATH'] || './public/promotions'),
},
users: {
verification_token_validity: 7, // number of days before the account being deleted
picture_cooldown: parseInt(process.env['USERS_PICTURES_DELAY'], 10) || 60 * 60 * 24 * 7 * 1000, // 1 week
},
email: {
enabled: process.env['EMAIL_ENABLED'] === 'true',
host: process.env['EMAIL_HOST'],
port: parseInt(process.env['EMAIL_PORT'], 10) || 465,
secure: process.env['EMAIL_SECURE'] === 'true',
auth: {
user: process.env['EMAIL_AUTH_USER'],
pass: process.env['EMAIL_AUTH_PASS'],
},
whitelist: {
hosts: process.env['WHITELISTED_HOSTS']?.split(';') ?? [],
emails: ['[email protected]', ...(process.env['WHITELISTED_EMAILS']?.split(';') ?? [])],
},
blacklist: {
hosts: ['@utbm.fr', ...(process.env['BLACKLISTED_HOSTS']?.split(';') ?? [])],
emails: process.env['BLACKLISTED_EMAILS']?.split(';') ?? [],
},
},
});

export default config;
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import 'dotenv/config';

import { Logger } from '@nestjs/common';
import { z } from 'zod';

/**
* NodeJS environment variables + API specific environment variables, validated using zod
* @see https://github.com/colinhacks/zod
*/
export const env = (() => {
// Throw if the .env file is missing
if (!existsSync(join(process.cwd(), '.env'))) {
Logger.error(
"Cannot start the server, the '.env' file is missing, have you copied and edited the '.env.example' file?",
'env.ts',
);
process.exit(1);
}

const refineAsEmail = (str: string) => {
str.split(';').every((s) => {
z.string().email().parse(s);
});
return true;
};

const refineAsHost = (str: string) => {
str.split(';').every((s) => {
z.string().startsWith('@').parse(s);
});
return true;
};

// Validate the environment variables using zod
const schema = z.object({
API_PORT: z.coerce.number().min(0),
API_URL: z.string().url(),

CORS_ORIGIN_WHITELIST: z.string().refine((str) => str.split(';').every((s) => s.startsWith('http') || s === '*'), {
message: 'The CORS_ORIGIN_WHITELIST environment variable must be a list of origins separated by a semicolon (;)',
}),
DEBUG: z.enum(['true', 'false']).transform((value) => value === 'true'),

JWT_KEY: z.string().min(1),
JWT_EXPIRATION_TIME: z.coerce.number().min(0),

POSTGRES_DB: z.string(),
POSTGRES_HOST: z.string().ip({ version: 'v4' }),
POSTGRES_PASSWORD: z.string().optional(),
POSTGRES_PORT: z.coerce.number().min(0),
POSTGRES_USER: z.string(),

FILES_BASE_DIR: z.string().startsWith('./'),

PROMOTION_BASE_PATH: z.string().startsWith('./'),

USERS_PICTURES_DELAY: z.coerce.number().min(0),
USERS_VERIFICATION_DELAY: z.coerce.number().min(0),
USERS_BASE_PATH: z.string().startsWith('./'),

EMAIL_HOST: z.string(),
EMAIL_PORT: z.coerce.number().min(0),
EMAIL_SECURE: z.enum(['true', 'false']).transform((value) => value === 'true'),
EMAIL_AUTH_USER: z.string().email(),
EMAIL_AUTH_PASS: z.string(),
EMAIL_ENABLED: z.enum(['true', 'false']).transform((value) => value === 'true'),

WHITELISTED_HOSTS: z.string().refine(refineAsHost, {
message: 'Should be a list of emails host, each starting with arobase (@) and separated with a semicolon (;)',
}),
WHITELISTED_EMAILS: z
.string()
.refine(refineAsEmail, { message: 'Should be a list of emails separated by a semicolon (;)' }),

BLACKLISTED_HOSTS: z.string().refine(refineAsHost, {
message: 'Should be a list of emails host, each starting with arobase (@) and separated with a semicolon (;)',
}),
BLACKLISTED_EMAILS: z
.string()
.refine(refineAsEmail, { message: 'Should be a list of emails separated by a semicolon (;)' }),
});

return schema.parse(process.env) as NodeJS.ProcessEnv &
Required<Omit<ReturnType<typeof schema.parse>, 'POSTGRES_PASSWORD'>> & {
POSTGRES_PASSWORD?: string;
};
})();
Loading

0 comments on commit a24a78d

Please sign in to comment.