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:
@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
import { ConfigurableModuleBuilder } from '@nestjs/common';
|
||||
|
||||
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
|
||||
new ConfigurableModuleBuilder({
|
||||
moduleName: 'Environment',
|
||||
})
|
||||
.setClassMethodName('forRoot')
|
||||
.build();
|
||||
19
server/src/integrations/environment/environment.module.ts
Normal file
19
server/src/integrations/environment/environment.module.ts
Normal 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 {}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
66
server/src/integrations/environment/environment.service.ts
Normal file
66
server/src/integrations/environment/environment.service.ts
Normal 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')!;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export type AwsRegion = `${string}-${string}-${number}`;
|
||||
@ -0,0 +1,4 @@
|
||||
export enum StorageType {
|
||||
S3 = 's3',
|
||||
Local = 'local',
|
||||
}
|
||||
69
server/src/integrations/integrations.module.ts
Normal file
69
server/src/integrations/integrations.module.ts
Normal 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 {}
|
||||
@ -0,0 +1 @@
|
||||
export * from './local-storage.interface';
|
||||
@ -0,0 +1,3 @@
|
||||
export interface LocalStorageModuleOptions {
|
||||
storagePath: string;
|
||||
}
|
||||
@ -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();
|
||||
@ -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 {}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
1
server/src/integrations/s3-storage/interfaces/index.ts
Normal file
1
server/src/integrations/s3-storage/interfaces/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './s3-storage-module.interface';
|
||||
@ -0,0 +1,5 @@
|
||||
import { S3ClientConfig } from '@aws-sdk/client-s3';
|
||||
|
||||
export interface S3StorageModuleOptions extends S3ClientConfig {
|
||||
bucketName: string;
|
||||
}
|
||||
@ -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();
|
||||
10
server/src/integrations/s3-storage/s3-storage.module.ts
Normal file
10
server/src/integrations/s3-storage/s3-storage.module.ts
Normal 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 {}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
86
server/src/integrations/s3-storage/s3-storage.service.ts
Normal file
86
server/src/integrations/s3-storage/s3-storage.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user