Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export enum BucketProviderEnum {
AWS = 'aws',
DigitalOceanSpaces = 'digitalocean',
BackblazeB2 = 'backblaze',
Wasabi = 'wasabi',
CloudflareR2 = 'cloudflare-r2',
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export class S3GetFileUrlDs {
export class GetBucketFileUrlDs {
connectionId: string;
tableName: string;
fieldName: string;
Expand All @@ -7,7 +7,7 @@ export class S3GetFileUrlDs {
masterPwd: string;
}

export class S3GetUploadUrlDs {
export class GetBucketUploadUrlDs {
connectionId: string;
tableName: string;
fieldName: string;
Expand All @@ -17,13 +17,13 @@ export class S3GetUploadUrlDs {
contentType: string;
}

export class S3FileUrlResponseDs {
export class BucketFileUrlResponseDs {
url: string;
key: string;
expiresIn: number;
}

export class S3UploadUrlResponseDs {
export class BucketUploadUrlResponseDs {
uploadUrl: string;
key: string;
expiresIn: number;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
export interface S3WidgetParams {
import { BucketProviderEnum } from './bucket-provider.enum.js';

export interface BucketWidgetParams {
provider?: BucketProviderEnum;
bucket: string;
prefix?: string;
region?: string;
aws_access_key_id_secret_name: string;
aws_secret_access_key_secret_name: string;
type?: 'file' | 'image'; // 'file' (default) - accepts all files, 'image' - accepts only images
account_id?: string;
access_key_id_secret_name: string;
secret_access_key_secret_name: string;
type?: 'file' | 'image';
}
59 changes: 55 additions & 4 deletions backend/src/entities/s3-widget/s3-helper.service.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Injectable } from '@nestjs/common';
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { nanoid } from 'nanoid';
import { BucketProviderEnum } from './application/data-structures/bucket-provider.enum.js';

export interface BucketClientConfig {
accessKeyId: string;
secretAccessKey: string;
provider?: BucketProviderEnum;
region?: string;
accountId?: string;
}

@Injectable()
export class S3HelperService {
public createS3Client(accessKeyId: string, secretAccessKey: string, region: string = 'us-east-1'): S3Client {
public createS3Client(config: BucketClientConfig): S3Client {
const provider = config.provider || BucketProviderEnum.AWS;
const region = this._resolveRegion(provider, config.region);
const endpoint = this._resolveEndpoint(provider, region, config.accountId);

return new S3Client({
region,
endpoint,
credentials: {
accessKeyId,
secretAccessKey,
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
});
}
Expand Down Expand Up @@ -52,6 +66,43 @@ export class S3HelperService {
return key;
}

private _resolveRegion(provider: BucketProviderEnum, region: string | undefined): string {
if (region) {
return region;
}
if (provider === BucketProviderEnum.CloudflareR2) {
return 'auto';
}
return 'us-east-1';
}

private _resolveEndpoint(
provider: BucketProviderEnum,
region: string,
accountId: string | undefined,
): string | undefined {
switch (provider) {
case BucketProviderEnum.AWS:
return undefined;
case BucketProviderEnum.DigitalOceanSpaces:
return `https://${region}.digitaloceanspaces.com`;
case BucketProviderEnum.BackblazeB2:
return `https://s3.${region}.backblazeb2.com`;
case BucketProviderEnum.Wasabi:
return `https://s3.${region}.wasabisys.com`;
case BucketProviderEnum.CloudflareR2:
if (!accountId) {
throw new HttpException(
{ message: 'Cloudflare R2 requires account_id in widget params' },
HttpStatus.BAD_REQUEST,
);
}
return `https://${accountId}.r2.cloudflarestorage.com`;
default:
return undefined;
}
}

private _extractFileExtension(filename: string): string {
const lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex === -1 || lastDotIndex === 0) {
Expand Down
6 changes: 3 additions & 3 deletions backend/src/entities/s3-widget/s3-widget.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { Messages } from '../../exceptions/text/messages.js';
import { ConnectionEditGuard } from '../../guards/connection-edit.guard.js';
import { ConnectionReadGuard } from '../../guards/connection-read.guard.js';
import { SentryInterceptor } from '../../interceptors/sentry.interceptor.js';
import { S3FileUrlResponseDs, S3UploadUrlResponseDs } from './application/data-structures/s3-operation.ds.js';
import { BucketFileUrlResponseDs, BucketUploadUrlResponseDs } from './application/data-structures/s3-operation.ds.js';
import { IGetS3FileUrl, IGetS3UploadUrl } from './use-cases/s3-use-cases.interface.js';

@UseInterceptors(SentryInterceptor)
Expand Down Expand Up @@ -61,7 +61,7 @@ export class S3WidgetController {
@QueryTableName() tableName: string,
@Query('fieldName') fieldName: string,
@Query('rowPrimaryKey') rowPrimaryKeyStr: string,
): Promise<S3FileUrlResponseDs> {
): Promise<BucketFileUrlResponseDs> {
if (!connectionId) {
throw new HttpException({ message: Messages.CONNECTION_ID_MISSING }, HttpStatus.BAD_REQUEST);
}
Expand Down Expand Up @@ -118,7 +118,7 @@ export class S3WidgetController {
@QueryTableName() tableName: string,
@Query('fieldName') fieldName: string,
@Body() body: { filename: string; contentType: string },
): Promise<S3UploadUrlResponseDs> {
): Promise<BucketUploadUrlResponseDs> {
if (!connectionId) {
throw new HttpException({ message: Messages.CONNECTION_ID_MISSING }, HttpStatus.BAD_REQUEST);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ import { WidgetTypeEnum } from '../../../enums/widget-type.enum.js';
import { Messages } from '../../../exceptions/text/messages.js';
import { Encryptor } from '../../../helpers/encryption/encryptor.js';
import { isConnectionTypeAgent } from '../../../helpers/is-connection-entity-agent.js';
import { S3FileUrlResponseDs, S3GetFileUrlDs } from '../application/data-structures/s3-operation.ds.js';
import { S3WidgetParams } from '../application/data-structures/s3-widget-params.ds.js';
import { BucketFileUrlResponseDs, GetBucketFileUrlDs } from '../application/data-structures/s3-operation.ds.js';
import { BucketWidgetParams } from '../application/data-structures/s3-widget-params.ds.js';
import { S3HelperService } from '../s3-helper.service.js';
import { IGetS3FileUrl } from './s3-use-cases.interface.js';

@Injectable()
export class GetS3FileUrlUseCase extends AbstractUseCase<S3GetFileUrlDs, S3FileUrlResponseDs> implements IGetS3FileUrl {
export class GetS3FileUrlUseCase
extends AbstractUseCase<GetBucketFileUrlDs, BucketFileUrlResponseDs>
implements IGetS3FileUrl
{
constructor(
@Inject(BaseType.GLOBAL_DB_CONTEXT)
protected _dbContext: IGlobalDatabaseContext,
Expand All @@ -25,7 +28,7 @@ export class GetS3FileUrlUseCase extends AbstractUseCase<S3GetFileUrlDs, S3FileU
super();
}

protected async implementation(inputData: S3GetFileUrlDs): Promise<S3FileUrlResponseDs> {
protected async implementation(inputData: GetBucketFileUrlDs): Promise<BucketFileUrlResponseDs> {
const { connectionId, tableName, fieldName, rowPrimaryKey, userId, masterPwd } = inputData;

const user = await this._dbContext.userRepository.findOneUserByIdWithCompany(userId);
Expand All @@ -45,7 +48,7 @@ export class GetS3FileUrlUseCase extends AbstractUseCase<S3GetFileUrlDs, S3FileU
throw new HttpException({ message: 'S3 widget not configured for this field' }, HttpStatus.BAD_REQUEST);
}

const params: S3WidgetParams =
const params: BucketWidgetParams =
typeof widget.widget_params === 'string' ? JSON5.parse(widget.widget_params) : widget.widget_params;

// Fetch the row from database to get the actual file key
Expand Down Expand Up @@ -78,17 +81,17 @@ export class GetS3FileUrlUseCase extends AbstractUseCase<S3GetFileUrlDs, S3FileU
}

const accessKeySecret = await this._dbContext.userSecretRepository.findSecretBySlugAndCompanyId(
params.aws_access_key_id_secret_name,
params.access_key_id_secret_name,
user.company.id,
);

Comment on lines 83 to 87
const secretKeySecret = await this._dbContext.userSecretRepository.findSecretBySlugAndCompanyId(
params.aws_secret_access_key_secret_name,
params.secret_access_key_secret_name,
user.company.id,
);

if (!accessKeySecret || !secretKeySecret) {
throw new HttpException({ message: 'AWS credentials secrets not found' }, HttpStatus.NOT_FOUND);
throw new HttpException({ message: 'Bucket credentials secrets not found' }, HttpStatus.NOT_FOUND);
}

let accessKeyId = Encryptor.decryptData(accessKeySecret.encryptedValue);
Expand All @@ -101,7 +104,13 @@ export class GetS3FileUrlUseCase extends AbstractUseCase<S3GetFileUrlDs, S3FileU
secretAccessKey = Encryptor.decryptDataMasterPwd(secretAccessKey, masterPwd);
}

const client = this.s3Helper.createS3Client(accessKeyId, secretAccessKey, params.region || 'us-east-1');
const client = this.s3Helper.createS3Client({
accessKeyId,
secretAccessKey,
provider: params.provider,
region: params.region,
accountId: params.account_id,
});

const expiresIn = 3600;
const url = await this.s3Helper.getSignedGetUrl(client, params.bucket, fileKey, expiresIn);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ import { BaseType } from '../../../common/data-injection.tokens.js';
import { WidgetTypeEnum } from '../../../enums/widget-type.enum.js';
import { Messages } from '../../../exceptions/text/messages.js';
import { Encryptor } from '../../../helpers/encryption/encryptor.js';
import { S3GetUploadUrlDs, S3UploadUrlResponseDs } from '../application/data-structures/s3-operation.ds.js';
import { S3WidgetParams } from '../application/data-structures/s3-widget-params.ds.js';
import { BucketUploadUrlResponseDs, GetBucketUploadUrlDs } from '../application/data-structures/s3-operation.ds.js';
import { BucketWidgetParams } from '../application/data-structures/s3-widget-params.ds.js';
import { S3HelperService } from '../s3-helper.service.js';
import { IGetS3UploadUrl } from './s3-use-cases.interface.js';

@Injectable()
export class GetS3UploadUrlUseCase
extends AbstractUseCase<S3GetUploadUrlDs, S3UploadUrlResponseDs>
extends AbstractUseCase<GetBucketUploadUrlDs, BucketUploadUrlResponseDs>
implements IGetS3UploadUrl
{
constructor(
Expand All @@ -25,7 +25,7 @@ export class GetS3UploadUrlUseCase
super();
}

protected async implementation(inputData: S3GetUploadUrlDs): Promise<S3UploadUrlResponseDs> {
protected async implementation(inputData: GetBucketUploadUrlDs): Promise<BucketUploadUrlResponseDs> {
const { connectionId, tableName, fieldName, userId, masterPwd, filename, contentType } = inputData;

const user = await this._dbContext.userRepository.findOneUserByIdWithCompany(userId);
Expand All @@ -40,21 +40,21 @@ export class GetS3UploadUrlUseCase
throw new HttpException({ message: 'S3 widget not configured for this field' }, HttpStatus.BAD_REQUEST);
}

const params: S3WidgetParams =
const params: BucketWidgetParams =
typeof widget.widget_params === 'string' ? JSON5.parse(widget.widget_params) : widget.widget_params;

const accessKeySecret = await this._dbContext.userSecretRepository.findSecretBySlugAndCompanyId(
params.aws_access_key_id_secret_name,
params.access_key_id_secret_name,
Comment on lines +43 to +47
user.company.id,
);

const secretKeySecret = await this._dbContext.userSecretRepository.findSecretBySlugAndCompanyId(
params.aws_secret_access_key_secret_name,
params.secret_access_key_secret_name,
user.company.id,
);

if (!accessKeySecret || !secretKeySecret) {
throw new HttpException({ message: 'AWS credentials secrets not found' }, HttpStatus.NOT_FOUND);
throw new HttpException({ message: 'Bucket credentials secrets not found' }, HttpStatus.NOT_FOUND);
}

let accessKeyId = Encryptor.decryptData(accessKeySecret.encryptedValue);
Expand All @@ -67,7 +67,13 @@ export class GetS3UploadUrlUseCase
secretAccessKey = Encryptor.decryptDataMasterPwd(secretAccessKey, masterPwd);
}

const client = this.s3Helper.createS3Client(accessKeyId, secretAccessKey, params.region || 'us-east-1');
const client = this.s3Helper.createS3Client({
accessKeyId,
secretAccessKey,
provider: params.provider,
region: params.region,
accountId: params.account_id,
});

const key = this.s3Helper.generateFileKey(params.prefix, filename);
const expiresIn = 3600;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { InTransactionEnum } from '../../../enums/in-transaction.enum.js';
import {
S3FileUrlResponseDs,
S3GetFileUrlDs,
S3GetUploadUrlDs,
S3UploadUrlResponseDs,
BucketFileUrlResponseDs,
BucketUploadUrlResponseDs,
GetBucketFileUrlDs,
GetBucketUploadUrlDs,
} from '../application/data-structures/s3-operation.ds.js';

export interface IGetS3FileUrl {
execute(inputData: S3GetFileUrlDs, inTransaction: InTransactionEnum): Promise<S3FileUrlResponseDs>;
execute(inputData: GetBucketFileUrlDs, inTransaction: InTransactionEnum): Promise<BucketFileUrlResponseDs>;
}

export interface IGetS3UploadUrl {
execute(inputData: S3GetUploadUrlDs, inTransaction: InTransactionEnum): Promise<S3UploadUrlResponseDs>;
execute(inputData: GetBucketUploadUrlDs, inTransaction: InTransactionEnum): Promise<BucketUploadUrlResponseDs>;
}
18 changes: 14 additions & 4 deletions backend/src/entities/widget/utils/validate-create-widgets-ds.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import JSON5 from 'json5';
import { BucketProviderEnum } from '../../s3-widget/application/data-structures/bucket-provider.enum.js';
import { EncryptionAlgorithmEnum } from '../../../enums/encryption-algorithm.enum.js';
import { WidgetTypeEnum } from '../../../enums/widget-type.enum.js';
import { Messages } from '../../../exceptions/text/messages.js';
Expand Down Expand Up @@ -102,11 +103,20 @@ export async function validateCreateWidgetsDs(
if (!widget_params.bucket) {
errors.push(Messages.WIDGET_REQUIRED_PARAMETER_MISSING('bucket'));
}
if (!widget_params.aws_access_key_id_secret_name) {
errors.push(Messages.WIDGET_REQUIRED_PARAMETER_MISSING('aws_access_key_id_secret_name'));
if (!widget_params.access_key_id_secret_name) {
errors.push(Messages.WIDGET_REQUIRED_PARAMETER_MISSING('access_key_id_secret_name'));
}
if (!widget_params.aws_secret_access_key_secret_name) {
errors.push(Messages.WIDGET_REQUIRED_PARAMETER_MISSING('aws_secret_access_key_secret_name'));
if (!widget_params.secret_access_key_secret_name) {
errors.push(Messages.WIDGET_REQUIRED_PARAMETER_MISSING('secret_access_key_secret_name'));
}
if (
widget_params.provider &&
!Object.values(BucketProviderEnum).includes(widget_params.provider as BucketProviderEnum)
) {
errors.push(Messages.WIDGET_PARAMETER_UNSUPPORTED('provider', widget_type));
}
if (widget_params.provider === BucketProviderEnum.CloudflareR2 && !widget_params.account_id) {
errors.push(Messages.WIDGET_REQUIRED_PARAMETER_MISSING('account_id'));
}
}

Expand Down
Loading
Loading