Skip to content

Commit a179bb2

Browse files
Merge pull request #1532 from rocket-admin/s3-widget-secure-paths-upload-fix
S3 widget secure paths upload fix
2 parents 6b50dec + 35acfeb commit a179bb2

File tree

10 files changed

+587
-530
lines changed

10 files changed

+587
-530
lines changed

backend/src/entities/s3-widget/application/data-structures/s3-operation.ds.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,5 @@ export class S3UploadUrlResponseDs {
2727
uploadUrl: string;
2828
key: string;
2929
expiresIn: number;
30+
previewUrl: string;
3031
}
Lines changed: 57 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,66 @@
1-
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
1+
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
22
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
33
import { Injectable } from '@nestjs/common';
4+
import { nanoid } from 'nanoid';
45

56
@Injectable()
67
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-
}
8+
public createS3Client(accessKeyId: string, secretAccessKey: string, region: string = 'us-east-1'): S3Client {
9+
return new S3Client({
10+
region,
11+
credentials: {
12+
accessKeyId,
13+
secretAccessKey,
14+
},
15+
});
16+
}
1617

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-
}
18+
public async getSignedGetUrl(
19+
client: S3Client,
20+
bucket: string,
21+
key: string,
22+
expiresIn: number = 3600,
23+
): Promise<string> {
24+
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
25+
return getSignedUrl(client, command, { expiresIn });
26+
}
2627

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-
}
28+
public async getSignedPutUrl(
29+
client: S3Client,
30+
bucket: string,
31+
key: string,
32+
contentType: string,
33+
expiresIn: number = 3600,
34+
): Promise<string> {
35+
const command = new PutObjectCommand({
36+
Bucket: bucket,
37+
Key: key,
38+
ContentType: contentType,
39+
});
40+
return getSignedUrl(client, command, { expiresIn });
41+
}
4142

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-
}
43+
public generateFileKey(prefix: string | undefined, filename: string): string {
44+
const id = nanoid(12);
45+
const extension = this._extractFileExtension(filename);
46+
const key = extension ? `${id}${extension}` : id;
47+
48+
if (prefix) {
49+
const normalizedPrefix = prefix.replace(/\/$/, '');
50+
return `${normalizedPrefix}/${key}`;
51+
}
52+
return key;
53+
}
54+
55+
private _extractFileExtension(filename: string): string {
56+
const lastDotIndex = filename.lastIndexOf('.');
57+
if (lastDotIndex === -1 || lastDotIndex === 0) {
58+
return '';
59+
}
60+
const extension = filename.slice(lastDotIndex).toLowerCase();
61+
if (/^\.[a-z0-9]+$/i.test(extension)) {
62+
return extension;
63+
}
64+
return '';
65+
}
5166
}
Lines changed: 50 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,79 @@
11
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
22
import { HttpException } from '@nestjs/common/exceptions/http.exception.js';
3+
import JSON5 from 'json5';
34
import AbstractUseCase from '../../../common/abstract-use.case.js';
45
import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js';
56
import { BaseType } from '../../../common/data-injection.tokens.js';
7+
import { WidgetTypeEnum } from '../../../enums/index.js';
68
import { Messages } from '../../../exceptions/text/messages.js';
79
import { Encryptor } from '../../../helpers/encryption/encryptor.js';
810
import { S3GetUploadUrlDs, S3UploadUrlResponseDs } from '../application/data-structures/s3-operation.ds.js';
911
import { S3WidgetParams } from '../application/data-structures/s3-widget-params.ds.js';
1012
import { S3HelperService } from '../s3-helper.service.js';
1113
import { IGetS3UploadUrl } from './s3-use-cases.interface.js';
12-
import { WidgetTypeEnum } from '../../../enums/index.js';
13-
import JSON5 from 'json5';
1414

1515
@Injectable()
1616
export class GetS3UploadUrlUseCase
17-
extends AbstractUseCase<S3GetUploadUrlDs, S3UploadUrlResponseDs>
18-
implements IGetS3UploadUrl
17+
extends AbstractUseCase<S3GetUploadUrlDs, S3UploadUrlResponseDs>
18+
implements IGetS3UploadUrl
1919
{
20-
constructor(
21-
@Inject(BaseType.GLOBAL_DB_CONTEXT)
22-
protected _dbContext: IGlobalDatabaseContext,
23-
private readonly s3Helper: S3HelperService,
24-
) {
25-
super();
26-
}
20+
constructor(
21+
@Inject(BaseType.GLOBAL_DB_CONTEXT)
22+
protected _dbContext: IGlobalDatabaseContext,
23+
private readonly s3Helper: S3HelperService,
24+
) {
25+
super();
26+
}
2727

28-
protected async implementation(inputData: S3GetUploadUrlDs): Promise<S3UploadUrlResponseDs> {
29-
const { connectionId, tableName, fieldName, userId, masterPwd, filename, contentType } = inputData;
28+
protected async implementation(inputData: S3GetUploadUrlDs): Promise<S3UploadUrlResponseDs> {
29+
const { connectionId, tableName, fieldName, userId, masterPwd, filename, contentType } = inputData;
3030

31-
const user = await this._dbContext.userRepository.findOneUserByIdWithCompany(userId);
32-
if (!user || !user.company) {
33-
throw new HttpException({ message: Messages.USER_NOT_FOUND_OR_NOT_IN_COMPANY }, HttpStatus.NOT_FOUND);
34-
}
31+
const user = await this._dbContext.userRepository.findOneUserByIdWithCompany(userId);
32+
if (!user || !user.company) {
33+
throw new HttpException({ message: Messages.USER_NOT_FOUND_OR_NOT_IN_COMPANY }, HttpStatus.NOT_FOUND);
34+
}
3535

36-
const foundTableWidgets = await this._dbContext.tableWidgetsRepository.findTableWidgets(connectionId, tableName);
37-
const widget = foundTableWidgets.find((w) => w.field_name === fieldName);
36+
const foundTableWidgets = await this._dbContext.tableWidgetsRepository.findTableWidgets(connectionId, tableName);
37+
const widget = foundTableWidgets.find((w) => w.field_name === fieldName);
3838

39-
if (!widget || widget.widget_type !== WidgetTypeEnum.S3) {
40-
throw new HttpException({ message: 'S3 widget not configured for this field' }, HttpStatus.BAD_REQUEST);
41-
}
39+
if (!widget || widget.widget_type !== WidgetTypeEnum.S3) {
40+
throw new HttpException({ message: 'S3 widget not configured for this field' }, HttpStatus.BAD_REQUEST);
41+
}
4242

43-
const params: S3WidgetParams =
44-
typeof widget.widget_params === 'string' ? JSON5.parse(widget.widget_params) : widget.widget_params;
43+
const params: S3WidgetParams =
44+
typeof widget.widget_params === 'string' ? JSON5.parse(widget.widget_params) : widget.widget_params;
4545

46-
const accessKeySecret = await this._dbContext.userSecretRepository.findSecretBySlugAndCompanyId(
47-
params.aws_access_key_id_secret_name,
48-
user.company.id,
49-
);
46+
const accessKeySecret = await this._dbContext.userSecretRepository.findSecretBySlugAndCompanyId(
47+
params.aws_access_key_id_secret_name,
48+
user.company.id,
49+
);
5050

51-
const secretKeySecret = await this._dbContext.userSecretRepository.findSecretBySlugAndCompanyId(
52-
params.aws_secret_access_key_secret_name,
53-
user.company.id,
54-
);
51+
const secretKeySecret = await this._dbContext.userSecretRepository.findSecretBySlugAndCompanyId(
52+
params.aws_secret_access_key_secret_name,
53+
user.company.id,
54+
);
5555

56-
if (!accessKeySecret || !secretKeySecret) {
57-
throw new HttpException({ message: 'AWS credentials secrets not found' }, HttpStatus.NOT_FOUND);
58-
}
56+
if (!accessKeySecret || !secretKeySecret) {
57+
throw new HttpException({ message: 'AWS credentials secrets not found' }, HttpStatus.NOT_FOUND);
58+
}
5959

60-
let accessKeyId = Encryptor.decryptData(accessKeySecret.encryptedValue);
61-
let secretAccessKey = Encryptor.decryptData(secretKeySecret.encryptedValue);
60+
let accessKeyId = Encryptor.decryptData(accessKeySecret.encryptedValue);
61+
let secretAccessKey = Encryptor.decryptData(secretKeySecret.encryptedValue);
6262

63-
if (accessKeySecret.masterEncryption && masterPwd) {
64-
accessKeyId = Encryptor.decryptDataMasterPwd(accessKeyId, masterPwd);
65-
}
66-
if (secretKeySecret.masterEncryption && masterPwd) {
67-
secretAccessKey = Encryptor.decryptDataMasterPwd(secretAccessKey, masterPwd);
68-
}
63+
if (accessKeySecret.masterEncryption && masterPwd) {
64+
accessKeyId = Encryptor.decryptDataMasterPwd(accessKeyId, masterPwd);
65+
}
66+
if (secretKeySecret.masterEncryption && masterPwd) {
67+
secretAccessKey = Encryptor.decryptDataMasterPwd(secretAccessKey, masterPwd);
68+
}
6969

70-
const client = this.s3Helper.createS3Client(accessKeyId, secretAccessKey, params.region || 'us-east-1');
70+
const client = this.s3Helper.createS3Client(accessKeyId, secretAccessKey, params.region || 'us-east-1');
7171

72-
const key = this.s3Helper.generateFileKey(params.prefix, filename);
73-
const expiresIn = 3600;
74-
const uploadUrl = await this.s3Helper.getSignedPutUrl(client, params.bucket, key, contentType, expiresIn);
72+
const key = this.s3Helper.generateFileKey(params.prefix, filename);
73+
const expiresIn = 3600;
74+
const uploadUrl = await this.s3Helper.getSignedPutUrl(client, params.bucket, key, contentType, expiresIn);
75+
const previewUrl = await this.s3Helper.getSignedGetUrl(client, params.bucket, key, expiresIn);
7576

76-
return { uploadUrl, key, expiresIn };
77-
}
77+
return { uploadUrl, key, expiresIn, previewUrl };
78+
}
7879
}
Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,37 @@
11
<div class="s3-widget">
22
<mat-form-field class="s3-widget__key" appearance="outline">
3-
<mat-label>{{normalizedLabel}}</mat-label>
4-
<input matInput type="text" name="{{label}}-{{key}}"
5-
[required]="required" [disabled]="disabled" [readonly]="readonly"
6-
attr.data-testid="record-{{label}}-s3"
7-
[(ngModel)]="value" (ngModelChange)="onFieldChange.emit($event)">
3+
<mat-label>{{normalizedLabel()}}</mat-label>
4+
<input matInput type="text" name="{{label()}}-{{key()}}"
5+
[required]="required()" [disabled]="disabled()" [readonly]="readonly()"
6+
attr.data-testid="record-{{label()}}-s3"
7+
[ngModel]="internalValue() || value()" (ngModelChange)="onValueChange($event)">
88
</mat-form-field>
99

1010
<div class="s3-widget__actions">
11-
<input type="file" #fileInput hidden [accept]="fileAccept" (change)="onFileSelected($event)">
11+
<input type="file" #fileInput hidden [accept]="fileAccept()" (change)="onFileSelected($event)">
1212
<button mat-stroked-button type="button"
13-
[disabled]="disabled || readonly || isLoading"
13+
[disabled]="disabled() || readonly() || isLoading()"
1414
(click)="fileInput.click()">
1515
<mat-icon>upload</mat-icon>
1616
Upload
1717
</button>
1818
<button mat-stroked-button type="button"
19-
*ngIf="previewUrl"
19+
*ngIf="previewUrl()"
2020
(click)="openFile()">
2121
<mat-icon>open_in_new</mat-icon>
2222
Open
2323
</button>
2424
</div>
2525

26-
<div class="s3-widget__preview" *ngIf="value">
27-
<mat-spinner *ngIf="isLoading" diameter="40"></mat-spinner>
28-
<img *ngIf="isImage && previewUrl && !isLoading"
29-
[src]="previewUrl"
26+
<div class="s3-widget__preview" *ngIf="internalValue() || value()">
27+
<mat-spinner *ngIf="isLoading()" diameter="40"></mat-spinner>
28+
<img *ngIf="isImage() && previewUrl() && !isLoading()"
29+
[src]="previewUrl()"
3030
class="s3-widget__thumbnail"
3131
alt="Preview">
32-
<div *ngIf="!isImage && previewUrl && !isLoading" class="s3-widget__file-icon">
32+
<div *ngIf="!isImage() && previewUrl() && !isLoading()" class="s3-widget__file-icon">
3333
<mat-icon>insert_drive_file</mat-icon>
34-
<span class="s3-widget__filename">{{value | slice:-30}}</span>
34+
<span class="s3-widget__filename">{{(internalValue() || value()) | slice:-30}}</span>
3535
</div>
3636
</div>
3737
</div>

0 commit comments

Comments
 (0)