mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-23 12:02:10 +03:00
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:
parent
820ef184d3
commit
5e1fc1ad11
@ -9,3 +9,6 @@ REFRESH_TOKEN_SECRET=secret_refresh_token
|
|||||||
REFRESH_TOKEN_EXPIRES_IN=90d
|
REFRESH_TOKEN_EXPIRES_IN=90d
|
||||||
PG_DATABASE_URL=postgres://postgres:postgrespassword@postgres:5432/default?connection_limit=1
|
PG_DATABASE_URL=postgres://postgres:postgrespassword@postgres:5432/default?connection_limit=1
|
||||||
FRONT_AUTH_CALLBACK_URL=http://localhost:3001/auth/callback
|
FRONT_AUTH_CALLBACK_URL=http://localhost:3001/auth/callback
|
||||||
|
STORAGE_TYPE=local
|
||||||
|
STORAGE_REGION=eu-west-1
|
||||||
|
STORAGE_LOCATION=.local-storage
|
||||||
|
3
server/.gitignore
vendored
3
server/.gitignore
vendored
@ -33,3 +33,6 @@ lerna-debug.log*
|
|||||||
!.vscode/inbox.json
|
!.vscode/inbox.json
|
||||||
!.vscode/launch.json
|
!.vscode/launch.json
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# Local storage
|
||||||
|
.local-storage
|
||||||
|
@ -28,6 +28,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/server": "^4.7.3",
|
"@apollo/server": "^4.7.3",
|
||||||
|
"@aws-sdk/client-s3": "^3.363.0",
|
||||||
|
"@aws-sdk/credential-providers": "^3.363.0",
|
||||||
"@casl/ability": "^6.5.0",
|
"@casl/ability": "^6.5.0",
|
||||||
"@casl/prisma": "^1.4.0",
|
"@casl/prisma": "^1.4.0",
|
||||||
"@nestjs/apollo": "^11.0.5",
|
"@nestjs/apollo": "^11.0.5",
|
||||||
@ -42,6 +44,7 @@
|
|||||||
"@nestjs/terminus": "^9.2.2",
|
"@nestjs/terminus": "^9.2.2",
|
||||||
"@paljs/plugins": "^5.3.3",
|
"@paljs/plugins": "^5.3.3",
|
||||||
"@prisma/client": "^4.13.0",
|
"@prisma/client": "^4.13.0",
|
||||||
|
"@types/lodash.camelcase": "^4.3.7",
|
||||||
"add": "^2.0.6",
|
"add": "^2.0.6",
|
||||||
"apollo-server-express": "^3.12.0",
|
"apollo-server-express": "^3.12.0",
|
||||||
"axios": "^1.4.0",
|
"axios": "^1.4.0",
|
||||||
@ -51,8 +54,12 @@
|
|||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
"graphql": "^16.6.0",
|
"graphql": "^16.6.0",
|
||||||
"graphql-type-json": "^0.3.2",
|
"graphql-type-json": "^0.3.2",
|
||||||
|
"graphql-upload": "^13.0.0",
|
||||||
"jest-mock-extended": "^3.0.4",
|
"jest-mock-extended": "^3.0.4",
|
||||||
"jsonwebtoken": "^9.0.0",
|
"jsonwebtoken": "^9.0.0",
|
||||||
|
"lodash.camelcase": "^4.3.0",
|
||||||
|
"lodash.isobject": "^3.0.2",
|
||||||
|
"lodash.kebabcase": "^4.1.1",
|
||||||
"ms": "^2.1.3",
|
"ms": "^2.1.3",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
@ -61,6 +68,8 @@
|
|||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"rxjs": "^7.2.0",
|
"rxjs": "^7.2.0",
|
||||||
|
"sharp": "^0.32.1",
|
||||||
|
"type-fest": "^3.12.0",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"yarn": "^1.22.19"
|
"yarn": "^1.22.19"
|
||||||
},
|
},
|
||||||
@ -71,7 +80,10 @@
|
|||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/date-fns": "^2.6.0",
|
"@types/date-fns": "^2.6.0",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express": "^4.17.13",
|
||||||
|
"@types/graphql-upload": "^8.0.12",
|
||||||
"@types/jest": "28.1.8",
|
"@types/jest": "28.1.8",
|
||||||
|
"@types/lodash.isobject": "^3.0.7",
|
||||||
|
"@types/lodash.kebabcase": "^4.1.7",
|
||||||
"@types/ms": "^0.7.31",
|
"@types/ms": "^0.7.31",
|
||||||
"@types/node": "^16.0.0",
|
"@types/node": "^16.0.0",
|
||||||
"@types/passport-google-oauth20": "^2.0.11",
|
"@types/passport-google-oauth20": "^2.0.11",
|
||||||
|
@ -4,6 +4,7 @@ import { AppService } from './app.service';
|
|||||||
|
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { CoreModule } from './core/core.module';
|
import { CoreModule } from './core/core.module';
|
||||||
|
import { IntegrationsModule } from './integrations/integrations.module';
|
||||||
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
|
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
|
||||||
import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default';
|
import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default';
|
||||||
import { GraphQLError } from 'graphql';
|
import { GraphQLError } from 'graphql';
|
||||||
@ -33,6 +34,7 @@ import GraphQLJSON from 'graphql-type-json';
|
|||||||
HealthModule,
|
HealthModule,
|
||||||
AbilityModule,
|
AbilityModule,
|
||||||
CoreModule,
|
CoreModule,
|
||||||
|
IntegrationsModule,
|
||||||
],
|
],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
})
|
})
|
||||||
|
9
server/src/constants/settings/index.ts
Normal file
9
server/src/constants/settings/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Settings } from './interfaces/settings.interface';
|
||||||
|
|
||||||
|
export const settings: Settings = {
|
||||||
|
storage: {
|
||||||
|
imageCropSizes: {
|
||||||
|
profilePicture: ['original'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
@ -0,0 +1,12 @@
|
|||||||
|
import { FileFolder } from 'src/core/file/interfaces/file-folder.interface';
|
||||||
|
import { ShortCropSize } from 'src/utils/image';
|
||||||
|
|
||||||
|
type ValueOfFileFolder = `${FileFolder}`;
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
storage: {
|
||||||
|
imageCropSizes: {
|
||||||
|
[key in ValueOfFileFolder]: ShortCropSize[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
@ -1,6 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
||||||
import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
||||||
import { AuthService } from './services/auth.service';
|
import { AuthService } from './services/auth.service';
|
||||||
import { GoogleAuthController } from './controllers/google-auth.controller';
|
import { GoogleAuthController } from './controllers/google-auth.controller';
|
||||||
@ -8,25 +7,24 @@ import { GoogleStrategy } from './strategies/google.auth.strategy';
|
|||||||
import { PrismaService } from 'src/database/prisma.service';
|
import { PrismaService } from 'src/database/prisma.service';
|
||||||
import { UserModule } from '../user/user.module';
|
import { UserModule } from '../user/user.module';
|
||||||
import { VerifyAuthController } from './controllers/verify-auth.controller';
|
import { VerifyAuthController } from './controllers/verify-auth.controller';
|
||||||
|
|
||||||
import { TokenService } from './services/token.service';
|
import { TokenService } from './services/token.service';
|
||||||
import { AuthResolver } from './auth.resolver';
|
import { AuthResolver } from './auth.resolver';
|
||||||
|
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
|
|
||||||
const jwtModule = JwtModule.registerAsync({
|
const jwtModule = JwtModule.registerAsync({
|
||||||
useFactory: async (configService: ConfigService) => {
|
useFactory: async (environmentService: EnvironmentService) => {
|
||||||
return {
|
return {
|
||||||
secret: configService.get<string>('ACCESS_TOKEN_SECRET'),
|
secret: environmentService.getAccessTokenSecret(),
|
||||||
signOptions: {
|
signOptions: {
|
||||||
expiresIn: configService.get<string>('ACCESS_TOKEN_EXPIRES_IN'),
|
expiresIn: environmentService.getAccessTokenExpiresIn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
imports: [ConfigModule.forRoot({})],
|
inject: [EnvironmentService],
|
||||||
inject: [ConfigService],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [jwtModule, ConfigModule.forRoot({}), UserModule],
|
imports: [jwtModule, UserModule],
|
||||||
controllers: [GoogleAuthController, VerifyAuthController],
|
controllers: [GoogleAuthController, VerifyAuthController],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
|
@ -3,7 +3,7 @@ import { TokenService } from './token.service';
|
|||||||
import { PrismaService } from 'src/database/prisma.service';
|
import { PrismaService } from 'src/database/prisma.service';
|
||||||
import { prismaMock } from 'src/database/client-mock/jest-prisma-singleton';
|
import { prismaMock } from 'src/database/client-mock/jest-prisma-singleton';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
|
|
||||||
describe('TokenService', () => {
|
describe('TokenService', () => {
|
||||||
let service: TokenService;
|
let service: TokenService;
|
||||||
@ -17,7 +17,7 @@ describe('TokenService', () => {
|
|||||||
useValue: {},
|
useValue: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: ConfigService,
|
provide: EnvironmentService,
|
||||||
useValue: {},
|
useValue: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -8,24 +8,24 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { JwtPayload } from '../strategies/jwt.auth.strategy';
|
import { JwtPayload } from '../strategies/jwt.auth.strategy';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { PrismaService } from 'src/database/prisma.service';
|
import { PrismaService } from 'src/database/prisma.service';
|
||||||
import { assert } from 'src/utils/assert';
|
import { assert } from 'src/utils/assert';
|
||||||
import { addMilliseconds } from 'date-fns';
|
import { addMilliseconds } from 'date-fns';
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
import { AuthToken } from '../dto/token.entity';
|
import { AuthToken } from '../dto/token.entity';
|
||||||
import { TokenExpiredError } from 'jsonwebtoken';
|
import { TokenExpiredError } from 'jsonwebtoken';
|
||||||
|
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TokenService {
|
export class TokenService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly configService: ConfigService,
|
private readonly environmentService: EnvironmentService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generateAccessToken(userId: string): Promise<AuthToken> {
|
async generateAccessToken(userId: string): Promise<AuthToken> {
|
||||||
const expiresIn = this.configService.get<string>('ACCESS_TOKEN_EXPIRES_IN');
|
const expiresIn = this.environmentService.getAccessTokenExpiresIn();
|
||||||
assert(expiresIn, '', InternalServerErrorException);
|
assert(expiresIn, '', InternalServerErrorException);
|
||||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||||
|
|
||||||
@ -56,10 +56,8 @@ export class TokenService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async generateRefreshToken(userId: string): Promise<AuthToken> {
|
async generateRefreshToken(userId: string): Promise<AuthToken> {
|
||||||
const secret = this.configService.get('REFRESH_TOKEN_SECRET');
|
const secret = this.environmentService.getRefreshTokenSecret();
|
||||||
const expiresIn = this.configService.get<string>(
|
const expiresIn = this.environmentService.getRefreshTokenExpiresIn();
|
||||||
'REFRESH_TOKEN_EXPIRES_IN',
|
|
||||||
);
|
|
||||||
assert(expiresIn, '', InternalServerErrorException);
|
assert(expiresIn, '', InternalServerErrorException);
|
||||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||||
|
|
||||||
@ -87,8 +85,8 @@ export class TokenService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async generateLoginToken(email: string): Promise<AuthToken> {
|
async generateLoginToken(email: string): Promise<AuthToken> {
|
||||||
const secret = this.configService.get('LOGIN_TOKEN_SECRET');
|
const secret = this.environmentService.getLoginTokenSecret();
|
||||||
const expiresIn = this.configService.get<string>('LOGIN_TOKEN_EXPIRES_IN');
|
const expiresIn = this.environmentService.getLoginTokenExpiresIn();
|
||||||
assert(expiresIn, '', InternalServerErrorException);
|
assert(expiresIn, '', InternalServerErrorException);
|
||||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||||
const jwtPayload = {
|
const jwtPayload = {
|
||||||
@ -105,7 +103,7 @@ export class TokenService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async verifyLoginToken(loginToken: string): Promise<string> {
|
async verifyLoginToken(loginToken: string): Promise<string> {
|
||||||
const loginTokenSecret = this.configService.get('LOGIN_TOKEN_SECRET');
|
const loginTokenSecret = this.environmentService.getLoginTokenSecret();
|
||||||
|
|
||||||
const payload = await this.verifyJwt(loginToken, loginTokenSecret);
|
const payload = await this.verifyJwt(loginToken, loginTokenSecret);
|
||||||
|
|
||||||
@ -113,7 +111,7 @@ export class TokenService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async verifyRefreshToken(refreshToken: string) {
|
async verifyRefreshToken(refreshToken: string) {
|
||||||
const secret = this.configService.get('REFRESH_TOKEN_SECRET');
|
const secret = this.environmentService.getRefreshTokenSecret();
|
||||||
const jwtPayload = await this.verifyJwt(refreshToken, secret);
|
const jwtPayload = await this.verifyJwt(refreshToken, secret);
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
@ -191,9 +189,7 @@ export class TokenService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
computeRedirectURI(loginToken: string): string {
|
computeRedirectURI(loginToken: string): string {
|
||||||
return `${this.configService.get<string>(
|
return `${this.environmentService.getFrontAuthCallbackUrl()}?loginToken=${loginToken}`;
|
||||||
'FRONT_AUTH_CALLBACK_URL',
|
|
||||||
)}?loginToken=${loginToken}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyJwt(token: string, secret?: string) {
|
async verifyJwt(token: string, secret?: string) {
|
||||||
|
@ -2,8 +2,8 @@ import { PassportStrategy } from '@nestjs/passport';
|
|||||||
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
|
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
|
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
|
|
||||||
export type GoogleRequest = Request & {
|
export type GoogleRequest = Request & {
|
||||||
user: {
|
user: {
|
||||||
@ -15,11 +15,11 @@ export type GoogleRequest = Request & {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||||
constructor(configService: ConfigService) {
|
constructor(environmentService: EnvironmentService) {
|
||||||
super({
|
super({
|
||||||
clientID: configService.get<string>('AUTH_GOOGLE_CLIENT_ID'),
|
clientID: environmentService.getAuthGoogleClientId(),
|
||||||
clientSecret: configService.get<string>('AUTH_GOOGLE_CLIENT_SECRET'),
|
clientSecret: environmentService.getAuthGoogleClientSecret(),
|
||||||
callbackURL: configService.get<string>('AUTH_GOOGLE_CALLBACK_URL'),
|
callbackURL: environmentService.getAuthGoogleCallbackUrl(),
|
||||||
scope: ['email', 'profile'],
|
scope: ['email', 'profile'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { Strategy, ExtractJwt } from 'passport-jwt';
|
import { Strategy, ExtractJwt } from 'passport-jwt';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { PrismaService } from 'src/database/prisma.service';
|
import { PrismaService } from 'src/database/prisma.service';
|
||||||
import { User, Workspace } from '@prisma/client';
|
import { User, Workspace } from '@prisma/client';
|
||||||
|
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
|
|
||||||
export type JwtPayload = { sub: string; workspaceId: string };
|
export type JwtPayload = { sub: string; workspaceId: string };
|
||||||
export type PassportUser = { user: User; workspace: Workspace };
|
export type PassportUser = { user: User; workspace: Workspace };
|
||||||
@ -11,13 +11,13 @@ export type PassportUser = { user: User; workspace: Workspace };
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly environmentService: EnvironmentService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
secretOrKey: configService.get<string>('ACCESS_TOKEN_SECRET'),
|
secretOrKey: environmentService.getAccessTokenSecret(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ import { PipelineModule } from './pipeline/pipeline.module';
|
|||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { WorkspaceModule } from './workspace/workspace.module';
|
import { WorkspaceModule } from './workspace/workspace.module';
|
||||||
import { AnalyticsModule } from './analytics/analytics.module';
|
import { AnalyticsModule } from './analytics/analytics.module';
|
||||||
|
import { FileModule } from './file/file.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -18,6 +19,7 @@ import { AnalyticsModule } from './analytics/analytics.module';
|
|||||||
PipelineModule,
|
PipelineModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
AnalyticsModule,
|
AnalyticsModule,
|
||||||
|
FileModule,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
25
server/src/core/file/controllers/file.controller.spec.ts
Normal file
25
server/src/core/file/controllers/file.controller.spec.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { FileController } from './file.controller';
|
||||||
|
import { FileService } from '../services/file.service';
|
||||||
|
|
||||||
|
describe('FileController', () => {
|
||||||
|
let controller: FileController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [FileController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: FileService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<FileController>(FileController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
30
server/src/core/file/controllers/file.controller.ts
Normal file
30
server/src/core/file/controllers/file.controller.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { Controller, Get, Param, Res, UseGuards } from '@nestjs/common';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { checkFilePath, checkFilename } from '../file.utils';
|
||||||
|
import { FileService } from '../services/file.service';
|
||||||
|
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('files')
|
||||||
|
export class FileController {
|
||||||
|
constructor(private readonly fileService: FileService) {}
|
||||||
|
/**
|
||||||
|
* Serve files from local storage
|
||||||
|
* We recommend using an s3 bucket for production
|
||||||
|
*/
|
||||||
|
@Get('*/:filename')
|
||||||
|
async getFile(@Param() params: string[], @Res() res: Response) {
|
||||||
|
const folderPath = checkFilePath(params[0]);
|
||||||
|
const filename = checkFilename(params['filename']);
|
||||||
|
const fileStream = await this.fileService.getFileStream(
|
||||||
|
folderPath,
|
||||||
|
filename,
|
||||||
|
);
|
||||||
|
|
||||||
|
fileStream.on('error', () => {
|
||||||
|
res.status(404).send({ error: 'File not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
fileStream.pipe(res);
|
||||||
|
}
|
||||||
|
}
|
12
server/src/core/file/file.module.ts
Normal file
12
server/src/core/file/file.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { FileService } from './services/file.service';
|
||||||
|
import { FileUploadService } from './services/file-upload.service';
|
||||||
|
import { FileUploadResolver } from './resolvers/file-upload.resolver';
|
||||||
|
import { FileController } from './controllers/file.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [FileService, FileUploadService, FileUploadResolver],
|
||||||
|
exports: [FileService, FileUploadService],
|
||||||
|
controllers: [FileController],
|
||||||
|
})
|
||||||
|
export class FileModule {}
|
46
server/src/core/file/file.utils.ts
Normal file
46
server/src/core/file/file.utils.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { kebabCase } from 'src/utils/kebab-case';
|
||||||
|
import { FileFolder } from './interfaces/file-folder.interface';
|
||||||
|
import { KebabCase } from 'type-fest';
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { basename } from 'path';
|
||||||
|
import { settings } from 'src/constants/settings';
|
||||||
|
import { camelCase } from 'src/utils/camel-case';
|
||||||
|
|
||||||
|
type AllowedFolders = KebabCase<keyof typeof FileFolder>;
|
||||||
|
|
||||||
|
export function checkFilePath(filePath: string): string {
|
||||||
|
const allowedFolders = Object.values(FileFolder).map((value) =>
|
||||||
|
kebabCase(value),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sanitizedFilePath = filePath.replace(/\0/g, '');
|
||||||
|
const [folder, size] = sanitizedFilePath.split('/');
|
||||||
|
|
||||||
|
if (!allowedFolders.includes(folder as AllowedFolders)) {
|
||||||
|
throw new BadRequestException(`Folder ${folder} is not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
size &&
|
||||||
|
!settings.storage.imageCropSizes[camelCase(folder)]?.includes(size)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(`Size ${size} is not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitizedFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkFilename(filename: string) {
|
||||||
|
const sanitizedFilename = basename(filename.replace(/\0/g, ''));
|
||||||
|
|
||||||
|
if (
|
||||||
|
!sanitizedFilename ||
|
||||||
|
sanitizedFilename.includes('/') ||
|
||||||
|
sanitizedFilename.includes('\\') ||
|
||||||
|
!sanitizedFilename.includes('.')
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(`Filename is not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return basename(sanitizedFilename);
|
||||||
|
}
|
9
server/src/core/file/interfaces/file-folder.interface.ts
Normal file
9
server/src/core/file/interfaces/file-folder.interface.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { registerEnumType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
export enum FileFolder {
|
||||||
|
ProfilePicture = 'profilePicture',
|
||||||
|
}
|
||||||
|
|
||||||
|
registerEnumType(FileFolder, {
|
||||||
|
name: 'FileFolder',
|
||||||
|
});
|
25
server/src/core/file/resolvers/file-upload.resolver.spec.ts
Normal file
25
server/src/core/file/resolvers/file-upload.resolver.spec.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { FileUploadResolver } from './file-upload.resolver';
|
||||||
|
import { FileUploadService } from '../services/file-upload.service';
|
||||||
|
|
||||||
|
describe('FileUploadResolver', () => {
|
||||||
|
let resolver: FileUploadResolver;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
FileUploadResolver,
|
||||||
|
{
|
||||||
|
provide: FileUploadService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
resolver = module.get<FileUploadResolver>(FileUploadResolver);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(resolver).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
60
server/src/core/file/resolvers/file-upload.resolver.ts
Normal file
60
server/src/core/file/resolvers/file-upload.resolver.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||||
|
import { GraphQLUpload, FileUpload } from 'graphql-upload';
|
||||||
|
import { v4 as uuidV4 } from 'uuid';
|
||||||
|
import { FileUploadService } from '../services/file-upload.service';
|
||||||
|
import { UseGuards } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
|
||||||
|
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||||
|
import { FileFolder } from '../interfaces/file-folder.interface';
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Resolver()
|
||||||
|
export class FileUploadResolver {
|
||||||
|
constructor(private readonly fileUploadService: FileUploadService) {}
|
||||||
|
|
||||||
|
@Mutation(() => String)
|
||||||
|
async uploadFile(
|
||||||
|
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||||
|
{ createReadStream, filename, mimetype }: FileUpload,
|
||||||
|
@Args('fileFolder', { type: () => FileFolder, nullable: true })
|
||||||
|
fileFolder: FileFolder,
|
||||||
|
): Promise<string> {
|
||||||
|
const stream = createReadStream();
|
||||||
|
const buffer = await streamToBuffer(stream);
|
||||||
|
const ext = filename.split('.')?.[1];
|
||||||
|
const id = uuidV4();
|
||||||
|
const name = `${id}${ext ? `.${ext}` : ''}`;
|
||||||
|
|
||||||
|
const path = await this.fileUploadService.uploadFile({
|
||||||
|
file: buffer,
|
||||||
|
name,
|
||||||
|
mimeType: mimetype,
|
||||||
|
fileFolder,
|
||||||
|
});
|
||||||
|
|
||||||
|
return path.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => String)
|
||||||
|
async uploadImage(
|
||||||
|
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||||
|
{ createReadStream, filename, mimetype }: FileUpload,
|
||||||
|
@Args('fileFolder', { type: () => FileFolder, nullable: true })
|
||||||
|
fileFolder: FileFolder,
|
||||||
|
): Promise<string> {
|
||||||
|
const stream = createReadStream();
|
||||||
|
const buffer = await streamToBuffer(stream);
|
||||||
|
const ext = filename.split('.')?.[1];
|
||||||
|
const id = uuidV4();
|
||||||
|
const name = `${id}${ext ? `.${ext}` : ''}`;
|
||||||
|
|
||||||
|
const path = await this.fileUploadService.uploadImage({
|
||||||
|
file: buffer,
|
||||||
|
name,
|
||||||
|
mimeType: mimetype,
|
||||||
|
fileFolder,
|
||||||
|
});
|
||||||
|
|
||||||
|
return path.name;
|
||||||
|
}
|
||||||
|
}
|
35
server/src/core/file/services/file-upload.service.spec.ts
Normal file
35
server/src/core/file/services/file-upload.service.spec.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { FileUploadService } from './file-upload.service';
|
||||||
|
import { S3StorageService } from 'src/integrations/s3-storage/s3-storage.service';
|
||||||
|
import { LocalStorageService } from 'src/integrations/local-storage/local-storage.service';
|
||||||
|
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
|
|
||||||
|
describe('FileUploadService', () => {
|
||||||
|
let service: FileUploadService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
FileUploadService,
|
||||||
|
{
|
||||||
|
provide: S3StorageService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: LocalStorageService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: EnvironmentService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<FileUploadService>(FileUploadService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
135
server/src/core/file/services/file-upload.service.ts
Normal file
135
server/src/core/file/services/file-upload.service.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { S3StorageService } from 'src/integrations/s3-storage/s3-storage.service';
|
||||||
|
import { kebabCase } from 'src/utils/kebab-case';
|
||||||
|
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
|
import { LocalStorageService } from 'src/integrations/local-storage/local-storage.service';
|
||||||
|
import { getCropSize } from 'src/utils/image';
|
||||||
|
import { settings } from 'src/constants/settings';
|
||||||
|
import { FileFolder } from '../interfaces/file-folder.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FileUploadService {
|
||||||
|
constructor(
|
||||||
|
private readonly s3Storage: S3StorageService,
|
||||||
|
private readonly localStorage: LocalStorageService,
|
||||||
|
private readonly environmentService: EnvironmentService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async uploadFile({
|
||||||
|
file,
|
||||||
|
name,
|
||||||
|
mimeType,
|
||||||
|
fileFolder,
|
||||||
|
}: {
|
||||||
|
file: Buffer | Uint8Array | string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string | undefined;
|
||||||
|
fileFolder: FileFolder;
|
||||||
|
}) {
|
||||||
|
const storageType = this.environmentService.getStorageType();
|
||||||
|
|
||||||
|
switch (storageType) {
|
||||||
|
case 's3': {
|
||||||
|
await this.uploadFileToS3(file, name, mimeType, fileFolder);
|
||||||
|
return {
|
||||||
|
name: `/${name}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'local':
|
||||||
|
default: {
|
||||||
|
await this.uploadToLocal(file, name, fileFolder);
|
||||||
|
return {
|
||||||
|
name: `/${name}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadImage({
|
||||||
|
file,
|
||||||
|
name,
|
||||||
|
mimeType,
|
||||||
|
fileFolder,
|
||||||
|
}: {
|
||||||
|
file: Buffer | Uint8Array | string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string | undefined;
|
||||||
|
fileFolder: FileFolder;
|
||||||
|
}) {
|
||||||
|
// Get all cropSizes for this fileFolder
|
||||||
|
const cropSizes = settings.storage.imageCropSizes[fileFolder];
|
||||||
|
// Extract the values from ShortCropSize
|
||||||
|
const sizes = cropSizes.map((shortSize) => getCropSize(shortSize));
|
||||||
|
// Crop images based on sizes
|
||||||
|
const images = await Promise.all(
|
||||||
|
sizes.map((size) =>
|
||||||
|
sharp(file).resize({
|
||||||
|
[size?.type || 'width']: size?.value ?? undefined,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Upload all images to corresponding folders
|
||||||
|
await Promise.all(
|
||||||
|
images.map(async (image, index) => {
|
||||||
|
const buffer = await image.toBuffer();
|
||||||
|
|
||||||
|
return this.uploadFile({
|
||||||
|
file: buffer,
|
||||||
|
name: `${cropSizes[index]}/${name}`,
|
||||||
|
mimeType,
|
||||||
|
fileFolder,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: `/${name}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async uploadToLocal(
|
||||||
|
file: Buffer | Uint8Array | string,
|
||||||
|
name: string,
|
||||||
|
fileFolder: FileFolder,
|
||||||
|
): Promise<void> {
|
||||||
|
const folderName = kebabCase(fileFolder.toString());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.localStorage.uploadFile({
|
||||||
|
file,
|
||||||
|
name,
|
||||||
|
folder: folderName,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
console.log('uploadFile error: ', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async uploadFileToS3(
|
||||||
|
file: Buffer | Uint8Array | string,
|
||||||
|
name: string,
|
||||||
|
mimeType: string | undefined,
|
||||||
|
fileFolder: FileFolder,
|
||||||
|
) {
|
||||||
|
// Aws only accept bucket with kebab-case name
|
||||||
|
const bucketFolderName = kebabCase(fileFolder.toString());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.s3Storage.uploadFile({
|
||||||
|
Key: `${bucketFolderName}/${name}`,
|
||||||
|
Body: file,
|
||||||
|
ContentType: mimeType,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
console.log('uploadFile error: ', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
30
server/src/core/file/services/file.service.spec.ts
Normal file
30
server/src/core/file/services/file.service.spec.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { FileService } from './file.service';
|
||||||
|
import { S3StorageService } from 'src/integrations/s3-storage/s3-storage.service';
|
||||||
|
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
|
|
||||||
|
describe('FileService', () => {
|
||||||
|
let service: FileService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
FileService,
|
||||||
|
{
|
||||||
|
provide: S3StorageService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: EnvironmentService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<FileService>(FileService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
55
server/src/core/file/services/file.service.ts
Normal file
55
server/src/core/file/services/file.service.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { S3StorageService } from 'src/integrations/s3-storage/s3-storage.service';
|
||||||
|
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
|
import { createReadStream } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FileService {
|
||||||
|
constructor(
|
||||||
|
private readonly s3Storage: S3StorageService,
|
||||||
|
private readonly environmentService: EnvironmentService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getFileStream(folderPath: string, filename: string) {
|
||||||
|
const storageType = this.environmentService.getStorageType();
|
||||||
|
|
||||||
|
switch (storageType) {
|
||||||
|
case 's3':
|
||||||
|
return this.getS3FileStream(folderPath, filename);
|
||||||
|
case 'local':
|
||||||
|
default:
|
||||||
|
return this.getLocalFileStream(folderPath, filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getLocalFileStream(folderPath: string, filename: string) {
|
||||||
|
const storageLocation = this.environmentService.getStorageLocation();
|
||||||
|
|
||||||
|
const filePath = join(
|
||||||
|
process.cwd(),
|
||||||
|
`${storageLocation}/`,
|
||||||
|
folderPath,
|
||||||
|
filename,
|
||||||
|
);
|
||||||
|
|
||||||
|
return createReadStream(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getS3FileStream(folderPath: string, filename: string) {
|
||||||
|
try {
|
||||||
|
const file = await this.s3Storage.getFile({
|
||||||
|
Key: `${folderPath}/${filename}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!file || !file.Body || !(file.Body instanceof Readable)) {
|
||||||
|
throw new Error('Unable to get file stream');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Readable.from(file.Body);
|
||||||
|
} catch (error) {
|
||||||
|
throw new NotFoundException('File not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { graphqlUploadExpress } from 'graphql-upload';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule, { cors: true });
|
const app = await NestFactory.create(AppModule, { cors: true });
|
||||||
@ -8,6 +9,9 @@ async function bootstrap() {
|
|||||||
// Apply validation pipes globally
|
// Apply validation pipes globally
|
||||||
app.useGlobalPipes(new ValidationPipe());
|
app.useGlobalPipes(new ValidationPipe());
|
||||||
|
|
||||||
|
// Graphql file upload
|
||||||
|
app.use(graphqlUploadExpress());
|
||||||
|
|
||||||
await app.listen(3000);
|
await app.listen(3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
26
server/src/utils/camel-case.ts
Normal file
26
server/src/utils/camel-case.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import isObject from 'lodash.isobject';
|
||||||
|
import lodashCamelCase from 'lodash.camelcase';
|
||||||
|
import { CamelCase, CamelCasedPropertiesDeep } from 'type-fest';
|
||||||
|
|
||||||
|
export const camelCase = <T>(text: T) =>
|
||||||
|
lodashCamelCase(text as unknown as string) as CamelCase<T>;
|
||||||
|
|
||||||
|
export const camelCaseDeep = <T>(value: T): CamelCasedPropertiesDeep<T> => {
|
||||||
|
// Check if it's an array
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(camelCaseDeep) as CamelCasedPropertiesDeep<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's an object
|
||||||
|
if (isObject(value)) {
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const key in value) {
|
||||||
|
result[camelCase(key)] = camelCaseDeep(value[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result as CamelCasedPropertiesDeep<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value as CamelCasedPropertiesDeep<T>;
|
||||||
|
};
|
21
server/src/utils/image.ts
Normal file
21
server/src/utils/image.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
const cropRegex = /([w|h])([0-9]+)/;
|
||||||
|
|
||||||
|
export type ShortCropSize = `${'w' | 'h'}${number}` | 'original';
|
||||||
|
|
||||||
|
export interface CropSize {
|
||||||
|
type: 'width' | 'height';
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCropSize = (value: ShortCropSize): CropSize | null => {
|
||||||
|
const match = value.match(cropRegex);
|
||||||
|
|
||||||
|
if (value === 'original' || match === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: match[1] === 'w' ? 'width' : 'height',
|
||||||
|
value: +match[2],
|
||||||
|
};
|
||||||
|
};
|
26
server/src/utils/kebab-case.ts
Normal file
26
server/src/utils/kebab-case.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import isObject from 'lodash.isobject';
|
||||||
|
import lodashKebabCase from 'lodash.kebabcase';
|
||||||
|
import { KebabCase, KebabCasedPropertiesDeep } from 'type-fest';
|
||||||
|
|
||||||
|
export const kebabCase = <T>(text: T) =>
|
||||||
|
lodashKebabCase(text as unknown as string) as KebabCase<T>;
|
||||||
|
|
||||||
|
export const kebabCaseDeep = <T>(value: T): KebabCasedPropertiesDeep<T> => {
|
||||||
|
// Check if it's an array
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(kebabCaseDeep) as KebabCasedPropertiesDeep<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's an object
|
||||||
|
if (isObject(value)) {
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const key in value) {
|
||||||
|
result[kebabCase(key)] = kebabCaseDeep(value[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result as KebabCasedPropertiesDeep<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value as KebabCasedPropertiesDeep<T>;
|
||||||
|
};
|
11
server/src/utils/stream-to-buffer.ts
Normal file
11
server/src/utils/stream-to-buffer.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
|
export async function streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||||
|
const chunks: any[] = [];
|
||||||
|
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
1500
server/yarn.lock
1500
server/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user