diff --git a/.pnp.cjs b/.pnp.cjs index a6dd053f..db4307d0 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -190,7 +190,7 @@ const RAW_RUNTIME_STATE = ["@atls/nestjs-pipes", ["workspace:packages/nestjs-pipes"]],\ ["@atls/nestjs-proto-types", ["workspace:packages/nestjs-proto-types"]],\ ["@atls/nestjs-redis", ["virtual:aa86a8b1e4af9c7ed2d40753369461968e36be23b2875d3c89dbac92602aff336fbb024f39404040b429227b7fc2a1f6e029467aba212068d84f7a25c2e5afcc#workspace:packages/nestjs-redis", "workspace:packages/nestjs-redis"]],\ - ["@atls/nestjs-s3-client", ["workspace:packages/nestjs-s3-client"]],\ + ["@atls/nestjs-s3-client", ["virtual:d0811ed27bbe998f2a12c5e875d25702d790db932248e9cd4c22d3771f27ac58970cb92e039afec78356382490581bcdbffd2df919a17c38bd0c4a3bbf7bb1ea#workspace:packages/nestjs-s3-client", "workspace:packages/nestjs-s3-client"]],\ ["@atls/nestjs-signed-url", ["workspace:packages/nestjs-signed-url"]],\ ["@atls/nestjs-typeorm-seeding", ["workspace:packages/nestjs-typeorm-seeding"]],\ ["@atls/nestjs-typesense", ["virtual:084046633b9e7ae3480afb238e14edfec9426b631126ce31d204d664b2eaf2dd2c2c5ef9a4d5ee9cdc10a0b796d57bd90ef5416ebdadd91ed67409da21b59a10#workspace:packages/nestjs-typesense", "workspace:packages/nestjs-typesense"]],\ @@ -1604,6 +1604,35 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["@atls/nestjs-s3-client", [\ + ["virtual:d0811ed27bbe998f2a12c5e875d25702d790db932248e9cd4c22d3771f27ac58970cb92e039afec78356382490581bcdbffd2df919a17c38bd0c4a3bbf7bb1ea#workspace:packages/nestjs-s3-client", {\ + "packageLocation": "./.yarn/__virtual__/@atls-nestjs-s3-client-virtual-81ccc33a78/1/packages/nestjs-s3-client/",\ + "packageDependencies": [\ + ["@atls/nestjs-s3-client", "virtual:d0811ed27bbe998f2a12c5e875d25702d790db932248e9cd4c22d3771f27ac58970cb92e039afec78356382490581bcdbffd2df919a17c38bd0c4a3bbf7bb1ea#workspace:packages/nestjs-s3-client"],\ + ["@aws-sdk/client-s3", "npm:3.651.1"],\ + ["@aws-sdk/credential-providers", "npm:3.651.1"],\ + ["@aws-sdk/s3-request-presigner", "npm:3.651.1"],\ + ["@aws-sdk/types", "npm:3.649.0"],\ + ["@nestjs/common", "virtual:d0811ed27bbe998f2a12c5e875d25702d790db932248e9cd4c22d3771f27ac58970cb92e039afec78356382490581bcdbffd2df919a17c38bd0c4a3bbf7bb1ea#npm:10.4.22"],\ + ["@nestjs/core", "virtual:d0811ed27bbe998f2a12c5e875d25702d790db932248e9cd4c22d3771f27ac58970cb92e039afec78356382490581bcdbffd2df919a17c38bd0c4a3bbf7bb1ea#npm:10.4.22"],\ + ["@types/nestjs__common", null],\ + ["@types/nestjs__core", null],\ + ["@types/reflect-metadata", null],\ + ["@types/rxjs", null],\ + ["reflect-metadata", "npm:0.2.2"],\ + ["rxjs", "npm:7.8.2"]\ + ],\ + "packagePeers": [\ + "@nestjs/common",\ + "@nestjs/core",\ + "@types/nestjs__common",\ + "@types/nestjs__core",\ + "@types/reflect-metadata",\ + "@types/rxjs",\ + "reflect-metadata",\ + "rxjs"\ + ],\ + "linkType": "SOFT"\ + }],\ ["workspace:packages/nestjs-s3-client", {\ "packageLocation": "./packages/nestjs-s3-client/",\ "packageDependencies": [\ @@ -1612,9 +1641,9 @@ const RAW_RUNTIME_STATE = ["@aws-sdk/credential-providers", "npm:3.651.1"],\ ["@aws-sdk/s3-request-presigner", "npm:3.651.1"],\ ["@aws-sdk/types", "npm:3.649.0"],\ - ["@nestjs/common", "virtual:a83c01fc4cba5ca49a56474f02d0e2a42b67fd84218e0ed8446c312a68b5dcda210438f3221534d1b20254ee4cad2d7a7704d91b0774cb67fd2dcafbea6e550e#npm:10.4.3"],\ - ["@nestjs/core", "virtual:a83c01fc4cba5ca49a56474f02d0e2a42b67fd84218e0ed8446c312a68b5dcda210438f3221534d1b20254ee4cad2d7a7704d91b0774cb67fd2dcafbea6e550e#npm:10.4.3"],\ - ["reflect-metadata", "npm:0.1.14"],\ + ["@nestjs/common", "virtual:6c9dc9bccc9e9b135b3d207eb5492c0f611804f454a654b42d7f16642b9765843f2a78588b253925b98b198c823be1ececbcab5059503f1d6d4d7effc5de65b3#npm:10.4.3"],\ + ["@nestjs/core", "virtual:6c9dc9bccc9e9b135b3d207eb5492c0f611804f454a654b42d7f16642b9765843f2a78588b253925b98b198c823be1ececbcab5059503f1d6d4d7effc5de65b3#npm:10.4.3"],\ + ["reflect-metadata", "npm:0.2.2"],\ ["rxjs", "npm:7.8.1"]\ ],\ "linkType": "SOFT"\ @@ -1625,6 +1654,7 @@ const RAW_RUNTIME_STATE = "packageLocation": "./packages/nestjs-signed-url/",\ "packageDependencies": [\ ["@atls/nestjs-gcs-client", "virtual:d0811ed27bbe998f2a12c5e875d25702d790db932248e9cd4c22d3771f27ac58970cb92e039afec78356382490581bcdbffd2df919a17c38bd0c4a3bbf7bb1ea#workspace:packages/nestjs-gcs-client"],\ + ["@atls/nestjs-s3-client", "virtual:d0811ed27bbe998f2a12c5e875d25702d790db932248e9cd4c22d3771f27ac58970cb92e039afec78356382490581bcdbffd2df919a17c38bd0c4a3bbf7bb1ea#workspace:packages/nestjs-s3-client"],\ ["@atls/nestjs-signed-url", "workspace:packages/nestjs-signed-url"],\ ["@google-cloud/storage", "npm:7.14.0"],\ ["@jest/globals", "npm:29.7.0"],\ @@ -7858,6 +7888,34 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "HARD"\ }],\ + ["virtual:6c9dc9bccc9e9b135b3d207eb5492c0f611804f454a654b42d7f16642b9765843f2a78588b253925b98b198c823be1ececbcab5059503f1d6d4d7effc5de65b3#npm:10.4.3", {\ + "packageLocation": "./.yarn/__virtual__/@nestjs-common-virtual-ca700bdfd8/2/.yarn/berry/cache/@nestjs-common-npm-10.4.3-c8baed1848-10c0.zip/node_modules/@nestjs/common/",\ + "packageDependencies": [\ + ["@nestjs/common", "virtual:6c9dc9bccc9e9b135b3d207eb5492c0f611804f454a654b42d7f16642b9765843f2a78588b253925b98b198c823be1ececbcab5059503f1d6d4d7effc5de65b3#npm:10.4.3"],\ + ["@types/class-transformer", null],\ + ["@types/class-validator", null],\ + ["@types/reflect-metadata", null],\ + ["@types/rxjs", null],\ + ["class-transformer", null],\ + ["class-validator", null],\ + ["iterare", "npm:1.2.1"],\ + ["reflect-metadata", "npm:0.2.2"],\ + ["rxjs", "npm:7.8.1"],\ + ["tslib", "npm:2.7.0"],\ + ["uid", "npm:2.0.2"]\ + ],\ + "packagePeers": [\ + "@types/class-transformer",\ + "@types/class-validator",\ + "@types/reflect-metadata",\ + "@types/rxjs",\ + "class-transformer",\ + "class-validator",\ + "reflect-metadata",\ + "rxjs"\ + ],\ + "linkType": "HARD"\ + }],\ ["virtual:773e124e6d7d5c23e2706ffde057b7c1d13d30452fb17121aed2189489091a233a955fdd9b958f8fa713a6cdbe245c696fcd7e05eb8d3e4a938f0a00ad757325#npm:10.4.15", {\ "packageLocation": "./.yarn/__virtual__/@nestjs-common-virtual-bc8d5973ad/2/.yarn/berry/cache/@nestjs-common-npm-10.4.15-6477955573-10c0.zip/node_modules/@nestjs/common/",\ "packageDependencies": [\ @@ -8415,6 +8473,45 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "HARD"\ }],\ + ["virtual:6c9dc9bccc9e9b135b3d207eb5492c0f611804f454a654b42d7f16642b9765843f2a78588b253925b98b198c823be1ececbcab5059503f1d6d4d7effc5de65b3#npm:10.4.3", {\ + "packageLocation": "./.yarn/__virtual__/@nestjs-core-virtual-00b0bdd729/2/.yarn/berry/cache/@nestjs-core-npm-10.4.3-60d70056b1-10c0.zip/node_modules/@nestjs/core/",\ + "packageDependencies": [\ + ["@nestjs/common", "virtual:6c9dc9bccc9e9b135b3d207eb5492c0f611804f454a654b42d7f16642b9765843f2a78588b253925b98b198c823be1ececbcab5059503f1d6d4d7effc5de65b3#npm:10.4.3"],\ + ["@nestjs/core", "virtual:6c9dc9bccc9e9b135b3d207eb5492c0f611804f454a654b42d7f16642b9765843f2a78588b253925b98b198c823be1ececbcab5059503f1d6d4d7effc5de65b3#npm:10.4.3"],\ + ["@nestjs/microservices", null],\ + ["@nestjs/platform-express", null],\ + ["@nestjs/websockets", null],\ + ["@nuxtjs/opencollective", "npm:0.3.2"],\ + ["@types/nestjs__common", null],\ + ["@types/nestjs__microservices", null],\ + ["@types/nestjs__platform-express", null],\ + ["@types/nestjs__websockets", null],\ + ["@types/reflect-metadata", null],\ + ["@types/rxjs", null],\ + ["fast-safe-stringify", "npm:2.1.1"],\ + ["iterare", "npm:1.2.1"],\ + ["path-to-regexp", "npm:3.3.0"],\ + ["reflect-metadata", "npm:0.2.2"],\ + ["rxjs", "npm:7.8.1"],\ + ["tslib", "npm:2.7.0"],\ + ["uid", "npm:2.0.2"]\ + ],\ + "packagePeers": [\ + "@nestjs/common",\ + "@nestjs/microservices",\ + "@nestjs/platform-express",\ + "@nestjs/websockets",\ + "@types/nestjs__common",\ + "@types/nestjs__microservices",\ + "@types/nestjs__platform-express",\ + "@types/nestjs__websockets",\ + "@types/reflect-metadata",\ + "@types/rxjs",\ + "reflect-metadata",\ + "rxjs"\ + ],\ + "linkType": "HARD"\ + }],\ ["virtual:773e124e6d7d5c23e2706ffde057b7c1d13d30452fb17121aed2189489091a233a955fdd9b958f8fa713a6cdbe245c696fcd7e05eb8d3e4a938f0a00ad757325#npm:10.4.15", {\ "packageLocation": "./.yarn/__virtual__/@nestjs-core-virtual-ebe8886e16/2/.yarn/berry/cache/@nestjs-core-npm-10.4.15-47c96ddffa-10c0.zip/node_modules/@nestjs/core/",\ "packageDependencies": [\ diff --git a/packages/nestjs-s3-client/package.json b/packages/nestjs-s3-client/package.json index ce8e1f54..4380b3e0 100644 --- a/packages/nestjs-s3-client/package.json +++ b/packages/nestjs-s3-client/package.json @@ -25,13 +25,13 @@ "devDependencies": { "@nestjs/common": "^10.0.5", "@nestjs/core": "^10.0.5", - "reflect-metadata": "^0.1.13", + "reflect-metadata": "0.2.2", "rxjs": "^7.8.1" }, "peerDependencies": { "@nestjs/common": "^10", "@nestjs/core": "^10", - "reflect-metadata": "^0.1", + "reflect-metadata": "^0.2", "rxjs": "^7" }, "publishConfig": { diff --git a/packages/nestjs-signed-url/package.json b/packages/nestjs-signed-url/package.json index b6564959..46882725 100644 --- a/packages/nestjs-signed-url/package.json +++ b/packages/nestjs-signed-url/package.json @@ -17,6 +17,9 @@ "prepack": "yarn run build", "postpack": "rm -rf dist" }, + "dependencies": { + "@atls/nestjs-s3-client": "workspace:*" + }, "devDependencies": { "@atls/nestjs-gcs-client": "workspace:*", "@google-cloud/storage": "7.14.0", diff --git a/packages/nestjs-signed-url/s3/tests/fixtures/client.fixture.ts b/packages/nestjs-signed-url/s3/tests/fixtures/client.fixture.ts new file mode 100644 index 00000000..d9961553 --- /dev/null +++ b/packages/nestjs-signed-url/s3/tests/fixtures/client.fixture.ts @@ -0,0 +1,29 @@ +import type { S3Client } from '@atls/nestjs-s3-client' + +import type { FakeS3Presigner } from './client.interfaces.js' + +import { S3Client as AwsS3Client } from '@atls/nestjs-s3-client' + +export const createFakeS3Client = (): S3Client => + new AwsS3Client({ + credentials: { + accessKeyId: 'access-key', + secretAccessKey: 'secret-key', + }, + endpoint: 'https://s3.example.com', + region: 'auto', + }) + +export const createFakeS3Presigner = (signedUrl = 'signed-url'): FakeS3Presigner => { + const presigner: FakeS3Presigner = { + async sign(client, command, options): Promise { + presigner.client = client + presigner.command = command + presigner.options = options + + return signedUrl + }, + } + + return presigner +} diff --git a/packages/nestjs-signed-url/s3/tests/fixtures/client.interfaces.ts b/packages/nestjs-signed-url/s3/tests/fixtures/client.interfaces.ts new file mode 100644 index 00000000..66ac0b94 --- /dev/null +++ b/packages/nestjs-signed-url/s3/tests/fixtures/client.interfaces.ts @@ -0,0 +1,15 @@ +import type { GetObjectCommand } from '@atls/nestjs-s3-client' +import type { PutObjectCommand } from '@atls/nestjs-s3-client' +import type { S3Client } from '@atls/nestjs-s3-client' + +import type { S3SignedUrlPresigner } from '../../../src/s3/interfaces.js' +import type { S3SignedUrlPresignerOptions } from '../../../src/s3/interfaces.js' + +export type FakeS3Command = GetObjectCommand | PutObjectCommand + +export interface FakeS3Presigner { + client?: S3Client + command?: FakeS3Command + options?: S3SignedUrlPresignerOptions + sign: S3SignedUrlPresigner +} diff --git a/packages/nestjs-signed-url/s3/tests/fixtures/module.fixture.ts b/packages/nestjs-signed-url/s3/tests/fixtures/module.fixture.ts new file mode 100644 index 00000000..d2d1bd62 --- /dev/null +++ b/packages/nestjs-signed-url/s3/tests/fixtures/module.fixture.ts @@ -0,0 +1,25 @@ +import type { S3Client } from '@atls/nestjs-s3-client' +import type { DynamicModule } from '@nestjs/common' + +import type { TestingS3ClientFactory } from './module.interfaces.js' + +class TestingS3ClientModule {} + +export const TESTING_S3_CLIENT_FACTORY = Symbol('testing-s3-client-factory') + +export const createTestingS3ClientModule = (client: S3Client): DynamicModule => { + const clientFactory: TestingS3ClientFactory = { + create: (): S3Client => client, + } + + return { + module: TestingS3ClientModule, + providers: [ + { + provide: TESTING_S3_CLIENT_FACTORY, + useValue: clientFactory, + }, + ], + exports: [TESTING_S3_CLIENT_FACTORY], + } +} diff --git a/packages/nestjs-signed-url/s3/tests/fixtures/module.interfaces.ts b/packages/nestjs-signed-url/s3/tests/fixtures/module.interfaces.ts new file mode 100644 index 00000000..7019a1d9 --- /dev/null +++ b/packages/nestjs-signed-url/s3/tests/fixtures/module.interfaces.ts @@ -0,0 +1,5 @@ +import type { S3Client } from '@atls/nestjs-s3-client' + +export interface TestingS3ClientFactory { + create: () => S3Client +} diff --git a/packages/nestjs-signed-url/s3/tests/gateway.test.ts b/packages/nestjs-signed-url/s3/tests/gateway.test.ts new file mode 100644 index 00000000..7e1baab9 --- /dev/null +++ b/packages/nestjs-signed-url/s3/tests/gateway.test.ts @@ -0,0 +1,141 @@ +import type { FakeS3Presigner } from './fixtures/client.interfaces.js' + +import assert from 'node:assert/strict' +import { beforeEach } from 'node:test' +import { describe } from 'node:test' +import { it } from 'node:test' +import { mock } from 'node:test' + +import { GetObjectCommand } from '@atls/nestjs-s3-client' +import { PutObjectCommand } from '@atls/nestjs-s3-client' + +import { S3SignedUrlGateway } from '../../src/s3/gateway.js' +import { createFakeS3Client } from './fixtures/client.fixture.js' +import { createFakeS3Presigner } from './fixtures/client.fixture.js' + +describe('S3SignedUrlGateway', () => { + let gateway: S3SignedUrlGateway + let client: ReturnType + let presigner: FakeS3Presigner + + beforeEach(() => { + client = createFakeS3Client() + presigner = createFakeS3Presigner() + gateway = new S3SignedUrlGateway(client, presigner.sign) + }) + + describe('generateWriteUrl', () => { + it('maps S3 write options to PutObjectCommand and getSignedUrl', async () => { + const dateNowMock = mock.method(Date, 'now', () => 1000) + + try { + const value = await gateway.generateWriteUrl('bucket', 'file.png', { + contentType: 'image/png', + expiresAt: 61000, + responseDisposition: 'inline', + s3: { + command: { + CacheControl: 'public, max-age=60', + Metadata: { + source: 'gateway-test', + }, + }, + presign: { + unhoistableHeaders: new Set(['x-amz-meta-source']), + }, + }, + }) + + assert.deepEqual(value, { + url: 'signed-url', + fields: [], + }) + assert.equal(presigner.client, client) + assert.ok(presigner.command instanceof PutObjectCommand) + assert.deepEqual(presigner.command.input, { + CacheControl: 'public, max-age=60', + Metadata: { + source: 'gateway-test', + }, + Bucket: 'bucket', + Key: 'file.png', + ContentType: 'image/png', + ContentDisposition: 'inline', + }) + assert.deepEqual(presigner.options, { + expiresIn: 60, + signableHeaders: new Set(['content-type']), + unhoistableHeaders: new Set(['x-amz-meta-source']), + }) + } finally { + dateNowMock.mock.restore() + } + }) + + it('signs write content type without requiring duplicated headers', async () => { + await gateway.generateWriteUrl('bucket', 'file.png', { + contentType: 'image/png', + }) + + assert.ok(presigner.command instanceof PutObjectCommand) + assert.deepEqual(presigner.command.input, { + Bucket: 'bucket', + Key: 'file.png', + ContentType: 'image/png', + }) + assert.deepEqual(presigner.options, { + expiresIn: 900, + signableHeaders: new Set(['content-type']), + }) + }) + }) + + describe('generateReadUrl', () => { + it('maps provider-neutral read options to GetObjectCommand and getSignedUrl', async () => { + const value = await gateway.generateReadUrl('bucket', 'file.png', { + expiresInSeconds: 30, + responseDisposition: 'attachment; filename="file.png"', + s3: { + command: { + Range: 'bytes=0-1023', + VersionId: 'version-id', + }, + presign: { + unsignableHeaders: new Set(['x-amz-checksum-mode']), + }, + }, + }) + + assert.deepEqual(value, { + url: 'signed-url', + fields: [], + }) + assert.equal(presigner.client, client) + assert.ok(presigner.command instanceof GetObjectCommand) + assert.deepEqual(presigner.command.input, { + Range: 'bytes=0-1023', + VersionId: 'version-id', + Bucket: 'bucket', + Key: 'file.png', + ResponseContentDisposition: 'attachment; filename="file.png"', + }) + assert.deepEqual(presigner.options, { + expiresIn: 30, + unsignableHeaders: new Set(['x-amz-checksum-mode']), + }) + }) + + it('uses the package default expiration for read urls', async () => { + await gateway.generateReadUrl('bucket', 'file.png') + + assert.ok(presigner.command instanceof GetObjectCommand) + assert.deepEqual(presigner.command.input, { + Bucket: 'bucket', + Key: 'file.png', + }) + assert.deepEqual(presigner.options, { + expiresIn: 900, + }) + }) + }) +}) diff --git a/packages/nestjs-signed-url/s3/tests/module.test.ts b/packages/nestjs-signed-url/s3/tests/module.test.ts new file mode 100644 index 00000000..c75b30a0 --- /dev/null +++ b/packages/nestjs-signed-url/s3/tests/module.test.ts @@ -0,0 +1,188 @@ +import type { S3Client } from '@atls/nestjs-s3-client' +import type { TestingModule } from '@nestjs/testing' + +import type { SignedUrlGateway } from '../../src/interfaces.js' +import type { TestingS3ClientFactory } from './fixtures/module.interfaces.js' + +import assert from 'node:assert/strict' +import { afterEach } from 'node:test' +import { describe } from 'node:test' +import { it } from 'node:test' + +import { Inject } from '@nestjs/common' +import { Test } from '@nestjs/testing' + +import { S3ClientFactory } from '@atls/nestjs-s3-client' +import { S3ClientModule } from '@atls/nestjs-s3-client' + +import { SIGNED_URL_GATEWAY } from '../../src/constants.js' +import { SignedUrlModule } from '../../src/module/index.js' +import { S3_SIGNED_URL_CLIENT } from '../../src/s3/index.js' +import { S3_SIGNED_URL_PRESIGNER } from '../../src/s3/index.js' +import { S3SignedUrlSigner } from '../../src/s3/signer.js' +import { SignedUrlSigner } from '../../src/signer.js' +import { TESTING_S3_CLIENT_FACTORY } from './fixtures/module.fixture.js' +import { createFakeS3Client } from './fixtures/client.fixture.js' +import { createFakeS3Presigner } from './fixtures/client.fixture.js' +import { createTestingS3ClientModule } from './fixtures/module.fixture.js' + +class TestingS3ClientConsumer { + constructor(@Inject(S3_SIGNED_URL_CLIENT) readonly client: S3Client) {} +} + +describe('SignedUrlModule S3 APIs', () => { + let moduleRef: TestingModule | undefined + + afterEach(async () => { + await moduleRef?.close() + moduleRef = undefined + }) + + it('wires a value S3 client into the signed-url signer', async () => { + const client = createFakeS3Client() + const presigner = createFakeS3Presigner('module-signed-url') + + moduleRef = await Test.createTestingModule({ + imports: [SignedUrlModule.s3({ useValue: client })], + providers: [TestingS3ClientConsumer], + }) + .overrideProvider(S3_SIGNED_URL_PRESIGNER) + .useValue(presigner.sign) + .compile() + + const signer = moduleRef.get(SignedUrlSigner) + const s3Signer = moduleRef.get(S3SignedUrlSigner) + const gateway = moduleRef.get(SIGNED_URL_GATEWAY) + const consumer = moduleRef.get(TestingS3ClientConsumer) + + assert.equal(signer, s3Signer) + assert.equal(consumer.client, client) + + const value = await signer.generateWriteUrl('bucket', 'file.png', { + contentType: 'image/png', + expiresInSeconds: 60, + }) + + assert.deepEqual(value, { + url: 'module-signed-url', + fields: [], + }) + assert.deepEqual(presigner.command?.input, { + Bucket: 'bucket', + Key: 'file.png', + ContentType: 'image/png', + }) + assert.deepEqual(presigner.options, { + expiresIn: 60, + signableHeaders: new Set(['content-type']), + }) + + const readValue = await gateway.generateReadUrl('bucket', 'file.png') + + assert.deepEqual(readValue, { + url: 'module-signed-url', + fields: [], + }) + }) + + it('wires an injected S3 client factory into the signed-url signer', async () => { + const client = createFakeS3Client() + const presigner = createFakeS3Presigner('module-async-signed-url') + + moduleRef = await Test.createTestingModule({ + imports: [ + SignedUrlModule.s3Async<[TestingS3ClientFactory]>({ + imports: [createTestingS3ClientModule(client)], + inject: [TESTING_S3_CLIENT_FACTORY], + useFactory: (clientFactory: TestingS3ClientFactory): S3Client => clientFactory.create(), + }), + ], + providers: [TestingS3ClientConsumer], + }) + .overrideProvider(S3_SIGNED_URL_PRESIGNER) + .useValue(presigner.sign) + .compile() + + const signer = moduleRef.get(S3SignedUrlSigner) + const consumer = moduleRef.get(TestingS3ClientConsumer) + + assert.equal(consumer.client, client) + + const value = await signer.generateReadUrl('bucket', 'file.png', { + expiresInSeconds: 30, + s3: { + command: { + VersionId: 'version-id', + }, + }, + }) + + assert.deepEqual(value, { + url: 'module-async-signed-url', + fields: [], + }) + assert.deepEqual(presigner.command?.input, { + VersionId: 'version-id', + Bucket: 'bucket', + Key: 'file.png', + }) + }) + + it('wires the public s3-client module boundary into the signed-url signer', async () => { + const client = createFakeS3Client() + const presigner = createFakeS3Presigner('s3-client-boundary-signed-url') + const s3ClientFactory: Pick = { + create: (): S3Client => client, + } + + moduleRef = await Test.createTestingModule({ + imports: [ + SignedUrlModule.s3Async<[S3ClientFactory]>({ + imports: [S3ClientModule.register()], + inject: [S3ClientFactory], + useFactory: (factory: S3ClientFactory): S3Client => factory.create(), + }), + ], + providers: [TestingS3ClientConsumer], + }) + .overrideProvider(S3ClientFactory) + .useValue(s3ClientFactory) + .overrideProvider(S3_SIGNED_URL_PRESIGNER) + .useValue(presigner.sign) + .compile() + + const signer = moduleRef.get(S3SignedUrlSigner) + const consumer = moduleRef.get(TestingS3ClientConsumer) + + assert.equal(consumer.client, client) + + const value = await signer.generateWriteUrl('bucket', 'file.png', { + contentType: 'image/png', + expiresInSeconds: 60, + s3: { + command: { + Metadata: { + source: 's3-client-boundary', + }, + }, + }, + }) + + assert.deepEqual(value, { + url: 's3-client-boundary-signed-url', + fields: [], + }) + assert.deepEqual(presigner.command?.input, { + Metadata: { + source: 's3-client-boundary', + }, + Bucket: 'bucket', + Key: 'file.png', + ContentType: 'image/png', + }) + assert.deepEqual(presigner.options, { + expiresIn: 60, + signableHeaders: new Set(['content-type']), + }) + }) +}) diff --git a/packages/nestjs-signed-url/src/index.ts b/packages/nestjs-signed-url/src/index.ts index b9e0a872..4a8beb80 100644 --- a/packages/nestjs-signed-url/src/index.ts +++ b/packages/nestjs-signed-url/src/index.ts @@ -2,4 +2,5 @@ export * from './constants.js' export * from './gcs/index.js' export type * from './interfaces.js' export * from './module/index.js' +export * from './s3/index.js' export * from './signer.js' diff --git a/packages/nestjs-signed-url/src/module/interfaces.ts b/packages/nestjs-signed-url/src/module/interfaces.ts index 7f31a52b..e4cc930a 100644 --- a/packages/nestjs-signed-url/src/module/interfaces.ts +++ b/packages/nestjs-signed-url/src/module/interfaces.ts @@ -1,3 +1,4 @@ +import type { S3Client } from '@atls/nestjs-s3-client' import type { Storage } from '@google-cloud/storage' import type { InjectionToken } from '@nestjs/common/interfaces' import type { ModuleMetadata } from '@nestjs/common/interfaces' @@ -77,3 +78,22 @@ export interface GcsSignedUrlModuleFactoryOptions< export type GcsSignedUrlModuleAsyncOptions< FactoryArgs extends ReadonlyArray = ReadonlyArray, > = GcsSignedUrlModuleExistingOptions | GcsSignedUrlModuleFactoryOptions + +export interface S3SignedUrlModuleOptions { + useValue: S3Client +} + +export interface S3SignedUrlModuleExistingOptions extends Pick { + useExisting: InjectionToken +} + +export interface S3SignedUrlModuleFactoryOptions< + FactoryArgs extends ReadonlyArray = ReadonlyArray, +> extends Pick { + useFactory: (...args: FactoryArgs) => Promise | S3Client + inject?: Array +} + +export type S3SignedUrlModuleAsyncOptions< + FactoryArgs extends ReadonlyArray = ReadonlyArray, +> = S3SignedUrlModuleExistingOptions | S3SignedUrlModuleFactoryOptions diff --git a/packages/nestjs-signed-url/src/module/module.ts b/packages/nestjs-signed-url/src/module/module.ts index e9b2d97b..62a7c9ac 100644 --- a/packages/nestjs-signed-url/src/module/module.ts +++ b/packages/nestjs-signed-url/src/module/module.ts @@ -1,3 +1,4 @@ +import type { S3Client } from '@atls/nestjs-s3-client' import type { Storage } from '@google-cloud/storage' import type { DynamicModule } from '@nestjs/common' import type { Provider } from '@nestjs/common' @@ -6,15 +7,23 @@ import type { SignedUrlReadOptions } from '../interfaces.js' import type { SignedUrlWriteOptions } from '../interfaces.js' import type { GcsSignedUrlModuleAsyncOptions } from './interfaces.js' import type { GcsSignedUrlModuleOptions } from './interfaces.js' +import type { S3SignedUrlModuleAsyncOptions } from './interfaces.js' +import type { S3SignedUrlModuleOptions } from './interfaces.js' import type { SignedUrlModuleAsyncOptions } from './interfaces.js' import type { SignedUrlModuleOptions } from './interfaces.js' import { Module } from '@nestjs/common' +import { getSignedUrl } from '@atls/nestjs-s3-client' + import { SIGNED_URL_GATEWAY } from '../constants.js' import { GCS_SIGNED_URL_CLIENT } from '../gcs/constants.js' import { GcsSignedUrlGateway } from '../gcs/gateway.js' import { GcsSignedUrlSigner } from '../gcs/signer.js' +import { S3_SIGNED_URL_CLIENT } from '../s3/constants.js' +import { S3_SIGNED_URL_PRESIGNER } from '../s3/constants.js' +import { S3SignedUrlGateway } from '../s3/gateway.js' +import { S3SignedUrlSigner } from '../s3/signer.js' import { SignedUrlSigner } from '../signer.js' @Module({}) @@ -49,6 +58,21 @@ export class SignedUrlModule { return this.createGcsModule(this.createGcsClientProvider(options), options.imports || []) } + static s3(options: S3SignedUrlModuleOptions): DynamicModule { + const clientProvider: Provider = { + provide: S3_SIGNED_URL_CLIENT, + useValue: options.useValue, + } + + return this.createS3Module(clientProvider) + } + + static s3Async = ReadonlyArray>( + options: S3SignedUrlModuleAsyncOptions + ): DynamicModule { + return this.createS3Module(this.createS3ClientProvider(options), options.imports || []) + } + private static createSignedUrlModule< FactoryArgs extends ReadonlyArray = ReadonlyArray, ReadOptions extends SignedUrlReadOptions = SignedUrlReadOptions, @@ -159,4 +183,51 @@ export class SignedUrlModule { useExisting: options.useExisting, } } + + private static createS3Module( + clientProvider: Provider, + imports: DynamicModule['imports'] = [] + ): DynamicModule { + const signedUrlGateway: Provider = { + provide: SIGNED_URL_GATEWAY, + useExisting: S3SignedUrlGateway, + } + + return { + module: SignedUrlModule, + imports, + providers: [ + S3SignedUrlSigner, + { + provide: SignedUrlSigner, + useExisting: S3SignedUrlSigner, + }, + clientProvider, + { + provide: S3_SIGNED_URL_PRESIGNER, + useValue: getSignedUrl, + }, + S3SignedUrlGateway, + signedUrlGateway, + ], + exports: [SignedUrlSigner, S3SignedUrlSigner, SIGNED_URL_GATEWAY, S3_SIGNED_URL_CLIENT], + } + } + + private static createS3ClientProvider< + FactoryArgs extends ReadonlyArray = ReadonlyArray, + >(options: S3SignedUrlModuleAsyncOptions): Provider { + if ('useFactory' in options) { + return { + provide: S3_SIGNED_URL_CLIENT, + useFactory: options.useFactory, + inject: options.inject || [], + } + } + + return { + provide: S3_SIGNED_URL_CLIENT, + useExisting: options.useExisting, + } + } } diff --git a/packages/nestjs-signed-url/src/s3/constants.ts b/packages/nestjs-signed-url/src/s3/constants.ts new file mode 100644 index 00000000..f2467d08 --- /dev/null +++ b/packages/nestjs-signed-url/src/s3/constants.ts @@ -0,0 +1,2 @@ +export const S3_SIGNED_URL_CLIENT = Symbol('S3_SIGNED_URL_CLIENT') +export const S3_SIGNED_URL_PRESIGNER = Symbol('S3_SIGNED_URL_PRESIGNER') diff --git a/packages/nestjs-signed-url/src/s3/gateway.ts b/packages/nestjs-signed-url/src/s3/gateway.ts new file mode 100644 index 00000000..12f021f3 --- /dev/null +++ b/packages/nestjs-signed-url/src/s3/gateway.ts @@ -0,0 +1,147 @@ +import type { GetObjectCommandInput } from '@atls/nestjs-s3-client' +import type { PutObjectCommandInput } from '@atls/nestjs-s3-client' +import type { S3Client } from '@atls/nestjs-s3-client' + +import type { SignedUrlGateway } from '../interfaces.js' +import type { SignedUrlOptions } from '../interfaces.js' +import type { SignedUrl } from '../interfaces.js' +import type { S3SignedUrlPresignerOptions } from './interfaces.js' +import type { S3SignedUrlPresigner } from './interfaces.js' +import type { S3SignedUrlReadOptions } from './interfaces.js' +import type { S3SignedUrlWriteOptions } from './interfaces.js' + +import { Inject } from '@nestjs/common' +import { Injectable } from '@nestjs/common' + +import { GetObjectCommand } from '@atls/nestjs-s3-client' +import { PutObjectCommand } from '@atls/nestjs-s3-client' + +import { DEFAULT_SIGNED_URL_EXPIRES_IN_SECONDS } from '../constants.js' +import { S3_SIGNED_URL_CLIENT } from './constants.js' +import { S3_SIGNED_URL_PRESIGNER } from './constants.js' + +const MILLISECONDS_IN_SECOND = 1000 +const CONTENT_TYPE_HEADER = 'content-type' + +const resolveExpiresAt = (expiresAt: Date | number): number => { + if (expiresAt instanceof Date) { + return expiresAt.getTime() + } + + return expiresAt +} + +const resolveExpiresIn = (options: SignedUrlOptions): number => { + if (options.expiresAt !== undefined) { + return Math.ceil((resolveExpiresAt(options.expiresAt) - Date.now()) / MILLISECONDS_IN_SECOND) + } + + return options.expiresInSeconds ?? DEFAULT_SIGNED_URL_EXPIRES_IN_SECONDS +} + +const mergeSignableHeaders = ( + presignOptions: S3SignedUrlPresignerOptions, + headerNames: ReadonlyArray +): S3SignedUrlPresignerOptions => { + if (headerNames.length === 0) { + return presignOptions + } + + const signableHeaders = new Set(presignOptions.signableHeaders ?? []) + + for (const headerName of headerNames) { + signableHeaders.add(headerName.toLowerCase()) + } + + return { + ...presignOptions, + signableHeaders, + } +} + +const buildPresignOptions = ( + options: S3SignedUrlReadOptions | S3SignedUrlWriteOptions, + signableHeaders: ReadonlyArray = [] +): S3SignedUrlPresignerOptions => + mergeSignableHeaders( + { + ...options.s3?.presign, + expiresIn: resolveExpiresIn(options), + }, + signableHeaders + ) + +const buildWriteInput = ( + bucket: string, + filename: string, + options: S3SignedUrlWriteOptions +): PutObjectCommandInput => { + const input: PutObjectCommandInput = { + ...options.s3?.command, + Bucket: bucket, + Key: filename, + ContentType: options.contentType, + } + + if (options.responseDisposition !== undefined) { + input.ContentDisposition = options.responseDisposition + } + + return input +} + +const buildReadInput = ( + bucket: string, + filename: string, + options: S3SignedUrlReadOptions +): GetObjectCommandInput => { + const input: GetObjectCommandInput = { + ...options.s3?.command, + Bucket: bucket, + Key: filename, + } + + if (options.responseDisposition !== undefined) { + input.ResponseContentDisposition = options.responseDisposition + } + + return input +} + +@Injectable() +export class S3SignedUrlGateway + implements SignedUrlGateway +{ + constructor( + @Inject(S3_SIGNED_URL_CLIENT) + private readonly client: S3Client, + @Inject(S3_SIGNED_URL_PRESIGNER) + private readonly presigner: S3SignedUrlPresigner + ) {} + + async generateWriteUrl( + bucket: string, + filename: string, + options: S3SignedUrlWriteOptions + ): Promise { + const command = new PutObjectCommand(buildWriteInput(bucket, filename, options)) + const url = await this.presigner( + this.client, + command, + buildPresignOptions(options, [CONTENT_TYPE_HEADER]) + ) + + return { url, fields: [] } + } + + async generateReadUrl( + bucket: string, + filename: string, + options: S3SignedUrlReadOptions = {} + ): Promise { + const command = new GetObjectCommand(buildReadInput(bucket, filename, options)) + const url = await this.presigner(this.client, command, buildPresignOptions(options)) + + return { url, fields: [] } + } +} diff --git a/packages/nestjs-signed-url/src/s3/index.ts b/packages/nestjs-signed-url/src/s3/index.ts new file mode 100644 index 00000000..ed26ee37 --- /dev/null +++ b/packages/nestjs-signed-url/src/s3/index.ts @@ -0,0 +1,4 @@ +export * from './constants.js' +export * from './gateway.js' +export type * from './interfaces.js' +export * from './signer.js' diff --git a/packages/nestjs-signed-url/src/s3/interfaces.ts b/packages/nestjs-signed-url/src/s3/interfaces.ts new file mode 100644 index 00000000..22e32378 --- /dev/null +++ b/packages/nestjs-signed-url/src/s3/interfaces.ts @@ -0,0 +1,49 @@ +import type { GetObjectCommand } from '@atls/nestjs-s3-client' +import type { GetObjectCommandInput } from '@atls/nestjs-s3-client' +import type { PutObjectCommand } from '@atls/nestjs-s3-client' +import type { PutObjectCommandInput } from '@atls/nestjs-s3-client' +import type { S3Client } from '@atls/nestjs-s3-client' +import type { getSignedUrl } from '@atls/nestjs-s3-client' + +import type { SignedUrlReadOptions } from '../interfaces.js' +import type { SignedUrlSigning } from '../interfaces.js' +import type { SignedUrlWriteOptions } from '../interfaces.js' + +export type S3SignedUrlPresignerOptions = NonNullable[2]> + +export type S3SignedUrlPresignOptions = Omit + +type S3SignedUrlBaseReadOptions = Omit + +type S3SignedUrlBaseWriteOptions = Omit + +export type S3SignedUrlReadCommandInput = Partial< + Omit +> + +export type S3SignedUrlWriteCommandInput = Partial< + Omit +> + +export interface S3SignedUrlConfig { + command?: CommandInput + presign?: S3SignedUrlPresignOptions +} + +export interface S3SignedUrlReadOptions extends S3SignedUrlBaseReadOptions { + s3?: S3SignedUrlConfig +} + +export interface S3SignedUrlWriteOptions extends S3SignedUrlBaseWriteOptions { + s3?: S3SignedUrlConfig +} + +export type S3SignedUrlCommand = GetObjectCommand | PutObjectCommand + +export type S3SignedUrlPresigner = ( + client: S3Client, + command: S3SignedUrlCommand, + options?: S3SignedUrlPresignerOptions +) => Promise + +export type S3SignedUrlSigning = SignedUrlSigning diff --git a/packages/nestjs-signed-url/src/s3/signer.ts b/packages/nestjs-signed-url/src/s3/signer.ts new file mode 100644 index 00000000..4b5bf80d --- /dev/null +++ b/packages/nestjs-signed-url/src/s3/signer.ts @@ -0,0 +1,23 @@ +import type { SignedUrlGateway } from '../interfaces.js' +import type { S3SignedUrlReadOptions } from './interfaces.js' +import type { S3SignedUrlSigning } from './interfaces.js' +import type { S3SignedUrlWriteOptions } from './interfaces.js' + +import { Inject } from '@nestjs/common' +import { Injectable } from '@nestjs/common' + +import { SIGNED_URL_GATEWAY } from '../constants.js' +import { SignedUrlSigner } from '../signer.js' + +@Injectable() +export class S3SignedUrlSigner + extends SignedUrlSigner + implements S3SignedUrlSigning +{ + constructor( + @Inject(SIGNED_URL_GATEWAY) + gateway: SignedUrlGateway + ) { + super(gateway) + } +} diff --git a/packages/nestjs-signed-url/src/tests/options.test.ts b/packages/nestjs-signed-url/src/tests/options.test.ts index c4b19e9b..849e6118 100644 --- a/packages/nestjs-signed-url/src/tests/options.test.ts +++ b/packages/nestjs-signed-url/src/tests/options.test.ts @@ -1,11 +1,13 @@ -import type { GcsSignedUrlReadOptions } from '../gcs/index.js' -import type { GcsSignedUrlSigning } from '../gcs/index.js' -import type { SignedUrlOptions } from '../interfaces.js' -import type { SignedUrlWriteOptions } from '../interfaces.js' +import type { GcsSignedUrlReadOptions } from '../gcs/index.js' +import type { GcsSignedUrlSigning } from '../gcs/index.js' +import type { SignedUrlOptions } from '../interfaces.js' +import type { SignedUrlWriteOptions } from '../interfaces.js' +import type { S3SignedUrlModuleOptions } from '../module/index.js' +import type { S3SignedUrlSigning } from '../s3/index.js' -import assert from 'node:assert/strict' -import { describe } from 'node:test' -import { it } from 'node:test' +import assert from 'node:assert/strict' +import { describe } from 'node:test' +import { it } from 'node:test' describe('signed-url options', () => { it('exposes contentType without the legacy type option', () => { @@ -24,16 +26,61 @@ describe('signed-url options', () => { type HasGcsOptions = 'gcs' extends keyof GcsSignedUrlReadOptions ? true : false type GcsSignerReadOptions = NonNullable[2]> type GcsSignerHasGcsOptions = 'gcs' extends keyof GcsSignerReadOptions ? true : false + type S3SignerReadOptions = NonNullable[2]> + type S3SignerWriteOptions = Parameters[2] + type S3SignerReadS3Options = NonNullable + type S3SignerWriteS3Options = NonNullable + type S3SignerReadPresignOptions = NonNullable + type S3SignerWritePresignOptions = NonNullable + type S3SignerHasS3Options = 's3' extends keyof S3SignerReadOptions ? true : false + type S3SignerReadHasHeaders = 'headers' extends keyof S3SignerReadOptions ? true : false + type S3SignerWriteHasHeaders = 'headers' extends keyof S3SignerWriteOptions ? true : false + type S3SignerReadPresignHasExpiresIn = 'expiresIn' extends keyof S3SignerReadPresignOptions + ? true + : false + type S3SignerWritePresignHasExpiresIn = 'expiresIn' extends keyof S3SignerWritePresignOptions + ? true + : false type CommonHasGcsOptions = 'gcs' extends keyof SignedUrlOptions ? true : false + type CommonHasS3Options = 's3' extends keyof SignedUrlOptions ? true : false const hasProviderOptions: HasProviderOptions = false const hasGcsOptions: HasGcsOptions = true const gcsSignerHasGcsOptions: GcsSignerHasGcsOptions = true + const s3SignerHasS3Options: S3SignerHasS3Options = true + const s3SignerReadHasHeaders: S3SignerReadHasHeaders = false + const s3SignerWriteHasHeaders: S3SignerWriteHasHeaders = false + const s3SignerReadPresignHasExpiresIn: S3SignerReadPresignHasExpiresIn = false + const s3SignerWritePresignHasExpiresIn: S3SignerWritePresignHasExpiresIn = false const commonHasGcsOptions: CommonHasGcsOptions = false + const commonHasS3Options: CommonHasS3Options = false assert.equal(hasProviderOptions, false) assert.equal(hasGcsOptions, true) assert.equal(gcsSignerHasGcsOptions, true) + assert.equal(s3SignerHasS3Options, true) + assert.equal(s3SignerReadHasHeaders, false) + assert.equal(s3SignerWriteHasHeaders, false) + assert.equal(s3SignerReadPresignHasExpiresIn, false) + assert.equal(s3SignerWritePresignHasExpiresIn, false) assert.equal(commonHasGcsOptions, false) + assert.equal(commonHasS3Options, false) + }) + + it('keeps S3 client configuration out of the signed-url module options', () => { + type HasRegion = 'region' extends keyof S3SignedUrlModuleOptions ? true : false + type HasEndpoint = 'endpoint' extends keyof S3SignedUrlModuleOptions ? true : false + type HasCredentials = 'credentials' extends keyof S3SignedUrlModuleOptions ? true : false + type HasForcePathStyle = 'forcePathStyle' extends keyof S3SignedUrlModuleOptions ? true : false + + const hasRegion: HasRegion = false + const hasEndpoint: HasEndpoint = false + const hasCredentials: HasCredentials = false + const hasForcePathStyle: HasForcePathStyle = false + + assert.equal(hasRegion, false) + assert.equal(hasEndpoint, false) + assert.equal(hasCredentials, false) + assert.equal(hasForcePathStyle, false) }) }) diff --git a/yarn.lock b/yarn.lock index bc0a957f..3dac5295 100644 --- a/yarn.lock +++ b/yarn.lock @@ -954,7 +954,7 @@ __metadata: languageName: unknown linkType: soft -"@atls/nestjs-s3-client@workspace:packages/nestjs-s3-client": +"@atls/nestjs-s3-client@workspace:*, @atls/nestjs-s3-client@workspace:packages/nestjs-s3-client": version: 0.0.0-use.local resolution: "@atls/nestjs-s3-client@workspace:packages/nestjs-s3-client" dependencies: @@ -964,12 +964,12 @@ __metadata: "@aws-sdk/types": "npm:^3.357.0" "@nestjs/common": "npm:^10.0.5" "@nestjs/core": "npm:^10.0.5" - reflect-metadata: "npm:^0.1.13" + reflect-metadata: "npm:0.2.2" rxjs: "npm:^7.8.1" peerDependencies: "@nestjs/common": ^10 "@nestjs/core": ^10 - reflect-metadata: ^0.1 + reflect-metadata: ^0.2 rxjs: ^7 languageName: unknown linkType: soft @@ -979,6 +979,7 @@ __metadata: resolution: "@atls/nestjs-signed-url@workspace:packages/nestjs-signed-url" dependencies: "@atls/nestjs-gcs-client": "workspace:*" + "@atls/nestjs-s3-client": "workspace:*" "@google-cloud/storage": "npm:7.14.0" "@jest/globals": "npm:29.7.0" "@nestjs/common": "npm:10.4.22"