From 9e3a7d8f37a09c1b51cb4d82e5ab2963b7057252 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Mon, 22 Dec 2025 10:03:39 +0000 Subject: [PATCH 1/5] s3 widget --- backend/package.json | 2 + backend/src/app.module.ts | 2 + backend/src/common/data-injection.tokens.ts | 3 + .../data-structures/s3-operation.ds.ts | 30 ++++ .../data-structures/s3-widget-params.ds.ts | 7 + .../entities/s3-widget/s3-helper.service.ts | 51 ++++++ .../s3-widget/s3-widget.controller.ts | 131 +++++++++++++++ .../entities/s3-widget/s3-widget.module.ts | 42 +++++ .../use-cases/get-s3-file-url.use.case.ts | 83 ++++++++++ .../use-cases/get-s3-upload-url.use.case.ts | 78 +++++++++ .../use-cases/s3-use-cases.interface.ts | 10 ++ .../utils/validate-create-widgets-ds.ts | 17 ++ backend/src/enums/widget-type.enum.ts | 3 +- .../db-table-widgets.component.ts | 16 ++ .../widget/widget.component.ts | 2 +- .../record-edit-fields/s3/s3.component.css | 57 +++++++ .../record-edit-fields/s3/s3.component.html | 37 +++++ .../record-edit-fields/s3/s3.component.ts | 149 ++++++++++++++++++ frontend/src/app/consts/record-edit-types.ts | 2 + frontend/src/app/services/s3.service.ts | 85 ++++++++++ .../shared/enums/table-widget-type.enum.ts | 1 + 21 files changed, 806 insertions(+), 2 deletions(-) create mode 100644 backend/src/entities/s3-widget/application/data-structures/s3-operation.ds.ts create mode 100644 backend/src/entities/s3-widget/application/data-structures/s3-widget-params.ds.ts create mode 100644 backend/src/entities/s3-widget/s3-helper.service.ts create mode 100644 backend/src/entities/s3-widget/s3-widget.controller.ts create mode 100644 backend/src/entities/s3-widget/s3-widget.module.ts create mode 100644 backend/src/entities/s3-widget/use-cases/get-s3-file-url.use.case.ts create mode 100644 backend/src/entities/s3-widget/use-cases/get-s3-upload-url.use.case.ts create mode 100644 backend/src/entities/s3-widget/use-cases/s3-use-cases.interface.ts create mode 100644 frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.css create mode 100644 frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.html create mode 100644 frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.ts create mode 100644 frontend/src/app/services/s3.service.ts diff --git a/backend/package.json b/backend/package.json index a26f17bdb..2d54b223e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,7 +26,9 @@ }, "dependencies": { "@amplitude/node": "1.10.2", + "@aws-sdk/client-s3": "^3.953.0", "@aws-sdk/lib-dynamodb": "^3.953.0", + "@aws-sdk/s3-request-presigner": "^3.953.0", "@electric-sql/pglite": "^0.3.14", "@faker-js/faker": "^10.1.0", "@nestjs/common": "11.1.9", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 91c83bf59..a3bd334cb 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -40,6 +40,7 @@ import { SharedJobsModule } from './entities/shared-jobs/shared-jobs.module.js'; import { TableCategoriesModule } from './entities/table-categories/table-categories.module.js'; import { UserSecretModule } from './entities/user-secret/user-secret.module.js'; import { SignInAuditModule } from './entities/user-sign-in-audit/sign-in-audit.module.js'; +import { S3WidgetModule } from './entities/s3-widget/s3-widget.module.js'; @Module({ imports: [ @@ -84,6 +85,7 @@ import { SignInAuditModule } from './entities/user-sign-in-audit/sign-in-audit.m TableCategoriesModule, UserSecretModule, SignInAuditModule, + S3WidgetModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/common/data-injection.tokens.ts b/backend/src/common/data-injection.tokens.ts index bb0176b55..2114c0ba4 100644 --- a/backend/src/common/data-injection.tokens.ts +++ b/backend/src/common/data-injection.tokens.ts @@ -172,4 +172,7 @@ export enum UseCaseType { DELETE_SECRET = 'DELETE_SECRET', GET_SECRET_AUDIT_LOG = 'GET_SECRET_AUDIT_LOG', FIND_SIGN_IN_AUDIT_LOGS = 'FIND_SIGN_IN_AUDIT_LOGS', + + GET_S3_FILE_URL = 'GET_S3_FILE_URL', + GET_S3_UPLOAD_URL = 'GET_S3_UPLOAD_URL', } diff --git a/backend/src/entities/s3-widget/application/data-structures/s3-operation.ds.ts b/backend/src/entities/s3-widget/application/data-structures/s3-operation.ds.ts new file mode 100644 index 000000000..af906e282 --- /dev/null +++ b/backend/src/entities/s3-widget/application/data-structures/s3-operation.ds.ts @@ -0,0 +1,30 @@ +export class S3GetFileUrlDs { + connectionId: string; + tableName: string; + fieldName: string; + fileKey: string; + userId: string; + masterPwd: string; +} + +export class S3GetUploadUrlDs { + connectionId: string; + tableName: string; + fieldName: string; + userId: string; + masterPwd: string; + filename: string; + contentType: string; +} + +export class S3FileUrlResponseDs { + url: string; + key: string; + expiresIn: number; +} + +export class S3UploadUrlResponseDs { + uploadUrl: string; + key: string; + expiresIn: number; +} diff --git a/backend/src/entities/s3-widget/application/data-structures/s3-widget-params.ds.ts b/backend/src/entities/s3-widget/application/data-structures/s3-widget-params.ds.ts new file mode 100644 index 000000000..254c66da6 --- /dev/null +++ b/backend/src/entities/s3-widget/application/data-structures/s3-widget-params.ds.ts @@ -0,0 +1,7 @@ +export interface S3WidgetParams { + bucket: string; + prefix?: string; + region?: string; + aws_access_key_id_secret_name: string; + aws_secret_access_key_secret_name: string; +} diff --git a/backend/src/entities/s3-widget/s3-helper.service.ts b/backend/src/entities/s3-widget/s3-helper.service.ts new file mode 100644 index 000000000..0c7ed711f --- /dev/null +++ b/backend/src/entities/s3-widget/s3-helper.service.ts @@ -0,0 +1,51 @@ +import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class S3HelperService { + public createS3Client(accessKeyId: string, secretAccessKey: string, region: string = 'us-east-1'): S3Client { + return new S3Client({ + region, + credentials: { + accessKeyId, + secretAccessKey, + }, + }); + } + + public async getSignedGetUrl( + client: S3Client, + bucket: string, + key: string, + expiresIn: number = 3600, + ): Promise { + const command = new GetObjectCommand({ Bucket: bucket, Key: key }); + return getSignedUrl(client, command, { expiresIn }); + } + + public async getSignedPutUrl( + client: S3Client, + bucket: string, + key: string, + contentType: string, + expiresIn: number = 3600, + ): Promise { + const command = new PutObjectCommand({ + Bucket: bucket, + Key: key, + ContentType: contentType, + }); + return getSignedUrl(client, command, { expiresIn }); + } + + public generateFileKey(prefix: string | undefined, filename: string): string { + const timestamp = Date.now(); + const sanitizedFilename = filename.replace(/[^a-zA-Z0-9._-]/g, '_'); + if (prefix) { + const normalizedPrefix = prefix.replace(/\/$/, ''); + return `${normalizedPrefix}/${timestamp}_${sanitizedFilename}`; + } + return `${timestamp}_${sanitizedFilename}`; + } +} diff --git a/backend/src/entities/s3-widget/s3-widget.controller.ts b/backend/src/entities/s3-widget/s3-widget.controller.ts new file mode 100644 index 000000000..360be6110 --- /dev/null +++ b/backend/src/entities/s3-widget/s3-widget.controller.ts @@ -0,0 +1,131 @@ +import { + Body, + Controller, + Get, + HttpStatus, + Inject, + Injectable, + Post, + Query, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { HttpException } from '@nestjs/common/exceptions/http.exception.js'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { UseCaseType } from '../../common/data-injection.tokens.js'; +import { MasterPassword, QueryTableName, SlugUuid, UserId } from '../../decorators/index.js'; +import { InTransactionEnum } from '../../enums/index.js'; +import { Messages } from '../../exceptions/text/messages.js'; +import { ConnectionEditGuard, ConnectionReadGuard } from '../../guards/index.js'; +import { SentryInterceptor } from '../../interceptors/index.js'; +import { S3FileUrlResponseDs, S3UploadUrlResponseDs } from './application/data-structures/s3-operation.ds.js'; +import { IGetS3FileUrl, IGetS3UploadUrl } from './use-cases/s3-use-cases.interface.js'; + +@UseInterceptors(SentryInterceptor) +@Controller() +@ApiBearerAuth() +@ApiTags('S3 Widget') +@Injectable() +export class S3WidgetController { + constructor( + @Inject(UseCaseType.GET_S3_FILE_URL) + private readonly getS3FileUrlUseCase: IGetS3FileUrl, + @Inject(UseCaseType.GET_S3_UPLOAD_URL) + private readonly getS3UploadUrlUseCase: IGetS3UploadUrl, + ) {} + + @UseGuards(ConnectionReadGuard) + @ApiOperation({ summary: 'Get pre-signed URL for S3 file download' }) + @ApiResponse({ + status: 200, + description: 'Pre-signed URL generated successfully.', + }) + @ApiQuery({ name: 'tableName', required: true }) + @ApiQuery({ name: 'fieldName', required: true }) + @ApiQuery({ name: 'fileKey', required: true }) + @Get('/s3/file/:connectionId') + async getFileUrl( + @SlugUuid('connectionId') connectionId: string, + @UserId() userId: string, + @MasterPassword() masterPwd: string, + @QueryTableName() tableName: string, + @Query('fieldName') fieldName: string, + @Query('fileKey') fileKey: string, + ): Promise { + if (!connectionId) { + throw new HttpException({ message: Messages.CONNECTION_ID_MISSING }, HttpStatus.BAD_REQUEST); + } + if (!fieldName) { + throw new HttpException({ message: 'Field name is required' }, HttpStatus.BAD_REQUEST); + } + if (!fileKey) { + throw new HttpException({ message: 'File key is required' }, HttpStatus.BAD_REQUEST); + } + + return await this.getS3FileUrlUseCase.execute( + { + connectionId, + tableName, + fieldName, + fileKey, + userId, + masterPwd, + }, + InTransactionEnum.OFF, + ); + } + + @UseGuards(ConnectionEditGuard) + @ApiOperation({ summary: 'Get pre-signed URL for S3 file upload' }) + @ApiResponse({ + status: 201, + description: 'Pre-signed upload URL generated successfully.', + }) + @ApiQuery({ name: 'tableName', required: true }) + @ApiQuery({ name: 'fieldName', required: true }) + @ApiBody({ + schema: { + type: 'object', + properties: { + filename: { type: 'string', description: 'Name of the file to upload' }, + contentType: { type: 'string', description: 'MIME type of the file' }, + }, + required: ['filename', 'contentType'], + }, + }) + @Post('/s3/upload-url/:connectionId') + async getUploadUrl( + @SlugUuid('connectionId') connectionId: string, + @UserId() userId: string, + @MasterPassword() masterPwd: string, + @QueryTableName() tableName: string, + @Query('fieldName') fieldName: string, + @Body() body: { filename: string; contentType: string }, + ): Promise { + if (!connectionId) { + throw new HttpException({ message: Messages.CONNECTION_ID_MISSING }, HttpStatus.BAD_REQUEST); + } + if (!fieldName) { + throw new HttpException({ message: 'Field name is required' }, HttpStatus.BAD_REQUEST); + } + if (!body.filename) { + throw new HttpException({ message: 'Filename is required' }, HttpStatus.BAD_REQUEST); + } + if (!body.contentType) { + throw new HttpException({ message: 'Content type is required' }, HttpStatus.BAD_REQUEST); + } + + return await this.getS3UploadUrlUseCase.execute( + { + connectionId, + tableName, + fieldName, + userId, + masterPwd, + filename: body.filename, + contentType: body.contentType, + }, + InTransactionEnum.OFF, + ); + } +} diff --git a/backend/src/entities/s3-widget/s3-widget.module.ts b/backend/src/entities/s3-widget/s3-widget.module.ts new file mode 100644 index 000000000..bc730e595 --- /dev/null +++ b/backend/src/entities/s3-widget/s3-widget.module.ts @@ -0,0 +1,42 @@ +import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthMiddleware } from '../../authorization/index.js'; +import { GlobalDatabaseContext } from '../../common/application/global-database-context.js'; +import { BaseType, UseCaseType } from '../../common/data-injection.tokens.js'; +import { LogOutEntity } from '../log-out/log-out.entity.js'; +import { UserEntity } from '../user/user.entity.js'; +import { S3WidgetController } from './s3-widget.controller.js'; +import { S3HelperService } from './s3-helper.service.js'; +import { GetS3FileUrlUseCase } from './use-cases/get-s3-file-url.use.case.js'; +import { GetS3UploadUrlUseCase } from './use-cases/get-s3-upload-url.use.case.js'; + +@Module({ + imports: [TypeOrmModule.forFeature([UserEntity, LogOutEntity])], + providers: [ + S3HelperService, + { + provide: BaseType.GLOBAL_DB_CONTEXT, + useClass: GlobalDatabaseContext, + }, + { + provide: UseCaseType.GET_S3_FILE_URL, + useClass: GetS3FileUrlUseCase, + }, + { + provide: UseCaseType.GET_S3_UPLOAD_URL, + useClass: GetS3UploadUrlUseCase, + }, + ], + controllers: [S3WidgetController], + exports: [S3HelperService], +}) +export class S3WidgetModule { + public configure(consumer: MiddlewareConsumer): any { + consumer + .apply(AuthMiddleware) + .forRoutes( + { path: '/s3/file/:connectionId', method: RequestMethod.GET }, + { path: '/s3/upload-url/:connectionId', method: RequestMethod.POST }, + ); + } +} diff --git a/backend/src/entities/s3-widget/use-cases/get-s3-file-url.use.case.ts b/backend/src/entities/s3-widget/use-cases/get-s3-file-url.use.case.ts new file mode 100644 index 000000000..3be0a1b28 --- /dev/null +++ b/backend/src/entities/s3-widget/use-cases/get-s3-file-url.use.case.ts @@ -0,0 +1,83 @@ +import { HttpStatus, Inject, Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { HttpException } from '@nestjs/common/exceptions/http.exception.js'; +import AbstractUseCase from '../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../common/data-injection.tokens.js'; +import { Messages } from '../../../exceptions/text/messages.js'; +import { Encryptor } from '../../../helpers/encryption/encryptor.js'; +import { S3FileUrlResponseDs, S3GetFileUrlDs } from '../application/data-structures/s3-operation.ds.js'; +import { S3WidgetParams } from '../application/data-structures/s3-widget-params.ds.js'; +import { S3HelperService } from '../s3-helper.service.js'; +import { IGetS3FileUrl } from './s3-use-cases.interface.js'; +import { WidgetTypeEnum } from '../../../enums/index.js'; +import JSON5 from 'json5'; + +@Injectable() +export class GetS3FileUrlUseCase extends AbstractUseCase implements IGetS3FileUrl { + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + private readonly s3Helper: S3HelperService, + ) { + super(); + } + + protected async implementation(inputData: S3GetFileUrlDs): Promise { + const { connectionId, tableName, fieldName, fileKey, userId, masterPwd } = inputData; + + const user = await this._dbContext.userRepository.findOneUserByIdWithCompany(userId); + if (!user || !user.company) { + throw new HttpException( + { message: Messages.USER_NOT_FOUND_OR_NOT_IN_COMPANY }, + HttpStatus.NOT_FOUND, + ); + } + + const foundTableWidgets = await this._dbContext.tableWidgetsRepository.findTableWidgets(connectionId, tableName); + const widget = foundTableWidgets.find((w) => w.field_name === fieldName); + + if (!widget || widget.widget_type !== WidgetTypeEnum.S3) { + throw new HttpException( + { message: 'S3 widget not configured for this field' }, + HttpStatus.BAD_REQUEST, + ); + } + + const params: S3WidgetParams = + 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, + user.company.id, + ); + + const secretKeySecret = await this._dbContext.userSecretRepository.findSecretBySlugAndCompanyId( + params.aws_secret_access_key_secret_name, + user.company.id, + ); + + if (!accessKeySecret || !secretKeySecret) { + throw new HttpException( + { message: 'AWS credentials secrets not found' }, + HttpStatus.NOT_FOUND, + ); + } + + let accessKeyId = Encryptor.decryptData(accessKeySecret.encryptedValue); + let secretAccessKey = Encryptor.decryptData(secretKeySecret.encryptedValue); + + if (accessKeySecret.masterEncryption && masterPwd) { + accessKeyId = Encryptor.decryptDataMasterPwd(accessKeyId, masterPwd); + } + if (secretKeySecret.masterEncryption && masterPwd) { + secretAccessKey = Encryptor.decryptDataMasterPwd(secretAccessKey, masterPwd); + } + + const client = this.s3Helper.createS3Client(accessKeyId, secretAccessKey, params.region || 'us-east-1'); + + const expiresIn = 3600; + const url = await this.s3Helper.getSignedGetUrl(client, params.bucket, fileKey, expiresIn); + + return { url, key: fileKey, expiresIn }; + } +} diff --git a/backend/src/entities/s3-widget/use-cases/get-s3-upload-url.use.case.ts b/backend/src/entities/s3-widget/use-cases/get-s3-upload-url.use.case.ts new file mode 100644 index 000000000..bc5f57b10 --- /dev/null +++ b/backend/src/entities/s3-widget/use-cases/get-s3-upload-url.use.case.ts @@ -0,0 +1,78 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { HttpException } from '@nestjs/common/exceptions/http.exception.js'; +import AbstractUseCase from '../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../common/data-injection.tokens.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 { S3HelperService } from '../s3-helper.service.js'; +import { IGetS3UploadUrl } from './s3-use-cases.interface.js'; +import { WidgetTypeEnum } from '../../../enums/index.js'; +import JSON5 from 'json5'; + +@Injectable() +export class GetS3UploadUrlUseCase + extends AbstractUseCase + implements IGetS3UploadUrl +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + private readonly s3Helper: S3HelperService, + ) { + super(); + } + + protected async implementation(inputData: S3GetUploadUrlDs): Promise { + const { connectionId, tableName, fieldName, userId, masterPwd, filename, contentType } = inputData; + + const user = await this._dbContext.userRepository.findOneUserByIdWithCompany(userId); + if (!user || !user.company) { + throw new HttpException({ message: Messages.USER_NOT_FOUND_OR_NOT_IN_COMPANY }, HttpStatus.NOT_FOUND); + } + + const foundTableWidgets = await this._dbContext.tableWidgetsRepository.findTableWidgets(connectionId, tableName); + const widget = foundTableWidgets.find((w) => w.field_name === fieldName); + + if (!widget || widget.widget_type !== WidgetTypeEnum.S3) { + throw new HttpException({ message: 'S3 widget not configured for this field' }, HttpStatus.BAD_REQUEST); + } + + const params: S3WidgetParams = + 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, + user.company.id, + ); + + const secretKeySecret = await this._dbContext.userSecretRepository.findSecretBySlugAndCompanyId( + params.aws_secret_access_key_secret_name, + user.company.id, + ); + + if (!accessKeySecret || !secretKeySecret) { + throw new HttpException({ message: 'AWS credentials secrets not found' }, HttpStatus.NOT_FOUND); + } + + let accessKeyId = Encryptor.decryptData(accessKeySecret.encryptedValue); + let secretAccessKey = Encryptor.decryptData(secretKeySecret.encryptedValue); + + if (accessKeySecret.masterEncryption && masterPwd) { + accessKeyId = Encryptor.decryptDataMasterPwd(accessKeyId, masterPwd); + } + if (secretKeySecret.masterEncryption && masterPwd) { + secretAccessKey = Encryptor.decryptDataMasterPwd(secretAccessKey, masterPwd); + } + + const client = this.s3Helper.createS3Client(accessKeyId, secretAccessKey, params.region || 'us-east-1'); + + const key = this.s3Helper.generateFileKey(params.prefix, filename); + const expiresIn = 3600; + const uploadUrl = await this.s3Helper.getSignedPutUrl(client, params.bucket, key, contentType, expiresIn); + + return { uploadUrl, key, expiresIn }; + } +} diff --git a/backend/src/entities/s3-widget/use-cases/s3-use-cases.interface.ts b/backend/src/entities/s3-widget/use-cases/s3-use-cases.interface.ts new file mode 100644 index 000000000..daa3a7fa8 --- /dev/null +++ b/backend/src/entities/s3-widget/use-cases/s3-use-cases.interface.ts @@ -0,0 +1,10 @@ +import { InTransactionEnum } from '../../../enums/index.js'; +import { S3FileUrlResponseDs, S3GetFileUrlDs, S3GetUploadUrlDs, S3UploadUrlResponseDs } from '../application/data-structures/s3-operation.ds.js'; + +export interface IGetS3FileUrl { + execute(inputData: S3GetFileUrlDs, inTransaction: InTransactionEnum): Promise; +} + +export interface IGetS3UploadUrl { + execute(inputData: S3GetUploadUrlDs, inTransaction: InTransactionEnum): Promise; +} diff --git a/backend/src/entities/widget/utils/validate-create-widgets-ds.ts b/backend/src/entities/widget/utils/validate-create-widgets-ds.ts index 52206a000..0a2697572 100644 --- a/backend/src/entities/widget/utils/validate-create-widgets-ds.ts +++ b/backend/src/entities/widget/utils/validate-create-widgets-ds.ts @@ -92,6 +92,23 @@ export async function validateCreateWidgetsDs( ); } } + + if (widget_type && widget_type === WidgetTypeEnum.S3) { + let widget_params = widgetDS.widget_params as string | Record; + if (typeof widget_params === 'string') { + widget_params = JSON5.parse(widget_params); + } + + 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.aws_secret_access_key_secret_name) { + errors.push(Messages.WIDGET_REQUIRED_PARAMETER_MISSING('aws_secret_access_key_secret_name')); + } + } } return errors; } diff --git a/backend/src/enums/widget-type.enum.ts b/backend/src/enums/widget-type.enum.ts index 9baed383f..3a9136e0e 100644 --- a/backend/src/enums/widget-type.enum.ts +++ b/backend/src/enums/widget-type.enum.ts @@ -21,5 +21,6 @@ export enum WidgetTypeEnum { Country = 'Country', Color = 'Color', Range = 'Range', - Timezone = 'Timezone' + Timezone = 'Timezone', + S3 = 'S3', } diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts index 0fe5da5bc..43b499808 100644 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts +++ b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts @@ -278,6 +278,22 @@ export class DbTableWidgetsComponent implements OnInit { "namespace": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "name": "" } +`, + S3: `// Configure AWS S3 widget for file storage +// bucket: S3 bucket name (required) +// prefix: Optional path prefix for uploaded files +// region: AWS region (default: us-east-1) +// aws_access_key_id_secret_name: Slug of the secret containing AWS Access Key ID +// aws_secret_access_key_secret_name: Slug of the secret containing AWS Secret Access Key +// Note: Create secrets in Settings -> Secrets before configuring this widget + +{ + "bucket": "your-bucket-name", + "prefix": "uploads/", + "region": "us-east-1", + "aws_access_key_id_secret_name": "aws-access-key-id", + "aws_secret_access_key_secret_name": "aws-secret-access-key" +} `, } diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget/widget.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget/widget.component.ts index 5a61eecac..e89c706da 100644 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget/widget.component.ts +++ b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget/widget.component.ts @@ -79,7 +79,7 @@ export class WidgetComponent implements OnInit, OnChanges { Date: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#date-time-and-datetime', DateTime: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#date-time-and-datetime', Time: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#date-time-and-datetime', - + S3: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#s3', }; ngOnInit(): void { diff --git a/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.css b/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.css new file mode 100644 index 000000000..7c0f62d5d --- /dev/null +++ b/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.css @@ -0,0 +1,57 @@ +.s3-widget { + display: flex; + flex-direction: column; + gap: 12px; +} + +.s3-widget__key { + width: 100%; +} + +.s3-widget__actions { + display: flex; + gap: 8px; +} + +.s3-widget__actions button { + display: flex; + align-items: center; + gap: 4px; +} + +.s3-widget__preview { + display: flex; + align-items: center; + justify-content: center; + min-height: 100px; + border: 1px dashed #ccc; + border-radius: 4px; + padding: 8px; +} + +.s3-widget__thumbnail { + max-width: 200px; + max-height: 150px; + object-fit: contain; + border-radius: 4px; +} + +.s3-widget__file-icon { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + color: #666; +} + +.s3-widget__file-icon mat-icon { + font-size: 48px; + width: 48px; + height: 48px; +} + +.s3-widget__filename { + font-size: 12px; + word-break: break-all; + text-align: center; +} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.html b/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.html new file mode 100644 index 000000000..a124a4e5f --- /dev/null +++ b/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.html @@ -0,0 +1,37 @@ +
+ + {{normalizedLabel}} + + + +
+ + + +
+ +
+ + Preview +
+ insert_drive_file + {{value | slice:-30}} +
+
+
diff --git a/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.ts new file mode 100644 index 000000000..3a447adc3 --- /dev/null +++ b/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.ts @@ -0,0 +1,149 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; +import { S3Service } from 'src/app/services/s3.service'; +import { ConnectionsService } from 'src/app/services/connections.service'; +import { TablesService } from 'src/app/services/tables.service'; + +interface S3WidgetParams { + bucket: string; + prefix?: string; + region?: string; + aws_access_key_id_secret_name: string; + aws_secret_access_key_secret_name: string; +} + +@Component({ + selector: 'app-edit-s3', + templateUrl: './s3.component.html', + styleUrl: './s3.component.css', + imports: [ + CommonModule, + FormsModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule, + ] +}) +export class S3EditComponent extends BaseEditFieldComponent implements OnInit { + @Input() value: string; + + public params: S3WidgetParams; + public previewUrl: string | null = null; + public isImage: boolean = false; + public isLoading: boolean = false; + + private connectionId: string; + private tableName: string; + + constructor( + private s3Service: S3Service, + private connectionsService: ConnectionsService, + private tablesService: TablesService + ) { + super(); + } + + ngOnInit(): void { + super.ngOnInit(); + this.connectionId = this.connectionsService.currentConnectionID; + this.tableName = this.tablesService.currentTableName; + this._parseWidgetParams(); + if (this.value) { + this._loadPreview(); + } + } + + ngOnChanges(): void { + this._parseWidgetParams(); + if (this.value && !this.previewUrl && !this.isLoading) { + this._loadPreview(); + } + } + + onFileSelected(event: Event): void { + const input = event.target as HTMLInputElement; + if (!input.files?.length) return; + + const file = input.files[0]; + this.isLoading = true; + + this.s3Service.getUploadUrl( + this.connectionId, + this.tableName, + this.widgetStructure.field_name, + file.name, + file.type + ).subscribe({ + next: (response) => { + this.s3Service.uploadToS3(response.uploadUrl, file).subscribe({ + next: () => { + this.value = response.key; + this.onFieldChange.emit(response.key); + this._loadPreview(); + }, + error: () => { + this.isLoading = false; + } + }); + }, + error: () => { + this.isLoading = false; + } + }); + } + + openFile(): void { + if (this.previewUrl) { + window.open(this.previewUrl, '_blank'); + } + } + + private _parseWidgetParams(): void { + if (this.widgetStructure?.widget_params) { + try { + this.params = typeof this.widgetStructure.widget_params === 'string' + ? JSON.parse(this.widgetStructure.widget_params) + : this.widgetStructure.widget_params; + } catch (e) { + console.error('Error parsing S3 widget params:', e); + } + } + } + + private _loadPreview(): void { + if (!this.value || !this.connectionId || !this.tableName) return; + + this.isLoading = true; + this.isImage = this._isImageFile(this.value); + + this.s3Service.getFileUrl( + this.connectionId, + this.tableName, + this.widgetStructure.field_name, + this.value + ).subscribe({ + next: (response) => { + this.previewUrl = response.url; + this.isLoading = false; + }, + error: () => { + this.isLoading = false; + } + }); + } + + private _isImageFile(key: string): boolean { + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp']; + const lowerKey = key.toLowerCase(); + return imageExtensions.some(ext => lowerKey.endsWith(ext)); + } +} diff --git a/frontend/src/app/consts/record-edit-types.ts b/frontend/src/app/consts/record-edit-types.ts index 18505d969..568d384e7 100644 --- a/frontend/src/app/consts/record-edit-types.ts +++ b/frontend/src/app/consts/record-edit-types.ts @@ -27,6 +27,7 @@ import { TimeIntervalEditComponent } from '../components/ui-components/record-ed import { TimezoneEditComponent } from '../components/ui-components/record-edit-fields/timezone/timezone.component'; import { UrlEditComponent } from '../components/ui-components/record-edit-fields/url/url.component'; import { UuidEditComponent } from '../components/ui-components/record-edit-fields/uuid/uuid.component'; +import { S3EditComponent } from '../components/ui-components/record-edit-fields/s3/s3.component'; export const timestampTypes = ['timestamp without time zone', 'timestamp with time zone', 'timestamp', 'date', 'time without time zone', 'time with time zone' , 'time', 'datetime', 'date time', 'datetime2', 'datetimeoffset', 'curdate', 'curtime', 'now', 'localtime', 'localtimestamp']; export const defaultTimestampValues = { @@ -61,6 +62,7 @@ export const UIwidgets = { Timezone: TimezoneEditComponent, URL: UrlEditComponent, UUID: UuidEditComponent, + S3: S3EditComponent, } export const recordEditTypes = { diff --git a/frontend/src/app/services/s3.service.ts b/frontend/src/app/services/s3.service.ts new file mode 100644 index 000000000..42ccc69f3 --- /dev/null +++ b/frontend/src/app/services/s3.service.ts @@ -0,0 +1,85 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, catchError, EMPTY } from 'rxjs'; +import { NotificationsService } from './notifications.service'; +import { AlertActionType, AlertType } from '../models/alert'; + +interface S3FileUrlResponse { + url: string; + key: string; + expiresIn: number; +} + +interface S3UploadUrlResponse { + uploadUrl: string; + key: string; + expiresIn: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class S3Service { + constructor( + private http: HttpClient, + private notifications: NotificationsService + ) {} + + getFileUrl( + connectionId: string, + tableName: string, + fieldName: string, + fileKey: string + ): Observable { + return this.http.get(`/s3/file/${connectionId}`, { + params: { tableName, fieldName, fileKey } + }).pipe( + catchError(err => { + this.notifications.showAlert( + AlertType.Error, + { abstract: 'Failed to get S3 file URL', details: err.error?.message }, + [{ type: AlertActionType.Button, caption: 'Dismiss', action: () => this.notifications.dismissAlert() }] + ); + return EMPTY; + }) + ); + } + + getUploadUrl( + connectionId: string, + tableName: string, + fieldName: string, + filename: string, + contentType: string + ): Observable { + return this.http.post( + `/s3/upload-url/${connectionId}`, + { filename, contentType }, + { params: { tableName, fieldName } } + ).pipe( + catchError(err => { + this.notifications.showAlert( + AlertType.Error, + { abstract: 'Failed to get upload URL', details: err.error?.message }, + [{ type: AlertActionType.Button, caption: 'Dismiss', action: () => this.notifications.dismissAlert() }] + ); + return EMPTY; + }) + ); + } + + uploadToS3(uploadUrl: string, file: File): Observable { + return this.http.put(uploadUrl, file, { + headers: { 'Content-Type': file.type } + }).pipe( + catchError(err => { + this.notifications.showAlert( + AlertType.Error, + { abstract: 'File upload failed', details: err.message }, + [{ type: AlertActionType.Button, caption: 'Dismiss', action: () => this.notifications.dismissAlert() }] + ); + return EMPTY; + }) + ); + } +} diff --git a/shared-code/src/shared/enums/table-widget-type.enum.ts b/shared-code/src/shared/enums/table-widget-type.enum.ts index 745996e62..12dd6a13a 100644 --- a/shared-code/src/shared/enums/table-widget-type.enum.ts +++ b/shared-code/src/shared/enums/table-widget-type.enum.ts @@ -22,4 +22,5 @@ export enum TableWidgetTypeEnum { Color = 'Color', Range = 'Range', Timezone = 'Timezone', + S3 = 'S3', } From 76c5dc0ffb384d16d7cef5fc9dd8e71c9310c1cd Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Mon, 22 Dec 2025 10:55:33 +0000 Subject: [PATCH 2/5] s3 widget by row pk instead of filename --- .husky/pre-commit | 1 + .../data-structures/s3-operation.ds.ts | 38 +- .../s3-widget/s3-widget.controller.ts | 294 ++-- .../use-cases/get-s3-file-url.use.case.ts | 245 ++-- .../db-table-row-edit.component.html | 3 +- .../record-edit-fields/s3/s3.component.ts | 302 ++-- frontend/src/app/services/s3.service.ts | 176 ++- package.json | 50 +- yarn.lock | 1275 ++++++++++++++++- 9 files changed, 1927 insertions(+), 457 deletions(-) create mode 100644 .husky/pre-commit diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 000000000..2312dc587 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/backend/src/entities/s3-widget/application/data-structures/s3-operation.ds.ts b/backend/src/entities/s3-widget/application/data-structures/s3-operation.ds.ts index af906e282..719f495d9 100644 --- a/backend/src/entities/s3-widget/application/data-structures/s3-operation.ds.ts +++ b/backend/src/entities/s3-widget/application/data-structures/s3-operation.ds.ts @@ -1,30 +1,30 @@ export class S3GetFileUrlDs { - connectionId: string; - tableName: string; - fieldName: string; - fileKey: string; - userId: string; - masterPwd: string; + connectionId: string; + tableName: string; + fieldName: string; + rowPrimaryKey: Record; + userId: string; + masterPwd: string; } export class S3GetUploadUrlDs { - connectionId: string; - tableName: string; - fieldName: string; - userId: string; - masterPwd: string; - filename: string; - contentType: string; + connectionId: string; + tableName: string; + fieldName: string; + userId: string; + masterPwd: string; + filename: string; + contentType: string; } export class S3FileUrlResponseDs { - url: string; - key: string; - expiresIn: number; + url: string; + key: string; + expiresIn: number; } export class S3UploadUrlResponseDs { - uploadUrl: string; - key: string; - expiresIn: number; + uploadUrl: string; + key: string; + expiresIn: number; } diff --git a/backend/src/entities/s3-widget/s3-widget.controller.ts b/backend/src/entities/s3-widget/s3-widget.controller.ts index 360be6110..c1549968e 100644 --- a/backend/src/entities/s3-widget/s3-widget.controller.ts +++ b/backend/src/entities/s3-widget/s3-widget.controller.ts @@ -1,131 +1,187 @@ import { - Body, - Controller, - Get, - HttpStatus, - Inject, - Injectable, - Post, - Query, - UseGuards, - UseInterceptors, -} from '@nestjs/common'; -import { HttpException } from '@nestjs/common/exceptions/http.exception.js'; -import { ApiBearerAuth, ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { UseCaseType } from '../../common/data-injection.tokens.js'; -import { MasterPassword, QueryTableName, SlugUuid, UserId } from '../../decorators/index.js'; -import { InTransactionEnum } from '../../enums/index.js'; -import { Messages } from '../../exceptions/text/messages.js'; -import { ConnectionEditGuard, ConnectionReadGuard } from '../../guards/index.js'; -import { SentryInterceptor } from '../../interceptors/index.js'; -import { S3FileUrlResponseDs, S3UploadUrlResponseDs } from './application/data-structures/s3-operation.ds.js'; -import { IGetS3FileUrl, IGetS3UploadUrl } from './use-cases/s3-use-cases.interface.js'; + Body, + Controller, + Get, + HttpStatus, + Inject, + Injectable, + Post, + Query, + UseGuards, + UseInterceptors, +} from "@nestjs/common"; +import { HttpException } from "@nestjs/common/exceptions/http.exception.js"; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiQuery, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; +import { UseCaseType } from "../../common/data-injection.tokens.js"; +import { + MasterPassword, + QueryTableName, + SlugUuid, + UserId, +} from "../../decorators/index.js"; +import { InTransactionEnum } from "../../enums/index.js"; +import { Messages } from "../../exceptions/text/messages.js"; +import { + ConnectionEditGuard, + ConnectionReadGuard, +} from "../../guards/index.js"; +import { SentryInterceptor } from "../../interceptors/index.js"; +import { + S3FileUrlResponseDs, + S3UploadUrlResponseDs, +} from "./application/data-structures/s3-operation.ds.js"; +import { + IGetS3FileUrl, + IGetS3UploadUrl, +} from "./use-cases/s3-use-cases.interface.js"; @UseInterceptors(SentryInterceptor) @Controller() @ApiBearerAuth() -@ApiTags('S3 Widget') +@ApiTags("S3 Widget") @Injectable() export class S3WidgetController { - constructor( - @Inject(UseCaseType.GET_S3_FILE_URL) - private readonly getS3FileUrlUseCase: IGetS3FileUrl, - @Inject(UseCaseType.GET_S3_UPLOAD_URL) - private readonly getS3UploadUrlUseCase: IGetS3UploadUrl, - ) {} + constructor( + @Inject(UseCaseType.GET_S3_FILE_URL) + private readonly getS3FileUrlUseCase: IGetS3FileUrl, + @Inject(UseCaseType.GET_S3_UPLOAD_URL) + private readonly getS3UploadUrlUseCase: IGetS3UploadUrl, + ) {} + + @UseGuards(ConnectionReadGuard) + @ApiOperation({ summary: "Get pre-signed URL for S3 file download" }) + @ApiResponse({ + status: 200, + description: "Pre-signed URL generated successfully.", + }) + @ApiQuery({ name: "tableName", required: true }) + @ApiQuery({ name: "fieldName", required: true }) + @ApiQuery({ + name: "rowPrimaryKey", + required: true, + description: "JSON-encoded primary key object", + }) + @Get("/s3/file/:connectionId") + async getFileUrl( + @SlugUuid("connectionId") connectionId: string, + @UserId() userId: string, + @MasterPassword() masterPwd: string, + @QueryTableName() tableName: string, + @Query("fieldName") fieldName: string, + @Query("rowPrimaryKey") rowPrimaryKeyStr: string, + ): Promise { + if (!connectionId) { + throw new HttpException( + { message: Messages.CONNECTION_ID_MISSING }, + HttpStatus.BAD_REQUEST, + ); + } + if (!fieldName) { + throw new HttpException( + { message: "Field name is required" }, + HttpStatus.BAD_REQUEST, + ); + } + if (!rowPrimaryKeyStr) { + throw new HttpException( + { message: "Row primary key is required" }, + HttpStatus.BAD_REQUEST, + ); + } - @UseGuards(ConnectionReadGuard) - @ApiOperation({ summary: 'Get pre-signed URL for S3 file download' }) - @ApiResponse({ - status: 200, - description: 'Pre-signed URL generated successfully.', - }) - @ApiQuery({ name: 'tableName', required: true }) - @ApiQuery({ name: 'fieldName', required: true }) - @ApiQuery({ name: 'fileKey', required: true }) - @Get('/s3/file/:connectionId') - async getFileUrl( - @SlugUuid('connectionId') connectionId: string, - @UserId() userId: string, - @MasterPassword() masterPwd: string, - @QueryTableName() tableName: string, - @Query('fieldName') fieldName: string, - @Query('fileKey') fileKey: string, - ): Promise { - if (!connectionId) { - throw new HttpException({ message: Messages.CONNECTION_ID_MISSING }, HttpStatus.BAD_REQUEST); - } - if (!fieldName) { - throw new HttpException({ message: 'Field name is required' }, HttpStatus.BAD_REQUEST); - } - if (!fileKey) { - throw new HttpException({ message: 'File key is required' }, HttpStatus.BAD_REQUEST); - } + let rowPrimaryKey: Record; + try { + rowPrimaryKey = JSON.parse(rowPrimaryKeyStr); + } catch { + throw new HttpException( + { message: "Invalid row primary key format" }, + HttpStatus.BAD_REQUEST, + ); + } - return await this.getS3FileUrlUseCase.execute( - { - connectionId, - tableName, - fieldName, - fileKey, - userId, - masterPwd, - }, - InTransactionEnum.OFF, - ); - } + return await this.getS3FileUrlUseCase.execute( + { + connectionId, + tableName, + fieldName, + rowPrimaryKey, + userId, + masterPwd, + }, + InTransactionEnum.OFF, + ); + } - @UseGuards(ConnectionEditGuard) - @ApiOperation({ summary: 'Get pre-signed URL for S3 file upload' }) - @ApiResponse({ - status: 201, - description: 'Pre-signed upload URL generated successfully.', - }) - @ApiQuery({ name: 'tableName', required: true }) - @ApiQuery({ name: 'fieldName', required: true }) - @ApiBody({ - schema: { - type: 'object', - properties: { - filename: { type: 'string', description: 'Name of the file to upload' }, - contentType: { type: 'string', description: 'MIME type of the file' }, - }, - required: ['filename', 'contentType'], - }, - }) - @Post('/s3/upload-url/:connectionId') - async getUploadUrl( - @SlugUuid('connectionId') connectionId: string, - @UserId() userId: string, - @MasterPassword() masterPwd: string, - @QueryTableName() tableName: string, - @Query('fieldName') fieldName: string, - @Body() body: { filename: string; contentType: string }, - ): Promise { - if (!connectionId) { - throw new HttpException({ message: Messages.CONNECTION_ID_MISSING }, HttpStatus.BAD_REQUEST); - } - if (!fieldName) { - throw new HttpException({ message: 'Field name is required' }, HttpStatus.BAD_REQUEST); - } - if (!body.filename) { - throw new HttpException({ message: 'Filename is required' }, HttpStatus.BAD_REQUEST); - } - if (!body.contentType) { - throw new HttpException({ message: 'Content type is required' }, HttpStatus.BAD_REQUEST); - } + @UseGuards(ConnectionEditGuard) + @ApiOperation({ summary: "Get pre-signed URL for S3 file upload" }) + @ApiResponse({ + status: 201, + description: "Pre-signed upload URL generated successfully.", + }) + @ApiQuery({ name: "tableName", required: true }) + @ApiQuery({ name: "fieldName", required: true }) + @ApiBody({ + schema: { + type: "object", + properties: { + filename: { type: "string", description: "Name of the file to upload" }, + contentType: { type: "string", description: "MIME type of the file" }, + }, + required: ["filename", "contentType"], + }, + }) + @Post("/s3/upload-url/:connectionId") + async getUploadUrl( + @SlugUuid("connectionId") connectionId: string, + @UserId() userId: string, + @MasterPassword() masterPwd: string, + @QueryTableName() tableName: string, + @Query("fieldName") fieldName: string, + @Body() body: { filename: string; contentType: string }, + ): Promise { + if (!connectionId) { + throw new HttpException( + { message: Messages.CONNECTION_ID_MISSING }, + HttpStatus.BAD_REQUEST, + ); + } + if (!fieldName) { + throw new HttpException( + { message: "Field name is required" }, + HttpStatus.BAD_REQUEST, + ); + } + if (!body.filename) { + throw new HttpException( + { message: "Filename is required" }, + HttpStatus.BAD_REQUEST, + ); + } + if (!body.contentType) { + throw new HttpException( + { message: "Content type is required" }, + HttpStatus.BAD_REQUEST, + ); + } - return await this.getS3UploadUrlUseCase.execute( - { - connectionId, - tableName, - fieldName, - userId, - masterPwd, - filename: body.filename, - contentType: body.contentType, - }, - InTransactionEnum.OFF, - ); - } + return await this.getS3UploadUrlUseCase.execute( + { + connectionId, + tableName, + fieldName, + userId, + masterPwd, + filename: body.filename, + contentType: body.contentType, + }, + InTransactionEnum.OFF, + ); + } } diff --git a/backend/src/entities/s3-widget/use-cases/get-s3-file-url.use.case.ts b/backend/src/entities/s3-widget/use-cases/get-s3-file-url.use.case.ts index 3be0a1b28..ce097e846 100644 --- a/backend/src/entities/s3-widget/use-cases/get-s3-file-url.use.case.ts +++ b/backend/src/entities/s3-widget/use-cases/get-s3-file-url.use.case.ts @@ -1,83 +1,168 @@ -import { HttpStatus, Inject, Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; -import { HttpException } from '@nestjs/common/exceptions/http.exception.js'; -import AbstractUseCase from '../../../common/abstract-use.case.js'; -import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; -import { BaseType } from '../../../common/data-injection.tokens.js'; -import { Messages } from '../../../exceptions/text/messages.js'; -import { Encryptor } from '../../../helpers/encryption/encryptor.js'; -import { S3FileUrlResponseDs, S3GetFileUrlDs } from '../application/data-structures/s3-operation.ds.js'; -import { S3WidgetParams } from '../application/data-structures/s3-widget-params.ds.js'; -import { S3HelperService } from '../s3-helper.service.js'; -import { IGetS3FileUrl } from './s3-use-cases.interface.js'; -import { WidgetTypeEnum } from '../../../enums/index.js'; -import JSON5 from 'json5'; +import { HttpStatus, Inject, Injectable } from "@nestjs/common"; +import { HttpException } from "@nestjs/common/exceptions/http.exception.js"; +import { getDataAccessObject } from "@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js"; +import JSON5 from "json5"; +import AbstractUseCase from "../../../common/abstract-use.case.js"; +import { IGlobalDatabaseContext } from "../../../common/application/global-database-context.interface.js"; +import { BaseType } from "../../../common/data-injection.tokens.js"; +import { WidgetTypeEnum } from "../../../enums/index.js"; +import { Messages } from "../../../exceptions/text/messages.js"; +import { Encryptor } from "../../../helpers/encryption/encryptor.js"; +import { isConnectionTypeAgent } from "../../../helpers/index.js"; +import { + S3FileUrlResponseDs, + S3GetFileUrlDs, +} from "../application/data-structures/s3-operation.ds.js"; +import { S3WidgetParams } 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 implements IGetS3FileUrl { - constructor( - @Inject(BaseType.GLOBAL_DB_CONTEXT) - protected _dbContext: IGlobalDatabaseContext, - private readonly s3Helper: S3HelperService, - ) { - super(); - } - - protected async implementation(inputData: S3GetFileUrlDs): Promise { - const { connectionId, tableName, fieldName, fileKey, userId, masterPwd } = inputData; - - const user = await this._dbContext.userRepository.findOneUserByIdWithCompany(userId); - if (!user || !user.company) { - throw new HttpException( - { message: Messages.USER_NOT_FOUND_OR_NOT_IN_COMPANY }, - HttpStatus.NOT_FOUND, - ); - } - - const foundTableWidgets = await this._dbContext.tableWidgetsRepository.findTableWidgets(connectionId, tableName); - const widget = foundTableWidgets.find((w) => w.field_name === fieldName); - - if (!widget || widget.widget_type !== WidgetTypeEnum.S3) { - throw new HttpException( - { message: 'S3 widget not configured for this field' }, - HttpStatus.BAD_REQUEST, - ); - } - - const params: S3WidgetParams = - 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, - user.company.id, - ); - - const secretKeySecret = await this._dbContext.userSecretRepository.findSecretBySlugAndCompanyId( - params.aws_secret_access_key_secret_name, - user.company.id, - ); - - if (!accessKeySecret || !secretKeySecret) { - throw new HttpException( - { message: 'AWS credentials secrets not found' }, - HttpStatus.NOT_FOUND, - ); - } - - let accessKeyId = Encryptor.decryptData(accessKeySecret.encryptedValue); - let secretAccessKey = Encryptor.decryptData(secretKeySecret.encryptedValue); - - if (accessKeySecret.masterEncryption && masterPwd) { - accessKeyId = Encryptor.decryptDataMasterPwd(accessKeyId, masterPwd); - } - if (secretKeySecret.masterEncryption && masterPwd) { - secretAccessKey = Encryptor.decryptDataMasterPwd(secretAccessKey, masterPwd); - } - - const client = this.s3Helper.createS3Client(accessKeyId, secretAccessKey, params.region || 'us-east-1'); - - const expiresIn = 3600; - const url = await this.s3Helper.getSignedGetUrl(client, params.bucket, fileKey, expiresIn); - - return { url, key: fileKey, expiresIn }; - } +export class GetS3FileUrlUseCase + extends AbstractUseCase + implements IGetS3FileUrl +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + private readonly s3Helper: S3HelperService, + ) { + super(); + } + + protected async implementation( + inputData: S3GetFileUrlDs, + ): Promise { + const { + connectionId, + tableName, + fieldName, + rowPrimaryKey, + userId, + masterPwd, + } = inputData; + + const user = + await this._dbContext.userRepository.findOneUserByIdWithCompany(userId); + if (!user || !user.company) { + throw new HttpException( + { message: Messages.USER_NOT_FOUND_OR_NOT_IN_COMPANY }, + HttpStatus.NOT_FOUND, + ); + } + + const connection = + await this._dbContext.connectionRepository.findAndDecryptConnection( + connectionId, + masterPwd, + ); + if (!connection) { + throw new HttpException( + { message: Messages.CONNECTION_NOT_FOUND }, + HttpStatus.BAD_REQUEST, + ); + } + + const foundTableWidgets = + await this._dbContext.tableWidgetsRepository.findTableWidgets( + connectionId, + tableName, + ); + const widget = foundTableWidgets.find((w) => w.field_name === fieldName); + + if (!widget || widget.widget_type !== WidgetTypeEnum.S3) { + throw new HttpException( + { message: "S3 widget not configured for this field" }, + HttpStatus.BAD_REQUEST, + ); + } + + const params: S3WidgetParams = + typeof widget.widget_params === "string" + ? JSON5.parse(widget.widget_params) + : widget.widget_params; + + // Fetch the row from database to get the actual file key + const dao = getDataAccessObject(connection); + let userEmail: string; + if (isConnectionTypeAgent(connection.type)) { + userEmail = + await this._dbContext.userRepository.getUserEmailOrReturnNull(userId); + } + + const tableSettings = + await this._dbContext.tableSettingsRepository.findTableSettingsPure( + connectionId, + tableName, + ); + const rowData = await dao.getRowByPrimaryKey( + tableName, + rowPrimaryKey, + tableSettings, + userEmail, + ); + + if (!rowData) { + throw new HttpException( + { message: Messages.ROW_PRIMARY_KEY_NOT_FOUND }, + HttpStatus.NOT_FOUND, + ); + } + + const fileKey = rowData[fieldName] as string; + if (!fileKey) { + throw new HttpException( + { message: "File key not found in row" }, + HttpStatus.NOT_FOUND, + ); + } + + const accessKeySecret = + await this._dbContext.userSecretRepository.findSecretBySlugAndCompanyId( + params.aws_access_key_id_secret_name, + user.company.id, + ); + + const secretKeySecret = + await this._dbContext.userSecretRepository.findSecretBySlugAndCompanyId( + params.aws_secret_access_key_secret_name, + user.company.id, + ); + + if (!accessKeySecret || !secretKeySecret) { + throw new HttpException( + { message: "AWS credentials secrets not found" }, + HttpStatus.NOT_FOUND, + ); + } + + let accessKeyId = Encryptor.decryptData(accessKeySecret.encryptedValue); + let secretAccessKey = Encryptor.decryptData(secretKeySecret.encryptedValue); + + if (accessKeySecret.masterEncryption && masterPwd) { + accessKeyId = Encryptor.decryptDataMasterPwd(accessKeyId, masterPwd); + } + if (secretKeySecret.masterEncryption && masterPwd) { + secretAccessKey = Encryptor.decryptDataMasterPwd( + secretAccessKey, + masterPwd, + ); + } + + const client = this.s3Helper.createS3Client( + accessKeyId, + secretAccessKey, + params.region || "us-east-1", + ); + + const expiresIn = 3600; + const url = await this.s3Helper.getSignedGetUrl( + client, + params.bucket, + fileKey, + expiresIn, + ); + + return { url, key: fileKey, expiresIn }; + } } diff --git a/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.html b/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.html index b7204d8fa..e4b8a42b6 100644 --- a/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.html +++ b/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.html @@ -126,7 +126,8 @@

readonly: (!permissions?.edit && pageAction !== 'dub') || pageMode === 'view', disabled: isReadonlyField(value), widgetStructure: tableWidgets[value], - relations: tableTypes[value] === 'foreign key' ? getRelations(value) : undefined + relations: tableTypes[value] === 'foreign key' ? getRelations(value) : undefined, + rowPrimaryKey: keyAttributesFromURL }" [ndcDynamicOutputs]="{ onFieldChange: { handler: updateField, args: ['$event', value] } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.ts index 3a447adc3..cbf3ef7ad 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.ts @@ -1,149 +1,169 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; -import { S3Service } from 'src/app/services/s3.service'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TablesService } from 'src/app/services/tables.service'; +import { CommonModule } from "@angular/common"; +import { Component, Input, OnInit } from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { MatButtonModule } from "@angular/material/button"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatIconModule } from "@angular/material/icon"; +import { MatInputModule } from "@angular/material/input"; +import { MatProgressSpinnerModule } from "@angular/material/progress-spinner"; +import { ConnectionsService } from "src/app/services/connections.service"; +import { S3Service } from "src/app/services/s3.service"; +import { TablesService } from "src/app/services/tables.service"; +import { BaseEditFieldComponent } from "../base-row-field/base-row-field.component"; interface S3WidgetParams { - bucket: string; - prefix?: string; - region?: string; - aws_access_key_id_secret_name: string; - aws_secret_access_key_secret_name: string; + bucket: string; + prefix?: string; + region?: string; + aws_access_key_id_secret_name: string; + aws_secret_access_key_secret_name: string; } @Component({ - selector: 'app-edit-s3', - templateUrl: './s3.component.html', - styleUrl: './s3.component.css', - imports: [ - CommonModule, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatButtonModule, - MatIconModule, - MatProgressSpinnerModule, - ] + selector: "app-edit-s3", + templateUrl: "./s3.component.html", + styleUrl: "./s3.component.css", + imports: [ + CommonModule, + FormsModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule, + ], }) export class S3EditComponent extends BaseEditFieldComponent implements OnInit { - @Input() value: string; - - public params: S3WidgetParams; - public previewUrl: string | null = null; - public isImage: boolean = false; - public isLoading: boolean = false; - - private connectionId: string; - private tableName: string; - - constructor( - private s3Service: S3Service, - private connectionsService: ConnectionsService, - private tablesService: TablesService - ) { - super(); - } - - ngOnInit(): void { - super.ngOnInit(); - this.connectionId = this.connectionsService.currentConnectionID; - this.tableName = this.tablesService.currentTableName; - this._parseWidgetParams(); - if (this.value) { - this._loadPreview(); - } - } - - ngOnChanges(): void { - this._parseWidgetParams(); - if (this.value && !this.previewUrl && !this.isLoading) { - this._loadPreview(); - } - } - - onFileSelected(event: Event): void { - const input = event.target as HTMLInputElement; - if (!input.files?.length) return; - - const file = input.files[0]; - this.isLoading = true; - - this.s3Service.getUploadUrl( - this.connectionId, - this.tableName, - this.widgetStructure.field_name, - file.name, - file.type - ).subscribe({ - next: (response) => { - this.s3Service.uploadToS3(response.uploadUrl, file).subscribe({ - next: () => { - this.value = response.key; - this.onFieldChange.emit(response.key); - this._loadPreview(); - }, - error: () => { - this.isLoading = false; - } - }); - }, - error: () => { - this.isLoading = false; - } - }); - } - - openFile(): void { - if (this.previewUrl) { - window.open(this.previewUrl, '_blank'); - } - } - - private _parseWidgetParams(): void { - if (this.widgetStructure?.widget_params) { - try { - this.params = typeof this.widgetStructure.widget_params === 'string' - ? JSON.parse(this.widgetStructure.widget_params) - : this.widgetStructure.widget_params; - } catch (e) { - console.error('Error parsing S3 widget params:', e); - } - } - } - - private _loadPreview(): void { - if (!this.value || !this.connectionId || !this.tableName) return; - - this.isLoading = true; - this.isImage = this._isImageFile(this.value); - - this.s3Service.getFileUrl( - this.connectionId, - this.tableName, - this.widgetStructure.field_name, - this.value - ).subscribe({ - next: (response) => { - this.previewUrl = response.url; - this.isLoading = false; - }, - error: () => { - this.isLoading = false; - } - }); - } - - private _isImageFile(key: string): boolean { - const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp']; - const lowerKey = key.toLowerCase(); - return imageExtensions.some(ext => lowerKey.endsWith(ext)); - } + @Input() value: string; + @Input() rowPrimaryKey: Record; + + public params: S3WidgetParams; + public previewUrl: string | null = null; + public isImage: boolean = false; + public isLoading: boolean = false; + + private connectionId: string; + private tableName: string; + + constructor( + private s3Service: S3Service, + private connectionsService: ConnectionsService, + private tablesService: TablesService, + ) { + super(); + } + + ngOnInit(): void { + super.ngOnInit(); + this.connectionId = this.connectionsService.currentConnectionID; + this.tableName = this.tablesService.currentTableName; + this._parseWidgetParams(); + if (this.value) { + this._loadPreview(); + } + } + + ngOnChanges(): void { + this._parseWidgetParams(); + if (this.value && !this.previewUrl && !this.isLoading) { + this._loadPreview(); + } + } + + onFileSelected(event: Event): void { + const input = event.target as HTMLInputElement; + if (!input.files?.length) return; + + const file = input.files[0]; + this.isLoading = true; + + this.s3Service + .getUploadUrl( + this.connectionId, + this.tableName, + this.widgetStructure.field_name, + file.name, + file.type, + ) + .subscribe({ + next: (response) => { + this.s3Service.uploadToS3(response.uploadUrl, file).subscribe({ + next: () => { + this.value = response.key; + this.onFieldChange.emit(response.key); + this._loadPreview(); + }, + error: () => { + this.isLoading = false; + }, + }); + }, + error: () => { + this.isLoading = false; + }, + }); + } + + openFile(): void { + if (this.previewUrl) { + window.open(this.previewUrl, "_blank"); + } + } + + private _parseWidgetParams(): void { + if (this.widgetStructure?.widget_params) { + try { + this.params = + typeof this.widgetStructure.widget_params === "string" + ? JSON.parse(this.widgetStructure.widget_params) + : this.widgetStructure.widget_params; + } catch (e) { + console.error("Error parsing S3 widget params:", e); + } + } + } + + private _loadPreview(): void { + if ( + !this.value || + !this.connectionId || + !this.tableName || + !this.rowPrimaryKey + ) + return; + + this.isLoading = true; + this.isImage = this._isImageFile(this.value); + + this.s3Service + .getFileUrl( + this.connectionId, + this.tableName, + this.widgetStructure.field_name, + this.rowPrimaryKey, + ) + .subscribe({ + next: (response) => { + this.previewUrl = response.url; + this.isLoading = false; + }, + error: () => { + this.isLoading = false; + }, + }); + } + + private _isImageFile(key: string): boolean { + const imageExtensions = [ + ".jpg", + ".jpeg", + ".png", + ".gif", + ".webp", + ".svg", + ".bmp", + ]; + const lowerKey = key.toLowerCase(); + return imageExtensions.some((ext) => lowerKey.endsWith(ext)); + } } diff --git a/frontend/src/app/services/s3.service.ts b/frontend/src/app/services/s3.service.ts index 42ccc69f3..486c105de 100644 --- a/frontend/src/app/services/s3.service.ts +++ b/frontend/src/app/services/s3.service.ts @@ -1,85 +1,119 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { Observable, catchError, EMPTY } from 'rxjs'; -import { NotificationsService } from './notifications.service'; -import { AlertActionType, AlertType } from '../models/alert'; +import { HttpClient } from "@angular/common/http"; +import { Injectable } from "@angular/core"; +import { catchError, EMPTY, Observable } from "rxjs"; +import { AlertActionType, AlertType } from "../models/alert"; +import { NotificationsService } from "./notifications.service"; interface S3FileUrlResponse { - url: string; - key: string; - expiresIn: number; + url: string; + key: string; + expiresIn: number; } interface S3UploadUrlResponse { - uploadUrl: string; - key: string; - expiresIn: number; + uploadUrl: string; + key: string; + expiresIn: number; } @Injectable({ - providedIn: 'root' + providedIn: "root", }) export class S3Service { - constructor( - private http: HttpClient, - private notifications: NotificationsService - ) {} + constructor( + private http: HttpClient, + private notifications: NotificationsService, + ) {} - getFileUrl( - connectionId: string, - tableName: string, - fieldName: string, - fileKey: string - ): Observable { - return this.http.get(`/s3/file/${connectionId}`, { - params: { tableName, fieldName, fileKey } - }).pipe( - catchError(err => { - this.notifications.showAlert( - AlertType.Error, - { abstract: 'Failed to get S3 file URL', details: err.error?.message }, - [{ type: AlertActionType.Button, caption: 'Dismiss', action: () => this.notifications.dismissAlert() }] - ); - return EMPTY; - }) - ); - } + getFileUrl( + connectionId: string, + tableName: string, + fieldName: string, + rowPrimaryKey: Record, + ): Observable { + return this.http + .get(`/s3/file/${connectionId}`, { + params: { + tableName, + fieldName, + rowPrimaryKey: JSON.stringify(rowPrimaryKey), + }, + }) + .pipe( + catchError((err) => { + this.notifications.showAlert( + AlertType.Error, + { + abstract: "Failed to get S3 file URL", + details: err.error?.message, + }, + [ + { + type: AlertActionType.Button, + caption: "Dismiss", + action: () => this.notifications.dismissAlert(), + }, + ], + ); + return EMPTY; + }), + ); + } - getUploadUrl( - connectionId: string, - tableName: string, - fieldName: string, - filename: string, - contentType: string - ): Observable { - return this.http.post( - `/s3/upload-url/${connectionId}`, - { filename, contentType }, - { params: { tableName, fieldName } } - ).pipe( - catchError(err => { - this.notifications.showAlert( - AlertType.Error, - { abstract: 'Failed to get upload URL', details: err.error?.message }, - [{ type: AlertActionType.Button, caption: 'Dismiss', action: () => this.notifications.dismissAlert() }] - ); - return EMPTY; - }) - ); - } + getUploadUrl( + connectionId: string, + tableName: string, + fieldName: string, + filename: string, + contentType: string, + ): Observable { + return this.http + .post( + `/s3/upload-url/${connectionId}`, + { filename, contentType }, + { params: { tableName, fieldName } }, + ) + .pipe( + catchError((err) => { + this.notifications.showAlert( + AlertType.Error, + { + abstract: "Failed to get upload URL", + details: err.error?.message, + }, + [ + { + type: AlertActionType.Button, + caption: "Dismiss", + action: () => this.notifications.dismissAlert(), + }, + ], + ); + return EMPTY; + }), + ); + } - uploadToS3(uploadUrl: string, file: File): Observable { - return this.http.put(uploadUrl, file, { - headers: { 'Content-Type': file.type } - }).pipe( - catchError(err => { - this.notifications.showAlert( - AlertType.Error, - { abstract: 'File upload failed', details: err.message }, - [{ type: AlertActionType.Button, caption: 'Dismiss', action: () => this.notifications.dismissAlert() }] - ); - return EMPTY; - }) - ); - } + uploadToS3(uploadUrl: string, file: File): Observable { + return this.http + .put(uploadUrl, file, { + headers: { "Content-Type": file.type }, + }) + .pipe( + catchError((err) => { + this.notifications.showAlert( + AlertType.Error, + { abstract: "File upload failed", details: err.message }, + [ + { + type: AlertActionType.Button, + caption: "Dismiss", + action: () => this.notifications.dismissAlert(), + }, + ], + ); + return EMPTY; + }), + ); + } } diff --git a/package.json b/package.json index 383716007..4ac591705 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,31 @@ { - "name": "root", - "private": true, - "workspaces": [ - "backend", - "rocketadmin-agent", - "shared-code" - ], - "packageManager": "yarn@3.4.1", - "dependencies": { - "monaco-editor": "^0.53.0" - }, - "resolutions": { - "cipher-base": "1.0.7", - "tar-fs": "1.16.6" - }, - "scripts": { - "lint": "biome lint" - }, - "devDependencies": { - "@biomejs/biome": "2.3.8" - } + "name": "root", + "private": true, + "workspaces": [ + "backend", + "rocketadmin-agent", + "shared-code" + ], + "packageManager": "yarn@3.4.1", + "dependencies": { + "monaco-editor": "^0.53.0" + }, + "resolutions": { + "cipher-base": "1.0.7", + "tar-fs": "1.16.6" + }, + "scripts": { + "lint": "biome lint", + "prepare": "husky" + }, + "devDependencies": { + "@biomejs/biome": "2.3.8", + "husky": "^9.1.7", + "lint-staged": "^16.2.7" + }, + "lint-staged": { + "*.{js,ts,jsx,tsx,json,html,css}": [ + "biome check --write" + ] + } } diff --git a/yarn.lock b/yarn.lock index c3f30d1e8..52b7f39b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -153,6 +153,42 @@ __metadata: languageName: node linkType: hard +"@aws-crypto/crc32@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/crc32@npm:5.2.0" + dependencies: + "@aws-crypto/util": ^5.2.0 + "@aws-sdk/types": ^3.222.0 + tslib: ^2.6.2 + checksum: 1ddf7ec3fccf106205ff2476d90ae1d6625eabd47752f689c761b71e41fe451962b7a1c9ed25fe54e17dd747a62fbf4de06030fe56fe625f95285f6f70b96c57 + languageName: node + linkType: hard + +"@aws-crypto/crc32c@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/crc32c@npm:5.2.0" + dependencies: + "@aws-crypto/util": ^5.2.0 + "@aws-sdk/types": ^3.222.0 + tslib: ^2.6.2 + checksum: 0b399de8607c59e1e46c05d2b24a16b56d507944fdac925c611f0ba7302f5555c098139806d7da1ebef1f89bf4e4b5d4dec74d4809ce0f18238b72072065effe + languageName: node + linkType: hard + +"@aws-crypto/sha1-browser@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/sha1-browser@npm:5.2.0" + dependencies: + "@aws-crypto/supports-web-crypto": ^5.2.0 + "@aws-crypto/util": ^5.2.0 + "@aws-sdk/types": ^3.222.0 + "@aws-sdk/util-locate-window": ^3.0.0 + "@smithy/util-utf8": ^2.0.0 + tslib: ^2.6.2 + checksum: 8b04af601d945c5ef0f5f733b55681edc95b81c02ce5067b57f1eb4ee718e45485cf9aeeb7a84da9131656d09e1c4bc78040ec759f557a46703422d8df098d59 + languageName: node + linkType: hard + "@aws-crypto/sha256-browser@npm:5.2.0": version: 5.2.0 resolution: "@aws-crypto/sha256-browser@npm:5.2.0" @@ -188,7 +224,7 @@ __metadata: languageName: node linkType: hard -"@aws-crypto/util@npm:^5.2.0": +"@aws-crypto/util@npm:5.2.0, @aws-crypto/util@npm:^5.2.0": version: 5.2.0 resolution: "@aws-crypto/util@npm:5.2.0" dependencies: @@ -249,6 +285,69 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-s3@npm:^3.953.0": + version: 3.956.0 + resolution: "@aws-sdk/client-s3@npm:3.956.0" + dependencies: + "@aws-crypto/sha1-browser": 5.2.0 + "@aws-crypto/sha256-browser": 5.2.0 + "@aws-crypto/sha256-js": 5.2.0 + "@aws-sdk/core": 3.956.0 + "@aws-sdk/credential-provider-node": 3.956.0 + "@aws-sdk/middleware-bucket-endpoint": 3.956.0 + "@aws-sdk/middleware-expect-continue": 3.956.0 + "@aws-sdk/middleware-flexible-checksums": 3.956.0 + "@aws-sdk/middleware-host-header": 3.956.0 + "@aws-sdk/middleware-location-constraint": 3.956.0 + "@aws-sdk/middleware-logger": 3.956.0 + "@aws-sdk/middleware-recursion-detection": 3.956.0 + "@aws-sdk/middleware-sdk-s3": 3.956.0 + "@aws-sdk/middleware-ssec": 3.956.0 + "@aws-sdk/middleware-user-agent": 3.956.0 + "@aws-sdk/region-config-resolver": 3.956.0 + "@aws-sdk/signature-v4-multi-region": 3.956.0 + "@aws-sdk/types": 3.956.0 + "@aws-sdk/util-endpoints": 3.956.0 + "@aws-sdk/util-user-agent-browser": 3.956.0 + "@aws-sdk/util-user-agent-node": 3.956.0 + "@smithy/config-resolver": ^4.4.5 + "@smithy/core": ^3.20.0 + "@smithy/eventstream-serde-browser": ^4.2.7 + "@smithy/eventstream-serde-config-resolver": ^4.3.7 + "@smithy/eventstream-serde-node": ^4.2.7 + "@smithy/fetch-http-handler": ^5.3.8 + "@smithy/hash-blob-browser": ^4.2.8 + "@smithy/hash-node": ^4.2.7 + "@smithy/hash-stream-node": ^4.2.7 + "@smithy/invalid-dependency": ^4.2.7 + "@smithy/md5-js": ^4.2.7 + "@smithy/middleware-content-length": ^4.2.7 + "@smithy/middleware-endpoint": ^4.4.1 + "@smithy/middleware-retry": ^4.4.17 + "@smithy/middleware-serde": ^4.2.8 + "@smithy/middleware-stack": ^4.2.7 + "@smithy/node-config-provider": ^4.3.7 + "@smithy/node-http-handler": ^4.4.7 + "@smithy/protocol-http": ^5.3.7 + "@smithy/smithy-client": ^4.10.2 + "@smithy/types": ^4.11.0 + "@smithy/url-parser": ^4.2.7 + "@smithy/util-base64": ^4.3.0 + "@smithy/util-body-length-browser": ^4.2.0 + "@smithy/util-body-length-node": ^4.2.1 + "@smithy/util-defaults-mode-browser": ^4.3.16 + "@smithy/util-defaults-mode-node": ^4.2.19 + "@smithy/util-endpoints": ^3.2.7 + "@smithy/util-middleware": ^4.2.7 + "@smithy/util-retry": ^4.2.7 + "@smithy/util-stream": ^4.5.8 + "@smithy/util-utf8": ^4.2.0 + "@smithy/util-waiter": ^4.2.7 + tslib: ^2.6.2 + checksum: 7aa188233ca17be02fa5eb0aca40796a144ed8c6352c55856e684ced863ee941e23918606ba9b9e50bb8bb735d1ccd671460087e94dca3cde7bb3739680efcf7 + languageName: node + linkType: hard + "@aws-sdk/client-sesv2@npm:^3.839.0": version: 3.926.0 resolution: "@aws-sdk/client-sesv2@npm:3.926.0" @@ -389,6 +488,52 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-sso@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/client-sso@npm:3.956.0" + dependencies: + "@aws-crypto/sha256-browser": 5.2.0 + "@aws-crypto/sha256-js": 5.2.0 + "@aws-sdk/core": 3.956.0 + "@aws-sdk/middleware-host-header": 3.956.0 + "@aws-sdk/middleware-logger": 3.956.0 + "@aws-sdk/middleware-recursion-detection": 3.956.0 + "@aws-sdk/middleware-user-agent": 3.956.0 + "@aws-sdk/region-config-resolver": 3.956.0 + "@aws-sdk/types": 3.956.0 + "@aws-sdk/util-endpoints": 3.956.0 + "@aws-sdk/util-user-agent-browser": 3.956.0 + "@aws-sdk/util-user-agent-node": 3.956.0 + "@smithy/config-resolver": ^4.4.5 + "@smithy/core": ^3.20.0 + "@smithy/fetch-http-handler": ^5.3.8 + "@smithy/hash-node": ^4.2.7 + "@smithy/invalid-dependency": ^4.2.7 + "@smithy/middleware-content-length": ^4.2.7 + "@smithy/middleware-endpoint": ^4.4.1 + "@smithy/middleware-retry": ^4.4.17 + "@smithy/middleware-serde": ^4.2.8 + "@smithy/middleware-stack": ^4.2.7 + "@smithy/node-config-provider": ^4.3.7 + "@smithy/node-http-handler": ^4.4.7 + "@smithy/protocol-http": ^5.3.7 + "@smithy/smithy-client": ^4.10.2 + "@smithy/types": ^4.11.0 + "@smithy/url-parser": ^4.2.7 + "@smithy/util-base64": ^4.3.0 + "@smithy/util-body-length-browser": ^4.2.0 + "@smithy/util-body-length-node": ^4.2.1 + "@smithy/util-defaults-mode-browser": ^4.3.16 + "@smithy/util-defaults-mode-node": ^4.2.19 + "@smithy/util-endpoints": ^3.2.7 + "@smithy/util-middleware": ^4.2.7 + "@smithy/util-retry": ^4.2.7 + "@smithy/util-utf8": ^4.2.0 + tslib: ^2.6.2 + checksum: 25fcd1e1937878e7f14cb7a29fcdde5d1c2a43eafcc53711bc6ab49dd8e7dd45c2b170ffde81985620efb7e6aac60e8edc3c25152e5ff63735eb40ab63f2d997 + languageName: node + linkType: hard + "@aws-sdk/core@npm:3.926.0": version: 3.926.0 resolution: "@aws-sdk/core@npm:3.926.0" @@ -452,6 +597,27 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/core@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/core@npm:3.956.0" + dependencies: + "@aws-sdk/types": 3.956.0 + "@aws-sdk/xml-builder": 3.956.0 + "@smithy/core": ^3.20.0 + "@smithy/node-config-provider": ^4.3.7 + "@smithy/property-provider": ^4.2.7 + "@smithy/protocol-http": ^5.3.7 + "@smithy/signature-v4": ^5.3.7 + "@smithy/smithy-client": ^4.10.2 + "@smithy/types": ^4.11.0 + "@smithy/util-base64": ^4.3.0 + "@smithy/util-middleware": ^4.2.7 + "@smithy/util-utf8": ^4.2.0 + tslib: ^2.6.2 + checksum: 20b6404d5b2a44e9b325dadaa4d619eb8999c757123c6486ec0e9a0abf21baa9709dd64e8c8a50a83c46c09dca829335a8c790a919e9cbadd329ecdc64fded11 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-env@npm:3.926.0": version: 3.926.0 resolution: "@aws-sdk/credential-provider-env@npm:3.926.0" @@ -478,6 +644,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-env@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/credential-provider-env@npm:3.956.0" + dependencies: + "@aws-sdk/core": 3.956.0 + "@aws-sdk/types": 3.956.0 + "@smithy/property-provider": ^4.2.7 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: b000c51827db4079c49babdbb20d6b90a3dad0798e431b6731ff1d2d2057f322c24937a9ea6ce794e5b487867bd10f32f1b2cac88e745ef8a145a60508aef355 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-http@npm:3.926.0": version: 3.926.0 resolution: "@aws-sdk/credential-provider-http@npm:3.926.0" @@ -514,6 +693,24 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-http@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/credential-provider-http@npm:3.956.0" + dependencies: + "@aws-sdk/core": 3.956.0 + "@aws-sdk/types": 3.956.0 + "@smithy/fetch-http-handler": ^5.3.8 + "@smithy/node-http-handler": ^4.4.7 + "@smithy/property-provider": ^4.2.7 + "@smithy/protocol-http": ^5.3.7 + "@smithy/smithy-client": ^4.10.2 + "@smithy/types": ^4.11.0 + "@smithy/util-stream": ^4.5.8 + tslib: ^2.6.2 + checksum: df77f2c971c1679e3411ef66f3ce95ddd9f91b7133ee789d868167e578a27a78b87340e253fbd918e61caaa921965e29dcecb667f22ef71a713cb41deb9e5046 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-ini@npm:3.926.0": version: 3.926.0 resolution: "@aws-sdk/credential-provider-ini@npm:3.926.0" @@ -557,6 +754,28 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-ini@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/credential-provider-ini@npm:3.956.0" + dependencies: + "@aws-sdk/core": 3.956.0 + "@aws-sdk/credential-provider-env": 3.956.0 + "@aws-sdk/credential-provider-http": 3.956.0 + "@aws-sdk/credential-provider-login": 3.956.0 + "@aws-sdk/credential-provider-process": 3.956.0 + "@aws-sdk/credential-provider-sso": 3.956.0 + "@aws-sdk/credential-provider-web-identity": 3.956.0 + "@aws-sdk/nested-clients": 3.956.0 + "@aws-sdk/types": 3.956.0 + "@smithy/credential-provider-imds": ^4.2.7 + "@smithy/property-provider": ^4.2.7 + "@smithy/shared-ini-file-loader": ^4.4.2 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: e3d95c8ef14c6a07f2c5d97f98e8159a0e6d548ea204a50a94b1ac746bf4c95fd34c2a2aaa097c092f4d929d28019600abc7afc146373bbf5aecce42a30e8f94 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-login@npm:3.952.0": version: 3.952.0 resolution: "@aws-sdk/credential-provider-login@npm:3.952.0" @@ -573,6 +792,22 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-login@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/credential-provider-login@npm:3.956.0" + dependencies: + "@aws-sdk/core": 3.956.0 + "@aws-sdk/nested-clients": 3.956.0 + "@aws-sdk/types": 3.956.0 + "@smithy/property-provider": ^4.2.7 + "@smithy/protocol-http": ^5.3.7 + "@smithy/shared-ini-file-loader": ^4.4.2 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: e9be95babad7674a18f0c3e538ee3e19840a289a7c9933fd1ec9cfc64857d737f2785c3f651d218ece4a37a321188a0dc3ed029794f69fb7b9d704c945ded325 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-node@npm:3.926.0": version: 3.926.0 resolution: "@aws-sdk/credential-provider-node@npm:3.926.0" @@ -613,6 +848,26 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-node@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/credential-provider-node@npm:3.956.0" + dependencies: + "@aws-sdk/credential-provider-env": 3.956.0 + "@aws-sdk/credential-provider-http": 3.956.0 + "@aws-sdk/credential-provider-ini": 3.956.0 + "@aws-sdk/credential-provider-process": 3.956.0 + "@aws-sdk/credential-provider-sso": 3.956.0 + "@aws-sdk/credential-provider-web-identity": 3.956.0 + "@aws-sdk/types": 3.956.0 + "@smithy/credential-provider-imds": ^4.2.7 + "@smithy/property-provider": ^4.2.7 + "@smithy/shared-ini-file-loader": ^4.4.2 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: cde980f2fed543ccec19ea94f5d5c94786c1ecec0bd15fc4b7cdcb17c7eeeac03239a931e018af919d7e0dd60d197f437d243c61059428b5f9a3f2e99ee8d3b2 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-process@npm:3.926.0": version: 3.926.0 resolution: "@aws-sdk/credential-provider-process@npm:3.926.0" @@ -641,6 +896,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-process@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/credential-provider-process@npm:3.956.0" + dependencies: + "@aws-sdk/core": 3.956.0 + "@aws-sdk/types": 3.956.0 + "@smithy/property-provider": ^4.2.7 + "@smithy/shared-ini-file-loader": ^4.4.2 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: ab2b68f0827a864f060d330853e04861839b23a9619d23880d6829fbdf4e7f01b916f1d9145800733d9d7c7c7fb87f99b056017f7dfe8a937d51cb1c48a2be11 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-sso@npm:3.926.0": version: 3.926.0 resolution: "@aws-sdk/credential-provider-sso@npm:3.926.0" @@ -673,6 +942,22 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-sso@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/credential-provider-sso@npm:3.956.0" + dependencies: + "@aws-sdk/client-sso": 3.956.0 + "@aws-sdk/core": 3.956.0 + "@aws-sdk/token-providers": 3.956.0 + "@aws-sdk/types": 3.956.0 + "@smithy/property-provider": ^4.2.7 + "@smithy/shared-ini-file-loader": ^4.4.2 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: cc4200e8cff3dda1892067661da95799d8e81f08a1381e7b07079395a5daf05afe286882629e91e67927dc55b132fa7cb941f62105348c330858d7a551b660b6 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-web-identity@npm:3.926.0": version: 3.926.0 resolution: "@aws-sdk/credential-provider-web-identity@npm:3.926.0" @@ -703,6 +988,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-web-identity@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.956.0" + dependencies: + "@aws-sdk/core": 3.956.0 + "@aws-sdk/nested-clients": 3.956.0 + "@aws-sdk/types": 3.956.0 + "@smithy/property-provider": ^4.2.7 + "@smithy/shared-ini-file-loader": ^4.4.2 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 2f8f879aadd2cd6bc0c32e9a7b1570912ad0b7600caf3a8ede4e3e58d9cd3a32384f51fb814e2c99b4f408248454747f4bfdaddad4197027b1cafb9a041ba9d6 + languageName: node + linkType: hard + "@aws-sdk/dynamodb-codec@npm:3.947.0": version: 3.947.0 resolution: "@aws-sdk/dynamodb-codec@npm:3.947.0" @@ -745,6 +1045,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-bucket-endpoint@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/middleware-bucket-endpoint@npm:3.956.0" + dependencies: + "@aws-sdk/types": 3.956.0 + "@aws-sdk/util-arn-parser": 3.953.0 + "@smithy/node-config-provider": ^4.3.7 + "@smithy/protocol-http": ^5.3.7 + "@smithy/types": ^4.11.0 + "@smithy/util-config-provider": ^4.2.0 + tslib: ^2.6.2 + checksum: ec253dd9767a500d9c1cae7e99b06c2fde95b671c0ab8b116802d0daa32748fdcaf1498b66c5b6e025f052d6b88467dc7c31d39f3df778e14025e8a07f02f7df + languageName: node + linkType: hard + "@aws-sdk/middleware-endpoint-discovery@npm:3.936.0": version: 3.936.0 resolution: "@aws-sdk/middleware-endpoint-discovery@npm:3.936.0" @@ -759,6 +1074,39 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-expect-continue@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/middleware-expect-continue@npm:3.956.0" + dependencies: + "@aws-sdk/types": 3.956.0 + "@smithy/protocol-http": ^5.3.7 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 5ccde16e095fa5c80f6fb1ea3c7155f66d671caeeca818801fa1cd50b16c7de902530bf0e170e981ea75ca72cbdc3d7dada4c774379cd5dcde3614f1d41d68a8 + languageName: node + linkType: hard + +"@aws-sdk/middleware-flexible-checksums@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/middleware-flexible-checksums@npm:3.956.0" + dependencies: + "@aws-crypto/crc32": 5.2.0 + "@aws-crypto/crc32c": 5.2.0 + "@aws-crypto/util": 5.2.0 + "@aws-sdk/core": 3.956.0 + "@aws-sdk/types": 3.956.0 + "@smithy/is-array-buffer": ^4.2.0 + "@smithy/node-config-provider": ^4.3.7 + "@smithy/protocol-http": ^5.3.7 + "@smithy/types": ^4.11.0 + "@smithy/util-middleware": ^4.2.7 + "@smithy/util-stream": ^4.5.8 + "@smithy/util-utf8": ^4.2.0 + tslib: ^2.6.2 + checksum: 5791606d82818c03b171440b97dfb4f0446c7398f8b7eee3c48a52ed0dc854882d0139aca5aa87190cc3b68a33746a93151f8c145ba322157dc34da8b649f40c + languageName: node + linkType: hard + "@aws-sdk/middleware-host-header@npm:3.922.0": version: 3.922.0 resolution: "@aws-sdk/middleware-host-header@npm:3.922.0" @@ -783,6 +1131,29 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-host-header@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/middleware-host-header@npm:3.956.0" + dependencies: + "@aws-sdk/types": 3.956.0 + "@smithy/protocol-http": ^5.3.7 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: a8185c17a319d7457a5a8562d968e34087183890a6bfa81c10c43c857a660f46b07540dfb267bce6b8413d09e9feeb1226aa836664f39f2b4bfff7a1b1cdf3eb + languageName: node + linkType: hard + +"@aws-sdk/middleware-location-constraint@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/middleware-location-constraint@npm:3.956.0" + dependencies: + "@aws-sdk/types": 3.956.0 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 389fa4d39e726eccfc12405e1f4d588482c091734742d0098eaeec331bd71678f1b2c7675f18b91b02e2d7693ba106743aff6f9a56e524faa76b8f91e69d4cbb + languageName: node + linkType: hard + "@aws-sdk/middleware-logger@npm:3.922.0": version: 3.922.0 resolution: "@aws-sdk/middleware-logger@npm:3.922.0" @@ -805,6 +1176,17 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-logger@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/middleware-logger@npm:3.956.0" + dependencies: + "@aws-sdk/types": 3.956.0 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 1bee0de1f6ca869408df58d87c59f2a6f35f94a3335d6ee1815862b9a623fe220a6669673e3a9e9e1e3f6fdd5a688a91498719f0bf486fb36b3bf8c14685a44b + languageName: node + linkType: hard + "@aws-sdk/middleware-recursion-detection@npm:3.922.0": version: 3.922.0 resolution: "@aws-sdk/middleware-recursion-detection@npm:3.922.0" @@ -831,6 +1213,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-recursion-detection@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/middleware-recursion-detection@npm:3.956.0" + dependencies: + "@aws-sdk/types": 3.956.0 + "@aws/lambda-invoke-store": ^0.2.2 + "@smithy/protocol-http": ^5.3.7 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 6f9e7bbf0e96121e331e55fba887bc18aca867c2060644acb35b6b3892e075452bdc9be83caa69ef2a9b1b9bec718cc680f815701978c01210235fe7b79153f8 + languageName: node + linkType: hard + "@aws-sdk/middleware-sdk-s3@npm:3.926.0": version: 3.926.0 resolution: "@aws-sdk/middleware-sdk-s3@npm:3.926.0" @@ -853,6 +1248,39 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-sdk-s3@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/middleware-sdk-s3@npm:3.956.0" + dependencies: + "@aws-sdk/core": 3.956.0 + "@aws-sdk/types": 3.956.0 + "@aws-sdk/util-arn-parser": 3.953.0 + "@smithy/core": ^3.20.0 + "@smithy/node-config-provider": ^4.3.7 + "@smithy/protocol-http": ^5.3.7 + "@smithy/signature-v4": ^5.3.7 + "@smithy/smithy-client": ^4.10.2 + "@smithy/types": ^4.11.0 + "@smithy/util-config-provider": ^4.2.0 + "@smithy/util-middleware": ^4.2.7 + "@smithy/util-stream": ^4.5.8 + "@smithy/util-utf8": ^4.2.0 + tslib: ^2.6.2 + checksum: 89452ae4e5fd5d4cddda0627a063697235e038dd248975219ff9e4c460c8219ee1356080e883a65228976af177f47e1a3924d96d3e45c16ffd12700fed77856b + languageName: node + linkType: hard + +"@aws-sdk/middleware-ssec@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/middleware-ssec@npm:3.956.0" + dependencies: + "@aws-sdk/types": 3.956.0 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: bb4ac5daf7d245abd6ea78a28f9261e3d48323de71d701fe22f4866a27edce28d1a949270a394e1aee8e7aebe9cb4aaf1946668389a897a2337340d6c2a9bcec + languageName: node + linkType: hard + "@aws-sdk/middleware-user-agent@npm:3.926.0": version: 3.926.0 resolution: "@aws-sdk/middleware-user-agent@npm:3.926.0" @@ -883,6 +1311,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-user-agent@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/middleware-user-agent@npm:3.956.0" + dependencies: + "@aws-sdk/core": 3.956.0 + "@aws-sdk/types": 3.956.0 + "@aws-sdk/util-endpoints": 3.956.0 + "@smithy/core": ^3.20.0 + "@smithy/protocol-http": ^5.3.7 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 691575a82ab20a6f82f9fec61aa7ac56ebc30212059858b4b1ef10d062118c5a4b94ea8e339e6448f558351d7f608d567a1ae56528517732209f86ff50b142c5 + languageName: node + linkType: hard + "@aws-sdk/nested-clients@npm:3.926.0": version: 3.926.0 resolution: "@aws-sdk/nested-clients@npm:3.926.0" @@ -975,6 +1418,52 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/nested-clients@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/nested-clients@npm:3.956.0" + dependencies: + "@aws-crypto/sha256-browser": 5.2.0 + "@aws-crypto/sha256-js": 5.2.0 + "@aws-sdk/core": 3.956.0 + "@aws-sdk/middleware-host-header": 3.956.0 + "@aws-sdk/middleware-logger": 3.956.0 + "@aws-sdk/middleware-recursion-detection": 3.956.0 + "@aws-sdk/middleware-user-agent": 3.956.0 + "@aws-sdk/region-config-resolver": 3.956.0 + "@aws-sdk/types": 3.956.0 + "@aws-sdk/util-endpoints": 3.956.0 + "@aws-sdk/util-user-agent-browser": 3.956.0 + "@aws-sdk/util-user-agent-node": 3.956.0 + "@smithy/config-resolver": ^4.4.5 + "@smithy/core": ^3.20.0 + "@smithy/fetch-http-handler": ^5.3.8 + "@smithy/hash-node": ^4.2.7 + "@smithy/invalid-dependency": ^4.2.7 + "@smithy/middleware-content-length": ^4.2.7 + "@smithy/middleware-endpoint": ^4.4.1 + "@smithy/middleware-retry": ^4.4.17 + "@smithy/middleware-serde": ^4.2.8 + "@smithy/middleware-stack": ^4.2.7 + "@smithy/node-config-provider": ^4.3.7 + "@smithy/node-http-handler": ^4.4.7 + "@smithy/protocol-http": ^5.3.7 + "@smithy/smithy-client": ^4.10.2 + "@smithy/types": ^4.11.0 + "@smithy/url-parser": ^4.2.7 + "@smithy/util-base64": ^4.3.0 + "@smithy/util-body-length-browser": ^4.2.0 + "@smithy/util-body-length-node": ^4.2.1 + "@smithy/util-defaults-mode-browser": ^4.3.16 + "@smithy/util-defaults-mode-node": ^4.2.19 + "@smithy/util-endpoints": ^3.2.7 + "@smithy/util-middleware": ^4.2.7 + "@smithy/util-retry": ^4.2.7 + "@smithy/util-utf8": ^4.2.0 + tslib: ^2.6.2 + checksum: fbb51eece8db6f7c4ec9083e4dc24f9f82395ba9a77076cc44a795d7f77d51a55260242a1bdd690b1b193d0923d89ce64ba25e74431256b6cd33a3c9f649edad + languageName: node + linkType: hard + "@aws-sdk/region-config-resolver@npm:3.925.0": version: 3.925.0 resolution: "@aws-sdk/region-config-resolver@npm:3.925.0" @@ -1001,6 +1490,35 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/region-config-resolver@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/region-config-resolver@npm:3.956.0" + dependencies: + "@aws-sdk/types": 3.956.0 + "@smithy/config-resolver": ^4.4.5 + "@smithy/node-config-provider": ^4.3.7 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 120074b132391e04dbee35c53312aaf47ae4320047e7cd34b96da9e3e20f0ebd0fdb885a6e5b4095fb0c7d71ba5ba546553c8be73d8ac869059519a2f82845e8 + languageName: node + linkType: hard + +"@aws-sdk/s3-request-presigner@npm:^3.953.0": + version: 3.956.0 + resolution: "@aws-sdk/s3-request-presigner@npm:3.956.0" + dependencies: + "@aws-sdk/signature-v4-multi-region": 3.956.0 + "@aws-sdk/types": 3.956.0 + "@aws-sdk/util-format-url": 3.956.0 + "@smithy/middleware-endpoint": ^4.4.1 + "@smithy/protocol-http": ^5.3.7 + "@smithy/smithy-client": ^4.10.2 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: ee4217ae93857c249ab3c4c0184265f56c94aea4768e56ba32fdcad6f9e5d065e2794b51f2182e8cc7f4241604455447fdfcafa553fced9ecc32778c3ae47acc + languageName: node + linkType: hard + "@aws-sdk/signature-v4-multi-region@npm:3.926.0": version: 3.926.0 resolution: "@aws-sdk/signature-v4-multi-region@npm:3.926.0" @@ -1015,6 +1533,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/signature-v4-multi-region@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/signature-v4-multi-region@npm:3.956.0" + dependencies: + "@aws-sdk/middleware-sdk-s3": 3.956.0 + "@aws-sdk/types": 3.956.0 + "@smithy/protocol-http": ^5.3.7 + "@smithy/signature-v4": ^5.3.7 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: d29a9e83c051ccac3f4c09e615401d1a19282e5f911b301ee65244deafc6f3df9f74a43e0cac2643999f9a583458cbb2035c5c2308ad639dcc4b06b618c19ff4 + languageName: node + linkType: hard + "@aws-sdk/token-providers@npm:3.926.0": version: 3.926.0 resolution: "@aws-sdk/token-providers@npm:3.926.0" @@ -1045,6 +1577,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/token-providers@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/token-providers@npm:3.956.0" + dependencies: + "@aws-sdk/core": 3.956.0 + "@aws-sdk/nested-clients": 3.956.0 + "@aws-sdk/types": 3.956.0 + "@smithy/property-provider": ^4.2.7 + "@smithy/shared-ini-file-loader": ^4.4.2 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 2218b1ad950db2f2ec38f8db8c11855647d830c73cbfb598cd03f273515031dbbe761c5972518024cb01ab2c5d3ab56636ee9ef9824d95e0d7a38127e6b93be9 + languageName: node + linkType: hard + "@aws-sdk/types@npm:3.922.0": version: 3.922.0 resolution: "@aws-sdk/types@npm:3.922.0" @@ -1075,6 +1622,16 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/types@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/types@npm:3.956.0" + dependencies: + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: b05d3c966fa94f97a60ab754f55ba38f8861487d3427dc88dd0a7df7e6235e8af243459ebdeac40f388acbf747fe705201ef503c2bf2e9f210bdbc8ccda8ecee + languageName: node + linkType: hard + "@aws-sdk/util-arn-parser@npm:3.893.0": version: 3.893.0 resolution: "@aws-sdk/util-arn-parser@npm:3.893.0" @@ -1084,6 +1641,15 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/util-arn-parser@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/util-arn-parser@npm:3.953.0" + dependencies: + tslib: ^2.6.2 + checksum: 2ac01da0bb8945d1ece8885bd74a388b2c53c9bc185540da8c2b7736e3b58927a5c1e032e693088f8b26ef94e42cc85d28aa0809fe47f061b003e952b4ff615d + languageName: node + linkType: hard + "@aws-sdk/util-dynamodb@npm:3.953.0": version: 3.953.0 resolution: "@aws-sdk/util-dynamodb@npm:3.953.0" @@ -1121,6 +1687,31 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/util-endpoints@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/util-endpoints@npm:3.956.0" + dependencies: + "@aws-sdk/types": 3.956.0 + "@smithy/types": ^4.11.0 + "@smithy/url-parser": ^4.2.7 + "@smithy/util-endpoints": ^3.2.7 + tslib: ^2.6.2 + checksum: 2d555c3dbd4bcf71dab4921d7086cb4f1bf15201dcf5dd36223fd4d4d89319770f85b7fd73a415320fc188f95d948fd52b7eb1f420dcfc0c833c9336b268dd35 + languageName: node + linkType: hard + +"@aws-sdk/util-format-url@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/util-format-url@npm:3.956.0" + dependencies: + "@aws-sdk/types": 3.956.0 + "@smithy/querystring-builder": ^4.2.7 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 490eb3d7082ecbaf88b2aafbd65c2a5bcf501e9d529752b0cbe7414aed3454d80d2206aba3ae00b8df622baea7e6a91097f6320eacd92e0b6c00031ec23fd181 + languageName: node + linkType: hard + "@aws-sdk/util-locate-window@npm:^3.0.0": version: 3.893.0 resolution: "@aws-sdk/util-locate-window@npm:3.893.0" @@ -1154,6 +1745,18 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/util-user-agent-browser@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/util-user-agent-browser@npm:3.956.0" + dependencies: + "@aws-sdk/types": 3.956.0 + "@smithy/types": ^4.11.0 + bowser: ^2.11.0 + tslib: ^2.6.2 + checksum: 0326f0d66b84bc2308d2c96b013f480d3cd23842149541061eadae625f4582558179f9ee1fbc8288ee52176bae1c210225627d399d50df8a25d1a9391807f7e5 + languageName: node + linkType: hard + "@aws-sdk/util-user-agent-node@npm:3.926.0": version: 3.926.0 resolution: "@aws-sdk/util-user-agent-node@npm:3.926.0" @@ -1190,6 +1793,24 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/util-user-agent-node@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/util-user-agent-node@npm:3.956.0" + dependencies: + "@aws-sdk/middleware-user-agent": 3.956.0 + "@aws-sdk/types": 3.956.0 + "@smithy/node-config-provider": ^4.3.7 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + peerDependencies: + aws-crt: ">=1.0.0" + peerDependenciesMeta: + aws-crt: + optional: true + checksum: 8e2004be10ee98e9ec0b213222f493c4972a0907b53183f0c5fd116375c9ecc07f8ac877cf97409594811391789102e617e3afffd34b1d67e326618d0e9f58ee + languageName: node + linkType: hard + "@aws-sdk/xml-builder@npm:3.921.0": version: 3.921.0 resolution: "@aws-sdk/xml-builder@npm:3.921.0" @@ -1223,6 +1844,17 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/xml-builder@npm:3.956.0": + version: 3.956.0 + resolution: "@aws-sdk/xml-builder@npm:3.956.0" + dependencies: + "@smithy/types": ^4.11.0 + fast-xml-parser: 5.2.5 + tslib: ^2.6.2 + checksum: 876b78af6c350a6077f8bb8c4a242d22866489d15890260457e51e2f12388d175c7a5e3a4163000633e50163386d7419ef5b28aa32ad59bc482ff945a204430c + languageName: node + linkType: hard + "@aws/lambda-invoke-store@npm:^0.1.1": version: 0.1.1 resolution: "@aws/lambda-invoke-store@npm:0.1.1" @@ -3781,6 +4413,35 @@ __metadata: languageName: node linkType: hard +"@smithy/abort-controller@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/abort-controller@npm:4.2.7" + dependencies: + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 2312577b51ef4a444204ff806b16193b4ba384e6d9f8ac8c5d23d4e6eac12d1234da7faca0680e7c9f65721417277d61f739ecc67d7e87d2da0d675378c25319 + languageName: node + linkType: hard + +"@smithy/chunked-blob-reader-native@npm:^4.2.1": + version: 4.2.1 + resolution: "@smithy/chunked-blob-reader-native@npm:4.2.1" + dependencies: + "@smithy/util-base64": ^4.3.0 + tslib: ^2.6.2 + checksum: 717d0fa7cdf6db30fe1d75d4e5d8bbbd35c1421c59cd4802ac588bbe213e49f7b8af52bbf6f32b77d8c2cc43639a73fc99d83aed3d55046bec68ec54bc1888f8 + languageName: node + linkType: hard + +"@smithy/chunked-blob-reader@npm:^5.2.0": + version: 5.2.0 + resolution: "@smithy/chunked-blob-reader@npm:5.2.0" + dependencies: + tslib: ^2.6.2 + checksum: ce68e3a455ed78aaf5735844d4bb3ab50770ac1210c40a6da01992be37f234fff49fa488aba1b46214888c004db4f3fb4a0fa6a88796b084fc2eb385a5b0deb3 + languageName: node + linkType: hard + "@smithy/config-resolver@npm:^4.4.2, @smithy/config-resolver@npm:^4.4.3, @smithy/config-resolver@npm:^4.4.4": version: 4.4.4 resolution: "@smithy/config-resolver@npm:4.4.4" @@ -3795,6 +4456,20 @@ __metadata: languageName: node linkType: hard +"@smithy/config-resolver@npm:^4.4.5": + version: 4.4.5 + resolution: "@smithy/config-resolver@npm:4.4.5" + dependencies: + "@smithy/node-config-provider": ^4.3.7 + "@smithy/types": ^4.11.0 + "@smithy/util-config-provider": ^4.2.0 + "@smithy/util-endpoints": ^3.2.7 + "@smithy/util-middleware": ^4.2.7 + tslib: ^2.6.2 + checksum: 858b37fecca49d1104915de5932c09e1375f3cebfce505f569b408fe6b23078df4afe35366137a9603376d603586e2df68f015e4440b6406c6eee977fd1c1d13 + languageName: node + linkType: hard + "@smithy/core@npm:^3.17.2, @smithy/core@npm:^3.18.7, @smithy/core@npm:^3.19.0": version: 3.19.0 resolution: "@smithy/core@npm:3.19.0" @@ -3813,6 +4488,24 @@ __metadata: languageName: node linkType: hard +"@smithy/core@npm:^3.20.0": + version: 3.20.0 + resolution: "@smithy/core@npm:3.20.0" + dependencies: + "@smithy/middleware-serde": ^4.2.8 + "@smithy/protocol-http": ^5.3.7 + "@smithy/types": ^4.11.0 + "@smithy/util-base64": ^4.3.0 + "@smithy/util-body-length-browser": ^4.2.0 + "@smithy/util-middleware": ^4.2.7 + "@smithy/util-stream": ^4.5.8 + "@smithy/util-utf8": ^4.2.0 + "@smithy/uuid": ^1.1.0 + tslib: ^2.6.2 + checksum: e4f776b96e756dc087b0ca24334fc20efab0e80a9ff38b457e011baca0491ddac6e71ce58da9cf884d05905d859d923614df0cd30ce6fc08565c504b5c27e901 + languageName: node + linkType: hard + "@smithy/credential-provider-imds@npm:^4.2.4, @smithy/credential-provider-imds@npm:^4.2.5, @smithy/credential-provider-imds@npm:^4.2.6": version: 4.2.6 resolution: "@smithy/credential-provider-imds@npm:4.2.6" @@ -3822,7 +4515,75 @@ __metadata: "@smithy/types": ^4.10.0 "@smithy/url-parser": ^4.2.6 tslib: ^2.6.2 - checksum: 70a1e53f85217e22d2d84d96fe3ad8b29715b255105f3685c6a22d95cfad19ad32e44033ef81aabfcb215e7defec83bf9171180ba0bddbcdef6e5bb301c27eef + checksum: 70a1e53f85217e22d2d84d96fe3ad8b29715b255105f3685c6a22d95cfad19ad32e44033ef81aabfcb215e7defec83bf9171180ba0bddbcdef6e5bb301c27eef + languageName: node + linkType: hard + +"@smithy/credential-provider-imds@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/credential-provider-imds@npm:4.2.7" + dependencies: + "@smithy/node-config-provider": ^4.3.7 + "@smithy/property-provider": ^4.2.7 + "@smithy/types": ^4.11.0 + "@smithy/url-parser": ^4.2.7 + tslib: ^2.6.2 + checksum: 1d5688706f8cd3aad8f0202363257376f585931e077b910acfbb1fd5d555637a74c522dfdf80a5865c374b9caafed0125186cec53d35d464a80c9cb7f9f233a5 + languageName: node + linkType: hard + +"@smithy/eventstream-codec@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/eventstream-codec@npm:4.2.7" + dependencies: + "@aws-crypto/crc32": 5.2.0 + "@smithy/types": ^4.11.0 + "@smithy/util-hex-encoding": ^4.2.0 + tslib: ^2.6.2 + checksum: 9a9c84864f630303ddc2aa1de6901906363f5d703b0212eaa4c7dc5368bd9fffd142fa9d9505700613f07a8149730a9f1f8d59a19702678fb5dae7f21cea6571 + languageName: node + linkType: hard + +"@smithy/eventstream-serde-browser@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/eventstream-serde-browser@npm:4.2.7" + dependencies: + "@smithy/eventstream-serde-universal": ^4.2.7 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: e6393de54e3a4394ac60ba4278910f9179f8b8589bf4d74b2b30d33760567416e789be977250dbc67bb393592765a65face40aa9d7c4cb4a816e82dfc487db05 + languageName: node + linkType: hard + +"@smithy/eventstream-serde-config-resolver@npm:^4.3.7": + version: 4.3.7 + resolution: "@smithy/eventstream-serde-config-resolver@npm:4.3.7" + dependencies: + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: d42ff00eb9453fb1ebf920ab500c2eba3efa5ed1cb10cdd638cfeee164faea5e1c38e1bef8fdcda1ace4eb348bb8b739769c811b218d2547f7e031b742cae019 + languageName: node + linkType: hard + +"@smithy/eventstream-serde-node@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/eventstream-serde-node@npm:4.2.7" + dependencies: + "@smithy/eventstream-serde-universal": ^4.2.7 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 12ebbe494ba2446fbfd7b4491dfdc22297656ac7292cbda7985a01f72d1ae28918749e835ded7a723d32075f69e3cc412ac4f4588d1df53b2b583699b3aecb21 + languageName: node + linkType: hard + +"@smithy/eventstream-serde-universal@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/eventstream-serde-universal@npm:4.2.7" + dependencies: + "@smithy/eventstream-codec": ^4.2.7 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 13111b46ba00d87c7d8296002d0b19a6b1893c221b2e01855cbc557a16cce1b401797a3903ba9c4a8ab84ac99b013c807427087f3f5419cd242c947b6b9b9716 languageName: node linkType: hard @@ -3839,6 +4600,31 @@ __metadata: languageName: node linkType: hard +"@smithy/fetch-http-handler@npm:^5.3.8": + version: 5.3.8 + resolution: "@smithy/fetch-http-handler@npm:5.3.8" + dependencies: + "@smithy/protocol-http": ^5.3.7 + "@smithy/querystring-builder": ^4.2.7 + "@smithy/types": ^4.11.0 + "@smithy/util-base64": ^4.3.0 + tslib: ^2.6.2 + checksum: 434434093459604ea9a3bdfe9b11ecb5fa85200a1eff42b32317e8a5c580a3757bff41b49a17ed9e7848a2a0a5eb0d8e91c0d0076ad895aaac80e5ea5a015f74 + languageName: node + linkType: hard + +"@smithy/hash-blob-browser@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/hash-blob-browser@npm:4.2.8" + dependencies: + "@smithy/chunked-blob-reader": ^5.2.0 + "@smithy/chunked-blob-reader-native": ^4.2.1 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 5b0f2dcc03d1413767f0031ca7f448e3d1df468028d346a784a37c7e0f5e2c0670cdf4684cfcac8a164f3d43212fc4baa9bb53aae0cb3bdca1a2461e45c42f7a + languageName: node + linkType: hard + "@smithy/hash-node@npm:^4.2.4, @smithy/hash-node@npm:^4.2.5": version: 4.2.6 resolution: "@smithy/hash-node@npm:4.2.6" @@ -3851,6 +4637,29 @@ __metadata: languageName: node linkType: hard +"@smithy/hash-node@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/hash-node@npm:4.2.7" + dependencies: + "@smithy/types": ^4.11.0 + "@smithy/util-buffer-from": ^4.2.0 + "@smithy/util-utf8": ^4.2.0 + tslib: ^2.6.2 + checksum: f2d646f8366f6ed98d930c25315d7e9da671ac1ec2fdf8a0b999605e907ad37e01d1b4d2a69b04eda296be09aea8cbe57e88a8f4a3b3aaa125933d3b988e4be8 + languageName: node + linkType: hard + +"@smithy/hash-stream-node@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/hash-stream-node@npm:4.2.7" + dependencies: + "@smithy/types": ^4.11.0 + "@smithy/util-utf8": ^4.2.0 + tslib: ^2.6.2 + checksum: abc13f5fb15984bbfa58dfa4f71937ddbb9d9c90e09ee416ab2214bbeab642b070a74d037147f833ca8c4ddf68de0256edf4f5101e2d4b997e613d801fa8b277 + languageName: node + linkType: hard + "@smithy/invalid-dependency@npm:^4.2.4, @smithy/invalid-dependency@npm:^4.2.5": version: 4.2.6 resolution: "@smithy/invalid-dependency@npm:4.2.6" @@ -3861,6 +4670,16 @@ __metadata: languageName: node linkType: hard +"@smithy/invalid-dependency@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/invalid-dependency@npm:4.2.7" + dependencies: + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 1c3d82804779fd1cb7dd56f03c0ada0d21c2656b55338b56e91bdb4b96de57a3a7c8f940c627b46bb126175305112d3a386dfb78dd3b1e10b42d3c672a8ed802 + languageName: node + linkType: hard + "@smithy/is-array-buffer@npm:^2.2.0": version: 2.2.0 resolution: "@smithy/is-array-buffer@npm:2.2.0" @@ -3879,6 +4698,17 @@ __metadata: languageName: node linkType: hard +"@smithy/md5-js@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/md5-js@npm:4.2.7" + dependencies: + "@smithy/types": ^4.11.0 + "@smithy/util-utf8": ^4.2.0 + tslib: ^2.6.2 + checksum: c5861fc093c35f1230b208b3c4c44a97ef48b7c9c8e3853f689f8a1d6c0147e4be75bed546e28ecbb6f92bf65844642d973148b93b43e55dc4b258de33bad6ec + languageName: node + linkType: hard + "@smithy/middleware-content-length@npm:^4.2.4, @smithy/middleware-content-length@npm:^4.2.5": version: 4.2.6 resolution: "@smithy/middleware-content-length@npm:4.2.6" @@ -3890,6 +4720,17 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-content-length@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/middleware-content-length@npm:4.2.7" + dependencies: + "@smithy/protocol-http": ^5.3.7 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 3a749b56a53774bc0c13e5f48c6c7353948968a4b72df3a75a92d66909bfc72e55f886a611d62a714789559d2d62e0fa455fe8ca9a12526c9e415cf4a65351d3 + languageName: node + linkType: hard + "@smithy/middleware-endpoint@npm:^4.3.14, @smithy/middleware-endpoint@npm:^4.3.6, @smithy/middleware-endpoint@npm:^4.4.0": version: 4.4.0 resolution: "@smithy/middleware-endpoint@npm:4.4.0" @@ -3906,6 +4747,22 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-endpoint@npm:^4.4.1": + version: 4.4.1 + resolution: "@smithy/middleware-endpoint@npm:4.4.1" + dependencies: + "@smithy/core": ^3.20.0 + "@smithy/middleware-serde": ^4.2.8 + "@smithy/node-config-provider": ^4.3.7 + "@smithy/shared-ini-file-loader": ^4.4.2 + "@smithy/types": ^4.11.0 + "@smithy/url-parser": ^4.2.7 + "@smithy/util-middleware": ^4.2.7 + tslib: ^2.6.2 + checksum: 1f06c07294c4b1fdc4ae145bc0698762dc1db823c66ef936f70e00eeb3194e3c9bcd38d95739ebed17e514a53fb7e30174f1dfae005851153a2068f722216d1a + languageName: node + linkType: hard + "@smithy/middleware-retry@npm:^4.4.14, @smithy/middleware-retry@npm:^4.4.6": version: 4.4.16 resolution: "@smithy/middleware-retry@npm:4.4.16" @@ -3923,6 +4780,23 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-retry@npm:^4.4.17": + version: 4.4.17 + resolution: "@smithy/middleware-retry@npm:4.4.17" + dependencies: + "@smithy/node-config-provider": ^4.3.7 + "@smithy/protocol-http": ^5.3.7 + "@smithy/service-error-classification": ^4.2.7 + "@smithy/smithy-client": ^4.10.2 + "@smithy/types": ^4.11.0 + "@smithy/util-middleware": ^4.2.7 + "@smithy/util-retry": ^4.2.7 + "@smithy/uuid": ^1.1.0 + tslib: ^2.6.2 + checksum: 5d249f317ca20d8e781ba360d9572b65ba1620055510d756d78e2e6a572a4ff125f65d722076f4fc140fae3121d97b3685d935d90588d0b92ce4464b8ca75475 + languageName: node + linkType: hard + "@smithy/middleware-serde@npm:^4.2.4, @smithy/middleware-serde@npm:^4.2.6, @smithy/middleware-serde@npm:^4.2.7": version: 4.2.7 resolution: "@smithy/middleware-serde@npm:4.2.7" @@ -3934,6 +4808,17 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-serde@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/middleware-serde@npm:4.2.8" + dependencies: + "@smithy/protocol-http": ^5.3.7 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 136a6264361888729368587699d1f8c2fe6976530ac9947af77456921c919b95fa739f4658fb51d49b9e2d109c7c793e3e79b9d824066b0b5b0605fbcca85950 + languageName: node + linkType: hard + "@smithy/middleware-stack@npm:^4.2.4, @smithy/middleware-stack@npm:^4.2.5, @smithy/middleware-stack@npm:^4.2.6": version: 4.2.6 resolution: "@smithy/middleware-stack@npm:4.2.6" @@ -3944,6 +4829,16 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-stack@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/middleware-stack@npm:4.2.7" + dependencies: + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: b4a37250dee7c5163cff0520f6ddfb6d38fd2af0a9c7de70139cb6f4ca009ca18467c2ba41e477997caa36d083ce2429092c8b0a7e1c4133a6377497534a992a + languageName: node + linkType: hard + "@smithy/node-config-provider@npm:^4.3.4, @smithy/node-config-provider@npm:^4.3.5, @smithy/node-config-provider@npm:^4.3.6": version: 4.3.6 resolution: "@smithy/node-config-provider@npm:4.3.6" @@ -3956,6 +4851,18 @@ __metadata: languageName: node linkType: hard +"@smithy/node-config-provider@npm:^4.3.7": + version: 4.3.7 + resolution: "@smithy/node-config-provider@npm:4.3.7" + dependencies: + "@smithy/property-provider": ^4.2.7 + "@smithy/shared-ini-file-loader": ^4.4.2 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: be6827951670d095a593fdf0721ebc091d31dc67e3343bb122617bbfb65bf3e2d30a5ae5a2806f1b0cd0507d491a0747eefbde20ae1fea95cf420c0801c1d614 + languageName: node + linkType: hard + "@smithy/node-http-handler@npm:^4.4.4, @smithy/node-http-handler@npm:^4.4.5, @smithy/node-http-handler@npm:^4.4.6": version: 4.4.6 resolution: "@smithy/node-http-handler@npm:4.4.6" @@ -3969,6 +4876,19 @@ __metadata: languageName: node linkType: hard +"@smithy/node-http-handler@npm:^4.4.7": + version: 4.4.7 + resolution: "@smithy/node-http-handler@npm:4.4.7" + dependencies: + "@smithy/abort-controller": ^4.2.7 + "@smithy/protocol-http": ^5.3.7 + "@smithy/querystring-builder": ^4.2.7 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: a03e8543414e794ca95eeaec19048a7f9354cb57ed0a7ccfd1e5c90bc06a36a9c55db128ce50cf7944a885c412a445cc9868005a6e0ee2aa8b7b0fcc02770c33 + languageName: node + linkType: hard + "@smithy/property-provider@npm:^4.2.4, @smithy/property-provider@npm:^4.2.5, @smithy/property-provider@npm:^4.2.6": version: 4.2.6 resolution: "@smithy/property-provider@npm:4.2.6" @@ -3979,6 +4899,16 @@ __metadata: languageName: node linkType: hard +"@smithy/property-provider@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/property-provider@npm:4.2.7" + dependencies: + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 76a33c70caa86fd72d7ae487a91b1abf5384e7c2c562293ebad4279d89102e84a36d071bcabcd3aabf82174e2aa3407f651d1680cea04b23d841bd3bd8c6aa3c + languageName: node + linkType: hard + "@smithy/protocol-http@npm:^5.3.4, @smithy/protocol-http@npm:^5.3.5, @smithy/protocol-http@npm:^5.3.6": version: 5.3.6 resolution: "@smithy/protocol-http@npm:5.3.6" @@ -3989,6 +4919,16 @@ __metadata: languageName: node linkType: hard +"@smithy/protocol-http@npm:^5.3.7": + version: 5.3.7 + resolution: "@smithy/protocol-http@npm:5.3.7" + dependencies: + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 6d7f1cf78e44d67c5e8552173cd8e087b1f96d08b869966e593717af97d1373d8bd03c5d3d6346ebe08502ecc8f684889682519deba6e287179353271d006f4b + languageName: node + linkType: hard + "@smithy/querystring-builder@npm:^4.2.6": version: 4.2.6 resolution: "@smithy/querystring-builder@npm:4.2.6" @@ -4000,6 +4940,17 @@ __metadata: languageName: node linkType: hard +"@smithy/querystring-builder@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/querystring-builder@npm:4.2.7" + dependencies: + "@smithy/types": ^4.11.0 + "@smithy/util-uri-escape": ^4.2.0 + tslib: ^2.6.2 + checksum: 479bddfc80ff2c759e173528fd0e18842dcd858da7980eed3c3958fefa63b803f795eb794fa3a07c5cda4cb27bd196536ace61eab3fb7d361e7692fe5db14935 + languageName: node + linkType: hard + "@smithy/querystring-parser@npm:^4.2.6": version: 4.2.6 resolution: "@smithy/querystring-parser@npm:4.2.6" @@ -4010,6 +4961,16 @@ __metadata: languageName: node linkType: hard +"@smithy/querystring-parser@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/querystring-parser@npm:4.2.7" + dependencies: + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: e4b89cdcc9ffa90d6db151d63ca2dba8cb92e52edb568eef653f47153877a48597bff228c474ff1b5b98697533ea336b053c8fdea26b8a09f4105689f0879bbd + languageName: node + linkType: hard + "@smithy/service-error-classification@npm:^4.2.6": version: 4.2.6 resolution: "@smithy/service-error-classification@npm:4.2.6" @@ -4019,6 +4980,15 @@ __metadata: languageName: node linkType: hard +"@smithy/service-error-classification@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/service-error-classification@npm:4.2.7" + dependencies: + "@smithy/types": ^4.11.0 + checksum: b7dfc1cbc20e53ee669fd64ce77e0a6a89c866f9b107cd995c7b40a54a72267d4f0de2ccc53f4de5d8fa028f9b42a4bab847ef353aae06ec35f213b5878a8313 + languageName: node + linkType: hard + "@smithy/shared-ini-file-loader@npm:^4.3.4, @smithy/shared-ini-file-loader@npm:^4.4.0, @smithy/shared-ini-file-loader@npm:^4.4.1": version: 4.4.1 resolution: "@smithy/shared-ini-file-loader@npm:4.4.1" @@ -4029,6 +4999,16 @@ __metadata: languageName: node linkType: hard +"@smithy/shared-ini-file-loader@npm:^4.4.2": + version: 4.4.2 + resolution: "@smithy/shared-ini-file-loader@npm:4.4.2" + dependencies: + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 60c2550a430c6b69a0309788adb4c79d852a4ed62f201956f87ebfb6ce22b9457782f42270e5f3dfeeb9ea43e7c1e9e3120bdd91ddffb275bb4aaa9172acb394 + languageName: node + linkType: hard + "@smithy/signature-v4@npm:^5.3.4, @smithy/signature-v4@npm:^5.3.5, @smithy/signature-v4@npm:^5.3.6": version: 5.3.6 resolution: "@smithy/signature-v4@npm:5.3.6" @@ -4045,6 +5025,22 @@ __metadata: languageName: node linkType: hard +"@smithy/signature-v4@npm:^5.3.7": + version: 5.3.7 + resolution: "@smithy/signature-v4@npm:5.3.7" + dependencies: + "@smithy/is-array-buffer": ^4.2.0 + "@smithy/protocol-http": ^5.3.7 + "@smithy/types": ^4.11.0 + "@smithy/util-hex-encoding": ^4.2.0 + "@smithy/util-middleware": ^4.2.7 + "@smithy/util-uri-escape": ^4.2.0 + "@smithy/util-utf8": ^4.2.0 + tslib: ^2.6.2 + checksum: e4fdb32a4f9e4e886e4f0d1c830a1823e175c31858852a8e24d5d2f8718a50aad4b2ec0bac40f01bfddf63d6c89c08b09df52e83469b3b64c03cde5780b69093 + languageName: node + linkType: hard + "@smithy/smithy-client@npm:^4.10.0, @smithy/smithy-client@npm:^4.10.1, @smithy/smithy-client@npm:^4.9.10, @smithy/smithy-client@npm:^4.9.2": version: 4.10.1 resolution: "@smithy/smithy-client@npm:4.10.1" @@ -4060,6 +5056,21 @@ __metadata: languageName: node linkType: hard +"@smithy/smithy-client@npm:^4.10.2": + version: 4.10.2 + resolution: "@smithy/smithy-client@npm:4.10.2" + dependencies: + "@smithy/core": ^3.20.0 + "@smithy/middleware-endpoint": ^4.4.1 + "@smithy/middleware-stack": ^4.2.7 + "@smithy/protocol-http": ^5.3.7 + "@smithy/types": ^4.11.0 + "@smithy/util-stream": ^4.5.8 + tslib: ^2.6.2 + checksum: 63617a87100635c0c898277a72389e5b593d6799d9d230ca8c1abfe171756fbc7d917d6701618d4e3b7d2d09760d74c38f93845cd0876cf88f6a50221ea93295 + languageName: node + linkType: hard + "@smithy/types@npm:^4.10.0, @smithy/types@npm:^4.8.1, @smithy/types@npm:^4.9.0": version: 4.10.0 resolution: "@smithy/types@npm:4.10.0" @@ -4069,6 +5080,15 @@ __metadata: languageName: node linkType: hard +"@smithy/types@npm:^4.11.0": + version: 4.11.0 + resolution: "@smithy/types@npm:4.11.0" + dependencies: + tslib: ^2.6.2 + checksum: f2e59add0aeb24380ff4e1c7abbbe9dd6f6ceb6e16a33803807709be75205389b26500a0d3e9307419bd6def263654752999a2873f33fce2370a6dd52744f75f + languageName: node + linkType: hard + "@smithy/url-parser@npm:^4.2.4, @smithy/url-parser@npm:^4.2.5, @smithy/url-parser@npm:^4.2.6": version: 4.2.6 resolution: "@smithy/url-parser@npm:4.2.6" @@ -4080,6 +5100,17 @@ __metadata: languageName: node linkType: hard +"@smithy/url-parser@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/url-parser@npm:4.2.7" + dependencies: + "@smithy/querystring-parser": ^4.2.7 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: ec867e7b8889dde798c8f7c55caa530877d8fa4f7dade5ecb8d3971c34d938d2289ec1cd8ebec0ab4943602a6046eb7616e709cc857d6c3747ae8f19b9d9f429 + languageName: node + linkType: hard + "@smithy/util-base64@npm:^4.3.0": version: 4.3.0 resolution: "@smithy/util-base64@npm:4.3.0" @@ -4150,6 +5181,18 @@ __metadata: languageName: node linkType: hard +"@smithy/util-defaults-mode-browser@npm:^4.3.16": + version: 4.3.16 + resolution: "@smithy/util-defaults-mode-browser@npm:4.3.16" + dependencies: + "@smithy/property-provider": ^4.2.7 + "@smithy/smithy-client": ^4.10.2 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: bd7b548d9f742437aaaaa9d026882aa0ba8435640f88d4343e4d8971ce682e76e394bd278a7a5fdfb4a41d54b261b3850a28e1c19640371d9bff4808755d373b + languageName: node + linkType: hard + "@smithy/util-defaults-mode-node@npm:^4.2.16, @smithy/util-defaults-mode-node@npm:^4.2.8": version: 4.2.18 resolution: "@smithy/util-defaults-mode-node@npm:4.2.18" @@ -4165,6 +5208,21 @@ __metadata: languageName: node linkType: hard +"@smithy/util-defaults-mode-node@npm:^4.2.19": + version: 4.2.19 + resolution: "@smithy/util-defaults-mode-node@npm:4.2.19" + dependencies: + "@smithy/config-resolver": ^4.4.5 + "@smithy/credential-provider-imds": ^4.2.7 + "@smithy/node-config-provider": ^4.3.7 + "@smithy/property-provider": ^4.2.7 + "@smithy/smithy-client": ^4.10.2 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: fdb5fdbe2a9cbd8e1212b4b0ddb2ef8a574b4fe3af390c688c5a3951f83f41b09b4893b60efad69f5a727afa9d0c9526232c768ea1c5f8cead3ace7c80803de4 + languageName: node + linkType: hard + "@smithy/util-endpoints@npm:^3.2.4, @smithy/util-endpoints@npm:^3.2.5, @smithy/util-endpoints@npm:^3.2.6": version: 3.2.6 resolution: "@smithy/util-endpoints@npm:3.2.6" @@ -4176,6 +5234,17 @@ __metadata: languageName: node linkType: hard +"@smithy/util-endpoints@npm:^3.2.7": + version: 3.2.7 + resolution: "@smithy/util-endpoints@npm:3.2.7" + dependencies: + "@smithy/node-config-provider": ^4.3.7 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 192d07f63daec53ae9c036a2535e429f2f97996c50fbe034fc4f589c810f5a141cf92b91339aa3a8026884a3fbe18924def4a0319d95cfefbc1683fad8b18948 + languageName: node + linkType: hard + "@smithy/util-hex-encoding@npm:^4.2.0": version: 4.2.0 resolution: "@smithy/util-hex-encoding@npm:4.2.0" @@ -4195,6 +5264,16 @@ __metadata: languageName: node linkType: hard +"@smithy/util-middleware@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/util-middleware@npm:4.2.7" + dependencies: + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: cd78d30f1b7a3d643709c01dc194eeae6e253ea25d3ce10ed957daf6cda272ce817c84a5ffae2a6462c81c71a4623bd9ae98c9db237898a54289d6ad0475c307 + languageName: node + linkType: hard + "@smithy/util-retry@npm:^4.2.4, @smithy/util-retry@npm:^4.2.5, @smithy/util-retry@npm:^4.2.6": version: 4.2.6 resolution: "@smithy/util-retry@npm:4.2.6" @@ -4206,6 +5285,17 @@ __metadata: languageName: node linkType: hard +"@smithy/util-retry@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/util-retry@npm:4.2.7" + dependencies: + "@smithy/service-error-classification": ^4.2.7 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: c46d1758706568309ff1498371f2441222280bf89930c3f3b31f900f91b581baff0b546b946e7fcf4b041aa0f0521f216f1af5c027753735ba0b122d587edb9c + languageName: node + linkType: hard + "@smithy/util-stream@npm:^4.5.5, @smithy/util-stream@npm:^4.5.6, @smithy/util-stream@npm:^4.5.7": version: 4.5.7 resolution: "@smithy/util-stream@npm:4.5.7" @@ -4222,6 +5312,22 @@ __metadata: languageName: node linkType: hard +"@smithy/util-stream@npm:^4.5.8": + version: 4.5.8 + resolution: "@smithy/util-stream@npm:4.5.8" + dependencies: + "@smithy/fetch-http-handler": ^5.3.8 + "@smithy/node-http-handler": ^4.4.7 + "@smithy/types": ^4.11.0 + "@smithy/util-base64": ^4.3.0 + "@smithy/util-buffer-from": ^4.2.0 + "@smithy/util-hex-encoding": ^4.2.0 + "@smithy/util-utf8": ^4.2.0 + tslib: ^2.6.2 + checksum: c8cb714e9cab5f1b04b9058be3c962fbfb38d13b2fb6e81625326ba96044e4d0da5530a8c96b170cd5dc4730ba199418ce313282505a687e69121d4f6da23b29 + languageName: node + linkType: hard + "@smithy/util-uri-escape@npm:^4.2.0": version: 4.2.0 resolution: "@smithy/util-uri-escape@npm:4.2.0" @@ -4262,6 +5368,17 @@ __metadata: languageName: node linkType: hard +"@smithy/util-waiter@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/util-waiter@npm:4.2.7" + dependencies: + "@smithy/abort-controller": ^4.2.7 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 97fd4cc65a28b3adfbdf9347a1fd661d5be0bc9df10fc664360da04c73b6e1b16e154da7d148c9f80893d09315af344d7e41512f3ecca187381e0564b71adb25 + languageName: node + linkType: hard + "@smithy/uuid@npm:^1.1.0": version: 1.1.0 resolution: "@smithy/uuid@npm:1.1.0" @@ -5357,6 +6474,15 @@ __metadata: languageName: node linkType: hard +"ansi-escapes@npm:^7.0.0": + version: 7.2.0 + resolution: "ansi-escapes@npm:7.2.0" + dependencies: + environment: ^1.0.0 + checksum: d490871d4107dc6d4b0f2d0eaddbe3e4681d584c58495f06fe2908f2707bbaaf54825f485c5a6ce14c2d2a731fa382b18d7235a102db69aeff5dd9461397cb44 + languageName: node + linkType: hard + "ansi-regex@npm:^5.0.1": version: 5.0.1 resolution: "ansi-regex@npm:5.0.1" @@ -5642,7 +6768,9 @@ __metadata: dependencies: "@amplitude/node": 1.10.2 "@ava/typescript": 6.0.0 + "@aws-sdk/client-s3": ^3.953.0 "@aws-sdk/lib-dynamodb": ^3.953.0 + "@aws-sdk/s3-request-presigner": ^3.953.0 "@electric-sql/pglite": ^0.3.14 "@faker-js/faker": ^10.1.0 "@nestjs/cli": ^11.0.14 @@ -6412,6 +7540,16 @@ __metadata: languageName: node linkType: hard +"cli-truncate@npm:^5.0.0": + version: 5.1.1 + resolution: "cli-truncate@npm:5.1.1" + dependencies: + slice-ansi: ^7.1.0 + string-width: ^8.0.0 + checksum: 994262b5fc8691657a06aeb352d4669354252989e5333b5fc74beb5cec75ceaad9de779864e68e6a1fe83b7cca04fa35eb505de67b20668896880728876631ba + languageName: node + linkType: hard + "cli-width@npm:^4.1.0": version: 4.1.0 resolution: "cli-width@npm:4.1.0" @@ -6522,6 +7660,13 @@ __metadata: languageName: node linkType: hard +"colorette@npm:^2.0.20": + version: 2.0.20 + resolution: "colorette@npm:2.0.20" + checksum: 0c016fea2b91b733eb9f4bcdb580018f52c0bc0979443dad930e5037a968237ac53d9beb98e218d2e9235834f8eebce7f8e080422d6194e957454255bde71d3d + languageName: node + linkType: hard + "combined-stream@npm:^1.0.8": version: 1.0.8 resolution: "combined-stream@npm:1.0.8" @@ -7285,6 +8430,13 @@ __metadata: languageName: node linkType: hard +"environment@npm:^1.0.0": + version: 1.1.0 + resolution: "environment@npm:1.1.0" + checksum: dd3c1b9825e7f71f1e72b03c2344799ac73f2e9ef81b78ea8b373e55db021786c6b9f3858ea43a436a2c4611052670ec0afe85bc029c384cc71165feee2f4ba6 + languageName: node + linkType: hard + "err-code@npm:^2.0.2": version: 2.0.3 resolution: "err-code@npm:2.0.3" @@ -8301,7 +9453,7 @@ __metadata: languageName: node linkType: hard -"get-east-asian-width@npm:^1.0.0, get-east-asian-width@npm:^1.3.0": +"get-east-asian-width@npm:^1.0.0, get-east-asian-width@npm:^1.3.0, get-east-asian-width@npm:^1.3.1": version: 1.4.0 resolution: "get-east-asian-width@npm:1.4.0" checksum: 1d9a81a8004f4217ebef5d461875047d269e4b57e039558fd65130877cd4da8e3f61e1c4eada0c8b10e2816c7baf7d5fddb7006f561da13bc6f6dd19c1e964a4 @@ -8608,6 +9760,15 @@ __metadata: languageName: node linkType: hard +"husky@npm:^9.1.7": + version: 9.1.7 + resolution: "husky@npm:9.1.7" + bin: + husky: bin.js + checksum: c2412753f15695db369634ba70f50f5c0b7e5cb13b673d0826c411ec1bd9ddef08c1dad89ea154f57da2521d2605bd64308af748749b27d08c5f563bcd89975f + languageName: node + linkType: hard + "i18n-iso-countries@npm:^7.14.0": version: 7.14.0 resolution: "i18n-iso-countries@npm:7.14.0" @@ -8924,6 +10085,15 @@ __metadata: languageName: node linkType: hard +"is-fullwidth-code-point@npm:^5.0.0": + version: 5.1.0 + resolution: "is-fullwidth-code-point@npm:5.1.0" + dependencies: + get-east-asian-width: ^1.3.1 + checksum: 4700d8a82cb71bd2a2955587b2823c36dc4660eadd4047bfbd070821ddbce8504fc5f9b28725567ecddf405b1e06c6692c9b719f65df6af9ec5262bc11393a6a + languageName: node + linkType: hard + "is-generator-function@npm:^1.0.10": version: 1.1.2 resolution: "is-generator-function@npm:1.1.2" @@ -9545,6 +10715,37 @@ __metadata: languageName: node linkType: hard +"lint-staged@npm:^16.2.7": + version: 16.2.7 + resolution: "lint-staged@npm:16.2.7" + dependencies: + commander: ^14.0.2 + listr2: ^9.0.5 + micromatch: ^4.0.8 + nano-spawn: ^2.0.0 + pidtree: ^0.6.0 + string-argv: ^0.3.2 + yaml: ^2.8.1 + bin: + lint-staged: bin/lint-staged.js + checksum: fbf234787ed1f6fe9e754c03ef64571ef09d68e4ebce21141a85972e5b7c00fee9d00a4c892db3c00ddc22e8b0c337d757734a4df4848661469b7591f14cbc24 + languageName: node + linkType: hard + +"listr2@npm:^9.0.5": + version: 9.0.5 + resolution: "listr2@npm:9.0.5" + dependencies: + cli-truncate: ^5.0.0 + colorette: ^2.0.20 + eventemitter3: ^5.0.1 + log-update: ^6.1.0 + rfdc: ^1.4.1 + wrap-ansi: ^9.0.0 + checksum: 64ef0dcd6f69e131f5699f584c13096d828a747472d4a18e68a9418848a54aef86bf953bf4a8a03a3dff24dacf771fab919dad59dce115244308a06d147acbfa + languageName: node + linkType: hard + "load-esm@npm:1.0.3": version: 1.0.3 resolution: "load-esm@npm:1.0.3" @@ -9681,6 +10882,19 @@ __metadata: languageName: node linkType: hard +"log-update@npm:^6.1.0": + version: 6.1.0 + resolution: "log-update@npm:6.1.0" + dependencies: + ansi-escapes: ^7.0.0 + cli-cursor: ^5.0.0 + slice-ansi: ^7.1.0 + strip-ansi: ^7.1.0 + wrap-ansi: ^9.0.0 + checksum: 817a9ba6c5cbc19e94d6359418df8cfe8b3244a2903f6d53354e175e243a85b782dc6a98db8b5e457ee2f09542ca8916c39641b9cd3b0e6ef45e9481d50c918a + languageName: node + linkType: hard + "logform@npm:^2.7.0": version: 2.7.0 resolution: "logform@npm:2.7.0" @@ -10276,6 +11490,13 @@ __metadata: languageName: node linkType: hard +"nano-spawn@npm:^2.0.0": + version: 2.0.0 + resolution: "nano-spawn@npm:2.0.0" + checksum: 48223f6f5b2ab3f7562d0e6e85abb9b9c4ee52f5c096bd50012c0776664ddc63cd2602c9e4dc3d6cd47ae5189e9c71b51afb2d44b2746d8ca80cb52fc8013904 + languageName: node + linkType: hard + "nanoid@npm:5.1.6, nanoid@npm:^5.1.6": version: 5.1.6 resolution: "nanoid@npm:5.1.6" @@ -11143,6 +12364,15 @@ __metadata: languageName: node linkType: hard +"pidtree@npm:^0.6.0": + version: 0.6.0 + resolution: "pidtree@npm:0.6.0" + bin: + pidtree: bin/pidtree.js + checksum: 8fbc073ede9209dd15e80d616e65eb674986c93be49f42d9ddde8dbbd141bb53d628a7ca4e58ab5c370bb00383f67d75df59a9a226dede8fa801267a7030c27a + languageName: node + linkType: hard + "plur@npm:^5.1.0": version: 5.1.0 resolution: "plur@npm:5.1.0" @@ -11626,6 +12856,13 @@ __metadata: languageName: node linkType: hard +"rfdc@npm:^1.4.1": + version: 1.4.1 + resolution: "rfdc@npm:1.4.1" + checksum: 3b05bd55062c1d78aaabfcea43840cdf7e12099968f368e9a4c3936beb744adb41cbdb315eac6d4d8c6623005d6f87fdf16d8a10e1ff3722e84afea7281c8d13 + languageName: node + linkType: hard + "rimraf@npm:6.1.2, rimraf@npm:^6.1.2": version: 6.1.2 resolution: "rimraf@npm:6.1.2" @@ -11689,6 +12926,8 @@ __metadata: resolution: "root@workspace:." dependencies: "@biomejs/biome": 2.3.8 + husky: ^9.1.7 + lint-staged: ^16.2.7 monaco-editor: ^0.53.0 languageName: unknown linkType: soft @@ -12097,6 +13336,16 @@ __metadata: languageName: node linkType: hard +"slice-ansi@npm:^7.1.0": + version: 7.1.2 + resolution: "slice-ansi@npm:7.1.2" + dependencies: + ansi-styles: ^6.2.1 + is-fullwidth-code-point: ^5.0.0 + checksum: 75f61e1285c294b18c88521a0cdb22cdcbe9b0fd5e8e26f649be804cc43122aa7751bd960a968e3ed7f5aa7f3c67ac605c939019eae916870ec288e878b6fafb + languageName: node + linkType: hard + "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" @@ -12310,6 +13559,13 @@ __metadata: languageName: node linkType: hard +"string-argv@npm:^0.3.2": + version: 0.3.2 + resolution: "string-argv@npm:0.3.2" + checksum: 8703ad3f3db0b2641ed2adbb15cf24d3945070d9a751f9e74a924966db9f325ac755169007233e8985a39a6a292f14d4fee20482989b89b96e473c4221508a0f + languageName: node + linkType: hard + "string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" @@ -12343,7 +13599,7 @@ __metadata: languageName: node linkType: hard -"string-width@npm:^8.1.0": +"string-width@npm:^8.0.0, string-width@npm:^8.1.0": version: 8.1.0 resolution: "string-width@npm:8.1.0" dependencies: @@ -13662,7 +14918,7 @@ __metadata: languageName: node linkType: hard -"wrap-ansi@npm:^9.0.2": +"wrap-ansi@npm:^9.0.0, wrap-ansi@npm:^9.0.2": version: 9.0.2 resolution: "wrap-ansi@npm:9.0.2" dependencies: @@ -13749,6 +15005,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.8.1": + version: 2.8.2 + resolution: "yaml@npm:2.8.2" + bin: + yaml: bin.mjs + checksum: 5ffd9f23bc7a450129cbd49dcf91418988f154ede10c83fd28ab293661ac2783c05da19a28d76a22cbd77828eae25d4bd7453f9a9fe2d287d085d72db46fd105 + languageName: node + linkType: hard + "yargs-parser@npm:21.1.1, yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" From 7e87bcc68fa2fde81d980904364fc40155a0ca61 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Mon, 22 Dec 2025 14:15:35 +0000 Subject: [PATCH 3/5] tests fix --- .../utils/validate-create-widgets-ds.ts | 251 ++++--- .../non-saas-s3-widget-e2e.test.ts | 677 ++++++++++++++++++ docker-compose.tst.yml | 33 - justfile | 2 +- 4 files changed, 826 insertions(+), 137 deletions(-) create mode 100644 backend/test/ava-tests/non-saas-tests/non-saas-s3-widget-e2e.test.ts diff --git a/backend/src/entities/widget/utils/validate-create-widgets-ds.ts b/backend/src/entities/widget/utils/validate-create-widgets-ds.ts index 0a2697572..91ffaba7e 100644 --- a/backend/src/entities/widget/utils/validate-create-widgets-ds.ts +++ b/backend/src/entities/widget/utils/validate-create-widgets-ds.ts @@ -1,114 +1,159 @@ -import { EncryptionAlgorithmEnum, WidgetTypeEnum } from '../../../enums/index.js'; -import { Messages } from '../../../exceptions/text/messages.js'; -import { getPropertyValueByDescriptor } from '../../../helpers/index.js'; -import { Constants } from '../../../helpers/constants/constants.js'; -import { ConnectionEntity } from '../../connection/connection.entity.js'; -import { ForeignKeyDSInfo } from '../../table/table-datastructures.js'; -import { findTableFieldsUtil } from '../../table/utils/find-table-fields.util.js'; -import { findTablesInConnectionUtil } from '../../table/utils/find-tables-in-connection.util.js'; -import { CreateTableWidgetDs } from '../application/data-sctructures/create-table-widgets.ds.js'; -import JSON5 from 'json5'; +import JSON5 from "json5"; +import { + EncryptionAlgorithmEnum, + WidgetTypeEnum, +} from "../../../enums/index.js"; +import { Messages } from "../../../exceptions/text/messages.js"; +import { Constants } from "../../../helpers/constants/constants.js"; +import { getPropertyValueByDescriptor } from "../../../helpers/index.js"; +import { ConnectionEntity } from "../../connection/connection.entity.js"; +import { ForeignKeyDSInfo } from "../../table/table-datastructures.js"; +import { findTableFieldsUtil } from "../../table/utils/find-table-fields.util.js"; +import { findTablesInConnectionUtil } from "../../table/utils/find-tables-in-connection.util.js"; +import { CreateTableWidgetDs } from "../application/data-sctructures/create-table-widgets.ds.js"; export async function validateCreateWidgetsDs( - widgetsDS: Array, - userId: string, - connection: ConnectionEntity, - tableName: string, - userEmail: string, + widgetsDS: Array, + userId: string, + connection: ConnectionEntity, + tableName: string, + userEmail: string, ): Promise> { - const errors = []; - const availableTablesInConnection = await findTablesInConnectionUtil(connection, userId, userEmail); - if (!availableTablesInConnection.includes(tableName)) { - errors.push(Messages.TABLE_NOT_FOUND); - return errors; - } - const availableTableFields = await findTableFieldsUtil(connection, tableName, userId, userEmail); - if (!widgetsDS || !Array.isArray(widgetsDS)) { - errors.push(Messages.WIDGETS_PROPERTY_MISSING); - return errors; - } + const errors = []; + const availableTablesInConnection = await findTablesInConnectionUtil( + connection, + userId, + userEmail, + ); + if (!availableTablesInConnection.includes(tableName)) { + errors.push(Messages.TABLE_NOT_FOUND); + return errors; + } + const availableTableFields = await findTableFieldsUtil( + connection, + tableName, + userId, + userEmail, + ); + if (!widgetsDS || !Array.isArray(widgetsDS)) { + errors.push(Messages.WIDGETS_PROPERTY_MISSING); + return errors; + } - for (const widgetDS of widgetsDS) { - if (!widgetDS.field_name) { - errors.push(Messages.WIDGET_FIELD_NAME_MISSING); - } else { - const fieldIndex = availableTableFields.indexOf(widgetDS.field_name); - if (fieldIndex < 0) { - errors.push(Messages.EXCLUDED_OR_NOT_EXISTS(widgetDS.field_name)); - } - } - const { widget_type } = widgetDS; + for (const widgetDS of widgetsDS) { + if (!widgetDS.field_name) { + errors.push(Messages.WIDGET_FIELD_NAME_MISSING); + } else { + const fieldIndex = availableTableFields.indexOf(widgetDS.field_name); + if (fieldIndex < 0) { + errors.push(Messages.EXCLUDED_OR_NOT_EXISTS(widgetDS.field_name)); + } + } + const { widget_type } = widgetDS; - // if (widget_type) { - // if (!Object.keys(WidgetTypeEnum).find((key) => key === widget_type)) { - // errors.push(Messages.WIDGET_TYPE_INCORRECT); - // } - // } - if (widget_type && widget_type === WidgetTypeEnum.Password) { - let widget_params = widgetDS.widget_params as string | Record; - if (typeof widget_params === 'string') { - widget_params = JSON5.parse(widget_params); - } + // if (widget_type) { + // if (!Object.keys(WidgetTypeEnum).find((key) => key === widget_type)) { + // errors.push(Messages.WIDGET_TYPE_INCORRECT); + // } + // } + if (widget_type && widget_type === WidgetTypeEnum.Password) { + let widget_params = widgetDS.widget_params as + | string + | Record; + if (typeof widget_params === "string") { + widget_params = JSON5.parse(widget_params); + } - if ( - widget_params.algorithm && - !Object.keys(EncryptionAlgorithmEnum).find((key) => key === widget_params.algorithm) - ) { - errors.push(Messages.ENCRYPTION_ALGORITHM_INCORRECT(widget_params.algorithm)); - } - if (widget_params.encrypt === undefined) { - errors.push(Messages.WIDGET_REQUIRED_PARAMETER_MISSING('encrypt')); - } - } + if ( + widget_params.algorithm && + !Object.keys(EncryptionAlgorithmEnum).find( + (key) => key === widget_params.algorithm, + ) + ) { + errors.push( + Messages.ENCRYPTION_ALGORITHM_INCORRECT(widget_params.algorithm), + ); + } + if (widget_params.encrypt === undefined) { + errors.push(Messages.WIDGET_REQUIRED_PARAMETER_MISSING("encrypt")); + } + } - if (widget_type && widget_type === WidgetTypeEnum.Foreign_key) { - const widget_params: ForeignKeyDSInfo = JSON5.parse(widgetDS.widget_params); + if (widget_type && widget_type === WidgetTypeEnum.Foreign_key) { + const widget_params: ForeignKeyDSInfo = JSON5.parse( + widgetDS.widget_params, + ); - for (const key in widget_params) { - if (!Constants.FOREIGN_KEY_FIELDS.includes(key)) { - errors.push(Messages.WIDGET_PARAMETER_UNSUPPORTED(key, widgetDS.widget_type)); - continue; - } - if (!getPropertyValueByDescriptor(widget_params, key) && key !== 'constraint_name') { - errors.push(Messages.WIDGET_REQUIRED_PARAMETER_MISSING(key)); - } - } - if (errors.length > 0) { - return errors; - } - if (!availableTablesInConnection.includes(widget_params.referenced_table_name)) { - errors.push(Messages.TABLE_WITH_NAME_NOT_EXISTS(widget_params.referenced_table_name)); - return errors; - } - const foreignTableFields = await findTableFieldsUtil( - connection, - widget_params.referenced_table_name, - userId, - userEmail, - ); - if (!foreignTableFields.includes(widget_params.referenced_column_name)) { - errors.push( - Messages.NO_SUCH_FIELD_IN_TABLE(widget_params.referenced_column_name, widget_params.referenced_table_name), - ); - } - } + for (const key in widget_params) { + if (!Constants.FOREIGN_KEY_FIELDS.includes(key)) { + errors.push( + Messages.WIDGET_PARAMETER_UNSUPPORTED(key, widgetDS.widget_type), + ); + continue; + } + if ( + !getPropertyValueByDescriptor(widget_params, key) && + key !== "constraint_name" + ) { + errors.push(Messages.WIDGET_REQUIRED_PARAMETER_MISSING(key)); + } + } + if (errors.length > 0) { + return errors; + } + if ( + !availableTablesInConnection.includes( + widget_params.referenced_table_name, + ) + ) { + errors.push( + Messages.TABLE_WITH_NAME_NOT_EXISTS( + widget_params.referenced_table_name, + ), + ); + return errors; + } + const foreignTableFields = await findTableFieldsUtil( + connection, + widget_params.referenced_table_name, + userId, + userEmail, + ); + if (!foreignTableFields.includes(widget_params.referenced_column_name)) { + errors.push( + Messages.NO_SUCH_FIELD_IN_TABLE( + widget_params.referenced_column_name, + widget_params.referenced_table_name, + ), + ); + } + } - if (widget_type && widget_type === WidgetTypeEnum.S3) { - let widget_params = widgetDS.widget_params as string | Record; - if (typeof widget_params === 'string') { - widget_params = JSON5.parse(widget_params); - } + if (widget_type && widget_type === WidgetTypeEnum.S3) { + const rawParams = widgetDS.widget_params; + const widget_params: Record = + typeof rawParams === "string" + ? JSON5.parse(rawParams) + : (rawParams as Record); - 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.aws_secret_access_key_secret_name) { - errors.push(Messages.WIDGET_REQUIRED_PARAMETER_MISSING('aws_secret_access_key_secret_name')); - } - } - } - return errors; + 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.aws_secret_access_key_secret_name) { + errors.push( + Messages.WIDGET_REQUIRED_PARAMETER_MISSING( + "aws_secret_access_key_secret_name", + ), + ); + } + } + } + return errors; } diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-s3-widget-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-s3-widget-e2e.test.ts new file mode 100644 index 000000000..42d9cf1be --- /dev/null +++ b/backend/test/ava-tests/non-saas-tests/non-saas-s3-widget-e2e.test.ts @@ -0,0 +1,677 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { faker } from "@faker-js/faker"; +import { INestApplication, ValidationPipe } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; +import test from "ava"; +import { ValidationError } from "class-validator"; +import cookieParser from "cookie-parser"; +import request from "supertest"; +import { ApplicationModule } from "../../../src/app.module.js"; +import { WinstonLogger } from "../../../src/entities/logging/winston-logger.js"; +import { AllExceptionsFilter } from "../../../src/exceptions/all-exceptions.filter.js"; +import { ValidationException } from "../../../src/exceptions/custom-exceptions/validation-exception.js"; +import { Messages } from "../../../src/exceptions/text/messages.js"; +import { Cacher } from "../../../src/helpers/cache/cacher.js"; +import { DatabaseModule } from "../../../src/shared/database/database.module.js"; +import { DatabaseService } from "../../../src/shared/database/database.service.js"; +import { MockFactory } from "../../mock.factory.js"; +import { getRandomTestTableName } from "../../utils/get-random-test-table-name.js"; +import { getTestData } from "../../utils/get-test-data.js"; +import { getTestKnex } from "../../utils/get-test-knex.js"; +import { registerUserAndReturnUserInfo } from "../../utils/register-user-and-return-user-info.js"; +import { setSaasEnvVariable } from "../../utils/set-saas-env-variable.js"; + +const mockFactory = new MockFactory(); +let app: INestApplication; +let currentTest: string; + +// Helper to setup user with connection, secrets, table with S3 widget, and row data +async function setupS3WidgetTestEnvironment( + options: { + createSecrets?: boolean; + createWidget?: boolean; + s3FieldName?: string; + } = {}, +): Promise<{ + token: string; + connectionId: string; + tableName: string; + fieldName: string; + rowPrimaryKey: Record; +}> { + const { + createSecrets = true, + createWidget = true, + s3FieldName = "file_key", + } = options; + + const { token } = await registerUserAndReturnUserInfo(app); + const connectionToPostgres = getTestData(mockFactory).connectionToPostgres; + + // Create test table with S3 field + const { testTableName } = await createTestTableWithS3Field( + connectionToPostgres, + s3FieldName, + ); + + // Create connection + const createdConnection = await request(app.getHttpServer()) + .post("/connection") + .send(connectionToPostgres) + .set("Cookie", token) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + const connectionId = JSON.parse(createdConnection.text).id; + + // Create secrets for AWS credentials + if (createSecrets) { + await request(app.getHttpServer()) + .post("/secrets") + .set("Cookie", token) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json") + .send({ + slug: "test-aws-access-key", + value: "AKIAIOSFODNN7EXAMPLE", + masterEncryption: false, + }); + + await request(app.getHttpServer()) + .post("/secrets") + .set("Cookie", token) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json") + .send({ + slug: "test-aws-secret-key", + value: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + masterEncryption: false, + }); + } + + // Create S3 widget for the field + if (createWidget) { + const s3WidgetParams = JSON.stringify({ + bucket: "test-bucket", + prefix: "uploads", + region: "us-east-1", + aws_access_key_id_secret_name: "test-aws-access-key", + aws_secret_access_key_secret_name: "test-aws-secret-key", + }); + + const widgetDto = { + widgets: [ + { + field_name: s3FieldName, + widget_type: "S3", + widget_params: s3WidgetParams, + name: "S3 File Widget", + description: "Test S3 widget", + }, + ], + }; + + await request(app.getHttpServer()) + .post(`/widget/${connectionId}?tableName=${testTableName}`) + .send(widgetDto) + .set("Cookie", token) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + } + + return { + token, + connectionId, + tableName: testTableName, + fieldName: s3FieldName, + rowPrimaryKey: { id: 1 }, + }; +} + +// Create test table with S3 field +async function createTestTableWithS3Field( + connectionParams: any, + s3FieldName: string, +): Promise<{ testTableName: string; testTableColumnName: string }> { + const testTableName = getRandomTestTableName(); + const testTableColumnName = "name"; + const Knex = getTestKnex(connectionParams); + + await Knex.schema.createTable(testTableName, (table) => { + table.increments("id"); + table.string(testTableColumnName); + table.string(s3FieldName); // Field to store S3 file key + table.timestamps(); + }); + + // Insert test row with file key + await Knex(testTableName).insert({ + [testTableColumnName]: "Test User", + [s3FieldName]: "uploads/test-file.pdf", + created_at: new Date(), + updated_at: new Date(), + }); + + return { testTableName, testTableColumnName }; +} + +test.before(async () => { + setSaasEnvVariable(); + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService], + }).compile(); + app = moduleFixture.createNestApplication(); + + app.use(cookieParser()); + app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger))); + app.useGlobalPipes( + new ValidationPipe({ + exceptionFactory(validationErrors: ValidationError[] = []) { + return new ValidationException(validationErrors); + }, + }), + ); + await app.init(); + app.getHttpServer().listen(0); +}); + +test.after(async () => { + try { + await Cacher.clearAllCache(); + await app.close(); + } catch (e) { + console.error("After tests error " + e); + } +}); + +// ==================== GET /s3/file/:connectionId Tests ==================== + +currentTest = "GET /s3/file/:connectionId"; + +test.serial( + `${currentTest} - should return 403 when user tries to access another user's connection`, + async (t) => { + // First user creates connection + const { connectionId, tableName, fieldName, rowPrimaryKey } = + await setupS3WidgetTestEnvironment(); + + // Second user tries to access first user's connection + const { token: secondUserToken } = await registerUserAndReturnUserInfo(app); + + const response = await request(app.getHttpServer()) + .get( + `/s3/file/${connectionId}?tableName=${tableName}&fieldName=${fieldName}&rowPrimaryKey=${JSON.stringify(rowPrimaryKey)}`, + ) + .set("Cookie", secondUserToken) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(response.status, 403); + const responseBody = JSON.parse(response.text); + t.is(responseBody.message, Messages.DONT_HAVE_PERMISSIONS); + }, +); + +test.serial( + `${currentTest} - should return 403 when using fake connection ID`, + async (t) => { + const { token } = await registerUserAndReturnUserInfo(app); + const fakeConnectionId = faker.string.uuid(); + + const response = await request(app.getHttpServer()) + .get( + `/s3/file/${fakeConnectionId}?tableName=test_table&fieldName=file_key&rowPrimaryKey=${JSON.stringify({ id: 1 })}`, + ) + .set("Cookie", token) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(response.status, 403); + const responseBody = JSON.parse(response.text); + t.is(responseBody.message, Messages.DONT_HAVE_PERMISSIONS); + }, +); + +test.serial( + `${currentTest} - should return 400 when tableName is missing`, + async (t) => { + const { token, connectionId, fieldName, rowPrimaryKey } = + await setupS3WidgetTestEnvironment(); + + const response = await request(app.getHttpServer()) + .get( + `/s3/file/${connectionId}?fieldName=${fieldName}&rowPrimaryKey=${JSON.stringify(rowPrimaryKey)}`, + ) + .set("Cookie", token) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(response.status, 400); + }, +); + +test.serial( + `${currentTest} - should return 400 when fieldName is missing`, + async (t) => { + const { token, connectionId, tableName, rowPrimaryKey } = + await setupS3WidgetTestEnvironment(); + + const response = await request(app.getHttpServer()) + .get( + `/s3/file/${connectionId}?tableName=${tableName}&rowPrimaryKey=${JSON.stringify(rowPrimaryKey)}`, + ) + .set("Cookie", token) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(response.status, 400); + const responseBody = JSON.parse(response.text); + t.is(responseBody.message, "Field name is required"); + }, +); + +test.serial( + `${currentTest} - should return 400 when rowPrimaryKey is missing`, + async (t) => { + const { token, connectionId, tableName, fieldName } = + await setupS3WidgetTestEnvironment(); + + const response = await request(app.getHttpServer()) + .get( + `/s3/file/${connectionId}?tableName=${tableName}&fieldName=${fieldName}`, + ) + .set("Cookie", token) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(response.status, 400); + const responseBody = JSON.parse(response.text); + t.is(responseBody.message, "Row primary key is required"); + }, +); + +test.serial( + `${currentTest} - should return 400 when rowPrimaryKey has invalid JSON format`, + async (t) => { + const { token, connectionId, tableName, fieldName } = + await setupS3WidgetTestEnvironment(); + + const response = await request(app.getHttpServer()) + .get( + `/s3/file/${connectionId}?tableName=${tableName}&fieldName=${fieldName}&rowPrimaryKey=invalid-json`, + ) + .set("Cookie", token) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(response.status, 400); + const responseBody = JSON.parse(response.text); + t.is(responseBody.message, "Invalid row primary key format"); + }, +); + +test.serial( + `${currentTest} - should return 400 when S3 widget is not configured for the field`, + async (t) => { + const { token, connectionId, tableName, rowPrimaryKey } = + await setupS3WidgetTestEnvironment({ + createWidget: false, + }); + + const response = await request(app.getHttpServer()) + .get( + `/s3/file/${connectionId}?tableName=${tableName}&fieldName=file_key&rowPrimaryKey=${JSON.stringify(rowPrimaryKey)}`, + ) + .set("Cookie", token) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(response.status, 400); + const responseBody = JSON.parse(response.text); + t.is(responseBody.message, "S3 widget not configured for this field"); + }, +); + +test.serial( + `${currentTest} - should return 404 when row with primary key not found`, + async (t) => { + const { token, connectionId, tableName, fieldName } = + await setupS3WidgetTestEnvironment(); + + // Use a primary key that doesn't exist + const nonExistentPrimaryKey = { id: 99999 }; + + const response = await request(app.getHttpServer()) + .get( + `/s3/file/${connectionId}?tableName=${tableName}&fieldName=${fieldName}&rowPrimaryKey=${JSON.stringify(nonExistentPrimaryKey)}`, + ) + .set("Cookie", token) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(response.status, 404); + const responseBody = JSON.parse(response.text); + t.is(responseBody.message, Messages.ROW_PRIMARY_KEY_NOT_FOUND); + }, +); + +test.serial( + `${currentTest} - should return 404 when AWS credentials secrets are not found`, + async (t) => { + const { token, connectionId, tableName, fieldName, rowPrimaryKey } = + await setupS3WidgetTestEnvironment({ + createSecrets: false, + }); + + const response = await request(app.getHttpServer()) + .get( + `/s3/file/${connectionId}?tableName=${tableName}&fieldName=${fieldName}&rowPrimaryKey=${JSON.stringify(rowPrimaryKey)}`, + ) + .set("Cookie", token) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(response.status, 404); + const responseBody = JSON.parse(response.text); + t.is(responseBody.message, "AWS credentials secrets not found"); + }, +); + +// ==================== POST /s3/upload-url/:connectionId Tests ==================== + +currentTest = "POST /s3/upload-url/:connectionId"; + +test.serial( + `${currentTest} - should return 403 when user tries to access another user's connection`, + async (t) => { + // First user creates connection + const { connectionId, tableName, fieldName } = + await setupS3WidgetTestEnvironment(); + + // Second user tries to access first user's connection + const { token: secondUserToken } = await registerUserAndReturnUserInfo(app); + + const response = await request(app.getHttpServer()) + .post( + `/s3/upload-url/${connectionId}?tableName=${tableName}&fieldName=${fieldName}`, + ) + .send({ filename: "test.pdf", contentType: "application/pdf" }) + .set("Cookie", secondUserToken) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(response.status, 403); + const responseBody = JSON.parse(response.text); + t.is(responseBody.message, Messages.DONT_HAVE_PERMISSIONS); + }, +); + +test.serial( + `${currentTest} - should return 403 when using fake connection ID`, + async (t) => { + const { token } = await registerUserAndReturnUserInfo(app); + const fakeConnectionId = faker.string.uuid(); + + const response = await request(app.getHttpServer()) + .post( + `/s3/upload-url/${fakeConnectionId}?tableName=test_table&fieldName=file_key`, + ) + .send({ filename: "test.pdf", contentType: "application/pdf" }) + .set("Cookie", token) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(response.status, 403); + const responseBody = JSON.parse(response.text); + t.is(responseBody.message, Messages.DONT_HAVE_PERMISSIONS); + }, +); + +test.serial( + `${currentTest} - should return 400 when tableName is missing`, + async (t) => { + const { token, connectionId, fieldName } = + await setupS3WidgetTestEnvironment(); + + const response = await request(app.getHttpServer()) + .post(`/s3/upload-url/${connectionId}?fieldName=${fieldName}`) + .send({ filename: "test.pdf", contentType: "application/pdf" }) + .set("Cookie", token) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(response.status, 400); + }, +); + +test.serial( + `${currentTest} - should return 400 when fieldName is missing`, + async (t) => { + const { token, connectionId, tableName } = + await setupS3WidgetTestEnvironment(); + + const response = await request(app.getHttpServer()) + .post(`/s3/upload-url/${connectionId}?tableName=${tableName}`) + .send({ filename: "test.pdf", contentType: "application/pdf" }) + .set("Cookie", token) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(response.status, 400); + const responseBody = JSON.parse(response.text); + t.is(responseBody.message, "Field name is required"); + }, +); + +test.serial( + `${currentTest} - should return 400 when filename is missing`, + async (t) => { + const { token, connectionId, tableName, fieldName } = + await setupS3WidgetTestEnvironment(); + + const response = await request(app.getHttpServer()) + .post( + `/s3/upload-url/${connectionId}?tableName=${tableName}&fieldName=${fieldName}`, + ) + .send({ contentType: "application/pdf" }) + .set("Cookie", token) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(response.status, 400); + const responseBody = JSON.parse(response.text); + t.is(responseBody.message, "Filename is required"); + }, +); + +test.serial( + `${currentTest} - should return 400 when contentType is missing`, + async (t) => { + const { token, connectionId, tableName, fieldName } = + await setupS3WidgetTestEnvironment(); + + const response = await request(app.getHttpServer()) + .post( + `/s3/upload-url/${connectionId}?tableName=${tableName}&fieldName=${fieldName}`, + ) + .send({ filename: "test.pdf" }) + .set("Cookie", token) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(response.status, 400); + const responseBody = JSON.parse(response.text); + t.is(responseBody.message, "Content type is required"); + }, +); + +test.serial( + `${currentTest} - should return 400 when S3 widget is not configured for the field`, + async (t) => { + const { token, connectionId, tableName } = + await setupS3WidgetTestEnvironment({ + createWidget: false, + }); + + const response = await request(app.getHttpServer()) + .post( + `/s3/upload-url/${connectionId}?tableName=${tableName}&fieldName=file_key`, + ) + .send({ filename: "test.pdf", contentType: "application/pdf" }) + .set("Cookie", token) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(response.status, 400); + const responseBody = JSON.parse(response.text); + t.is(responseBody.message, "S3 widget not configured for this field"); + }, +); + +test.serial( + `${currentTest} - should return 404 when AWS credentials secrets are not found`, + async (t) => { + const { token, connectionId, tableName, fieldName } = + await setupS3WidgetTestEnvironment({ + createSecrets: false, + }); + + const response = await request(app.getHttpServer()) + .post( + `/s3/upload-url/${connectionId}?tableName=${tableName}&fieldName=${fieldName}`, + ) + .send({ filename: "test.pdf", contentType: "application/pdf" }) + .set("Cookie", token) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(response.status, 404); + const responseBody = JSON.parse(response.text); + t.is(responseBody.message, "AWS credentials secrets not found"); + }, +); + +// ==================== Authorization Tests - Multiple Users ==================== + +currentTest = "S3 Widget Authorization"; + +test.serial( + `${currentTest} - user A cannot get file URL from user B's connection`, + async (t) => { + // User A sets up their environment + const userA = await setupS3WidgetTestEnvironment(); + + // User B registers + const { token: userBToken } = await registerUserAndReturnUserInfo(app); + + // User B tries to get file URL from User A's connection + const response = await request(app.getHttpServer()) + .get( + `/s3/file/${userA.connectionId}?tableName=${userA.tableName}&fieldName=${userA.fieldName}&rowPrimaryKey=${JSON.stringify(userA.rowPrimaryKey)}`, + ) + .set("Cookie", userBToken) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(response.status, 403); + t.is(JSON.parse(response.text).message, Messages.DONT_HAVE_PERMISSIONS); + }, +); + +test.serial( + `${currentTest} - user A cannot get upload URL from user B's connection`, + async (t) => { + // User A sets up their environment + const userA = await setupS3WidgetTestEnvironment(); + + // User B registers + const { token: userBToken } = await registerUserAndReturnUserInfo(app); + + // User B tries to get upload URL from User A's connection + const response = await request(app.getHttpServer()) + .post( + `/s3/upload-url/${userA.connectionId}?tableName=${userA.tableName}&fieldName=${userA.fieldName}`, + ) + .send({ filename: "malicious.pdf", contentType: "application/pdf" }) + .set("Cookie", userBToken) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(response.status, 403); + t.is(JSON.parse(response.text).message, Messages.DONT_HAVE_PERMISSIONS); + }, +); + +test.serial( + `${currentTest} - user can access their own connection successfully`, + async (t) => { + // User sets up their environment - this should work without permission errors + // Note: The actual S3 call may fail (no real AWS), but we should not get 403 + const { token, connectionId, tableName, fieldName, rowPrimaryKey } = + await setupS3WidgetTestEnvironment(); + + const response = await request(app.getHttpServer()) + .get( + `/s3/file/${connectionId}?tableName=${tableName}&fieldName=${fieldName}&rowPrimaryKey=${JSON.stringify(rowPrimaryKey)}`, + ) + .set("Cookie", token) + .set("masterpwd", "ahalaimahalai") + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + // Should not be 403 - may fail at S3 level but authorization passes + t.not(response.status, 403); + }, +); + +test.serial( + `${currentTest} - unauthenticated user cannot access S3 endpoints`, + async (t) => { + const { connectionId, tableName, fieldName, rowPrimaryKey } = + await setupS3WidgetTestEnvironment(); + + // Try to access without authentication token + const getFileResponse = await request(app.getHttpServer()) + .get( + `/s3/file/${connectionId}?tableName=${tableName}&fieldName=${fieldName}&rowPrimaryKey=${JSON.stringify(rowPrimaryKey)}`, + ) + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(getFileResponse.status, 401); + + const uploadUrlResponse = await request(app.getHttpServer()) + .post( + `/s3/upload-url/${connectionId}?tableName=${tableName}&fieldName=${fieldName}`, + ) + .send({ filename: "test.pdf", contentType: "application/pdf" }) + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + t.is(uploadUrlResponse.status, 401); + }, +); diff --git a/docker-compose.tst.yml b/docker-compose.tst.yml index 661df4791..92753e203 100644 --- a/docker-compose.tst.yml +++ b/docker-compose.tst.yml @@ -1,38 +1,5 @@ services: backend: - build: - context: . - ports: - - 3000:3000 - env_file: ./backend/.development.env - environment: - DATABASE_URL: "postgres://postgres:abc123@postgres:5432/postgres" - volumes: - - ./backend/src/migrations:/app/src/migrations - depends_on: - - postgres - - testMySQL-e2e-testing - - testPg-e2e-testing - - mssql-e2e-testing - - test-oracle-e2e-testing - - test-ibm-db2-e2e-testing - - test-dynamodb-e2e-testing - - test-redis-e2e-testing - - test-cassandra-e2e-testing - - test-clickhouse-e2e-testing - links: - - postgres - - testMySQL-e2e-testing - - testPg-e2e-testing - - mssql-e2e-testing - - test-oracle-e2e-testing - - test-ibm-db2-e2e-testing - - test-dynamodb-e2e-testing - - test-redis-e2e-testing - - test-cassandra-e2e-testing - - test-clickhouse-e2e-testing - command: ["yarn", "start"] - backend_test: build: context: . env_file: ./backend/.development.env diff --git a/justfile b/justfile index c46044740..03b0817cf 100644 --- a/justfile +++ b/justfile @@ -1,2 +1,2 @@ test args='test/ava-tests/non-saas-tests/*': - EXTRA_ARGS="{{args}}" docker compose -f docker-compose.tst.yml up --abort-on-container-exit --force-recreate --build --attach=backend_test --no-log-prefix + EXTRA_ARGS="{{args}}" docker compose -f docker-compose.tst.yml up --abort-on-container-exit --force-recreate --build --attach=backend --no-log-prefix From 07004ebc5042dce412fa88fd2d8e344cf705a6ae Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Tue, 23 Dec 2025 21:27:43 +0000 Subject: [PATCH 4/5] frontend tests --- .../s3/s3.component.spec.ts | 555 ++++++++++++++++++ frontend/src/app/services/s3.service.spec.ts | 331 +++++++++++ 2 files changed, 886 insertions(+) create mode 100644 frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.spec.ts create mode 100644 frontend/src/app/services/s3.service.spec.ts diff --git a/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.spec.ts new file mode 100644 index 000000000..cd1c763c6 --- /dev/null +++ b/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.spec.ts @@ -0,0 +1,555 @@ +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from "@angular/core/testing"; +import { FormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { of, Subject, throwError } from "rxjs"; +import { WidgetStructure } from "src/app/models/table"; +import { ConnectionsService } from "src/app/services/connections.service"; +import { S3Service } from "src/app/services/s3.service"; +import { TablesService } from "src/app/services/tables.service"; +import { S3EditComponent } from "./s3.component"; + +describe("S3EditComponent", () => { + let component: S3EditComponent; + let fixture: ComponentFixture; + let fakeS3Service: jasmine.SpyObj; + let fakeConnectionsService: jasmine.SpyObj; + let fakeTablesService: jasmine.SpyObj; + + const mockWidgetStructure: WidgetStructure = { + field_name: "document", + widget_type: "S3", + widget_params: { + bucket: "test-bucket", + prefix: "uploads/", + region: "us-east-1", + aws_access_key_id_secret_name: "aws-key", + aws_secret_access_key_secret_name: "aws-secret", + }, + name: "Document Upload", + description: "Upload documents to S3", + }; + + const mockWidgetStructureStringParams: WidgetStructure = { + field_name: "document", + widget_type: "S3", + widget_params: JSON.stringify({ + bucket: "test-bucket", + prefix: "uploads/", + region: "us-east-1", + aws_access_key_id_secret_name: "aws-key", + aws_secret_access_key_secret_name: "aws-secret", + }) as any, + name: "Document Upload", + description: "Upload documents to S3", + }; + + const mockFileUrlResponse = { + url: "https://s3.amazonaws.com/bucket/file.pdf?signature=abc123", + key: "uploads/file.pdf", + expiresIn: 3600, + }; + + const mockUploadUrlResponse = { + uploadUrl: + "https://s3.amazonaws.com/bucket/uploads/newfile.pdf?signature=xyz789", + key: "uploads/newfile.pdf", + expiresIn: 3600, + }; + + beforeEach(async () => { + fakeS3Service = jasmine.createSpyObj("S3Service", [ + "getFileUrl", + "getUploadUrl", + "uploadToS3", + ]); + fakeConnectionsService = jasmine.createSpyObj("ConnectionsService", [], { + currentConnectionID: "conn-123", + }); + fakeTablesService = jasmine.createSpyObj("TablesService", [], { + currentTableName: "users", + }); + + await TestBed.configureTestingModule({ + imports: [FormsModule, BrowserAnimationsModule, S3EditComponent], + providers: [ + { provide: S3Service, useValue: fakeS3Service }, + { provide: ConnectionsService, useValue: fakeConnectionsService }, + { provide: TablesService, useValue: fakeTablesService }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(S3EditComponent); + component = fixture.componentInstance; + + component.key = "document"; + component.label = "Document"; + component.widgetStructure = mockWidgetStructure; + component.rowPrimaryKey = { id: 1 }; + }); + + it("should create", () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + describe("ngOnInit", () => { + it("should set connectionId and tableName from services", () => { + fixture.detectChanges(); + + expect((component as any).connectionId).toBe("conn-123"); + expect((component as any).tableName).toBe("users"); + }); + + it("should parse widget params from object", () => { + component.widgetStructure = mockWidgetStructure; + fixture.detectChanges(); + + expect(component.params).toEqual({ + bucket: "test-bucket", + prefix: "uploads/", + region: "us-east-1", + aws_access_key_id_secret_name: "aws-key", + aws_secret_access_key_secret_name: "aws-secret", + }); + }); + + it("should parse widget params from string", () => { + component.widgetStructure = mockWidgetStructureStringParams; + fixture.detectChanges(); + + expect(component.params.bucket).toBe("test-bucket"); + }); + + it("should load preview if value is present", () => { + component.value = "uploads/existing-file.pdf"; + fakeS3Service.getFileUrl.and.returnValue(of(mockFileUrlResponse)); + + fixture.detectChanges(); + + expect(fakeS3Service.getFileUrl).toHaveBeenCalledWith( + "conn-123", + "users", + "document", + { id: 1 }, + ); + }); + + it("should not load preview if value is empty", () => { + component.value = ""; + fixture.detectChanges(); + + expect(fakeS3Service.getFileUrl).not.toHaveBeenCalled(); + }); + }); + + describe("ngOnChanges", () => { + it("should load preview when value changes and no preview exists", () => { + fixture.detectChanges(); + fakeS3Service.getFileUrl.and.returnValue(of(mockFileUrlResponse)); + + component.value = "uploads/new-file.pdf"; + component.ngOnChanges(); + + expect(fakeS3Service.getFileUrl).toHaveBeenCalled(); + }); + + it("should not reload preview if already loading", () => { + fixture.detectChanges(); + component.isLoading = true; + component.value = "uploads/file.pdf"; + + component.ngOnChanges(); + + expect(fakeS3Service.getFileUrl).not.toHaveBeenCalled(); + }); + + it("should not reload preview if preview already exists", () => { + fixture.detectChanges(); + component.previewUrl = "https://example.com/preview"; + component.value = "uploads/file.pdf"; + + component.ngOnChanges(); + + expect(fakeS3Service.getFileUrl).not.toHaveBeenCalled(); + }); + }); + + describe("onFileSelected", () => { + it("should upload file and update value on success", fakeAsync(() => { + fixture.detectChanges(); + fakeS3Service.getUploadUrl.and.returnValue(of(mockUploadUrlResponse)); + fakeS3Service.uploadToS3.and.returnValue(of(undefined)); + fakeS3Service.getFileUrl.and.returnValue(of(mockFileUrlResponse)); + + const file = new File(["test content"], "test.pdf", { + type: "application/pdf", + }); + const event = { + target: { + files: [file], + }, + } as unknown as Event; + + spyOn(component.onFieldChange, "emit"); + component.onFileSelected(event); + tick(); + + expect(fakeS3Service.getUploadUrl).toHaveBeenCalledWith( + "conn-123", + "users", + "document", + "test.pdf", + "application/pdf", + ); + expect(fakeS3Service.uploadToS3).toHaveBeenCalledWith( + mockUploadUrlResponse.uploadUrl, + file, + ); + expect(component.value).toBe("uploads/newfile.pdf"); + expect(component.onFieldChange.emit).toHaveBeenCalledWith( + "uploads/newfile.pdf", + ); + })); + + it("should do nothing if no files selected", () => { + fixture.detectChanges(); + const event = { + target: { + files: [], + }, + } as unknown as Event; + + component.onFileSelected(event); + + expect(fakeS3Service.getUploadUrl).not.toHaveBeenCalled(); + }); + + it("should do nothing if files is null", () => { + fixture.detectChanges(); + const event = { + target: { + files: null, + }, + } as unknown as Event; + + component.onFileSelected(event); + + expect(fakeS3Service.getUploadUrl).not.toHaveBeenCalled(); + }); + + it("should set isLoading to true during upload", () => { + fixture.detectChanges(); + fakeS3Service.getUploadUrl.and.returnValue(of(mockUploadUrlResponse)); + // Use a Subject that never emits to keep the upload "in progress" + const pendingUpload$ = new Subject(); + fakeS3Service.uploadToS3.and.returnValue(pendingUpload$.asObservable()); + + const file = new File(["test"], "test.pdf", { type: "application/pdf" }); + const event = { target: { files: [file] } } as unknown as Event; + + component.onFileSelected(event); + + expect(component.isLoading).toBeTrue(); + }); + + it("should set isLoading to false on getUploadUrl error", fakeAsync(() => { + fixture.detectChanges(); + fakeS3Service.getUploadUrl.and.returnValue( + throwError(() => new Error("Upload URL error")), + ); + + const file = new File(["test"], "test.pdf", { type: "application/pdf" }); + const event = { target: { files: [file] } } as unknown as Event; + + component.onFileSelected(event); + tick(); + + expect(component.isLoading).toBeFalse(); + })); + + it("should set isLoading to false on uploadToS3 error", fakeAsync(() => { + fixture.detectChanges(); + fakeS3Service.getUploadUrl.and.returnValue(of(mockUploadUrlResponse)); + fakeS3Service.uploadToS3.and.returnValue( + throwError(() => new Error("S3 upload error")), + ); + + const file = new File(["test"], "test.pdf", { type: "application/pdf" }); + const event = { target: { files: [file] } } as unknown as Event; + + component.onFileSelected(event); + tick(); + + expect(component.isLoading).toBeFalse(); + })); + }); + + describe("openFile", () => { + it("should open preview URL in new tab", () => { + fixture.detectChanges(); + component.previewUrl = "https://s3.amazonaws.com/bucket/file.pdf"; + spyOn(window, "open"); + + component.openFile(); + + expect(window.open).toHaveBeenCalledWith( + "https://s3.amazonaws.com/bucket/file.pdf", + "_blank", + ); + }); + + it("should not open if previewUrl is null", () => { + fixture.detectChanges(); + component.previewUrl = null; + spyOn(window, "open"); + + component.openFile(); + + expect(window.open).not.toHaveBeenCalled(); + }); + }); + + describe("_isImageFile", () => { + const testCases = [ + { key: "photo.jpg", expected: true }, + { key: "photo.JPG", expected: true }, + { key: "photo.jpeg", expected: true }, + { key: "photo.png", expected: true }, + { key: "photo.gif", expected: true }, + { key: "photo.webp", expected: true }, + { key: "photo.svg", expected: true }, + { key: "photo.bmp", expected: true }, + { key: "document.pdf", expected: false }, + { key: "document.doc", expected: false }, + { key: "data.csv", expected: false }, + { key: "archive.zip", expected: false }, + { key: "uploads/folder/photo.png", expected: true }, + { key: "file-without-extension", expected: false }, + ]; + + testCases.forEach(({ key, expected }) => { + it(`should return ${expected} for "${key}"`, () => { + fixture.detectChanges(); + const result = (component as any)._isImageFile(key); + expect(result).toBe(expected); + }); + }); + }); + + describe("_loadPreview", () => { + it("should set previewUrl and isImage on successful load", fakeAsync(() => { + component.value = "uploads/photo.jpg"; + fakeS3Service.getFileUrl.and.returnValue(of(mockFileUrlResponse)); + + fixture.detectChanges(); + tick(); + + expect(component.previewUrl).toBe(mockFileUrlResponse.url); + expect(component.isImage).toBeTrue(); + expect(component.isLoading).toBeFalse(); + })); + + it("should set isImage to false for non-image files", fakeAsync(() => { + component.value = "uploads/document.pdf"; + fakeS3Service.getFileUrl.and.returnValue(of(mockFileUrlResponse)); + + fixture.detectChanges(); + tick(); + + expect(component.isImage).toBeFalse(); + })); + + it("should not load preview if value is empty", () => { + component.value = ""; + fixture.detectChanges(); + + expect(fakeS3Service.getFileUrl).not.toHaveBeenCalled(); + }); + + it("should not load preview if connectionId is missing", () => { + (component as any).connectionId = ""; + component.value = "uploads/file.pdf"; + (component as any)._loadPreview(); + + expect(fakeS3Service.getFileUrl).not.toHaveBeenCalled(); + }); + + it("should not load preview if tableName is missing", () => { + fixture.detectChanges(); + (component as any).tableName = ""; + component.value = "uploads/file.pdf"; + fakeS3Service.getFileUrl.calls.reset(); + + (component as any)._loadPreview(); + + expect(fakeS3Service.getFileUrl).not.toHaveBeenCalled(); + }); + + it("should not load preview if rowPrimaryKey is missing", () => { + component.rowPrimaryKey = null as any; + component.value = "uploads/file.pdf"; + fixture.detectChanges(); + + expect(fakeS3Service.getFileUrl).not.toHaveBeenCalled(); + }); + + it("should set isLoading to false on error", fakeAsync(() => { + component.value = "uploads/file.pdf"; + fakeS3Service.getFileUrl.and.returnValue( + throwError(() => new Error("File URL error")), + ); + + fixture.detectChanges(); + tick(); + + expect(component.isLoading).toBeFalse(); + })); + }); + + describe("_parseWidgetParams", () => { + it("should handle undefined widgetStructure gracefully", () => { + component.widgetStructure = undefined as any; + fixture.detectChanges(); + + expect(component.params).toBeUndefined(); + }); + + it("should handle null widget_params gracefully", () => { + component.widgetStructure = { + ...mockWidgetStructure, + widget_params: null as any, + }; + fixture.detectChanges(); + + expect(component.params).toBeUndefined(); + }); + + it("should handle invalid JSON string gracefully", () => { + spyOn(console, "error"); + component.widgetStructure = { + ...mockWidgetStructure, + widget_params: "invalid json" as any, + }; + fixture.detectChanges(); + + expect(console.error).toHaveBeenCalled(); + }); + }); + + describe("template integration", () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it("should display label in form field", () => { + const label = fixture.nativeElement.querySelector("mat-label"); + expect(label.textContent).toContain("Document"); + }); + + it("should show upload button", () => { + const uploadButton = fixture.nativeElement.querySelector("button"); + expect(uploadButton.textContent).toContain("Upload"); + }); + + it("should disable upload button when disabled", () => { + component.disabled = true; + fixture.detectChanges(); + + const uploadButton = fixture.nativeElement.querySelector("button"); + expect(uploadButton.disabled).toBeTrue(); + }); + + it("should disable upload button when readonly", () => { + component.readonly = true; + fixture.detectChanges(); + + const uploadButton = fixture.nativeElement.querySelector("button"); + expect(uploadButton.disabled).toBeTrue(); + }); + + it("should disable upload button when loading", () => { + component.isLoading = true; + fixture.detectChanges(); + + const uploadButton = fixture.nativeElement.querySelector("button"); + expect(uploadButton.disabled).toBeTrue(); + }); + + it("should show open button when previewUrl exists", () => { + component.previewUrl = "https://example.com/file.pdf"; + fixture.detectChanges(); + + const buttons = fixture.nativeElement.querySelectorAll("button"); + const openButton = Array.from(buttons).find((b: any) => + b.textContent.includes("Open"), + ); + expect(openButton).toBeTruthy(); + }); + + it("should not show open button when previewUrl is null", () => { + component.previewUrl = null; + fixture.detectChanges(); + + const buttons = fixture.nativeElement.querySelectorAll("button"); + const openButton = Array.from(buttons).find((b: any) => + b.textContent.includes("Open"), + ); + expect(openButton).toBeFalsy(); + }); + + it("should show spinner when loading", () => { + component.value = "uploads/file.pdf"; + component.isLoading = true; + fixture.detectChanges(); + + const spinner = fixture.nativeElement.querySelector("mat-spinner"); + expect(spinner).toBeTruthy(); + }); + + it("should show image preview for image files", () => { + component.value = "uploads/photo.jpg"; + component.isImage = true; + component.previewUrl = "https://example.com/photo.jpg"; + component.isLoading = false; + fixture.detectChanges(); + + const img = fixture.nativeElement.querySelector(".s3-widget__thumbnail"); + expect(img).toBeTruthy(); + expect(img.src).toBe("https://example.com/photo.jpg"); + }); + + it("should show file icon for non-image files", () => { + component.value = "uploads/document.pdf"; + component.isImage = false; + component.previewUrl = "https://example.com/document.pdf"; + component.isLoading = false; + fixture.detectChanges(); + + const fileIcon = fixture.nativeElement.querySelector( + ".s3-widget__file-icon", + ); + expect(fileIcon).toBeTruthy(); + }); + + it("should show truncated filename for long filenames", () => { + component.value = + "uploads/very-long-filename-that-should-be-truncated.pdf"; + component.isImage = false; + component.previewUrl = "https://example.com/file.pdf"; + component.isLoading = false; + fixture.detectChanges(); + + const filename = fixture.nativeElement.querySelector( + ".s3-widget__filename", + ); + expect(filename).toBeTruthy(); + }); + }); +}); diff --git a/frontend/src/app/services/s3.service.spec.ts b/frontend/src/app/services/s3.service.spec.ts new file mode 100644 index 000000000..240f6fa28 --- /dev/null +++ b/frontend/src/app/services/s3.service.spec.ts @@ -0,0 +1,331 @@ +import { provideHttpClient } from "@angular/common/http"; +import { + HttpTestingController, + provideHttpClientTesting, +} from "@angular/common/http/testing"; +import { TestBed } from "@angular/core/testing"; +import { MatSnackBarModule } from "@angular/material/snack-bar"; +import { NotificationsService } from "./notifications.service"; +import { S3Service } from "./s3.service"; + +describe("S3Service", () => { + let service: S3Service; + let httpMock: HttpTestingController; + let fakeNotifications: jasmine.SpyObj; + + const mockFileUrlResponse = { + url: "https://s3.amazonaws.com/bucket/file.pdf?signature=abc123", + key: "prefix/file.pdf", + expiresIn: 3600, + }; + + const mockUploadUrlResponse = { + uploadUrl: + "https://s3.amazonaws.com/bucket/prefix/newfile.pdf?signature=xyz789", + key: "prefix/newfile.pdf", + expiresIn: 3600, + }; + + const fakeError = { + message: "Something went wrong", + statusCode: 400, + }; + + beforeEach(() => { + fakeNotifications = jasmine.createSpyObj("NotificationsService", [ + "showAlert", + "dismissAlert", + ]); + + TestBed.configureTestingModule({ + imports: [MatSnackBarModule], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + S3Service, + { provide: NotificationsService, useValue: fakeNotifications }, + ], + }); + + service = TestBed.inject(S3Service); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + describe("getFileUrl", () => { + const connectionId = "conn-123"; + const tableName = "users"; + const fieldName = "avatar"; + const rowPrimaryKey = { id: 1 }; + + it("should fetch file URL successfully", () => { + let result: any; + + service + .getFileUrl(connectionId, tableName, fieldName, rowPrimaryKey) + .subscribe((res) => { + result = res; + }); + + const req = httpMock.expectOne( + (request) => + request.url === `/s3/file/${connectionId}` && + request.params.get("tableName") === tableName && + request.params.get("fieldName") === fieldName && + request.params.get("rowPrimaryKey") === JSON.stringify(rowPrimaryKey), + ); + expect(req.request.method).toBe("GET"); + req.flush(mockFileUrlResponse); + + expect(result).toEqual(mockFileUrlResponse); + }); + + it("should handle complex primary key", () => { + const complexPrimaryKey = { user_id: 1, org_id: "abc" }; + let result: any; + + service + .getFileUrl(connectionId, tableName, fieldName, complexPrimaryKey) + .subscribe((res) => { + result = res; + }); + + const req = httpMock.expectOne( + (request) => + request.url === `/s3/file/${connectionId}` && + request.params.get("rowPrimaryKey") === + JSON.stringify(complexPrimaryKey), + ); + req.flush(mockFileUrlResponse); + + expect(result).toEqual(mockFileUrlResponse); + }); + + it("should show error alert on failure", async () => { + const promise = service + .getFileUrl(connectionId, tableName, fieldName, rowPrimaryKey) + .toPromise(); + + const req = httpMock.expectOne( + (request) => request.url === `/s3/file/${connectionId}`, + ); + req.flush(fakeError, { status: 400, statusText: "Bad Request" }); + + await promise; + + expect(fakeNotifications.showAlert).toHaveBeenCalledWith( + jasmine.anything(), + jasmine.objectContaining({ + abstract: "Failed to get S3 file URL", + details: fakeError.message, + }), + jasmine.any(Array), + ); + }); + + it("should return EMPTY observable on error", (done) => { + let completed = false; + let emitted = false; + + service + .getFileUrl(connectionId, tableName, fieldName, rowPrimaryKey) + .subscribe({ + next: () => { + emitted = true; + }, + complete: () => { + completed = true; + expect(emitted).toBeFalse(); + done(); + }, + }); + + const req = httpMock.expectOne( + (request) => request.url === `/s3/file/${connectionId}`, + ); + req.flush(fakeError, { status: 400, statusText: "Bad Request" }); + }); + }); + + describe("getUploadUrl", () => { + const connectionId = "conn-123"; + const tableName = "users"; + const fieldName = "avatar"; + const filename = "document.pdf"; + const contentType = "application/pdf"; + + it("should fetch upload URL successfully", () => { + let result: any; + + service + .getUploadUrl(connectionId, tableName, fieldName, filename, contentType) + .subscribe((res) => { + result = res; + }); + + const req = httpMock.expectOne( + (request) => + request.url === `/s3/upload-url/${connectionId}` && + request.params.get("tableName") === tableName && + request.params.get("fieldName") === fieldName, + ); + expect(req.request.method).toBe("POST"); + expect(req.request.body).toEqual({ filename, contentType }); + req.flush(mockUploadUrlResponse); + + expect(result).toEqual(mockUploadUrlResponse); + }); + + it("should handle image upload", () => { + const imageFilename = "photo.jpg"; + const imageContentType = "image/jpeg"; + + service + .getUploadUrl( + connectionId, + tableName, + fieldName, + imageFilename, + imageContentType, + ) + .subscribe(); + + const req = httpMock.expectOne( + (request) => request.url === `/s3/upload-url/${connectionId}`, + ); + expect(req.request.body).toEqual({ + filename: imageFilename, + contentType: imageContentType, + }); + req.flush(mockUploadUrlResponse); + }); + + it("should show error alert on failure", async () => { + const promise = service + .getUploadUrl(connectionId, tableName, fieldName, filename, contentType) + .toPromise(); + + const req = httpMock.expectOne( + (request) => request.url === `/s3/upload-url/${connectionId}`, + ); + req.flush(fakeError, { status: 400, statusText: "Bad Request" }); + + await promise; + + expect(fakeNotifications.showAlert).toHaveBeenCalledWith( + jasmine.anything(), + jasmine.objectContaining({ + abstract: "Failed to get upload URL", + details: fakeError.message, + }), + jasmine.any(Array), + ); + }); + + it("should return EMPTY observable on error", (done) => { + let emitted = false; + + service + .getUploadUrl(connectionId, tableName, fieldName, filename, contentType) + .subscribe({ + next: () => { + emitted = true; + }, + complete: () => { + expect(emitted).toBeFalse(); + done(); + }, + }); + + const req = httpMock.expectOne( + (request) => request.url === `/s3/upload-url/${connectionId}`, + ); + req.flush(fakeError, { status: 400, statusText: "Bad Request" }); + }); + }); + + describe("uploadToS3", () => { + const uploadUrl = + "https://s3.amazonaws.com/bucket/file.pdf?signature=abc123"; + + it("should upload file to S3 successfully", () => { + const file = new File(["test content"], "test.pdf", { + type: "application/pdf", + }); + let completed = false; + + service.uploadToS3(uploadUrl, file).subscribe({ + complete: () => { + completed = true; + }, + }); + + const req = httpMock.expectOne(uploadUrl); + expect(req.request.method).toBe("PUT"); + expect(req.request.headers.get("Content-Type")).toBe("application/pdf"); + expect(req.request.body).toBe(file); + req.flush(null); + + expect(completed).toBeTrue(); + }); + + it("should upload image file with correct content type", () => { + const file = new File(["image data"], "photo.jpg", { + type: "image/jpeg", + }); + + service.uploadToS3(uploadUrl, file).subscribe(); + + const req = httpMock.expectOne(uploadUrl); + expect(req.request.headers.get("Content-Type")).toBe("image/jpeg"); + req.flush(null); + }); + + it("should show error alert on upload failure", async () => { + const file = new File(["test content"], "test.pdf", { + type: "application/pdf", + }); + const promise = service.uploadToS3(uploadUrl, file).toPromise(); + + const req = httpMock.expectOne(uploadUrl); + req.flush(null, { status: 500, statusText: "Internal Server Error" }); + + await promise; + + expect(fakeNotifications.showAlert).toHaveBeenCalledWith( + jasmine.anything(), + jasmine.objectContaining({ + abstract: "File upload failed", + }), + jasmine.any(Array), + ); + }); + + it("should return EMPTY observable on error", (done) => { + const file = new File(["test content"], "test.pdf", { + type: "application/pdf", + }); + let emitted = false; + + service.uploadToS3(uploadUrl, file).subscribe({ + next: () => { + emitted = true; + }, + complete: () => { + expect(emitted).toBeFalse(); + done(); + }, + }); + + const req = httpMock.expectOne(uploadUrl); + req.flush(null, { status: 500, statusText: "Internal Server Error" }); + }); + }); +}); From c08f88c511063fd92616d9824bdc2157bd99662d Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Wed, 24 Dec 2025 14:42:57 +0000 Subject: [PATCH 5/5] missing dependency --- backend/package.json | 1 + yarn.lock | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/backend/package.json b/backend/package.json index e883f1ccb..a4ec905ed 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,6 +28,7 @@ "@aws-sdk/client-bedrock-runtime": "^3.954.0", "@aws-sdk/client-s3": "^3.958.0", "@aws-sdk/lib-dynamodb": "^3.953.0", + "@aws-sdk/s3-request-presigner": "^3.958.0", "@electric-sql/pglite": "^0.3.14", "@faker-js/faker": "^10.1.0", "@nestjs/common": "11.1.9", diff --git a/yarn.lock b/yarn.lock index 5bb88d93e..9dc86f22f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -963,6 +963,22 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/s3-request-presigner@npm:^3.958.0": + version: 3.958.0 + resolution: "@aws-sdk/s3-request-presigner@npm:3.958.0" + dependencies: + "@aws-sdk/signature-v4-multi-region": 3.957.0 + "@aws-sdk/types": 3.957.0 + "@aws-sdk/util-format-url": 3.957.0 + "@smithy/middleware-endpoint": ^4.4.1 + "@smithy/protocol-http": ^5.3.7 + "@smithy/smithy-client": ^4.10.2 + "@smithy/types": ^4.11.0 + tslib: ^2.6.2 + checksum: 9b6d504dfee3382cf06568aa02d9d8b21dd8ec1c01c62e544f33f05d11277fdddcf81383a72cb96cf25291f4ca591230b43d0e49b51223ac695d126be47485e6 + languageName: node + linkType: hard + "@aws-sdk/signature-v4-multi-region@npm:3.957.0": version: 3.957.0 resolution: "@aws-sdk/signature-v4-multi-region@npm:3.957.0" @@ -5275,6 +5291,7 @@ __metadata: "@aws-sdk/client-bedrock-runtime": ^3.954.0 "@aws-sdk/client-s3": ^3.958.0 "@aws-sdk/lib-dynamodb": ^3.953.0 + "@aws-sdk/s3-request-presigner": ^3.958.0 "@electric-sql/pglite": ^0.3.14 "@faker-js/faker": ^10.1.0 "@nestjs/cli": ^11.0.14