feat: upload module (#486)

* feat: wip upload module

* feat: local storage and serve local images

* feat: protect against injections

* feat: server local and s3 files

* fix: use storage location when serving local files

* feat: cross field env validation
This commit is contained in:
Jérémy M
2023-07-04 16:02:44 +02:00
committed by GitHub
parent 820ef184d3
commit 5e1fc1ad11
52 changed files with 2632 additions and 64 deletions

View File

@ -0,0 +1,26 @@
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
@ValidatorConstraint({ async: true })
export class IsAWSRegionConstraint implements ValidatorConstraintInterface {
validate(region: string) {
const regex = /^[a-z]{2}-[a-z]+-\d{1}$/;
return regex.test(region); // Returns true if region matches regex
}
}
export function IsAWSRegion(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsAWSRegionConstraint,
});
};
}

View File

@ -0,0 +1,27 @@
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
@ValidatorConstraint({ async: true })
export class IsDurationConstraint implements ValidatorConstraintInterface {
validate(duration: string) {
const regex =
/^-?[0-9]+(.[0-9]+)?(m(illiseconds?)?|s(econds?)?|h((ou)?rs?)?|d(ays?)?|w(eeks?)?|M(onths?)?|y(ears?)?)?$/;
return regex.test(duration); // Returns true if duration matches regex
}
}
export function IsDuration(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsDurationConstraint,
});
};
}

View File

@ -0,0 +1,8 @@
import { ConfigurableModuleBuilder } from '@nestjs/common';
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder({
moduleName: 'Environment',
})
.setClassMethodName('forRoot')
.build();

View File

@ -0,0 +1,19 @@
import { Global, Module } from '@nestjs/common';
import { EnvironmentService } from './environment.service';
import { ConfigurableModuleClass } from './environment.module-definition';
import { ConfigModule } from '@nestjs/config';
import { validate } from './environment.validation';
@Global()
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
expandVariables: true,
validate,
}),
],
providers: [EnvironmentService],
exports: [EnvironmentService],
})
export class EnvironmentModule extends ConfigurableModuleClass {}

View File

@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EnvironmentService } from './environment.service';
import { ConfigService } from '@nestjs/config';
describe('EnvironmentService', () => {
let service: EnvironmentService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EnvironmentService,
{
provide: ConfigService,
useValue: {},
},
],
}).compile();
service = module.get<EnvironmentService>(EnvironmentService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,66 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AwsRegion } from './interfaces/aws-region.interface';
import { StorageType } from './interfaces/storage.interface';
@Injectable()
export class EnvironmentService {
constructor(private configService: ConfigService) {}
getPGDatabaseUrl(): string {
return this.configService.get<string>('PG_DATABASE_URL')!;
}
getAccessTokenSecret(): string {
return this.configService.get<string>('ACCESS_TOKEN_SECRET')!;
}
getAccessTokenExpiresIn(): string {
return this.configService.get<string>('ACCESS_TOKEN_EXPIRES_IN')!;
}
getRefreshTokenSecret(): string {
return this.configService.get<string>('REFRESH_TOKEN_SECRET')!;
}
getRefreshTokenExpiresIn(): string {
return this.configService.get<string>('REFRESH_TOKEN_EXPIRES_IN')!;
}
getLoginTokenSecret(): string {
return this.configService.get<string>('LOGIN_TOKEN_SECRET')!;
}
getLoginTokenExpiresIn(): string {
return this.configService.get<string>('LOGIN_TOKEN_EXPIRES_IN')!;
}
getFrontAuthCallbackUrl(): string {
return this.configService.get<string>('FRONT_AUTH_CALLBACK_URL')!;
}
getAuthGoogleClientId(): string | undefined {
return this.configService.get<string>('AUTH_GOOGLE_CLIENT_ID');
}
getAuthGoogleClientSecret(): string | undefined {
return this.configService.get<string>('AUTH_GOOGLE_CLIENT_SECRET');
}
getAuthGoogleCallbackUrl(): string | undefined {
return this.configService.get<string>('AUTH_GOOGLE_CALLBACK_URL');
}
getStorageType(): StorageType | undefined {
return this.configService.get<StorageType>('STORAGE_TYPE');
}
getStorageRegion(): AwsRegion | undefined {
return this.configService.get<AwsRegion>('STORAGE_REGION');
}
getStorageLocation(): string {
return this.configService.get<string>('STORAGE_LOCATION')!;
}
}

View File

@ -0,0 +1,77 @@
import { plainToClass } from 'class-transformer';
import {
IsEnum,
IsOptional,
IsString,
IsUrl,
ValidateIf,
validateSync,
} from 'class-validator';
import { assert } from 'src/utils/assert';
import { IsDuration } from './decorators/is-duration.decorator';
import { StorageType } from './interfaces/storage.interface';
import { AwsRegion } from './interfaces/aws-region.interface';
import { IsAWSRegion } from './decorators/is-aws-region.decorator';
export class EnvironmentVariables {
// Database
@IsUrl({ protocols: ['postgres'], require_tld: false })
PG_DATABASE_URL: string;
// Json Web Token
@IsString()
ACCESS_TOKEN_SECRET: string;
@IsDuration()
ACCESS_TOKEN_EXPIRES_IN: string;
@IsString()
REFRESH_TOKEN_SECRET: string;
@IsDuration()
REFRESH_TOKEN_EXPIRES_IN: string;
@IsString()
LOGIN_TOKEN_SECRET: string;
@IsDuration()
LOGIN_TOKEN_EXPIRES_IN: string;
// Auth
@IsUrl({ require_tld: false })
FRONT_AUTH_CALLBACK_URL: string;
@IsString()
@IsOptional()
AUTH_GOOGLE_CLIENT_ID?: string;
@IsString()
@IsOptional()
AUTH_GOOGLE_CLIENT_SECRET?: string;
@IsUrl({ require_tld: false })
@IsOptional()
AUTH_GOOGLE_CALLBACK_URL?: string;
// Storage
@IsEnum(StorageType)
@IsOptional()
STORAGE_TYPE?: StorageType;
@ValidateIf((_, value) => value === StorageType.S3)
@IsAWSRegion()
STORAGE_REGION?: AwsRegion;
@IsString()
STORAGE_LOCATION: string;
}
export function validate(config: Record<string, unknown>) {
const validatedConfig = plainToClass(EnvironmentVariables, config, {
enableImplicitConversion: true,
});
const errors = validateSync(validatedConfig, {
skipMissingProperties: false,
});
assert(!errors.length, errors.toString());
return validatedConfig;
}

View File

@ -0,0 +1 @@
export type AwsRegion = `${string}-${string}-${number}`;

View File

@ -0,0 +1,4 @@
export enum StorageType {
S3 = 's3',
Local = 'local',
}