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',
}

View File

@ -0,0 +1,69 @@
import { Module } from '@nestjs/common';
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
import { S3StorageModule } from './s3-storage/s3-storage.module';
import { S3StorageModuleOptions } from './s3-storage/interfaces';
import { LocalStorageModule } from './local-storage/local-storage.module';
import { LocalStorageModuleOptions } from './local-storage/interfaces';
import { EnvironmentModule } from './environment/environment.module';
import { EnvironmentService } from './environment/environment.service';
import { assert } from 'src/utils/assert';
/**
* S3 Storage Module factory
* @param config
* @returns S3ModuleOptions
*/
const S3StorageModuleFactory = async (
environmentService: EnvironmentService,
): Promise<S3StorageModuleOptions> => {
const fileSystem = environmentService.getStorageType();
const bucketName = environmentService.getStorageLocation();
const region = environmentService.getStorageRegion();
if (fileSystem === 'local') {
return { bucketName };
}
assert(region, 'S3 region is not defined');
return {
bucketName,
credentials: fromNodeProviderChain({
clientConfig: { region },
}),
forcePathStyle: true,
region,
};
};
/**
* LocalStorage Module factory
* @param environment
* @returns LocalStorageModuleOptions
*/
const localStorageModuleFactory = async (
environmentService: EnvironmentService,
): Promise<LocalStorageModuleOptions> => {
const folderName = environmentService.getStorageLocation();
return {
storagePath: process.cwd() + '/' + folderName,
};
};
@Module({
imports: [
S3StorageModule.forRootAsync({
useFactory: S3StorageModuleFactory,
inject: [EnvironmentService],
}),
LocalStorageModule.forRootAsync({
useFactory: localStorageModuleFactory,
inject: [EnvironmentService],
}),
EnvironmentModule.forRoot({}),
],
exports: [],
providers: [],
})
export class IntegrationsModule {}

View File

@ -0,0 +1 @@
export * from './local-storage.interface';

View File

@ -0,0 +1,3 @@
export interface LocalStorageModuleOptions {
storagePath: string;
}

View File

@ -0,0 +1,9 @@
import { ConfigurableModuleBuilder } from '@nestjs/common';
import { LocalStorageModuleOptions } from './interfaces';
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<LocalStorageModuleOptions>({
moduleName: 'LocalStorage',
})
.setClassMethodName('forRoot')
.build();

View File

@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { LocalStorageService } from './local-storage.service';
import { ConfigurableModuleClass } from './local-storage.module-definition';
@Global()
@Module({
providers: [LocalStorageService],
exports: [LocalStorageService],
})
export class LocalStorageModule extends ConfigurableModuleClass {}

View File

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

View File

@ -0,0 +1,35 @@
import { Injectable, Inject } from '@nestjs/common';
import * as fs from 'fs/promises';
import { existsSync } from 'fs';
import * as path from 'path';
import { MODULE_OPTIONS_TOKEN } from './local-storage.module-definition';
import { LocalStorageModuleOptions } from './interfaces';
@Injectable()
export class LocalStorageService {
constructor(
@Inject(MODULE_OPTIONS_TOKEN)
private readonly options: LocalStorageModuleOptions,
) {}
async createFolder(path: string) {
if (existsSync(path)) {
return;
}
return fs.mkdir(path, { recursive: true });
}
async uploadFile(params: {
file: Buffer | Uint8Array | string;
name: string;
folder: string;
}) {
const filePath = `${this.options.storagePath}/${params.folder}/${params.name}`;
const folderPath = path.dirname(filePath);
await this.createFolder(folderPath);
return fs.writeFile(filePath, params.file);
}
}

View File

@ -0,0 +1 @@
export * from './s3-storage-module.interface';

View File

@ -0,0 +1,5 @@
import { S3ClientConfig } from '@aws-sdk/client-s3';
export interface S3StorageModuleOptions extends S3ClientConfig {
bucketName: string;
}

View File

@ -0,0 +1,9 @@
import { ConfigurableModuleBuilder } from '@nestjs/common';
import { S3StorageModuleOptions } from './interfaces';
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<S3StorageModuleOptions>({
moduleName: 'S3Storage',
})
.setClassMethodName('forRoot')
.build();

View File

@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { S3StorageService } from './s3-storage.service';
import { ConfigurableModuleClass } from './s3-storage.module-definition';
@Global()
@Module({
providers: [S3StorageService],
exports: [S3StorageService],
})
export class S3StorageModule extends ConfigurableModuleClass {}

View File

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

View File

@ -0,0 +1,86 @@
import { Injectable, Inject } from '@nestjs/common';
import { MODULE_OPTIONS_TOKEN } from './s3-storage.module-definition';
import { S3StorageModuleOptions } from './interfaces';
import {
CreateBucketCommandInput,
GetObjectCommand,
GetObjectCommandInput,
GetObjectCommandOutput,
HeadBucketCommandInput,
NotFound,
PutObjectCommand,
PutObjectCommandInput,
PutObjectCommandOutput,
S3,
} from '@aws-sdk/client-s3';
@Injectable()
export class S3StorageService {
private s3Client: S3;
private bucketName: string;
constructor(
@Inject(MODULE_OPTIONS_TOKEN)
private readonly options: S3StorageModuleOptions,
) {
const { bucketName, ...s3Options } = options;
this.s3Client = new S3(s3Options);
this.bucketName = bucketName;
}
public get client(): S3 {
return this.s3Client;
}
async uploadFile(
params: Omit<PutObjectCommandInput, 'Bucket'>,
): Promise<PutObjectCommandOutput> {
const command = new PutObjectCommand({
...params,
Bucket: this.bucketName,
});
await this.createBucket({ Bucket: this.bucketName });
return this.s3Client.send(command);
}
async getFile(
params: Omit<GetObjectCommandInput, 'Bucket'>,
): Promise<GetObjectCommandOutput> {
const command = new GetObjectCommand({
...params,
Bucket: this.bucketName,
});
return this.s3Client.send(command);
}
async checkBucketExists(args: HeadBucketCommandInput) {
try {
await this.s3Client.headBucket(args);
return true;
} catch (error) {
console.log(error);
if (error instanceof NotFound) {
return false;
}
throw error;
}
}
async createBucket(args: CreateBucketCommandInput) {
const exist = await this.checkBucketExists({
Bucket: args.Bucket,
});
if (exist) {
return;
}
return this.s3Client.createBucket(args);
}
}