Skip to content

Commit 9e3a7d8

Browse files
committed
s3 widget
1 parent 9fdd9ef commit 9e3a7d8

21 files changed

Lines changed: 806 additions & 2 deletions

File tree

backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626
},
2727
"dependencies": {
2828
"@amplitude/node": "1.10.2",
29+
"@aws-sdk/client-s3": "^3.953.0",
2930
"@aws-sdk/lib-dynamodb": "^3.953.0",
31+
"@aws-sdk/s3-request-presigner": "^3.953.0",
3032
"@electric-sql/pglite": "^0.3.14",
3133
"@faker-js/faker": "^10.1.0",
3234
"@nestjs/common": "11.1.9",

backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { SharedJobsModule } from './entities/shared-jobs/shared-jobs.module.js';
4040
import { TableCategoriesModule } from './entities/table-categories/table-categories.module.js';
4141
import { UserSecretModule } from './entities/user-secret/user-secret.module.js';
4242
import { SignInAuditModule } from './entities/user-sign-in-audit/sign-in-audit.module.js';
43+
import { S3WidgetModule } from './entities/s3-widget/s3-widget.module.js';
4344

4445
@Module({
4546
imports: [
@@ -84,6 +85,7 @@ import { SignInAuditModule } from './entities/user-sign-in-audit/sign-in-audit.m
8485
TableCategoriesModule,
8586
UserSecretModule,
8687
SignInAuditModule,
88+
S3WidgetModule,
8789
],
8890
controllers: [AppController],
8991
providers: [

backend/src/common/data-injection.tokens.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,4 +172,7 @@ export enum UseCaseType {
172172
DELETE_SECRET = 'DELETE_SECRET',
173173
GET_SECRET_AUDIT_LOG = 'GET_SECRET_AUDIT_LOG',
174174
FIND_SIGN_IN_AUDIT_LOGS = 'FIND_SIGN_IN_AUDIT_LOGS',
175+
176+
GET_S3_FILE_URL = 'GET_S3_FILE_URL',
177+
GET_S3_UPLOAD_URL = 'GET_S3_UPLOAD_URL',
175178
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
export class S3GetFileUrlDs {
2+
connectionId: string;
3+
tableName: string;
4+
fieldName: string;
5+
fileKey: string;
6+
userId: string;
7+
masterPwd: string;
8+
}
9+
10+
export class S3GetUploadUrlDs {
11+
connectionId: string;
12+
tableName: string;
13+
fieldName: string;
14+
userId: string;
15+
masterPwd: string;
16+
filename: string;
17+
contentType: string;
18+
}
19+
20+
export class S3FileUrlResponseDs {
21+
url: string;
22+
key: string;
23+
expiresIn: number;
24+
}
25+
26+
export class S3UploadUrlResponseDs {
27+
uploadUrl: string;
28+
key: string;
29+
expiresIn: number;
30+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export interface S3WidgetParams {
2+
bucket: string;
3+
prefix?: string;
4+
region?: string;
5+
aws_access_key_id_secret_name: string;
6+
aws_secret_access_key_secret_name: string;
7+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
2+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
3+
import { Injectable } from '@nestjs/common';
4+
5+
@Injectable()
6+
export class S3HelperService {
7+
public createS3Client(accessKeyId: string, secretAccessKey: string, region: string = 'us-east-1'): S3Client {
8+
return new S3Client({
9+
region,
10+
credentials: {
11+
accessKeyId,
12+
secretAccessKey,
13+
},
14+
});
15+
}
16+
17+
public async getSignedGetUrl(
18+
client: S3Client,
19+
bucket: string,
20+
key: string,
21+
expiresIn: number = 3600,
22+
): Promise<string> {
23+
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
24+
return getSignedUrl(client, command, { expiresIn });
25+
}
26+
27+
public async getSignedPutUrl(
28+
client: S3Client,
29+
bucket: string,
30+
key: string,
31+
contentType: string,
32+
expiresIn: number = 3600,
33+
): Promise<string> {
34+
const command = new PutObjectCommand({
35+
Bucket: bucket,
36+
Key: key,
37+
ContentType: contentType,
38+
});
39+
return getSignedUrl(client, command, { expiresIn });
40+
}
41+
42+
public generateFileKey(prefix: string | undefined, filename: string): string {
43+
const timestamp = Date.now();
44+
const sanitizedFilename = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
45+
if (prefix) {
46+
const normalizedPrefix = prefix.replace(/\/$/, '');
47+
return `${normalizedPrefix}/${timestamp}_${sanitizedFilename}`;
48+
}
49+
return `${timestamp}_${sanitizedFilename}`;
50+
}
51+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import {
2+
Body,
3+
Controller,
4+
Get,
5+
HttpStatus,
6+
Inject,
7+
Injectable,
8+
Post,
9+
Query,
10+
UseGuards,
11+
UseInterceptors,
12+
} from '@nestjs/common';
13+
import { HttpException } from '@nestjs/common/exceptions/http.exception.js';
14+
import { ApiBearerAuth, ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
15+
import { UseCaseType } from '../../common/data-injection.tokens.js';
16+
import { MasterPassword, QueryTableName, SlugUuid, UserId } from '../../decorators/index.js';
17+
import { InTransactionEnum } from '../../enums/index.js';
18+
import { Messages } from '../../exceptions/text/messages.js';
19+
import { ConnectionEditGuard, ConnectionReadGuard } from '../../guards/index.js';
20+
import { SentryInterceptor } from '../../interceptors/index.js';
21+
import { S3FileUrlResponseDs, S3UploadUrlResponseDs } from './application/data-structures/s3-operation.ds.js';
22+
import { IGetS3FileUrl, IGetS3UploadUrl } from './use-cases/s3-use-cases.interface.js';
23+
24+
@UseInterceptors(SentryInterceptor)
25+
@Controller()
26+
@ApiBearerAuth()
27+
@ApiTags('S3 Widget')
28+
@Injectable()
29+
export class S3WidgetController {
30+
constructor(
31+
@Inject(UseCaseType.GET_S3_FILE_URL)
32+
private readonly getS3FileUrlUseCase: IGetS3FileUrl,
33+
@Inject(UseCaseType.GET_S3_UPLOAD_URL)
34+
private readonly getS3UploadUrlUseCase: IGetS3UploadUrl,
35+
) {}
36+
37+
@UseGuards(ConnectionReadGuard)
38+
@ApiOperation({ summary: 'Get pre-signed URL for S3 file download' })
39+
@ApiResponse({
40+
status: 200,
41+
description: 'Pre-signed URL generated successfully.',
42+
})
43+
@ApiQuery({ name: 'tableName', required: true })
44+
@ApiQuery({ name: 'fieldName', required: true })
45+
@ApiQuery({ name: 'fileKey', required: true })
46+
@Get('/s3/file/:connectionId')
47+
async getFileUrl(
48+
@SlugUuid('connectionId') connectionId: string,
49+
@UserId() userId: string,
50+
@MasterPassword() masterPwd: string,
51+
@QueryTableName() tableName: string,
52+
@Query('fieldName') fieldName: string,
53+
@Query('fileKey') fileKey: string,
54+
): Promise<S3FileUrlResponseDs> {
55+
if (!connectionId) {
56+
throw new HttpException({ message: Messages.CONNECTION_ID_MISSING }, HttpStatus.BAD_REQUEST);
57+
}
58+
if (!fieldName) {
59+
throw new HttpException({ message: 'Field name is required' }, HttpStatus.BAD_REQUEST);
60+
}
61+
if (!fileKey) {
62+
throw new HttpException({ message: 'File key is required' }, HttpStatus.BAD_REQUEST);
63+
}
64+
65+
return await this.getS3FileUrlUseCase.execute(
66+
{
67+
connectionId,
68+
tableName,
69+
fieldName,
70+
fileKey,
71+
userId,
72+
masterPwd,
73+
},
74+
InTransactionEnum.OFF,
75+
);
76+
}
77+
78+
@UseGuards(ConnectionEditGuard)
79+
@ApiOperation({ summary: 'Get pre-signed URL for S3 file upload' })
80+
@ApiResponse({
81+
status: 201,
82+
description: 'Pre-signed upload URL generated successfully.',
83+
})
84+
@ApiQuery({ name: 'tableName', required: true })
85+
@ApiQuery({ name: 'fieldName', required: true })
86+
@ApiBody({
87+
schema: {
88+
type: 'object',
89+
properties: {
90+
filename: { type: 'string', description: 'Name of the file to upload' },
91+
contentType: { type: 'string', description: 'MIME type of the file' },
92+
},
93+
required: ['filename', 'contentType'],
94+
},
95+
})
96+
@Post('/s3/upload-url/:connectionId')
97+
async getUploadUrl(
98+
@SlugUuid('connectionId') connectionId: string,
99+
@UserId() userId: string,
100+
@MasterPassword() masterPwd: string,
101+
@QueryTableName() tableName: string,
102+
@Query('fieldName') fieldName: string,
103+
@Body() body: { filename: string; contentType: string },
104+
): Promise<S3UploadUrlResponseDs> {
105+
if (!connectionId) {
106+
throw new HttpException({ message: Messages.CONNECTION_ID_MISSING }, HttpStatus.BAD_REQUEST);
107+
}
108+
if (!fieldName) {
109+
throw new HttpException({ message: 'Field name is required' }, HttpStatus.BAD_REQUEST);
110+
}
111+
if (!body.filename) {
112+
throw new HttpException({ message: 'Filename is required' }, HttpStatus.BAD_REQUEST);
113+
}
114+
if (!body.contentType) {
115+
throw new HttpException({ message: 'Content type is required' }, HttpStatus.BAD_REQUEST);
116+
}
117+
118+
return await this.getS3UploadUrlUseCase.execute(
119+
{
120+
connectionId,
121+
tableName,
122+
fieldName,
123+
userId,
124+
masterPwd,
125+
filename: body.filename,
126+
contentType: body.contentType,
127+
},
128+
InTransactionEnum.OFF,
129+
);
130+
}
131+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { AuthMiddleware } from '../../authorization/index.js';
4+
import { GlobalDatabaseContext } from '../../common/application/global-database-context.js';
5+
import { BaseType, UseCaseType } from '../../common/data-injection.tokens.js';
6+
import { LogOutEntity } from '../log-out/log-out.entity.js';
7+
import { UserEntity } from '../user/user.entity.js';
8+
import { S3WidgetController } from './s3-widget.controller.js';
9+
import { S3HelperService } from './s3-helper.service.js';
10+
import { GetS3FileUrlUseCase } from './use-cases/get-s3-file-url.use.case.js';
11+
import { GetS3UploadUrlUseCase } from './use-cases/get-s3-upload-url.use.case.js';
12+
13+
@Module({
14+
imports: [TypeOrmModule.forFeature([UserEntity, LogOutEntity])],
15+
providers: [
16+
S3HelperService,
17+
{
18+
provide: BaseType.GLOBAL_DB_CONTEXT,
19+
useClass: GlobalDatabaseContext,
20+
},
21+
{
22+
provide: UseCaseType.GET_S3_FILE_URL,
23+
useClass: GetS3FileUrlUseCase,
24+
},
25+
{
26+
provide: UseCaseType.GET_S3_UPLOAD_URL,
27+
useClass: GetS3UploadUrlUseCase,
28+
},
29+
],
30+
controllers: [S3WidgetController],
31+
exports: [S3HelperService],
32+
})
33+
export class S3WidgetModule {
34+
public configure(consumer: MiddlewareConsumer): any {
35+
consumer
36+
.apply(AuthMiddleware)
37+
.forRoutes(
38+
{ path: '/s3/file/:connectionId', method: RequestMethod.GET },
39+
{ path: '/s3/upload-url/:connectionId', method: RequestMethod.POST },
40+
);
41+
}
42+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { HttpStatus, Inject, Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
2+
import { HttpException } from '@nestjs/common/exceptions/http.exception.js';
3+
import AbstractUseCase from '../../../common/abstract-use.case.js';
4+
import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js';
5+
import { BaseType } from '../../../common/data-injection.tokens.js';
6+
import { Messages } from '../../../exceptions/text/messages.js';
7+
import { Encryptor } from '../../../helpers/encryption/encryptor.js';
8+
import { S3FileUrlResponseDs, S3GetFileUrlDs } from '../application/data-structures/s3-operation.ds.js';
9+
import { S3WidgetParams } from '../application/data-structures/s3-widget-params.ds.js';
10+
import { S3HelperService } from '../s3-helper.service.js';
11+
import { IGetS3FileUrl } from './s3-use-cases.interface.js';
12+
import { WidgetTypeEnum } from '../../../enums/index.js';
13+
import JSON5 from 'json5';
14+
15+
@Injectable()
16+
export class GetS3FileUrlUseCase extends AbstractUseCase<S3GetFileUrlDs, S3FileUrlResponseDs> implements IGetS3FileUrl {
17+
constructor(
18+
@Inject(BaseType.GLOBAL_DB_CONTEXT)
19+
protected _dbContext: IGlobalDatabaseContext,
20+
private readonly s3Helper: S3HelperService,
21+
) {
22+
super();
23+
}
24+
25+
protected async implementation(inputData: S3GetFileUrlDs): Promise<S3FileUrlResponseDs> {
26+
const { connectionId, tableName, fieldName, fileKey, userId, masterPwd } = inputData;
27+
28+
const user = await this._dbContext.userRepository.findOneUserByIdWithCompany(userId);
29+
if (!user || !user.company) {
30+
throw new HttpException(
31+
{ message: Messages.USER_NOT_FOUND_OR_NOT_IN_COMPANY },
32+
HttpStatus.NOT_FOUND,
33+
);
34+
}
35+
36+
const foundTableWidgets = await this._dbContext.tableWidgetsRepository.findTableWidgets(connectionId, tableName);
37+
const widget = foundTableWidgets.find((w) => w.field_name === fieldName);
38+
39+
if (!widget || widget.widget_type !== WidgetTypeEnum.S3) {
40+
throw new HttpException(
41+
{ message: 'S3 widget not configured for this field' },
42+
HttpStatus.BAD_REQUEST,
43+
);
44+
}
45+
46+
const params: S3WidgetParams =
47+
typeof widget.widget_params === 'string' ? JSON5.parse(widget.widget_params) : widget.widget_params;
48+
49+
const accessKeySecret = await this._dbContext.userSecretRepository.findSecretBySlugAndCompanyId(
50+
params.aws_access_key_id_secret_name,
51+
user.company.id,
52+
);
53+
54+
const secretKeySecret = await this._dbContext.userSecretRepository.findSecretBySlugAndCompanyId(
55+
params.aws_secret_access_key_secret_name,
56+
user.company.id,
57+
);
58+
59+
if (!accessKeySecret || !secretKeySecret) {
60+
throw new HttpException(
61+
{ message: 'AWS credentials secrets not found' },
62+
HttpStatus.NOT_FOUND,
63+
);
64+
}
65+
66+
let accessKeyId = Encryptor.decryptData(accessKeySecret.encryptedValue);
67+
let secretAccessKey = Encryptor.decryptData(secretKeySecret.encryptedValue);
68+
69+
if (accessKeySecret.masterEncryption && masterPwd) {
70+
accessKeyId = Encryptor.decryptDataMasterPwd(accessKeyId, masterPwd);
71+
}
72+
if (secretKeySecret.masterEncryption && masterPwd) {
73+
secretAccessKey = Encryptor.decryptDataMasterPwd(secretAccessKey, masterPwd);
74+
}
75+
76+
const client = this.s3Helper.createS3Client(accessKeyId, secretAccessKey, params.region || 'us-east-1');
77+
78+
const expiresIn = 3600;
79+
const url = await this.s3Helper.getSignedGetUrl(client, params.bucket, fileKey, expiresIn);
80+
81+
return { url, key: fileKey, expiresIn };
82+
}
83+
}

0 commit comments

Comments
 (0)